library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen: Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen, Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text (1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs gegen den lokalen Server. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
437 lines
19 KiB
Python
437 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, 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
|
|
library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars))
|
|
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()
|