Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ each:
- Example of using Rich Tables within a cmd2 application for displaying tabular data
- [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py)
- Demonstrates how to create a custom theme for a cmd2 application
- [textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py)
- Shows how to embed a `cmd2` application in a [Textual](https://textual.textualize.io/)
application using [textual-tty](https://github.com/ttygroup/textual-tty), with a realtime
status panel next to the terminal. Requires `textual-tty`, which is not a `cmd2` dependency
- [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh)
- Shell script that launches two applications using tmux in different windows/tabs
- [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh)
Expand Down
227 changes: 227 additions & 0 deletions examples/textual_tty_embed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Embed a cmd2 application in a Textual application using textual-tty.

The Textual application shows the cmd2 application in a terminal pane on the left. A status panel on the
right is a regular Textual widget, so it can show much richer, realtime information than a bottom toolbar.
The panel has a wall clock which is updated by Textual and the state of the cmd2 application: whether it
is idle or running a command, how many commands it has run, and what the last one was.

How it works:
- [textual-tty](https://github.com/ttygroup/textual-tty) provides the ``Terminal`` widget, a terminal
emulator (built on bittty) which runs a child process attached to a pseudo-terminal.
- The child process is simply this script run with ``--cmd2-only``, so the cmd2 application is completely
unaware that it is being embedded. It is a normal cmd2 application which uses prompt-toolkit.
- To report its state to the host, the cmd2 application calls ``cmd2.Cmd.set_window_title()`` with a JSON
document from precmd and command finalization hooks. The ``Terminal`` widget turns the title change
into a ``Terminal.TitleChanged`` message which the Textual application uses to update the status panel.
This uses only the pseudo-terminal, so there are no sockets, files, or threads to manage.

Neither textual nor textual-tty are dependencies of cmd2. To run this example from a cmd2 checkout use:

uv run --with textual-tty examples/textual_tty_embed.py

or install the extra packages into your own environment with ``pip install textual-tty`` and then run
``python examples/textual_tty_embed.py``. This requires a POSIX terminal or Windows with pywinpty
(which textual-tty installs for you).

The cmd2 application can also be run on its own, without Textual:

python examples/textual_tty_embed.py --cmd2-only

Use the ``quit`` command in the embedded cmd2 application, or press Ctrl-Q, to exit.
"""

import argparse
import json
import sys
import time
from datetime import datetime
from typing import Any, ClassVar

from prompt_toolkit.application import get_app_session
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header, Static
from textual_tty import Terminal

import cmd2

# Prefix which marks a window title as a status update from the cmd2 application
STATUS_PREFIX = "cmd2-status:"


class StatusApp(cmd2.Cmd):
"""A small cmd2 application which reports its state to whatever terminal it is running in."""

def __init__(self) -> None:
"""Initialize the cmd2 application and register the hooks which report its status."""
super().__init__(allow_cli_args=False)
self.prompt = "status-demo> "
self.intro = "Run a few commands and watch the status panel next to this terminal."

self._commands_run = 0
self._last_command = ""
self._last_duration = 0.0
self._command_started = 0.0

self.register_precmd_hook(self._on_command_start)
self.register_cmdfinalization_hook(self._on_command_end)

def _report_status(self, state: str) -> None:
"""Send the application's current state to the terminal as a window title."""
status = {
"state": state,
"commands_run": self._commands_run,
"last_command": self._last_command,
"last_duration": self._last_duration,
}
self.set_window_title(STATUS_PREFIX + json.dumps(status))
# prompt-toolkit buffers its output, so flush it or the "running" state would only appear after the command ends
get_app_session().output.flush()

def preloop(self) -> None:
"""Report the initial status before the first prompt."""
self._report_status("idle")

def _on_command_start(self, data: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData:
"""Report that a command is about to run."""
self._last_command = data.statement.command or ""
self._command_started = time.monotonic()
self._report_status("running")
return data

def _on_command_end(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData:
"""Report that the command is done, even when it failed."""
self._commands_run += 1
self._last_duration = time.monotonic() - self._command_started
self._report_status("idle")
return data

@cmd2.with_argument_list
def do_greet(self, args: list[str]) -> None:
"""Greet someone."""
self.poutput(f"Hello, {' '.join(args) or 'world'}!")

@cmd2.with_argument_list
def do_slow(self, args: list[str]) -> None:
"""Run for a number of seconds (default 3) so the status panel can be seen in the running state."""
try:
seconds = float(args[0]) if args else 3.0
except ValueError:
self.perror(f"Not a number: {args[0]}")
return
self.poutput(f"Working for {seconds:g} seconds...")
time.sleep(seconds)
self.poutput("Done.")


class ClockPanel(Static):
"""The time of day, which is updated by Textual and has nothing to do with the cmd2 application."""

def on_mount(self) -> None:
"""Start updating the clock every second."""
self.update_clock()
self.set_interval(1, self.update_clock)

def update_clock(self) -> None:
"""Show the current time."""
self.update(f"[b]Clock[/b]\n{datetime.now().astimezone():%H:%M:%S %Z}")


class StatusPanel(Static):
"""The state of the embedded cmd2 application as last reported through its window title."""

state: reactive[str] = reactive("starting")
commands_run: reactive[int] = reactive(0)
last_command: reactive[str] = reactive("")
last_duration: reactive[float] = reactive(0.0)

def render(self) -> str:
"""Render the panel from the reactive attributes. Textual refreshes it when any of them change."""
color = "green" if self.state == "idle" else "yellow"
return (
"[b]cmd2 status[/b]\n"
f"State: [{color}]{self.state}[/{color}]\n"
f"Commands run: {self.commands_run}\n"
f"Last command: {self.last_command or '-'}\n"
f"Last duration: {self.last_duration:.2f}s"
)

def apply(self, status: dict[str, Any]) -> None:
"""Update the panel from a status document sent by the cmd2 application."""
self.state = str(status.get("state", "unknown"))
self.commands_run = int(status.get("commands_run", 0))
self.last_command = str(status.get("last_command", ""))
self.last_duration = float(status.get("last_duration", 0.0))


class Cmd2InTextual(App[None]):
"""A Textual application with a cmd2 application in a terminal pane and a status panel beside it."""

TITLE = "cmd2 inside Textual"

CSS = """
#cmd2-pane {
width: 2fr;
border: round $primary;
}
#cmd2 {
width: 1fr;
height: 1fr;
}
#sidebar {
width: 1fr;
}
#sidebar Static {
border: round $secondary;
padding: 0 1;
margin-bottom: 1;
}
"""

# Priority bindings are needed because the focused Terminal sends every key press to the cmd2 application
BINDINGS: ClassVar[list[BindingType]] = [Binding("ctrl+q", "quit", "Quit", priority=True)]

def compose(self) -> ComposeResult:
"""Create the layout: the terminal running cmd2 on the left and the status panels on the right."""
yield Header()
with Horizontal():
# The border is on a container because a Terminal sizes its pseudo-terminal from its outer size, so a
# border on the Terminal itself makes the cmd2 app draw more rows than are visible and hides its prompt
with Vertical(id="cmd2-pane"):
yield Terminal(command=[sys.executable, __file__, "--cmd2-only"], id="cmd2")
with Vertical(id="sidebar"):
yield StatusPanel(id="status")
yield ClockPanel(id="clock")
yield Footer()

def on_mount(self) -> None:
"""Give the keyboard focus to the terminal so typing goes to the cmd2 application."""
self.query_one(Terminal).focus()

def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None:
"""Update the status panel when the cmd2 application reports its status."""
if not event.title.startswith(STATUS_PREFIX):
return
try:
status = json.loads(event.title.removeprefix(STATUS_PREFIX))
except json.JSONDecodeError:
return
self.query_one(StatusPanel).apply(status)

def on_terminal_process_exited(self, _event: Terminal.ProcessExited) -> None:
"""Exit when the cmd2 application exits, for example due to the quit command."""
self.exit()


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run a cmd2 application embedded in a Textual application.")
parser.add_argument("--cmd2-only", action="store_true", help="run just the cmd2 application, without Textual")
cli_args = parser.parse_args()

if cli_args.cmd2_only:
sys.exit(StatusApp().cmdloop())
else:
Cmd2InTextual().run()
Loading