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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 48 additions & 11 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()

Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -2800,13 +2815,17 @@ 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(
self,
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.

Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -2867,28 +2897,33 @@ 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]).

Note that this method may only be called when running in a worker.

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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion src/textual/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 6 additions & 8 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/textual/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)?"
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions src/textual/widgets/_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 0 additions & 17 deletions tests/test_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading