view src/browser/sat_browser/base_menu.py @ 502:4aa627b059df

browser_side: categories of the menus can be "flattened": - add the parameter "flat_level" to GenericMenuBar - the items of flattened sub-menus are displayed in the parent menu XXX: the implementation covers the current needs but is not fully completed: - the flattened categories of all levels are displayed the same way - items of flattened categories are appended to the parent menus instead of being inserted
author souliane <souliane@mailoo.org>
date Wed, 13 Aug 2014 15:06:40 +0200
parents 60be99de3808
children 1d41cc5b57b1
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Libervia: a Salut à Toi frontend
# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson <goffi@goffi.org>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


"""Base classes for building a menu.

These classes have been moved here from menu.py because they are also used
by base_widget.py, and the import sequence caused a JS runtime error."""


import pyjd  # this is dummy in pyjs
from sat.core.log import getLogger
log = getLogger(__name__)

from pyjamas.ui.MenuBar import MenuBar
from pyjamas.ui.MenuItem import MenuItem
from pyjamas import Window


class MenuCmd:
    """Return an object with an "execute" method that can be set to a menu item callback"""

    def __init__(self, object_, handler):
        self._object = object_
        self._handler = handler

    def execute(self):
        handler = getattr(self._object, self._handler)
        handler()


class PluginMenuCmd:
    """Like MenuCmd, but instead of executing a method, it will command the bridge to launch an action"""

    def __init__(self, host, action_id, menu_data=None):
        self.host = host
        self.action_id = action_id
        self.menu_data = menu_data

    def execute(self):
        self.host.launchAction(self.action_id, self.menu_data)


class MenuNode(object):
    """MenuNode is a basic data structure to build a menu hierarchy.
    When Pyjamas MenuBar and MenuItem defines UI elements, MenuNode
    stores the logical relation between them."""

    """This class has been introduced to deal with "flattened menus", when you
    want the items of a sub-menu to be displayed in the parent menu. It was
    needed to break the naive relation of "one MenuBar = one category"."""

    def __init__(self, name=None, item=None, menu=None, flat_level=0):
        """
        @param name (str): node name
        @param item (MenuItem): associated menu item
        @param menu (GenericMenuBar): associated menu bar
        @param flat_level (int): sub-menus until that level see their items
        displayed in the parent menu bar, instead of in a callback popup.
        """
        self.name = name
        self.item = item or None  # associated menu item
        self.menu = menu or None  # associated menu bar (sub-menu)
        self.flat_level = max(flat_level, -1)
        self.children = []

    def _getOrCreateCategory(self, path, path_i18n=None, types=None, create=False):
        """Return the requested category. If create is True, path_i18n and
        types are specified, recursively create the category and its parent.

        @param path (list[str]): path to the category
        @param path_i18n (list[str]): internationalized path to the category
        @param types (list[str]): types of the category and its parents
        @param create (bool): if True, create the category
        @return: MenuNode or None
        """
        assert(len(path) > 0 and len(path) == len(path_i18n) == len(types))
        if isinstance(path, list) and len(path) > 1:
            cat = self._getOrCreateCategory(path[:1], path_i18n[:1], types[:1], create)
            return cat._getOrCreateCategory(path[1:], path_i18n[1:], types[1:], create) if cat else None
        cats = [child for child in self.children if child.menu and child.name == path[0]]
        if len(cats) == 1:
            return cats[0]
        assert(cats == [])  # there should not be more than 1 category with the same name
        if create:
            html = self.menu.getCategoryHTML(path_i18n[0], types[0])
            sub_menu = GenericMenuBar(self.menu.host, vertical=True)
            return self.addItem(html, True, sub_menu, name=path[0])
        return None

    def getCategories(self, target_path=None):
        """Return all the categories of the current node, or those of the
        sub-category which is specified by target_path.

        @param target_path (list[str]): path to the target node
        @return: list[MenuNode]
        """
        assert(self.menu)  # this method applies to category nodes
        if target_path:
            assert(isinstance(target_path, list))
            cat = self._getOrCreateCategory(target_path[:-1])
            return cat.getCategories(target_path[-1:]) if cat else None
        return [child for child in self.children if child.menu]

    def addMenuItem(self, path, path_i18n, types, callback=None):
        """Recursively add a new node, which could be a category or a leaf node.

        @param path (list[str], str): path to the item
        @param path_i18n (list[str], str): internationalized path to the item
        @param types (list[str], str): types of the item and its parents
        @param menu_cmd (MenuCmd, PluginMenuCmd or GenericMenuBar): instance to
        execute as a leaf's callback or to popup as a category's sub-menu.
        """
        log.info("addMenuItem: %s %s %s %s" % (path, path_i18n, types, callback))

        leaf_node = hasattr(callback, "execute")
        category = hasattr(callback, "onShow")
        assert(not leaf_node or not category)

        path = [path] if isinstance(path, str) else path
        path_i18n = [path_i18n] if isinstance(path_i18n, str) else path_i18n
        types = [types for dummy in range(len(path_i18n))] if isinstance(types, str) else types

        if category:
            cat = self._getOrCreateCategory(path, path_i18n, types, True)
            cat.item.setSubMenu(callback)
            return cat

        if len(path) == len(path_i18n) - 1:
            path.append(None)  # dummy name for a leaf node

        parent = self._getOrCreateCategory(path[:-1], path_i18n[:-1], types[:-1], True)
        return parent.addItem(path_i18n[-1], callback)

    def addItem(self, item, asHTML=None, popup=None, name=None):
        """Add a single child to the current node.

        @param item: see MenuBar.addItem
        @param asHTML: see MenuBar.addItem
        @param popup: see MenuBar.addItem
        @param name (str): the item node's name
        """
        if item is None:  # empty string is allowed to set a separator
            return None
        item = MenuBar.addItem(self.menu, item, asHTML, popup)
        node_menu = item.getSubMenu()  # node eventually uses it's own menu

        # XXX: all the dealing with flattened menus is done here
        if self.flat_level > 0:
            item.setSubMenu(None)  # eventually remove any sub-menu callback
            if item.getCommand():
                node_menu = None  # node isn't a category, it needs no menu
            else:
                node_menu = self.menu  # node uses the menu of its parent
                item.setStyleName(self.menu.styles["flattened-category"])

        node = MenuNode(name=name, item=item, menu=node_menu, flat_level=self.flat_level - 1)
        self.children.append(node)
        return node

    def addCachedMenus(self, type_, menu_data=None):
        """Add cached menus to instance.

        @param type_: menu type like in sat.core.sat_main.importMenu
        @param menu_data: data to send with these menus
        """
        menus = self.menu.host.menus.get(type_, [])
        for action_id, path, path_i18n in menus:
            if len(path) != len(path_i18n):
                log.error("inconsistency between menu paths")
                continue
            callback = PluginMenuCmd(self.menu.host, action_id, menu_data)
            self.addMenuItem(path, path_i18n, 'plugins', callback)


class GenericMenuBar(MenuBar):
    """A menu bar with sub-categories and items"""

    def __init__(self, host, vertical=False, styles=None, flat_level=0, **kwargs):
        """
        @param host (SatWebFrontend): host instance
        @param vertical (bool): True to display the popup menu vertically
        @param styles (dict): specific styles to be applied:
            - key: a value in ('moved_popup', 'menu_bar')
            - value: a CSS class name
        @param flat_level (int): sub-menus until that level see their items
        displayed in the parent menu bar instead of in a callback popup.
        """
        MenuBar.__init__(self, vertical, **kwargs)
        self.host = host
        self.styles = {'separator': 'menuSeparator', 'flattened-category': 'menuFlattenedCategory'}
        if styles:
            self.styles.update(styles)
        if 'menu_bar' in self.styles:
            # XXX: pyjamas set the style to object string representation!
            # FIXME: fix the bug upstream
            first = 'gwt-MenuBar'
            second = first + '-' + ('vertical' if self.vertical else 'horizontal')
            self.setStyleName(' '.join([first, second, self.styles['menu_bar']]))
        self.node = MenuNode(menu=self, flat_level=flat_level)

    @classmethod
    def getCategoryHTML(cls, menu_name_i18n, type_):
        """Build the html to be used for displaying a category item.

        Inheriting classes may overwrite this method.
        @param menu_name_i18n (str): internationalized category name
        @param type_ (str): category type
        @return: str
        """
        return menu_name_i18n

    def doItemAction(self, item, fireCommand):
        """Overwrites the default behavior for the popup menu to fit in the screen"""
        MenuBar.doItemAction(self, item, fireCommand)
        if not self.popup:
            return
        if self.vertical:
            # move the popup if it would go over the screen's viewport
            max_left = Window.getClientWidth() - self.getOffsetWidth() + 1 - self.popup.getOffsetWidth()
            new_left = self.getAbsoluteLeft() - self.popup.getOffsetWidth() + 1
            top = item.getAbsoluteTop()
        else:
            # move the popup if it would go over the menu bar right extremity
            max_left = self.getAbsoluteLeft() + self.getOffsetWidth() - self.popup.getOffsetWidth()
            new_left = max_left
            top = self.getAbsoluteTop() + self.getOffsetHeight() - 1
        if item.getAbsoluteLeft() > max_left:
            self.popup.setPopupPosition(new_left, top)
            # eventually smooth the popup edges to fit the menu own style
            if 'moved_popup' in self.styles:
                self.popup.addStyleName(self.styles['moved_popup'])

    def getCategories(self, parent_path=None):
        """Return all the categories items.

        @return: list[CategoryItem]
        """
        return [cat.item for cat in self.node.getCategories(parent_path)]

    def addMenuItem(self, path, path_i18n, types, menu_cmd):
        self.node.addMenuItem(path, path_i18n, types, menu_cmd)

    def addItem(self, item, asHTML=None, popup=None):
        return self.node.addItem(item, asHTML, popup).item

    def addCachedMenus(self, type_, menu_data=None):
        self.node.addCachedMenus(type_, menu_data)

    def addSeparator(self):
        """Add a separator between the categories"""
        item = MenuItem(text='', asHTML=None, StyleName=self.styles['separator'])
        return self.addItem(item)