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

Application suspension #4064

merged 50 commits into from
Jan 31, 2024

Conversation

davep
Copy link
Contributor

@davep davep commented Jan 23, 2024

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:

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()
Screen.Recording.2024-01-30.at.13.24.40.mov

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 Ctrl+Z. 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.

Screen.Recording.2024-01-30.at.13.30.05.mov

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

    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:

Screen.Recording.2024-01-30.at.14.18.38.mov

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:

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

Screen.Recording.2024-01-30.at.14.24.32.mov

davep and others added 16 commits January 22, 2024 13:17
Pulling out the very core of Textualize#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).
This reverts commit db31c61.

Didn't address the issue I was trying to understand.
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.
Adding Josh Karpel as a co-author here; not because of the docstring, but
the core idea started with Textualize#1541 and this is a reimplementation of that code
in the current version of Textual.

Co-authored-by: Josh Karpel <[email protected]>
This will be used by subclasses to say if the environment they pertain to
permits a suspension of the application.
And by extension macOS and BSD, etc (the Linux driver is really a Un*x
driver).
And, if it doesn't, raise an exception.
Borrowing heavily from Josh's testing.

Co-authored-by: Josh Karpel <[email protected]>
@davep davep added enhancement New feature or request Task labels Jan 23, 2024
@davep davep self-assigned this Jan 23, 2024
davep added 8 commits January 23, 2024 15:09
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.
This adds a signal that is published before the suspension finally happens,
and another once the application is back and running again.
@davep davep linked an issue Jan 24, 2024 that may be closed by this pull request
@davep davep requested a review from darrenburns January 30, 2024 14:45
src/textual/app.py Outdated Show resolved Hide resolved
davep and others added 4 commits January 30, 2024 18:05
Co-authored-by: Darren Burns <[email protected]>
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.
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.
Copy link
Collaborator

@willmcgugan willmcgugan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Just some suggestions, mostly doc related.

src/textual/app.py Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
docs/guide/app.md Outdated Show resolved Hide resolved
@davep
Copy link
Contributor Author

davep commented Jan 31, 2024

@willmcgugan Changes made and doc-tweaks merged.

Copy link
Collaborator

@willmcgugan willmcgugan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request Task
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add support for suspend and resume.
3 participants