Examples¶
Six complete projects, each self-contained enough to copy out of the repository and use. The demos below are live: every one is a real build of the app beside it, running in your browser on this static site.
Note
The first load of each demo fetches a CPython interpreter (~10 MB, then cached by your browser), so give it a few seconds. That cost is Pyodide’s, paid once per visitor.
A terminal app on the web¶
examples/simple-app
— a task list. The smallest complete thing: an ordinary Textual app, a .tcss stylesheet, and
nothing else.
What it shows. The default page: the terminal fills its box and nothing is drawn around
it. The app imports nothing from textual_wasm and does not branch on the platform — shipping
it to a browser is a build step, not a port.
Its stylesheet is a real .tcss file on purpose: a stylesheet left behind by the build fails
at mount time in the browser and nowhere earlier, so the example is what proves it ships.
poetry run simple-app # a terminal
textual-wasm build simple_app.app:TaskList simple_app -o dist/ # a web page
A terminal inside a page¶
examples/embedded-page
— the same idea, but the terminal is one component of an article with headings, prose and
controls of its own.
What it shows. --template replacing the whole page, and HTML buttons driving the
application:
globalThis.textualWasm.input("2");
That is xterm’s own user-input entry point, so the app receives a keystroke and cannot tell a button from a keyboard. It exposes no JavaScript API and knows nothing about the page. See Embedding and page customisation.
Its stylesheet lands in the overrides cascade layer the shipped CSS declares and leaves
empty, so it restyles the page without out-specifying anything.
Live state, both directions¶
examples/page-bridge
— a mixing desk. Three HTML sliders and three Textual meters over the same three values.
What it shows. Drag a slider and the meter follows. Click the terminal and press the arrow keys, and the slider follows. Neither side owns the levels, and the binding is one line on each:
self.bridge.bind("gain", self, "gain")
bridge.on("gain", (value) => { slider.value = value; });
slider.addEventListener("input", () => bridge.send("gain", Number(slider.value)));
The orange line under the sliders has no control behind it. The application sends it when a level goes over 85 — the direction that did not exist while the keyboard was the only seam. The text field uses the raw pipe instead of the JSON codec, because a line of prose is already a string. See The data channel.
The demo is built with --worker, so the interpreter runs in a Web Worker and every value
above crosses a postMessage boundary. Nothing in the app or the page is written differently
because of it.
The page is type-checked against the app. mixer_app/channels.py declares what each
channel carries. page/channels.d.ts is generated from it and committed, and tsc checks
page/controls.mjs against it. An undeclared channel name is a type error on the page, and a
payload that gains a field fails the drift gate until the declarations are regenerated.
textual-wasm build mixer_app.app:Mixer mixer_app -o dist/ --template page --worker
A terminal inside a Svelte component¶
examples/svelte-app
— Svelte 5 and Vite, with the Textual app mounted in a component and Svelte state around it.
What it shows. Two toolchains that never have to know about each other:
textual-wasm build palette_app.app:Palette palette_app -o public/terminal
vite build
Vite copies public/ through untouched — it never sees the Python — and the build’s entry
module resolves its manifest relative to itself, which is what lets it live at /terminal/
while the Svelte page lives at /.
The buttons send number keys, and the Textual app switches its own theme in response:
every widget in the frame repaints, and none of that is the page’s doing. The boundary is a
single input() call in one direction.
Owns |
|
|---|---|
Svelte |
the page, the layout, the buttons, its own reactive state |
Python |
everything inside the terminal, including its theme |
The same shape works in Vue’s onMounted or React’s useEffect; nothing about it is
Svelte-specific.
Storage that survives a reload¶
examples/persistent-notes
— a notebook backed by a real SQLite database, with a real schema and real queries,
persisting across page reloads.
What it shows. That a browser needs no storage abstraction. Pyodide can mount IndexedDB
as a filesystem, so sqlite3.connect(...) works in a page and persists — and a Store
protocol with two backends would be a worse reimplementation of that, without SQL.
What differs is when a write becomes durable, so the app calls one extra method:
store = Store.open("persistent-notes")
connection = sqlite3.connect(store.path("notes.db")) # the one line that differs
...
await store.flush() # no-op natively
textual-wasm build notes_app.app:Notes notes_app -o dist/ --storage --worker
Build it without --storage and the app says so in its own banner. Measured across Chromium
and Firefox, main thread and Web Worker: a note written, the page reloaded, the note still there
— in all four combinations. Managing persistent storage is the full account, including why localStorage is
the wrong answer (a Web Worker does not have it).
Third-party libraries in one page¶
examples/addon-gallery
— a signal explorer built from textual-autocomplete, textual-plotext, textual-plot and
textual-slider.
What it shows. That the add-on ecosystem works, and that none of these libraries knows
it is in a browser — no shim, no conditional import, no vendored fork. Four packages off
PyPI, installed by micropip at boot.
The part that makes it work is not in the application at all:
textual-wasm build gallery_app.app:Gallery gallery_app -o dist/ --worker \
-r textual-autocomplete -r textual-plotext -r textual-plot -r textual-slider
A missing -r produces a page that fetches a 10 MB interpreter, boots it, and then fails on
the first import — so textual-wasm doctor -r <dist> belongs before the build, not after the
deploy.
Two plotting libraries side by side on identical data, because they are the two options in the ecosystem. Library support is the survey these four were picked from: 37 libraries installed into a real Pyodide and mounted, of which 23 can be shipped today.
Textual’s own demo¶
Not shipped as an example, but worth knowing: python -m textual — lazily-loaded screens, a
Markdown widget, a command palette, network calls — runs unmodified.
textual-wasm check --app textual.demo.demo_app:DemoApp \
--ready-marker "What is Textual?" --width 100 --height 30
All eight checks pass on both Python runtimes. Pointing the tool at it found two real bugs in this project, which is what an acceptance case is for; Feasibility study §14 has the account.