diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f69b779c1..2b47cc730f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ 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` 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 +- 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 + +### 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 diff --git a/src/textual/app.py b/src/textual/app.py index 744cee3b30..8c65e351bb 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) @@ -2569,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() @@ -2580,7 +2588,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 +2805,8 @@ 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 +2815,8 @@ 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 +2824,8 @@ 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 +2834,7 @@ 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. Raises: NoActiveWorker: If using `wait_for_dismiss` outside of a worker. @@ -2836,10 +2856,19 @@ 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 + + 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 +2878,10 @@ 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()) + self.screen_change_signal.publish(next_screen) if wait_for_dismiss: try: get_current_worker() @@ -2867,14 +2897,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]). @@ -2882,13 +2914,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. @@ -2914,6 +2949,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: @@ -3002,6 +3038,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/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/screen.py b/src/textual/screen.py index 0c9f1f8df5..83cbcc7073 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1375,8 +1375,12 @@ 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() + 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) @@ -1464,7 +1468,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 +1894,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 +1905,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 eacb3fcf20..f62cee2a73 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)?" @@ -110,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_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")]