"""The library screen: the web library (docs/library.md) in a terminal — "Weiterlesen" card, status and folder chips, search, sort, Bücher/Zeitschriften. The rules are in library.py. Keys: 1–4 status (Alle/Angefangen/Ungelesen/Gelesen), o folder, / search, s sort, m Bücher or Zeitschriften, c continue the book read last, Enter open. The chips and the card also answer the mouse. """ from __future__ import annotations from pathlib import Path from rich.text import Text from textual import work from textual.binding import Binding from textual.screen import ModalScreen, Screen from textual.markup import escape from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, OptionList, Static from . import blocks, config as config_mod, epub, library, library_meta, progress, remote from .reader_screen import ReaderScreen from .statusbar import StatusBar PAGES_NOTE_EVERY = 4 # seconds between redraws while lengths are worked out class ChoiceScreen(ModalScreen[int]): """Pick one of a few options; dismisses with its index, or None on Escape.""" BINDINGS = [Binding("escape,q", "cancel", "Abbrechen")] def __init__(self, title: str, options: list[str], current: int = 0) -> None: super().__init__() self._title = title self._options = options self._current = current def compose(self): yield Label(self._title, id="choice-title") yield OptionList(*[Text(o) for o in self._options], id="choice-list") def on_mount(self) -> None: ol = self.query_one(OptionList) ol.highlighted = max(0, min(self._current, len(self._options) - 1)) ol.focus() def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: self.dismiss(event.option_index) def action_cancel(self) -> None: self.dismiss(None) class LibraryScreen(Screen): BINDINGS = [ Binding("enter", "open_selected", "Öffnen"), Binding("c", "continue_reading", "Weiterlesen"), Binding("/", "search", "Suchen"), Binding("s", "pick_sort", "Sortierung"), Binding("o", "pick_folder", "Ordner"), Binding("m", "toggle_section", "Bücher/Zeitschriften"), Binding("1", "set_status('all')", "Alle", show=False), Binding("2", "set_status('started')", "Angefangen", show=False), Binding("3", "set_status('unread')", "Ungelesen", show=False), Binding("4", "set_status('read')", "Gelesen", show=False), Binding("escape", "reset", "Zurücksetzen", show=False), Binding("q", "quit", "Beenden"), ] def __init__(self, library_dir: Path) -> None: super().__init__() self.library_dir = library_dir self.rows: list[library.Row] = [] # every book self.section_rows: list[library.Row] = [] # of the section shown (Bücher or Zeitschriften) self.shown: list[library.Row] = [] # the list on screen self.going: library.Row | None = None # the card's book self.folder_names: list[str] = [] self.q = "" self.status = "all" self.folder: str | None = None # None: all, '': without folder, else a name self.sort = library_meta.get_extra("sort", "recent") if self.sort not in dict(library.SORTS): self.sort = "recent" self.magazines = False self._lengths_running = False self._lengths_tried: set[str] = set() self._lengths_note = "" # ---- layout def compose(self): yield Header() yield Static("", id="lib-chips") yield Input(placeholder="Titel, Autor …", id="lib-search") yield Static("", id="lib-card") yield Static("", id="lib-note") yield ListView(id="library-list") yield StatusBar() yield Footer() def on_mount(self) -> None: self.query_one("#lib-search").display = False self.sub_title = str(self.library_dir) self._refresh_list() cfg = config_mod.load() if cfg is not None: self._auto_sync(cfg) def on_screen_resume(self) -> None: self._refresh_list() # a book was read: the order and the percentages changed # ---- sync @work(thread=True) def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None: try: result = remote.sync_library(self.library_dir, cfg) except remote.SyncError as e: self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5) return # best-effort — e.g. offline; the local library still works self.app.call_from_thread(self._on_auto_sync_done, result) def _on_auto_sync_done(self, result: remote.SyncResult) -> None: self._refresh_list() if result.new_titles: self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3) # ---- the list def _gather(self) -> list[library.Row]: store = progress.default() remote_books = library_meta.remote_entries() files = epub.scan_library(self.library_dir) keys = {epub._book_id(p) for p in files} | set(remote_books) states = {k: st for k in keys if (st := store.get(k)) is not None} return library.build_rows(files, remote_books, states, library_meta.local_pages(), epub._book_id) def _refresh_list(self) -> None: self.rows = self._gather() self.section_rows = library.section(self.rows, self.magazines) order = library_meta.get_extra("folder_order", []) self.folder_names = [n for n, _ in library.folder_list(self.section_rows, order)] if self.folder not in (None, "") and self.folder not in self.folder_names: self.folder = None self._draw() self._fill_lengths() def _draw(self) -> None: self._draw_chips() list_view = self.query_one("#library-list", ListView) selected = self.shown[list_view.index].key if self.shown and list_view.index is not None and list_view.index < len(self.shown) else None shown = library.shown(self.section_rows, q=self.q, status=self.status, folder=self.folder, sort=self.sort) # The card: only on the plain list, and its book is not repeated below (once, on top). plain = self.status == "all" and self.folder is None and not self.q self.going, recent = library.continue_rows(self.section_rows) if plain else (None, []) self._draw_card(recent) if self.going is not None and self.going in shown: shown.remove(self.going) self.shown = shown list_view.clear() if not self.rows: list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}"))) elif not self.section_rows: list_view.append(ListItem(Label("Noch keine Zeitschriften — m wechselt zu den Büchern." if self.magazines else "Alle Bücher stehen bei den Zeitschriften — m wechselt dorthin."))) elif not self.shown and self.going is None: list_view.append(ListItem(Label("Nichts gefunden."))) by_length = self.sort in ("short", "long") for r in self.shown: list_view.append(ListItem(Label(Text(self._label(r, by_length))))) if self.shown: idx = next((i for i, r in enumerate(self.shown) if r.key == selected), 0) list_view.index = idx if not self.query_one("#lib-search", Input).display: # not while a search is open list_view.focus() self._draw_note() @staticmethod def _label(r: library.Row, by_length: bool) -> str: mark = "✓ " if r.is_read else "" cloud = "☁ " if r.path is None else "" author = f" — {r.author}" if r.author else "" pct = f" {library.percent(r)} %" if (r.fraction > 0 and not r.is_read) else "" length = f" · {r.pages} S." if by_length and r.pages else "" return f"{mark}{cloud}{r.title}{author}{pct}{length}" def _draw_chips(self) -> None: counts = library.status_counts(self.section_rows) parts = [] for i, (sid, label) in enumerate(library.STATUSES): text = label if sid == "all" else f"{label} {counts[sid]}" parts.append(self._chip(f"{i + 1} {text}", f"screen.set_status('{sid}')", self.status == sid)) folders = library.folder_list(self.section_rows, library_meta.get_extra("folder_order", [])) if folders: parts.append("[dim]│[/]") parts.append(self._chip("Alle Ordner", "screen.set_folder('all')", self.folder is None)) for i, (name, count) in enumerate(folders): parts.append(self._chip(f"{name} {count}", f"screen.set_folder('{i}')", self.folder == name)) none = sum(1 for r in self.section_rows if not r.folders) if none: parts.append(self._chip(f"Ohne Ordner {none}", "screen.set_folder('none')", self.folder == "")) section = "Zeitschriften" if self.magazines else "Bücher" sort = dict(library.SORTS)[self.sort] head = f"[b]{section}[/] [dim]Sortierung:[/] {escape(sort)} " self.query_one("#lib-chips", Static).update(head + " ".join(parts)) @staticmethod def _chip(label: str, action: str, on: bool) -> str: label = escape(label) return f"[@click={action}]" + (f"[b reverse] {label} [/]" if on else f" [underline]{label}[/] ") + "[/]" def _draw_card(self, recent: list[library.Row]) -> None: card = self.query_one("#lib-card", Static) b = self.going if b is None: card.display = False return card.display = True ago = library.ago(b.read_at) pct = library.percent(b) bar = "█" * (pct // 5) + "░" * (20 - pct // 5) pages = f" · Seite {max(1, int(b.pages * b.fraction + 0.5))} von {b.pages}" if b.pages else "" where = f" · zuletzt auf {escape(b.device)}" if b.device and b.device != progress.DEVICE_NAME else "" lines = [ f"[@click=screen.continue_reading][dim]Weiterlesen{' · ' + ago if ago else ''}{where}[/]", f"[b]{escape(b.title)}[/]", escape(b.author) if b.author else "", f"{bar} {pct} %{pages} [dim](c)[/][/]", ] if recent: lines.append("[dim]Zuletzt gelesen:[/] " + " · ".join( f"{escape(r.title)}{' ✓' if r.is_read else ''}" for r in recent)) card.update("\n".join(l for l in lines if l)) def _draw_note(self) -> None: note = self.query_one("#lib-note", Static) text = self._lengths_note if not text and self.sort in ("short", "long"): unknown = sum(1 for r in self.section_rows if not r.pages) if unknown: text = f"{unknown} Bücher ohne bekannte Länge (noch nicht geladen) stehen am Ende." note.update(escape(text)) note.display = bool(text) # ---- lengths (the web works them out once a length sort is chosen: the file is needed) def _fill_lengths(self) -> None: if self._lengths_running or self.sort not in ("short", "long"): return todo = [r for r in self.rows if not r.pages and r.path is not None and r.key not in self._lengths_tried] if not todo: return self._lengths_running = True self._work_lengths([(r.key, r.path) for r in todo]) @work(thread=True, exclusive=True, group="lengths") def _work_lengths(self, todo: list[tuple[str, Path]]) -> None: import time shown_at = time.monotonic() try: for done, (key, path) in enumerate(todo, 1): self._lengths_tried.add(key) try: pages = blocks.book_pages(path) except Exception: pages = 0 # stays at the end if pages: library_meta.set_local_pages(key, pages) self._lengths_note = f"Länge wird ermittelt … {done} von {len(todo)} Büchern" if time.monotonic() - shown_at > PAGES_NOTE_EVERY: shown_at = time.monotonic() self.app.call_from_thread(self._refresh_list_keep_note) finally: self._lengths_note = "" self._lengths_running = False self.app.call_from_thread(self._refresh_list) def _refresh_list_keep_note(self) -> None: self.rows = self._gather() self.section_rows = library.section(self.rows, self.magazines) self._draw() # ---- actions def action_set_status(self, status: str) -> None: self.status = "all" if self.status == status else status self._draw() def action_set_folder(self, which: str) -> None: """`which`: 'all', 'none' (without a folder) or the index of a folder chip.""" if which == "all": self.folder = None elif which == "none": self.folder = "" elif which.isdigit() and int(which) < len(self.folder_names): name = self.folder_names[int(which)] self.folder = None if self.folder == name else name self._draw() def action_pick_folder(self) -> None: values: list[str | None] = [None] + self.folder_names + ([""] if any(not r.folders for r in self.section_rows) else []) if len(values) == 1: self.notify("Keine Ordner angelegt.", timeout=2) return names = ["Alle Ordner" if v is None else "Ohne Ordner" if v == "" else v for v in values] def picked(i: int | None) -> None: if i is not None: self.folder = values[i] self._draw() self.app.push_screen(ChoiceScreen("Ordner", names, values.index(self.folder)), picked) def action_pick_sort(self) -> None: ids = [s for s, _ in library.SORTS] def picked(i: int | None) -> None: if i is None: return self.sort = ids[i] library_meta.set_extra("sort", self.sort) self._draw() self._fill_lengths() self.app.push_screen(ChoiceScreen("Sortieren nach", [n for _, n in library.SORTS], ids.index(self.sort)), picked) def action_toggle_section(self) -> None: self.magazines = not self.magazines self.folder = None self._refresh_list() def action_search(self) -> None: box = self.query_one("#lib-search", Input) box.display = True box.focus() def on_input_changed(self, event: Input.Changed) -> None: self.q = event.value self._draw() def on_input_submitted(self, event: Input.Submitted) -> None: self.query_one("#library-list", ListView).focus() def action_reset(self) -> None: box = self.query_one("#lib-search", Input) if box.display or self.q or self.status != "all" or self.folder is not None: box.value = "" box.display = False self.q, self.status, self.folder = "", "all", None self._draw() def action_continue_reading(self) -> None: if self.going is not None: self._open(self.going) else: self.notify("Nichts zum Weiterlesen — ein Buch öffnen.", timeout=3) def action_open_selected(self) -> None: list_view = self.query_one("#library-list", ListView) if not self.shown or list_view.index is None or list_view.index >= len(self.shown): return self._open(self.shown[list_view.index]) def _open(self, row: library.Row) -> None: if row.path is not None: self.app.push_screen(ReaderScreen(row.path)) return cfg = config_mod.load() if cfg is None or row.server_id is None: self.notify("Nicht angemeldet — `diora-tui login`.", severity="error") return self.notify(f"Lade „{row.title}“ …", timeout=4) self._download_and_open(cfg, row) @work(thread=True, exclusive=True, group="download") def _download_and_open(self, cfg: config_mod.RemoteConfig, row: library.Row) -> None: try: path = remote.download_book(self.library_dir, cfg, row.server_id, row.title) except remote.SyncError as e: self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6) return self.app.call_from_thread(self.app.push_screen, ReaderScreen(path)) def on_list_view_selected(self, event: ListView.Selected) -> None: self.action_open_selected()