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
54 changes: 47 additions & 7 deletions fnd/tui/preview/presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions fnd/tui/preview/tuning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/test_a_freeze_sweep_returns_for_unlaid_chunks.py
Original file line number Diff line number Diff line change
@@ -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",
)
17 changes: 12 additions & 5 deletions tests/test_outline_pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading