diff --git a/CHANGELOG.md b/CHANGELOG.md index 62abfcdb0e..ab342eef44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `x_axis` and `y_axis` parameters to `Widget.scroll_to_region` https://github.com/Textualize/textual/pull/5047 - Added `Tree.move_cursor_to_line` https://github.com/Textualize/textual/pull/5052 +- Added `Screen.pop_until_active` https://github.com/Textualize/textual/pull/5069 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index e59c833059..abc06bda32 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2636,6 +2636,33 @@ async def do_pop() -> None: return AwaitComplete(do_pop()).call_next(self) + def _pop_to_screen(self, screen: Screen) -> None: + """Pop screens until the given screen is active. + + Args: + screen: desired active screen + + Raises: + ScreenError: If the screen doesn't exist in the stack. + """ + screens_to_pop: list[Screen] = [] + for pop_screen in reversed(self.screen_stack): + if pop_screen is not screen: + screens_to_pop.append(pop_screen) + else: + break + else: + raise ScreenError(f"Screen {screen!r} not in screen stack") + + async def pop_screens() -> None: + """Pop any screens in `screens_to_pop`.""" + with self.batch_update(): + for screen in screens_to_pop: + await screen.dismiss() + + if screens_to_pop: + self.call_later(pop_screens) + def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. diff --git a/src/textual/screen.py b/src/textual/screen.py index 59ff0bf5f8..36c4de658b 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1426,6 +1426,23 @@ def pre_await() -> None: return await_pop + def pop_until_active(self) -> None: + """Pop any screens on top of this one, until this screen is active. + + Raises: + ScreenError: If this screen is not in the current mode. + + """ + from textual.app import ScreenError + + try: + self.app._pop_to_screen(self) + except ScreenError: + # More specific error message + raise ScreenError( + f"Can't make {self} active as it is not in the current stack." + ) from None + async def action_dismiss(self, result: ScreenResultType | None = None) -> None: """A wrapper around [`dismiss`][textual.screen.Screen.dismiss] that can be called as an action. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_pop_until_active.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_pop_until_active.svg new file mode 100644 index 0000000000..dcd9341a48 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_pop_until_active.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PopApp + + + + + + + + + + BASE                                                                             + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index e222473837..b11d1f5592 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2101,6 +2101,41 @@ def action_toggle_console(self) -> None: app = MRE() assert snap_compare(app, press=["space", "space", "z"]) +def test_pop_until_active(snap_compare): + """End result should be screen showing 'BASE'""" + + class BaseScreen(Screen): + def compose(self) -> ComposeResult: + yield Label("BASE") + + class FooScreen(Screen): + def compose(self) -> ComposeResult: + yield Label("Foo") + + class BarScreen(Screen): + BINDINGS = [("b", "app.make_base_active")] + + def compose(self) -> ComposeResult: + yield Label("Bar") + + class PopApp(App): + SCREENS = {"base": BaseScreen} + + async def on_mount(self) -> None: + # Push base + await self.push_screen("base") + # Push two screens + await self.push_screen(FooScreen()) + await self.push_screen(BarScreen()) + + def action_make_base_active(self) -> None: + self.get_screen("base").pop_until_active() + + app = PopApp() + # App will push three screens + # Pressing "b" will call pop_until_active, and pop two screens + # End result should be screen showing "BASE" + assert snap_compare(app, press=["b"]) def test_updates_with_auto_refresh(snap_compare): """Regression test for https://github.com/Textualize/textual/issues/5056 @@ -2136,7 +2171,6 @@ def action_toggle_widget(self, widget_type: str) -> None: app = MRE() assert snap_compare(app, press=["z", "z"]) - def test_push_screen_on_mount(snap_compare): """Test pushing (modal) screen immediately on mount, which was not refreshing the base screen. @@ -2191,3 +2225,4 @@ def on_mount(self) -> None: app = MyApp() assert snap_compare(app) +