diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b280e..991b139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9deb724..a14cec5 100644 --- a/README.md +++ b/README.md @@ -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 +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). diff --git a/docs/testing.md b/docs/testing.md index 4a947bb..55e2d87 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 @@ -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. diff --git a/examples/README.md b/examples/README.md index 55c5c81..34c69ae 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. @@ -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. diff --git a/pyproject.toml b/pyproject.toml index a7cc504..f2c1fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }, ] diff --git a/showcase b/showcase new file mode 100755 index 0000000..1694a98 --- /dev/null +++ b/showcase @@ -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" diff --git a/tests/test_showcase_launcher.py b/tests/test_showcase_launcher.py new file mode 100644 index 0000000..502216d --- /dev/null +++ b/tests/test_showcase_launcher.py @@ -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