| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573 |
- # This file is part of DarkFi (https://dark.fi)
- #
- # Copyright (C) 2020-2023 Dyne.org foundation
- #
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU Affero General Public License as
- # published by the Free Software Foundation, either version 3 of the
- # License, or (at your option) any later version.
- #
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU Affero General Public License for more details.
- #
- # You should have received a copy of the GNU Affero General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- import urwid
- from urwid.widget import (BOX, FLOW, FIXED)
- # Scroll actions
- SCROLL_LINE_UP = 'line up'
- SCROLL_LINE_DOWN = 'line down'
- SCROLL_PAGE_UP = 'page up'
- SCROLL_PAGE_DOWN = 'page down'
- SCROLL_TO_TOP = 'to top'
- SCROLL_TO_END = 'to end'
- # Scrollbar positions
- SCROLLBAR_LEFT = 'left'
- SCROLLBAR_RIGHT = 'right'
- # Add support for ScrollBar class (see stig.tui.scroll)
- # https://github.com/urwid/urwid/issues/226
- class ListBox_patched(urwid.ListBox):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._rows_max = None
- def _invalidate(self):
- super()._invalidate()
- self._rows_max = None
- def get_scrollpos(self, size, focus=False):
- """Current scrolling position
- Lower limit is 0, upper limit is the highest index of `body`.
- """
- middle, top, bottom = self.calculate_visible(size, focus)
- if middle is None:
- return 0
- else:
- offset_rows, _, focus_pos, _, _ = middle
- maxcol, maxrow = size
- flow_size = (maxcol,)
- body = self.body
- if hasattr(body, 'positions'):
- # For body[pos], pos can be anything, not just an int. In that
- # case, the positions() method returns an interable of valid
- # positions.
- positions = tuple(self.body.positions())
- focus_index = positions.index(focus_pos)
- widgets_above_focus = (body[pos] for pos in positions[:focus_index])
- else:
- # Treat body like a normal list
- widgets_above_focus = (w for w in body[:focus_pos])
- rows_above_focus = sum(w.rows(flow_size) for w in widgets_above_focus)
- rows_above_top = rows_above_focus - offset_rows
- return rows_above_top
- def rows_max(self, size, focus=False):
- if self._rows_max is None:
- flow_size = (size[0],)
- body = self.body
- if hasattr(body, 'positions'):
- self._rows_max = sum(body[pos].rows(flow_size) for pos in body.positions())
- else:
- self._rows_max = sum(w.rows(flow_size) for w in self.body)
- return self._rows_max
- urwid.ListBox = ListBox_patched
- class Scrollable(urwid.WidgetDecoration):
- def sizing(self):
- return frozenset([BOX,])
- def selectable(self):
- return True
- def __init__(self, widget):
- """Box widget that makes a fixed or flow widget vertically scrollable
- TODO: Focusable widgets are handled, including switching focus, but
- possibly not intuitively, depending on the arrangement of widgets. When
- switching focus to a widget that is ouside of the visible part of the
- original widget, the canvas scrolls up/down to the focused widget. It
- would be better to scroll until the next focusable widget is in sight
- first. But for that to work we must somehow obtain a list of focusable
- rows in the original canvas.
- """
- if not any(s in widget.sizing() for s in (FIXED, FLOW)):
- raise ValueError('Not a fixed or flow widget: %r' % widget)
- self._trim_top = 0
- self._scroll_action = None
- self._forward_keypress = None
- self._old_cursor_coords = None
- self._rows_max_cached = 0
- self.__super.__init__(widget)
- def render(self, size, focus=False):
- maxcol, maxrow = size
- # Render complete original widget
- ow = self._original_widget
- ow_size = self._get_original_widget_size(size)
- canv_full = ow.render(ow_size, focus)
- # Make full canvas editable
- canv = urwid.CompositeCanvas(canv_full)
- canv_cols, canv_rows = canv.cols(), canv.rows()
- if canv_cols <= maxcol:
- pad_width = maxcol - canv_cols
- if pad_width > 0:
- # Canvas is narrower than available horizontal space
- canv.pad_trim_left_right(0, pad_width)
- if canv_rows <= maxrow:
- fill_height = maxrow - canv_rows
- if fill_height > 0:
- # Canvas is lower than available vertical space
- canv.pad_trim_top_bottom(0, fill_height)
- if canv_cols <= maxcol and canv_rows <= maxrow:
- # Canvas is small enough to fit without trimming
- return canv
- self._adjust_trim_top(canv, size)
- # Trim canvas if necessary
- trim_top = self._trim_top
- trim_end = canv_rows - maxrow - trim_top
- trim_right = canv_cols - maxcol
- if trim_top > 0:
- canv.trim(trim_top)
- if trim_end > 0:
- canv.trim_end(trim_end)
- if trim_right > 0:
- canv.pad_trim_left_right(0, -trim_right)
- # Disable cursor display if cursor is outside of visible canvas parts
- if canv.cursor is not None:
- curscol, cursrow = canv.cursor
- if cursrow >= maxrow or cursrow < 0:
- canv.cursor = None
- # Figure out whether we should forward keypresses to original widget
- if canv.cursor is not None:
- # Trimmed canvas contains the cursor, e.g. in an Edit widget
- self._forward_keypress = True
- else:
- if canv_full.cursor is not None:
- # Full canvas contains the cursor, but scrolled out of view
- self._forward_keypress = False
- else:
- # Original widget does not have a cursor, but may be selectable
- # FIXME: Using ow.selectable() is bad because the original
- # widget may be selectable because it's a container widget with
- # a key-grabbing widget that is scrolled out of view.
- # ow.selectable() returns True anyway because it doesn't know
- # how we trimmed our canvas.
- #
- # To fix this, we need to resolve ow.focus and somehow
- # ask canv whether it contains bits of the focused widget. I
- # can't see a way to do that.
- if ow.selectable():
- self._forward_keypress = True
- else:
- self._forward_keypress = False
- return canv
- def keypress(self, size, key):
- # Maybe offer key to original widget
- if self._forward_keypress:
- ow = self._original_widget
- ow_size = self._get_original_widget_size(size)
- # Remember previous cursor position if possible
- if hasattr(ow, 'get_cursor_coords'):
- self._old_cursor_coords = ow.get_cursor_coords(ow_size)
- key = ow.keypress(ow_size, key)
- if key is None:
- return None
- # Handle up/down, page up/down, etc
- command_map = self._command_map
- if command_map[key] == urwid.CURSOR_UP:
- self._scroll_action = SCROLL_LINE_UP
- elif command_map[key] == urwid.CURSOR_DOWN:
- self._scroll_action = SCROLL_LINE_DOWN
- elif command_map[key] == urwid.CURSOR_PAGE_UP:
- self._scroll_action = SCROLL_PAGE_UP
- elif command_map[key] == urwid.CURSOR_PAGE_DOWN:
- self._scroll_action = SCROLL_PAGE_DOWN
- elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home'
- self._scroll_action = SCROLL_TO_TOP
- elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end'
- self._scroll_action = SCROLL_TO_END
- else:
- return key
- self._invalidate()
- def mouse_event(self, size, event, button, col, row, focus):
- ow = self._original_widget
- if hasattr(ow, 'mouse_event'):
- ow_size = self._get_original_widget_size(size)
- row += self._trim_top
- return ow.mouse_event(ow_size, event, button, col, row, focus)
- else:
- return False
- def _adjust_trim_top(self, canv, size):
- """Adjust self._trim_top according to self._scroll_action"""
- action = self._scroll_action
- self._scroll_action = None
- maxcol, maxrow = size
- trim_top = self._trim_top
- canv_rows = canv.rows()
- if trim_top < 0:
- # Negative trim_top values use bottom of canvas as reference
- trim_top = canv_rows - maxrow + trim_top + 1
- if canv_rows <= maxrow:
- self._trim_top = 0 # Reset scroll position
- return
- def ensure_bounds(new_trim_top):
- return max(0, min(canv_rows - maxrow, new_trim_top))
- if action == SCROLL_LINE_UP:
- self._trim_top = ensure_bounds(trim_top - 1)
- elif action == SCROLL_LINE_DOWN:
- self._trim_top = ensure_bounds(trim_top + 1)
- elif action == SCROLL_PAGE_UP:
- self._trim_top = ensure_bounds(trim_top - maxrow+1)
- elif action == SCROLL_PAGE_DOWN:
- self._trim_top = ensure_bounds(trim_top + maxrow-1)
- elif action == SCROLL_TO_TOP:
- self._trim_top = 0
- elif action == SCROLL_TO_END:
- self._trim_top = canv_rows - maxrow
- else:
- self._trim_top = ensure_bounds(trim_top)
- # If the cursor was moved by the most recent keypress, adjust trim_top
- # so that the new cursor position is within the displayed canvas part.
- # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
- if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor:
- self._old_cursor_coords = None
- curscol, cursrow = canv.cursor
- if cursrow < self._trim_top:
- self._trim_top = cursrow
- elif cursrow >= self._trim_top + maxrow:
- self._trim_top = max(0, cursrow - maxrow + 1)
- def _get_original_widget_size(self, size):
- ow = self._original_widget
- sizing = ow.sizing()
- if FIXED in sizing:
- return ()
- elif FLOW in sizing:
- return (size[0],)
- def get_scrollpos(self, size=None, focus=False):
- """Current scrolling position
- Lower limit is 0, upper limit is the maximum number of rows with the
- given maxcol minus maxrow.
- NOTE: The returned value may be too low or too high if the position has
- changed but the widget wasn't rendered yet.
- """
- return self._trim_top
- def set_scrollpos(self, position):
- """Set scrolling position
- If `position` is positive it is interpreted as lines from the top.
- If `position` is negative it is interpreted as lines from the bottom.
- Values that are too high or too low values are automatically adjusted
- during rendering.
- """
- self._trim_top = int(position)
- self._invalidate()
- def rows_max(self, size=None, focus=False):
- """Return the number of rows for `size`
- If `size` is not given, the currently rendered number of rows is returned.
- """
- if size is not None:
- ow = self._original_widget
- ow_size = self._get_original_widget_size(size)
- sizing = ow.sizing()
- if FIXED in sizing:
- self._rows_max_cached = ow.pack(ow_size, focus)[1]
- elif FLOW in sizing:
- self._rows_max_cached = ow.rows(ow_size, focus)
- else:
- raise RuntimeError('Not a flow/box widget: %r' % self._original_widget)
- return self._rows_max_cached
- DEFAULT_THUMB_CHAR = '\u2588'
- DEFAULT_TROUGH_CHAR = " "
- DEFAULT_SIDE = SCROLLBAR_RIGHT
- class ScrollBar(urwid.WidgetDecoration):
- _thumb_char = DEFAULT_THUMB_CHAR
- _trough_char = DEFAULT_TROUGH_CHAR
- _thumb_indicator_top = None
- _thumb_indicator_bottom = None
- _scroll_bar_side = DEFAULT_SIDE
- def sizing(self):
- return frozenset((BOX,))
- def selectable(self):
- return True
- def __init__(self, widget,
- thumb_char=None, trough_char=None,
- thumb_indicator_top=None, thumb_indicator_bottom=None,
- side=DEFAULT_SIDE, width=1,
- always_visible=False):
- """Box widget that adds a scrollbar to `widget`
- `widget` must be a box widget with the following methods:
- - `get_scrollpos` takes the arguments `size` and `focus` and returns
- the index of the first visible row.
- - `set_scrollpos` (optional; needed for mouse click support) takes the
- index of the first visible row.
- - `rows_max` takes `size` and `focus` and returns the total number of
- rows `widget` can render.
- `thumb_char` is the character used for the scrollbar handle.
- `trough_char` is used for the space above and below the handle.
- `side` must be 'left' or 'right'.
- `width` specifies the number of columns the scrollbar uses.
- `always_visible` will always draw the scrollbar, even when unnecessary.
- """
- if BOX not in widget.sizing():
- raise ValueError('Not a box widget: %r' % widget)
- self.__super.__init__(widget)
- if thumb_char is not None:
- self._thumb_char = thumb_char
- if trough_char is not None:
- self._trough_char = trough_char
- if thumb_indicator_top is not None:
- self._thumb_indicator_top = thumb_indicator_top
- if thumb_indicator_bottom is not None:
- self._thumb_indicator_bottom = thumb_indicator_bottom
- self.scrollbar_side = side
- self.scrollbar_width = max(1, width)
- self.always_visible = always_visible
- self._original_widget_size = (0, 0)
- def render(self, size, focus=False):
- maxcol, maxrow = size
- sb_width = self._scrollbar_width
- ow_size = (max(0, maxcol - sb_width), maxrow)
- sb_width = maxcol - ow_size[0]
- ow = self._original_widget
- ow_base = self.scrolling_base_widget
- if not self.always_visible:
- ow_rows_max = ow_base.rows_max(size, focus)
- if ow_rows_max <= maxrow:
- # Canvas fits without scrolling - no scrollbar needed
- self._original_widget_size = size
- return ow.render(size, focus)
- ow_rows_max = ow_base.rows_max(ow_size, focus)
- ow_canv = ow.render(ow_size, focus)
- self._original_widget_size = ow_size
- pos = ow_base.get_scrollpos(ow_size, focus)
- posmax = ow_rows_max - maxrow
- # Thumb shrinks/grows according to the ratio of
- # <number of visible lines> / <number of total lines>
- thumb_weight = min(1, maxrow / max(1, ow_rows_max))
- thumb_height = max(1, round(thumb_weight * maxrow))
- # Thumb may only touch top/bottom if the first/last row is visible
- top_weight = float(pos) / max(1, posmax)
- top_height = int((maxrow-thumb_height) * top_weight)
- if top_height == 0 and top_weight > 0:
- top_height = 1
- # Bottom part is remaining space
- bottom_height = maxrow - thumb_height - top_height
- assert thumb_height + top_height + bottom_height == maxrow
- # Create scrollbar canvas
- # Creating SolidCanvases of correct height may result in "cviews do not
- # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!"
- # exceptions. Stacking the same SolidCanvas is a workaround.
- # https://github.com/urwid/urwid/issues/226#issuecomment-437176837
- thumb_top = thumb_bottom = None
- if (self._thumb_indicator_top
- or self._thumb_indicator_bottom) and hasattr(ow.body, "positions"):
- if hasattr(ow.body, "focus"):
- pos = ow.body.focus
- elif hasattr(ow.body, "get_focus"):
- pos = ow.body.get_focus()[1]
- try:
- head = next(iter(ow.body.positions()))
- except StopIteration:
- head = None
- if pos == head:
- if isinstance(self._thumb_indicator_top, tuple):
- attr, char = self._thumb_indicator_top
- else:
- attr, char = None, self._thumb_indicator_top
- if char:
- thumb_top = urwid.Text(
- (attr, char * sb_width),
- wrap="any"
- ).render((sb_width,))
- if thumb_height:
- thumb_height -= 1
- try:
- tail = next(iter(ow.body.positions(reverse=True)))
- except StopIteration:
- tail = None
- if pos == tail:
- if isinstance(self._thumb_indicator_bottom, tuple):
- attr, char = self._thumb_indicator_bottom
- else:
- attr, char = None, self._thumb_indicator_bottom
- if char:
- thumb_bottom = urwid.Text(
- (attr, char * sb_width),
- wrap="any"
- ).render((sb_width,))
- if thumb_height:
- thumb_height -= 1
- if isinstance(self._trough_char, tuple):
- trough_attr, trough_char = self._trough_char
- else:
- trough_attr, trough_char = None, self._trough_char
- top = urwid.Text(
- (trough_attr, trough_char * top_height * sb_width),
- wrap="any"
- ).render((sb_width,))
- if isinstance(self._thumb_char, tuple):
- thumb_attr, thumb_char = self._thumb_char
- else:
- thumb_attr, thumb_char = (None, self._thumb_char)
- thumb = urwid.Text(
- (thumb_attr, thumb_char * thumb_height * sb_width),
- wrap="any"
- ).render((sb_width,))
- bottom = urwid.Text(
- (trough_attr, trough_char * bottom_height * sb_width),
- wrap="any"
- ).render((sb_width,))
- sb_canv = urwid.CanvasCombine(
- [ (top, None, False)] * (1 if top_height else 0) +
- [ (thumb_top, None, False)] * (1 if thumb_top else 0) +
- [ (thumb, None, False)] * (1 if thumb_height else 0) +
- [ (thumb_bottom, None, False)] * (1 if thumb_bottom else 0) +
- [ (bottom, None, False)] * (1 if bottom_height else 0)
- )
- combinelist = [(ow_canv, None, True, ow_size[0]),
- (sb_canv, None, False, sb_width)]
- if self._scrollbar_side != SCROLLBAR_LEFT:
- return urwid.CanvasJoin(combinelist)
- else:
- return urwid.CanvasJoin(reversed(combinelist))
- @property
- def scrollbar_width(self):
- """Columns the scrollbar uses"""
- return max(1, self._scrollbar_width)
- @scrollbar_width.setter
- def scrollbar_width(self, width):
- self._scrollbar_width = max(1, int(width))
- self._invalidate()
- @property
- def scrollbar_side(self):
- """Where to display the scrollbar; must be 'left' or 'right'"""
- return self._scrollbar_side
- @scrollbar_side.setter
- def scrollbar_side(self, side):
- if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT):
- raise ValueError('scrollbar_side must be "left" or "right", not %r' % side)
- self._scrollbar_side = side
- self._invalidate()
- @property
- def scrolling_base_widget(self):
- """Nearest `original_widget` that is compatible with the scrolling API"""
- def orig_iter(w):
- while hasattr(w, 'original_widget'):
- w = w.original_widget
- yield w
- yield w
- def is_scrolling_widget(w):
- return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max')
- for w in orig_iter(self):
- if is_scrolling_widget(w):
- return w
- raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w)
- def keypress(self, size, key):
- return self._original_widget.keypress(self._original_widget_size, key)
- def mouse_event(self, size, event, button, col, row, focus):
- ow = self._original_widget
- ow_size = self._original_widget_size
- handled = False
- if hasattr(ow, 'mouse_event'):
- handled = ow.mouse_event(ow_size, event, button, col, row, focus)
- if not handled and hasattr(ow, 'set_scrollpos'):
- if button == 4: # scroll wheel up
- pos = ow.get_scrollpos(ow_size)
- ow.set_scrollpos(pos - 1)
- return True
- elif button == 5: # scroll wheel down
- pos = ow.get_scrollpos(ow_size)
- ow.set_scrollpos(pos + 1)
- return True
- return False
- __all__ = ["Scrollable", "ScrollBar"]
|