diff cagou/core/widgets_handler.py @ 154:a5e8833184c6

widget handler: refactoring: - replaced proof of concept implementation with cleaner one based on custom layout - removed proof of concept big bars in favor of thin line to separate widgets, with a 3 dots area in the center where user can touch/click more easily - when in delete zone, the line + half circle become red, so user knows that she's about to delete a widget - carousel is now created in kv - ignore perpendicular swipes. This was not working before but is know working well, and the swipe is far more easy to do on desktop or mobile - each new widget of the handler has an id (its creation number), which is displayed in debug logs on touch - handler's widgets keep track of which widgets are on sides (left, top, right, bottom)
author Goffi <goffi@goffi.org>
date Fri, 27 Apr 2018 16:45:09 +0200
parents cd99f70ea592
children a0e486074d91
line wrap: on
line diff
--- a/cagou/core/widgets_handler.py	Fri Apr 27 16:37:15 2018 +0200
+++ b/cagou/core/widgets_handler.py	Fri Apr 27 16:45:09 2018 +0200
@@ -20,68 +20,283 @@
 
 from sat.core import log as logging
 log = logging.getLogger(__name__)
+from sat.core import exceptions
 from sat_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.button import Button
-from kivy.uix.carousel import Carousel
 from kivy.metrics import dp
 from kivy import properties
 from cagou import G
 
-
 CAROUSEL_SCROLL_DISTANCE = dp(50)
 CAROUSEL_SCROLL_TIMEOUT = 80
-NEW_WIDGET_DIST = 10
-REMOVE_WIDGET_DIST = NEW_WIDGET_DIST
+REMOVE_WID_LIMIT = dp(10)
+MIN_WIDTH = MIN_HEIGHT = dp(50)
 
 
-class WHSplitter(Button):
-    horizontal=properties.BooleanProperty(True)
-    thickness=properties.NumericProperty(dp(20))
-    split_move = None # we handle one split at a time, so we use a class attribute
+class WHWrapper(BoxLayout):
+    carousel = properties.ObjectProperty(None)
+    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 = properties.ListProperty([0.0, 0.8, 0.8, 1])
+    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._former_slide = None
+        self.carousel.bind(current_slide=self.onSlideChange)
+        self._slides_update_lock = False
+        self._left_wids = set()
+        self._top_wids = set()
+        self._right_wids = set()
+        self._bottom_wids = set()
+
+    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 splitter
+        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))
+
+    @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)
 
-    def __init__(self, handler, **kwargs):
-        super(WHSplitter, self).__init__(**kwargs)
-        self.handler = handler
+    @property
+    def current_slide(self):
+        return self.carousel.current_slide
+
+    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 getPos(self, touch):
-        if self.horizontal:
-            relative_y = self.handler.to_local(*touch.pos, relative=True)[1]
-            return self.handler.height - relative_y
+    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:
-            return touch.x
+            return super(WHWrapper, self).on_touch_down(touch)
 
     def on_touch_move(self, touch):
-        if self.split_move is None and self.collide_point(*touch.opos):
-            WHSplitter.split_move = self
+        """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:
+                        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)
 
-        if self.split_move is self:
-            pos = self.getPos(touch)
-            if pos > NEW_WIDGET_DIST:
-                # we are above minimal distance, we resize the widget
-                self.handler.setWidgetSize(self.horizontal, pos)
+        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:
+                        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_move is self:
-            pos = self.getPos(touch)
-            if pos <= REMOVE_WIDGET_DIST:
-                # if we go under minimal distance, the widget is not wanted anymore
-                self.handler.removeWidget(self.horizontal)
-            WHSplitter.split_move=None
-        return super(WHSplitter, self).on_touch_up(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:
+                    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:
+                    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
 
-class HandlerCarousel(Carousel):
-
-    def __init__(self, *args, **kwargs):
-        super(HandlerCarousel, self).__init__(
-            *args,
-            direction='right',
-            loop=True,
-            **kwargs)
-        self._former_slide = None
-        self.bind(current_slide=self.onSlideChange)
-        self._slides_update_lock = False
+    def set_widget(self, wid, index=0):
+        self.carousel.add_widget(wid, index)
 
     def changeWidget(self, new_widget):
         """Change currently displayed widget
@@ -91,14 +306,14 @@
         # slides update need to be blocked to avoid the update in onSlideChange
         # which would mess the removal of current widgets
         self._slides_update_lock = True
-        current = self.current_slide
-        for w in self.slides:
+        current = self.carousel.current_slide
+        for w in self.carousel.slides:
             if w == current or w == new_widget:
                 continue
             if isinstance(w, quick_widgets.QuickWidget):
                 G.host.widgets.deleteWidget(w)
-        self.clear_widgets()
-        self.add_widget(new_widget)
+        self.carousel.clear_widgets()
+        self.carousel.add_widget(new_widget)
         self._slides_update_lock = False
         self.updateHiddenSlides()
 
@@ -138,16 +353,16 @@
         """adjust carousel slides according to visible widgets"""
         if self._slides_update_lock:
             return
-        if not isinstance(self.current_slide, quick_widgets.QuickWidget):
+        if not isinstance(self.carousel.current_slide, quick_widgets.QuickWidget):
             return
         # lock must be used here to avoid recursions
         self._slides_update_lock = True
         visible_list = G.host.getVisibleList(self.current_slide.__class__)
         hidden = list(self.hiddenList(visible_list))
-        slides_sorted =  sorted(hidden + [self.current_slide], key=self.widgets_sort)
-        to_remove = set(self.slides).difference({self.current_slide})
+        slides_sorted =  sorted(hidden + [self.carousel.current_slide], key=self.widgets_sort)
+        to_remove = set(self.carousel.slides).difference({self.carousel.current_slide})
         for w in to_remove:
-            self.remove_widget(w)
+            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
@@ -156,75 +371,85 @@
                 next_slide = slides_sorted[current_idx+1]
             except IndexError:
                 next_slide = slides_sorted[0]
-            self.add_widget(G.host.getOrClone(next_slide))
+            self.carousel.add_widget(G.host.getOrClone(next_slide))
             if len(hidden)>1:
                 previous_slide = slides_sorted[current_idx-1]
-                self.add_widget(G.host.getOrClone(previous_slide))
+                self.carousel.add_widget(G.host.getOrClone(previous_slide))
 
-        if len(self.slides) == 1:
-            # we block carousel with high scroll_distance to avoid swiping
-            # when the is not other instance of the widget
-            self.scroll_distance=2**32
-            self.scroll_timeout=0
-        else:
-            self.scroll_distance = CAROUSEL_SCROLL_DISTANCE
-            self.scroll_timeout=CAROUSEL_SCROLL_TIMEOUT
         self._slides_update_lock = False
 
 
-class WidgetsHandler(BoxLayout):
+class WidgetsHandlerLayout(Layout):
+    count = 0
 
-    def __init__(self, wid=None, **kw):
-        if wid is None:
-            wid=self.default_widget
-        self.vert_wid = self.hor_wid = None
-        BoxLayout.__init__(self, orientation="vertical", **kw)
-        self.blh = BoxLayout(orientation="horizontal")
-        self.blv = BoxLayout(orientation="vertical")
-        self.blv.add_widget(WHSplitter(self))
-        self.carousel = HandlerCarousel()
-        self.blv.add_widget(self.carousel)
-        self.blh.add_widget(WHSplitter(self, horizontal=False))
-        self.blh.add_widget(self.blv)
-        self.add_widget(self.blh)
-        self.changeWidget(wid)
+    def __init__(self, **kwargs):
+        super(WidgetsHandlerLayout, self).__init__(**kwargs)
+        fbind = self.fbind
+        update = self._trigger_layout
+        fbind('children', update)
+        fbind('parent', update)
+        fbind('size', update)
+        fbind('pos', update)
 
     @property
     def default_widget(self):
         return G.host.default_wid['factory'](G.host.default_wid, None, None)
 
+    def do_layout(self, *args):
+        x, y = self.pos
+        width, height = self.width, self.height
+        end_x, end_y = x + width, y + height
+        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 = x-1 if left is None else left.x + left.width
+            if child.x != left_end_x + 1 and child._split == "None":
+                child.x = left_end_x + 1
+            # right
+            right = child._right_wid
+            right_x = end_x + 1 if right is None else right.x
+            if child.x + child.width != right_x - 1:
+                child.width = right_x - child.x - 1
+            # bottom
+            bottom = child._bottom_wid
+            if bottom is None:
+                if child.y != y:
+                    child.y = y
+            else:
+                bottom_end_y = bottom.y + bottom.height
+                if child.y != bottom_end_y + 1:
+                    child.y = bottom_end_y + 1
+            # top
+            top = child._top_wid
+            top_y = end_y+1 if top is None else top.y
+            if child.y + child.height != 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
+        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.wrapper = self.add_widget()
+
     @property
     def cagou_widget(self):
         """get holded CagouWidget"""
-        return self.carousel.current_slide
-
-    def changeWidget(self, new_widget):
-        self.carousel.changeWidget(new_widget)
-
-    def removeWidget(self, vertical):
-        if vertical and self.vert_wid is not None:
-            self.remove_widget(self.vert_wid)
-            self.vert_wid.onDelete()
-            self.vert_wid = None
-        elif self.hor_wid is not None:
-            self.blh.remove_widget(self.hor_wid)
-            self.hor_wid.onDelete()
-            self.hor_wid = None
-
-    def setWidgetSize(self, vertical, size):
-        if vertical:
-            if self.vert_wid is None:
-                self.vert_wid = WidgetsHandler(self.default_widget, size_hint=(1, None))
-                self.add_widget(self.vert_wid, len(self.children))
-            self.vert_wid.height=size
-        else:
-            if self.hor_wid is None:
-                self.hor_wid = WidgetsHandler(self.default_widget, size_hint=(None, 1))
-                self.blh.add_widget(self.hor_wid, len(self.blh.children))
-            self.hor_wid.width=size
-
-    def onDelete(self):
-        # when this handler is deleted, we need to delete the holded CagouWidget
-        cagou_widget = self.cagou_widget
-        if isinstance(cagou_widget, quick_widgets.QuickWidget):
-            G.host.removeVisibleWidget(cagou_widget)
+        return self.wrapper.current_slide