diff --git a/fnd/tui/preview/presenter.py b/fnd/tui/preview/presenter.py index 6d80dfa1..dfde6da8 100644 --- a/fnd/tui/preview/presenter.py +++ b/fnd/tui/preview/presenter.py @@ -179,6 +179,8 @@ def __init__(self, app: FNDApp) -> None: self.mount_phase: str | None = None self.init_coverage_state() self._stale_strip_repair: asyncio.Task[None] | None = None + self._unlaid_refreeze: asyncio.Task[None] | None = None + self._freeze_generation = 0 # Per-chunk captures, filled by coverage and read by every mount path. # Separate from the document store because this one is SPARSE: it holds # the matches scattered through a file, which a contiguous document @@ -2468,7 +2470,12 @@ def above_window_start(self, chunks: list[FileChunk], focus_idx: int, viewport_h return start async def _freeze_chunks_outside_window( - self, container: PreviewContainer, chunks: list[FileChunk], win_start: int, win_end: int + self, + container: PreviewContainer, + chunks: list[FileChunk], + win_start: int, + win_end: int, + retries: int | None = None, ) -> None: """Swap every background-filled chunk for its frozen capture. @@ -2501,6 +2508,15 @@ async def _freeze_chunks_outside_window( if _os.environ.get("_FND_NO_FREEZE") == "1": return + # The newest window wins: a retry, or an overlapping sweep, holding an older + # one would freeze the chunk a later navigation keeps live. + pending = self._unlaid_refreeze + if pending is not None and pending is not asyncio.current_task() and not pending.done(): + pending.cancel() + first_pass = retries is None + retries = tuning.FREEZE_UNLAID_RETRIES if retries is None else retries + self._freeze_generation += 1 + generation = self._freeze_generation await self.await_settled() if self.active is not container: return @@ -2522,7 +2538,7 @@ async def _freeze_chunks_outside_window( pane_width = self.capture_width(self._app.query_one("#preview_pane", VerticalScroll)) except Exception: pane_width = 0 - frozen = 0 + frozen = unlaid = 0 sweep_started = time.perf_counter() slice_start = sweep_started for index, chunk in enumerate(chunks): @@ -2549,6 +2565,7 @@ async def _freeze_chunks_outside_window( continue captured = freeze(widget, chunk.chunk_seq) if captured is None: + unlaid += widget.size.height == 0 continue view = FrozenChunkView(captured) try: @@ -2593,15 +2610,38 @@ async def _freeze_chunks_outside_window( # sweep is measured for how long it holds the loop. with contextlib.suppress(Exception): self._app.call_after_refresh(self._app._match_nav.on_preview_scrolled) - self.diag_log( - f"mount served={container.served_chunks} built={container.built_chunks} " - f"parent={container.parent_doc_id[:8]}" - ) + if first_pass: + self.diag_log( + f"mount served={container.served_chunks} built={container.built_chunks} " + f"parent={container.parent_doc_id[:8]}" + ) if frozen: self.diag_log( f"backfill froze={frozen} chunks in " f"{(time.perf_counter() - sweep_started) * 1000:.0f}ms" ) + if ( + unlaid + and retries > 0 + and generation == self._freeze_generation + and self.active is container + and is_live(container) + ): + # In the background: the navigation that called this must not wait. + self._unlaid_refreeze = asyncio.create_task( + self._refreeze_unlaid(container, chunks, win_start, win_end, retries - 1) + ) + + async def _refreeze_unlaid( + self, + container: PreviewContainer, + chunks: list[FileChunk], + win_start: int, + win_end: int, + retries: int, + ) -> None: + await asyncio.sleep(tuning.FREEZE_UNLAID_RETRY_SECONDS) + await self._freeze_chunks_outside_window(container, chunks, win_start, win_end, retries) def stop_background_work(self) -> None: """Cancel everything this presenter runs in the background. @@ -2612,7 +2652,7 @@ def stop_background_work(self) -> None: real corpus: 0.49s to quit with coverage off against 1.09s with it on, of which pacing the captures recovered about half and this the rest. """ - for task in (self._coverage_task, self._stale_strip_repair): + for task in (self._coverage_task, self._stale_strip_repair, self._unlaid_refreeze): if task is not None and not task.done(): task.cancel() diff --git a/fnd/tui/preview/tuning.py b/fnd/tui/preview/tuning.py index 4a252ff4..e31d496d 100644 --- a/fnd/tui/preview/tuning.py +++ b/fnd/tui/preview/tuning.py @@ -38,6 +38,12 @@ # later as an empty preview. FREEZE_REVEAL_WAIT_TICKS = 40 +# How often, and how far apart, the freeze sweep comes back for chunks not yet +# laid out (height 0). Its settle can time out on a loaded runner, and a sweep +# that met only unlaid chunks froze none and never ran again. +FREEZE_UNLAID_RETRIES = 20 +FREEZE_UNLAID_RETRY_SECONDS = 0.1 + # How long the freeze sweep may hold the event loop before yielding, in seconds. # The sweep swaps every out-of-window chunk's widget tree for its capture, and it # used to do the whole file in ONE synchronous loop — so the cold-to-frozen diff --git a/tests/test_a_freeze_sweep_returns_for_unlaid_chunks.py b/tests/test_a_freeze_sweep_returns_for_unlaid_chunks.py new file mode 100644 index 00000000..ed30d6a8 --- /dev/null +++ b/tests/test_a_freeze_sweep_returns_for_unlaid_chunks.py @@ -0,0 +1,46 @@ +"""A freeze sweep that meets chunks not yet laid out comes back for them.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from textual.containers import VerticalScroll + +from fnd.tui import FNDApp +from fnd.tui.preview.presenter import PreviewPresenter +from tests._pilot_wait import wait_until +from tests.test_a_requery_reuses_what_it_does_not_highlight import _sections + + +@pytest.mark.asyncio +async def test_a_sweep_whose_settle_gave_up_still_captures_the_fill( + tmp_path: Path, tmp_index_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A settle that times out on a loaded runner leaves the fill unlaid.""" + + async def gave_up(self: PreviewPresenter, max_rounds: int = 10) -> None: + return None + + monkeypatch.setattr(PreviewPresenter, "await_settled", gave_up) + app = FNDApp(index_dir=_sections(tmp_path, tmp_index_dir), initial_query="alphaword") + async with app.run_test(size=(100, 30)) as pilot: + await wait_until( + pilot, + lambda: bool(app._search.groups) and app._preview.active is not None, + timeout=20.0, + message="the query never showed a preview", + ) + parent_id = app._search.groups[0].parent_id + width = app._preview.capture_width(app.query_one("#preview_pane", VerticalScroll)) + searcher = app._search.searcher + assert searcher is not None + far = next( + c.chunk_seq for c in searcher.get_file_chunks(parent_id) if "betaword" in c.body_md + ) + await wait_until( + pilot, + lambda: app._preview.capture_store.get_plain(parent_id, width, far) is not None, + timeout=20.0, + message="the sweep never came back for the chunks it met unlaid", + ) diff --git a/tests/test_outline_pane.py b/tests/test_outline_pane.py index a4c183ee..315b09c6 100644 --- a/tests/test_outline_pane.py +++ b/tests/test_outline_pane.py @@ -118,12 +118,19 @@ def _layout_current(app: FNDApp) -> bool: def _landed(app: FNDApp) -> bool: + """Settled and at the landing's offset: a glide's scroll can start after it commits.""" pane = app.query_one("#preview_pane") - return not ( - app._preview_scroll.is_settling - or app._preview.pipeline_busy() - or app.animator.is_being_animated(pane, "scroll_y") - ) and _layout_current(app) + destination = app._preview_scroll.landing_destination() + arrived = destination is None or int(app._outline._scroller().scroll_offset.y) == destination + return ( + arrived + and not ( + app._preview_scroll.is_settling + or app._preview.pipeline_busy() + or app.animator.is_being_animated(pane, "scroll_y") + ) + and _layout_current(app) + ) def _followed(app: FNDApp) -> bool: