comparison 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
comparison
equal deleted inserted replaced
153:e0985834f8eb 154:a5e8833184c6
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 20
21 from sat.core import log as logging 21 from sat.core import log as logging
22 log = logging.getLogger(__name__) 22 log = logging.getLogger(__name__)
23 from sat.core import exceptions
23 from sat_frontends.quick_frontend import quick_widgets 24 from sat_frontends.quick_frontend import quick_widgets
25 from kivy.graphics import Color, Ellipse
26 from kivy.uix.layout import Layout
24 from kivy.uix.boxlayout import BoxLayout 27 from kivy.uix.boxlayout import BoxLayout
25 from kivy.uix.button import Button
26 from kivy.uix.carousel import Carousel
27 from kivy.metrics import dp 28 from kivy.metrics import dp
28 from kivy import properties 29 from kivy import properties
29 from cagou import G 30 from cagou import G
30 31
31
32 CAROUSEL_SCROLL_DISTANCE = dp(50) 32 CAROUSEL_SCROLL_DISTANCE = dp(50)
33 CAROUSEL_SCROLL_TIMEOUT = 80 33 CAROUSEL_SCROLL_TIMEOUT = 80
34 NEW_WIDGET_DIST = 10 34 REMOVE_WID_LIMIT = dp(10)
35 REMOVE_WIDGET_DIST = NEW_WIDGET_DIST 35 MIN_WIDTH = MIN_HEIGHT = dp(50)
36 36
37 37
38 class WHSplitter(Button): 38 class WHWrapper(BoxLayout):
39 horizontal=properties.BooleanProperty(True) 39 carousel = properties.ObjectProperty(None)
40 thickness=properties.NumericProperty(dp(20)) 40 split_size = properties.NumericProperty(dp(1))
41 split_move = None # we handle one split at a time, so we use a class attribute 41 split_margin = properties.NumericProperty(dp(2))
42 42 split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
43 def __init__(self, handler, **kwargs): 43 split_color_move = properties.ListProperty([0.0, 0.8, 0.8, 1])
44 super(WHSplitter, self).__init__(**kwargs) 44 split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1])
45 self.handler = handler 45 # sp stands for "split point"
46 46 sp_size = properties.NumericProperty(dp(1))
47 def getPos(self, touch): 47 sp_space = properties.NumericProperty(dp(4))
48 if self.horizontal: 48 sp_zone = properties.NumericProperty(dp(30))
49 relative_y = self.handler.to_local(*touch.pos, relative=True)[1] 49 _split = properties.OptionProperty('None', options=['None', 'left', 'top'])
50 return self.handler.height - relative_y 50 _split_del = properties.BooleanProperty(False)
51
52 def __init__(self, **kwargs):
53 idx = kwargs.pop('_wid_idx')
54 self._wid_idx = idx
55 super(WHWrapper, self).__init__(**kwargs)
56 self._former_slide = None
57 self.carousel.bind(current_slide=self.onSlideChange)
58 self._slides_update_lock = False
59 self._left_wids = set()
60 self._top_wids = set()
61 self._right_wids = set()
62 self._bottom_wids = set()
63
64 def __repr__(self):
65 return "WHWrapper_{idx}".format(idx=self._wid_idx)
66
67 def _main_wid(self, wid_list):
68 """return main widget of a side list
69
70 main widget is either the widget currently splitter
71 or any widget if none is split
72 @return (WHWrapper, None): main widget or None
73 if there is not widget
74 """
75 if not wid_list:
76 return None
77 for wid in wid_list:
78 if wid._split != 'None':
79 return wid
80 return next(iter(wid_list))
81
82 @property
83 def _left_wid(self):
84 return self._main_wid(self._left_wids)
85
86 @property
87 def _top_wid(self):
88 return self._main_wid(self._top_wids)
89
90 @property
91 def _right_wid(self):
92 return self._main_wid(self._right_wids)
93
94 @property
95 def _bottom_wid(self):
96 return self._main_wid(self._bottom_wids)
97
98 @property
99 def current_slide(self):
100 return self.carousel.current_slide
101
102 def _draw_ellipse(self):
103 """draw split ellipse"""
104 color = self.split_color_del if self._split_del else self.split_color_move
105 try:
106 self.canvas.after.remove(self.ellipse)
107 except AttributeError:
108 pass
109 if self._split == "top":
110 with self.canvas.after:
111 Color(*color)
112 self.ellipse = Ellipse(angle_start=90, angle_end=270,
113 pos=(self.x + self.width/2 - self.sp_zone/2,
114 self.y + self.height - self.sp_zone/2),
115 size=(self.sp_zone, self.sp_zone))
116 elif self._split == "left":
117 with self.canvas.after:
118 Color(*color)
119 self.ellipse = Ellipse(angle_end=180,
120 pos=(self.x + -self.sp_zone/2,
121 self.y + self.height/2 - self.sp_zone/2),
122 size = (self.sp_zone, self.sp_zone))
51 else: 123 else:
52 return touch.x 124 raise exceptions.InternalError('unexpected split value')
125
126 def on_touch_down(self, touch):
127 """activate split if touch is on a split zone"""
128 if not self.collide_point(*touch.pos):
129 return
130 log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format(
131 self._wid_idx,
132 'None' if not self._left_wids else [w._wid_idx for w in self._left_wids],
133 'None' if not self._top_wids else [w._wid_idx for w in self._top_wids],
134 'None' if not self._right_wids else [w._wid_idx for w in self._right_wids],
135 'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids],
136 self.pos,
137 self.size,
138 ))
139 touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True)
140 if (touch_ry <= self.height and
141 touch_ry >= self.height - self.split_size - self.split_margin or
142 touch_ry <= self.height and
143 touch_ry >= self.height - self.sp_zone and
144 touch_rx >= self.width/2 - self.sp_zone/2 and
145 touch_rx <= self.width/2 + self.sp_zone/2):
146 # split area is touched, we activate top split mode
147 self._split = "top"
148 self._draw_ellipse()
149 elif (touch_rx >= 0 and
150 touch_rx <= self.split_size + self.split_margin or
151 touch_rx >= 0 and
152 touch_rx <= self.sp_zone and
153 touch_ry >= self.height/2 - self.sp_zone/2 and
154 touch_ry <= self.height/2 + self.sp_zone/2):
155 # split area is touched, we activate left split mode
156 self._split = "left"
157 touch.ud['ori_width'] = self.width
158 self._draw_ellipse()
159 else:
160 return super(WHWrapper, self).on_touch_down(touch)
53 161
54 def on_touch_move(self, touch): 162 def on_touch_move(self, touch):
55 if self.split_move is None and self.collide_point(*touch.opos): 163 """handle size change and widget creation on split"""
56 WHSplitter.split_move = self 164 if self._split == 'None':
57 165 return super(WHWrapper, self).on_touch_move(touch)
58 if self.split_move is self: 166
59 pos = self.getPos(touch) 167 elif self._split == 'top':
60 if pos > NEW_WIDGET_DIST: 168 new_height = touch.y - self.y
61 # we are above minimal distance, we resize the widget 169
62 self.handler.setWidgetSize(self.horizontal, pos) 170 if new_height < MIN_HEIGHT:
171 return
172
173 # we must not pass the top widget/border
174 if self._top_wids:
175 top = next(iter(self._top_wids))
176 y_limit = top.y + top.height
177
178 if top.height <= REMOVE_WID_LIMIT:
179 # we are in remove zone, we add visual hint for that
180 if not self._split_del:
181 self._split_del = True
182 self._draw_ellipse()
183 else:
184 if self._split_del:
185 self._split_del = False
186 self._draw_ellipse()
187 else:
188 y_limit = self.y + self.height
189
190 if touch.y >= y_limit:
191 return
192
193 # all right, we can change size
194 self.height = new_height
195 self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2)
196
197 if not self._top_wids:
198 # we are the last widget on the top
199 # so we create a new widget
200 new_wid = self.parent.add_widget()
201 self._top_wids.add(new_wid)
202 new_wid._bottom_wids.add(self)
203 for w in self._right_wids:
204 new_wid._right_wids.add(w)
205 w._left_wids.add(new_wid)
206 for w in self._left_wids:
207 new_wid._left_wids.add(w)
208 w._right_wids.add(new_wid)
209
210 elif self._split == 'left':
211 ori_width = touch.ud['ori_width']
212 new_x = touch.x
213 new_width = ori_width - (touch.x - touch.ox)
214
215 if new_width < MIN_WIDTH:
216 return
217
218 # we must not pass the left widget/border
219 if self._left_wids:
220 left = next(iter(self._left_wids))
221 x_limit = left.x
222
223 if left.width <= REMOVE_WID_LIMIT:
224 # we are in remove zone, we add visual hint for that
225 if not self._split_del:
226 self._split_del = True
227 self._draw_ellipse()
228 else:
229 if self._split_del:
230 self._split_del = False
231 self._draw_ellipse()
232 else:
233 x_limit = self.x
234
235 if new_x <= x_limit:
236 return
237
238 # all right, we can change position/size
239 self.x = new_x
240 self.width = new_width
241 self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1])
242
243 if not self._left_wids:
244 # we are the last widget on the left
245 # so we create a new widget
246 new_wid = self.parent.add_widget()
247 self._left_wids.add(new_wid)
248 new_wid._right_wids.add(self)
249 for w in self._top_wids:
250 new_wid._top_wids.add(w)
251 w._bottom_wids.add(new_wid)
252 for w in self._bottom_wids:
253 new_wid._bottom_wids.add(w)
254 w._top_wids.add(new_wid)
255
256 else:
257 raise Exception.InternalError('invalid _split value')
63 258
64 def on_touch_up(self, touch): 259 def on_touch_up(self, touch):
65 if self.split_move is self: 260 if self._split == 'None':
66 pos = self.getPos(touch) 261 return super(WHWrapper, self).on_touch_up(touch)
67 if pos <= REMOVE_WIDGET_DIST: 262 if self._split == 'top':
68 # if we go under minimal distance, the widget is not wanted anymore 263 # we remove all top widgets in delete zone,
69 self.handler.removeWidget(self.horizontal) 264 # and update there side widgets list
70 WHSplitter.split_move=None 265 for top in self._top_wids.copy():
71 return super(WHSplitter, self).on_touch_up(touch) 266 if top.height <= REMOVE_WID_LIMIT:
72 267 for w in top._top_wids:
73 268 w._bottom_wids.remove(top)
74 class HandlerCarousel(Carousel): 269 w._bottom_wids.update(top._bottom_wids)
75 270 for w in top._bottom_wids:
76 def __init__(self, *args, **kwargs): 271 w._top_wids.remove(top)
77 super(HandlerCarousel, self).__init__( 272 w._top_wids.update(top._top_wids)
78 *args, 273 for w in top._left_wids:
79 direction='right', 274 w._right_wids.remove(top)
80 loop=True, 275 for w in top._right_wids:
81 **kwargs) 276 w._left_wids.remove(top)
82 self._former_slide = None 277 self.parent.remove_widget(top)
83 self.bind(current_slide=self.onSlideChange) 278 elif self._split == 'left':
84 self._slides_update_lock = False 279 # we remove all left widgets in delete zone,
280 # and update there side widgets list
281 for left in self._left_wids.copy():
282 if left.width <= REMOVE_WID_LIMIT:
283 for w in left._left_wids:
284 w._right_wids.remove(left)
285 w._right_wids.update(left._right_wids)
286 for w in left._right_wids:
287 w._left_wids.remove(left)
288 w._left_wids.update(left._left_wids)
289 for w in left._top_wids:
290 w._bottom_wids.remove(left)
291 for w in left._bottom_wids:
292 w._top_wids.remove(left)
293 self.parent.remove_widget(left)
294 self._split = 'None'
295 self.canvas.after.remove(self.ellipse)
296 del self.ellipse
297
298 def set_widget(self, wid, index=0):
299 self.carousel.add_widget(wid, index)
85 300
86 def changeWidget(self, new_widget): 301 def changeWidget(self, new_widget):
87 """Change currently displayed widget 302 """Change currently displayed widget
88 303
89 slides widgets will be updated 304 slides widgets will be updated
90 """ 305 """
91 # slides update need to be blocked to avoid the update in onSlideChange 306 # slides update need to be blocked to avoid the update in onSlideChange
92 # which would mess the removal of current widgets 307 # which would mess the removal of current widgets
93 self._slides_update_lock = True 308 self._slides_update_lock = True
94 current = self.current_slide 309 current = self.carousel.current_slide
95 for w in self.slides: 310 for w in self.carousel.slides:
96 if w == current or w == new_widget: 311 if w == current or w == new_widget:
97 continue 312 continue
98 if isinstance(w, quick_widgets.QuickWidget): 313 if isinstance(w, quick_widgets.QuickWidget):
99 G.host.widgets.deleteWidget(w) 314 G.host.widgets.deleteWidget(w)
100 self.clear_widgets() 315 self.carousel.clear_widgets()
101 self.add_widget(new_widget) 316 self.carousel.add_widget(new_widget)
102 self._slides_update_lock = False 317 self._slides_update_lock = False
103 self.updateHiddenSlides() 318 self.updateHiddenSlides()
104 319
105 def onSlideChange(self, handler, new_slide): 320 def onSlideChange(self, handler, new_slide):
106 if isinstance(self._former_slide, quick_widgets.QuickWidget): 321 if isinstance(self._former_slide, quick_widgets.QuickWidget):
136 351
137 def updateHiddenSlides(self): 352 def updateHiddenSlides(self):
138 """adjust carousel slides according to visible widgets""" 353 """adjust carousel slides according to visible widgets"""
139 if self._slides_update_lock: 354 if self._slides_update_lock:
140 return 355 return
141 if not isinstance(self.current_slide, quick_widgets.QuickWidget): 356 if not isinstance(self.carousel.current_slide, quick_widgets.QuickWidget):
142 return 357 return
143 # lock must be used here to avoid recursions 358 # lock must be used here to avoid recursions
144 self._slides_update_lock = True 359 self._slides_update_lock = True
145 visible_list = G.host.getVisibleList(self.current_slide.__class__) 360 visible_list = G.host.getVisibleList(self.current_slide.__class__)
146 hidden = list(self.hiddenList(visible_list)) 361 hidden = list(self.hiddenList(visible_list))
147 slides_sorted = sorted(hidden + [self.current_slide], key=self.widgets_sort) 362 slides_sorted = sorted(hidden + [self.carousel.current_slide], key=self.widgets_sort)
148 to_remove = set(self.slides).difference({self.current_slide}) 363 to_remove = set(self.carousel.slides).difference({self.carousel.current_slide})
149 for w in to_remove: 364 for w in to_remove:
150 self.remove_widget(w) 365 self.carousel.remove_widget(w)
151 if hidden: 366 if hidden:
152 # no need to add more than two widgets (next and previous), 367 # no need to add more than two widgets (next and previous),
153 # as the list will be updated on each new visible widget 368 # as the list will be updated on each new visible widget
154 current_idx = slides_sorted.index(self.current_slide) 369 current_idx = slides_sorted.index(self.current_slide)
155 try: 370 try:
156 next_slide = slides_sorted[current_idx+1] 371 next_slide = slides_sorted[current_idx+1]
157 except IndexError: 372 except IndexError:
158 next_slide = slides_sorted[0] 373 next_slide = slides_sorted[0]
159 self.add_widget(G.host.getOrClone(next_slide)) 374 self.carousel.add_widget(G.host.getOrClone(next_slide))
160 if len(hidden)>1: 375 if len(hidden)>1:
161 previous_slide = slides_sorted[current_idx-1] 376 previous_slide = slides_sorted[current_idx-1]
162 self.add_widget(G.host.getOrClone(previous_slide)) 377 self.carousel.add_widget(G.host.getOrClone(previous_slide))
163 378
164 if len(self.slides) == 1:
165 # we block carousel with high scroll_distance to avoid swiping
166 # when the is not other instance of the widget
167 self.scroll_distance=2**32
168 self.scroll_timeout=0
169 else:
170 self.scroll_distance = CAROUSEL_SCROLL_DISTANCE
171 self.scroll_timeout=CAROUSEL_SCROLL_TIMEOUT
172 self._slides_update_lock = False 379 self._slides_update_lock = False
173 380
174 381
175 class WidgetsHandler(BoxLayout): 382 class WidgetsHandlerLayout(Layout):
176 383 count = 0
177 def __init__(self, wid=None, **kw): 384
178 if wid is None: 385 def __init__(self, **kwargs):
179 wid=self.default_widget 386 super(WidgetsHandlerLayout, self).__init__(**kwargs)
180 self.vert_wid = self.hor_wid = None 387 fbind = self.fbind
181 BoxLayout.__init__(self, orientation="vertical", **kw) 388 update = self._trigger_layout
182 self.blh = BoxLayout(orientation="horizontal") 389 fbind('children', update)
183 self.blv = BoxLayout(orientation="vertical") 390 fbind('parent', update)
184 self.blv.add_widget(WHSplitter(self)) 391 fbind('size', update)
185 self.carousel = HandlerCarousel() 392 fbind('pos', update)
186 self.blv.add_widget(self.carousel)
187 self.blh.add_widget(WHSplitter(self, horizontal=False))
188 self.blh.add_widget(self.blv)
189 self.add_widget(self.blh)
190 self.changeWidget(wid)
191 393
192 @property 394 @property
193 def default_widget(self): 395 def default_widget(self):
194 return G.host.default_wid['factory'](G.host.default_wid, None, None) 396 return G.host.default_wid['factory'](G.host.default_wid, None, None)
195 397
398 def do_layout(self, *args):
399 x, y = self.pos
400 width, height = self.width, self.height
401 end_x, end_y = x + width, y + height
402 for child in self.children:
403 # XXX: left must be calculated before right and bottom before top
404 # because they are the pos, and are used to caculate size (right and top)
405 # left
406 left = child._left_wid
407 left_end_x = x-1 if left is None else left.x + left.width
408 if child.x != left_end_x + 1 and child._split == "None":
409 child.x = left_end_x + 1
410 # right
411 right = child._right_wid
412 right_x = end_x + 1 if right is None else right.x
413 if child.x + child.width != right_x - 1:
414 child.width = right_x - child.x - 1
415 # bottom
416 bottom = child._bottom_wid
417 if bottom is None:
418 if child.y != y:
419 child.y = y
420 else:
421 bottom_end_y = bottom.y + bottom.height
422 if child.y != bottom_end_y + 1:
423 child.y = bottom_end_y + 1
424 # top
425 top = child._top_wid
426 top_y = end_y+1 if top is None else top.y
427 if child.y + child.height != top_y - 1:
428 if child._split == "None":
429 child.height = top_y - child.y - 1
430
431 def remove_widget(self, wid):
432 super(WidgetsHandlerLayout, self).remove_widget(wid)
433 log.debug("widget deleted ({})".format(wid._wid_idx))
434
435 def add_widget(self, wid=None, index=0):
436 WidgetsHandlerLayout.count += 1
437 if wid is None:
438 wid = self.default_widget
439 wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count)
440 log.debug("WHWrapper created ({})".format(wrapper._wid_idx))
441 wrapper.set_widget(wid)
442 super(WidgetsHandlerLayout, self).add_widget(wrapper, index)
443 return wrapper
444
445
446 class WidgetsHandler(WidgetsHandlerLayout):
447
448 def __init__(self, **kw):
449 super(WidgetsHandler, self).__init__(**kw)
450 self.wrapper = self.add_widget()
451
196 @property 452 @property
197 def cagou_widget(self): 453 def cagou_widget(self):
198 """get holded CagouWidget""" 454 """get holded CagouWidget"""
199 return self.carousel.current_slide 455 return self.wrapper.current_slide
200
201 def changeWidget(self, new_widget):
202 self.carousel.changeWidget(new_widget)
203
204 def removeWidget(self, vertical):
205 if vertical and self.vert_wid is not None:
206 self.remove_widget(self.vert_wid)
207 self.vert_wid.onDelete()
208 self.vert_wid = None
209 elif self.hor_wid is not None:
210 self.blh.remove_widget(self.hor_wid)
211 self.hor_wid.onDelete()
212 self.hor_wid = None
213
214 def setWidgetSize(self, vertical, size):
215 if vertical:
216 if self.vert_wid is None:
217 self.vert_wid = WidgetsHandler(self.default_widget, size_hint=(1, None))
218 self.add_widget(self.vert_wid, len(self.children))
219 self.vert_wid.height=size
220 else:
221 if self.hor_wid is None:
222 self.hor_wid = WidgetsHandler(self.default_widget, size_hint=(None, 1))
223 self.blh.add_widget(self.hor_wid, len(self.blh.children))
224 self.hor_wid.width=size
225
226 def onDelete(self):
227 # when this handler is deleted, we need to delete the holded CagouWidget
228 cagou_widget = self.cagou_widget
229 if isinstance(cagou_widget, quick_widgets.QuickWidget):
230 G.host.removeVisibleWidget(cagou_widget)