diora-web/tui/diora_tui/reader_screen.py
marwin db3f520632 TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel
statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py,
reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR
gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive
linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte
sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen
den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung
(Terminal hat keine Pixel-Geometrie), was funktioniert, weil der
Furthest-Wins-Vergleich primär nach blockIndex sortiert.

Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt
eines einzelnen riesigen Static — bei großen Büchern (mehrere reale
heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den
Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached
diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte
für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und
zentriert.

Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js'
_looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen
Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele
häufig auf einem inneren <a> statt dem umschließenden <p> sitzen.

Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da
beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus
dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei
offenen server-verknüpften Büchern Updates zurück (force: false, im
Hintergrund-Worker).

Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback
localStorage.getItem(...) falls die Clipboard-API in der Konsole
verweigert wird.

Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a.
3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik
per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll,
Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere
Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s,
warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine
Race-Condition beim ersten Post-Load-Scroll, und Progress-Push
End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00

257 lines
10 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.
"""
from __future__ import annotations
import bisect
import re
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 api, blocks, cache, config as config_mod, layout, progress
from .book_view import ContinuousBookView
MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
# remote.py) — reused here to find the server book id for progress push.
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
def _server_book_id(path: Path) -> int | None:
m = _SERVER_ID_RE.match(path.name)
return int(m.group(1)) if m else None
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 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("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._server_id = _server_book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None
self._last_pushed_anchor = ""
def compose(self):
yield Header()
yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll"):
# 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)
yield self._book_view
yield Footer()
def on_mount(self) -> None:
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
self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
def _content_width(self) -> int:
return max(20, min(MAX_LINE_WIDTH, self.size.width - 4))
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout
self._book_view.styles.width = width
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)
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _restore_position(self, attempt: int = 0) -> None:
if self.book is None:
return
saved = progress.load(self.book.id)
if saved is None:
return
parsed = blocks.parse_anchor(saved.anchor)
if parsed is None:
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
if y <= 0:
return
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))
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)
def _autosave(self) -> None:
self._save_progress()
def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights:
return
anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
if advanced and self._remote_cfg is not None and self._server_id is not None:
self._push_remote_progress(anchor)
@work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
try:
api.post_progress(
self._remote_cfg.server_url,
self._remote_cfg.api_token,
self._server_id,
scroll_fraction=0.0,
position_anchor=anchor,
force=False,
)
self._last_pushed_anchor = anchor
except Exception:
pass # best-effort — local progress is already saved regardless
def on_unmount(self) -> None:
self._save_progress()
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()