|
| 1 | +# Embedding in Textual |
| 2 | + |
| 3 | +[Textual](https://textual.textualize.io/) builds full-screen terminal user interfaces with widgets, |
| 4 | +layouts, and mouse support, while `cmd2` builds interactive command interpreters. You don't have to |
| 5 | +choose between them. [textual-tty](https://github.com/ttygroup/textual-tty) provides a `Terminal` |
| 6 | +widget for Textual applications, which is a terminal emulator that runs a program attached to a |
| 7 | +pseudo-terminal. That program can be a `cmd2` application, so a Textual application can show a |
| 8 | +`cmd2` application in one pane and, beside it, other widgets which display realtime status and |
| 9 | +situational awareness information. This can be much richer than what fits in a |
| 10 | +[bottom toolbar](../features/prompt.md#bottom-toolbar). |
| 11 | + |
| 12 | +The |
| 13 | +[textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py) |
| 14 | +example shows how to do this. It has a `cmd2` application in a terminal pane on the left. On the |
| 15 | +right, a status panel shows whether the `cmd2` application is idle or running a command, how many |
| 16 | +commands it has run, and how long the last one took, and a clock is updated by Textual on its own. |
| 17 | + |
| 18 | +!!! warning |
| 19 | + |
| 20 | + textual-tty is still pre-alpha software, so its API may change. The example was written for |
| 21 | + textual-tty 0.4. Also, on Windows textual-tty can only start a program by name and can't pass |
| 22 | + it arguments, so the example's Textual application requires a POSIX system such as Linux or |
| 23 | + macOS. |
| 24 | + |
| 25 | +## Running the Example |
| 26 | + |
| 27 | +Neither Textual nor textual-tty are dependencies of `cmd2`. In a `cmd2` checkout, textual-tty is |
| 28 | +part of the development environment on POSIX systems, so you can run: |
| 29 | + |
| 30 | +```shell |
| 31 | +uv run examples/textual_tty_embed.py |
| 32 | +``` |
| 33 | + |
| 34 | +Otherwise, install textual-tty, which also installs Textual, into your own environment: |
| 35 | + |
| 36 | +```shell |
| 37 | +pip install textual-tty |
| 38 | +python examples/textual_tty_embed.py |
| 39 | +``` |
| 40 | + |
| 41 | +Use the `quit` command or press ++ctrl+q++ to exit. Pass `--cmd2-only` to run just the `cmd2` |
| 42 | +application in your current terminal, without Textual. |
| 43 | + |
| 44 | +## How It Works |
| 45 | + |
| 46 | +The Textual application runs the `cmd2` application as a child process in a `Terminal` widget. The |
| 47 | +child process is the example script itself, run with `--cmd2-only --report-status`: |
| 48 | + |
| 49 | +```py |
| 50 | +def compose(self) -> ComposeResult: |
| 51 | + cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"] |
| 52 | + yield Header() |
| 53 | + with Horizontal(): |
| 54 | + with Vertical(id="cmd2-pane"): |
| 55 | + yield Terminal(command=cmd2_command, id="cmd2") |
| 56 | + with Vertical(id="sidebar"): |
| 57 | + yield StatusPanel(id="status") |
| 58 | + yield ClockPanel(id="clock") |
| 59 | + yield Footer() |
| 60 | +``` |
| 61 | + |
| 62 | +Because it is attached to a pseudo-terminal, the `cmd2` application is a completely normal `cmd2` |
| 63 | +application which uses `prompt-toolkit` and doesn't know that it is embedded. Everything typed into |
| 64 | +the focused `Terminal` goes to the `cmd2` application. |
| 65 | + |
| 66 | +To report its state, the `cmd2` application registers a |
| 67 | +[postparsing hook](../features/hooks.md#postparsing-hooks), which runs before each command, and a |
| 68 | +[command finalization hook](../features/hooks.md#command-finalization-hooks), which runs after it. |
| 69 | +Both of them call [cmd2.Cmd.set_window_title][] with a JSON document. The document is prefixed with |
| 70 | +`cmd2-status:` so that the Textual application can tell it apart from other window titles. The |
| 71 | +`Terminal` widget turns each title change into a `Terminal.TitleChanged` message, which the Textual |
| 72 | +application handles to update the status panel: |
| 73 | + |
| 74 | +```py |
| 75 | +def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None: |
| 76 | + if not event.title.startswith(STATUS_PREFIX): |
| 77 | + return |
| 78 | + with contextlib.suppress(TypeError, ValueError, OverflowError, RecursionError): |
| 79 | + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) |
| 80 | + if isinstance(status, dict): |
| 81 | + self.query_one(StatusPanel).apply(status) |
| 82 | +``` |
| 83 | + |
| 84 | +Since the status travels through the pseudo-terminal, there are no sockets, files, or threads to |
| 85 | +manage. |
| 86 | + |
| 87 | +## Things to Watch Out For |
| 88 | + |
| 89 | +The example handles several details which are easy to miss when building something similar: |
| 90 | + |
| 91 | +- Command finalization hooks run for every line of input, including blank lines, comments, |
| 92 | + ++ctrl+c++ at the prompt, and lines with syntax errors. The other hooks only run for lines which |
| 93 | + were parsed into a statement, so check whether `data.statement` is `None` in a finalization hook. |
| 94 | +- Commands run by other commands, like `run_script`, `history -r`, and `run_pyscript`, go through |
| 95 | + the hooks too. The example counts the running commands so that only the outermost one is reported. |
| 96 | +- The postparsing hooks stop running as soon as one of them sets `stop` or raises an exception, but |
| 97 | + the finalization hooks still run. If your application has other postparsing hooks, register the |
| 98 | + one which counts commands first so that it is never skipped. |
| 99 | +- `prompt-toolkit` buffers its output, so flush it after setting the window title when a command |
| 100 | + starts. Otherwise the "running" state only reaches the Textual application after the command is |
| 101 | + done. |
| 102 | +- Any program run in the terminal, for example by the `shell` command, can set the window title. |
| 103 | + Validate a status document before using it. Besides `json.JSONDecodeError`, `json.loads` raises |
| 104 | + `ValueError` for integers with too many digits and `RecursionError` for documents which are nested |
| 105 | + too deeply. Also escape the document's strings with `textual.markup.escape` before putting them in |
| 106 | + Textual markup. |
| 107 | +- Only report status through the window title when running inside the Textual application. In a |
| 108 | + normal terminal, it would overwrite the title of the user's window. |
| 109 | +- If the Textual application exits while a command is running, the pseudo-terminal is closed before |
| 110 | + the finalization hook reports the end of that command, so setting the window title raises |
| 111 | + `OSError`. The example ignores that error. |
| 112 | +- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code, which is the |
| 113 | + negated signal number if a signal killed it. The example passes the exit code back to the shell, |
| 114 | + using 128 plus the signal number like a shell does, and when it isn't 0 the example keeps the |
| 115 | + terminal on the screen so that any traceback can still be read. |
| 116 | +- A `Terminal` sizes its pseudo-terminal from its outer size, so put a border on a container around |
| 117 | + the `Terminal` rather than on the `Terminal` itself. Otherwise the `cmd2` application draws more |
| 118 | + rows than are visible and its prompt can end up hidden. |
| 119 | +- The focused `Terminal` sends almost every key press to the `cmd2` application. Textual's built-in |
| 120 | + ++ctrl+q++ binding to quit is a priority binding, so it still works. |
0 commit comments