Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ All notable changes to TextUI are documented here.

### Added

- A checkout `./showcase` launcher for macOS/Linux that installs the locked project through isolated Poetry and launches via `python -m textui`, including from another working directory. Setup instructions now use the same managed environment instead of assuming a global command or `.venv/bin/textui`.
- Log `on-selection-ended` actions receive completed pointer selections without polling or redraw-triggered callbacks. Copying remains opt-in through asynchronous `window.copy(text)` or the exported `copy_to_clipboard(app, text)` helper, with native clipboard tools and OSC 52 transport.
- `ProjectApp(source, context=...)` exposes a host-supplied object through read-only `window.context` from controller script loading onward.
- Linked controllers may define synchronous or asynchronous `on_resize(width, height)` to respond after terminal resize layout refreshes, without polling.
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,35 @@ The [roadmap](docs/roadmap.md) records current priorities, completion gates, and

Python `>=3.11,<4` is required; the release matrix covers 3.11, 3.12, and 3.14. Core dependencies are Textual `>=8.2.8,<9` and lxml `>=6.1.3,<7`. Core installation does not require Pillow or textual-imageview; image components are a future extension.

From a checkout:
For a checkout on macOS or Linux, install [uv](https://docs.astral.sh/uv/getting-started/installation/) once, then run:

```sh
python -m pip install .
textui run examples/project/app.ui
textui run examples/controls/app.ui
textui run examples/data/app.ui
textui run examples/showcase/app.ui
python -m examples.editor
./showcase
```

The launcher uses Python 3.12 and isolated Poetry 2.4.3, installs the locked project and test dependencies, and runs `python -m textui`. Its Poetry-managed environment lives in Poetry's cache, outside the checkout. No shell activation or globally available `textui` command is needed. The first run may download Python, Poetry and dependencies; later runs check the locked install before opening the app. Ctrl+Q quits.

From another directory, use the launcher's absolute path, for example:

```sh
/Users/abeihl/Development/TextUI/showcase
```

For other examples or development commands, use the same environment policy from the repository root:

```sh
unset VIRTUAL_ENV CONDA_PREFIX
export POETRY_VIRTUALENVS_CREATE=true
export POETRY_VIRTUALENVS_IN_PROJECT=false
Comment thread
thunderballfists marked this conversation as resolved.
export POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true
uvx --python 3.12 --from poetry==2.4.3 poetry env use 3.12
uvx --python 3.12 --from poetry==2.4.3 poetry install --with test --no-interaction
uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run examples/project/app.ui
uvx --python 3.12 --from poetry==2.4.3 poetry run python -m examples.editor
```

Replace `examples/project/app.ui` with `examples/controls/app.ui`, `examples/data/app.ui` or `examples/components/app.ui` to try those projects. Run tests with `uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest -q`. To locate the managed environment, run `uvx --python 3.12 --from poetry==2.4.3 poetry env info --path`; do not assume `.venv/bin/textui` exists. An installed library also supports `python -m textui run /absolute/path/app.ui` using the Python interpreter where it was installed.

The [showcase](examples/showcase/app.ui) combines every built-in widget family in one navigable project: components, layouts, controls, tables, trees, lists, logs, modals, actions, and timers. The project example demonstrates a linked Python controller, local TCSS, an included view, sidebar navigation, a resizable split, and a timer; see the [project runtime guide](docs/project-runtime.md). The [component example](examples/components/app.ui) demonstrates imported `.ui` components, literal properties, and slots. The [controls example](examples/controls/app.ui) demonstrates form controls, tabs, radio choices, collapsible content, progress bars, and rules. The [data example](examples/data/app.ui) demonstrates an API-backed runtime table beside seeded native tables and trees; see the [controls guide](docs/controls.md). The separate editor is a small form demonstrating a Save action that updates a status label; it does not write a file. Press Ctrl+Q to quit. Its XML path is relative to the example module, independent of the working directory. Examples are included in the source distribution, not the installed library wheel.

For a linked-script project launched from Python, `ProjectApp(source, context=host_value)` makes `host_value` available as read-only `window.context` in the controller. Controllers may also define `on_resize(width, height)` to update width-sensitive content after the screen refreshes; see the [runtime lifecycle guide](docs/project-runtime.md).
Expand Down
12 changes: 6 additions & 6 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Testing TextUI

Run the normal headless suite from the repository root:
Follow the [checkout setup](../README.md#install-and-run), then run the normal headless suite from the repository root. All commands use Poetry's managed environment; no checkout `.venv` path is assumed:

```sh
uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest -q
Expand All @@ -9,18 +9,18 @@ uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest -q
Normal tests do not create screenshots. The visual suite is collected only when `TEXTUI_VISUAL_TESTS=1` is set and compares exported Textual SVGs with reviewed baselines:

```sh
TEXTUI_VISUAL_TESTS=1 .venv/bin/python -m pytest tests/visual -q
TEXTUI_VISUAL_TESTS=1 uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/visual -q
```

The four baselines are stored under `tests/visual/__snapshots__/`: the showcase at 120×50 and 80×24, plus its first and reopened help modal at 80×24. They use Textual's built-in `App.export_screenshot()` rather than an additional snapshot package because the available `pytest-textual-snapshot` release requires pytest below TextUI's supported pytest 9 range.

To intentionally update a baseline, run the command below, then render and inspect every changed SVG before committing it. The update command is local-only; CI never updates baselines.
To intentionally update a baseline, run the commands below, then render and inspect every changed SVG before committing it. CI may regenerate SVG artifacts after a failure for inspection; it does not change committed baselines.

```sh
TEXTUI_VISUAL_TESTS=1 .venv/bin/python -m pytest tests/visual --snapshot-update -q
TEXTUI_VISUAL_TESTS=1 .venv/bin/python -m pytest tests/visual -q
TEXTUI_VISUAL_TESTS=1 uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/visual --snapshot-update -q
TEXTUI_VISUAL_TESTS=1 uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/visual -q
```

Generate and review baselines using the same supported Python and Textual lockfile as CI. A changed SVG should correspond to a deliberate visible behavior change and include the relevant interaction test.

GitHub Actions compares the visual suite on Ubuntu 24.04 with Python 3.12. On a visual failure, it uploads the reviewed SVG baselines so the failing run can be reproduced locally with the exact command above.
GitHub Actions compares the visual suite on Ubuntu 24.04 with Python 3.12. On a visual failure, it regenerates and uploads SVG artifacts for review. Reproduce the comparison locally with the same command above, using the matching platform baselines.
18 changes: 10 additions & 8 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Examples

The examples use the public TextUI 0.2 API and should be run from the repository root after installing the project.
The examples use the public TextUI 0.6 API. Follow the [checkout setup](../README.md#install-and-run) once, then run the commands below from the repository root. They use the installed project through Poetry rather than relying on a `textui` command on your shell's PATH.

For the full showcase on macOS or Linux, `./showcase` handles setup and launch automatically. Its absolute path also works from another directory; Ctrl+Q quits. Examples ship in the source distribution, not the library wheel.

## Editor

Run the normal Textual host example:

```sh
python -m examples.editor
uvx --python 3.12 --from poetry==2.4.3 poetry run python -m examples.editor
```

Enter a name and press **Save**. The action updates the mounted `status` label. The example loads `form.xml` relative to its own module, so it also works when launched from another working directory. Press Ctrl+Q to quit.
Expand All @@ -26,18 +28,18 @@ TextUI(document).run()
```

The sample has no actions, so it is useful for checking structure and styling without application callbacks. See the main [README](../README.md) for custom components and explicit event forwarding.
# Project runtime example
## Project runtime example

Run `textui run examples/project/app.ui` from the repository root. The entry file loads `shell.tcss` and `controller.py`, then includes `views/form.ui`. The sidebar selects a page with the mouse or keyboard; the ☰ button hides or shows it, and the divider can be dragged or resized with arrow keys. The controller also shows a greeting and updates the clock once per second. Paths inside the project resolve from their declaring files, so the absolute entry path also works from another directory. See the [runtime guide](../docs/project-runtime.md).
Run `uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run examples/project/app.ui` from the repository root. The entry file loads `shell.tcss` and `controller.py`, then includes `views/form.ui`. The sidebar selects a page with the mouse or keyboard; the ☰ button hides or shows it, and the divider can be dragged or resized with arrow keys. The controller also shows a greeting and updates the clock once per second. Paths inside the project resolve from their declaring files, so the absolute entry path also works from another directory. See the [runtime guide](../docs/project-runtime.md).

Run `textui run examples/controls/app.ui` to try native select, switch, text area, tabs, radio choices, collapsible content, progress bars, and rules. Their `on-*` actions update the feedback label. See the [controls guide](../docs/controls.md).
Run `uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run examples/controls/app.ui` to try native select, switch, text area, tabs, radio choices, collapsible content, progress bars, and rules. Their `on-*` actions update the feedback label. See the [controls guide](../docs/controls.md).

Run `textui run examples/data/app.ui` to try a native table and tree. Select a row or node to update the feedback label, then press **Add job and file** to change both widgets from linked Python. See [tables and trees](../docs/controls.md#tables-and-trees).
Run `uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run examples/data/app.ui` to try a native table and tree. Select a row or node to update the feedback label, then press **Add job and file** to change both widgets from linked Python. See [tables and trees](../docs/controls.md#tables-and-trees).

## Reusable components

Run `textui run examples/components/app.ui` to see a project-local `agent-card` component. The entry file imports the component, passes literal properties, and supplies a named action slot. See the [project runtime guide](../docs/project-runtime.md#reusable-components) for the authoring contract.
Run `uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run examples/components/app.ui` to see a project-local `agent-card` component. The entry file imports the component, passes literal properties, and supplies a named action slot. See the [project runtime guide](../docs/project-runtime.md#reusable-components) for the authoring contract.

## Feature showcase

Run `textui run examples/showcase/app.ui` for a single application that exercises the full built-in surface. The sidebar is hideable and resizable, includes **Borders** (`Ctrl+B`) and **Quit** (`Ctrl+Q`) command buttons, and the right-justified top bar contains **Compact** (`Ctrl+D`) and Help controls. Compact toggles the framework's dense TCSS preset; Borders toggles rounded button outlines. Its pages cover imported components and includes, inputs and choices, tabs and indicators, API-backed runtime table records, seeded tables and trees, runtime lists, logs, modal screens, linked actions, and a repeating timer. It is the quickest manual smoke test after changing framework behavior.
Run `./showcase` for a single application that exercises the full built-in surface. The sidebar is hideable and resizable, includes **Borders** (`Ctrl+B`) and **Quit** (`Ctrl+Q`) command buttons, and the right-justified top bar contains **Compact** (`Ctrl+D`) and Help controls. Compact toggles the framework's dense TCSS preset; Borders toggles rounded button outlines. Its pages cover imported components and includes, inputs and choices, tabs and indicators, API-backed runtime table records, seeded tables and trees, runtime lists, logs, modal screens, linked actions, and a repeating timer. It is the quickest manual smoke test after changing framework behavior.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ textui = "textui.__main__:main"
[tool.poetry]
packages = [{ include = "textui" }]
include = [
{ path = "showcase", format = "sdist" },
{ path = "poetry.lock", format = "sdist" },
{ path = "examples", format = "sdist" },
{ path = "docs", format = "sdist" },
]
Expand Down
21 changes: 21 additions & 0 deletions showcase
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
# Launch the checkout's showcase through an isolated, locked environment.
set -eu

if ! command -v uvx >/dev/null 2>&1; then
echo "Install uv (which provides uvx): https://docs.astral.sh/uv/getting-started/installation/" >&2
exit 127
fi

repo_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
cd "$repo_root"

# Avoid borrowing an activated environment or a stale checkout .venv.
unset VIRTUAL_ENV CONDA_PREFIX
export POETRY_VIRTUALENVS_CREATE=true
export POETRY_VIRTUALENVS_IN_PROJECT=false
export POETRY_VIRTUALENVS_USE_POETRY_PYTHON=true

uvx --python 3.12 --from poetry==2.4.3 poetry env use 3.12
uvx --python 3.12 --from poetry==2.4.3 poetry install --with test --no-interaction
exec uvx --python 3.12 --from poetry==2.4.3 poetry run python -m textui run "$repo_root/examples/showcase/app.ui"
100 changes: 100 additions & 0 deletions tests/test_showcase_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Run the checkout launcher with only the external installer replaced."""

import json
import os
from pathlib import Path
import shutil
import subprocess
import sys

def run_launcher(tmp_path, *, env_exit=0, install_exit=0, run_exit=0, missing_uvx=False):
source = Path(__file__).parents[1] / "showcase"
assert source.is_file(), "The checkout needs a showcase launcher"
checkout = tmp_path / "checkout with spaces"
checkout.mkdir()
launcher = checkout / "showcase"
shutil.copy2(source, launcher)
caller = tmp_path / "another directory"
caller.mkdir()
tools = tmp_path / "tools"
tools.mkdir()
calls = tmp_path / "calls.jsonl"
fake = tools / "uvx"
fake.write_text(
f"#!{sys.executable}\n"
"import json, os, sys\n"
"with open(os.environ['LAUNCH_CALLS'], 'a') as output:\n"
" output.write(json.dumps({'args': sys.argv[1:], 'cwd': os.getcwd(), "
"'active_env': os.environ.get('VIRTUAL_ENV'), "
"'conda_env': os.environ.get('CONDA_PREFIX'), "
"'create': os.environ.get('POETRY_VIRTUALENVS_CREATE'), "
"'in_project': os.environ.get('POETRY_VIRTUALENVS_IN_PROJECT'), "
"'poetry_python': os.environ.get('POETRY_VIRTUALENVS_USE_POETRY_PYTHON')}) + '\\n')\n"
"stage = 'ENV_EXIT' if 'env' in sys.argv else 'INSTALL_EXIT' if 'install' in sys.argv else 'RUN_EXIT'\n"
"sys.exit(int(os.environ[stage]))\n",
encoding="utf-8",
)
fake.chmod(0o755)
environment = {
**os.environ,
"PATH": "" if missing_uvx else f"{tools}{os.pathsep}/usr/bin{os.pathsep}/bin",
"LAUNCH_CALLS": str(calls),
"ENV_EXIT": str(env_exit),
"INSTALL_EXIT": str(install_exit),
"RUN_EXIT": str(run_exit),
"VIRTUAL_ENV": str(tmp_path / "unrelated environment"),
"CONDA_PREFIX": str(tmp_path / "unrelated conda"),
"POETRY_VIRTUALENVS_CREATE": "false",
"POETRY_VIRTUALENVS_IN_PROJECT": "true",
"POETRY_VIRTUALENVS_USE_POETRY_PYTHON": "false",
}
result = subprocess.run(
[str(launcher)], cwd=caller, env=environment, capture_output=True, text=True,
)
recorded = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
return result, recorded, checkout


def test_launcher_installs_then_runs_showcase_from_another_directory(tmp_path):
result, calls, checkout = run_launcher(tmp_path)

assert result.returncode == 0, result.stderr
prefix = ["--python", "3.12", "--from", "poetry==2.4.3", "poetry"]
assert [call["args"] for call in calls] == [
prefix + ["env", "use", "3.12"],
prefix + ["install", "--with", "test", "--no-interaction"],
prefix + ["run", "python", "-m", "textui", "run", str(checkout / "examples/showcase/app.ui")],
]
assert all(call["cwd"] == str(checkout) for call in calls)
assert all(call["active_env"] is None and call["conda_env"] is None for call in calls)
assert all(call["create"] == "true" and call["in_project"] == "false" for call in calls)
assert all(call["poetry_python"] == "true" for call in calls)


def test_launcher_does_not_run_after_installation_fails(tmp_path):
result, calls, _ = run_launcher(tmp_path, install_exit=17)

assert result.returncode == 17
assert len(calls) == 2


def test_launcher_preserves_application_exit_status(tmp_path):
result, calls, _ = run_launcher(tmp_path, run_exit=23)

assert result.returncode == 23
assert len(calls) == 3


def test_launcher_stops_when_python_environment_selection_fails(tmp_path):
result, calls, _ = run_launcher(tmp_path, env_exit=19)

assert result.returncode == 19
assert len(calls) == 1


def test_launcher_explains_missing_uvx_without_attempting_setup(tmp_path):
result, calls, _ = run_launcher(tmp_path, missing_uvx=True)

assert result.returncode == 127
assert "uv" in result.stderr and "https://docs.astral.sh/uv/" in result.stderr
assert not calls
Loading