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.
257 lines
10 KiB
Python
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()
|