"""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)