/ nomadnet / vendor / Scrollable.py
Scrollable.py
  1  # This program is free software: you can redistribute it and/or modify
  2  # it under the terms of the GNU General Public License as published by
  3  # the Free Software Foundation, either version 3 of the License, or
  4  # (at your option) any later version.
  5  #
  6  # This program is distributed in the hope that it will be useful
  7  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  8  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  9  # GNU General Public License for more details
 10  # http://www.gnu.org/licenses/gpl-3.0.txt
 11  
 12  import urwid
 13  from urwid.widget import BOX, FIXED, FLOW
 14  
 15  # Scroll actions
 16  SCROLL_LINE_UP        = 'line up'
 17  SCROLL_LINE_DOWN      = 'line down'
 18  SCROLL_PAGE_UP        = 'page up'
 19  SCROLL_PAGE_DOWN      = 'page down'
 20  SCROLL_TO_TOP         = 'to top'
 21  SCROLL_TO_END         = 'to end'
 22  
 23  # Scrollbar positions
 24  SCROLLBAR_LEFT  = 'left'
 25  SCROLLBAR_RIGHT = 'right'
 26  
 27  class Scrollable(urwid.WidgetDecoration):
 28      def sizing(self):
 29          return frozenset([BOX,])
 30  
 31      def selectable(self):
 32          return True
 33  
 34      def __init__(self, widget, force_forward_keypress = False):
 35          """Box widget that makes a fixed or flow widget vertically scrollable
 36  
 37          TODO: Focusable widgets are handled, including switching focus, but
 38          possibly not intuitively, depending on the arrangement of widgets.  When
 39          switching focus to a widget that is ouside of the visible part of the
 40          original widget, the canvas scrolls up/down to the focused widget.  It
 41          would be better to scroll until the next focusable widget is in sight
 42          first.  But for that to work we must somehow obtain a list of focusable
 43          rows in the original canvas.
 44          """
 45          if not any(s in widget.sizing() for s in (FIXED, FLOW)):
 46              raise ValueError('Not a fixed or flow widget: %r' % widget)
 47          self._trim_top = 0
 48          self._scroll_action = None
 49          self._forward_keypress = None
 50          self._old_cursor_coords = None
 51          self._rows_max_cached = 0
 52          self.force_forward_keypress = force_forward_keypress
 53          super().__init__(widget)
 54  
 55      def render(self, size, focus=False):
 56          maxcol, maxrow = size
 57  
 58          # Render complete original widget
 59          ow = self._original_widget
 60          ow_size = self._get_original_widget_size(size)
 61          canv_full = ow.render(ow_size, focus)
 62  
 63          # Make full canvas editable
 64          canv = urwid.CompositeCanvas(canv_full)
 65          canv_cols, canv_rows = canv.cols(), canv.rows()
 66  
 67          if canv_cols <= maxcol:
 68              pad_width = maxcol - canv_cols
 69              if pad_width > 0:
 70                  # Canvas is narrower than available horizontal space
 71                  canv.pad_trim_left_right(0, pad_width)
 72  
 73          if canv_rows <= maxrow:
 74              fill_height = maxrow - canv_rows
 75              if fill_height > 0:
 76                  # Canvas is lower than available vertical space
 77                  canv.pad_trim_top_bottom(0, fill_height)
 78  
 79          if canv_cols <= maxcol and canv_rows <= maxrow:
 80              # Canvas is small enough to fit without trimming
 81              return canv
 82  
 83          self._adjust_trim_top(canv, size)
 84  
 85          # Trim canvas if necessary
 86          trim_top = self._trim_top
 87          trim_end = canv_rows - maxrow - trim_top
 88          trim_right = canv_cols - maxcol
 89          if trim_top > 0:
 90              canv.trim(trim_top)
 91          if trim_end > 0:
 92              canv.trim_end(trim_end)
 93          if trim_right > 0:
 94              canv.pad_trim_left_right(0, -trim_right)
 95  
 96          # Disable cursor display if cursor is outside of visible canvas parts
 97          if canv.cursor is not None:
 98              curscol, cursrow = canv.cursor
 99              if cursrow >= maxrow or cursrow < 0:
100                  canv.cursor = None
101  
102          # Figure out whether we should forward keypresses to original widget
103          if canv.cursor is not None:
104              # Trimmed canvas contains the cursor, e.g. in an Edit widget
105              self._forward_keypress = True
106          else:
107              if canv_full.cursor is not None:
108                  # Full canvas contains the cursor, but scrolled out of view
109                  self._forward_keypress = False
110                  
111                  # Reset cursor position on page/up down scrolling
112                  try:
113                      if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll:
114                          pwi = 0
115                          ch = 0
116                          last_hidden = False
117                          first_visible = False
118                          for w,o in ow.contents:
119                              wcanv = w.render((maxcol,))
120                              wh = wcanv.rows()
121                              if wh:
122                                  ch += wh
123  
124                              if not last_hidden and ch >= self._trim_top:
125                                  last_hidden = True
126  
127                              elif last_hidden:
128                                  if not first_visible:
129                                      first_visible = True
130  
131                                  if w.selectable():
132                                      ow.focus_item = pwi
133  
134                                      st = None
135                                      nf = ow.get_focus()
136                                      if hasattr(nf, "key_timeout"):
137                                          st = nf
138                                      elif hasattr(nf, "original_widget"):
139                                          no = nf.original_widget
140                                          if hasattr(no, "original_widget"):
141                                              st = no.original_widget
142                                          else:
143                                              if hasattr(no, "key_timeout"):
144                                                  st = no
145                                      
146                                      if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress):
147                                          st.keypress(None, None)
148                                      
149                                      break
150  
151                              pwi += 1
152                  except Exception as e:
153                      pass
154  
155              else:
156                  # Original widget does not have a cursor, but may be selectable
157  
158                  # FIXME: Using ow.selectable() is bad because the original
159                  # widget may be selectable because it's a container widget with
160                  # a key-grabbing widget that is scrolled out of view.
161                  # ow.selectable() returns True anyway because it doesn't know
162                  # how we trimmed our canvas.
163                  #
164                  # To fix this, we need to resolve ow.focus and somehow
165                  # ask canv whether it contains bits of the focused widget.  I
166                  # can't see a way to do that.
167                  if ow.selectable():
168                      self._forward_keypress = True
169                  else:
170                      self._forward_keypress = False
171  
172          return canv
173  
174      def keypress(self, size, key):
175          # Maybe offer key to original widget
176          if self._forward_keypress or self.force_forward_keypress:
177              ow = self._original_widget
178              ow_size = self._get_original_widget_size(size)
179  
180              # Remember previous cursor position if possible
181              if hasattr(ow, 'get_cursor_coords'):
182                  self._old_cursor_coords = ow.get_cursor_coords(ow_size)
183  
184              key = ow.keypress(ow_size, key)
185              if key is None:
186                  return None
187  
188          # Handle up/down, page up/down, etc
189          command_map = self._command_map
190          if command_map[key] == urwid.CURSOR_UP:
191              self._scroll_action = SCROLL_LINE_UP
192          elif command_map[key] == urwid.CURSOR_DOWN:
193              self._scroll_action = SCROLL_LINE_DOWN
194  
195          elif command_map[key] == urwid.CURSOR_PAGE_UP:
196              self._scroll_action = SCROLL_PAGE_UP
197          elif command_map[key] == urwid.CURSOR_PAGE_DOWN:
198              self._scroll_action = SCROLL_PAGE_DOWN
199  
200          elif command_map[key] == urwid.CURSOR_MAX_LEFT:   # 'home'
201              self._scroll_action = SCROLL_TO_TOP
202          elif command_map[key] == urwid.CURSOR_MAX_RIGHT:  # 'end'
203              self._scroll_action = SCROLL_TO_END
204  
205          else:
206              return key
207  
208          self._invalidate()
209  
210      def mouse_event(self, size, event, button, col, row, focus):
211          ow = self._original_widget
212          if hasattr(ow, 'mouse_event'):
213              ow_size = self._get_original_widget_size(size)
214              row += self._trim_top
215              return ow.mouse_event(ow_size, event, button, col, row, focus)
216          else:
217              return False
218  
219      def _adjust_trim_top(self, canv, size):
220          """Adjust self._trim_top according to self._scroll_action"""
221          action = self._scroll_action
222          self._scroll_action = None
223  
224          maxcol, maxrow = size
225          trim_top = self._trim_top
226          canv_rows = canv.rows()
227  
228          if trim_top < 0:
229              # Negative trim_top values use bottom of canvas as reference
230              trim_top = canv_rows - maxrow + trim_top + 1
231  
232          if canv_rows <= maxrow:
233              self._trim_top = 0  # Reset scroll position
234              return
235  
236          def ensure_bounds(new_trim_top):
237              return max(0, min(canv_rows - maxrow, new_trim_top))
238  
239          if action == SCROLL_LINE_UP:
240              self._trim_top = ensure_bounds(trim_top - 1)
241          elif action == SCROLL_LINE_DOWN:
242              self._trim_top = ensure_bounds(trim_top + 1)
243  
244          elif action == SCROLL_PAGE_UP:
245              self._trim_top = ensure_bounds(trim_top - maxrow + 1)
246          elif action == SCROLL_PAGE_DOWN:
247              self._trim_top = ensure_bounds(trim_top + maxrow - 1)
248  
249          elif action == SCROLL_TO_TOP:
250              self._trim_top = 0
251          elif action == SCROLL_TO_END:
252              self._trim_top = canv_rows - maxrow
253  
254          else:
255              self._trim_top = ensure_bounds(trim_top)
256  
257          # If the cursor was moved by the most recent keypress, adjust trim_top
258          # so that the new cursor position is within the displayed canvas part.
259          # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
260          if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None:
261              self._old_cursor_coords = None
262              curscol, cursrow = canv.cursor
263              if cursrow < self._trim_top:
264                  self._trim_top = cursrow
265              elif cursrow >= self._trim_top + maxrow:
266                  self._trim_top = max(0, cursrow - maxrow + 1)
267  
268      def _get_original_widget_size(self, size):
269          ow = self._original_widget
270          sizing = ow.sizing()
271          if FLOW in sizing:
272              return (size[0],)
273          elif FIXED in sizing:
274              return ()
275  
276      def get_scrollpos(self, size=None, focus=False):
277          """Current scrolling position
278  
279          Lower limit is 0, upper limit is the maximum number of rows with the
280          given maxcol minus maxrow.
281  
282          NOTE: The returned value may be too low or too high if the position has
283          changed but the widget wasn't rendered yet.
284          """
285          return self._trim_top
286  
287      def set_scrollpos(self, position):
288          """Set scrolling position
289  
290          If `position` is positive it is interpreted as lines from the top.
291          If `position` is negative it is interpreted as lines from the bottom.
292  
293          Values that are too high or too low values are automatically adjusted
294          during rendering.
295          """
296          self._trim_top = int(position)
297          self._invalidate()
298  
299      def rows_max(self, size=None, focus=False):
300          """Return the number of rows for `size`
301  
302          If `size` is not given, the currently rendered number of rows is returned.
303          """
304          if size is not None:
305              ow = self._original_widget
306              ow_size = self._get_original_widget_size(size)
307              sizing = ow.sizing()
308              if FIXED in sizing:
309                  self._rows_max_cached = ow.pack(ow_size, focus)[1]
310              elif FLOW in sizing:
311                  self._rows_max_cached = ow.rows(ow_size, focus)
312              else:
313                  raise RuntimeError('Not a flow/box widget: %r' % self._original_widget)
314          return self._rows_max_cached
315  
316  
317  class ScrollBar(urwid.WidgetDecoration):
318      def sizing(self):
319          return frozenset((BOX,))
320  
321      def selectable(self):
322          return True
323  
324      def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ',
325                   side=SCROLLBAR_RIGHT, width=1):
326          """Box widget that adds a scrollbar to `widget`
327  
328          `widget` must be a box widget with the following methods:
329            - `get_scrollpos` takes the arguments `size` and `focus` and returns
330              the index of the first visible row.
331            - `set_scrollpos` (optional; needed for mouse click support) takes the
332              index of the first visible row.
333            - `rows_max` takes `size` and `focus` and returns the total number of
334              rows `widget` can render.
335  
336          `thumb_char` is the character used for the scrollbar handle.
337          `trough_char` is used for the space above and below the handle.
338          `side` must be 'left' or 'right'.
339          `width` specifies the number of columns the scrollbar uses.
340          """
341          if BOX not in widget.sizing():
342              raise ValueError('Not a box widget: %r' % widget)
343          super().__init__(widget)
344          self._thumb_char = thumb_char
345          self._trough_char = trough_char
346          self.scrollbar_side = side
347          self.scrollbar_width = max(1, width)
348          self._original_widget_size = (0, 0)
349  
350      def render(self, size, focus=False):
351          maxcol, maxrow = size
352  
353          sb_width = self._scrollbar_width
354          ow_size = (max(0, maxcol - sb_width), maxrow)
355          sb_width = maxcol - ow_size[0]
356  
357          ow = self._original_widget
358          ow_base = self.scrolling_base_widget
359          ow_rows_max = ow_base.rows_max(size, focus)
360          if ow_rows_max <= maxrow:
361              # Canvas fits without scrolling - no scrollbar needed
362              self._original_widget_size = size
363              return ow.render(size, focus)
364          ow_rows_max = ow_base.rows_max(ow_size, focus)
365  
366          ow_canv = ow.render(ow_size, focus)
367          self._original_widget_size = ow_size
368  
369          pos = ow_base.get_scrollpos(ow_size, focus)
370          posmax = ow_rows_max - maxrow
371  
372          # Thumb shrinks/grows according to the ratio of
373          # <number of visible lines> / <number of total lines>
374          thumb_weight = min(1, maxrow / max(1, ow_rows_max))
375          thumb_height = max(1, round(thumb_weight * maxrow))
376  
377          # Thumb may only touch top/bottom if the first/last row is visible
378          top_weight = float(pos) / max(1, posmax)
379          top_height = int((maxrow - thumb_height) * top_weight)
380          if top_height == 0 and top_weight > 0:
381              top_height = 1
382  
383          # Bottom part is remaining space
384          bottom_height = maxrow - thumb_height - top_height
385          assert thumb_height + top_height + bottom_height == maxrow
386  
387          # Create scrollbar canvas
388          # Creating SolidCanvases of correct height may result in "cviews do not
389          # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!"
390          # exceptions. Stacking the same SolidCanvas is a workaround.
391          # https://github.com/urwid/urwid/issues/226#issuecomment-437176837
392          top = urwid.SolidCanvas(self._trough_char, sb_width, 1)
393          thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1)
394          bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1)
395          sb_canv = urwid.CanvasCombine(
396              [(top, None, False)] * top_height +
397              [(thumb, None, False)] * thumb_height +
398              [(bottom, None, False)] * bottom_height,
399          )
400  
401          combinelist = [(ow_canv, None, True, ow_size[0]),
402                         (sb_canv, None, False, sb_width)]
403          if self._scrollbar_side != SCROLLBAR_LEFT:
404              return urwid.CanvasJoin(combinelist)
405          else:
406              return urwid.CanvasJoin(reversed(combinelist))
407  
408      @property
409      def scrollbar_width(self):
410          """Columns the scrollbar uses"""
411          return max(1, self._scrollbar_width)
412  
413      @scrollbar_width.setter
414      def scrollbar_width(self, width):
415          self._scrollbar_width = max(1, int(width))
416          self._invalidate()
417  
418      @property
419      def scrollbar_side(self):
420          """Where to display the scrollbar; must be 'left' or 'right'"""
421          return self._scrollbar_side
422  
423      @scrollbar_side.setter
424      def scrollbar_side(self, side):
425          if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT):
426              raise ValueError('scrollbar_side must be "left" or "right", not %r' % side)
427          self._scrollbar_side = side
428          self._invalidate()
429  
430      @property
431      def scrolling_base_widget(self):
432          """Nearest `original_widget` that is compatible with the scrolling API"""
433          def orig_iter(w):
434              while hasattr(w, 'original_widget'):
435                  w = w.original_widget
436                  yield w
437              yield w
438  
439          def is_scrolling_widget(w):
440              return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max')
441  
442          for w in orig_iter(self):
443              if is_scrolling_widget(w):
444                  return w
445          raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w)
446  
447      def keypress(self, size, key):
448          return self._original_widget.keypress(self._original_widget_size, key)
449  
450      def mouse_event(self, size, event, button, col, row, focus):
451          ow = self._original_widget
452          ow_size = self._original_widget_size
453          handled = False
454          if hasattr(ow, 'mouse_event'):
455              handled = ow.mouse_event(ow_size, event, button, col, row, focus)
456  
457          if not handled and hasattr(ow, 'set_scrollpos'):
458              if button == 4:    # scroll wheel up
459                  pos = ow.get_scrollpos(ow_size)
460                  newpos = pos - 1
461                  if newpos < 0:
462                      newpos = 0
463                  ow.set_scrollpos(newpos)
464                  return True
465              elif button == 5:  # scroll wheel down
466                  pos = ow.get_scrollpos(ow_size)
467                  ow.set_scrollpos(pos + 1)
468                  return True
469  
470          return False