"""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()