mirror of
https://github.com/1j01/textual-paint.git
synced 2024-12-22 22:31:43 +03:00
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:
parent
1df201ae62
commit
9a0a2c4f29
@ -2,6 +2,7 @@ GalleryItem {
|
|||||||
layout: vertical;
|
layout: vertical;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align: center middle;
|
align: center middle;
|
||||||
|
dock: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
@ -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."""
|
||||||
|
Loading…
Reference in New Issue
Block a user