From b5b9554f9bccd77ee505b3ace62c26ed5fc92936 Mon Sep 17 00:00:00 2001 From: rr- Date: Thu, 8 Nov 2018 10:26:18 +0100 Subject: [PATCH] ui: make quest book scrollable --- pqcli/ui/game_view.py | 18 +- pqcli/ui/scrollable.py | 432 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 pqcli/ui/scrollable.py diff --git a/pqcli/ui/game_view.py b/pqcli/ui/game_view.py index d882816..578ce61 100644 --- a/pqcli/ui/game_view.py +++ b/pqcli/ui/game_view.py @@ -19,6 +19,7 @@ from pqcli.ui.custom_list_box import CustomListBox from pqcli.ui.custom_progress_bar import CustomProgressBar from pqcli.ui.double_line_box import DoubleLineBox from pqcli.ui.read_only_check_box import ReadOnlyCheckBox +from pqcli.ui.scrollable import ScrollBar, Scrollable class CharacterSheetView(DoubleLineBox): @@ -286,14 +287,15 @@ class QuestBookView(DoubleLineBox): def __init__(self, player: Player) -> None: self.player = player - self.list_box = CustomListBox([]) + self.pile = urwid.Pile([]) self.quest_bar = CustomProgressBar() + self.scrollable = Scrollable(self.pile) self.player.quest_book.connect("start_quest", self.sync_quest_add) self.player.quest_book.quest_bar.connect("change", self.sync_position) super().__init__( - top_widget=self.list_box, + top_widget=ScrollBar(self.scrollable), top_title="Quests", bottom_widget=self.quest_bar, ) @@ -304,15 +306,17 @@ class QuestBookView(DoubleLineBox): self.sync_position() def sync_quests(self) -> None: - del self.list_box.body[:] for quest_name in self.player.quest_book.quests[-self.cutoff :]: self.sync_quest_add(quest_name) def sync_quest_add(self, quest_name: str) -> None: - del self.list_box.body[: -self.cutoff] - if self.list_box.body: - self.list_box.body[-1].set_state(True) - self.list_box.body.append(ReadOnlyCheckBox(quest_name, state=False)) + del self.pile.contents[: -self.cutoff] + if self.pile.contents: + self.pile.contents[-1][0].set_state(True) + self.pile.contents.append( + (ReadOnlyCheckBox(quest_name, state=False), ("pack", None)) + ) + self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1) def sync_position(self) -> None: self.quest_bar.set_completion( diff --git a/pqcli/ui/scrollable.py b/pqcli/ui/scrollable.py new file mode 100644 index 0000000..91145cf --- /dev/null +++ b/pqcli/ui/scrollable.py @@ -0,0 +1,432 @@ +# 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 + +# Taken from stig (stig.tui.scroll). +# Changes compared to upstream: +# - reformatted the code + +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" + + +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 + + +class ScrollBar(urwid.WidgetDecoration): + def sizing(self): + return frozenset((BOX,)) + + def selectable(self): + return True + + def __init__( + self, + widget, + thumb_char="\N{FULL BLOCK}", + 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 + + 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) + + sb_width = self._scrollbar_width + self._original_widget_size = ow_size = (maxcol - sb_width, maxrow) + ow_canv = ow.render(ow_size, focus) + + 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 + top = urwid.SolidCanvas(self._trough_char, sb_width, top_height) + thumb = urwid.SolidCanvas(self._thumb_char, sb_width, thumb_height) + bottom = urwid.SolidCanvas(self._trough_char, sb_width, bottom_height) + sb_canv = urwid.CanvasCombine( + [(top, None, False), (thumb, None, False), (bottom, None, False)] + ) + + 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