diora-web/tui/diora_tui/layout.py
mrwnslz e246a32212 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

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)