diora-web/tui/diora_tui/app.py

169 lines
5.6 KiB
Python
Raw Normal View History

"""diora-tui: local EPUB reader, precursor to a TUI client that syncs against
diora's e-book progress API once it exists (see project README)."""
from __future__ import annotations
import argparse
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static
from . import epub, progress
DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="library-list")
yield Footer()
def on_mount(self) -> None:
self.sub_title = str(self.library_dir)
self.paths = epub.scan_library(self.library_dir)
list_view = self.query_one("#library-list", ListView)
if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
for path in self.paths:
list_view.append(ListItem(Label(path.stem)))
list_view.index = 0
list_view.focus()
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.paths or list_view.index is None:
return
self.app.push_screen(ReaderScreen(self.paths[list_view.index]))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()
class ReaderScreen(Screen):
BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False),
Binding("k,up", "scroll_up_line", "Hoch", show=False),
Binding("n,]", "next_chapter", "Nächstes Kapitel"),
Binding("p,[", "prev_chapter", "Vorheriges Kapitel"),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
AUTOSAVE_INTERVAL = 5.0
def __init__(self, path: Path) -> None:
super().__init__()
self.book = epub.load_epub(path)
self.chapter_index = 0
saved = progress.load(self.book.id)
if saved is not None and 0 <= saved.chapter_index < len(self.book.chapters):
self.chapter_index = saved.chapter_index
self._pending_scroll_fraction = saved.scroll_fraction if saved else 0.0
def compose(self) -> ComposeResult:
self._text = Static(id="chapter-text")
yield Header()
with VerticalScroll(id="reader-scroll") as scroll:
self._scroll = scroll
yield self._text
yield Footer()
def on_mount(self) -> None:
self._render_chapter(restore_scroll=True)
self.set_interval(self.AUTOSAVE_INTERVAL, self._save_progress)
def on_unmount(self) -> None:
self._save_progress()
def _render_chapter(self, *, restore_scroll: bool = False) -> None:
chapter = self.book.chapters[self.chapter_index]
text = "\n\n".join(chapter.paragraphs)
self._text.update(text)
self.sub_title = f"{self.book.title} — Kapitel {self.chapter_index + 1}/{len(self.book.chapters)}"
self._scroll.scroll_home(animate=False)
if restore_scroll and self._pending_scroll_fraction:
fraction = self._pending_scroll_fraction
self.call_after_refresh(lambda: self._scroll_to_fraction(fraction))
def _scroll_to_fraction(self, fraction: float) -> None:
max_y = max(0, self._scroll.virtual_size.height - self._scroll.size.height)
self._scroll.scroll_to(y=round(max_y * fraction), animate=False)
def _current_fraction(self) -> float:
max_y = max(1, self._scroll.virtual_size.height - self._scroll.size.height)
return max(0.0, min(1.0, self._scroll.scroll_y / max_y))
def _save_progress(self) -> None:
progress.save(
self.book.id,
progress.Position(chapter_index=self.chapter_index, scroll_fraction=self._current_fraction()),
)
def action_scroll_down_line(self) -> None:
self._scroll.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
self._scroll.scroll_relative(y=-1, animate=False)
def action_next_chapter(self) -> None:
if self.chapter_index + 1 < len(self.book.chapters):
self.chapter_index += 1
self._pending_scroll_fraction = 0.0
self._render_chapter()
self._save_progress()
def action_prev_chapter(self) -> None:
if self.chapter_index > 0:
self.chapter_index -= 1
self._pending_scroll_fraction = 0.0
self._render_chapter()
def action_back(self) -> None:
self.app.pop_screen()
class DioraTuiApp(App):
CSS_PATH = "app.tcss"
TITLE = "diora-tui"
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
def on_mount(self) -> None:
self.push_screen(LibraryScreen(self.library_dir))
def main() -> None:
parser = argparse.ArgumentParser(
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser.add_argument(
"--library",
type=Path,
default=DEFAULT_LIBRARY,
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
)
args = parser.parse_args()
DioraTuiApp(args.library.expanduser()).run()
if __name__ == "__main__":
main()