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>
108 lines
3.9 KiB
Python
108 lines
3.9 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 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
|
|
|
|
|
|
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)
|
|
strips = render_block(text, width, console)
|
|
heights.append(len(strips))
|
|
row_strips.extend(strips)
|
|
row_strips.append(blank)
|
|
row += len(strips) + _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)
|