view src/browser/sat_browser/base_menu.py @ 602:be3fd3874c3a frontends_multi_profiles

add very basic test system accessible from <root_url>/test, to be improved: - if you don't get any AssertError pop-up, everything is fine - TODO: nicely display the results in HTML output
author souliane <souliane@mailoo.org>
date Sat, 07 Feb 2015 19:24:38 +0100
parents a5019e62c3e9
children c2abadf31afb
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 sat.core import exceptions
from pyjamas.ui.MenuBar import MenuBar
from pyjamas.ui.UIObject import UIObject
from pyjamas.ui.MenuItem import MenuItem
from pyjamas import Window

import re


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

    def __init__(self, object_, handler=None, data=None):
        """
        @param object_ (object): a callable or a class instance
        @param handler (str): method name if object_ is a class instance
        @param data (dict): data to pass as the callback argument
        """
        if handler is None:
            assert(callable(object_))
            self.callback = object_
        else:
            self.callback = getattr(object_, handler)
        self.data = data

    def execute(self):
        log.debug("execute %s" % self.callback)
        self.callback(self.data) if self.data else self.callback()


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, sub_menu=None):
        """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
        @param sub_menu (GenericMenuBar): instance to popup as the category
        sub-menu, if it is created. Otherwise keep the previous sub-menu.
        @return: MenuNode or None
        """
        assert(len(path) > 0 and len(path) == len(path_i18n) == len(types))
        if 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, sub_menu) 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 = sub_menu if sub_menu else 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, asHTML=False):
        """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 callback (MenuCmd, PluginMenuCmd or GenericMenuBar): instance to
        execute as a leaf's callback or to popup as a category sub-menu
        @param asHTML (boolean): True to display the UI item as HTML
        """
        log.info("addMenuItem: %s %s %s %s" % (path, path_i18n, types, callback))

        leaf_node = hasattr(callback, "execute")
        category = isinstance(callback, GenericMenuBar)
        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:
            return self._getOrCreateCategory(path, path_i18n, types, True, callback)

        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], asHTML=asHTML, popup=callback)

    def addCategory(self, path, path_i18n, types, menu_bar=None):
        """Recursively add a new category.

        @param path (list[str], str): path to the category
        @param path_i18n (list[str], str): internationalized path to the category
        @param types (list[str], str): types of the category and its parents
        @param menu_bar (GenericMenuBar): instance to popup as the category sub-menu.
        """
        if menu_bar:
            assert(isinstance(menu_bar, GenericMenuBar))
        else:
            menu_bar = GenericMenuBar(self.menu.host, vertical=True)
        return self.addMenuItem(path, path_i18n, types, menu_bar)

    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
            if isinstance(action_id, str):
                callback = PluginMenuCmd(self.menu.host, action_id, menu_data)
            elif callable(action_id):
                callback = MenuCmd(action_id, data=menu_data)
            else:
                raise exceptions.InternalError
            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:
            self.setStyleName(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 setStyleName(self, style):
        # XXX: pyjamas set the style to object string representation!
        # FIXME: fix the bug upstream
        menu_style = ['gwt-MenuBar']
        menu_style.append(menu_style[0] + '-' + ('vertical' if self.vertical else 'horizontal'))
        for classname in style.split(' '):
            if classname not in menu_style:
                menu_style.append(classname)
        UIObject.setStyleName(self, ' '.join(menu_style))

    def addStyleName(self, style):
        # XXX: same kind of problem then with setStyleName
        # FIXME: fix the bug upstream
        if not re.search('(^| )%s( |$)' % style, self.getStyleName()):
            UIObject.setStyleName(self, self.getStyleName() + ' ' + style)

    def removeStyleName(self, style):
        # XXX: same kind of problem then with setStyleName
        # FIXME: fix the bug upstream
        style = re.sub('(^| )%s( |$)' % style, ' ', self.getStyleName()).strip()
        UIObject.setStyleName(self, style)

    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, asHTML=False):
        return self.node.addMenuItem(path, path_i18n, types, menu_cmd, asHTML).item

    def addCategory(self, path, path_i18n, types, menu_bar=None):
        return self.node.addCategory(path, path_i18n, types, menu_bar).item

    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)