From 91c1b792bed23c8ce5255f2020f2a17055e582b7 Mon Sep 17 00:00:00 2001 From: Dan Schwarz Date: Sun, 8 Jan 2023 23:20:33 -0500 Subject: [PATCH] Status detail scrollbar feature Uses scroll.py from https://github.com/rndusr/stig --- .flake8 | 2 +- toot/tui/scroll.py | 426 +++++++++++++++++++++++++++++++++++++++++++ toot/tui/timeline.py | 86 ++++++--- 3 files changed, 489 insertions(+), 25 deletions(-) create mode 100644 toot/tui/scroll.py diff --git a/.flake8 b/.flake8 index d39e12b..603b785 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -exclude=build,tests,tmp +exclude=build,tests,tmp,toot/tui/scroll.py ignore=E128 max-line-length=120 diff --git a/toot/tui/scroll.py b/toot/tui/scroll.py new file mode 100644 index 0000000..fa2c3bb --- /dev/null +++ b/toot/tui/scroll.py @@ -0,0 +1,426 @@ +# scroll.py +# +# Copied from the stig project by rndusr@github +# https://github.com/rndusr/stig +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details +# http://www.gnu.org/licenses/gpl-3.0.txt + +import urwid +from urwid.widget import BOX, FIXED, FLOW + +# 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' + +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 outside 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 + + +class ScrollBar(urwid.WidgetDecoration): + def sizing(self): + return frozenset((BOX,)) + + def selectable(self): + return True + + def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', + side=SCROLLBAR_RIGHT, width=1): + """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. + """ + if BOX not in widget.sizing(): + raise ValueError('Not a box widget: %r' % widget) + self.__super.__init__(widget) + self._thumb_char = thumb_char + self._trough_char = trough_char + self.scrollbar_side = side + self.scrollbar_width = max(1, width) + 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 + 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 + # / + 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 + top = urwid.SolidCanvas(self._trough_char, sb_width, 1) + thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) + bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) + sb_canv = urwid.CanvasCombine( + [(top, None, False)] * top_height + + [(thumb, None, False)] * thumb_height + + [(bottom, None, False)] * bottom_height, + ) + + 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 \ No newline at end of file diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 7d1e601..99a41d6 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -7,6 +7,7 @@ from toot.utils.language import language_name from .utils import highlight_hashtags, parse_datetime, highlight_keys from .widgets import SelectableText, SelectableColumns +from toot.tui.scroll import Scrollable, ScrollBar logger = logging.getLogger("toot") @@ -43,10 +44,32 @@ class Timeline(urwid.Columns): self.can_translate = can_translate self.status_list = self.build_status_list(statuses, focus=focus) self.followed_tags = followed_tags + opts_footer = urwid.Text(self.get_option_text(statuses[focus])) try: - self.status_details = StatusDetails(statuses[focus], is_thread, can_translate, followed_tags) + self.status_details = urwid.Frame( + body=ScrollBar( + Scrollable( + urwid.Padding( + StatusDetails( + statuses[focus], + is_thread, + can_translate, + followed_tags, + ), + right=1, + ) + ), + thumb_char="\u2588", + trough_char="\u2591", + ), + footer=opts_footer, + ) + except IndexError: - self.status_details = StatusDetails(None, is_thread, can_translate, followed_tags) + # we have no statuses to display + self.status_details = StatusDetails( + None, is_thread, can_translate, followed_tags + ) super().__init__([ ("weight", 40, self.status_list), @@ -74,6 +97,25 @@ class Timeline(urwid.Columns): None: "green_selected", }) + def get_option_text(self, status): + options = [ + "[B]oost", + "[D]elete" if status.is_mine else "", + "B[o]okmark", + "[F]avourite", + "[V]iew", + "[T]hread" if not self.is_thread else "", + "[L]inks", + "[R]eply", + "So[u]rce", + "[Z]oom", + "Tra[n]slate" if self.can_translate else "", + "[H]elp", + ] + options = "\n" + " ".join(o for o in options if o) + options = highlight_keys(options, "white_bold", "cyan") + return options + def get_focused_status(self): try: return self.statuses[self.status_list.body.focus] @@ -104,8 +146,23 @@ class Timeline(urwid.Columns): self.draw_status_details(status) def draw_status_details(self, status): - self.status_details = StatusDetails(status, self.is_thread, self.can_translate, self.followed_tags) - self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False) + opts_footer = urwid.Text(self.get_option_text(status)) + self.status_details = StatusDetails( + status, self.is_thread, self.can_translate, self.followed_tags + ) + self.contents[2] = ( + urwid.Padding( + urwid.Frame( + body=ScrollBar( + Scrollable(urwid.Padding(self.status_details, right=1)), + thumb_char="\u2588", + trough_char="\u2591", + ), + footer=opts_footer, + ), + left=1, + ) + ), ("weight", 60, False) def keypress(self, size, key): status = self.get_focused_status() @@ -339,26 +396,7 @@ class StatusDetails(urwid.Pile): ])) # Push things to bottom - yield ("weight", 1, urwid.SolidFill(" ")) - - options = [ - "[B]oost", - "[D]elete" if status.is_mine else "", - "[F]avourite", - "B[o]okmark", - "[V]iew", - "[T]hread" if not self.in_thread else "", - "[L]inks", - "[R]eply", - "So[u]rce", - "[Z]oom", - "Tra[n]slate" if self.can_translate else "", - "[H]elp", - ] - options = " ".join(o for o in options if o) - - options = highlight_keys(options, "white_bold", "cyan") - yield ("pack", urwid.Text(options)) + yield ("weight", 1, urwid.BoxAdapter(urwid.SolidFill(" "), 1)) def build_linebox(self, contents): contents = urwid.Pile(list(contents))