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

Launching subprocesses like Vim from Textual apps #1093

Closed
darrenburns opened this issue Nov 2, 2022 · 11 comments
Closed

Launching subprocesses like Vim from Textual apps #1093

darrenburns opened this issue Nov 2, 2022 · 11 comments

Comments

@darrenburns
Copy link
Member

darrenburns commented Nov 2, 2022

Right now if you launch a subprocess of say Vim or Nano from a Textual app, there's a race condition between Textual and the subprocess - some of the input makes it into the subprocess, but some gets eaten by Textual's driver thread.

Also, if the subprocess changes the terminal state, then after it exits the terminal state may not be what Textual expects. For example, the cursor may be visible.

We should explore how to make this easier, e.g. via a context manager/utility method for launching subprocesses which pause the driver and resumes after the subprocess exits.

@davep
Copy link
Contributor

davep commented Nov 3, 2022

See also #165

@JoshKarpel
Copy link
Contributor

Just put up a related PR (I should have checked the issues first!) #1150

@rustybrooks
Copy link

rustybrooks commented Nov 14, 2022

I had a similar problem but took a different approach. In my case, I wanted to be able to run an interactive program within a widget in a textual app. The attached code is pretty rough and dirty but it mostly works. Some missing things:

  1. it doesn't do color
  2. there's no cursor - I'd have to track the cursor location and display it, which is doable

what it does is

  1. opens a pseudo tty
  2. opens whatever you want to run, giving half the ptty pair to the process as stdin/out/err
  3. uses the "pyte" library to create a virtual screen sized the same size as the component
  4. runs a scheduled job to check to see if there's any output from the ptty. If there is, stream it to the pyte screen and replace the widget contents with the rendered screen
  5. capture keystrokes and send them to the ptty input

I want to stress that this needs refinement and is just the first POC that actually worked for me.

You need to install "pyte" and of course textual. You can save this script as interactive_shell.py and run a test like
python ./interactive_shell.py
right now it'll just try to run vi

import asyncio
import os
import pty
import select
import subprocess

import pyte
from textual import events
from textual.app import App
from textual.widgets import Static


class InteractiveShell(Static, can_focus=True):
    _screen = None
    _stream = None
    _pty = _tty = None

    DEFAULT_CSS = """
    InteractiveShell {
        width: 100%;
        height: 100%;
    }"""

    def __init__(
        self,
        command,
        exit_command,
        *,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
    ) -> None:
        super().__init__(
            "",
            name=name,
            id=id,
            classes=classes,
        )
        self.command = command
        self.exit_command = exit_command

    async def on_mount(self):
        self.focus()
        self.call_later(self._run)

    def on_key(self, event: events.Key) -> None:
        if event.char:
            os.write(self._pty, event.char.encode("utf-8"))
        elif event.key == "up":
            os.write(self._pty, "".join([chr(0x1B), chr(0x5B), chr(0x41)]).encode("utf-8"))
        elif event.key == "down":
            os.write(self._pty, "".join([chr(0x1B), chr(0x5B), chr(0x42)]).encode("utf-8"))
        elif event.key == "left":
            os.write(self._pty, "".join([chr(0x1B), chr(0x5B), chr(0x44)]).encode("utf-8"))
        elif event.key == "right":
            os.write(self._pty, "".join([chr(0x1B), chr(0x5B), chr(0x43)]).encode("utf-8"))
        else:
            print(event)

        event.stop()

    async def _run(self):
        print("run", self.command)
        self._pty, self._tty = pty.openpty()
        self.process = subprocess.Popen(
            self.command, stdin=self._tty, stdout=self._tty, stderr=self._tty
        )
        self._screen = pyte.Screen(self.size[1], self.size[0])
        self._stream = pyte.Stream(self._screen)
        await self._run_check()

    async def _run_check(self):
        if self.process.poll() is not None:
            print("process is gone, quitting")
            self.exit_command()

        r, _, _ = select.select([self._pty], [], [], 0)
        if self._pty in r:
            output = os.read(self._pty, 10240)

            self._screen.resize(self.size[1], self.size[0])

            self._stream.feed(output.decode())
            self.update("\n".join(self._screen.display))
            self._screen.dirty.clear()
        else:
            await asyncio.sleep(1 / 100.0)

        self.call_later(self._run_check)


class TestApp(App):
    def compose(self):
        yield InteractiveShell(["vi"], self.exit)


if __name__ == "__main__":
    a = TestApp()
    a.run()

@zzzeek
Copy link

zzzeek commented May 4, 2023

chiming in my ultimate feature would be a way to pop open a dialog within a Textual layout that has a console application running inside of it, nested inside the bigger layout. for my end it's the "fzf" command so I may look into writing a mini "fzf-like" component for my immediate use case (choosing from large lists).

@davep
Copy link
Contributor

davep commented May 4, 2023

@zzzeek https://github.com/mitosch/textual-terminal might interest you.

@TomJGooding
Copy link
Contributor

I think this is now resolved by #4064?

@davep
Copy link
Contributor

davep commented Jan 31, 2024

Good catch; and managing to dig this up might make @TomJGooding the Phil Harding of Textual.

@davep davep closed this as completed Jan 31, 2024
Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

@JacobKochems
Copy link

JacobKochems commented Jul 9, 2024

@zzzeek Where you able to make it work? When I try to call fzf I see its interface but the moment I type something, escape sequences (;129u) appear in the input line and I have to kill the terminal window to quit.

I call it from within a button press handler like so:

def on_button_pressed(self, event: Button.Pressed):
    with self.suspend():
        iterfzf(['one', 'two', 'three'])

@zzzeek
Copy link

zzzeek commented Jul 10, 2024

@JacobKochems looks like last time I was doing a thing with fzf I didn't write the app as a textual app, just a stdin thing

@JacobKochems
Copy link

@zzzeek Where you able to make it work? When I try to call fzf I see its interface but the moment I type something, escape sequences (;129u) appear in the input line and I have to kill the terminal window to quit.

I call it from within a button press handler like so:

def on_button_pressed(self, event: Button.Pressed):
    with self.suspend():
        iterfzf(['one', 'two', 'three'])

Ok, this issue seems to be specific to the kitty terminal. I can't reproduce it with e.g. gnome-terminal. I guess it must have something to do with kitty's departure from some legacy terminal behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
7 participants