From 82a91abbb2170957de619574c5fdc7028a9fb03d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 Jan 2024 13:17:13 +0000 Subject: [PATCH 01/45] Strip trailing whitespace. --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 4f679c4787..bc27445098 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -371,7 +371,7 @@ class MyApp(App[None]): """Indicates if the app has focus. When run in the terminal, the app always has focus. When run in the web, the app will - get focus when the terminal widget has focus. + get focus when the terminal widget has focus. """ def __init__( From 004513c8da43b948d8f25be23d8fc315983ae8ba Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 Jan 2024 14:34:12 +0000 Subject: [PATCH 02/45] Experimental suspend context manager Pulling out the very core of #1541 to start to build it up again and experiment and test (getting into the forge so I can then pull it down onto Windows and test there). --- src/textual/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index bc27445098..913bec2d36 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -38,6 +38,7 @@ Generator, Generic, Iterable, + Iterator, List, Sequence, Type, @@ -3292,3 +3293,11 @@ def action_command_palette(self) -> None: """Show the Textual command palette.""" if self.use_command_palette and not CommandPalette.is_open(self): self.push_screen(CommandPalette(), callback=self.call_next) + + @contextmanager + def suspend(self) -> Iterator[None]: + if self._driver is not None: + self._driver.stop_application_mode() + with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): + yield + self._driver.start_application_mode() From db31c61d374af868b6f54414b763ce9092cdee2c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 Jan 2024 15:09:52 +0000 Subject: [PATCH 03/45] Use the dunder values for stdin and stdout --- src/textual/drivers/win32.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index 5751af2caa..a191cd4d57 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -161,8 +161,8 @@ def enable_application_mode() -> Callable[[], None]: A callable that will restore terminal to previous state. """ - terminal_in = sys.stdin - terminal_out = sys.stdout + terminal_in = sys.__stdin__ + terminal_out = sys.__stdout__ current_console_mode_in = get_console_mode(terminal_in) current_console_mode_out = get_console_mode(terminal_out) From 68c9667ef4c24b6b10e9614033a90be4e7987153 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 Jan 2024 15:19:50 +0000 Subject: [PATCH 04/45] Revert "Use the dunder values for stdin and stdout" This reverts commit db31c61d374af868b6f54414b763ce9092cdee2c. Didn't address the issue I was trying to understand. --- src/textual/drivers/win32.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index a191cd4d57..5751af2caa 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -161,8 +161,8 @@ def enable_application_mode() -> Callable[[], None]: A callable that will restore terminal to previous state. """ - terminal_in = sys.__stdin__ - terminal_out = sys.__stdout__ + terminal_in = sys.stdin + terminal_out = sys.stdout current_console_mode_in = get_console_mode(terminal_in) current_console_mode_out = get_console_mode(terminal_out) From 47087a90297fa35d16ecb8569ed7cdd8e924d33f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 08:48:30 +0000 Subject: [PATCH 05/45] Experiment to see if a call to close is needed too While things are generally working fine on macOS (and possibly GNU/Linux, that's still to be tested), there is the "can't input anything, have to kill the terminal" issue on Windows. This worked in the PR a year ago, and this bit of code seems to be the difference so let's test that out. --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 913bec2d36..38548d5aca 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3298,6 +3298,7 @@ def action_command_palette(self) -> None: def suspend(self) -> Iterator[None]: if self._driver is not None: self._driver.stop_application_mode() + self._driver.close() with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): yield self._driver.start_application_mode() From e7d7b1af8e42dd43bb07c779469fbbf4a7396511 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 08:59:22 +0000 Subject: [PATCH 06/45] Seek to eliminate the bad file descriptor error on Windows --- src/textual/drivers/win32.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index 5751af2caa..a191cd4d57 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -161,8 +161,8 @@ def enable_application_mode() -> Callable[[], None]: A callable that will restore terminal to previous state. """ - terminal_in = sys.stdin - terminal_out = sys.stdout + terminal_in = sys.__stdin__ + terminal_out = sys.__stdout__ current_console_mode_in = get_console_mode(terminal_in) current_console_mode_out = get_console_mode(terminal_out) From 693fd214b0c92db29106f3f4cd78348494f77a8a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 09:44:24 +0000 Subject: [PATCH 07/45] Add a docstring to suspend Adding Josh Karpel as a co-author here; not because of the docstring, but the core idea started with #1541 and this is a reimplementation of that code in the current version of Textual. Co-authored-by: Josh Karpel --- src/textual/app.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 38548d5aca..053df42d1c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3296,6 +3296,20 @@ def action_command_palette(self) -> None: @contextmanager def suspend(self) -> Iterator[None]: + """A context manager that temporarily suspends the app. + + While inside the `with` block, the app will stop reading input and + emitting output. Other applications will have full control of the + terminal, configured as it was before the app started running. When + the `with` block ends, the application will start reading input and + emitting output again. + + Example: + ```python + with self.suspend(): + os.system("emacs -nw") + ``` + """ if self._driver is not None: self._driver.stop_application_mode() self._driver.close() From ba87cf84bc573b59aa523ec7920746a802b70025 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 10:24:15 +0000 Subject: [PATCH 08/45] Add a can_suspend property to the Driver base class This will be used by subclasses to say if the environment they pertain to permits a suspension of the application. --- src/textual/driver.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/driver.py b/src/textual/driver.py index 7cada2a473..30533a4143 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -40,6 +40,11 @@ def is_headless(self) -> bool: """Is the driver 'headless' (no output)?""" return False + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return False + def send_event(self, event: events.Event) -> None: """Send an event to the target app. From 0b303022b1ddb9befb11d1b737aeef9a845b500e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 10:24:59 +0000 Subject: [PATCH 09/45] Allow suspending the application when running with the Linux driver And by extension macOS and BSD, etc (the Linux driver is really a Un*x driver). --- src/textual/drivers/linux_driver.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 9b4b7c9da5..a34fad250f 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -49,6 +49,11 @@ def __init__( self._key_thread: Thread | None = None self._writer_thread: WriterThread | None = None + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return True + def __rich_repr__(self) -> rich.repr.Result: yield self._app From bec1f814632c1814525cdbe32deeb93442a23d16 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 10:26:24 +0000 Subject: [PATCH 10/45] Allow suspending the application when running with the Windows driver --- src/textual/drivers/windows_driver.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index faf2c0a52c..455a3d4fe6 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -37,6 +37,11 @@ def __init__( self._restore_console: Callable[[], None] | None = None self._writer_thread: WriterThread | None = None + @property + def can_suspend(self) -> bool: + """Can this driver be suspended?""" + return True + def write(self, data: str) -> None: """Write data to the output device. From a2743fc92aec5ba696890c49e497030fa4074b57 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 10:27:01 +0000 Subject: [PATCH 11/45] Test if a driver allows suspending the application And, if it doesn't, raise an exception. --- src/textual/app.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 053df42d1c..321fbfa3d6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -202,6 +202,14 @@ class ActiveModeError(ModeError): """Raised when attempting to remove the currently active mode.""" +class SuspendNotSupported(Exception): + """Raised if suspending the application is not supported. + + This exception is raised if [`App.suspend`][App.suspend] is called while + the application is running in an environment where this isn't supported. + """ + + ReturnType = TypeVar("ReturnType") CSSPathType = Union[ @@ -3310,9 +3318,15 @@ def suspend(self) -> Iterator[None]: os.system("emacs -nw") ``` """ - if self._driver is not None: + if self._driver is None: + return + if self._driver.can_suspend: self._driver.stop_application_mode() self._driver.close() with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): yield self._driver.start_application_mode() + else: + raise SuspendNotSupported( + "App.suspend is not supported in this environment." + ) From cedb3f2f19977346450a736bc20594e135dc6416 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 10:33:12 +0000 Subject: [PATCH 12/45] Add a note about the suspend exception to the docstring --- src/textual/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 321fbfa3d6..963925962e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3317,6 +3317,14 @@ def suspend(self) -> Iterator[None]: with self.suspend(): os.system("emacs -nw") ``` + + Raises: + SuspendNotSupported: If the environment doesn't support suspending. + + !!! note + Suspending the application is currently only supported on + Unix-like operating systems and Microsoft Windows. Suspending is + not supported in Textual-Web. """ if self._driver is None: return From 0abb2c7f6f6557c739147dba33e96ca0a5c4a6f0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 11:08:47 +0000 Subject: [PATCH 13/45] Add a unit test for the suspend exception --- tests/test_suspend.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/test_suspend.py diff --git a/tests/test_suspend.py b/tests/test_suspend.py new file mode 100644 index 0000000000..29f42fdc5e --- /dev/null +++ b/tests/test_suspend.py @@ -0,0 +1,13 @@ +import pytest + +from textual.app import App, SuspendNotSupported + + +async def test_suspend_not_supported() -> None: + """Suspending when not supported should raise an error.""" + async with App().run_test() as pilot: + # Pilot uses the headless driver, the headless driver doesn't + # support suspend, and so... + with pytest.raises(SuspendNotSupported): + with pilot.app.suspend(): + pass From faf9b51a51caad9ea931114db385fdbe45685918 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 11:49:40 +0000 Subject: [PATCH 14/45] Add a test for doing a suspend Borrowing heavily from Josh's testing. Co-authored-by: Josh Karpel --- tests/test_suspend.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_suspend.py b/tests/test_suspend.py index 29f42fdc5e..7d159dd496 100644 --- a/tests/test_suspend.py +++ b/tests/test_suspend.py @@ -1,6 +1,9 @@ +import sys + import pytest from textual.app import App, SuspendNotSupported +from textual.drivers.headless_driver import HeadlessDriver async def test_suspend_not_supported() -> None: @@ -11,3 +14,41 @@ async def test_suspend_not_supported() -> None: with pytest.raises(SuspendNotSupported): with pilot.app.suspend(): pass + + +async def test_suspend_supported(capfd: pytest.CaptureFixture[str]) -> None: + """Suspending when supported should call the relevant driver methods.""" + + calls: set[str] = set() + + class HeadlessSuspendDriver(HeadlessDriver): + @property + def is_headless(self) -> bool: + return False + + @property + def can_suspend(self) -> bool: + return True + + def start_application_mode(self) -> None: + nonlocal calls + calls.add("start") + + def stop_application_mode(self) -> None: + nonlocal calls + calls.add("stop") + + def close(self) -> None: + nonlocal calls + calls.add("close") + + async with App(driver_class=HeadlessSuspendDriver).run_test( + headless=False + ) as pilot: + calls = set() + with pilot.app.suspend(): + _ = capfd.readouterr() # Clear the existing buffer. + print("USE THEM TOGETHER.", end="", flush=True) + print("USE THEM IN PEACE.", file=sys.stderr, end="", flush=True) + assert ("USE THEM TOGETHER.", "USE THEM IN PEACE.") == capfd.readouterr() + assert calls == {"start", "stop", "close"} From f20437392b32ae239959c6c27b82da8d3eeb062c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 13:22:08 +0000 Subject: [PATCH 15/45] Add support for using Ctrl+Z to background the application --- src/textual/app.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 963925962e..70e17fed7b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,6 +13,7 @@ import io import os import platform +import signal import sys import threading import warnings @@ -361,6 +362,7 @@ class MyApp(App[None]): BINDINGS: ClassVar[list[BindingType]] = [ Binding("ctrl+c", "quit", "Quit", show=False, priority=True), Binding("ctrl+backslash", "command_palette", show=False, priority=True), + Binding("ctrl+z", "suspend_process", show=False, priority=True), ] title: Reactive[str] = Reactive("", compute=False) @@ -3338,3 +3340,18 @@ def suspend(self) -> Iterator[None]: raise SuspendNotSupported( "App.suspend is not supported in this environment." ) + + def action_suspend_process(self) -> None: + """Suspend the process into the background. + + Note: + On Unix and Unix-like systems a [`SIGTSTP`][signal.SIGTSTP] is + sent to the application's process. Currently on Windows this is + a non-operation. + """ + if not WINDOWS: + try: + with self.suspend(): + os.kill(os.getpid(), signal.SIGTSTP) + except SuspendNotSupported: + pass From 0f20967730c66aee7251e3360b840dad32c1e4b2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 14:49:03 +0000 Subject: [PATCH 16/45] Modify the binding tests to take the new default binding into account --- tests/test_binding_inheritance.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 431920422c..5c617edffc 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -28,8 +28,8 @@ # An application with no bindings anywhere. # # The idea of this first little test is that an application that has no -# bindings set anywhere, and uses a default screen, should only have the one -# binding in place: ctrl+c; it's hard-coded in the app class for now. +# bindings set anywhere, and uses a default screen, should only its +# hard-coded bindings in place. class NoBindings(App[None]): @@ -37,9 +37,13 @@ class NoBindings(App[None]): async def test_just_app_no_bindings() -> None: - """An app with no bindings should have no bindings, other than ctrl+c.""" + """An app with no bindings should have no bindings, other than the app's hard-coded ones.""" async with NoBindings().run_test() as pilot: - assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "ctrl+backslash"] + assert list(pilot.app._bindings.keys.keys()) == [ + "ctrl+c", + "ctrl+backslash", + "ctrl+z", + ] assert pilot.app._bindings.get_key("ctrl+c").priority is True @@ -48,7 +52,8 @@ async def test_just_app_no_bindings() -> None: # # Sticking with just an app and the default screen: this configuration has a # BINDINGS on the app itself, and simply binds the letter a. The result -# should be that we see the letter a, ctrl+c, and nothing else. +# should be that we see the letter a, the app's default bindings, and +# nothing else. class AlphaBinding(App[None]): @@ -61,7 +66,7 @@ async def test_just_app_alpha_binding() -> None: """An app with a single binding should have just the one binding.""" async with AlphaBinding().run_test() as pilot: assert sorted(pilot.app._bindings.keys.keys()) == sorted( - ["ctrl+c", "ctrl+backslash", "a"] + ["ctrl+c", "ctrl+backslash", "ctrl+z", "a"] ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is True @@ -85,7 +90,7 @@ async def test_just_app_low_priority_alpha_binding() -> None: """An app with a single low-priority binding should have just the one binding.""" async with LowAlphaBinding().run_test() as pilot: assert sorted(pilot.app._bindings.keys.keys()) == sorted( - ["ctrl+c", "ctrl+backslash", "a"] + ["ctrl+c", "ctrl+backslash", "ctrl+z", "a"] ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is False From 7da4a1ab250350ed8fc07f8a0228fcd4156347a1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 Jan 2024 15:09:55 +0000 Subject: [PATCH 17/45] Tidy a couple of docstrings --- src/textual/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 70e17fed7b..18884a4555 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -206,7 +206,7 @@ class ActiveModeError(ModeError): class SuspendNotSupported(Exception): """Raised if suspending the application is not supported. - This exception is raised if [`App.suspend`][App.suspend] is called while + This exception is raised if [`App.suspend`][textual.app.App.suspend] is called while the application is running in an environment where this isn't supported. """ @@ -3345,9 +3345,9 @@ def action_suspend_process(self) -> None: """Suspend the process into the background. Note: - On Unix and Unix-like systems a [`SIGTSTP`][signal.SIGTSTP] is - sent to the application's process. Currently on Windows this is - a non-operation. + On Unix and Unix-like systems a `SIGTSTP` is sent to the + application's process. Currently on Windows this is a + non-operation. """ if not WINDOWS: try: From 070287922b09fc5bdf094990e36be85f32e625eb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 09:36:34 +0000 Subject: [PATCH 18/45] Correct the description of the signal exception --- src/textual/signal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/signal.py b/src/textual/signal.py index cbf7a8e1dc..460aa8246e 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -22,7 +22,7 @@ class SignalError(Exception): - """Base class for a signal.""" + """Raised for Signal errors.""" @rich.repr.auto(angular=True) From 78e57da95695591400ec1de2f9830f1d02ee65a2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 09:36:55 +0000 Subject: [PATCH 19/45] Add a Raises section to the Signal.subscribe docstring --- src/textual/signal.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/signal.py b/src/textual/signal.py index 460aa8246e..6226b0273b 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -55,6 +55,9 @@ def subscribe(self, node: DOMNode, callback: IgnoreReturnCallbackType) -> None: Args: node: Node to subscribe. callback: A callback function which takes no arguments, and returns anything (return type ignored). + + Raises: + SignalError: Raised when subscribing a non-mounted widget. """ if not node.is_running: raise SignalError( From 7eb06ac6bc4fbb95634c9ec76931f92e51b01a10 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 09:37:42 +0000 Subject: [PATCH 20/45] Include Signal in the API docs While this is intended to be "experimental" at the moment, it needs to be in the API docs so that it can be linked to from the docs for the signals. --- docs/api/signal.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/api/signal.md diff --git a/docs/api/signal.md b/docs/api/signal.md new file mode 100644 index 0000000000..36727f7f2b --- /dev/null +++ b/docs/api/signal.md @@ -0,0 +1 @@ +::: textual.signal From 6fb4d710e296bf4013d0466a97d7731b6218399d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 09:44:59 +0000 Subject: [PATCH 21/45] Add Signal support to suspend This adds a signal that is published before the suspension finally happens, and another once the application is back and running again. --- src/textual/app.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 18884a4555..4ce36f9986 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -100,6 +100,7 @@ ScreenResultType, _SystemModalScreen, ) +from .signal import Signal from .widget import AwaitMount, Widget from .widgets._toast import ToastRack from .worker import NoActiveWorker, get_current_worker @@ -583,6 +584,24 @@ def __init__( self._original_stderr = sys.__stderr__ """The original stderr stream (before redirection etc).""" + self.app_suspend_signal = Signal(self, "app-suspend") + """The signal that is published when the app is suspended. + + When [`App.suspend`][textual.app.App.suspend] is called this signal + will be [published][textual.signal.Signal.publish]; + [subscribe][textual.signal.Signal.subscribe] to this signal to + perform work before the suspension takes place. + """ + self.app_resume_signal = Signal(self, "app-resume") + """The signal that is published when the app is resumes after a suspend. + + When the app is resumed after a + [`App.suspend`][textual.app.App.suspend] call this signal will be + [published][textual.signal.Signal.publish]; + [subscribe][textual.signal.Signal.subscribe] to this signal to + perform work after the app has resumed. + """ + self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") @@ -3331,11 +3350,13 @@ def suspend(self) -> Iterator[None]: if self._driver is None: return if self._driver.can_suspend: + self.app_suspend_signal.publish() self._driver.stop_application_mode() self._driver.close() with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): yield self._driver.start_application_mode() + self.app_resume_signal.publish() else: raise SuspendNotSupported( "App.suspend is not supported in this environment." From 5adfda1ead0ea006399955903783290ddbe495cf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 10:02:22 +0000 Subject: [PATCH 22/45] Add the suspend and resume signals to the suspend tests --- tests/test_suspend.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/test_suspend.py b/tests/test_suspend.py index 7d159dd496..540cbf19d9 100644 --- a/tests/test_suspend.py +++ b/tests/test_suspend.py @@ -42,7 +42,20 @@ def close(self) -> None: nonlocal calls calls.add("close") - async with App(driver_class=HeadlessSuspendDriver).run_test( + class SuspendApp(App[None]): + def on_suspend(self) -> None: + nonlocal calls + calls.add("suspend signal") + + def on_resume(self) -> None: + nonlocal calls + calls.add("resume signal") + + def on_mount(self) -> None: + self.app_suspend_signal.subscribe(self, self.on_suspend) + self.app_resume_signal.subscribe(self, self.on_resume) + + async with SuspendApp(driver_class=HeadlessSuspendDriver).run_test( headless=False ) as pilot: calls = set() @@ -51,4 +64,4 @@ def close(self) -> None: print("USE THEM TOGETHER.", end="", flush=True) print("USE THEM IN PEACE.", file=sys.stderr, end="", flush=True) assert ("USE THEM TOGETHER.", "USE THEM IN PEACE.") == capfd.readouterr() - assert calls == {"start", "stop", "close"} + assert calls == {"start", "stop", "close", "suspend signal", "resume signal"} From e5accb2d4e537fb99bd9906ca50fcec7b4c5851f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 Jan 2024 11:24:55 +0000 Subject: [PATCH 23/45] Start an App Basics section about suspending an app --- docs/guide/app.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/guide/app.md b/docs/guide/app.md index c78b8b52a7..65c02429f0 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -240,6 +240,53 @@ if __name__ == "__main__" sys.exit(app.return_code or 0) ``` +## Suspending + +A Textual app can be suspended; this means that app input and output will be paused and the terminal display will be returned to its previous state. +When the app is resumed the display will be restored and all input and output will resume. + +!!! info "Compatible Environments" + + App suspension is only available in the following environments: + + - GNU/Lnux + - macOS + - Windows + + It is currently not available when an application is being served via Textual Web. + +### Suspending in code + +To suspend your application use the [App.suspend](/api/app/#textual.app.App.suspend) context manager. +For example, here is an application that has a button that will open an external editor, suspending the application before running it: + +```python hl_lines="14-15" +from os import system + +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + +class SuspendingApp(App[None]): + + def compose(self) -> ComposeResult: + yield Button("Open the editor", id="edit") + + @on(Button.Pressed, "#edit") + def run_external_editor(self) -> None: + with self.suspend(): # (1)! + system("vim") + +if __name__ == "__main__": + SuspendingApp().run() +``` + +1. All code in the body of the `with` statement will be run while the app is suspended. + +### Backgrounding with Ctrl+Z + +On Unix and Unix-like systems (GNU/Linux, macOS, etc) Textual has support for the user pressing Ctrl+Z to background the application. + ## CSS Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy). From 374478a0b12640df4753c041770e824a2c4259f0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 29 Jan 2024 13:55:47 +0000 Subject: [PATCH 24/45] Move the main work on suspending with Ctrl+Z into the Linux driver --- src/textual/app.py | 6 +--- src/textual/drivers/linux_driver.py | 46 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 4ce36f9986..590babf634 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3371,8 +3371,4 @@ def action_suspend_process(self) -> None: non-operation. """ if not WINDOWS: - try: - with self.suspend(): - os.kill(os.getpid(), signal.SIGTSTP) - except SuspendNotSupported: - pass + os.kill(os.getpid(), signal.SIGTSTP) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index a34fad250f..d314bdaf1f 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -49,6 +49,24 @@ def __init__( self._key_thread: Thread | None = None self._writer_thread: WriterThread | None = None + # Put handlers for SIGTSTP and SIGCONT in place. These are necessary + # to support the user pressing Ctrl+Z to suspend the application. + signal.signal(signal.SIGTSTP, self._sigtsop_application) + signal.signal(signal.SIGCONT, self._sigcont_application) + + def _sigtsop_application(self, *_) -> None: + """Handle a SIGTSTP signal.""" + # First off, shut down application mode. + self.stop_application_mode() + self.close() + # Now that we're all closed down, send a SIGSTOP to our process to + # *actually* suspend the process. + os.kill(os.getpid(), signal.SIGSTOP) + + def _sigcont_application(self, *_) -> None: + """Handle a SICONT application.""" + self.start_application_mode() + @property def can_suspend(self) -> bool: """Can this driver be suspended?""" @@ -120,6 +138,34 @@ def write(self, data: str) -> None: def start_application_mode(self): """Start application mode.""" + + def _stop_again(*_) -> None: + """Signal handler that will put the application back to sleep.""" + os.kill(os.getpid(), signal.SIGSTOP) + + # Set up handlers to ensure that, if there's a SIGTTOU or a SIGTTIN, + # we go back to sleep. + signal.signal(signal.SIGTTOU, _stop_again) + signal.signal(signal.SIGTTIN, _stop_again) + try: + # Here we perform a NOP tcsetattr. The reason for this is that, + # if we're suspended and the user has performed a `bg` in the + # shell, we'll SIGCONT *but* we won't be allowed to do terminal + # output; so rather than get into the business of spinning up + # application mode again and then finding out, we perform a + # no-consequence change and detect the problem right away. + termios.tcsetattr( + self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno) + ) + except termios.error: + # There was an error doing the tcsetattr; there is no sense in + # carrying on because we'll be doing a SIGSTOP (see above). + return + finally: + # We don't need to be hooking SIGTTOU or SIGTTIN any more. + signal.signal(signal.SIGTTOU, signal.SIG_DFL) + signal.signal(signal.SIGTTIN, signal.SIG_DFL) + loop = asyncio.get_running_loop() def send_size_event(): From 446424b606e1cb7eaf46495888eb385f3782b695 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 29 Jan 2024 15:31:37 +0000 Subject: [PATCH 25/45] Reinstate support for the Textual signals for suspend resume on OS suspend --- src/textual/app.py | 34 ++++++++++++++++++++++++----- src/textual/drivers/linux_driver.py | 22 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 5069d8c6c8..dc1987c080 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -58,7 +58,19 @@ from rich.protocol import is_renderable from rich.segment import Segment, Segments -from . import Logger, LogGroup, LogVerbosity, actions, constants, events, log, messages +from textual.drivers.linux_driver import LinuxDriver + +from . import ( + Logger, + LogGroup, + LogVerbosity, + actions, + constants, + events, + log, + messages, + on, +) from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START from ._callback import invoke @@ -3324,6 +3336,15 @@ def action_command_palette(self) -> None: if self.use_command_palette and not CommandPalette.is_open(self): self.push_screen(CommandPalette(), callback=self.call_next) + def _suspend_signal(self) -> None: + """Signal that the application is being suspended.""" + self.app_suspend_signal.publish() + + @on(LinuxDriver.SignalResume) + def _resume_signal(self) -> None: + """Signal that the application is being resumed from a suspension.""" + self.app_resume_signal.publish() + @contextmanager def suspend(self) -> Iterator[None]: """A context manager that temporarily suspends the app. @@ -3351,13 +3372,13 @@ def suspend(self) -> Iterator[None]: if self._driver is None: return if self._driver.can_suspend: - self.app_suspend_signal.publish() + self._suspend_signal() self._driver.stop_application_mode() self._driver.close() with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): yield self._driver.start_application_mode() - self.app_resume_signal.publish() + self._resume_signal() else: raise SuspendNotSupported( "App.suspend is not supported in this environment." @@ -3368,8 +3389,9 @@ def action_suspend_process(self) -> None: Note: On Unix and Unix-like systems a `SIGTSTP` is sent to the - application's process. Currently on Windows this is a - non-operation. + application's process. Currently on Windows and when running + under Textual-Web this is a non-operation. """ - if not WINDOWS: + if not WINDOWS and self._driver is not None and self._driver.can_suspend: + self._suspend_signal() os.kill(os.getpid(), signal.SIGTSTP) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index d314bdaf1f..cbbff64a4d 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -17,6 +17,7 @@ from .._xterm_parser import XTermParser from ..driver import Driver from ..geometry import Size +from ..message import Message from ._writer_thread import WriterThread if TYPE_CHECKING: @@ -27,6 +28,9 @@ class LinuxDriver(Driver): """Powers display and input for Linux / MacOS""" + class SignalResume(Message): + """Message sent to the app when a resume signal should be published.""" + def __init__( self, app: App, @@ -49,6 +53,12 @@ def __init__( self._key_thread: Thread | None = None self._writer_thread: WriterThread | None = None + # If we've finally and properly come back from a SIGSTOP we want to + # be able to ask the app to publish its resume signal; to do that we + # need to know that we came in here via a SIGTSTP; this flag helps + # keep track of this. + self._must_signal_resume = False + # Put handlers for SIGTSTP and SIGCONT in place. These are necessary # to support the user pressing Ctrl+Z to suspend the application. signal.signal(signal.SIGTSTP, self._sigtsop_application) @@ -59,6 +69,9 @@ def _sigtsop_application(self, *_) -> None: # First off, shut down application mode. self.stop_application_mode() self.close() + # Flag that we'll need to signal a resume on successful startup + # again. + self._must_signal_resume = True # Now that we're all closed down, send a SIGSTOP to our process to # *actually* suspend the process. os.kill(os.getpid(), signal.SIGSTOP) @@ -221,6 +234,15 @@ def on_terminal_resize(signum, stack) -> None: self._request_terminal_sync_mode_support() self._enable_bracketed_paste() + # If we need to ask the app to signal that we've come back from a + # SIGTSTP... + if self._must_signal_resume: + self._must_signal_resume = False + asyncio.run_coroutine_threadsafe( + self._app._post_message(self.SignalResume()), + loop=loop, + ) + def _request_terminal_sync_mode_support(self) -> None: """Writes an escape sequence to query the terminal support for the sync protocol.""" # Terminals should ignore this sequence if not supported. From 0f1c3e8ece82f28750dab1066f5a5b7910d32fe9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 08:49:22 +0000 Subject: [PATCH 26/45] Fix a typo --- src/textual/drivers/linux_driver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index cbbff64a4d..e79b531ab3 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -61,10 +61,10 @@ def __init__( # Put handlers for SIGTSTP and SIGCONT in place. These are necessary # to support the user pressing Ctrl+Z to suspend the application. - signal.signal(signal.SIGTSTP, self._sigtsop_application) + signal.signal(signal.SIGTSTP, self._sigtstp_application) signal.signal(signal.SIGCONT, self._sigcont_application) - def _sigtsop_application(self, *_) -> None: + def _sigtstp_application(self, *_) -> None: """Handle a SIGTSTP signal.""" # First off, shut down application mode. self.stop_application_mode() From 7de303bde5b6daab069f88aebac1b5b14fcb1a38 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 10:13:12 +0000 Subject: [PATCH 27/45] Ensure we don't restart application mode in the wrong place It's possible for the developer to have code that is something like: with self.suspend(): # do something here that we can Ctrl+Z. such that the suspended process is *this* process; because of the signal handlers involved, in this case, we wouldn't want to automatically restart application mode. So this commit adds the ability to mark a body of code as one where no auto-restart should take place. --- src/textual/app.py | 4 +++- src/textual/driver.py | 20 +++++++++++++++++++- src/textual/drivers/linux_driver.py | 21 ++++++++++++--------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 282cf6d3ee..dcf0cab6ec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3369,7 +3369,9 @@ def suspend(self) -> Iterator[None]: self._suspend_signal() self._driver.stop_application_mode() self._driver.close() - with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__): + with self._driver.no_automatic_restart(), redirect_stdout( + sys.__stdout__ + ), redirect_stderr(sys.__stderr__): yield self._driver.start_application_mode() self._resume_signal() diff --git a/src/textual/driver.py b/src/textual/driver.py index 30533a4143..7fd5ca9d71 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -2,7 +2,8 @@ import asyncio from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from contextlib import contextmanager +from typing import TYPE_CHECKING, Iterator from . import events from .events import MouseUp @@ -34,6 +35,8 @@ def __init__( self._loop = asyncio.get_running_loop() self._down_buttons: list[int] = [] self._last_move_event: events.MouseMove | None = None + self._auto_restart = True + """Should the application auto-restart (where appropriate)?""" @property def is_headless(self) -> bool: @@ -123,5 +126,20 @@ def disable_input(self) -> None: def stop_application_mode(self) -> None: """Stop application mode, restore state.""" + @contextmanager + def no_automatic_restart(self) -> Iterator[None]: + """A context manager used to tell the driver to not auto-restart. + + For drivers that support the application being suspended by the + operating system, this context manager is used to mark a body of + code as one that will manage its own stop and start. + """ + auto_restart = self._auto_restart + self._auto_restart = False + try: + yield + finally: + self._auto_restart = auto_restart + def close(self) -> None: """Perform any final cleanup.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index e79b531ab3..b83e1756d5 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -66,19 +66,22 @@ def __init__( def _sigtstp_application(self, *_) -> None: """Handle a SIGTSTP signal.""" - # First off, shut down application mode. - self.stop_application_mode() - self.close() - # Flag that we'll need to signal a resume on successful startup - # again. - self._must_signal_resume = True - # Now that we're all closed down, send a SIGSTOP to our process to - # *actually* suspend the process. + # If we're supposed to auto-restart, that means we need to shut down + # first. + if self._auto_restart: + self.stop_application_mode() + self.close() + # Flag that we'll need to signal a resume on successful startup + # again. + self._must_signal_resume = True + # Now send a SIGSTOP to our process to *actually* suspend the + # process. os.kill(os.getpid(), signal.SIGSTOP) def _sigcont_application(self, *_) -> None: """Handle a SICONT application.""" - self.start_application_mode() + if self._auto_restart: + self.start_application_mode() @property def can_suspend(self) -> bool: From ddb24a3e10cad254d79428e9e73259a75bb9a93e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 10:23:04 +0000 Subject: [PATCH 28/45] Bump the SignalResume message up to the Driver level --- src/textual/app.py | 4 +--- src/textual/driver.py | 3 +++ src/textual/drivers/linux_driver.py | 4 ---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index dcf0cab6ec..d7a0af6997 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -58,8 +58,6 @@ from rich.protocol import is_renderable from rich.segment import Segment, Segments -from textual.drivers.linux_driver import LinuxDriver - from . import ( Logger, LogGroup, @@ -3334,7 +3332,7 @@ def _suspend_signal(self) -> None: """Signal that the application is being suspended.""" self.app_suspend_signal.publish() - @on(LinuxDriver.SignalResume) + @on(Driver.SignalResume) def _resume_signal(self) -> None: """Signal that the application is being resumed from a suspension.""" self.app_resume_signal.publish() diff --git a/src/textual/driver.py b/src/textual/driver.py index 7fd5ca9d71..1bc3a49804 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -126,6 +126,9 @@ def disable_input(self) -> None: def stop_application_mode(self) -> None: """Stop application mode, restore state.""" + class SignalResume(events.Event): + """Event sent to the app when a resume signal should be published.""" + @contextmanager def no_automatic_restart(self) -> Iterator[None]: """A context manager used to tell the driver to not auto-restart. diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index b83e1756d5..cae4c95b6a 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -17,7 +17,6 @@ from .._xterm_parser import XTermParser from ..driver import Driver from ..geometry import Size -from ..message import Message from ._writer_thread import WriterThread if TYPE_CHECKING: @@ -28,9 +27,6 @@ class LinuxDriver(Driver): """Powers display and input for Linux / MacOS""" - class SignalResume(Message): - """Message sent to the app when a resume signal should be published.""" - def __init__( self, app: App, From e8291dac81050e9be18568b6bd99c1c38592837f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 11:26:35 +0000 Subject: [PATCH 29/45] Add some notes about what the suspend code is doing --- src/textual/app.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index d7a0af6997..8cebb791d3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3364,14 +3364,25 @@ def suspend(self) -> Iterator[None]: if self._driver is None: return if self._driver.can_suspend: + # Publish a suspend signal *before* we stop application mode and + # close the driver. self._suspend_signal() self._driver.stop_application_mode() self._driver.close() + # We're going to handle the start of the driver again so mark + # this next part as such; the reason for this is that the code + # the developer may be running could be in this process, and on + # Unix-like systems the user may Ctrl-Z the app, and we don't + # want to have the driver auto-restart application mode when the + # application comes back to the foreground, in this context. with self._driver.no_automatic_restart(), redirect_stdout( sys.__stdout__ ), redirect_stderr(sys.__stderr__): yield + # We're done with the dev's code so start up application mode + # again... self._driver.start_application_mode() + # ...and publish a resume signal. self._resume_signal() else: raise SuspendNotSupported( @@ -3386,6 +3397,14 @@ def action_suspend_process(self) -> None: application's process. Currently on Windows and when running under Textual-Web this is a non-operation. """ + # Check if we're in an environment that permits this kind of + # suspend. if not WINDOWS and self._driver is not None and self._driver.can_suspend: + # First, ensure that the suspend signal gets published while + # we're still in application mode. self._suspend_signal() + # With that out of the way, send the SIGTSTP signal. os.kill(os.getpid(), signal.SIGTSTP) + # NOTE: There is no call to publish the resume signal here, this + # will be handled by the driver posting a SignalResume event + # (see the event handler on App._resume_signal) above. From b73523ab8a3356ced0d1065830ed71533fdf1925 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 14:04:35 +0000 Subject: [PATCH 30/45] Remove Ctrl+Z as the default binding for suspending While this is the convention/standard on Unix and Unix-like systems; adding this as a default binding on App means that we sort of rob other environments for this key combination. It also means that it's not so easy for the developer to decide they *don't* want this enabled in their application. So here we swap to providing the action without providing a default binding for it; while also suggesting and encouraging the appropriate binding. --- docs/guide/app.md | 17 +++++++++++++++-- src/textual/app.py | 8 ++++---- src/textual/drivers/linux_driver.py | 4 +++- tests/test_binding_inheritance.py | 17 +++++++++++------ 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index 65c02429f0..642d69c054 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -283,9 +283,22 @@ if __name__ == "__main__": 1. All code in the body of the `with` statement will be run while the app is suspended. -### Backgrounding with Ctrl+Z +### Suspending from foreground -On Unix and Unix-like systems (GNU/Linux, macOS, etc) Textual has support for the user pressing Ctrl+Z to background the application. +On Unix and Unix-like systems (GNU/Linux, macOS, etc) Textual has support for the user pressing a key combination to suspend the application as the foreground process. +Ordinarily this key combination is Ctrl+Z; +in a Textual application this is disabled by default, but an action is provided ([`action_suspend_process`](/api/app/#textual.app.App.action_suspend_process)) that you can bind in the usual way. +For example: + +```python +BINDINGS = [ + Binding("ctrl+z", "suspend_process") +] +``` + +!!! note + + If `suspend_process` is called on Windows, or when your application is being hosted under Textual Web, the call will be ignored. ## CSS diff --git a/src/textual/app.py b/src/textual/app.py index 8cebb791d3..aa8804acb8 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -373,7 +373,6 @@ class MyApp(App[None]): BINDINGS: ClassVar[list[BindingType]] = [ Binding("ctrl+c", "quit", "Quit", show=False, priority=True), Binding("ctrl+backslash", "command_palette", show=False, priority=True), - Binding("ctrl+z", "suspend_process", show=False, priority=True), ] title: Reactive[str] = Reactive("", compute=False) @@ -3372,9 +3371,10 @@ def suspend(self) -> Iterator[None]: # We're going to handle the start of the driver again so mark # this next part as such; the reason for this is that the code # the developer may be running could be in this process, and on - # Unix-like systems the user may Ctrl-Z the app, and we don't - # want to have the driver auto-restart application mode when the - # application comes back to the foreground, in this context. + # Unix-like systems the user may `action_suspend_process` the + # app, and we don't want to have the driver auto-restart + # application mode when the application comes back to the + # foreground, in this context. with self._driver.no_automatic_restart(), redirect_stdout( sys.__stdout__ ), redirect_stderr(sys.__stderr__): diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index cae4c95b6a..0b14b46ba3 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -56,7 +56,9 @@ def __init__( self._must_signal_resume = False # Put handlers for SIGTSTP and SIGCONT in place. These are necessary - # to support the user pressing Ctrl+Z to suspend the application. + # to support the user pressing Ctrl+Z (or whatever the dev might + # have bound to call the relevant action on App) to suspend the + # application. signal.signal(signal.SIGTSTP, self._sigtstp_application) signal.signal(signal.SIGCONT, self._sigcont_application) diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 5c617edffc..07c483bc65 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -42,7 +42,6 @@ async def test_just_app_no_bindings() -> None: assert list(pilot.app._bindings.keys.keys()) == [ "ctrl+c", "ctrl+backslash", - "ctrl+z", ] assert pilot.app._bindings.get_key("ctrl+c").priority is True @@ -66,7 +65,7 @@ async def test_just_app_alpha_binding() -> None: """An app with a single binding should have just the one binding.""" async with AlphaBinding().run_test() as pilot: assert sorted(pilot.app._bindings.keys.keys()) == sorted( - ["ctrl+c", "ctrl+backslash", "ctrl+z", "a"] + ["ctrl+c", "ctrl+backslash", "a"] ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is True @@ -90,7 +89,7 @@ async def test_just_app_low_priority_alpha_binding() -> None: """An app with a single low-priority binding should have just the one binding.""" async with LowAlphaBinding().run_test() as pilot: assert sorted(pilot.app._bindings.keys.keys()) == sorted( - ["ctrl+c", "ctrl+backslash", "ctrl+z", "a"] + ["ctrl+c", "ctrl+backslash", "a"] ) assert pilot.app._bindings.get_key("ctrl+c").priority is True assert pilot.app._bindings.get_key("a").priority is False @@ -357,7 +356,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> None: +async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> ( + None +): """A contained focused child widget, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) @@ -448,7 +449,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> None: +async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> ( + None +): """A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) @@ -503,7 +506,9 @@ def on_mount(self) -> None: self.push_screen("main") -async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> None: +async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> ( + None +): """A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) From 5d09bc1dccc18374ed14527e9e8498b041216873 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 14:30:49 +0000 Subject: [PATCH 31/45] Spell Textual Web as Textual Web not Textual-Web --- src/textual/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index aa8804acb8..27255d3abf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3358,7 +3358,7 @@ def suspend(self) -> Iterator[None]: !!! note Suspending the application is currently only supported on Unix-like operating systems and Microsoft Windows. Suspending is - not supported in Textual-Web. + not supported in Textual Web. """ if self._driver is None: return @@ -3395,7 +3395,7 @@ def action_suspend_process(self) -> None: Note: On Unix and Unix-like systems a `SIGTSTP` is sent to the application's process. Currently on Windows and when running - under Textual-Web this is a non-operation. + under Textual Web this is a non-operation. """ # Check if we're in an environment that permits this kind of # suspend. From 3a91b3711d5d169e8c06c65fa718a7a6812f4100 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 14:39:59 +0000 Subject: [PATCH 32/45] Update the ChangeLog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 705eb5dd3e..6f39fa2a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Significant changes to `TextArea.__init__` default values/behaviour https://github.com/Textualize/textual/pull/3933 - `soft_wrap=True` - soft wrapping is now enabled by default. - `show_line_numbers=False` - line numbers are now disabled by default. - - `tab_behaviour="focus"` - pressing the tab key now switches focus instead of indenting by default. + - `tab_behaviour="focus"` - pressing the tab key now switches focus instead of indenting by default. - Breaking change: `DOMNode.has_pseudo_class` now accepts a single name only https://github.com/Textualize/textual/pull/3970 - Made `textual.cache` (formerly `textual._cache`) public https://github.com/Textualize/textual/pull/3976 - `Tab.label` can now be used to change the label of a tab https://github.com/Textualize/textual/pull/3979 @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Query.blur` and `Query.focus` https://github.com/Textualize/textual/pull/4012 - Added `MessagePump.message_queue_size` https://github.com/Textualize/textual/pull/4012 - Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012 +- Added `App.suspend` https://github.com/Textualize/textual/pull/4064 +- Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064 ### Fixed From d033407db43d169eb1ce2ddaac8ce7e99729a1e8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 30 Jan 2024 18:05:26 +0000 Subject: [PATCH 33/45] Fix a typo Co-authored-by: Darren Burns --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 27255d3abf..638bdc8c7d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -603,7 +603,7 @@ def __init__( perform work before the suspension takes place. """ self.app_resume_signal = Signal(self, "app-resume") - """The signal that is published when the app is resumes after a suspend. + """The signal that is published when the app is resumed after a suspend. When the app is resumed after a [`App.suspend`][textual.app.App.suspend] call this signal will be From 8dcf55a0f3b3b03a9af50d54b2a9a1e34d63d3bf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 10:40:35 +0000 Subject: [PATCH 34/45] Add Driver.suspend/resume_application_mode interface While at the moment these are the thinnest of shims around stop/start, the idea here is that we're going to add an API that *promises* to handle suspend and resume of the application mode in the driver; unlike stop/start which just promise that it'll stop and start and there's no promise that a start can happen after a stop. --- src/textual/app.py | 11 ++++------- src/textual/driver.py | 16 ++++++++++++++++ src/textual/drivers/linux_driver.py | 14 +++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 638bdc8c7d..31909d91c4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3363,11 +3363,9 @@ def suspend(self) -> Iterator[None]: if self._driver is None: return if self._driver.can_suspend: - # Publish a suspend signal *before* we stop application mode and - # close the driver. + # Publish a suspend signal *before* we suspend application mode. self._suspend_signal() - self._driver.stop_application_mode() - self._driver.close() + self._driver.suspend_application_mode() # We're going to handle the start of the driver again so mark # this next part as such; the reason for this is that the code # the developer may be running could be in this process, and on @@ -3379,9 +3377,8 @@ def suspend(self) -> Iterator[None]: sys.__stdout__ ), redirect_stderr(sys.__stderr__): yield - # We're done with the dev's code so start up application mode - # again... - self._driver.start_application_mode() + # We're done with the dev's code so resume application mode. + self._driver.resume_application_mode() # ...and publish a resume signal. self._resume_signal() else: diff --git a/src/textual/driver.py b/src/textual/driver.py index 1bc3a49804..6bd1d69e00 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -126,6 +126,22 @@ def disable_input(self) -> None: def stop_application_mode(self) -> None: """Stop application mode, restore state.""" + def suspend_application_mode(self) -> None: + """Suspend application mode. + + Used to suspend application mode and allow uninhibited access to the + terminal. + """ + self.stop_application_mode() + + def resume_application_mode(self) -> None: + """Resume application mode. + + Used to resume application mode after it has been previously + suspended. + """ + self.start_application_mode() + class SignalResume(events.Event): """Event sent to the app when a resume signal should be published.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 0b14b46ba3..5c5768b065 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -67,8 +67,7 @@ def _sigtstp_application(self, *_) -> None: # If we're supposed to auto-restart, that means we need to shut down # first. if self._auto_restart: - self.stop_application_mode() - self.close() + self.suspend_application_mode() # Flag that we'll need to signal a resume on successful startup # again. self._must_signal_resume = True @@ -79,7 +78,7 @@ def _sigtstp_application(self, *_) -> None: def _sigcont_application(self, *_) -> None: """Handle a SICONT application.""" if self._auto_restart: - self.start_application_mode() + self.resume_application_mode() @property def can_suspend(self) -> bool: @@ -307,6 +306,15 @@ def close(self) -> None: if self._writer_thread is not None: self._writer_thread.stop() + def suspend_application_mode(self) -> None: + """Suspend application mode. + + Used to suspend application mode and allow uninhibited access to the + terminal. + """ + super().suspend_application_mode() + self.close() + def _run_input_thread(self) -> None: """ Key thread target that wraps run_input_thread() to die gracefully if it raises From ea36a43f5e55e37c83a9de7d46c3395f9f05a348 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:00:45 +0000 Subject: [PATCH 35/45] Also Driver.close in Driver.suspend_application_mode I realised that Driver.close exists so it makes sense to call that in the base class rather than special-case that down in the LinuxDriver. --- src/textual/driver.py | 1 + src/textual/drivers/linux_driver.py | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index 6bd1d69e00..054628dac3 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -141,6 +141,7 @@ def resume_application_mode(self) -> None: suspended. """ self.start_application_mode() + self.close() class SignalResume(events.Event): """Event sent to the app when a resume signal should be published.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 5c5768b065..7f33082d34 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -306,15 +306,6 @@ def close(self) -> None: if self._writer_thread is not None: self._writer_thread.stop() - def suspend_application_mode(self) -> None: - """Suspend application mode. - - Used to suspend application mode and allow uninhibited access to the - terminal. - """ - super().suspend_application_mode() - self.close() - def _run_input_thread(self) -> None: """ Key thread target that wraps run_input_thread() to die gracefully if it raises From f5d32bcc528dd7a302017d63df45846c8f5dd178 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:01:42 +0000 Subject: [PATCH 36/45] Update suspend testing for the new approach --- tests/test_suspend.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/test_suspend.py b/tests/test_suspend.py index 540cbf19d9..8f4534dce1 100644 --- a/tests/test_suspend.py +++ b/tests/test_suspend.py @@ -30,17 +30,13 @@ def is_headless(self) -> bool: def can_suspend(self) -> bool: return True - def start_application_mode(self) -> None: + def suspend_application_mode(self) -> None: nonlocal calls - calls.add("start") + calls.add("suspend") - def stop_application_mode(self) -> None: + def resume_application_mode(self) -> None: nonlocal calls - calls.add("stop") - - def close(self) -> None: - nonlocal calls - calls.add("close") + calls.add("resume") class SuspendApp(App[None]): def on_suspend(self) -> None: @@ -64,4 +60,4 @@ def on_mount(self) -> None: print("USE THEM TOGETHER.", end="", flush=True) print("USE THEM IN PEACE.", file=sys.stderr, end="", flush=True) assert ("USE THEM TOGETHER.", "USE THEM IN PEACE.") == capfd.readouterr() - assert calls == {"start", "stop", "close", "suspend signal", "resume signal"} + assert calls == {"suspend", "resume", "suspend signal", "resume signal"} From dca798f574a41a21dc88017e7ea8ec350b10a445 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:06:55 +0000 Subject: [PATCH 37/45] Improve documentation Co-authored-by: Will McGugan --- docs/guide/app.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index 642d69c054..e2a63a5eea 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -242,8 +242,10 @@ if __name__ == "__main__" ## Suspending -A Textual app can be suspended; this means that app input and output will be paused and the terminal display will be returned to its previous state. -When the app is resumed the display will be restored and all input and output will resume. +A Textual app may be suspended so you can leave application mode for a period of time. +This is often used to temporarily replace your app with another terminal application. + +You could use this to allow the user to edit content with their preferred text editor, for example. !!! info "Compatible Environments" From 3914215636dc9a66fb7f1d68c642a0f470e7a2fd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:07:37 +0000 Subject: [PATCH 38/45] Simplify a caveat in the docs Co-authored-by: Will McGugan --- docs/guide/app.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index e2a63a5eea..3356d2f4b4 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -247,15 +247,9 @@ This is often used to temporarily replace your app with another terminal applica You could use this to allow the user to edit content with their preferred text editor, for example. -!!! info "Compatible Environments" - - App suspension is only available in the following environments: - - - GNU/Lnux - - macOS - - Windows +!!! info - It is currently not available when an application is being served via Textual Web. + App suspension is unavailable with [textual-web](https://github.com/Textualize/textual-web). ### Suspending in code From d94c2f9da1d436b561706ad80b2b867c76cd75b7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:08:03 +0000 Subject: [PATCH 39/45] Improve a heading in the docs Co-authored-by: Will McGugan --- docs/guide/app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index 3356d2f4b4..2fb17ef868 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -251,7 +251,7 @@ You could use this to allow the user to edit content with their preferred text e App suspension is unavailable with [textual-web](https://github.com/Textualize/textual-web). -### Suspending in code +### Suspend context manager To suspend your application use the [App.suspend](/api/app/#textual.app.App.suspend) context manager. For example, here is an application that has a button that will open an external editor, suspending the application before running it: From 7fb59d1831a601a58a14f19ddb0cea00ae1b5f34 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:08:29 +0000 Subject: [PATCH 40/45] Docs wording tweak Co-authored-by: Will McGugan --- docs/guide/app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index 2fb17ef868..e3b8a32c16 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -253,7 +253,7 @@ You could use this to allow the user to edit content with their preferred text e ### Suspend context manager -To suspend your application use the [App.suspend](/api/app/#textual.app.App.suspend) context manager. +You can use the [App.suspend](/api/app/#textual.app.App.suspend) context manager to suspend your app. For example, here is an application that has a button that will open an external editor, suspending the application before running it: ```python hl_lines="14-15" From 0003b52dcfac16d0bda6c9fd548e8adb43ae7688 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:09:44 +0000 Subject: [PATCH 41/45] Celebrate vim in the docs Co-authored-by: Will McGugan --- docs/guide/app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index e3b8a32c16..55391702cf 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -254,7 +254,7 @@ You could use this to allow the user to edit content with their preferred text e ### Suspend context manager You can use the [App.suspend](/api/app/#textual.app.App.suspend) context manager to suspend your app. -For example, here is an application that has a button that will open an external editor, suspending the application before running it: +The following Textual app will launch [vim](https://www.vim.org/) (a text editor) when the user clicks a button: ```python hl_lines="14-15" from os import system From c916a82934be6c30891223e7c3a107aab6593961 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 11:36:14 +0000 Subject: [PATCH 42/45] Include the output of the suspend example in the docs --- docs/examples/app/suspend.py | 20 ++++++++++++++++++++ docs/guide/app.md | 26 ++++++++------------------ 2 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 docs/examples/app/suspend.py diff --git a/docs/examples/app/suspend.py b/docs/examples/app/suspend.py new file mode 100644 index 0000000000..6a0073e040 --- /dev/null +++ b/docs/examples/app/suspend.py @@ -0,0 +1,20 @@ +from os import system + +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class SuspendingApp(App[None]): + + def compose(self) -> ComposeResult: + yield Button("Open the editor", id="edit") + + @on(Button.Pressed, "#edit") + def run_external_editor(self) -> None: + with self.suspend(): # (1)! + system("vim") + + +if __name__ == "__main__": + SuspendingApp().run() diff --git a/docs/guide/app.md b/docs/guide/app.md index 55391702cf..aefcce036b 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -256,28 +256,18 @@ You could use this to allow the user to edit content with their preferred text e You can use the [App.suspend](/api/app/#textual.app.App.suspend) context manager to suspend your app. The following Textual app will launch [vim](https://www.vim.org/) (a text editor) when the user clicks a button: -```python hl_lines="14-15" -from os import system +=== "suspend.py" -from textual import on -from textual.app import App, ComposeResult -from textual.widgets import Button + ```python hl_lines="14-15" + --8<-- "docs/examples/app/suspend.py" + ``` -class SuspendingApp(App[None]): + 1. All code in the body of the `with` statement will be run while the app is suspended. - def compose(self) -> ComposeResult: - yield Button("Open the editor", id="edit") +=== "Output" - @on(Button.Pressed, "#edit") - def run_external_editor(self) -> None: - with self.suspend(): # (1)! - system("vim") - -if __name__ == "__main__": - SuspendingApp().run() -``` - -1. All code in the body of the `with` statement will be run while the app is suspended. + ```{.textual path="docs/examples/app/suspend.py"} + ``` ### Suspending from foreground From 1cd64f974fe42983c63172c1b65ee1fe2479dce8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 12:47:10 +0000 Subject: [PATCH 43/45] Add a full app to show off action_suspend_process binding --- docs/examples/app/suspend_process.py | 15 +++++++++++++++ docs/guide/app.md | 15 ++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 docs/examples/app/suspend_process.py diff --git a/docs/examples/app/suspend_process.py b/docs/examples/app/suspend_process.py new file mode 100644 index 0000000000..695bd1cfc0 --- /dev/null +++ b/docs/examples/app/suspend_process.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Label + + +class SuspendKeysApp(App[None]): + + BINDINGS = [Binding("ctrl+z", "suspend_process")] + + def compose(self) -> ComposeResult: + yield Label("Press Ctrl+Z to suspend!") + + +if __name__ == "__main__": + SuspendKeysApp().run() diff --git a/docs/guide/app.md b/docs/guide/app.md index aefcce036b..ebee06748a 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -276,11 +276,16 @@ Ordinarily this key combination is Ctrl+Z; in a Textual application this is disabled by default, but an action is provided ([`action_suspend_process`](/api/app/#textual.app.App.action_suspend_process)) that you can bind in the usual way. For example: -```python -BINDINGS = [ - Binding("ctrl+z", "suspend_process") -] -``` +=== "suspend_process.py" + + ```python hl_lines="8" + --8<-- "docs/examples/app/suspend_process.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/app/suspend_process.py"} + ``` !!! note From 025ac85bb165ef5b6c34eaf5aa6ceca5201991f8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 12:48:21 +0000 Subject: [PATCH 44/45] Add action_suspend_process to the list of builtin actions --- docs/guide/actions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index ca5ea8b824..4e7c8f8c19 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -139,5 +139,6 @@ Textual supports the following builtin actions which are defined on the app. - [action_remove_class][textual.app.App.action_remove_class] - [action_screenshot][textual.app.App.action_screenshot] - [action_switch_screen][textual.app.App.action_switch_screen] +- [action_suspend_process][textual.app.App.action_suspend_process] - [action_toggle_class][textual.app.App.action_toggle_class] - [action_toggle_dark][textual.app.App.action_toggle_dark] From df73e71bff7d848f9bd98bf998dfa0e6f67313d0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 31 Jan 2024 12:54:10 +0000 Subject: [PATCH 45/45] Actually Driver.close in the right place! --- src/textual/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index 054628dac3..8500e30992 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -133,6 +133,7 @@ def suspend_application_mode(self) -> None: terminal. """ self.stop_application_mode() + self.close() def resume_application_mode(self) -> None: """Resume application mode. @@ -141,7 +142,6 @@ def resume_application_mode(self) -> None: suspended. """ self.start_application_mode() - self.close() class SignalResume(events.Event): """Event sent to the app when a resume signal should be published."""