Mercurial > libervia-desktop-kivy
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