Virtualize scrolling in gallery

- Make it more efficient by loading files progressively.
- Remove the HorizontalScroll, and instead position items absolutely, animating offsets to imitate the movement of scrolling horizontally.
  - This fixes the left/right bindings not showing in the footer, due to ScrollableContainer's hidden left/right bindings.
  - This also removes the possibility of scrolling half-way away from an item.
  - This also fixes a problem where you could lose track of the currently viewed item when resizing the terminal, due to the 100% width of gallery items not jiving with the absolute notion of scroll position. (If the scroll position were stored as a fraction, it wouldn't have been a problem.)
- Simplify the keyboard navigation logic by storing an index into the gallery, instead of having to figure out what item is centered.
This commit is contained in:
Isaiah Odhner 2023-09-06 19:37:11 -04:00
parent 1df201ae62
commit 9a0a2c4f29
2 changed files with 61 additions and 32 deletions

View File

@ -2,6 +2,7 @@ GalleryItem {
layout: vertical; layout: vertical;
width: 100%; width: 100%;
align: center middle; align: center middle;
dock: top;
} }
.image { .image {

View File

@ -8,7 +8,8 @@ from pathlib import Path
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import HorizontalScroll, ScrollableContainer from textual.containers import Container, ScrollableContainer
from textual.reactive import Reactive, var
from textual.widgets import Footer, Header, Static from textual.widgets import Footer, Header, Static
from .__init__ import __version__ from .__init__ import __version__
@ -31,6 +32,8 @@ def _(text: str) -> str:
class GalleryItem(ScrollableContainer): class GalleryItem(ScrollableContainer):
"""An image with a caption.""" """An image with a caption."""
position: Reactive[float] = var(0)
def __init__(self, image: AnsiArtDocument, caption: str): def __init__(self, image: AnsiArtDocument, caption: str):
"""Initialise the gallery item.""" """Initialise the gallery item."""
super().__init__() super().__init__()
@ -44,6 +47,10 @@ class GalleryItem(ScrollableContainer):
yield Static(text, classes="image") yield Static(text, classes="image")
yield Static(self.caption, classes="caption") yield Static(self.caption, classes="caption")
def watch_position(self, value: float) -> None:
"""Called when `position` is changed."""
self.styles.offset = (round(self.app.size.width * value), 0)
class GalleryApp(App[None]): class GalleryApp(App[None]):
"""ANSI art gallery TUI""" """ANSI art gallery TUI"""
@ -68,12 +75,14 @@ class GalleryApp(App[None]):
Binding("f12", "toggle_inspector", _("Toggle Inspector"), show=False), Binding("f12", "toggle_inspector", _("Toggle Inspector"), show=False),
] ]
path_index: Reactive[int] = var(0)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Add widgets to the layout.""" """Add widgets to the layout."""
yield Header(show_clock=True) yield Header(show_clock=True)
self.scroll = HorizontalScroll() self.container = Container()
yield self.scroll yield self.container
yield Footer() yield Footer()
@ -163,47 +172,66 @@ class GalleryApp(App[None]):
# self.exit(None, "\n".join(str(path) for path in paths)) # self.exit(None, "\n".join(str(path) for path in paths))
# return # return
for path in paths: self.paths = paths
# with open(path, "r", encoding="cp437") as f: self.path_to_gallery_item: dict[Path, GalleryItem] = {}
with open(path, "r", encoding="utf8") as f: self.path_index = 0
image = AnsiArtDocument.from_ansi(f.read()) self._load_upcoming_images()
self.scroll.mount(GalleryItem(image, caption=path.name)) def _load_upcoming_images(self) -> None:
"""Load current and upcoming images."""
range_start = max(self.path_index - 2, 0)
range_end = min(self.path_index + 2, len(self.paths))
def _scroll_to_adjacent_item(self, delta_index: int = 0) -> None: for path in self.paths[range_start:range_end]:
"""Scroll to the next/previous item.""" if path not in self.path_to_gallery_item:
# try: self._load_image(path)
# index = self.scroll.children.index(self.app.focused)
# except ValueError: def _load_image(self, path: Path) -> None:
# return # with open(path, "r", encoding="cp437") as f:
widget, _ = self.app.get_widget_at(self.screen.region.width // 2, self.screen.region.height // 2) with open(path, "r", encoding="utf8") as f:
while widget is not None and not isinstance(widget, GalleryItem): image = AnsiArtDocument.from_ansi(f.read())
widget = widget.parent
if widget is None: gallery_item = GalleryItem(image, caption=path.name)
index = 0 self.container.mount(gallery_item)
else: item_index = self.paths.index(path)
index = self.scroll.children.index(widget) # print(path, item_index, self.path_index)
index += delta_index # gallery_item.styles.opacity = 1.0 if item_index == self.path_index else 0.0
index = max(0, min(index, len(self.scroll.children) - 1)) gallery_item.position = 0 if item_index == self.path_index else (-1 if item_index < self.path_index else 1)
target = self.scroll.children[index] self.path_to_gallery_item[path] = gallery_item
target.focus()
self.scroll.scroll_to_widget(target) def validate_path_index(self, path_index: int) -> int:
"""Ensure the index is within range."""
return max(0, min(path_index, len(self.paths) - 1))
def watch_path_index(self, current_index: int) -> None:
"""Called when the path index is changed."""
self._load_upcoming_images()
for item_index, (path, gallery_item) in enumerate(self.path_to_gallery_item.items()):
# gallery_item.set_class(item_index < current_index, "previous")
# gallery_item.set_class(item_index == current_index, "current")
# gallery_item.set_class(item_index > current_index, "next")
# opacity = 1.0 if item_index == current_index else 0.0
# gallery_item.styles.animate("opacity", value=opacity, final_value=opacity, duration=0.5)
position = 0 if item_index == current_index else (-1 if item_index < current_index else 1)
gallery_item.animate("position", value=position, final_value=position, duration=0.3)
# gallery_item.position = position
def action_next(self) -> None: def action_next(self) -> None:
"""Scroll to the next item.""" """Scroll to the next item."""
self._scroll_to_adjacent_item(1) self.path_index += 1
def action_previous(self) -> None: def action_previous(self) -> None:
"""Scroll to the previous item.""" """Scroll to the previous item."""
self._scroll_to_adjacent_item(-1) self.path_index -= 1
def action_scroll_to_start(self) -> None: def action_scroll_to_start(self) -> None:
"""Scroll to the first item.""" """Scroll to the first item."""
self.scroll.scroll_to_widget(self.scroll.children[0]) self.path_index = 0
def action_scroll_to_end(self) -> None: def action_scroll_to_end(self) -> None:
"""Scroll to the last item.""" """Scroll to the last item."""
self.scroll.scroll_to_widget(self.scroll.children[-1]) self.path_index = len(self.paths) - 1
def action_reload(self) -> None: def action_reload(self) -> None:
"""Reload the program.""" """Reload the program."""