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.
90 lines
3.1 KiB
Python
90 lines
3.1 KiB
Python
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
|
|
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
|
|
blocks.py's module docstring for why blockIndex is exact but innerFraction is
|
|
only an approximation here).
|
|
|
|
Each block is wrapped independently at a known width, once, into a flat list
|
|
of pre-rendered Strip objects (one per terminal row) that book_view.py's
|
|
Line-API widget indexes directly in render_line() — this is what makes very
|
|
large books (tens of thousands of blocks) open in seconds rather than
|
|
minutes: Textual only ever renders the rows actually on screen, instead of
|
|
laying out the whole book up front the way a single giant Static would.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from rich.console import Console
|
|
from rich.text import Text
|
|
from textual.strip import Strip
|
|
|
|
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
|
|
|
|
|
|
@dataclass
|
|
class BookLayout:
|
|
starts: list[int] # row where block i begins
|
|
heights: list[int] # rendered row count for block i
|
|
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
|
|
total_rows: int
|
|
width: int
|
|
|
|
|
|
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
|
|
width = max(1, width)
|
|
starts: list[int] = []
|
|
heights: list[int] = []
|
|
row_strips: list[Strip] = []
|
|
blank = Strip.blank(width)
|
|
row = 0
|
|
for text in block_texts:
|
|
starts.append(row)
|
|
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
|
|
if not wrapped_lines:
|
|
wrapped_lines = [Text("")]
|
|
heights.append(len(wrapped_lines))
|
|
for line in wrapped_lines:
|
|
strip = Strip(line.render(console), None).adjust_cell_length(width)
|
|
row_strips.append(strip)
|
|
row_strips.append(blank)
|
|
row += len(wrapped_lines) + _SEPARATOR_ROWS
|
|
|
|
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
|
|
|
|
|
|
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
|
|
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
|
|
or above scroll_y, or the first block if none has scrolled that far yet."""
|
|
n = len(layout.heights)
|
|
if n == 0:
|
|
return 0, 0.0
|
|
|
|
best_index = 0
|
|
found = False
|
|
for i in range(n):
|
|
if layout.heights[i] < 1:
|
|
continue
|
|
if layout.starts[i] > scroll_y:
|
|
break
|
|
best_index = i
|
|
found = True
|
|
|
|
if not found:
|
|
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
|
|
|
|
top = layout.starts[best_index]
|
|
height = max(1, layout.heights[best_index])
|
|
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
|
|
return best_index, inner_fraction
|
|
|
|
|
|
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
|
|
"""Mirrors app.js's restoreFromAnchor()."""
|
|
n = len(layout.heights)
|
|
if n == 0:
|
|
return 0
|
|
idx = max(0, min(block_index, n - 1))
|
|
top = layout.starts[idx]
|
|
height = layout.heights[idx]
|
|
return top + round(inner_fraction * height)
|