83
|
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')) |