diff --git a/src/toad/app.py b/src/toad/app.py index 41e5eae9c..7b3272d4a 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -59,6 +59,7 @@ from toad.terminal_origin import TerminalOrigin from toad.clipboard import Clipboard from toad.constants import WELCOME +from toad.tab_identity import TabIdentity from toad.tab_order import TabOrder from toad.thread_navigation import ThreadNavigator from toad.session_navigation import SessionNavigation @@ -189,7 +190,7 @@ def __init__( self.sidebar_state = SidebarState() self._mode_switch_lock = asyncio.Lock() self.tab_order = TabOrder( - lambda identity, index: self.select_session(identity.mode_name, history_index=index), + lambda identity: self.show_history_step(identity.mode_name), home=WELCOME, db=DB(), scopes=connection.tab_scopes(), @@ -386,12 +387,20 @@ async def prepare_session(self, mode: str) -> SessionScope: async with self._mode_switch_lock: return await self.workspace_sessions.prepare(mode) - def select_session(self, mode: str, *, history_index: int | None = None) -> AwaitComplete: + def select_session(self, mode: str) -> AwaitComplete: + """A person's selection: its visit is recorded once the tab is shown.""" + return self._switch(mode, self.tab_order.record_visit) + + def show_history_step(self, mode: str) -> AwaitComplete: + """A history step: TabOrder recorded its visit when the step was taken.""" + return self._switch(mode, lambda identity: None) + + def _switch(self, mode: str, visited: Callable[[TabIdentity], object]) -> AwaitComplete: if mode != self.selected_mode: self.navigation_reader.invalidate() - return AwaitComplete(self._switch_mode_ready(mode, history_index=history_index)) + return AwaitComplete(self._switch_mode_ready(mode, visited)) - async def _switch_mode_ready(self, mode: str, *, history_index: int | None = None) -> None: + async def _switch_mode_ready(self, mode: str, visited: Callable[[TabIdentity], object]) -> None: async with self._mode_switch_lock: previous = self.selected_mode try: @@ -413,7 +422,7 @@ async def _switch_mode_ready(self, mode: str, *, history_index: int | None = Non await self.workspace_screen.layout_navigation() if mode != previous: if entry := self.session_navigation.get(mode): - self.tab_order.record_visit(entry.identity, history_index) + visited(entry.identity) self.events.publish(core_events.SessionSelected(mode, previous)) finally: self.screen.refresh() diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index e941dc1a2..56cc60a70 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -13,7 +13,7 @@ class TabOrder: def __init__( - self, focus: Callable[[TabIdentity, int | None], object], home: str, db: DB, + self, focus: Callable[[TabIdentity], object], home: str, db: DB, scopes: tuple[TabScope, ...], ) -> None: # What is shown while no tab is open. @@ -105,12 +105,15 @@ def history_target(self, direction: int) -> tuple[int, TabIdentity] | None: return None def navigate(self, direction: int) -> None: + """The cursor moves when the step is taken: a switch runs over several frames, and + a second step taken meanwhile counts from this one, not from where it started.""" if target := self.history_target(direction): index, mode = target - if mode == self._visits[self._cursor]: - self.record_visit(mode, index) - else: - self._focus(mode, index) + switching = mode != self._visits[self._cursor] + self._cursor = index + self.events.publish(TabHistoryChanged()) + if switching: + self._focus(mode) def came_from(self, current: TabIdentity) -> str: """The tab selected before ``current``: the newest other visit, then the open tabs; @@ -120,19 +123,13 @@ def came_from(self, current: TabIdentity) -> str: current.mode_name if current in self else self.home, ) - def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> None: + def record_visit(self, mode: TabIdentity) -> None: + """A new visit: it drops the steps ahead of the cursor and becomes the newest.""" if mode not in self: return - if ( - history_index is not None - and 0 <= history_index < len(self._visits) - and self._visits[history_index] == mode - ): - self._cursor = history_index - else: - del self._visits[self._cursor + 1 :] - self._visits.append(mode) - self._cursor = len(self._visits) - 1 + del self._visits[self._cursor + 1 :] + self._visits.append(mode) + self._cursor = len(self._visits) - 1 self.events.publish(TabHistoryChanged()) async def close(self, modes: Collection[TabIdentity]) -> None: diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 9e3fdd60d..df347eaf0 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -19,6 +19,7 @@ from agent_comms.child_process import join_retirement from agent_comms.coordination.errors import ( CoordinationReadUnavailable, + ReadAgain, ReloadRequired, StaleRevision, TranscriptChanged, @@ -345,7 +346,9 @@ def ready_to_commit(self) -> bool: return False async def read_page(self, scope, *, position: PagePosition, limit: PageLimit) -> TranscriptPage: - raise ValueError("Transcript paging requires a bound thread") + # A page request queued before the view lost its binding: its answer no longer + # stands, and the request a new binding makes reads again. + raise ReadAgain() async def publish(self) -> bool: """Nothing to publish: the request is done.""" @@ -1020,10 +1023,17 @@ def require_commit(self) -> None: async def mark_read( self, through: TranscriptCursor, acknowledge: Callable[[TranscriptCursor], None], selected: Callable[[], bool] ) -> None: - """A replaced source gets one fresh painted cursor, never a fatal read worker.""" + """A replaced source gets one fresh painted cursor, never a fatal read worker. + + An acknowledgement is in-flight view work: if this source's work was revoked + while it ran (suspended, retired, closed or changed), the work that replaced it + paints and acknowledges its own cursor, so a stale one asks for nothing.""" + generation = self.generation try: await asyncio.to_thread(acknowledge, through) except TranscriptChanged: + if self.generation != generation: + return try: fresh = await self.refresh_cursor() if selected(): diff --git a/src/toad/transcript_source_preparation.py b/src/toad/transcript_source_preparation.py index 33d2121fe..c4637ee34 100644 --- a/src/toad/transcript_source_preparation.py +++ b/src/toad/transcript_source_preparation.py @@ -94,8 +94,9 @@ def current(self, owner: TranscriptSourcePreparation) -> bool: Being on screen decides whether rows can be drawn, not whether the read may finish: requiring it here discarded every read a tab started while it was not visible, so the whole missed run loaded in one burst on return. + A history that has left its window holds no source, so no snapshot is current. """ - return self == owner.source_snapshot() + return owner.is_attached and self == owner.source_snapshot() class TranscriptSourcePreparation(CoreEventReceiver): diff --git a/src/toad/transcript_state.py b/src/toad/transcript_state.py index 7a9c4cf19..43ab129b6 100644 --- a/src/toad/transcript_state.py +++ b/src/toad/transcript_state.py @@ -319,6 +319,11 @@ def source(self, owner: ConversationHistory): async def page(self, owner: TranscriptSourcePreparation, source: PreparedPageSource): return self.initial.for_view() + def defer(self, owner: TranscriptSourcePreparation) -> LatestViewportState: + """A commit's page publishes only with its commit, which records the painted cursor and + retries itself; drawn later on its own, its rows would show a source the cursor never reached.""" + return NoLatestRequest() + @dataclass(frozen=True, eq=False) class WaitingSourceWork[Result]: diff --git a/src/toad/widgets/styled_rows.py b/src/toad/widgets/styled_rows.py index aaf000736..9eb5be6c8 100644 --- a/src/toad/widgets/styled_rows.py +++ b/src/toad/widgets/styled_rows.py @@ -48,6 +48,14 @@ class StyledRows(RestylesOnTheme, LinkedRows, ToadNode, Widget): def styled_blocks(self) -> tuple[LineBlock, ...]: """The semantic rows the next render styles.""" + @property + def rows_ready(self) -> bool: + """Whether the drawn rows are the render worker's for the current blocks. + + The rows come from the render processes, which no message queue owes, so a caller that + needs them (a click aimed at text, a copy of what is drawn) waits on this receipt.""" + return self._render_worker.is_finished and not self._styled.unstyled(self) + def invalidate_lines(self) -> None: """Restyle for a new theme or link context; the drawn rows stand until then.""" self._request_render() diff --git a/tests/model/test_session_name.py b/tests/model/test_session_name.py index ad941c725..1cde55306 100644 --- a/tests/model/test_session_name.py +++ b/tests/model/test_session_name.py @@ -50,7 +50,7 @@ def record(self, event: SessionChangedEvent, _subscription) -> None: def test_session_tracker_publishes_only_changed_labels(tmp_path): - order = TabOrder(lambda identity, index: None, home="home", db=DB(tmp_path / "toad.db"), + order = TabOrder(lambda identity: None, home="home", db=DB(tmp_path / "toad.db"), scopes=(TabScope("test-core"),)) tracker = SessionTracker(order) details = tracker.new_session(AcpTab(1), title="old") diff --git a/tests/model/test_tab_visit_history.py b/tests/model/test_tab_visit_history.py index 1f981f505..462288c37 100644 --- a/tests/model/test_tab_visit_history.py +++ b/tests/model/test_tab_visit_history.py @@ -13,7 +13,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no database = DB(tmp_path / "toad.db") await database.create() order = TabOrder( - lambda mode, index: focused.append((mode, index)), + focused.append, home="home", db=database, scopes=(TabScope("test-core"),), ) await order.load() @@ -21,8 +21,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no await order.open(mode) order.record_visit(mode) order.navigate(-1) - mode, index = focused[-1] - order.record_visit(mode, index) + mode = focused[-1] await order.open(new_kind, after=mode) order.record_visit(new_kind) assert order.names == (first, second, new_kind, third) @@ -30,7 +29,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no assert order.previous(new_kind) == second.mode_name await order.close({second, third}) order.navigate(-1) - assert focused[-1][0] == first + assert focused[-1] == first assert order.previous(new_kind) == first.mode_name assert order.project({first: 1, new_kind: 2, closed: 3}) == (1, 2) @@ -39,7 +38,7 @@ async def test_home_is_shown_only_while_no_tab_is_open(tmp_path): only, closed = PreviewTab(tmp_path / "only"), PreviewTab(tmp_path / "closed") database = DB(tmp_path / "toad.db") await database.create() - order = TabOrder(lambda mode, index: None, home="home", db=database, scopes=(TabScope("test-core"),)) + order = TabOrder(lambda mode: None, home="home", db=database, scopes=(TabScope("test-core"),)) await order.load() assert order.previous(closed) == "home" await order.open(only) diff --git a/tests/native/test_native_tool_output.py b/tests/native/test_native_tool_output.py index e69c2b2fb..9be86aba8 100644 --- a/tests/native/test_native_tool_output.py +++ b/tests/native/test_native_tool_output.py @@ -9,7 +9,6 @@ from textual.selection import SELECT_ALL from toad.widgets.tool_call import ToolCall from toad.widgets.tool_content import ToolContent -from support.styling import awaits_styling SOURCE = "# Native tool output\nvalue = 1\n" CALLS = ( @@ -56,7 +55,7 @@ def drawn(tool, text): read.scroll_visible(animate=False) await ui.until(lambda: bool(read.query(ToolContent)), why="the read output") code = read.query_one(ToolContent) - await ui.until(lambda: not awaits_styling(code._styled), why="prepared read rows") + await ui.until(lambda: code.rows_ready, why="prepared read rows") assert "value = 1" in code.get_selection(SELECT_ALL)[0] await ui.until(lambda: drawn(read, "value = 1"), why="the read output on screen") read.set_expanded(False) @@ -80,7 +79,7 @@ def drawn(tool, text): bash.scroll_visible(animate=False) await ui.until(lambda: bool(bash.query(ToolContent)), why="the bash output") text = bash.query_one(ToolContent) - await ui.until(lambda: not awaits_styling(text._styled), why="prepared ANSI rows") + await ui.until(lambda: text.rows_ready, why="prepared ANSI rows") assert "NATIVE_TOOL_TEXT" in text.get_selection(SELECT_ALL)[0] assert "\x1b" not in text.get_selection(SELECT_ALL)[0] await ui.until(lambda: drawn(bash, "NATIVE_TOOL_TEXT"), why="the bash output on screen") diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index 2f51bd2b1..07eb82343 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -7,7 +7,6 @@ from toad.widgets.side_bar import ThreadSidebar import asyncio -from support.styling import awaits_styling async def reveal_project_tree(ui): @@ -54,27 +53,6 @@ async def reveal_session_details(ui, target=None): return details -async def command_dialog(ui, *, seconds: float = 20): - """Wait for the command dialog to be mounted, focused on its name field and clickable.""" - from textual.widgets import Input - from toad.widgets.comms_command_dialog import CommandDialog - - def usable(): - dialog = ui.app.screen - if not (isinstance(dialog, CommandDialog) and dialog.is_mounted and dialog.is_attached): - return False - field = dialog.form.values.get("name") - entry = field.editor if field is not None else None - return (isinstance(entry, Input) and entry.is_mounted and entry.is_attached - and dialog.focused is entry and ui.app.focused is entry - and entry.region.width > 0 and entry.region.height > 0 - and entry.region.offset in dialog.size.region - and dialog.get_widget_at(*entry.region.offset)[0] is entry) - - await ui.until(usable, seconds=seconds, why="the command dialog") - return ui.app.screen - - async def refresh_chat(view) -> None: """Run the chat view's history read now and wait for it; a view that is not shown reads nothing.""" history = view.message_history @@ -135,7 +113,7 @@ async def wait_for_tool_diff(ui, tool): content = tool.query_one(ToolContent) # Replaced blocks keep their old rows until the render of the new ones lands, so wait for it. - await ui.until(lambda: content._render_worker.is_finished and not awaits_styling(content._styled), + await ui.until(lambda: content.rows_ready, seconds=10, why="the styled tool rows") await ui.step() return content diff --git a/tests/support/ui.py b/tests/support/ui.py index 4eaafd7ad..3a1b17fba 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -20,6 +20,8 @@ from typing import TYPE_CHECKING from textual.actions import StyleAction +from textual.errors import NoWidget +from textual.geometry import Offset from rich.cells import cell_len from support.agents import AppAgents, command @@ -254,6 +256,11 @@ async def until_shows(self, text: str, *, seconds: float = 10) -> None: lambda: self.shows(text), seconds=seconds, why=f"{text!r} on screen" ) + async def until_delivery_shown(self) -> None: + """A sent message's delivery status arrives from Core after its rows and lifts the + bottom-anchored page; a click aimed at the message waits for it.""" + await self.until_shows("Sent ยท Observed") + class TextualUi(AppAgents, Ui): """Toad drawn by Textual, driven headless through Textual's pilot; its agents attach to that app.""" @@ -271,6 +278,24 @@ def textual(self) -> tuple[ToadApp, Pilot]: def visible_lines(self) -> list[str]: return [strip.text for strip in self.app.screen._compositor.render_strips()] + def clickable(self, widget, offset: tuple[int, int] = (0, 0)) -> bool: + """Whether a click at `offset` in `widget` reaches it: a click lands on what the + published frame shows there, which a newly pushed or moving widget may not be yet.""" + if not widget.is_attached: + return False + region, screen = widget.region, widget.screen + point = Offset(region.x + offset[0], region.y + offset[1]) + if point not in region or point not in screen.size.region: + return False + try: + return screen.get_widget_at(*point)[0] is widget + except NoWidget: + # A screen pushed since the last frame has none of its widgets placed yet. + return False + + async def until_clickable(self, widget, offset: tuple[int, int] = (0, 0), *, why: str) -> None: + await self.until(lambda: self.clickable(widget, offset), why=why) + async def menu(self, why: str) -> dict: """The open context menu's actions and their labels, once it shows.""" from toad.widgets.comms_menu import ContextMenu diff --git a/tests/textual/test_acp_log_view.py b/tests/textual/test_acp_log_view.py index 810177b2a..dbc44cf1e 100644 --- a/tests/textual/test_acp_log_view.py +++ b/tests/textual/test_acp_log_view.py @@ -50,10 +50,17 @@ def view_button(view, name): return next(button for button in view.query(LogViewButton) if button.kind is LogView.member_named(name)) -async def reread(ui, view, button): +async def press(ui, button): + """Click a log control as a user does: a button refuses a press during the last one's effect.""" _app, pilot = ui.textual - assert await pilot.click(button) + widget = button if not isinstance(button, type) else ui.app.screen.query_one(button) + await ui.until(lambda: widget.accepts_press, why=f"{type(widget).__name__} to accept a press") + assert await pilot.click(widget) await ui.step() + + +async def reread(ui, view, button): + await press(ui, button) await asyncio.wait_for(view.wait_ready(), 10) await ui.step() @@ -67,14 +74,11 @@ async def test_log_views_wrap_keyboard_resize_and_latest_read_the_physical_log(w assert "Unsettled owner input; compaction not dispatched" in area.text assert "The usage limit has been reached" in area.text assert area.soft_wrap and area.read_only - assert await pilot.click(view_button(view, "events")) - await ui.step() + await press(ui, view_button(view, "events")) assert "session/update" in area.text and "response" in area.text - assert await pilot.click(view_button(view, "raw")) - await ui.step() + await press(ui, view_button(view, "raw")) assert area.text == log.read_bytes().decode("utf-8", errors="replace") - assert await pilot.click(LogWrapButton) - await ui.step() + await press(ui, LogWrapButton) assert not area.soft_wrap # Keyboard reaches the final column rather than clipping it. longest = max(range(len(area.document.lines)), key=lambda index: len(area.document.lines[index])) @@ -89,11 +93,9 @@ async def test_log_views_wrap_keyboard_resize_and_latest_read_the_physical_log(w await ui.resize(50, 24) for button in view.query(LogButton): assert button.region.right <= app.screen.size.width, (button, button.region) - assert await pilot.click(LogWrapButton) - await ui.step() + await press(ui, LogWrapButton) assert area.soft_wrap - assert await pilot.click(view_button(view, "errors")) - await ui.step() + await press(ui, view_button(view, "errors")) assert "Provider usage limit reached" in area.text assert area.region.height >= 5 with log.open("a") as stream: @@ -128,9 +130,6 @@ async def test_earlier_and_latest_page_through_a_log_larger_than_one_page_and_re assert "Unable to read log" in area.text assert view.page is None and view.query_one(LogEarlierButton).disabled log.write_bytes(original) - # Textual ignores another click during its active effect; wait for the control to accept it. - await ui.until(lambda: not view.query_one(LogLatestButton).has_class("-active"), - why="the Latest button to accept a click") await reread(ui, view, LogLatestButton) assert "The usage limit has been reached" in area.text assert ui.failure() is None diff --git a/tests/textual/test_application_actions_in_the_running_app.py b/tests/textual/test_application_actions_in_the_running_app.py index 3eac95364..5726939ab 100644 --- a/tests/textual/test_application_actions_in_the_running_app.py +++ b/tests/textual/test_application_actions_in_the_running_app.py @@ -61,7 +61,9 @@ async def open_column_width_editor(ui, app): async def palette(ui, app, query, completed): await ui.press('ctrl+p') await ui.until(lambda: isinstance(app.screen, CommandPalette), why="the command palette") - assert await ui.textual[1].click(app.screen.query_one(Input)) + field = app.screen.query_one(Input) + await ui.until_clickable(field, why="the palette's input drawn") + assert await ui.textual[1].click(field) await ui.type(query) await ui.until(lambda: app.screen.query_one(OptionList).option_count > 0, why="palette matches") await ui.press('enter') @@ -112,11 +114,7 @@ async def test_new_session_button_opens_creation_and_tab_close_keeps_the_origina await ui.step() new = app.screen.query_one(CommsSidebar).query_one(NewSessionButton) new.scroll_visible(animate=False, immediate=True) - # A click reaches what the published frame shows (compositor - # published_widgets), not a newer layout that has not been written yet. - await ui.until(lambda: new.region.width > 3 and new.region.height > 0 - and app.screen.get_widget_at(new.region.x + 3, new.region.y)[0] is new, - why="the new-session control to be shown where it is clicked") + await ui.until_clickable(new, (3, 0), why="the new-session control to be shown where it is clicked") assert await ui.textual[1].click(new, offset=(3, 0)) await ui.until(lambda: isinstance(app.screen, CommandDialog), why="the declared new-thread creation form") diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index 66ee30225..f28265d9e 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -113,6 +113,7 @@ async def send_and_preview(tab, target): why="the original image reference delivered to the recipient", ) await ui.until_shows("clipboard-") + await ui.until_delivery_shown() expected = DirectProjectFileLink(path, original_root=workspace.project) await ui.until(lambda: ui.link_at("clipboard-") == OpenLineLink(expected), why="the complete quoted attachment rendered as a file link") diff --git a/tests/textual/test_channel_any_mode.py b/tests/textual/test_channel_any_mode.py index 0506ec596..2c642cf2a 100644 --- a/tests/textual/test_channel_any_mode.py +++ b/tests/textual/test_channel_any_mode.py @@ -39,8 +39,7 @@ async def test_channel_menu_toggles_member_activity_and_keeps_unpainted_traffic_ sidebar = await channel_roster(ui, "#team") assert ACTION in await open_menu(ui, sidebar, "#team") await ui.choose_menu_action(ACTION) - await ui.step() - assert comms.bus.require_channel("#team").any_mode + await ui.until(lambda: comms.bus.require_channel("#team").any_mode, why="member activity on") assert bodies(comms) == ["exact route", "outbound from member", "inbound to member"] assert comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#team"] == ExactUnread(count=3) @@ -53,8 +52,7 @@ async def test_channel_menu_toggles_member_activity_and_keeps_unpainted_traffic_ assert ACTION in await open_menu(ui, sidebar, "#team") await ui.choose_menu_action(ACTION) - await ui.step() - assert not comms.bus.require_channel("#team").any_mode + await ui.until(lambda: not comms.bus.require_channel("#team").any_mode, why="member activity off") assert bodies(comms) == ["exact route"] diff --git a/tests/textual/test_existing_thread_link.py b/tests/textual/test_existing_thread_link.py index 45e038d77..b24893a73 100644 --- a/tests/textual/test_existing_thread_link.py +++ b/tests/textual/test_existing_thread_link.py @@ -32,6 +32,7 @@ async def test_name_of_an_open_thread_selects_its_tab_and_creates_nothing(worksp await ui.select_tab(owner) await ui.open_channel("#team", actor="owner", owner=owner) await ui.until_shows(header.label) + await ui.until_delivery_shown() await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == OpenLineLink(sender), why="the sender drawn as a link") # The channel tab's unread count clears as its message is read; the tabs themselves must not change. diff --git a/tests/textual/test_follow_stream.py b/tests/textual/test_follow_stream.py index 2c781f9e3..965bae631 100644 --- a/tests/textual/test_follow_stream.py +++ b/tests/textual/test_follow_stream.py @@ -1,7 +1,6 @@ """Textual-facing: the history window follows live output at the tail, releases while reading, resumes at the tail.""" from toad.widgets.agent_response import AgentResponse -from support.styling import awaits_styling async def test_window_follows_tail_releases_on_scroll_up_and_resumes(workspace): @@ -9,7 +8,7 @@ async def test_window_follows_tail_releases_on_scroll_up_and_resumes(workspace): conversation = ui.conversation response = await conversation.post(AgentResponse("\n\n".join(f"Paragraph {i}" for i in range(40)))) # A block's rows are its styled rows: scrolling needs them to exist. - await ui.until(lambda: not awaits_styling(response.body._styled), why="the response's first styled rows") + await ui.until(lambda: response.body.rows_ready, why="the response's first styled rows") window = conversation.window window.anchor(False) conversation.refresh_block_cursor(lambda cursor: cursor.show_moved_to(conversation.window)) diff --git a/tests/textual/test_hover_during_drag.py b/tests/textual/test_hover_during_drag.py index 57fbc8f07..88644f8cc 100644 --- a/tests/textual/test_hover_during_drag.py +++ b/tests/textual/test_hover_during_drag.py @@ -12,8 +12,8 @@ async def test_pointer_steps_inside_one_widget_cause_no_restyle(workspace): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual response = await ui.conversation.post(AgentResponse("Select this phrase " * 25)) - await ui.step() text = response.body + await ui.until(lambda: text.rows_ready, why="the response's styled rows") await pilot.mouse_down(text, offset=(1, 0)) screen = app.screen x, y = text.region.x + 3, text.region.y diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index bf86854ba..55f887c00 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -225,8 +225,8 @@ async def test_a_status_of_the_same_height_repaints_its_rows_without_measuring_t history.show_details(page, newest, newest.shown.toggled()) await ui.until(lambda: newest.shown.details_open, why="the details opened") - await ui.step() - assert page._fragment_slots is not measured, "a taller status kept the old box" + # The taller rows come from the render processes; the page measures again when they land. + await ui.until(lambda: page._fragment_slots is not measured, why="a taller status measured again") async def test_a_reread_or_resubscribed_message_starts_from_its_known_answer(workspace): diff --git a/tests/textual/test_saved_view_read_only.py b/tests/textual/test_saved_view_read_only.py index 31746717c..0c0563f70 100644 --- a/tests/textual/test_saved_view_read_only.py +++ b/tests/textual/test_saved_view_read_only.py @@ -34,12 +34,14 @@ async def test_saved_view_shows_union_history_keeps_its_pin_and_refuses_to_send( sidebar = await channel_roster(ui, "#engineering") next(group for group in sidebar.query(ChannelGroup) if group.row.target_name == "#engineering").row.focus() await ui.press("enter") - chat = ui.textual[0].screen.query_one(CommsChatView) + screen = ui.textual[0].screen + await ui.until(lambda: screen.query(CommsChatView), why="the saved view's chat") + chat = screen.query_one(CommsChatView) await ui.until(lambda: [fragment.message.body for fragment in chat.message_history.fragments_in_range] == [m.body for m in rows], why="the saved view's history") - assert chat.prompt.prompt_text_area.disabled await ui.until(lambda: "Read-only" in chat.status, why="the read-only status") + assert chat.prompt.prompt_text_area.disabled await (await chat.submissions.submit(input_events.UserInputSubmitted("must not publish"))).wait() assert chat.prompt.text == "must not publish" assert comms.views.latest_sequence() == before diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index 143e43827..945482838 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -23,7 +23,9 @@ async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): app.sidebar_layout.move(channels.placement_identity, side) app.sidebar_layout.move(thread.placement_identity, side) app.workspace_chrome.refresh_layout() - await ui.step() + # Moving a bar recomposes its controls; read them once both bars have them again. + await ui.until(lambda: all(bar.query(SidebarAction) for bar in (channels, thread)), + why="both sidebars' arrow controls") async def click(bar: SideBar, edge) -> None: button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) diff --git a/tests/textual/test_tab_scrollbar_top.py b/tests/textual/test_tab_scrollbar_top.py index 545140f38..2e2d7bc8c 100644 --- a/tests/textual/test_tab_scrollbar_top.py +++ b/tests/textual/test_tab_scrollbar_top.py @@ -1,6 +1,6 @@ """Textual-facing: the tab strip's horizontal scrollbar is drawn above the labels and underline, with matching pointer geometry, as overflow comes and goes.""" -from toad.widgets.session_tabs import SessionLabel, SessionsTabs, SessionTabClose, Underline +from toad.widgets.session_tabs import TAB_SLIDE, SessionLabel, SessionsTabs, SessionTabClose, Underline HEIGHT = 35 @@ -13,9 +13,17 @@ async def open_peers(ui, count: int) -> list[str]: ui.app.session_tracker.name_session( ui.app.session_navigation.get(identity).identity, f"thread-{index}-long-title") peers.append(identity) + await strip_settled(ui) + return peers + + +async def strip_settled(ui) -> None: + """Wait for the strip's scroll animations and its underline's slide, which runs on Toad's own clock.""" await ui.step() await ui.pilot.wait_for_scheduled_animations() - return peers + underline = ui.app.screen.query_one(SessionsTabs).query_one(Underline) + await ui.until(lambda: not underline.effect_phase(TAB_SLIDE).in_motion, why="the underline's slide") + await ui.step() def underline_is_painted(ui, tabs, underline, x_from=None, x_to=None): @@ -142,8 +150,7 @@ async def test_overflow_appearing_on_a_mounted_strip_keeps_the_underline_painted title = (f"pr17-standby-liveness-owner-{index} working on active goal" if long_titles else str(index)) app.session_tracker.name_session(session.identity, title) - await ui.step() - await ui.pilot.wait_for_scheduled_animations() + await strip_settled(ui) assert tabs.show_horizontal_scrollbar == long_titles label = tabs.query_one(f"#{owner}", SessionLabel) underline = tabs.query_one(Underline) diff --git a/tests/textual/test_tool_output_cases.py b/tests/textual/test_tool_output_cases.py index 6b9a8b424..560186a50 100644 --- a/tests/textual/test_tool_output_cases.py +++ b/tests/textual/test_tool_output_cases.py @@ -9,7 +9,6 @@ from toad.tool_output import SpecificTextToolOutputPart, ToolOutputPart from toad.widgets.tool_call import ToolCall from toad.widgets.tool_content import ToolContent -from support.styling import awaits_styling class InspectionToolOutputPart(SpecificTextToolOutputPart): @@ -38,7 +37,7 @@ async def test_a_declared_part_is_adopted_and_painted_without_catalog_edits(work assert InspectionToolOutputPart in ToolOutputPart.members_with(SpecificTextToolOutputPart) assert type(tool.output.parts[0]) is InspectionToolOutputPart assert tool.query_one(ToolContent).get_clipboard_text() == "case owns: DECLARED_NEW_OUTPUT" - await ui.until(lambda: not awaits_styling(tool.query_one(ToolContent)._styled), why="prepared output") + await ui.until(lambda: tool.query_one(ToolContent).rows_ready, why="prepared output") await ui.until_shows("case owns: DECLARED_NEW_OUTPUT") diff --git a/tests/textual/test_welcome_view.py b/tests/textual/test_welcome_view.py index 75f8e13db..4cb087d1b 100644 --- a/tests/textual/test_welcome_view.py +++ b/tests/textual/test_welcome_view.py @@ -96,9 +96,10 @@ async def test_opening_a_thread_run_elsewhere_opens_its_history_read_as_the_user entries = await welcome_entries(ui, "scribe") await focus_row(ui, entries.query_one(WelcomeThreads).rows["scribe"]) await ui.press("enter") - await ui.until(lambda: len(ui.tabs()) == 1, why="the thread's tab") app, _ = ui.textual - assert app.selected_session.thread_identity.thread_name() == "scribe" + await ui.until(lambda: app.selected_session.thread_identity.thread_name() == "scribe", + why="the thread's tab selected") + assert len(ui.tabs()) == 1 await ui.write_draft("hello scribe") await ui.press("enter") diff --git a/tests/textual/test_window_top_edge.py b/tests/textual/test_window_top_edge.py index c98747b56..38200bc17 100644 --- a/tests/textual/test_window_top_edge.py +++ b/tests/textual/test_window_top_edge.py @@ -16,7 +16,6 @@ from support.workspace import Workspace from toad.widgets.conversation import Conversation from toad.widgets.presentation_window import OLDER_END, PresentationBudget -from support.styling import awaits_styling THOUGHTS = ("Preparing branch notice tests", "Parametrizing rename routing tests", @@ -292,7 +291,7 @@ async def test_returning_to_tab_keeps_the_reader_below_a_live_thought( await view.post(thought) later = AgentResponse("\n\n".join(f"RETURN_ROW_{i:03d}" for i in range(40))) await view.post(later) - await ui.until(lambda: not awaits_styling(thought._styled) and not awaits_styling(later.body._styled), + await ui.until(lambda: thought.rows_ready and later.body.rows_ready, why="both live blocks to have their actual styled rows") window = view.window window.anchor() @@ -346,7 +345,7 @@ async def test_a_live_thought_crossing_the_top_edge_keeps_its_rows_at_phone_widt thought = AgentThought(THINKING) await view.post(thought) await view.post(AgentResponse("\n\n".join(f"Later paragraph {i}" for i in range(20)))) - await ui.until(lambda: not awaits_styling(thought._styled), why="the thought's styled rows") + await ui.until(lambda: thought.rows_ready, why="the thought's styled rows") window = view.window window.anchor() await ui.until(lambda: window.max_scroll_y > 0 and window.follows_tail, why="a scrollable tail") @@ -362,7 +361,7 @@ async def test_a_live_thought_crossing_the_top_edge_keeps_its_rows_at_phone_widt for chunk in (" and", " then a few more", " words arrive", " in the last paragraph"): thought.append(chunk) await ui.step() - await ui.until(lambda: thought._render_worker.is_finished, + await ui.until(lambda: thought.rows_ready, why="the streamed text to be styled") for _ in range(4): await ui.step() diff --git a/uv.lock b/uv.lock index e429ae583..f244eb283 100644 --- a/uv.lock +++ b/uv.lock @@ -1339,7 +1339,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#74e1f0c442851e9532d22bb758c9849ff85ef37e" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#e54d6c4c6baef8568cde613e71c14c11507b9f0a" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" },