Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 27 additions & 26 deletions fnd/tui/match_navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def offscreen_views(anchors: list[int], stops: list[int], top: int, vh: int) ->
# prove only that two samples matched, and this cache has already been wrong
# that way twice.
#
# The read is `enumerate_stop_regions` over the pane subtree, so it scales with
# The read is `enumerate_stop_rows` over the pane subtree, so it scales with
# the mounted STOPS, not just the chunks: 1.2ms warm at 13 chunks / 36 stops,
# 2.6ms at 102 chunks / 882 stops (the densest in this corpus) and 3.5ms at 1592
# stops on a synthetic shape denser than anything in it, against a
Expand All @@ -155,18 +155,18 @@ class MatchNavigator:
nav scroll-settle window (region reads did — measured to stall the
landing). It is cached; the footer reads the cache, so a focus change
never re-walks the preview subtree (a perf contract the pane keeps).
* **Navigation** (``next``/``prev``) enumerates the match REGIONS fresh on
* **Navigation** (``next``/``prev``) enumerates the match stops fresh on
the keypress — a snapshot goes stale as the lazy-mounted preview reflows
(the reported bug: n did nothing because the snapshot was captured empty
before a deep table laid out). Region reads here are fine: they happen on
before a deep table laid out). Layout reads here are fine: they happen on
a deliberate keypress, never during a cold-nav settle.
* **The ▲a/▼b view arrows** answer the original awareness question — "does
the result I'm on have matches I can't see?" Users navigate *between*
results with the results-pane arrows, which skip straight past matches
lower down in the *same* chunk; the arrows count how many screenfuls
("views") of the CURRENT result hold an off-screen match, above and below.
Everything here is scoped to the current chunk (``anchor.focus_chunk_seq``
and its widget extent) — never the whole file. The counts ARE region-
and its widget extent) — never the whole file. The counts ARE layout-
derived, but the read is deferred to settle-safe moments only (after the
mount settles, on a nav keypress, on a user scroll) and cached, so the
border refresh stays layout-free.
Expand Down Expand Up @@ -246,10 +246,10 @@ def _stops_within(self, root: Widget, spec: MatchSpec) -> int:
layout is forced: one per matching table cell, one per non-table match
block, one per matching plain line.

Deliberately per BLOCK where ``enumerate_stop_regions`` is per row: the
Deliberately per BLOCK where ``enumerate_stop_rows`` is per row: the
rows need layout, and this runs on paths that must not force it. They go
to zero together on a laid-out subtree only: a mounted block with no
geometry counts here and yields no region, so the hint can lead the keys
geometry counts here and yields no row, so the hint can lead the keys
while a fill is hiding chunks.

Shared by the preview-wide count and the current-chunk check so the two
Expand Down Expand Up @@ -299,17 +299,12 @@ def _count_stops(self, pane: VerticalScroll) -> int:
"""Stops across the whole mounted preview — the footer-hint cache."""
return self._stops_within(pane, self._app._effective_match_spec)

def _region_stops(self, pane: VerticalScroll) -> list[int]:
"""Match stops as content-space tops (reads regions — call only on a
def _content_stops(self, pane: VerticalScroll) -> list[int]:
"""Match stops as content rows (forces layout, so call only on a
deliberate navigation keypress, never during a cold-nav settle)."""
from fnd.tui.preview_scroll import enumerate_stop_regions
from fnd.tui.preview_scroll import enumerate_stop_rows

spec = self._app._effective_match_spec
if spec.is_empty:
return []
base = pane.scrollable_content_region.offset.y
oy = pane.scroll_offset.y
return sorted(r.y - base + oy for r in enumerate_stop_regions(pane, spec))
return enumerate_stop_rows(pane, self._app._effective_match_spec)

def _current_chunk_extent(self, pane: VerticalScroll) -> tuple[int, int] | None:
"""Content-space ``[top, bottom)`` of the CURRENT result's chunk — the
Expand All @@ -330,32 +325,38 @@ def _current_chunk_extent(self, pane: VerticalScroll) -> tuple[int, int] | None:
cur = widgets.get(seq)
if cur is None:
return None
base = pane.scrollable_content_region.offset.y
oy = pane.scroll_offset.y

def ctop(w: object) -> int | None:
region = getattr(w, "region", None)
if region is None or region.height <= 0:
return None
return region.y - base + oy
return self._chunk_top(pane, w)

top = ctop(cur)
if top is None:
return 0, 0 # EMPTY, not None: None unscopes the caller (see docstring)
# Bound on the next chunk that HAS laid out: a zero-height one leaks no
# stop (every arm of ``enumerate_stop_regions`` drops one), and for a
# stop (every arm of ``enumerate_stop_rows`` drops one), and for a
# plain chunk ``chunk_widgets`` holds only its first LINE.
laters = [
y for s2, w in widgets.items() if s2 > seq and (y := ctop(w)) is not None and y > top
]
bottom = min(laters) if laters else max(top + 1, pane.virtual_size.height)
return top, bottom

@staticmethod
def _chunk_top(pane: VerticalScroll, widget: object) -> int | None:
"""``widget``'s content row, or ``None`` while it is not laid out."""
from textual.widget import Widget

from fnd.tui.preview.match_row import layout_offset

if not isinstance(widget, Widget):
return None
return layout_offset(widget, pane, into_ancestor=False)

def _chunk_stops(self, pane: VerticalScroll) -> list[int]:
"""The current result's match stops (content-space tops). An unknown
extent (flat preview) falls back to every mounted stop — which on that
substrate is none, so the keys are inert rather than unscoped."""
stops = self._region_stops(pane)
stops = self._content_stops(pane)
extent = self._current_chunk_extent(pane)
if extent is None:
return stops
Expand All @@ -365,12 +366,12 @@ def _chunk_stops(self, pane: VerticalScroll) -> list[int]:
def _offscreen_views(self, pane: VerticalScroll) -> tuple[int, int]:
"""Matching views above / below the viewport, scoped to the current
result. ``(0, 0)`` when the chunk extent is unknown (no false signal on
flat previews). Reads regions — settle-safe callers only."""
flat previews). Forces layout: settle-safe callers only."""
extent = self._current_chunk_extent(pane)
if extent is None:
return 0, 0
lo, hi = extent
stops = [y for y in self._region_stops(pane) if lo <= y < hi]
stops = [y for y in self._content_stops(pane) if lo <= y < hi]
vh = pane.scrollable_content_region.height
# Not pinned here: a landing may still be committing. _go pins it.
home = self._home(lo)
Expand Down Expand Up @@ -649,7 +650,7 @@ def _recount_if_empty(self) -> None:

def _measure_offscreen(self) -> None:
"""Re-derive the cached ▲/▼ view counts (current result, above/below the
viewport) and refresh the border only when they changed. Reads regions —
viewport) and refresh the border only when they changed. Forces layout:
call only when the scroll is settled (post-settle, a deliberate nav, or a
user scroll)."""
pane = self._pane()
Expand Down
2 changes: 1 addition & 1 deletion fnd/tui/preview/frozen.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def freeze(chunk: Widget, chunk_seq: int) -> FrozenChunk | None:
cell_rows: dict[tuple[int, int, int], int] = {}
if isinstance(chunk, FNDMarkdown):
inner = chunk.first_match_block
# The chunk's own spec, so this asks exactly what enumerate_stop_regions
# The chunk's own spec, so this asks exactly what enumerate_stop_rows
# asks of the live blocks — a block whose spans were cleared after it
# registered would otherwise get a scanned row live and row 0 frozen.
spec = chunk.match_spec
Expand Down
2 changes: 1 addition & 1 deletion fnd/tui/preview/match_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def _match_rows(block: Widget, spec: MatchSpec | None) -> list[int]:
offsets = _match_offsets(block, plain, spec)
if not offsets:
return []
# Cached per block: ``enumerate_stop_regions`` asks every mounted match block
# Cached per block: ``enumerate_stop_rows`` asks every mounted match block
# on every n/b press — 56 wrapped 4,000-char paragraphs cost 13.8ms cold and
# 0.03ms after. Geometry is in the key, so a re-wrap recomputes.
key = (content.width, content.height, len(plain), tuple(offsets))
Expand Down
91 changes: 49 additions & 42 deletions fnd/tui/preview_scroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -1070,15 +1070,16 @@ def _scroll_proxy_for(self, inner: Widget, *, chunk: FNDMarkdown) -> Widget:

def locate(self) -> ViewportLocation | None:
"""The chunk whose top is nearest the viewport top, and the signed rows
between them. Only the boundary survives a re-wrap, so the nearest one
bounds the error: 13 rows out to 0, 60 rows into a 62-row chunk."""
between them, in the layout frame ``scroll_to_location`` restores in.
Only the boundary survives a re-wrap, so the nearest one bounds the
error: 13 rows out to 0, 60 rows into a 62-row chunk."""
pane = self._host.preview_pane()
top = pane.scrollable_content_region.y
top = int(pane.scroll_offset.y)
nearest: tuple[int, int] | None = None
for seq, w in self._host.chunk_widgets.items():
r = w.region
r = w.virtual_region
if r.height <= 0:
continue # culled: NULL_REGION reads as y=0, not as a position
continue # culled: an empty region reads as y=0, not as a position
offset = top - r.y
if nearest is None or abs(offset) < abs(nearest[0]):
nearest = (offset, seq)
Expand Down Expand Up @@ -1210,33 +1211,35 @@ def _restore_structural(
done()


def stop_region_for_cell(table: DataTable[Any], coord: Any) -> Region | None:
"""Screen-space region of a DataTable cell, or ``None`` if unresolved
(rows unmounted / not sized). Mirrors ``StructuralScrollStrategy.
_anchor_region``: the full-height table has no internal scroll, but honour
its offset defensively."""
def stop_row_in_table(table: DataTable[Any], coord: Any) -> int | None:
"""A DataTable cell's row below the table's top, or ``None`` while its rows
are unsized. The full-height table has no internal scroll, but its offset is
honoured defensively, as ``StructuralScrollStrategy._anchor_region`` does."""
try:
cell = table._get_cell_region(coord) # pyright: ignore[reportAttributeAccessIssue]
except Exception:
return None
if cell.height == 0:
return None
return cell.translate(table.region.offset - table.scroll_offset)
return cell.y - int(table.scroll_offset.y)


def enumerate_stop_regions(pane: VerticalScroll, spec: MatchSpec) -> list[Region]:
"""Every match stop's screen-space region across the pane's mounted
chunks, sorted by content-space y. Tables expand to one region per matching
cell (``_fnd_match_coords``); other ``FNDMarkdown`` blocks contribute one
per row they paint a match on; plain (pdf/txt) chunks contribute one per
matching body line.
def enumerate_stop_rows(pane: VerticalScroll, spec: MatchSpec) -> list[int]:
"""Every match stop's content row across the pane's mounted chunks, sorted.
Tables expand to one stop per matching cell (``_fnd_match_coords``); other
``FNDMarkdown`` blocks contribute one per row they paint a match on; plain
(pdf/txt) chunks contribute one per matching body line.

Off-screen cells of a mounted table resolve fine — the table is one
full-height widget — so a big flashcards/glossary table is fully covered
Read from layout positions, never ``region``: a region lags a scroll until
the next render, and reading one off screen rebuilds the map at the live
offset, so regions in one pass need not share a frame.

Off-screen cells of a mounted table resolve fine (the table is one
full-height widget), so a big flashcards or glossary table is fully covered
once its chunk is mounted. Queries descendants (chunks live inside a
``PreviewContainer``), not just the pane's direct children.

A FROZEN chunk has no blocks to walk — that is the point of freezing — so its
A FROZEN chunk has no blocks to walk (that is the point of freezing), so its
stops come from the rows recorded at capture time. Without this a frozen
chunk contributes nothing, and its matches become unreachable by ``n``/``b``
and invisible to the off-screen markers: the failure is silent, which is
Expand All @@ -1245,57 +1248,61 @@ def enumerate_stop_regions(pane: VerticalScroll, spec: MatchSpec) -> list[Region

from fnd.render import text_has_any_match
from fnd.tui.preview.frozen import FrozenChunkView
from fnd.tui.preview.match_row import region_at_row, rows_to_matches
from fnd.tui.preview.match_row import layout_offset, rows_to_matches
from fnd.tui.widgets.markdown import (
FNDMarkdown,
FNDMarkdownTableDT,
FNDMarkdownTD,
FNDMarkdownTH,
)

regions: list[Region] = []
rows: list[int] = []
if spec.is_empty:
return regions
return rows
for md in pane.query(FNDMarkdown):
md_top = layout_offset(md, pane, into_ancestor=False)
if md_top is None:
continue
# Tables: query the DataTable directly for every matching cell. (The
# table's TH/TD cells also self-register in match_blocks as phantom,
# never-mounted blocks — skip them below; the table owns their cells.)
# never-mounted blocks; skip them below, the table owns their cells.)
for dt in md.query(DataTable):
dt_top = layout_offset(dt, md, into_ancestor=True)
if dt_top is None:
continue
for coord in getattr(dt, "_fnd_match_coords", []):
r = stop_region_for_cell(dt, coord)
if r is not None:
regions.append(r)
cell = stop_row_in_table(dt, coord)
if cell is not None:
rows.append(md_top + dt_top + cell)
# Non-table match blocks, on every row a match PAINTS on: a stop on the
# block's top row sends n/b, and the ▲▼ markers that read this, to a row
# with no match on it.
for block in md.match_blocks:
if isinstance(block, FNDMarkdownTableDT | FNDMarkdownTD | FNDMarkdownTH):
continue
if block.region.height > 0:
regions.extend(
region_at_row(block.region, row) for row in rows_to_matches(block, spec)
)
top = layout_offset(block, md, into_ancestor=True)
if top is not None:
rows.extend(md_top + top + row for row in rows_to_matches(block, spec))
# Frozen chunks: the stops were recorded as rows while the blocks still
# existed, so they resolve by offset from the view's own top. A table cell's
# row came from the same capture, which is why they need no special case
# here — unlike the live path above, where a cell has to be resolved against
# here, unlike the live path above, where a cell has to be resolved against
# a DataTable that may not have laid its rows out yet.
for view in pane.query(FrozenChunkView):
base = view.region
if base.height == 0:
top = layout_offset(view, pane, into_ancestor=False)
if top is None:
continue
for row in view.frozen.stop_rows:
if 0 <= row < base.height:
regions.append(Region(base.x, base.y + row, base.width, 1))
height = view.virtual_region.height
rows.extend(top + row for row in view.frozen.stop_rows if 0 <= row < height)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Plain (pdf/txt) chunks render one Static per body line; a matching line
# is a stop.
for line in pane.query("Static.chunk-line"):
txt = getattr(line, "fnd_text", None)
if txt and text_has_any_match(txt, spec) and line.region.height > 0:
regions.append(line.region)
# Content-space y so the order is stable regardless of the live scroll.
regions.sort(key=lambda r: r.y + pane.scroll_offset.y)
return regions
if txt and text_has_any_match(txt, spec):
top = layout_offset(line, pane, into_ancestor=False)
if top is not None:
rows.append(top)
return sorted(rows)


class FlatHost(Protocol):
Expand Down
2 changes: 1 addition & 1 deletion tests/_failure_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def _match_nav_state(app: Any) -> str:
# from these against the viewport, so a disagreement between where
# the scroll landed and where the stops are is only readable here.
("chunk_stops", lambda: nav._chunk_stops(nav._pane())),
("all_stops", lambda: nav._region_stops(nav._pane())),
("all_stops", lambda: nav._content_stops(nav._pane())),
("chunk_extent", lambda: nav._current_chunk_extent(nav._pane())),
("viewport", lambda: _viewport(nav._pane())),
],
Expand Down
2 changes: 1 addition & 1 deletion tests/test_match_nav_cross_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def _nav_pressing(sections: list[int], *, chunk_has_stop: bool, stops: list[int]
)
taken: list[bool] = []
nav._pane = lambda: pane # type: ignore[assignment]
nav._region_stops = lambda _p: stops # type: ignore[assignment]
nav._content_stops = lambda _p: stops # type: ignore[assignment]
nav._current_chunk_extent = lambda _p: (0, 100) # type: ignore[assignment]
nav._select_section_row = lambda seq: (taken.append(seq), True)[1] # type: ignore[assignment]
return nav, taken
Expand Down
Loading
Loading