91 lines
3.1 KiB
Python
91 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)
|