From 36a231692acf3e2ca81be68aa6f71ef063ea710c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 11 Feb 2026 21:41:26 +0000 Subject: [PATCH 1/4] Add mode parameter to push_screen --- src/textual/app.py | 30 +++++++++++++++++++++++------- src/textual/filter.py | 1 - src/textual/signal.py | 1 - 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 744cee3b30..52f4af06b9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2792,6 +2792,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[False] = False, + mode: str | None = None, ) -> AwaitMount: ... @overload @@ -2800,6 +2801,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[True] = True, + mode: str | None = None, ) -> asyncio.Future[ScreenResultType]: ... def push_screen( @@ -2807,6 +2809,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: bool = False, + mode: str | None = None, ) -> AwaitMount | asyncio.Future[ScreenResultType]: """Push a new [screen](/guide/screens) on the screen stack, making it the current screen. @@ -2815,6 +2818,8 @@ def push_screen( callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result. wait_for_dismiss: If `True`, awaiting this method will return the dismiss value from the screen. When set to `False`, awaiting this method will wait for the screen to be mounted. Note that `wait_for_dismiss` should only be set to `True` when running in a worker. + mode: The mode to push the screen to, or `None` to the currently active mode. If pushing to a non-current mode, the screen will not be immediately visible. + Note that setting `mode` to something other than the current mode, will force `wait_for_dismiss` to be False. Raises: NoActiveWorker: If using `wait_for_dismiss` outside of a worker. @@ -2836,10 +2841,21 @@ def push_screen( else: future = loop.create_future() - self.app.capture_mouse(None) - if self._screen_stack: - self.screen.post_message(events.ScreenSuspend()) - self.screen.refresh() + if mode is None: + mode = self._current_mode + if mode != self._current_mode: + wait_for_dismiss = False + + try: + screen_stack = self._screen_stacks[mode] + except KeyError: + raise UnknownModeError(f"No such mode {mode!r}") + + if screen_stack and screen_stack[-1].is_active: + self.app.capture_mouse(None) + mode_screen = screen_stack[-1] + mode_screen.post_message(events.ScreenSuspend()) + mode_screen.refresh() next_screen, await_mount = self._get_screen(screen) try: message_pump = active_message_pump.get() @@ -2849,9 +2865,9 @@ def push_screen( next_screen._push_result_callback(message_pump, callback, future) self._load_screen_css(next_screen) next_screen._update_auto_focus() - self._screen_stack.append(next_screen) - next_screen.post_message(events.ScreenResume()) - self.log.system(f"{self.screen} is current (PUSHED)") + screen_stack.append(next_screen) + if next_screen.is_active: + next_screen.post_message(events.ScreenResume()) if wait_for_dismiss: try: get_current_worker() diff --git a/src/textual/filter.py b/src/textual/filter.py index 8f8e1086dd..31367fc344 100644 --- a/src/textual/filter.py +++ b/src/textual/filter.py @@ -202,7 +202,6 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]: _Segment = Segment _dim_style = dim_style factor = self.dim_factor - return [ ( _Segment( diff --git a/src/textual/signal.py b/src/textual/signal.py index eacb3fcf20..b67226bee2 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -74,7 +74,6 @@ def subscribe( Raises: SignalError: Raised when subscribing a non-mounted widget. """ - if not node.is_running: raise SignalError( f"Node must be running to subscribe to a signal (has {node} been mounted)?" From cb80c0d0ee6d2bb3904796fe8bc3e59cf1170e14 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 11 Feb 2026 21:43:05 +0000 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f69b779c1..97272984f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -### [7.5.0] - 2026-01-30 +## Unreleased + +### Added + +- Added `mode` argument to `push_screen` to enable pushing a screen to a non-active mode https://github.com/Textualize/textual/pull/6362 + +## [7.5.0] - 2026-01-30 + +### Changed - The DataTable row cursor will extend to the full width if there is excess space https://github.com/Textualize/textual/pull/6345 - The DataTable will send a selected event on click, only if the cell / row / column is currently highlighted https://github.com/Textualize/textual/pull/6345 From 267749b7cf3f54763320e1e1ac5eb42afe6128ec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 14 Feb 2026 13:56:14 +0000 Subject: [PATCH 3/4] test fixes --- CHANGELOG.md | 8 +++++++- src/textual/app.py | 33 ++++++++++++++++++++++++++------- src/textual/screen.py | 11 +++-------- src/textual/signal.py | 3 +++ src/textual/widgets/_tabs.py | 15 +++++++++++++++ tests/test_call_x_schedulers.py | 2 +- tests/test_screens.py | 17 ----------------- 7 files changed, 55 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97272984f6..0cbe8b1e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `mode` argument to `push_screen` to enable pushing a screen to a non-active mode https://github.com/Textualize/textual/pull/6362 +- Added `mode` argument to `push_screen` and `push_screen_wait` to enable pushing a screen to a non-active mode https://github.com/Textualize/textual/pull/6362 +- Added `App.mode_change_signal` and `App.screen_change_signal` https://github.com/Textualize/textual/pull/6362 +- Added `Tabs.get_tab` https://github.com/Textualize/textual/pull/6362 +### Changed + +- It is no longer a NOOP and warning to dismiss a non-active screen. The dismiss will still work, but the screen may not update if the current mode is not active. https://github.com/Textualize/textual/pull/6362# await pilot.press("2") + ## [7.5.0] - 2026-01-30 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 52f4af06b9..390d26fb2c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -796,6 +796,12 @@ def __init__( perform work after the app has resumed. """ + self.mode_change_signal: Signal[str] = Signal(self, "mode-change") + """A signal published when the current screen mode changes.""" + + self.screen_change_signal: Signal[Screen] = Signal(self, "screen-change") + """A signal published when the current screen changes.""" + self.set_class(self.current_theme.dark, "-dark-mode", update=False) self.set_class(not self.current_theme.dark, "-light-mode", update=False) @@ -2580,7 +2586,12 @@ def switch_mode(self, mode: str) -> AwaitMount: self._current_mode = mode if self.screen._css_update_count != self._css_update_count: self.refresh_css() + + self.mode_change_signal.publish(mode) + self.screen_change_signal.publish(self.screen) + self.screen._screen_resized(self.size) + self.screen.post_message(events.ScreenResume()) self.log.system(f"{self._current_mode!r} is the current mode") @@ -2792,6 +2803,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[False] = False, + *, mode: str | None = None, ) -> AwaitMount: ... @@ -2801,6 +2813,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: Literal[True] = True, + *, mode: str | None = None, ) -> asyncio.Future[ScreenResultType]: ... @@ -2809,6 +2822,7 @@ def push_screen( screen: Screen[ScreenResultType] | str, callback: ScreenResultCallbackType[ScreenResultType] | None = None, wait_for_dismiss: bool = False, + *, mode: str | None = None, ) -> AwaitMount | asyncio.Future[ScreenResultType]: """Push a new [screen](/guide/screens) on the screen stack, making it the current screen. @@ -2819,7 +2833,6 @@ def push_screen( wait_for_dismiss: If `True`, awaiting this method will return the dismiss value from the screen. When set to `False`, awaiting this method will wait for the screen to be mounted. Note that `wait_for_dismiss` should only be set to `True` when running in a worker. mode: The mode to push the screen to, or `None` to the currently active mode. If pushing to a non-current mode, the screen will not be immediately visible. - Note that setting `mode` to something other than the current mode, will force `wait_for_dismiss` to be False. Raises: NoActiveWorker: If using `wait_for_dismiss` outside of a worker. @@ -2843,8 +2856,6 @@ def push_screen( if mode is None: mode = self._current_mode - if mode != self._current_mode: - wait_for_dismiss = False try: screen_stack = self._screen_stacks[mode] @@ -2868,6 +2879,7 @@ def push_screen( screen_stack.append(next_screen) if next_screen.is_active: next_screen.post_message(events.ScreenResume()) + self.screen_change_signal.publish(next_screen) if wait_for_dismiss: try: get_current_worker() @@ -2883,14 +2895,16 @@ def push_screen( @overload async def push_screen_wait( - self, screen: Screen[ScreenResultType] + self, screen: Screen[ScreenResultType], *, mode: str | None = None ) -> ScreenResultType: ... @overload - async def push_screen_wait(self, screen: str) -> Any: ... + async def push_screen_wait( + self, screen: str, *, mode: str | None = None + ) -> Any: ... async def push_screen_wait( - self, screen: Screen[ScreenResultType] | str + self, screen: Screen[ScreenResultType] | str, *, mode: str | None = None ) -> ScreenResultType | Any: """Push a screen and wait for the result (received from [`Screen.dismiss`][textual.screen.Screen.dismiss]). @@ -2898,13 +2912,16 @@ async def push_screen_wait( Args: screen: A screen or the name of an installed screen. + mode: The mode to push the screen to, or `None` to the currently active mode. If pushing to a non-current mode, the screen will not be immediately visible. Returns: The screen's result. """ await self._flush_next_callbacks() # The shield prevents the cancellation of the current task from canceling the push_screen awaitable - return await asyncio.shield(self.push_screen(screen, wait_for_dismiss=True)) + return await asyncio.shield( + self.push_screen(screen, wait_for_dismiss=True, mode=mode) + ) def switch_screen(self, screen: Screen | str) -> AwaitComplete: """Switch to another [screen](/guide/screens) by replacing the top of the screen stack with a new screen. @@ -2930,6 +2947,7 @@ def switch_screen(self, screen: Screen | str) -> AwaitComplete: self._screen_stack.append(next_screen) self.screen.post_message(events.ScreenResume()) self.screen._push_result_callback(self.screen, None) + self.screen_change_signal.publish(self.screen) self.log.system(f"{self.screen} is current (SWITCHED)") async def do_switch() -> None: @@ -3018,6 +3036,7 @@ def pop_screen(self) -> AwaitComplete: self.screen.post_message( events.ScreenResume(refresh_styles=previous_screen.styles.background.a < 0) ) + self.screen_change_signal.publish(self.screen) self.log.system(f"{self.screen} is active") async def do_pop() -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index 0c9f1f8df5..c1e89d4e47 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1375,8 +1375,9 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non except Exception as error: self.app._handle_exception(error) return + if self.is_current: - self._compositor_refresh() + self.call_next(self._compositor_refresh) if self.app._dom_ready: self.screen_layout_refresh_signal.publish(self.screen) @@ -1464,7 +1465,7 @@ def _on_screen_resume(self, event: events.ScreenResume) -> None: self._update_auto_focus() if self.is_attached: - self._compositor_refresh() + if event.refresh_styles: self.update_node_styles(animate=False) if self._size != size: @@ -1890,9 +1891,6 @@ def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: Any callback provided in [push_screen][textual.app.App.push_screen] will be invoked with the supplied result. - Only the active screen may be dismissed. This method will produce a warning in the logs if - called on an inactive screen (but otherwise have no effect). - !!! warning Textual will raise a [`ScreenError`][textual.app.ScreenError] if you await the return value from a @@ -1904,9 +1902,6 @@ def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete: """ _rich_traceback_omit = True - if not self.is_active: - self.log.warning("Can't dismiss inactive screen") - return AwaitComplete() if self._result_callbacks: callback = self._result_callbacks[-1] callback(result) diff --git a/src/textual/signal.py b/src/textual/signal.py index b67226bee2..f62cee2a73 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -109,10 +109,13 @@ def publish(self, data: SignalT) -> None: data: An argument to pass to the callbacks. """ + if not self._subscriptions: + return # Don't publish if the DOM is not ready or shutting down owner = self.owner if owner is None: return + if not owner.is_attached or owner._pruning: return for ancestor_node in owner.ancestors_with_self: diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 42603e9a82..eded2ca53e 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -532,6 +532,21 @@ def clear(self) -> AwaitComplete: self.active = "" return AwaitComplete(self.query("#tabs-list > Tab").remove()) + def get_tab(self, tab_id: str) -> Tab | None: + """Get a tab from its ID. + + Args: + tab_id: The tab ID. + + Returns: + The Tab instance, or `None` if no tab with the given ID. + """ + try: + tab = self.query_one(f"#tabs-list > #{tab_id}", Tab) + except NoMatches: + return None + return tab + def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: """Remove a tab. diff --git a/tests/test_call_x_schedulers.py b/tests/test_call_x_schedulers.py index 32f7ae1154..88786689b0 100644 --- a/tests/test_call_x_schedulers.py +++ b/tests/test_call_x_schedulers.py @@ -45,6 +45,6 @@ def callback() -> None: app.call_after_refresh(callback) await asyncio.wait_for(called_event.wait(), 1) app_display_count = app.display_count - assert app_display_count == display_count + assert app_display_count >= display_count # Should be app because the callback should be in the same context as app assert callback_message_pump is app diff --git a/tests/test_screens.py b/tests/test_screens.py index 36bcd4cb5d..8583616f0f 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -275,23 +275,6 @@ def on_mount(self): assert isinstance(app.focused, Button) -async def test_dismiss_non_top_screen(): - class MyApp(App[None]): - async def key_p(self) -> None: - self.bottom = Screen() - top = Screen() - await self.push_screen(self.bottom) - await self.push_screen(top) - - app = MyApp() - async with app.run_test() as pilot: - await pilot.press("p") - # A noop if not the top - stack = list(app.screen_stack) - await app.bottom.dismiss() - assert app.screen_stack == stack - - async def test_dismiss_action(): class ConfirmScreen(Screen[bool]): BINDINGS = [("y", "dismiss(True)", "Dismiss")] From ff80c8036f1cc212b23fc4eddaee20bcd86e00de Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 14 Feb 2026 14:21:50 +0000 Subject: [PATCH 4/4] remove jank --- CHANGELOG.md | 3 ++- src/textual/app.py | 2 ++ src/textual/screen.py | 5 ++++- tests/test_call_x_schedulers.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbe8b1e8f..2b47cc730f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- It is no longer a NOOP and warning to dismiss a non-active screen. The dismiss will still work, but the screen may not update if the current mode is not active. https://github.com/Textualize/textual/pull/6362# await pilot.press("2") +- It is no longer a NOOP and warning to dismiss a non-active screen. The dismiss will still work, but the screen may not update if the current mode is not active. https://github.com/Textualize/textual/pull/6362 +- Added 50ms delay when switching screens to allow state to udpate and prevent janky flash of old content https://github.com/Textualize/textual/pull/6362 ## [7.5.0] - 2026-01-30 diff --git a/src/textual/app.py b/src/textual/app.py index 390d26fb2c..8c65e351bb 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2575,6 +2575,8 @@ def switch_mode(self, mode: str) -> AwaitMount: if mode not in self._modes: raise UnknownModeError(f"No known mode {mode!r}") + self.delay_update() + self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() diff --git a/src/textual/screen.py b/src/textual/screen.py index c1e89d4e47..83cbcc7073 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1377,7 +1377,10 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non return if self.is_current: - self.call_next(self._compositor_refresh) + if self.app._batch_count: + self.call_later(self._compositor_refresh) + else: + self._compositor_refresh() if self.app._dom_ready: self.screen_layout_refresh_signal.publish(self.screen) diff --git a/tests/test_call_x_schedulers.py b/tests/test_call_x_schedulers.py index 88786689b0..32f7ae1154 100644 --- a/tests/test_call_x_schedulers.py +++ b/tests/test_call_x_schedulers.py @@ -45,6 +45,6 @@ def callback() -> None: app.call_after_refresh(callback) await asyncio.wait_for(called_event.wait(), 1) app_display_count = app.display_count - assert app_display_count >= display_count + assert app_display_count == display_count # Should be app because the callback should be in the same context as app assert callback_message_pump is app