From 85d3543b75ded5343b2db68797c1f564f33509e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:55:49 -0400 Subject: [PATCH] Give unscheduled sidebar work its own queryable value --- src/toad/widgets/sidebar_tree.py | 24 ++++++++--- .../test_channel_member_mount_lifetime.py | 5 ++- tests/textual/test_sidebar_rows_in_steps.py | 43 ++++++++++++------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 7342ad0ba..305ae6fd8 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -221,6 +221,22 @@ def reconcile_keys(self, keys: Iterable[KeyT], *, changed: frozenset[KeyT]): return ordered +class NoWorker: + """No work was scheduled; queries need no absence branch or native task.""" + + @property + def is_finished(self) -> bool: + return True + + @property + def is_running(self) -> bool: + return False + + @property + def error(self) -> None: + return None + + class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" @@ -239,8 +255,8 @@ class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): _rows_pending = False """A reconciliation ran out of its step and left rows unmounted.""" - _row_mounting: Worker[None] | None = None - """The worker mounting pending rows after the following frames.""" + _row_mounting: Worker[None] | NoWorker = NoWorker() + """The pending-row worker, or unscheduled work answering the same queries.""" def __init__( self, @@ -390,9 +406,7 @@ def reconcile_rows(self, *, changed: frozenset[str]): self._rows_pending = False self._row_deadline = perf_counter_ns() + self.screen.LAYOUT_STEP_NS ordered = self.reconcile_keys(keys, changed=changed) - if self._rows_pending and ( - self._row_mounting is None or self._row_mounting.is_finished - ): + if self._rows_pending and self._row_mounting.is_finished: self._row_mounting = self.run_worker( self._mount_pending_rows(), group="sidebar-rows" ) diff --git a/tests/textual/test_channel_member_mount_lifetime.py b/tests/textual/test_channel_member_mount_lifetime.py index b0c84bb33..f4075416b 100644 --- a/tests/textual/test_channel_member_mount_lifetime.py +++ b/tests/textual/test_channel_member_mount_lifetime.py @@ -23,10 +23,11 @@ async def test_pending_channel_members_stop_when_sidebar_hides_and_resume_when_s # for the next frame; do not fabricate a model or a worker result. screen.LAYOUT_STEP_NS = 0 group.toggle_members() - await ui.until(lambda: group._row_mounting is not None - and group._row_mounting.is_running, + await ui.until(lambda: group._row_mounting.is_running, why='member mounting waiting for its next frame') worker = group._row_mounting + group.sync_members(frozenset()) + assert group._row_mounting is worker sidebar.collapsed = True assert not tree.model_state.shows_model() finally: diff --git a/tests/textual/test_sidebar_rows_in_steps.py b/tests/textual/test_sidebar_rows_in_steps.py index eb6a94a27..c2f72eca7 100644 --- a/tests/textual/test_sidebar_rows_in_steps.py +++ b/tests/textual/test_sidebar_rows_in_steps.py @@ -7,10 +7,11 @@ from textual.widgets import Static from toad.textual_colours import ThemedApp -from toad.widgets.sidebar_tree import SidebarGroup +from toad.tone_paint import AlternatingTone +from toad.widgets.sidebar_tree import SidebarGroup, TargetTree -class Row(Static): +class Row(AlternatingTone, Static): pass @@ -19,20 +20,28 @@ class Group(SidebarGroup): def __init__(self, count: int) -> None: self.keys = tuple(f"row-{index}" for index in range(count)) - self.members: dict[str, Row] = {} self.painted: list[str] = [] super().__init__(Static("group"), expanded=True) - def on_mount(self) -> None: - self.sync_members() + @property + def member_keys(self) -> tuple[str, ...]: + return self.keys - def sync_members(self, changed=None) -> None: - def update(key, row): - if changed is None or key in changed or not row.has_class("-painted"): - row.add_class("-painted") - self.painted.append(key) + def new_row(self, key: str) -> Row: + return Row(key) - self.reconcile_rows(self.keys, self.members, lambda key: Row(key), update) + def show_member(self, key: str, row: Row) -> None: + row.add_class("-painted") + self.painted.append(key) + + def sync_members(self, changed: frozenset[str]) -> None: + self.reconcile_rows(changed=changed) + + +class Tree(TargetTree): + @property + def navigation_groups(self): + return self.query(Group) class Rows(ThemedApp, App): @@ -43,6 +52,7 @@ def __init__(self, count: int) -> None: def compose(self) -> ComposeResult: yield Static("before") + yield Tree() def _display(self, screen: Screen, renderable) -> None: if renderable is not None: @@ -57,12 +67,12 @@ async def test_long_list_mounts_a_step_per_frame_in_order(self): async with app.run_test(size=(60, 30)) as pilot: app.screen.LAYOUT_STEP_NS = 1_000_000 group = Group(app.count) - await app.screen.mount(group) + await app.screen.query_one(Tree).mount(group) first = len(group.member_container.children) self.assertGreater(first, 0) self.assertLess(first, app.count, "the first step mounts only part of the list") await pilot.pause() - while group._row_mounting is not None and not group._row_mounting.is_finished: + while not group._row_mounting.is_finished: await pilot.pause() await pilot.pause() children = list(group.member_container.children) @@ -82,8 +92,11 @@ async def test_short_list_mounts_at_once(self): # so the budget is fixed here the way the long-list test fixes it. app.screen.LAYOUT_STEP_NS = 1_000_000_000 group = Group(app.count) - await app.screen.mount(group) + unscheduled = group._row_mounting + self.assertTrue(unscheduled.is_finished) + self.assertFalse(unscheduled.is_running) + await app.screen.query_one(Tree).mount(group) self.assertEqual(len(group.member_container.children), app.count) - self.assertIsNone(group._row_mounting) + self.assertIs(group._row_mounting, unscheduled) await pilot.pause()