Mercurial > libervia-desktop-kivy
comparison 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 |
comparison
equal
deleted
inserted
replaced
82:4c6d56c069d9 | 83:741a7d6d8c28 |
---|---|
1 from kivy.uix.gridlayout import GridLayout | |
2 from kivy.uix.relativelayout import RelativeLayout | |
3 from kivy.core.window import Window | |
4 from kivy.uix.behaviors import ButtonBehavior | |
5 from kivy.lang import Builder | |
6 from kivy.clock import Clock | |
7 from functools import partial | |
8 import traceback | |
9 | |
10 import kivy.properties as kp | |
11 import os | |
12 | |
13 | |
14 class AbstractMenu(object): | |
15 cancel_handler_widget = kp.ObjectProperty(None) | |
16 bounding_box_widget = kp.ObjectProperty(None) | |
17 | |
18 def __init__(self, *args, **kwargs): | |
19 self.clock_event = None | |
20 | |
21 def add_item(self, widget): | |
22 self.add_widget(widget) | |
23 | |
24 def add_text_item(self, text, on_release=None): | |
25 item = ContextMenuTextItem(text=text) | |
26 if on_release: | |
27 item.bind(on_release=on_release) | |
28 self.add_item(item) | |
29 | |
30 def get_height(self): | |
31 height = 0 | |
32 for widget in self.children: | |
33 height += widget.height | |
34 return height | |
35 | |
36 def hide_submenus(self): | |
37 for widget in self.menu_item_widgets: | |
38 widget.hovered = False | |
39 widget.hide_submenu() | |
40 | |
41 def self_or_submenu_collide_with_point(self, x, y): | |
42 raise NotImplementedError() | |
43 | |
44 def on_cancel_handler_widget(self, obj, widget): | |
45 self.cancel_handler_widget.bind(on_touch_down=self.hide_app_menus) | |
46 | |
47 def hide_app_menus(self, obj, pos): | |
48 raise NotImplementedError() | |
49 | |
50 @property | |
51 def menu_item_widgets(self): | |
52 """ | |
53 Return all children that are subclasses of ContextMenuItem | |
54 """ | |
55 return [w for w in self.children if issubclass(w.__class__, AbstractMenuItem)] | |
56 | |
57 def _setup_hover_timer(self): | |
58 if self.clock_event is None: | |
59 self.clock_event = Clock.schedule_interval(partial(self._check_mouse_hover), 0.05) | |
60 | |
61 def _check_mouse_hover(self, obj): | |
62 self.self_or_submenu_collide_with_point(*Window.mouse_pos) | |
63 | |
64 def _cancel_hover_timer(self): | |
65 if self.clock_event: | |
66 self.clock_event.cancel() | |
67 self.clock_event = None | |
68 | |
69 | |
70 class ContextMenu(GridLayout, AbstractMenu): | |
71 visible = kp.BooleanProperty(False) | |
72 spacer = kp.ObjectProperty(None) | |
73 | |
74 def __init__(self, *args, **kwargs): | |
75 super(ContextMenu, self).__init__(*args, **kwargs) | |
76 self.orig_parent = None | |
77 # self._on_visible(False) | |
78 | |
79 def hide(self): | |
80 self.visible = False | |
81 | |
82 def show(self, x=None, y=None): | |
83 self.visible = True | |
84 self._add_to_parent() | |
85 self.hide_submenus() | |
86 | |
87 root_parent = self.bounding_box_widget if self.bounding_box_widget is not None else self.get_context_menu_root_parent() | |
88 if root_parent is None: | |
89 return | |
90 | |
91 point_relative_to_root = root_parent.to_local(*self.to_window(x, y)) | |
92 | |
93 # Choose the best position to open the menu | |
94 if x is not None and y is not None: | |
95 if point_relative_to_root[0] + self.width < root_parent.width: | |
96 pox_x = x | |
97 else: | |
98 pox_x = x - self.width | |
99 if issubclass(self.parent.__class__, AbstractMenuItem): | |
100 pox_x -= self.parent.width | |
101 | |
102 if point_relative_to_root[1] - self.height < 0: | |
103 pos_y = y | |
104 if issubclass(self.parent.__class__, AbstractMenuItem): | |
105 pos_y -= self.parent.height + self.spacer.height | |
106 else: | |
107 pos_y = y - self.height | |
108 | |
109 parent_pos = root_parent.pos | |
110 pos = (pox_x + parent_pos[0], pos_y + parent_pos[1]) | |
111 | |
112 self.pos = pos | |
113 | |
114 def self_or_submenu_collide_with_point(self, x, y): | |
115 queue = self.menu_item_widgets | |
116 collide_widget = None | |
117 | |
118 # Iterate all siblings and all children | |
119 while len(queue) > 0: | |
120 widget = queue.pop(0) | |
121 submenu = widget.get_submenu() | |
122 if submenu is not None and widget.hovered: | |
123 queue += submenu.menu_item_widgets | |
124 | |
125 widget_pos = widget.to_window(0, 0) | |
126 if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: | |
127 widget.hovered = True | |
128 | |
129 collide_widget = widget | |
130 for sib in widget.siblings: | |
131 sib.hovered = False | |
132 elif submenu and submenu.visible: | |
133 widget.hovered = True | |
134 else: | |
135 widget.hovered = False | |
136 | |
137 return collide_widget | |
138 | |
139 def _on_visible(self, new_visibility): | |
140 if new_visibility: | |
141 self.size = self.get_max_width(), self.get_height() | |
142 self._add_to_parent() | |
143 # @todo: Do we need to remove self from self.parent.__context_menus? Probably not. | |
144 | |
145 elif self.parent and not new_visibility: | |
146 self.orig_parent = self.parent | |
147 | |
148 ''' | |
149 We create a set that holds references to all context menus in the parent widget. | |
150 It's necessary to keep at least one reference to this context menu. Otherwise when | |
151 removed from parent it might get de-allocated by GC. | |
152 ''' | |
153 if not hasattr(self.parent, '_ContextMenu__context_menus'): | |
154 self.parent.__context_menus = set() | |
155 self.parent.__context_menus.add(self) | |
156 | |
157 self.parent.remove_widget(self) | |
158 self.hide_submenus() | |
159 self._cancel_hover_timer() | |
160 | |
161 def _add_to_parent(self): | |
162 if not self.parent: | |
163 self.orig_parent.add_widget(self) | |
164 self.orig_parent = None | |
165 | |
166 # Create the timer on the outer most menu object | |
167 if self._get_root_context_menu() == self: | |
168 self._setup_hover_timer() | |
169 | |
170 def get_max_width(self): | |
171 max_width = 0 | |
172 for widget in self.menu_item_widgets: | |
173 width = widget.content_width if widget.content_width is not None else widget.width | |
174 if width is not None and width > max_width: | |
175 max_width = width | |
176 | |
177 return max_width | |
178 | |
179 def get_context_menu_root_parent(self): | |
180 """ | |
181 Return the bounding box widget for positioning sub menus. By default it's root context menu's parent. | |
182 """ | |
183 if self.bounding_box_widget is not None: | |
184 return self.bounding_box_widget | |
185 root_context_menu = self._get_root_context_menu() | |
186 return root_context_menu.bounding_box_widget if root_context_menu.bounding_box_widget else root_context_menu.parent | |
187 | |
188 def _get_root_context_menu(self): | |
189 """ | |
190 Return the outer most context menu object | |
191 """ | |
192 root = self | |
193 while issubclass(root.parent.__class__, ContextMenuItem) \ | |
194 or issubclass(root.parent.__class__, ContextMenu): | |
195 root = root.parent | |
196 return root | |
197 | |
198 def hide_app_menus(self, obj, pos): | |
199 return self.self_or_submenu_collide_with_point(pos.x, pos.y) is None and self.hide() | |
200 | |
201 | |
202 class AbstractMenuItem(object): | |
203 submenu = kp.ObjectProperty(None) | |
204 | |
205 def get_submenu(self): | |
206 return self.submenu if self.submenu != "" else None | |
207 | |
208 def show_submenu(self, x=None, y=None): | |
209 if self.get_submenu(): | |
210 self.get_submenu().show(*self._root_parent.to_local(x, y)) | |
211 | |
212 def hide_submenu(self): | |
213 submenu = self.get_submenu() | |
214 if submenu: | |
215 submenu.visible = False | |
216 submenu.hide_submenus() | |
217 | |
218 def _check_submenu(self): | |
219 if self.parent is not None and len(self.children) > 0: | |
220 submenus = [w for w in self.children if issubclass(w.__class__, ContextMenu)] | |
221 if len(submenus) > 1: | |
222 raise Exception('Menu item (ContextMenuItem) can have maximum one submenu (ContextMenu)') | |
223 elif len(submenus) == 1: | |
224 self.submenu = submenus[0] | |
225 | |
226 @property | |
227 def siblings(self): | |
228 return [w for w in self.parent.children if issubclass(w.__class__, AbstractMenuItem) and w != self] | |
229 | |
230 @property | |
231 def content_width(self): | |
232 return None | |
233 | |
234 @property | |
235 def _root_parent(self): | |
236 return self.parent.get_context_menu_root_parent() | |
237 | |
238 | |
239 class ContextMenuItem(RelativeLayout, AbstractMenuItem): | |
240 submenu_arrow = kp.ObjectProperty(None) | |
241 | |
242 def _check_submenu(self): | |
243 super(ContextMenuItem, self)._check_submenu() | |
244 if self.get_submenu() is None: | |
245 self.submenu_arrow.opacity = 0 | |
246 else: | |
247 self.submenu_arrow.opacity = 1 | |
248 | |
249 | |
250 class AbstractMenuItemHoverable(object): | |
251 hovered = kp.BooleanProperty(False) | |
252 | |
253 def _on_hovered(self, new_hovered): | |
254 if new_hovered: | |
255 spacer_height = self.parent.spacer.height if self.parent.spacer else 0 | |
256 point = self.right, self.top + spacer_height | |
257 self.show_submenu(self.width, self.height + spacer_height) | |
258 else: | |
259 self.hide_submenu() | |
260 | |
261 | |
262 class ContextMenuText(ContextMenuItem): | |
263 label = kp.ObjectProperty(None) | |
264 submenu_postfix = kp.StringProperty(' ...') | |
265 text = kp.StringProperty('') | |
266 font_size = kp.NumericProperty(14) | |
267 color = kp.ListProperty([1,1,1,1]) | |
268 | |
269 def __init__(self, *args, **kwargs): | |
270 super(ContextMenuText, self).__init__(*args, **kwargs) | |
271 | |
272 @property | |
273 def content_width(self): | |
274 # keep little space for eventual arrow for submenus | |
275 return self.label.texture_size[0] + 10 | |
276 | |
277 | |
278 class ContextMenuDivider(ContextMenuText): | |
279 pass | |
280 | |
281 | |
282 class ContextMenuTextItem(ButtonBehavior, ContextMenuText, AbstractMenuItemHoverable): | |
283 pass | |
284 | |
285 | |
286 _path = os.path.dirname(os.path.realpath(__file__)) | |
287 Builder.load_file(os.path.join(_path, 'context_menu.kv')) |