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
15 changes: 14 additions & 1 deletion src/korvid/ui/widgets/pick_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.css.query import NoMatches
from textual.screen import ModalScreen
from textual.widgets import OptionList, Static

Expand Down Expand Up @@ -62,7 +63,19 @@ def compose(self) -> ComposeResult:
yield OptionList(*self._options)

def on_mount(self) -> None:
option_list = self.query_one(OptionList)
self._focus_options()

def _focus_options(self, *, retry: bool = True) -> None:
try:
option_list = self.query_one(OptionList)
except NoMatches:
# `on_mount` can fire before compose children are queryable; try
# again once the screen has refreshed rather than taking the app
# down. Only once, so a screen that somehow never composes an
# option list fails quietly instead of looping forever.
if retry:
self.call_after_refresh(self._focus_options, retry=False)
return
option_list.highlighted = 0
option_list.focus()

Expand Down
58 changes: 58 additions & 0 deletions tests/ui/test_pick_screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""`PickScreen` must survive being mounted before its options exist."""

from __future__ import annotations

from collections.abc import Callable
from typing import Any

from korvid.ui.widgets.pick_screen import PickScreen


def _capture_scheduled_calls(screen: PickScreen) -> list[Callable[..., Any]]:
"""Record `call_after_refresh` callbacks instead of running them.

The screen is never mounted in these tests, so there is no app to drive the
refresh; capturing the callback lets a test invoke it explicitly.
"""
scheduled: list[Callable[..., Any]] = []

def record(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> bool:
scheduled.append(lambda: callback(*args, **kwargs))
return True

screen.call_after_refresh = record # type: ignore[method-assign] # exercising the unmounted path without a running app
return scheduled


def test_mount_does_not_require_the_option_list_to_exist_yet() -> None:
"""`on_mount` can fire before `compose` children are mounted.

Textual does not guarantee that a screen's composed children are queryable
by the time `on_mount` runs. When the option list is not there yet, the
screen must schedule the focus instead of raising `NoMatches` and killing
the whole app (observed as an intermittent CI failure in
`tests/ui/test_ctx_switch.py`).
"""
screen = PickScreen("pick a context", ["ctx-a", "ctx-b"])
scheduled = _capture_scheduled_calls(screen)

screen.on_mount()

assert scheduled, "focus should be retried once the option list is composed"


def test_the_deferred_focus_retry_does_not_reschedule_itself() -> None:
"""A screen whose options never appear must stop, not spin.

The retry exists to survive one lost race, not to poll forever: if the
option list is still missing when the deferred callback runs, the screen
gives up quietly rather than queueing more work on every refresh.
"""
screen = PickScreen("pick a context", ["ctx-a", "ctx-b"])
scheduled = _capture_scheduled_calls(screen)
screen.on_mount()
retry = scheduled.pop()

retry()

assert not scheduled, "the retry must not queue another retry"
Loading