scroll.py 21 KB

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