Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner, zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs) nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert, damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine Verschlüsselung.
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""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()
|