Mercurial > libervia-desktop-kivy
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/widgets_handler.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +from libervia.backend.core import exceptions +from libervia.frontends.quick_frontend import quick_widgets +from kivy.graphics import Color, Ellipse +from kivy.uix.layout import Layout +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.stencilview import StencilView +from kivy.uix.carousel import Carousel +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.metrics import dp +from kivy import properties +from libervia.desktop_kivy import G +from .constants import Const as C +from . import cagou_widget + +log = logging.getLogger(__name__) + + +REMOVE_WID_LIMIT = dp(50) +MIN_WIDTH = MIN_HEIGHT = dp(70) + + +class BoxStencil(BoxLayout, StencilView): + pass + + +class WHWrapper(BoxLayout): + main_container = properties.ObjectProperty(None) + screen_manager = properties.ObjectProperty(None, allownone=True) + carousel = properties.ObjectProperty(None, allownone=True) + split_size = properties.NumericProperty(dp(1)) + split_margin = properties.NumericProperty(dp(2)) + split_color = properties.ListProperty([0.8, 0.8, 0.8, 1]) + split_color_move = C.COLOR_SEC_DARK + split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1]) + # sp stands for "split point" + sp_size = properties.NumericProperty(dp(1)) + sp_space = properties.NumericProperty(dp(4)) + sp_zone = properties.NumericProperty(dp(30)) + _split = properties.OptionProperty('None', options=['None', 'left', 'top']) + _split_del = properties.BooleanProperty(False) + + def __init__(self, **kwargs): + idx = kwargs.pop('_wid_idx') + self._wid_idx = idx + super(WHWrapper, self).__init__(**kwargs) + self._left_wids = set() + self._top_wids = set() + self._right_wids = set() + self._bottom_wids = set() + self._clear_attributes() + + def _clear_attributes(self): + self._former_slide = None + + def __repr__(self): + return "WHWrapper_{idx}".format(idx=self._wid_idx) + + def _main_wid(self, wid_list): + """return main widget of a side list + + main widget is either the widget currently splitted + or any widget if none is split + @return (WHWrapper, None): main widget or None + if there is not widget + """ + if not wid_list: + return None + for wid in wid_list: + if wid._split != 'None': + return wid + return next(iter(wid_list)) + + def on_parent(self, __, new_parent): + if new_parent is None: + # we detach all children so LiberviaDesktopKivyWidget.whwrapper won't link to this one + # anymore + self.clear_widgets() + + @property + def _left_wid(self): + return self._main_wid(self._left_wids) + + @property + def _top_wid(self): + return self._main_wid(self._top_wids) + + @property + def _right_wid(self): + return self._main_wid(self._right_wids) + + @property + def _bottom_wid(self): + return self._main_wid(self._bottom_wids) + + @property + def current_slide(self): + if (self.carousel is not None + and (self.screen_manager is None or self.screen_manager.current == '')): + return self.carousel.current_slide + elif self.screen_manager is not None: + # we should have exactly one children in current_screen, else there is a bug + return self.screen_manager.current_screen.children[0] + else: + try: + return self.main_container.children[0] + except IndexError: + log.error("No child found, this should not happen") + return None + + @property + def carousel_active(self): + """Return True if Carousel is used and active""" + if self.carousel is None: + return False + if self.screen_manager is not None and self.screen_manager.current != '': + return False + return True + + @property + def former_screen_wid(self): + """Return widget currently active for former screen""" + if self.screen_manager is None: + raise exceptions.InternalError( + "former_screen_wid can only be used if ScreenManager is used") + if self._former_screen_name is None: + return None + return self.get_screen_widget(self._former_screen_name) + + def get_screen_widget(self, screen_name): + """Return screen main widget, handling carousel if necessary""" + if self.carousel is not None and screen_name == '': + return self.carousel.current_slide + try: + return self.screen_manager.get_screen(screen_name).children[0] + except IndexError: + return None + + def _draw_ellipse(self): + """draw split ellipse""" + color = self.split_color_del if self._split_del else self.split_color_move + try: + self.canvas.after.remove(self.ellipse) + except AttributeError: + pass + if self._split == "top": + with self.canvas.after: + Color(*color) + self.ellipse = Ellipse(angle_start=90, angle_end=270, + pos=(self.x + self.width/2 - self.sp_zone/2, + self.y + self.height - self.sp_zone/2), + size=(self.sp_zone, self.sp_zone)) + elif self._split == "left": + with self.canvas.after: + Color(*color) + self.ellipse = Ellipse(angle_end=180, + pos=(self.x + -self.sp_zone/2, + self.y + self.height/2 - self.sp_zone/2), + size = (self.sp_zone, self.sp_zone)) + else: + raise exceptions.InternalError('unexpected split value') + + def on_touch_down(self, touch): + """activate split if touch is on a split zone""" + if not self.collide_point(*touch.pos): + return + log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format( + self._wid_idx, + 'None' if not self._left_wids else [w._wid_idx for w in self._left_wids], + 'None' if not self._top_wids else [w._wid_idx for w in self._top_wids], + 'None' if not self._right_wids else [w._wid_idx for w in self._right_wids], + 'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids], + self.pos, + self.size, + )) + touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True) + if (touch_ry <= self.height and + touch_ry >= self.height - self.split_size - self.split_margin or + touch_ry <= self.height and + touch_ry >= self.height - self.sp_zone and + touch_rx >= self.width//2 - self.sp_zone//2 and + touch_rx <= self.width//2 + self.sp_zone//2): + # split area is touched, we activate top split mode + self._split = "top" + self._draw_ellipse() + elif (touch_rx >= 0 and + touch_rx <= self.split_size + self.split_margin or + touch_rx >= 0 and + touch_rx <= self.sp_zone and + touch_ry >= self.height//2 - self.sp_zone//2 and + touch_ry <= self.height//2 + self.sp_zone//2): + # split area is touched, we activate left split mode + self._split = "left" + touch.ud['ori_width'] = self.width + self._draw_ellipse() + else: + if self.carousel_active and len(self.carousel.slides) <= 1: + # we don't want swipe of carousel if there is only one slide + return StencilView.on_touch_down(self.carousel, touch) + else: + return super(WHWrapper, self).on_touch_down(touch) + + def on_touch_move(self, touch): + """handle size change and widget creation on split""" + if self._split == 'None': + return super(WHWrapper, self).on_touch_move(touch) + + elif self._split == 'top': + new_height = touch.y - self.y + + if new_height < MIN_HEIGHT: + return + + # we must not pass the top widget/border + if self._top_wids: + top = next(iter(self._top_wids)) + y_limit = top.y + top.height + + if top.height <= REMOVE_WID_LIMIT: + # we are in remove zone, we add visual hint for that + if not self._split_del and self._top_wids: + self._split_del = True + self._draw_ellipse() + else: + if self._split_del: + self._split_del = False + self._draw_ellipse() + else: + y_limit = self.y + self.height + + if touch.y >= y_limit: + return + + # all right, we can change size + self.height = new_height + self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2) + + if not self._top_wids: + # we are the last widget on the top + # so we create a new widget + new_wid = self.parent.add_widget() + self._top_wids.add(new_wid) + new_wid._bottom_wids.add(self) + for w in self._right_wids: + new_wid._right_wids.add(w) + w._left_wids.add(new_wid) + for w in self._left_wids: + new_wid._left_wids.add(w) + w._right_wids.add(new_wid) + + elif self._split == 'left': + ori_width = touch.ud['ori_width'] + new_x = touch.x + new_width = ori_width - (touch.x - touch.ox) + + if new_width < MIN_WIDTH: + return + + # we must not pass the left widget/border + if self._left_wids: + left = next(iter(self._left_wids)) + x_limit = left.x + + if left.width <= REMOVE_WID_LIMIT: + # we are in remove zone, we add visual hint for that + if not self._split_del and self._left_wids: + self._split_del = True + self._draw_ellipse() + else: + if self._split_del: + self._split_del = False + self._draw_ellipse() + else: + x_limit = self.x + + if new_x <= x_limit: + return + + # all right, we can change position/size + self.x = new_x + self.width = new_width + self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1]) + + if not self._left_wids: + # we are the last widget on the left + # so we create a new widget + new_wid = self.parent.add_widget() + self._left_wids.add(new_wid) + new_wid._right_wids.add(self) + for w in self._top_wids: + new_wid._top_wids.add(w) + w._bottom_wids.add(new_wid) + for w in self._bottom_wids: + new_wid._bottom_wids.add(w) + w._top_wids.add(new_wid) + + else: + raise Exception.InternalError('invalid _split value') + + def on_touch_up(self, touch): + if self._split == 'None': + return super(WHWrapper, self).on_touch_up(touch) + if self._split == 'top': + # we remove all top widgets in delete zone, + # and update there side widgets list + for top in self._top_wids.copy(): + if top.height <= REMOVE_WID_LIMIT: + G.host._remove_visible_widget(top.current_slide) + for w in top._top_wids: + w._bottom_wids.remove(top) + w._bottom_wids.update(top._bottom_wids) + for w in top._bottom_wids: + w._top_wids.remove(top) + w._top_wids.update(top._top_wids) + for w in top._left_wids: + w._right_wids.remove(top) + for w in top._right_wids: + w._left_wids.remove(top) + self.parent.remove_widget(top) + elif self._split == 'left': + # we remove all left widgets in delete zone, + # and update there side widgets list + for left in self._left_wids.copy(): + if left.width <= REMOVE_WID_LIMIT: + G.host._remove_visible_widget(left.current_slide) + for w in left._left_wids: + w._right_wids.remove(left) + w._right_wids.update(left._right_wids) + for w in left._right_wids: + w._left_wids.remove(left) + w._left_wids.update(left._left_wids) + for w in left._top_wids: + w._bottom_wids.remove(left) + for w in left._bottom_wids: + w._top_wids.remove(left) + self.parent.remove_widget(left) + self._split = 'None' + self.canvas.after.remove(self.ellipse) + del self.ellipse + + def clear_widgets(self): + current_slide = self.current_slide + if current_slide is not None: + G.host._remove_visible_widget(current_slide, ignore_missing=True) + + super().clear_widgets() + + self.screen_manager = None + self.carousel = None + self._clear_attributes() + + def set_widget(self, wid, index=0): + assert len(self.children) == 0 + + if wid.collection_carousel or wid.global_screen_manager: + self.main_container = self + else: + self.main_container = BoxStencil() + self.add_widget(self.main_container) + + if self.carousel is not None: + return self.carousel.add_widget(wid, index) + + if wid.global_screen_manager: + if self.screen_manager is None: + self.screen_manager = ScreenManager() + self.main_container.add_widget(self.screen_manager) + parent = Screen() + self.screen_manager.add_widget(parent) + self._former_screen_name = '' + self.screen_manager.bind(current=self.on_screen_change) + wid.screen_manager_init(self.screen_manager) + else: + parent = self.main_container + + if wid.collection_carousel: + # a Carousel is requested, and this is the first widget that we add + # so we need to create the carousel + self.carousel = Carousel( + direction = "right", + ignore_perpendicular_swipes = True, + loop = True, + ) + self._slides_update_lock = 0 + self.carousel.bind(current_slide=self.on_slide_change) + parent.add_widget(self.carousel) + self.carousel.add_widget(wid, index) + else: + # no Carousel requested, we add the widget as a direct child + parent.add_widget(wid) + G.host._add_visible_widget(wid) + + def change_widget(self, new_widget): + """Change currently displayed widget + + slides widgets will be updated + """ + if (self.carousel is not None + and self.carousel.current_slide.__class__ == new_widget.__class__): + # we have the same class, we reuse carousel and screen manager setting + + if self.carousel.current_slide != new_widget: + # slides update need to be blocked to avoid the update in on_slide_change + # which would mess the removal of current widgets + self._slides_update_lock += 1 + new_wid = None + for w in self.carousel.slides[:]: + if w.widget_hash == new_widget.widget_hash: + new_wid = w + continue + self.carousel.remove_widget(w) + if isinstance(w, quick_widgets.QuickWidget): + G.host.widgets.delete_widget(w) + if new_wid is None: + new_wid = G.host.get_or_clone(new_widget) + self.carousel.add_widget(new_wid) + self._update_hidden_slides() + self._slides_update_lock -= 1 + + if self.screen_manager is not None: + self.screen_manager.clear_widgets([ + s for s in self.screen_manager.screens if s.name != '']) + new_wid.screen_manager_init(self.screen_manager) + else: + # else, we restart fresh + self.clear_widgets() + self.set_widget(G.host.get_or_clone(new_widget)) + + def on_screen_change(self, screen_manager, new_screen): + try: + new_screen_wid = self.current_slide + except IndexError: + new_screen_wid = None + log.warning("Switching to a screen without children") + if new_screen == '' and self.carousel is not None: + # carousel may have been changed in the background, so we update slides + self._update_hidden_slides() + former_screen_wid = self.former_screen_wid + if isinstance(former_screen_wid, cagou_widget.LiberviaDesktopKivyWidget): + G.host._remove_visible_widget(former_screen_wid) + if isinstance(new_screen_wid, cagou_widget.LiberviaDesktopKivyWidget): + G.host._add_visible_widget(new_screen_wid) + self._former_screen_name = new_screen + G.host.selected_widget = new_screen_wid + + def on_slide_change(self, handler, new_slide): + if self._former_slide is new_slide: + # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide + # binding now calls on_slide_change twice with the same widget (here + # "new_slide"). To be checked with Kivy team. + return + log.debug(f"Slide change: new_slide = {new_slide}") + if self._former_slide is not None: + G.host._remove_visible_widget(self._former_slide, ignore_missing=True) + self._former_slide = new_slide + if self.carousel_active: + G.host.selected_widget = new_slide + if new_slide is not None: + G.host._add_visible_widget(new_slide) + self._update_hidden_slides() + + def hidden_list(self, visible_list, ignore=None): + """return widgets of same class as carousel current one, if they are hidden + + @param visible_list(list[QuickWidget]): widgets visible + @param ignore(QuickWidget, None): do no return this widget + @return (iter[QuickWidget]): widgets hidden + """ + # we want to avoid recreated widgets + added = [w.widget_hash for w in visible_list] + current_slide = self.carousel.current_slide + for w in G.host.widgets.get_widgets(current_slide.__class__, + profiles=current_slide.profiles): + wid_hash = w.widget_hash + if w in visible_list or wid_hash in added: + continue + if wid_hash == ignore.widget_hash: + continue + yield w + + + def _update_hidden_slides(self): + """adjust carousel slides according to visible widgets""" + if self._slides_update_lock or not self.carousel_active: + return + current_slide = self.carousel.current_slide + if not isinstance(current_slide, quick_widgets.QuickWidget): + return + # lock must be used here to avoid recursions + self._slides_update_lock += 1 + visible_list = G.host.get_visible_list(current_slide.__class__) + # we ignore current_slide as it may not be visible yet (e.g. if an other + # screen is shown + hidden = list(self.hidden_list(visible_list, ignore=current_slide)) + slides_sorted = sorted(set(hidden + [current_slide])) + to_remove = set(self.carousel.slides).difference({current_slide}) + for w in to_remove: + self.carousel.remove_widget(w) + if hidden: + # no need to add more than two widgets (next and previous), + # as the list will be updated on each new visible widget + current_idx = slides_sorted.index(current_slide) + try: + next_slide = slides_sorted[current_idx+1] + except IndexError: + next_slide = slides_sorted[0] + self.carousel.add_widget(G.host.get_or_clone(next_slide)) + if len(hidden)>1: + previous_slide = slides_sorted[current_idx-1] + self.carousel.add_widget(G.host.get_or_clone(previous_slide)) + + self._slides_update_lock -= 1 + + +class WidgetsHandlerLayout(Layout): + count = 0 + + def __init__(self, **kwargs): + super(WidgetsHandlerLayout, self).__init__(**kwargs) + self._layout_size = None # size used for the last layout + fbind = self.fbind + update = self._trigger_layout + fbind('children', update) + fbind('parent', update) + fbind('size', self.adjust_prop) + fbind('pos', update) + + @property + def default_widget(self): + return G.host.default_wid['factory'](G.host.default_wid, None, None) + + def adjust_prop(self, handler, new_size): + """Adjust children proportion + + useful when this widget is resized (e.g. when going to fullscreen) + """ + if len(self.children) > 1: + old_width, old_height = self._layout_size + if not old_width or not old_height: + # we don't want division by zero + return self._trigger_layout(handler, new_size) + width_factor = float(self.width) / old_width + height_factor = float(self.height) / old_height + for child in self.children: + child.width *= width_factor + child.height *= height_factor + child.x *= width_factor + child.y *= height_factor + self._trigger_layout(handler, new_size) + + def do_layout(self, *args): + self._layout_size = self.size[:] + for child in self.children: + # XXX: left must be calculated before right and bottom before top + # because they are the pos, and are used to caculate size (right and top) + # left + left = child._left_wid + left_end_x = self.x-1 if left is None else left.right + if child.x != left_end_x + 1 and child._split == "None": + child.x = left_end_x + 1 + # right + right = child._right_wid + right_x = self.right + 1 if right is None else right.x + if child.right != right_x - 1: + child.width = right_x - child.x - 1 + # bottom + bottom = child._bottom_wid + if bottom is None: + if child.y != self.y: + child.y = self.y + else: + if child.y != bottom.top + 1: + child.y = bottom.top + 1 + # top + top = child._top_wid + top_y = self.top+1 if top is None else top.y + if child.top != top_y - 1: + if child._split == "None": + child.height = top_y - child.y - 1 + + def remove_widget(self, wid): + super(WidgetsHandlerLayout, self).remove_widget(wid) + log.debug("widget deleted ({})".format(wid._wid_idx)) + + def add_widget(self, wid=None, index=0): + WidgetsHandlerLayout.count += 1 + if wid is None: + wid = self.default_widget + if G.host.selected_widget is None: + G.host.selected_widget = wid + wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count) + log.debug("WHWrapper created ({})".format(wrapper._wid_idx)) + wrapper.set_widget(wid) + super(WidgetsHandlerLayout, self).add_widget(wrapper, index) + return wrapper + + +class WidgetsHandler(WidgetsHandlerLayout): + + def __init__(self, **kw): + super(WidgetsHandler, self).__init__(**kw) + self.add_widget()