Skip to content

Commit

Permalink
Merge pull request #4064 from davep/suspend-redux
Browse files Browse the repository at this point in the history
Application suspension
  • Loading branch information
willmcgugan authored Jan 31, 2024
2 parents 60e0d8d + df73e71 commit 9268f29
Show file tree
Hide file tree
Showing 14 changed files with 419 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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

Expand Down
1 change: 1 addition & 0 deletions docs/api/signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual.signal
20 changes: 20 additions & 0 deletions docs/examples/app/suspend.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 15 additions & 0 deletions docs/examples/app/suspend_process.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
51 changes: 51 additions & 0 deletions docs/guide/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,57 @@ if __name__ == "__main__"
sys.exit(app.return_code or 0)
```

## Suspending

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

App suspension is unavailable with [textual-web](https://github.com/Textualize/textual-web).

### Suspend context manager

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:

=== "suspend.py"

```python hl_lines="14-15"
--8<-- "docs/examples/app/suspend.py"
```

1. All code in the body of the `with` statement will be run while the app is suspended.

=== "Output"

```{.textual path="docs/examples/app/suspend.py"}
```

### 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:

=== "suspend_process.py"

```python hl_lines="8"
--8<-- "docs/examples/app/suspend_process.py"
```

=== "Output"

```{.textual path="docs/examples/app/suspend_process.py"}
```

!!! 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).
Expand Down
122 changes: 120 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import io
import os
import platform
import signal
import sys
import threading
import warnings
Expand All @@ -38,6 +39,7 @@
Generator,
Generic,
Iterable,
Iterator,
List,
Sequence,
Type,
Expand All @@ -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
Expand Down Expand Up @@ -99,6 +111,7 @@
ScreenResultType,
_SystemModalScreen,
)
from .signal import Signal
from .widget import AwaitMount, Widget
from .widgets._toast import ToastRack
from .worker import NoActiveWorker, get_current_worker
Expand Down Expand Up @@ -202,6 +215,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[
Expand Down Expand Up @@ -372,7 +393,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__(
Expand Down Expand Up @@ -574,6 +595,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")

Expand Down Expand Up @@ -3296,3 +3335,82 @@ 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 suspend application mode.
self._suspend_signal()
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
# 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 resume application mode.
self._driver.resume_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.
45 changes: 44 additions & 1 deletion src/textual/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -118,5 +126,40 @@ 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()
self.close()

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."""

@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."""
Loading

0 comments on commit 9268f29

Please sign in to comment.