Repository: Textualize/textual
Pull request: Application suspension
# Introduction

This PR adds two related features to Textual applications:

- An `App.suspend` context manager that allows the dev to temporarily stop application mode to run some other code; a classic example would be shelling out to an external editor.
- An action (`App.action_suspend_process`) that, where appropriate (currently GNU/Linux and macOS, perhaps other Unix and Unix-like environments), suspends the application as the foreground task.

## App.suspend

Here's a simple example of using the `App.suspend` context manager:

```python
from os import system
from pathlib import Path
from tempfile import NamedTemporaryFile

from textual import on
from textual.app import App, ComposeResult
from textual.reactive import var
from textual.widgets import Button, Static

class EditInVimApp(App[None]):

    text: var[str] = var("Hello, World!", init=False)

    def compose(self) -> ComposeResult:
        yield Button("Edit in vim")
        yield Static(self.text)

    def edit_in_vim(self) -> str:
        with NamedTemporaryFile(delete=False) as file_to_edit:
            file_to_edit.write(self.text.encode())
        try:
            with self.suspend():
                system(f"vim {file_to_edit.name}")
                return Path(file_to_edit.name).read_text()
        finally:
            Path(file_to_edit.name).unlink()

    @on(Button.Pressed)
    def edit_text(self) -> None:
        self.text = self.edit_in_vim()

    def watch_text(self) -> None:
        self.query_one(Static).update(self.text)

if __name__ == "__main__":
    EditInVimApp().run()
```

https://github.com/Textualize/textual/assets/28237/975bbb65-c65f-40f7-b571-781e15da452c

## Suspend as foreground task

This PR also adds support for suspending the application as the foreground task. This is done by providing `App.action_suspend_process` as an action that can be bound to a key combination, the conventional choice being <kbd>Ctrl</kbd>+<kbd>Z</kbd>. This feature is only available on Unix and Unix-like operating systems (currently tested on GNU/Linux and macOS). On Windows it is a no-op. No matter the host operating system it is also a no-op when running under Textual Web.

https://github.com/Textualize/textual/assets/28237/e7573697-763f-428a-947e-bd0fe770ab8d

## Suspend and resume signals

Because the developer may with to perform some actions as a suspend happens, or when a resume happens, this PR also makes use of the new `Signal` facility. The developer can subscribe to `App.app_suspend_signal` and `App.app_resume_signal` to be notified when a suspend or a resume happens.

Using this, an example app with code like this in it (to log when a suspend and a resume happens):

```python
    def suspending(self) -> None:
        self.query_one(Log).write_line("Suspending!")

    def resuming(self) -> None:
        self.query_one(Log).write_line("Resuming!")

    def on_mount(self) -> None:
        self.app_suspend_signal.subscribe(self, self.suspending)
        self.app_resume_signal.subscribe(self, self.resuming)
```

will log the suspends and the resumes:

https://github.com/Textualize/textual/assets/28237/4b05dfd8-8e01-4e9d-b22f-0e24784abacc

## App.suspend and SuspendNotSupported

If `App.suspend` is called in an environment that isn't supported a `SuspendNotSupported` exception will be raised. Using code like this:

```python
    @on(Button.Pressed, "#countdown")
    def countdown(self) -> None:
        try:
            with self.app.suspend():
                for n in reversed(range(10)):
                    print(n)
                    sleep(1)
        except SuspendNotSupported:
            self.notify("I'm sorry Dave, I'm afraid I can't do that", severity="error")
```

in the test application above, and then run under Textual Web, will show the above notification:

https://github.com/Textualize/textual/assets/28237/0f83d139-2da0-4453-876f-36f531b3bc46



Problem statement:
Add support for suspend and resume.
Textual swallows ctrl+Z which would normally allow the user to suspend the app. We should restore this behaviour.

There many be another issue for this somewhere, and some initial work.

This may require a little work on the driver code. Bear in mind there are several concrete implementations and we want to avoid breaking them. Suggest talking this through with @willmcgugan once you have a plan for this.

2d

Hints from issue discussion:
Previous issue is #1582 and related PR is #1655 which, at the time, was working but stalled when it came to testing via the pilot.
While the bulk of this is now in place, I have run into one problem when it comes to `SIGTSTP` background of the app. With this PR in place, if you <kbd>Ctrl</kbd>+</kbd>Z</kbd> the app then `fg` it from the resulting shell it works fine. On the other hand if you first `bg` (which doesn't make sense for a full-screen application, of course, but it's something the user could attempt) and then `fg` you get the following:

```python
╭─────────────────────────────────────────────────── Traceback (most recent call last) ───────────────────────────────────────────────────╮
│ /Users/davep/develop/python/textual/src/textual/app.py:3375 in action_suspend_process                                                   │
│                                                                                                                                         │
│   3372 │   │   """                                                                                                                      │
│   3373 │   │   if not WINDOWS:                                                                                                          │
│   3374 │   │   │   try:                                                                                                                 │
│ ❱ 3375 │   │   │   │   with self.suspend():                                                                                             │
│   3376 │   │   │   │   │   os.kill(os.getpid(), signal.SIGTSTP)                                                                         │
│   3377 │   │   │   except SuspendNotSupported:                                                                                          │
│   3378 │   │   │   │   pass                                                                                                             │
│                                                                                                                                         │
│ ╭────────────────────────── locals ───────────────────────────╮                                                                         │
│ │ self = SplashApp(title='SplashApp', classes={'-dark-mode'}) │                                                                         │
│ ╰─────────────────────────────────────────────────────────────╯                                                                         │
│                                                                                                                                         │
│ /Users/davep/.pyenv/versions/3.11.6/lib/python3.11/contextlib.py:144 in __exit__                                                        │
│                                                                                                                                         │
│   141 │   def __exit__(self, typ, value, traceback):                                                                                    │
│   142 │   │   if typ is None:                                                                                                           │
│   143 │   │   │   try:                                                                                                                  │
│ ❱ 144 │   │   │   │   next(self.gen)                                                                                                    │
│   145 │   │   │   except StopIteration:                                                                                                 │
│   146 │   │   │   │   return False                                                                                                      │
│   147 │   │   │   else:                                                                                                                 │
│                                                                                                                                         │
│ ╭──────────────────────────────── locals ─────────────────────────────────╮                                                             │
│ │      self = <contextlib._GeneratorContextManager object at 0x106af29d0> │                                                             │
│ │ traceback = None                                                        │                                                             │
│ │       typ = None                                                        │                                                             │
│ │     value = None                                                        │                                                             │
│ ╰─────────────────────────────────────────────────────────────────────────╯                                                             │
│                                                                                                                                         │
│ /Users/davep/develop/python/textual/src/textual/app.py:3358 in suspend                                                                  │
│                                                                                                                                         │
│   3355 │   │   │   self._driver.close()                                                                                                 │
│   3356 │   │   │   with redirect_stdout(sys.__stdout__), redirect_stderr(sys.__stderr__):                                               │
│   3357 │   │   │   │   yield                                                                                                            │
│ ❱ 3358 │   │   │   self._driver.start_application_mode()                                                                                │
│   3359 │   │   │   self.app_resume_signal.publish()                                                                                     │
│   3360 │   │   else:                                                                                                                    │
│   3361 │   │   │   raise SuspendNotSupported(                                                                                           │
│                                                                                                                                         │
│ ╭────────────────────────── locals ───────────────────────────╮                                                                         │
│ │ self = SplashApp(title='SplashApp', classes={'-dark-mode'}) │                                                                         │
│ ╰─────────────────────────────────────────────────────────────╯                                                                         │
│                                                                                                                                         │
│ /Users/davep/develop/python/textual/src/textual/drivers/linux_driver.py:167 in start_application_mode                                   │
│                                                                                                                                         │
│   164 │   │   │   # defaults to ASCII EOT = Ctrl-D = 4.)                                                                                │
│   165 │   │   │   newattr[tty.CC][termios.VMIN] = 1                                                                                     │
│   166 │   │   │                                                                                                                         │
│ ❱ 167 │   │   │   termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)                                                              │
│   168 │   │                                                                                                                             │
│   169 │   │   self.write("\x1b[?25l")  # Hide cursor                                                                                    │
│   170 │   │   self.write("\033[?1003h\n")                                                                                               │
│                                                                                                                                         │
│ ╭─────────────────────────────────────────────────────────── locals ───────────────────────────────────────────────────────────╮        │
│ │               loop = <_UnixSelectorEventLoop running=True closed=False debug=False>                                          │        │
│ │            newattr = [                                                                                                       │        │
│ │                      │   26626,                                                                                              │        │
│ │                      │   3,                                                                                                  │        │
│ │                      │   19200,                                                                                              │        │
│ │                      │   67,                                                                                                 │        │
│ │                      │   9600,                                                                                               │        │
│ │                      │   9600,                                                                                               │        │
│ │                      │   [                                                                                                   │        │
│ │                      │   │   b'\x04',                                                                                        │        │
│ │                      │   │   b'\xff',                                                                                        │        │
│ │                      │   │   b'\xff',                                                                                        │        │
│ │                      │   │   b'\x7f',                                                                                        │        │
│ │                      │   │   b'\x17',                                                                                        │        │
│ │                      │   │   b'\x15',                                                                                        │        │
│ │                      │   │   b'\x12',                                                                                        │        │
│ │                      │   │   b'\xff',                                                                                        │        │
│ │                      │   │   b'\x03',                                                                                        │        │
│ │                      │   │   b'\xff',                                                                                        │        │
│ │                      │   │   ... +10                                                                                         │        │
│ │                      │   ]                                                                                                   │        │
│ │                      ]                                                                                                       │        │
│ │ on_terminal_resize = <function LinuxDriver.start_application_mode.<locals>.on_terminal_resize at 0x106a5fc40>                │        │
│ │               self = <LinuxDriver SplashApp(title='SplashApp', classes={'-dark-mode'})>                                      │        │
│ │    send_size_event = <function LinuxDriver.start_application_mode.<locals>.send_size_event at 0x106a5c220>                   │        │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯        │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
error: (4, 'Interrupted system call')
```

Attempting something similar with `vim` (for example), works just fine; the process stays in a `stopped` state and resumes without a problem. I'll use a bit more time to dig into how the Linux driver works and/or the method used to `SIGTSTP` the application as I sense the issue will be around there.

It's also worth noting that this mostly works fine with Midnight Commander (as another example app to test), although I have managed to get it into a state where it's failing to properly process non-alphanumeric keys or mouse input.
+1d

Commit message: Fix a typo

Co-authored-by: Darren Burns <darrenburns@users.noreply.github.com>

Patch to review:
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
 
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
diff --git a/docs/guide/app.md b/docs/guide/app.md
index c78b8b52a7..642d69c054 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -240,6 +240,66 @@ 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.
+
+### Suspending from foreground
+
+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 <kbd>Ctrl</kbd>+<kbd>Z</kbd>;
+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
 
 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).
diff --git a/src/textual/app.py b/src/textual/app.py
index 072625b0cb..638bdc8c7d 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
@@ -38,6 +39,7 @@
     Generator,
     Generic,
     Iterable,
+    Iterator,
     List,
     Sequence,
     Type,
@@ -56,7 +58,17 @@
 from rich.protocol import is_renderable
 from rich.segment import Segment, Segments
 
-from . import Logger, LogGroup, LogVerbosity, actions, constants, events, log, messages
+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
@@ -98,6 +110,7 @@
     ScreenResultType,
     _SystemModalScreen,
 )
+from .signal import Signal
 from .widget import AwaitMount, Widget
 from .widgets._toast import ToastRack
 from .worker import NoActiveWorker, get_current_worker
@@ -201,6 +214,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`][textual.app.App.suspend] is called while
+    the application is running in an environment where this isn't supported.
+    """
+
+
 ReturnType = TypeVar("ReturnType")
 
 CSSPathType = Union[
@@ -371,7 +392,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__(
@@ -573,6 +594,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 resumed 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")
 
@@ -3287,3 +3326,85 @@ 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)
+
+    def _suspend_signal(self) -> None:
+        """Signal that the application is being suspended."""
+        self.app_suspend_signal.publish()
+
+    @on(Driver.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.
+
+        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")
+            ```
+
+        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
+        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 `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__):
+                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(
+                "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` is sent to the
+            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.
diff --git a/src/textual/driver.py b/src/textual/driver.py
index 7cada2a473..1bc3a49804 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,12 +35,19 @@ 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:
         """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.
 
@@ -118,5 +126,23 @@ 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.
+
+        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 9b4b7c9da5..0b14b46ba3 100644
--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
@@ -49,6 +49,43 @@ 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 (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)
+
+    def _sigtstp_application(self, *_) -> None:
+        """Handle a SIGTSTP signal."""
+        # 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."""
+        if self._auto_restart:
+            self.start_application_mode()
+
+    @property
+    def can_suspend(self) -> bool:
+        """Can this driver be suspended?"""
+        return True
+
     def __rich_repr__(self) -> rich.repr.Result:
         yield self._app
 
@@ -115,6 +152,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():
@@ -170,6 +235,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.
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)
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.
 
diff --git a/src/textual/signal.py b/src/textual/signal.py
index cbf7a8e1dc..6226b0273b 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)
@@ -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(
diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py
index 431920422c..07c483bc65 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,12 @@ 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",
+        ]
         assert pilot.app._bindings.get_key("ctrl+c").priority is True
 
 
@@ -48,7 +51,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]):
@@ -352,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)
@@ -443,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)
@@ -498,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)
diff --git a/tests/test_suspend.py b/tests/test_suspend.py
new file mode 100644
index 0000000000..540cbf19d9
--- /dev/null
+++ b/tests/test_suspend.py
@@ -0,0 +1,67 @@
+import sys
+
+import pytest
+
+from textual.app import App, SuspendNotSupported
+from textual.drivers.headless_driver import HeadlessDriver
+
+
+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
+
+
+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")
+
+    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()
+        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", "suspend signal", "resume signal"}


Code review comments:
diff_hunk: @@ -240,6 +240,66 @@ 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.
path: docs/guide/app.md
text: @user1:
I think we can simplify, since it works everywhere except textual-web.

```suggestion
!!! info

    App suspension is unavailable with [textual-web](https://github.com/Textualize/textual-web).
```

diff_hunk: @@ -3287,3 +3326,85 @@ 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)
+
+    def _suspend_signal(self) -> None:
+        """Signal that the application is being suspended."""
+        self.app_suspend_signal.publish()
+
+    @on(Driver.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.
+
+        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")
+            ```
+
+        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
+        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()
path: src/textual/app.py
text: @user1:
Suggest we add `pause_application_mode` and `resume_application_mode` to the Driver, which would allow future implementations the ability to provide different implementations for these.

@author:
Good call; done.