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
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).
Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00
|
|
|
def render_block(text: str, width: int, console: Console, spans=()) -> list[Strip]:
|
|
|
|
|
"""One block wrapped to `width`, a Strip per row. `spans`: (start, end, style) over the text
|
|
|
|
|
(highlights, a selection); styling never changes how the text wraps."""
|
|
|
|
|
t = Text(text)
|
|
|
|
|
for start, end, style in spans:
|
|
|
|
|
t.stylize(style, start, end)
|
|
|
|
|
lines = list(t.wrap(console, width)) if text else []
|
|
|
|
|
if not lines:
|
|
|
|
|
lines = [Text("")]
|
|
|
|
|
return [Strip(line.render(console), None).adjust_cell_length(width) for line in lines]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def restyle_block(book_layout: "BookLayout", index: int, text: str, console: Console, spans=()) -> bool:
|
|
|
|
|
"""Draw block `index` again with `spans`, in place (its rows stay where they are)."""
|
|
|
|
|
strips = render_block(text, book_layout.width, console, spans)
|
|
|
|
|
if len(strips) != book_layout.heights[index]:
|
|
|
|
|
return False
|
|
|
|
|
start = book_layout.starts[index]
|
|
|
|
|
book_layout.row_strips[start:start + len(strips)] = strips
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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)
|
TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).
Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00
|
|
|
strips = render_block(text, width, console)
|
|
|
|
|
heights.append(len(strips))
|
|
|
|
|
row_strips.extend(strips)
|
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
|
|
|
row_strips.append(blank)
|
TUI: Markierungen, Notizen und Lesezeichen (anzeigen, anlegen, löschen, abgleichen)
Dieselben verschlüsselten Listen wie der Web-Reader (annotations.py). Die Anker des Webs
(Block-Index plus Zeichenposition im Rohtext) werden auf den angezeigten Text umgerechnet
(marks.py, blocks.collapse_map), alte XPath-Anker über das Zitat gefunden. Im Reader: v markiert
(Cursor je Wort, Anfang/Ende, Farbe oder Notiz), n legt eine Notiz an, b ein Lesezeichen,
a öffnet die Liste (springen, Notiz ändern, löschen).
Der Server ersetzt die Liste als Ganzes; die TUI legt ihre Änderungen vor dem Schreiben
dreiwege je Eintrag-ID auf die aktuelle Liste des Servers, offline bleiben sie vorgemerkt.
Geprüft: die echten resolveAnchorRange des Webs lösen 81 TUI-Anker exakt auf
(tools/parity/web_anchors.cjs), 34 von 34 echten Web-Markierungen werden angezeigt, der
Server-Smoke schreibt und mischt zwei Geräte. 75 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:42:32 +01:00
|
|
|
row += len(strips) + _SEPARATOR_ROWS
|
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
|
|
|
|
|
|
|
|
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)
|