comparison libervia/desktop_kivy/core/widgets_handler.py @ 493:b3cedbee561d

refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 18:26:16 +0200
parents cagou/core/widgets_handler.py@203755bbe0fe
children
comparison
equal deleted inserted replaced
492:5114bbb5daa3 493:b3cedbee561d
1 #!/usr/bin/env python3
2
3 #Libervia Desktop-Kivy
4 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19
20 from libervia.backend.core import log as logging
21 from libervia.backend.core import exceptions
22 from libervia.frontends.quick_frontend import quick_widgets
23 from kivy.graphics import Color, Ellipse
24 from kivy.uix.layout import Layout
25 from kivy.uix.boxlayout import BoxLayout
26 from kivy.uix.stencilview import StencilView
27 from kivy.uix.carousel import Carousel
28 from kivy.uix.screenmanager import ScreenManager, Screen
29 from kivy.metrics import dp
30 from kivy import properties
31 from libervia.desktop_kivy import G
32 from .constants import Const as C
33 from . import cagou_widget
34
35 log = logging.getLogger(__name__)
36
37
38 REMOVE_WID_LIMIT = dp(50)
39 MIN_WIDTH = MIN_HEIGHT = dp(70)
40
41
42 class BoxStencil(BoxLayout, StencilView):
43 pass
44
45
46 class WHWrapper(BoxLayout):
47 main_container = properties.ObjectProperty(None)
48 screen_manager = properties.ObjectProperty(None, allownone=True)
49 carousel = properties.ObjectProperty(None, allownone=True)
50 split_size = properties.NumericProperty(dp(1))
51 split_margin = properties.NumericProperty(dp(2))
52 split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
53 split_color_move = C.COLOR_SEC_DARK
54 split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1])
55 # sp stands for "split point"
56 sp_size = properties.NumericProperty(dp(1))
57 sp_space = properties.NumericProperty(dp(4))
58 sp_zone = properties.NumericProperty(dp(30))
59 _split = properties.OptionProperty('None', options=['None', 'left', 'top'])
60 _split_del = properties.BooleanProperty(False)
61
62 def __init__(self, **kwargs):
63 idx = kwargs.pop('_wid_idx')
64 self._wid_idx = idx
65 super(WHWrapper, self).__init__(**kwargs)
66 self._left_wids = set()
67 self._top_wids = set()
68 self._right_wids = set()
69 self._bottom_wids = set()
70 self._clear_attributes()
71
72 def _clear_attributes(self):
73 self._former_slide = None
74
75 def __repr__(self):
76 return "WHWrapper_{idx}".format(idx=self._wid_idx)
77
78 def _main_wid(self, wid_list):
79 """return main widget of a side list
80
81 main widget is either the widget currently splitted
82 or any widget if none is split
83 @return (WHWrapper, None): main widget or None
84 if there is not widget
85 """
86 if not wid_list:
87 return None
88 for wid in wid_list:
89 if wid._split != 'None':
90 return wid
91 return next(iter(wid_list))
92
93 def on_parent(self, __, new_parent):
94 if new_parent is None:
95 # we detach all children so LiberviaDesktopKivyWidget.whwrapper won't link to this one
96 # anymore
97 self.clear_widgets()
98
99 @property
100 def _left_wid(self):
101 return self._main_wid(self._left_wids)
102
103 @property
104 def _top_wid(self):
105 return self._main_wid(self._top_wids)
106
107 @property
108 def _right_wid(self):
109 return self._main_wid(self._right_wids)
110
111 @property
112 def _bottom_wid(self):
113 return self._main_wid(self._bottom_wids)
114
115 @property
116 def current_slide(self):
117 if (self.carousel is not None
118 and (self.screen_manager is None or self.screen_manager.current == '')):
119 return self.carousel.current_slide
120 elif self.screen_manager is not None:
121 # we should have exactly one children in current_screen, else there is a bug
122 return self.screen_manager.current_screen.children[0]
123 else:
124 try:
125 return self.main_container.children[0]
126 except IndexError:
127 log.error("No child found, this should not happen")
128 return None
129
130 @property
131 def carousel_active(self):
132 """Return True if Carousel is used and active"""
133 if self.carousel is None:
134 return False
135 if self.screen_manager is not None and self.screen_manager.current != '':
136 return False
137 return True
138
139 @property
140 def former_screen_wid(self):
141 """Return widget currently active for former screen"""
142 if self.screen_manager is None:
143 raise exceptions.InternalError(
144 "former_screen_wid can only be used if ScreenManager is used")
145 if self._former_screen_name is None:
146 return None
147 return self.get_screen_widget(self._former_screen_name)
148
149 def get_screen_widget(self, screen_name):
150 """Return screen main widget, handling carousel if necessary"""
151 if self.carousel is not None and screen_name == '':
152 return self.carousel.current_slide
153 try:
154 return self.screen_manager.get_screen(screen_name).children[0]
155 except IndexError:
156 return None
157
158 def _draw_ellipse(self):
159 """draw split ellipse"""
160 color = self.split_color_del if self._split_del else self.split_color_move
161 try:
162 self.canvas.after.remove(self.ellipse)
163 except AttributeError:
164 pass
165 if self._split == "top":
166 with self.canvas.after:
167 Color(*color)
168 self.ellipse = Ellipse(angle_start=90, angle_end=270,
169 pos=(self.x + self.width/2 - self.sp_zone/2,
170 self.y + self.height - self.sp_zone/2),
171 size=(self.sp_zone, self.sp_zone))
172 elif self._split == "left":
173 with self.canvas.after:
174 Color(*color)
175 self.ellipse = Ellipse(angle_end=180,
176 pos=(self.x + -self.sp_zone/2,
177 self.y + self.height/2 - self.sp_zone/2),
178 size = (self.sp_zone, self.sp_zone))
179 else:
180 raise exceptions.InternalError('unexpected split value')
181
182 def on_touch_down(self, touch):
183 """activate split if touch is on a split zone"""
184 if not self.collide_point(*touch.pos):
185 return
186 log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format(
187 self._wid_idx,
188 'None' if not self._left_wids else [w._wid_idx for w in self._left_wids],
189 'None' if not self._top_wids else [w._wid_idx for w in self._top_wids],
190 'None' if not self._right_wids else [w._wid_idx for w in self._right_wids],
191 'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids],
192 self.pos,
193 self.size,
194 ))
195 touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True)
196 if (touch_ry <= self.height and
197 touch_ry >= self.height - self.split_size - self.split_margin or
198 touch_ry <= self.height and
199 touch_ry >= self.height - self.sp_zone and
200 touch_rx >= self.width//2 - self.sp_zone//2 and
201 touch_rx <= self.width//2 + self.sp_zone//2):
202 # split area is touched, we activate top split mode
203 self._split = "top"
204 self._draw_ellipse()
205 elif (touch_rx >= 0 and
206 touch_rx <= self.split_size + self.split_margin or
207 touch_rx >= 0 and
208 touch_rx <= self.sp_zone and
209 touch_ry >= self.height//2 - self.sp_zone//2 and
210 touch_ry <= self.height//2 + self.sp_zone//2):
211 # split area is touched, we activate left split mode
212 self._split = "left"
213 touch.ud['ori_width'] = self.width
214 self._draw_ellipse()
215 else:
216 if self.carousel_active and len(self.carousel.slides) <= 1:
217 # we don't want swipe of carousel if there is only one slide
218 return StencilView.on_touch_down(self.carousel, touch)
219 else:
220 return super(WHWrapper, self).on_touch_down(touch)
221
222 def on_touch_move(self, touch):
223 """handle size change and widget creation on split"""
224 if self._split == 'None':
225 return super(WHWrapper, self).on_touch_move(touch)
226
227 elif self._split == 'top':
228 new_height = touch.y - self.y
229
230 if new_height < MIN_HEIGHT:
231 return
232
233 # we must not pass the top widget/border
234 if self._top_wids:
235 top = next(iter(self._top_wids))
236 y_limit = top.y + top.height
237
238 if top.height <= REMOVE_WID_LIMIT:
239 # we are in remove zone, we add visual hint for that
240 if not self._split_del and self._top_wids:
241 self._split_del = True
242 self._draw_ellipse()
243 else:
244 if self._split_del:
245 self._split_del = False
246 self._draw_ellipse()
247 else:
248 y_limit = self.y + self.height
249
250 if touch.y >= y_limit:
251 return
252
253 # all right, we can change size
254 self.height = new_height
255 self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2)
256
257 if not self._top_wids:
258 # we are the last widget on the top
259 # so we create a new widget
260 new_wid = self.parent.add_widget()
261 self._top_wids.add(new_wid)
262 new_wid._bottom_wids.add(self)
263 for w in self._right_wids:
264 new_wid._right_wids.add(w)
265 w._left_wids.add(new_wid)
266 for w in self._left_wids:
267 new_wid._left_wids.add(w)
268 w._right_wids.add(new_wid)
269
270 elif self._split == 'left':
271 ori_width = touch.ud['ori_width']
272 new_x = touch.x
273 new_width = ori_width - (touch.x - touch.ox)
274
275 if new_width < MIN_WIDTH:
276 return
277
278 # we must not pass the left widget/border
279 if self._left_wids:
280 left = next(iter(self._left_wids))
281 x_limit = left.x
282
283 if left.width <= REMOVE_WID_LIMIT:
284 # we are in remove zone, we add visual hint for that
285 if not self._split_del and self._left_wids:
286 self._split_del = True
287 self._draw_ellipse()
288 else:
289 if self._split_del:
290 self._split_del = False
291 self._draw_ellipse()
292 else:
293 x_limit = self.x
294
295 if new_x <= x_limit:
296 return
297
298 # all right, we can change position/size
299 self.x = new_x
300 self.width = new_width
301 self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1])
302
303 if not self._left_wids:
304 # we are the last widget on the left
305 # so we create a new widget
306 new_wid = self.parent.add_widget()
307 self._left_wids.add(new_wid)
308 new_wid._right_wids.add(self)
309 for w in self._top_wids:
310 new_wid._top_wids.add(w)
311 w._bottom_wids.add(new_wid)
312 for w in self._bottom_wids:
313 new_wid._bottom_wids.add(w)
314 w._top_wids.add(new_wid)
315
316 else:
317 raise Exception.InternalError('invalid _split value')
318
319 def on_touch_up(self, touch):
320 if self._split == 'None':
321 return super(WHWrapper, self).on_touch_up(touch)
322 if self._split == 'top':
323 # we remove all top widgets in delete zone,
324 # and update there side widgets list
325 for top in self._top_wids.copy():
326 if top.height <= REMOVE_WID_LIMIT:
327 G.host._remove_visible_widget(top.current_slide)
328 for w in top._top_wids:
329 w._bottom_wids.remove(top)
330 w._bottom_wids.update(top._bottom_wids)
331 for w in top._bottom_wids:
332 w._top_wids.remove(top)
333 w._top_wids.update(top._top_wids)
334 for w in top._left_wids:
335 w._right_wids.remove(top)
336 for w in top._right_wids:
337 w._left_wids.remove(top)
338 self.parent.remove_widget(top)
339 elif self._split == 'left':
340 # we remove all left widgets in delete zone,
341 # and update there side widgets list
342 for left in self._left_wids.copy():
343 if left.width <= REMOVE_WID_LIMIT:
344 G.host._remove_visible_widget(left.current_slide)
345 for w in left._left_wids:
346 w._right_wids.remove(left)
347 w._right_wids.update(left._right_wids)
348 for w in left._right_wids:
349 w._left_wids.remove(left)
350 w._left_wids.update(left._left_wids)
351 for w in left._top_wids:
352 w._bottom_wids.remove(left)
353 for w in left._bottom_wids:
354 w._top_wids.remove(left)
355 self.parent.remove_widget(left)
356 self._split = 'None'
357 self.canvas.after.remove(self.ellipse)
358 del self.ellipse
359
360 def clear_widgets(self):
361 current_slide = self.current_slide
362 if current_slide is not None:
363 G.host._remove_visible_widget(current_slide, ignore_missing=True)
364
365 super().clear_widgets()
366
367 self.screen_manager = None
368 self.carousel = None
369 self._clear_attributes()
370
371 def set_widget(self, wid, index=0):
372 assert len(self.children) == 0
373
374 if wid.collection_carousel or wid.global_screen_manager:
375 self.main_container = self
376 else:
377 self.main_container = BoxStencil()
378 self.add_widget(self.main_container)
379
380 if self.carousel is not None:
381 return self.carousel.add_widget(wid, index)
382
383 if wid.global_screen_manager:
384 if self.screen_manager is None:
385 self.screen_manager = ScreenManager()
386 self.main_container.add_widget(self.screen_manager)
387 parent = Screen()
388 self.screen_manager.add_widget(parent)
389 self._former_screen_name = ''
390 self.screen_manager.bind(current=self.on_screen_change)
391 wid.screen_manager_init(self.screen_manager)
392 else:
393 parent = self.main_container
394
395 if wid.collection_carousel:
396 # a Carousel is requested, and this is the first widget that we add
397 # so we need to create the carousel
398 self.carousel = Carousel(
399 direction = "right",
400 ignore_perpendicular_swipes = True,
401 loop = True,
402 )
403 self._slides_update_lock = 0
404 self.carousel.bind(current_slide=self.on_slide_change)
405 parent.add_widget(self.carousel)
406 self.carousel.add_widget(wid, index)
407 else:
408 # no Carousel requested, we add the widget as a direct child
409 parent.add_widget(wid)
410 G.host._add_visible_widget(wid)
411
412 def change_widget(self, new_widget):
413 """Change currently displayed widget
414
415 slides widgets will be updated
416 """
417 if (self.carousel is not None
418 and self.carousel.current_slide.__class__ == new_widget.__class__):
419 # we have the same class, we reuse carousel and screen manager setting
420
421 if self.carousel.current_slide != new_widget:
422 # slides update need to be blocked to avoid the update in on_slide_change
423 # which would mess the removal of current widgets
424 self._slides_update_lock += 1
425 new_wid = None
426 for w in self.carousel.slides[:]:
427 if w.widget_hash == new_widget.widget_hash:
428 new_wid = w
429 continue
430 self.carousel.remove_widget(w)
431 if isinstance(w, quick_widgets.QuickWidget):
432 G.host.widgets.delete_widget(w)
433 if new_wid is None:
434 new_wid = G.host.get_or_clone(new_widget)
435 self.carousel.add_widget(new_wid)
436 self._update_hidden_slides()
437 self._slides_update_lock -= 1
438
439 if self.screen_manager is not None:
440 self.screen_manager.clear_widgets([
441 s for s in self.screen_manager.screens if s.name != ''])
442 new_wid.screen_manager_init(self.screen_manager)
443 else:
444 # else, we restart fresh
445 self.clear_widgets()
446 self.set_widget(G.host.get_or_clone(new_widget))
447
448 def on_screen_change(self, screen_manager, new_screen):
449 try:
450 new_screen_wid = self.current_slide
451 except IndexError:
452 new_screen_wid = None
453 log.warning("Switching to a screen without children")
454 if new_screen == '' and self.carousel is not None:
455 # carousel may have been changed in the background, so we update slides
456 self._update_hidden_slides()
457 former_screen_wid = self.former_screen_wid
458 if isinstance(former_screen_wid, cagou_widget.LiberviaDesktopKivyWidget):
459 G.host._remove_visible_widget(former_screen_wid)
460 if isinstance(new_screen_wid, cagou_widget.LiberviaDesktopKivyWidget):
461 G.host._add_visible_widget(new_screen_wid)
462 self._former_screen_name = new_screen
463 G.host.selected_widget = new_screen_wid
464
465 def on_slide_change(self, handler, new_slide):
466 if self._former_slide is new_slide:
467 # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide
468 # binding now calls on_slide_change twice with the same widget (here
469 # "new_slide"). To be checked with Kivy team.
470 return
471 log.debug(f"Slide change: new_slide = {new_slide}")
472 if self._former_slide is not None:
473 G.host._remove_visible_widget(self._former_slide, ignore_missing=True)
474 self._former_slide = new_slide
475 if self.carousel_active:
476 G.host.selected_widget = new_slide
477 if new_slide is not None:
478 G.host._add_visible_widget(new_slide)
479 self._update_hidden_slides()
480
481 def hidden_list(self, visible_list, ignore=None):
482 """return widgets of same class as carousel current one, if they are hidden
483
484 @param visible_list(list[QuickWidget]): widgets visible
485 @param ignore(QuickWidget, None): do no return this widget
486 @return (iter[QuickWidget]): widgets hidden
487 """
488 # we want to avoid recreated widgets
489 added = [w.widget_hash for w in visible_list]
490 current_slide = self.carousel.current_slide
491 for w in G.host.widgets.get_widgets(current_slide.__class__,
492 profiles=current_slide.profiles):
493 wid_hash = w.widget_hash
494 if w in visible_list or wid_hash in added:
495 continue
496 if wid_hash == ignore.widget_hash:
497 continue
498 yield w
499
500
501 def _update_hidden_slides(self):
502 """adjust carousel slides according to visible widgets"""
503 if self._slides_update_lock or not self.carousel_active:
504 return
505 current_slide = self.carousel.current_slide
506 if not isinstance(current_slide, quick_widgets.QuickWidget):
507 return
508 # lock must be used here to avoid recursions
509 self._slides_update_lock += 1
510 visible_list = G.host.get_visible_list(current_slide.__class__)
511 # we ignore current_slide as it may not be visible yet (e.g. if an other
512 # screen is shown
513 hidden = list(self.hidden_list(visible_list, ignore=current_slide))
514 slides_sorted = sorted(set(hidden + [current_slide]))
515 to_remove = set(self.carousel.slides).difference({current_slide})
516 for w in to_remove:
517 self.carousel.remove_widget(w)
518 if hidden:
519 # no need to add more than two widgets (next and previous),
520 # as the list will be updated on each new visible widget
521 current_idx = slides_sorted.index(current_slide)
522 try:
523 next_slide = slides_sorted[current_idx+1]
524 except IndexError:
525 next_slide = slides_sorted[0]
526 self.carousel.add_widget(G.host.get_or_clone(next_slide))
527 if len(hidden)>1:
528 previous_slide = slides_sorted[current_idx-1]
529 self.carousel.add_widget(G.host.get_or_clone(previous_slide))
530
531 self._slides_update_lock -= 1
532
533
534 class WidgetsHandlerLayout(Layout):
535 count = 0
536
537 def __init__(self, **kwargs):
538 super(WidgetsHandlerLayout, self).__init__(**kwargs)
539 self._layout_size = None # size used for the last layout
540 fbind = self.fbind
541 update = self._trigger_layout
542 fbind('children', update)
543 fbind('parent', update)
544 fbind('size', self.adjust_prop)
545 fbind('pos', update)
546
547 @property
548 def default_widget(self):
549 return G.host.default_wid['factory'](G.host.default_wid, None, None)
550
551 def adjust_prop(self, handler, new_size):
552 """Adjust children proportion
553
554 useful when this widget is resized (e.g. when going to fullscreen)
555 """
556 if len(self.children) > 1:
557 old_width, old_height = self._layout_size
558 if not old_width or not old_height:
559 # we don't want division by zero
560 return self._trigger_layout(handler, new_size)
561 width_factor = float(self.width) / old_width
562 height_factor = float(self.height) / old_height
563 for child in self.children:
564 child.width *= width_factor
565 child.height *= height_factor
566 child.x *= width_factor
567 child.y *= height_factor
568 self._trigger_layout(handler, new_size)
569
570 def do_layout(self, *args):
571 self._layout_size = self.size[:]
572 for child in self.children:
573 # XXX: left must be calculated before right and bottom before top
574 # because they are the pos, and are used to caculate size (right and top)
575 # left
576 left = child._left_wid
577 left_end_x = self.x-1 if left is None else left.right
578 if child.x != left_end_x + 1 and child._split == "None":
579 child.x = left_end_x + 1
580 # right
581 right = child._right_wid
582 right_x = self.right + 1 if right is None else right.x
583 if child.right != right_x - 1:
584 child.width = right_x - child.x - 1
585 # bottom
586 bottom = child._bottom_wid
587 if bottom is None:
588 if child.y != self.y:
589 child.y = self.y
590 else:
591 if child.y != bottom.top + 1:
592 child.y = bottom.top + 1
593 # top
594 top = child._top_wid
595 top_y = self.top+1 if top is None else top.y
596 if child.top != top_y - 1:
597 if child._split == "None":
598 child.height = top_y - child.y - 1
599
600 def remove_widget(self, wid):
601 super(WidgetsHandlerLayout, self).remove_widget(wid)
602 log.debug("widget deleted ({})".format(wid._wid_idx))
603
604 def add_widget(self, wid=None, index=0):
605 WidgetsHandlerLayout.count += 1
606 if wid is None:
607 wid = self.default_widget
608 if G.host.selected_widget is None:
609 G.host.selected_widget = wid
610 wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count)
611 log.debug("WHWrapper created ({})".format(wrapper._wid_idx))
612 wrapper.set_widget(wid)
613 super(WidgetsHandlerLayout, self).add_widget(wrapper, index)
614 return wrapper
615
616
617 class WidgetsHandler(WidgetsHandlerLayout):
618
619 def __init__(self, **kw):
620 super(WidgetsHandler, self).__init__(**kw)
621 self.add_widget()