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
24 changes: 19 additions & 5 deletions src/toad/widgets/sidebar_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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,
Expand Down Expand Up @@ -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"
)
Expand Down
5 changes: 3 additions & 2 deletions tests/textual/test_channel_member_mount_lifetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 28 additions & 15 deletions tests/textual/test_sidebar_rows_in_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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

Loading