changeset 83:741a7d6d8c28

garden: added contextmenu
author Goffi <goffi@goffi.org>
date Sat, 24 Dec 2016 14:16:58 +0100
parents 4c6d56c069d9
children 2caee196d19a
files src/libs/garden/garden.contextmenu/.gitignore src/libs/garden/garden.contextmenu/LICENSE src/libs/garden/garden.contextmenu/README.md src/libs/garden/garden.contextmenu/__init__.py src/libs/garden/garden.contextmenu/app_menu.kv src/libs/garden/garden.contextmenu/app_menu.py src/libs/garden/garden.contextmenu/context_menu.kv src/libs/garden/garden.contextmenu/context_menu.py src/libs/garden/garden.contextmenu/doc/app-menu-01.png src/libs/garden/garden.contextmenu/doc/context-menu-01.png src/libs/garden/garden.contextmenu/doc/menu-divider-01.png src/libs/garden/garden.contextmenu/doc/menu-divider-02.png src/libs/garden/garden.contextmenu/examples/simple_app_menu.py src/libs/garden/garden.contextmenu/examples/simple_context_menu.py
diffstat 14 files changed, 1027 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/.gitignore	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,58 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/LICENSE	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Kivy Garden
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/README.md	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,221 @@
+# garden.contextmenu
+
+Collection of classes for easy creating **context** and **application** menus.
+
+## Context Menu
+
+![Example of context menu](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/context-menu-01.png)
+
+Context menu is represented by `ContextMenu` widget that wraps all menu items as `ContextMenuTextItem` widgets. Context menus can be nested, each `ContextMenuTextItem` can contain maximum one `ContextMenu` widget.
+
+```python
+import kivy
+from kivy.app import App
+from kivy.lang import Builder
+import kivy.garden.contextmenu
+
+kv = """
+FloatLayout:
+    id: layout
+    Label:
+        pos: 10, self.parent.height - self.height - 10
+        text: "Left click anywhere outside the context menu to close it"
+        size_hint: None, None
+        size: self.texture_size
+
+    Button:
+        size_hint: None, None
+        pos_hint: {"center_x": 0.5, "center_y": 0.8 }
+        size: 300, 40
+        text: "Click me to show the context menu"
+        on_release: context_menu.show(*app.root_window.mouse_pos)
+
+    ContextMenu:
+        id: context_menu
+        visible: False
+        cancel_handler_widget: layout
+
+        ContextMenuTextItem:
+            text: "SubMenu #2"
+        ContextMenuTextItem:
+            text: "SubMenu #3"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "SubMenu #5"
+                ContextMenuTextItem:
+                    text: "SubMenu #6"
+                    ContextMenu:
+                        ContextMenuTextItem:
+                            text: "SubMenu #9"
+                        ContextMenuTextItem:
+                            text: "SubMenu #10"
+                        ContextMenuTextItem:
+                            text: "SubMenu #11"
+                        ContextMenuTextItem:
+                            text: "Hello, World!"
+                            on_release: app.say_hello(self.text)
+                        ContextMenuTextItem:
+                            text: "SubMenu #12"
+                ContextMenuTextItem:
+                    text: "SubMenu #7"
+        ContextMenuTextItem:
+            text: "SubMenu #4"
+"""
+
+class MyApp(App):
+    def build(self):
+        self.title = 'Simple context menu example'
+        return Builder.load_string(kv)
+
+    def say_hello(self, text):
+        print(text)
+        self.root.ids['context_menu'].hide()
+
+if __name__ == '__main__':
+    MyApp().run()
+```
+
+Arrows that symbolize that an item has sub menu is created automatically. `ContextMenuTextItem` inherits from [ButtonBehavior](http://kivy.org/docs/api-kivy.uix.behaviors.html#kivy.uix.behaviors.ButtonBehavior) so you can use `on_release` to bind actions to it.
+
+The root context menu can use `cancel_handler_widget` parameter. This adds `on_touch_down` event to it that closes the menu when you click anywhere outside the menu.
+
+
+## Application Menu
+
+![Example of application menu](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/app-menu-01.png)
+
+Creating application menus is very similar to context menus. Use `AppMenu` and `AppMenuTextItem` widgets to create the top level menu. Then each `AppMenuTextItem` can contain one `ContextMenu` widget as we saw above. `AppMenuTextItem` without `ContextMenu` are disabled by default
+
+```python
+import kivy
+from kivy.app import App
+from kivy.lang import Builder
+import kivy.garden.contextmenu
+
+kv = """
+FloatLayout:
+    id: layout
+    AppMenu:
+        id: app_menu
+        top: root.height
+        cancel_handler_widget: layout
+
+        AppMenuTextItem:
+            text: "Menu #1"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "Item #11"
+                ContextMenuTextItem:
+                    text: "Item #12"
+        AppMenuTextItem:
+            text: "Menu Menu Menu #2"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "Item #21"
+                ContextMenuTextItem:
+                    text: "Item #22"
+                ContextMenuTextItem:
+                    text: "ItemItemItem #23"
+                ContextMenuTextItem:
+                    text: "Item #24"
+                    ContextMenu:
+                        ContextMenuTextItem:
+                            text: "Item #241"
+                        ContextMenuTextItem:
+                            text: "Hello, World!"
+                            on_release: app.say_hello(self.text)
+                        # ...
+                ContextMenuTextItem:
+                    text: "Item #5"
+        AppMenuTextItem:
+            text: "Menu Menu #3"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "SubMenu #31"
+                ContextMenuDivider:
+                ContextMenuTextItem:
+                    text: "SubMenu #32"
+                # ...
+        AppMenuTextItem:
+            text: "Menu #4"
+    # ...
+    # The rest follows as usually
+"""
+
+class MyApp(App):
+    def build(self):
+        self.title = 'Simple app menu example'
+        return Builder.load_string(kv)
+
+    def say_hello(self, text):
+        print(text)
+        self.root.ids['app_menu'].close_all()
+
+if __name__ == '__main__':
+    MyApp().run()
+```
+
+## All classes
+
+`garden.contextmenu` provides you with a set of classes and mixins for creating your own customised menu items for both context and application menus.
+
+### context_menu.AbstractMenu
+
+Mixin class that represents basic functionality for all menus. It cannot be used by itself and needs to be extended with a layout. Provides `cancel_handler_widget` property. See [AppMenu](https://github.com/kivy-garden/garden.contextmenu/blob/master/app_menu.py) or [ContextMenu](https://github.com/kivy-garden/garden.contextmenu/blob/master/context_menu.py).
+
+### context_menu.ContextMenu
+
+Implementation of a context menu.
+
+### context_menu.AbstractMenuItem
+
+Mixin class that represents a single menu item. Needs to be extended to be any useful. It's a base class for all menu items for both context and application menus.
+
+If you want to extend this class you need to override the `content_width` property which tells the parent `ContextMenu` what is the expected width of this item. It needs to know this to set it's own width.
+
+### context_menu.ContextMenuItem
+
+Single context menu item. Automatically draws an arrow if contains a `ContextMenu` children. If you want to create a custom menu item extend this class.
+
+### context_menu.AbstractMenuItemHoverable
+
+Mixin class that makes any class that inherits `ContextMenuItem` to change background color on mouse hover.
+
+### context_menu.ContextMenuText
+
+Menu item with `Label` widget without any extra functionality.
+
+### context_menu.ContextMenuDivider
+
+Menu widget that splits two parts of a context/app menu.
+
+![Example of ContextMenuDivider without text](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/menu-divider-01.png)
+
+It also contains an instance of `Label` which is not visible if you don't set it any text.
+
+```python
+ContextMenuTextItem:
+    text: "SubMenu #33"
+ContextMenuDivider:
+    text: "More options"
+ContextMenuTextItem:
+    text: "SubMenu #34"
+```
+
+![Example of ContextMenuDivider with text](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/menu-divider-02.png)
+
+### context_menu.ContextMenuTextItem
+
+Menu item with text. You'll be most of the time just fine using this class for all your menu items. You can also see it used in [all examples here](https://github.com/kivy-garden/garden.contextmenu/tree/master/examples).  Contains a `Label` widget and copies `text`, `font_size` and `color` properties to it automatically.
+
+### app_menu.AppMenu
+
+Application menu widget. By default it fills the entire parent's width.
+
+### app_menu.AppMenuTextItem
+
+Application menu item width text. Contains a `Label` widget and copies `text`, `font_size` and `color` properties to it automatically.
+
+# License
+
+garden.contextmenu is licensed under MIT license.
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/__init__.py	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,11 @@
+from .context_menu import ContextMenu, \
+    AbstractMenu, \
+    AbstractMenuItem, \
+    AbstractMenuItemHoverable, \
+    ContextMenuItem, \
+    ContextMenuDivider, \
+    ContextMenuText, \
+    ContextMenuTextItem
+
+from .app_menu import AppMenu, \
+    AppMenuTextItem
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/app_menu.kv	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,24 @@
+<AppMenu>:
+    height: 30
+    size_hint: 1, None
+
+    canvas.before:
+        Color:
+            rgb: 0.2, 0.2, 0.2
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+
+<AppMenuTextItem>:
+    disabled: True
+    size_hint: None, None
+    on_children: self._check_submenu()
+    background_normal: ""
+    background_down: ""
+    background_color: (0.2, 0.71, 0.9, 1.0) if self.state == 'down' else (0.2, 0.2, 0.2, 1.0)
+    background_disabled_normal: ""
+    background_disabled_down: ""
+    border: (0, 0, 0, 0)
+    size: self.texture_size[0], 30
+    padding_x: 10
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/app_menu.py	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,117 @@
+from kivy.uix.relativelayout import RelativeLayout
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.behaviors import ToggleButtonBehavior
+from kivy.uix.togglebutton import ToggleButton
+from kivy.lang import Builder
+import kivy.properties as kp
+import os
+
+from .context_menu import AbstractMenu, AbstractMenuItem, AbstractMenuItemHoverable
+
+
+class AppMenu(StackLayout, AbstractMenu):
+    bounding_box = kp.ObjectProperty(None)
+
+    def __init__(self, *args, **kwargs):
+        super(AppMenu, self).__init__(*args, **kwargs)
+        self.hovered_menu_item = None
+
+    def update_height(self):
+        max_height = 0
+        for widget in self.menu_item_widgets:
+            if widget.height > max_height:
+                max_height = widget.height
+        return max_height
+
+    def on_children(self, obj, new_children):
+        for w in new_children:
+            # bind events that update app menu height when any of its children resize
+            w.bind(on_size=self.update_height)
+            w.bind(on_height=self.update_height)
+
+    def get_context_menu_root_parent(self):
+        return self
+
+    def self_or_submenu_collide_with_point(self, x, y):
+        collide_widget = None
+
+        # Iterate all siblings and all children
+        for widget in self.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:
+                if self.hovered_menu_item is None:
+                    self.hovered_menu_item = widget
+
+                if self.hovered_menu_item != widget:
+                    self.hovered_menu_item = widget
+                    for sibling in widget.siblings:
+                        sibling.state = 'normal'
+
+                    if widget.state == 'normal':
+                        widget.state = 'down'
+                        widget.on_release()
+
+                    for sib in widget.siblings:
+                        sib.hovered = False
+            elif widget.get_submenu() is not None and not widget.get_submenu().visible:
+                widget.state = 'normal'
+
+        return collide_widget
+
+    def close_all(self):
+        for submenu in [w.get_submenu() for w in self.menu_item_widgets if w.get_submenu() is not None]:
+            submenu.hide()
+        for w in self.menu_item_widgets:
+            w.state = 'normal'
+
+    def hide_app_menus(self, obj, pos):
+        if not self.collide_point(pos.x, pos.y):
+            for w in [w for w in self.menu_item_widgets if not w.disabled and w.get_submenu().visible]:
+                submenu = w.get_submenu()
+                if submenu.self_or_submenu_collide_with_point(pos.x, pos.y) is None:
+                    self.close_all()
+                    self._cancel_hover_timer()
+
+
+class AppMenuTextItem(ToggleButton, AbstractMenuItem):
+    label = kp.ObjectProperty(None)
+    text = kp.StringProperty('')
+    font_size = kp.NumericProperty(14)
+    color = kp.ListProperty([1, 1, 1, 1])
+
+    def on_release(self):
+        submenu = self.get_submenu()
+
+        if self.state == 'down':
+            root = self._root_parent
+            submenu.bounding_box_widget = root.bounding_box if root.bounding_box else root.parent
+
+            submenu.bind(visible=self.on_visible)
+            submenu.show(self.x, self.y - 1)
+
+            for sibling in self.siblings:
+                if sibling.get_submenu() is not None:
+                    sibling.state = 'normal'
+                    sibling.get_submenu().hide()
+
+            self.parent._setup_hover_timer()
+        else:
+            self.parent._cancel_hover_timer()
+            submenu.hide()
+
+    def on_visible(self, *args):
+        submenu = self.get_submenu()
+        if self.width > submenu.get_max_width():
+            submenu.width = self.width
+
+    def _check_submenu(self):
+        super(AppMenuTextItem, self)._check_submenu()
+        self.disabled = (self.get_submenu() is None)
+
+    # def on_mouse_down(self):
+    #     print('on_mouse_down')
+    #     return True
+
+
+_path = os.path.dirname(os.path.realpath(__file__))
+Builder.load_file(os.path.join(_path, 'app_menu.kv'))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/context_menu.kv	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,133 @@
+<ContextMenu>:
+    cols: 1
+    size_hint: None, None
+    spacing: 0, 0
+    spacer: _spacer
+    on_visible: self._on_visible(args[1])
+    on_parent: self._on_visible(self.visible)
+
+#    canvas.before:
+#        Color:
+#            rgb: 1.0, 0, 0
+#        Rectangle:
+#            pos: self.pos
+#            size: self.size
+    Widget:
+        id: _spacer
+        size_hint: 1, None
+        height: 3
+        canvas.before:
+            Color:
+                rgb: 0.2, 0.71, 0.9
+            Rectangle:
+                pos: self.pos
+                size: self.size
+
+
+<ContextMenuItem>:
+    size_hint: None, None
+    submenu_arrow: _submenu_arrow
+    on_children: self._check_submenu()
+    on_parent: self._check_submenu()
+    canvas.before:
+        Color:
+            rgb: (0.15, 0.15, 0.15)
+        Rectangle:
+            pos: 0,0
+            size: self.size
+
+    Widget:
+        id: _submenu_arrow
+        size_hint: None, None
+        width: 6
+        height: 11
+#        pos: 10, 10
+        pos: self.parent.width - self.width - 5, (self.parent.height - self.height) / 2
+#        on_pos: print(self.pos)
+        canvas.before:
+            Translate:
+                xy: self.pos
+            Color:
+                rgb: (0.35, 0.35, 0.35) if self.disabled else (1, 1, 1)
+            Triangle:
+                points: [0,0, self.width,self.height/2, 0,self.height]
+            Translate:
+                xy: (-self.pos[0], -self.pos[1])
+
+
+<ContextMenuText>:
+    label: _label
+    width: self.parent.width if self.parent else 0
+    height: 26
+
+    Label:
+        pos: 0,0
+        id: _label
+        text: self.parent.text
+        color: self.parent.color
+        font_size: self.parent.font_size
+        padding: 10, 0
+#        font_size:
+        halign: 'left'
+        valign: 'middle'
+        size: self.texture_size
+        size_hint: None, 1
+
+
+<AbstractMenuItemHoverable>:
+    on_hovered: self._on_hovered(args[1])
+    canvas.before:
+        Color:
+            rgb: (0.25, 0.25, 0.25) if self.hovered and not self.disabled else (0.15, 0.15, 0.15)
+        Rectangle:
+            pos: 0,0
+            size: self.size
+
+
+<ContextMenuDivider>:
+    font_size: '10dp'
+    height: 20 if len(self.label.text) > 0 else 1
+    canvas.before:
+        Color:
+            rgb: (0.25, 0.25, 0.25)
+        Rectangle:
+            pos: 0,self.height - 1
+            size: self.width, 1
+
+
+<ContextMenuButton@Button>:
+    size_hint: None, None
+    font_size: 12
+    height: 20
+    background_normal: ""
+    background_down: ""
+    background_color: 0.2, 0.71, 0.9, 1.0
+    border: (0, 0, 0, 0)
+    on_press: self.background_color = 0.10, 0.6, 0.8, 1.0
+    on_release: self.background_color = 0.2, 0.71, 0.9, 1.0
+
+
+<ContextMenuToggleButton@ToggleButton>:
+    size_hint: None, None
+    font_size: '12px'
+    size: 30, 20
+    background_normal: ""
+    background_down: ""
+    background_color: (0.2, 0.71, 0.9, 1.0) if self.state == 'down' else (0.25, 0.25, 0.25, 1.0)
+    border: (0, 0, 0, 0)
+    on_press: self.background_color = 0.10, 0.6, 0.8, 1.0
+    on_release: self.background_color = 0.2, 0.71, 0.9, 1.0
+
+
+<ContextMenuSmallLabel@Label>:
+    size: self.texture_size[0], 18
+    size_hint: None, None
+    font_size: '12dp'
+
+
+<ContextMenuTextInput@TextInput>:
+    size_hint: None, None
+    height: 22
+    font_size: '12dp'
+    padding: 7, 3
+    multiline: False
\ No newline at end of file
--- /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'))
Binary file src/libs/garden/garden.contextmenu/doc/app-menu-01.png has changed
Binary file src/libs/garden/garden.contextmenu/doc/context-menu-01.png has changed
Binary file src/libs/garden/garden.contextmenu/doc/menu-divider-01.png has changed
Binary file src/libs/garden/garden.contextmenu/doc/menu-divider-02.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/examples/simple_app_menu.py	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,84 @@
+import kivy
+from kivy.app import App
+from kivy.lang import Builder
+from kivy.logger import Logger
+import logging
+
+kivy.require('1.9.0')
+# Logger.setLevel(logging.DEBUG)
+
+import kivy.garden.contextmenu
+
+
+kv = """
+FloatLayout:
+    id: layout
+    AppMenu:
+        id: app_menu
+        top: root.height
+        cancel_handler_widget: layout
+
+        AppMenuTextItem:
+            text: "Menu #1"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "Item #11"
+                ContextMenuTextItem:
+                    text: "Item #12"
+        AppMenuTextItem:
+            text: "Menu Menu Menu #2"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "Item #21"
+                ContextMenuTextItem:
+                    text: "Item #22"
+                ContextMenuTextItem:
+                    text: "ItemItemItem #23"
+                ContextMenuTextItem:
+                    text: "Item #24"
+                    ContextMenu:
+                        ContextMenuTextItem:
+                            text: "Item #241"
+                        ContextMenuTextItem:
+                            text: "Hello, World!"
+                            on_release: app.say_hello(self.text)
+                        ContextMenuTextItem:
+                            text: "Item #243"
+                        ContextMenuTextItem:
+                            text: "Item #244"
+                ContextMenuTextItem:
+                    text: "Item #5"
+        AppMenuTextItem:
+            text: "Menu Menu #3"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "SubMenu #31"
+                ContextMenuTextItem:
+                    text: "SubMenu #32"
+                ContextMenuTextItem:
+                    text: "SubMenu #33"
+                ContextMenuDivider:
+                ContextMenuTextItem:
+                    text: "SubMenu #34"
+        AppMenuTextItem:
+            text: "Menu #4"
+
+    Label:
+        pos: 10, 10
+        text: "Left click anywhere outside the context menu to close it"
+        size_hint: None, None
+        size: self.texture_size
+"""
+
+class MyApp(App):
+
+    def build(self):
+        self.title = 'Simple app menu example'
+        return Builder.load_string(kv)
+
+    def say_hello(self, text):
+        print(text)
+        self.root.ids['app_menu'].close_all()
+
+if __name__ == '__main__':
+    MyApp().run()
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/libs/garden/garden.contextmenu/examples/simple_context_menu.py	Sat Dec 24 14:16:58 2016 +0100
@@ -0,0 +1,70 @@
+import kivy
+from kivy.app import App
+from kivy.lang import Builder
+
+kivy.require('1.9.0')
+
+import kivy.garden.contextmenu
+
+
+kv = """
+FloatLayout:
+    id: layout
+    Label:
+        pos: 10, self.parent.height - self.height - 10
+        text: "Left click anywhere outside the context menu to close it"
+        size_hint: None, None
+        size: self.texture_size
+
+    Button:
+        size_hint: None, None
+        pos_hint: {"center_x": 0.5, "center_y": 0.8 }
+        size: 300, 40
+        text: "Click me to show the context menu"
+        on_release: context_menu.show(*app.root_window.mouse_pos)
+
+    ContextMenu:
+        id: context_menu
+        visible: False
+        cancel_handler_widget: layout
+
+        ContextMenuTextItem:
+            text: "SubMenu #2"
+        ContextMenuTextItem:
+            text: "SubMenu #3"
+            ContextMenu:
+                ContextMenuTextItem:
+                    text: "SubMenu #5"
+                ContextMenuTextItem:
+                    text: "SubMenu #6"
+                    ContextMenu:
+                        ContextMenuTextItem:
+                            text: "SubMenu #9"
+                        ContextMenuTextItem:
+                            text: "SubMenu #10"
+                        ContextMenuTextItem:
+                            text: "SubMenu #11"
+                        ContextMenuTextItem:
+                            text: "Hello, World!"
+                            on_release: app.say_hello(self.text)
+                        ContextMenuTextItem:
+                            text: "SubMenu #12"
+                ContextMenuTextItem:
+                    text: "SubMenu #7"
+        ContextMenuTextItem:
+            text: "SubMenu #4"
+"""
+
+class MyApp(App):
+
+    def build(self):
+        self.title = 'Simple context menu example'
+        return Builder.load_string(kv)
+
+    def say_hello(self, text):
+        print(text)
+        self.root.ids['context_menu'].hide()
+
+
+if __name__ == '__main__':
+    MyApp().run()
\ No newline at end of file