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;
width: 100%;
align: center middle;
dock: top;
}
.image {

View File

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