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