diora-web/tui/diora_tui/reader_screen.py
mrwnslz 51b7316398 TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.

Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00

436 lines
19 KiB
Python

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