Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Application suspension #4064

Merged
merged 50 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
82a91ab
Strip trailing whitespace.
davep Jan 22, 2024
004513c
Experimental suspend context manager
davep Jan 22, 2024
db31c61
Use the dunder values for stdin and stdout
davep Jan 22, 2024
68c9667
Revert "Use the dunder values for stdin and stdout"
davep Jan 22, 2024
47087a9
Experiment to see if a call to close is needed too
davep Jan 23, 2024
e7d7b1a
Seek to eliminate the bad file descriptor error on Windows
davep Jan 23, 2024
693fd21
Add a docstring to suspend
davep Jan 23, 2024
ba87cf8
Add a can_suspend property to the Driver base class
davep Jan 23, 2024
0b30302
Allow suspending the application when running with the Linux driver
davep Jan 23, 2024
bec1f81
Allow suspending the application when running with the Windows driver
davep Jan 23, 2024
a2743fc
Test if a driver allows suspending the application
davep Jan 23, 2024
cedb3f2
Add a note about the suspend exception to the docstring
davep Jan 23, 2024
0abb2c7
Add a unit test for the suspend exception
davep Jan 23, 2024
faf9b51
Add a test for doing a suspend
davep Jan 23, 2024
f204373
Add support for using Ctrl+Z to background the application
davep Jan 23, 2024
0f20967
Modify the binding tests to take the new default binding into account
davep Jan 23, 2024
7da4a1a
Tidy a couple of docstrings
davep Jan 23, 2024
cb9f580
Merge branch 'main' into suspend-redux
davep Jan 24, 2024
0702879
Correct the description of the signal exception
davep Jan 24, 2024
78e57da
Add a Raises section to the Signal.subscribe docstring
davep Jan 24, 2024
7eb06ac
Include Signal in the API docs
davep Jan 24, 2024
6fb4d71
Add Signal support to suspend
davep Jan 24, 2024
5adfda1
Add the suspend and resume signals to the suspend tests
davep Jan 24, 2024
e5accb2
Start an App Basics section about suspending an app
davep Jan 24, 2024
374478a
Move the main work on suspending with Ctrl+Z into the Linux driver
davep Jan 29, 2024
47a9a95
Merge branch 'main' into suspend-redux
davep Jan 29, 2024
446424b
Reinstate support for the Textual signals for suspend resume on OS su…
davep Jan 29, 2024
0f1c3e8
Fix a typo
davep Jan 30, 2024
f72c258
Merge branch 'main' into suspend-redux
davep Jan 30, 2024
7de303b
Ensure we don't restart application mode in the wrong place
davep Jan 30, 2024
ddb24a3
Bump the SignalResume message up to the Driver level
davep Jan 30, 2024
e8291da
Add some notes about what the suspend code is doing
davep Jan 30, 2024
b73523a
Remove Ctrl+Z as the default binding for suspending
davep Jan 30, 2024
5d09bc1
Spell Textual Web as Textual Web not Textual-Web
davep Jan 30, 2024
8f981f3
Merge branch 'main' into suspend-redux
davep Jan 30, 2024
3a91b37
Update the ChangeLog
davep Jan 30, 2024
d033407
Fix a typo
davep Jan 30, 2024
8dcf55a
Add Driver.suspend/resume_application_mode interface
davep Jan 31, 2024
ea36a43
Also Driver.close in Driver.suspend_application_mode
davep Jan 31, 2024
f5d32bc
Update suspend testing for the new approach
davep Jan 31, 2024
dca798f
Improve documentation
davep Jan 31, 2024
3914215
Simplify a caveat in the docs
davep Jan 31, 2024
d94c2f9
Improve a heading in the docs
davep Jan 31, 2024
7fb59d1
Docs wording tweak
davep Jan 31, 2024
0003b52
Celebrate vim in the docs
davep Jan 31, 2024
d613b8b
Merge branch 'main' into suspend-redux
davep Jan 31, 2024
c916a82
Include the output of the suspend example in the docs
davep Jan 31, 2024
1cd64f9
Add a full app to show off action_suspend_process binding
davep Jan 31, 2024
025ac85
Add action_suspend_process to the list of builtin actions
davep Jan 31, 2024
df73e71
Actually Driver.close in the right place!
davep Jan 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

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
60 changes: 60 additions & 0 deletions docs/guide/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
davep marked this conversation as resolved.
Show resolved Hide resolved

!!! 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.
davep marked this conversation as resolved.
Show resolved Hide resolved

### Suspending in code
davep marked this conversation as resolved.
Show resolved Hide resolved

To suspend your application use the [App.suspend](/api/app/#textual.app.App.suspend) context manager.
davep marked this conversation as resolved.
Show resolved Hide resolved
For example, here is an application that has a button that will open an external editor, suspending the application before running it:
davep marked this conversation as resolved.
Show resolved Hide resolved

```python hl_lines="14-15"
davep marked this conversation as resolved.
Show resolved Hide resolved
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
davep marked this conversation as resolved.
Show resolved Hide resolved
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).
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 @@ -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
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -3287,3 +3326,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)
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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.
44 changes: 43 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,39 @@ 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."""

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