"""Continuous whole-book reader screen — mirrors the web reader's single scrollable view (see blocks.py's module docstring) instead of the old per-chapter pagination, so reading position is expressed in the same "blockIndex:innerFraction" anchor space as the server and the web client. Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the whole book into one Static: for large books (tens of thousands of blocks) a single giant renderable made Textual's layout/paint pass take upwards of a minute, whereas the Line API only ever renders the rows on screen. Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins. The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing. While a book is open the server is asked now and then; a newer position from another device is offered ("Auf X weitergelesen — springen?"), never applied by itself. """ from __future__ import annotations import bisect import threading from pathlib import Path from rich.console import Console from textual import work from textual.binding import Binding from textual.containers import Container, VerticalScroll from textual.geometry import Size from textual.screen import ModalScreen, Screen from textual.widgets import Footer, Header, Label, LoadingIndicator, Static from . import blocks, cache, config as config_mod, epub, layout, library_meta, progress, remote from .book_view import ContinuousBookView from .statusbar import StatusBar MAX_LINE_WIDTH = 120 AUTOSAVE_INTERVAL = 5.0 # How often the server is asked for a newer position while the book is open (the apps pull # at most every 2 minutes). REMOTE_CHECK_INTERVAL = 120.0 # "Weiteste Stelle" is offered when the furthest place is this much of the book ahead. FURTHEST_OFFER_SHARE = 0.01 # ContinuousBookView forces its vertical scrollbar always-on (see book_view.py) # so this stays constant — measuring the container's width and subtracting # this fixed amount avoids a measure-after-constrain race against book_view's # own (possibly already-constrained-from-a-previous-layout) width. SCROLLBAR_GUTTER = 2 _EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1) class FootnoteScreen(ModalScreen[None]): BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")] def __init__(self, notes: list[str]) -> None: super().__init__() self._notes = notes def compose(self): with VerticalScroll(id="footnote-body"): for note in self._notes: yield Static(note, classes="footnote-note") yield Label("Escape/f zum Schließen", id="footnote-hint") def action_dismiss_self(self) -> None: self.dismiss(None) class JumpOfferScreen(ModalScreen[bool]): """A question the reader answers with a key; nothing moves unless the answer is yes.""" BINDINGS = [ Binding("y,enter", "answer(True)", "Springen"), Binding("n,escape,q", "answer(False)", "Hier bleiben"), ] def __init__(self, text: str) -> None: super().__init__() self._text = text def compose(self): with Container(id="offer-body"): yield Static(self._text) yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint") def action_answer(self, yes: bool) -> None: self.dismiss(yes) 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("f", "peek_footnote", "Fußnote"), Binding("w", "goto_furthest", "Weiteste Stelle"), Binding("q,escape", "back", "Zurück zur Bibliothek"), ] def __init__(self, path: Path) -> None: super().__init__() self.path = path self.book: blocks.FlatBook | None = None self.book_layout: layout.BookLayout = _EMPTY_LAYOUT self._key = epub._book_id(path) self._server_id = epub.server_id_of(path) self._cfg = config_mod.load() if self._server_id is not None else None self._store = progress.default() # The position the view is on, as last saved or restored, and when the reader last # moved it (this machine's clock). A change of the anchor is a reading position only # when there is such a move. self._last_anchor: str | None = None self._last_move_ms: int | None = None # read_at (server clock) of the state this view is based on: a newer one from the # server is what gets offered. self._held_read_at: int | None = None self._offer_open = False def compose(self): yield Header() yield LoadingIndicator(id="reader-loading") with Container(id="reader-scroll") as scroll_container: self._scroll_container = scroll_container # Stays visible (empty) from the start rather than toggling display # on once loaded — a widget that's just been switched from hidden # to visible hasn't been through a layout pass yet, so its `.size` # is still (0, 0) and an immediate scroll_to() right after has # nothing to clamp against and silently resets to 0. self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll) yield self._book_view yield StatusBar() yield Footer() def on_mount(self) -> None: # Deferred rather than read synchronously here: right after mount the # container hasn't been through a layout pass yet, so its size isn't # reliable — same lesson as the scroll-restore race below. self.call_after_refresh(self._start_loading) def _start_loading(self) -> None: if self._scroll_container.size.width == 0: # Not sized yet after all — keep deferring instead of guessing a # fixed number of refresh cycles (mirrors _restore_position below). self.call_after_refresh(self._start_loading) return self._load_book(self._content_width()) @work(thread=True) def _load_book(self, width: int) -> None: cached = cache.load(self.path, width) if cached is not None: book, book_layout = cached else: book = blocks.load_flat_book(self.path) console = Console(width=width) book_layout = layout.build_layout([b.text for b in book.blocks], width, console) cache.save(self.path, width, book, book_layout) self.app.call_from_thread(self._on_book_loaded, book, book_layout, width) def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None: self.book = book self.sub_title = book.title self._apply_layout(book_layout, width) self.query_one("#reader-loading", LoadingIndicator).display = False st = self._store.get(self._key) self._held_read_at = st.read_at if st else None self.call_after_refresh(self._restore_position) self.set_interval(AUTOSAVE_INTERVAL, self._autosave) if self._cfg is not None: self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote) def _content_width(self) -> int: # Measuring the *container* rather than book_view's own size avoids a # measure-after-constrain race: once a layout has been applied, # book_view's width is pinned to a previous value via styles.width # (below), so re-measuring book_view itself on a later resize would # just read that stale pinned width back instead of the new # available space. The container's width is unaffected by that. available = self._scroll_container.size.width - SCROLLBAR_GUTTER return max(20, min(MAX_LINE_WIDTH, available)) def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None: self.book_layout = book_layout # Pin book_view's outer width to content-width-plus-scrollbar so its # *inner* content region (what render_line actually draws into) ends # up exactly `width` — matching what block texts were wrapped at. self._book_view.styles.width = width + SCROLLBAR_GUTTER self._book_view.book_layout = book_layout self._book_view.virtual_size = Size(width, book_layout.total_rows) self._book_view.refresh() def _build_layout(self) -> None: assert self.book is not None width = self._content_width() console = Console(width=width) block_texts = [b.text for b in self.book.blocks] new_layout = layout.build_layout(block_texts, width, console) cache.save(self.path, width, self.book, new_layout) self._apply_layout(new_layout, width) def on_resize(self) -> None: if self.book is None: return old_y = self._book_view.scroll_y anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y) self._build_layout() new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac) with self._book_view.program_scroll(): self._book_view.scroll_to(y=new_y, animate=False, immediate=True) def _scroll_to_anchor(self, anchor: str) -> bool: parsed = blocks.parse_anchor(anchor) if parsed is None: return False y = layout.scroll_y_for_anchor(self.book_layout, *parsed) with self._book_view.program_scroll(): self._book_view.scroll_to(y=y, animate=False, immediate=True) return y > 0 def _restore_position(self, attempt: int = 0) -> None: if self.book is None: return st = self._store.get(self._key) if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None: self._settle() return parsed = blocks.parse_anchor(st.anchor) y = layout.scroll_y_for_anchor(self.book_layout, *parsed) if y <= 0: self._settle() return with self._book_view.program_scroll(): self._book_view.scroll_to(y=y, animate=False, immediate=True) # A widget that's only just become part of the layout doesn't always # honor an immediate scroll on the first attempt (its own size/scroll # bounds can still be mid-update) — verify it actually landed and # retry a bounded number of times rather than guessing a fixed delay. if attempt < 20 and abs(self._book_view.scroll_y - y) > 1: self.call_after_refresh(lambda: self._restore_position(attempt + 1)) return self._settle() self._offer_furthest(st) def _settle(self) -> None: """The view is where the saved position put it: from here a changed anchor with a move by the reader is a reading position.""" self._last_anchor = self._current_anchor_str() self._last_move_ms = None def _current_anchor_str(self) -> str: block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) return blocks.format_anchor(block_index, inner_fraction) # ---- saving def _on_read_scroll(self) -> None: self._last_move_ms = progress.now_ms() def _autosave(self) -> None: self._save_progress() def _save_progress(self) -> None: if self.book is None or not self.book_layout.heights or self._last_anchor is None: return anchor = self._current_anchor_str() if anchor == self._last_anchor: self._last_move_ms = None # moved and came back: nothing new return moved_at = self._last_move_ms self._last_anchor = anchor self._last_move_ms = None if moved_at is None: return # the layout moved it, not the reader block_index, inner = blocks.parse_anchor(anchor) fraction = blocks.fraction_for_anchor(self.book, block_index, inner) self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None) st = self._store.get(self._key) if st is not None: self._held_read_at = st.read_at if self._cfg is not None: self._push() @work(thread=True, exclusive=True, group="progress-push") def _push(self) -> None: try: remote.flush_pending(self._cfg) except Exception: return # best-effort — the position is saved locally and stays queued self.app.call_from_thread(self._after_push) def _after_push(self) -> None: # What the server stored of ours is no news; a newer position of another device in its # answer (our write was refused) is. st = self._store.get(self._key) if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0): self._held_read_at = st.echo_read_at self._offer_newer() def on_unmount(self) -> None: self._save_progress() if self._cfg is not None and self._store.pending_keys(): # The screen's workers die with it; the queue still has to go out. threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start() @staticmethod def _flush_quietly(cfg) -> None: try: remote.flush_pending(cfg) except Exception: pass # ---- the server's newer positions @work(thread=True, exclusive=True, group="progress-check") def _check_remote(self) -> None: try: remote.sync_library(self.path.parent, self._cfg) except Exception: return # offline etc.: ask again later self.app.call_from_thread(self._offer_newer) def _offer_newer(self) -> None: if self.book is None or self._offer_open or self._last_anchor is None: return st = self._store.get(self._key) if st is None or not st.anchor or st.read_at is None: return if self._held_read_at is not None and st.read_at <= self._held_read_at: return parsed = blocks.parse_anchor(st.anchor) if parsed is None: return shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) self._held_read_at = st.read_at # asked once per newer state if parsed[0] == shown_block: return pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100) who = st.device or "einem anderen Gerät" self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor) def _offer_furthest(self, st: progress.BookState) -> None: if self.book is None or not st.furthest_anchor: return here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor) if there is None: return share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0) if share >= FURTHEST_OFFER_SHARE: pct = round(blocks.fraction_for_anchor(self.book, *there) * 100) self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6) def _ask(self, text: str, anchor: str) -> None: self._offer_open = True def answered(yes: bool | None) -> None: self._offer_open = False if yes: self._scroll_to_anchor(anchor) self._last_anchor = self._current_anchor_str() self._last_move_ms = None self.app.push_screen(JumpOfferScreen(text), answered) def action_goto_furthest(self) -> None: st = self._store.get(self._key) if self.book is None or st is None or not st.furthest_anchor: self.notify("Keine weiteste Stelle bekannt.", timeout=3) return if blocks.parse_anchor(st.furthest_anchor) is None: return self._scroll_to_anchor(st.furthest_anchor) # A jump, not reading: nothing is saved until the reader carries on from there. self._last_anchor = self._current_anchor_str() self._last_move_ms = None # ---- navigation def action_scroll_down_line(self) -> None: self._book_view.scroll_relative(y=1, animate=False) def action_scroll_up_line(self) -> None: self._book_view.scroll_relative(y=-1, animate=False) def _current_chapter_index(self) -> int: if self.book is None: return 0 block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1 def _jump_to_block(self, block_index: int) -> None: y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0) self._book_view.scroll_to(y=y, animate=False, immediate=True) def action_next_chapter(self) -> None: if self.book is None: return chapter = self._current_chapter_index() if chapter + 1 < len(self.book.chapter_start_block): self._jump_to_block(self.book.chapter_start_block[chapter + 1]) self._save_progress() def action_prev_chapter(self) -> None: if self.book is None: return chapter = self._current_chapter_index() if chapter > 0: self._jump_to_block(self.book.chapter_start_block[chapter - 1]) self._save_progress() def action_peek_footnote(self) -> None: if self.book is None or not self.book_layout.heights: return current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y) forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes] backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes] candidate = forward[0] if forward else (backward[-1] if backward else None) if candidate is None: self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3) return _, block = candidate notes: list[str] = [] for ref in block.footnotes: target_idx = self.book.footnote_targets.get(ref.target_id) if target_idx is None: continue notes.append(self.book.blocks[target_idx].text) if not notes: self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3) return self.app.push_screen(FootnoteScreen(notes)) def action_back(self) -> None: self.app.pop_screen()