Mercurial > libervia-desktop-kivy
diff src/libs/garden/garden.contextmenu/context_menu.py @ 83:741a7d6d8c28
garden: added contextmenu
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 24 Dec 2016 14:16:58 +0100 |
parents | |
children | 2caee196d19a |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/libs/garden/garden.contextmenu/context_menu.py Sat Dec 24 14:16:58 2016 +0100 @@ -0,0 +1,287 @@ +from kivy.uix.gridlayout import GridLayout +from kivy.uix.relativelayout import RelativeLayout +from kivy.core.window import Window +from kivy.uix.behaviors import ButtonBehavior +from kivy.lang import Builder +from kivy.clock import Clock +from functools import partial +import traceback + +import kivy.properties as kp +import os + + +class AbstractMenu(object): + cancel_handler_widget = kp.ObjectProperty(None) + bounding_box_widget = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + self.clock_event = None + + def add_item(self, widget): + self.add_widget(widget) + + def add_text_item(self, text, on_release=None): + item = ContextMenuTextItem(text=text) + if on_release: + item.bind(on_release=on_release) + self.add_item(item) + + def get_height(self): + height = 0 + for widget in self.children: + height += widget.height + return height + + def hide_submenus(self): + for widget in self.menu_item_widgets: + widget.hovered = False + widget.hide_submenu() + + def self_or_submenu_collide_with_point(self, x, y): + raise NotImplementedError() + + def on_cancel_handler_widget(self, obj, widget): + self.cancel_handler_widget.bind(on_touch_down=self.hide_app_menus) + + def hide_app_menus(self, obj, pos): + raise NotImplementedError() + + @property + def menu_item_widgets(self): + """ + Return all children that are subclasses of ContextMenuItem + """ + return [w for w in self.children if issubclass(w.__class__, AbstractMenuItem)] + + def _setup_hover_timer(self): + if self.clock_event is None: + self.clock_event = Clock.schedule_interval(partial(self._check_mouse_hover), 0.05) + + def _check_mouse_hover(self, obj): + self.self_or_submenu_collide_with_point(*Window.mouse_pos) + + def _cancel_hover_timer(self): + if self.clock_event: + self.clock_event.cancel() + self.clock_event = None + + +class ContextMenu(GridLayout, AbstractMenu): + visible = kp.BooleanProperty(False) + spacer = kp.ObjectProperty(None) + + def __init__(self, *args, **kwargs): + super(ContextMenu, self).__init__(*args, **kwargs) + self.orig_parent = None + # self._on_visible(False) + + def hide(self): + self.visible = False + + def show(self, x=None, y=None): + self.visible = True + self._add_to_parent() + self.hide_submenus() + + root_parent = self.bounding_box_widget if self.bounding_box_widget is not None else self.get_context_menu_root_parent() + if root_parent is None: + return + + point_relative_to_root = root_parent.to_local(*self.to_window(x, y)) + + # Choose the best position to open the menu + if x is not None and y is not None: + if point_relative_to_root[0] + self.width < root_parent.width: + pox_x = x + else: + pox_x = x - self.width + if issubclass(self.parent.__class__, AbstractMenuItem): + pox_x -= self.parent.width + + if point_relative_to_root[1] - self.height < 0: + pos_y = y + if issubclass(self.parent.__class__, AbstractMenuItem): + pos_y -= self.parent.height + self.spacer.height + else: + pos_y = y - self.height + + parent_pos = root_parent.pos + pos = (pox_x + parent_pos[0], pos_y + parent_pos[1]) + + self.pos = pos + + def self_or_submenu_collide_with_point(self, x, y): + queue = self.menu_item_widgets + collide_widget = None + + # Iterate all siblings and all children + while len(queue) > 0: + widget = queue.pop(0) + submenu = widget.get_submenu() + if submenu is not None and widget.hovered: + queue += submenu.menu_item_widgets + + widget_pos = widget.to_window(0, 0) + if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: + widget.hovered = True + + collide_widget = widget + for sib in widget.siblings: + sib.hovered = False + elif submenu and submenu.visible: + widget.hovered = True + else: + widget.hovered = False + + return collide_widget + + def _on_visible(self, new_visibility): + if new_visibility: + self.size = self.get_max_width(), self.get_height() + self._add_to_parent() + # @todo: Do we need to remove self from self.parent.__context_menus? Probably not. + + elif self.parent and not new_visibility: + self.orig_parent = self.parent + + ''' + We create a set that holds references to all context menus in the parent widget. + It's necessary to keep at least one reference to this context menu. Otherwise when + removed from parent it might get de-allocated by GC. + ''' + if not hasattr(self.parent, '_ContextMenu__context_menus'): + self.parent.__context_menus = set() + self.parent.__context_menus.add(self) + + self.parent.remove_widget(self) + self.hide_submenus() + self._cancel_hover_timer() + + def _add_to_parent(self): + if not self.parent: + self.orig_parent.add_widget(self) + self.orig_parent = None + + # Create the timer on the outer most menu object + if self._get_root_context_menu() == self: + self._setup_hover_timer() + + def get_max_width(self): + max_width = 0 + for widget in self.menu_item_widgets: + width = widget.content_width if widget.content_width is not None else widget.width + if width is not None and width > max_width: + max_width = width + + return max_width + + def get_context_menu_root_parent(self): + """ + Return the bounding box widget for positioning sub menus. By default it's root context menu's parent. + """ + if self.bounding_box_widget is not None: + return self.bounding_box_widget + root_context_menu = self._get_root_context_menu() + return root_context_menu.bounding_box_widget if root_context_menu.bounding_box_widget else root_context_menu.parent + + def _get_root_context_menu(self): + """ + Return the outer most context menu object + """ + root = self + while issubclass(root.parent.__class__, ContextMenuItem) \ + or issubclass(root.parent.__class__, ContextMenu): + root = root.parent + return root + + def hide_app_menus(self, obj, pos): + return self.self_or_submenu_collide_with_point(pos.x, pos.y) is None and self.hide() + + +class AbstractMenuItem(object): + submenu = kp.ObjectProperty(None) + + def get_submenu(self): + return self.submenu if self.submenu != "" else None + + def show_submenu(self, x=None, y=None): + if self.get_submenu(): + self.get_submenu().show(*self._root_parent.to_local(x, y)) + + def hide_submenu(self): + submenu = self.get_submenu() + if submenu: + submenu.visible = False + submenu.hide_submenus() + + def _check_submenu(self): + if self.parent is not None and len(self.children) > 0: + submenus = [w for w in self.children if issubclass(w.__class__, ContextMenu)] + if len(submenus) > 1: + raise Exception('Menu item (ContextMenuItem) can have maximum one submenu (ContextMenu)') + elif len(submenus) == 1: + self.submenu = submenus[0] + + @property + def siblings(self): + return [w for w in self.parent.children if issubclass(w.__class__, AbstractMenuItem) and w != self] + + @property + def content_width(self): + return None + + @property + def _root_parent(self): + return self.parent.get_context_menu_root_parent() + + +class ContextMenuItem(RelativeLayout, AbstractMenuItem): + submenu_arrow = kp.ObjectProperty(None) + + def _check_submenu(self): + super(ContextMenuItem, self)._check_submenu() + if self.get_submenu() is None: + self.submenu_arrow.opacity = 0 + else: + self.submenu_arrow.opacity = 1 + + +class AbstractMenuItemHoverable(object): + hovered = kp.BooleanProperty(False) + + def _on_hovered(self, new_hovered): + if new_hovered: + spacer_height = self.parent.spacer.height if self.parent.spacer else 0 + point = self.right, self.top + spacer_height + self.show_submenu(self.width, self.height + spacer_height) + else: + self.hide_submenu() + + +class ContextMenuText(ContextMenuItem): + label = kp.ObjectProperty(None) + submenu_postfix = kp.StringProperty(' ...') + text = kp.StringProperty('') + font_size = kp.NumericProperty(14) + color = kp.ListProperty([1,1,1,1]) + + def __init__(self, *args, **kwargs): + super(ContextMenuText, self).__init__(*args, **kwargs) + + @property + def content_width(self): + # keep little space for eventual arrow for submenus + return self.label.texture_size[0] + 10 + + +class ContextMenuDivider(ContextMenuText): + pass + + +class ContextMenuTextItem(ButtonBehavior, ContextMenuText, AbstractMenuItemHoverable): + pass + + +_path = os.path.dirname(os.path.realpath(__file__)) +Builder.load_file(os.path.join(_path, 'context_menu.kv'))