textual-wasm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- textual_wasm-0.1.0/LICENSE +21 -0
- textual_wasm-0.1.0/PKG-INFO +184 -0
- textual_wasm-0.1.0/README.md +151 -0
- textual_wasm-0.1.0/pyproject.toml +154 -0
- textual_wasm-0.1.0/src/textual_wasm/__init__.py +41 -0
- textual_wasm-0.1.0/src/textual_wasm/__main__.py +9 -0
- textual_wasm-0.1.0/src/textual_wasm/app.py +104 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/boot.mjs +146 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/entry.py +51 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/index.html +47 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/main.mjs +360 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/styles/base.css +45 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/styles/index.css +21 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/styles/layout.css +79 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/styles/tokens.css +63 -0
- textual_wasm-0.1.0/src/textual_wasm/assets/worker.mjs +203 -0
- textual_wasm-0.1.0/src/textual_wasm/bootstrap.py +43 -0
- textual_wasm-0.1.0/src/textual_wasm/browser.py +170 -0
- textual_wasm-0.1.0/src/textual_wasm/bundler.py +371 -0
- textual_wasm-0.1.0/src/textual_wasm/capabilities.py +152 -0
- textual_wasm-0.1.0/src/textual_wasm/check.py +519 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/__init__.py +16 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/_app.py +15 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/_render.py +201 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/_target.py +32 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/build.py +138 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/check.py +95 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/experiment.py +148 -0
- textual_wasm-0.1.0/src/textual_wasm/cli/port.py +121 -0
- textual_wasm-0.1.0/src/textual_wasm/compare.py +169 -0
- textual_wasm-0.1.0/src/textual_wasm/diagnostics/__init__.py +43 -0
- textual_wasm-0.1.0/src/textual_wasm/diagnostics/errors.py +76 -0
- textual_wasm-0.1.0/src/textual_wasm/diagnostics/guards.py +224 -0
- textual_wasm-0.1.0/src/textual_wasm/diagnostics/surface.py +87 -0
- textual_wasm-0.1.0/src/textual_wasm/diagnostics/translate.py +29 -0
- textual_wasm-0.1.0/src/textual_wasm/docs.py +171 -0
- textual_wasm-0.1.0/src/textual_wasm/doctor/__init__.py +109 -0
- textual_wasm-0.1.0/src/textual_wasm/doctor/deps.py +174 -0
- textual_wasm-0.1.0/src/textual_wasm/doctor/scan.py +215 -0
- textual_wasm-0.1.0/src/textual_wasm/driver.py +267 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/_collect.mjs +162 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/browser-check.mjs +146 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/pyodide-probe.mjs +127 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/responsiveness.mjs +104 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/safari-check.mjs +46 -0
- textual_wasm-0.1.0/src/textual_wasm/harness/wasm_entry.py +62 -0
- textual_wasm-0.1.0/src/textual_wasm/node.py +175 -0
- textual_wasm-0.1.0/src/textual_wasm/pins.py +120 -0
- textual_wasm-0.1.0/src/textual_wasm/polyfills.py +55 -0
- textual_wasm-0.1.0/src/textual_wasm/probe.py +372 -0
- textual_wasm-0.1.0/src/textual_wasm/reference.py +47 -0
- textual_wasm-0.1.0/src/textual_wasm/report.py +253 -0
- textual_wasm-0.1.0/src/textual_wasm/responsiveness.py +148 -0
- textual_wasm-0.1.0/src/textual_wasm/screen.py +113 -0
- textual_wasm-0.1.0/src/textual_wasm/substitutions.py +433 -0
- textual_wasm-0.1.0/src/textual_wasm/target.py +319 -0
- textual_wasm-0.1.0/src/textual_wasm/terminal.py +276 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kai Erik Niermann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: textual-wasm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ship a Textual TUI as both a terminal app and a client-side web page.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: textual,tui,webassembly,wasm,pyodide,xterm
|
|
8
|
+
Author: KaiErikNiermann
|
|
9
|
+
Author-email: 61298289+KaiErikNiermann@users.noreply.github.com
|
|
10
|
+
Requires-Python: >=3.12,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Environment :: WebAssembly :: Emscripten
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Classifier: Topic :: Terminals
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: packaging (>=26.3,<27.0)
|
|
22
|
+
Requires-Dist: pyodide-lock (>=0.2.1,<0.3.0)
|
|
23
|
+
Requires-Dist: pyte (>=0.8.2,<0.9.0)
|
|
24
|
+
Requires-Dist: rich (>=13.0)
|
|
25
|
+
Requires-Dist: textual (>=8.2,<9.0)
|
|
26
|
+
Requires-Dist: typer (>=0.15,<1.0)
|
|
27
|
+
Project-URL: Documentation, https://kaierikniermann.github.io/textual-wasm/
|
|
28
|
+
Project-URL: Homepage, https://github.com/KaiErikNiermann/textual-wasm
|
|
29
|
+
Project-URL: Issues, https://github.com/KaiErikNiermann/textual-wasm/issues
|
|
30
|
+
Project-URL: Repository, https://github.com/KaiErikNiermann/textual-wasm
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# textual-wasm
|
|
34
|
+
|
|
35
|
+
[](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml)
|
|
36
|
+
[](https://kaierikniermann.github.io/textual-wasm/)
|
|
37
|
+
[](https://kaierikniermann.github.io/textual-wasm/installation.html)
|
|
38
|
+
[](./LICENSE)
|
|
39
|
+
|
|
40
|
+
Ship one [Textual](https://github.com/Textualize/textual) application as **both** a terminal
|
|
41
|
+
TUI and a fully client-side web page — same source, no server-side Python process, no fork of
|
|
42
|
+
Textual.
|
|
43
|
+
|
|
44
|
+
**[Documentation](https://kaierikniermann.github.io/textual-wasm/)** ·
|
|
45
|
+
**[Live demos](https://kaierikniermann.github.io/textual-wasm/examples.html)** ·
|
|
46
|
+
**[Porting guide](https://kaierikniermann.github.io/textual-wasm/porting.html)**
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
textual-wasm build myapp.main:App myapp -o dist/ # a static directory
|
|
50
|
+
textual-wasm dev dist/ # http://127.0.0.1:8000
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The output is static files. Pyodide and xterm.js come from a pinned CDN; your application is
|
|
54
|
+
copied in as source. There is no build toolchain at the far end and nothing for the person
|
|
55
|
+
deploying it to install.
|
|
56
|
+
|
|
57
|
+
Textual itself is untouched. The extension point is a public one —
|
|
58
|
+
`TEXTUAL_DRIVER=module:Symbol` (`textual/app.py:1585`) — so there is no patch to rebase.
|
|
59
|
+
|
|
60
|
+
## The four commands
|
|
61
|
+
|
|
62
|
+
| | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `textual-wasm doctor <module:App>` | What will break, with a `file:line`. Reads imports, **call sites**, and dependencies. |
|
|
65
|
+
| `textual-wasm build <module:App> <package> -o dist/` | A static site. Bare page by default; `--title` and `--template` if you want otherwise. |
|
|
66
|
+
| `textual-wasm dev dist/` | Serve it locally. Standard library only. |
|
|
67
|
+
| `textual-wasm check --app <module:App>` | Run it on every runtime available and compare. |
|
|
68
|
+
|
|
69
|
+
## What `check` actually checks
|
|
70
|
+
|
|
71
|
+
Four runtimes, and two comparisons that mean something:
|
|
72
|
+
|
|
73
|
+
| Leg | Runtime | Settles |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| native | CPython | The baseline. |
|
|
76
|
+
| wasm | Pyodide under Node | Everything on the Python side, in CI, with no browser. |
|
|
77
|
+
| browser | Chromium, Firefox **and** WebKit over a real `build` | Rendering, and whether the engines agree. |
|
|
78
|
+
| terminal | a real pty via tmux, on Textual's own driver | What a user would actually see. |
|
|
79
|
+
|
|
80
|
+
`native` against `wasm` is compared check-by-check, fact-by-fact and grid-by-grid.
|
|
81
|
+
`terminal` against `browser` is compared cell-by-cell — that one is the render claim, and it
|
|
82
|
+
is made against a real terminal rather than a replay because three different character-width
|
|
83
|
+
tables are involved and they do not share code.
|
|
84
|
+
|
|
85
|
+
A runtime this machine cannot reach is reported as **skipped, with the command that would
|
|
86
|
+
enable it**, and the rest still produce a verdict. `--strict` makes a skip a failure, which
|
|
87
|
+
is what CI wants.
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
textual_wasm.app:SpikeApp at 80x24
|
|
91
|
+
┏━━━━━━━━━━┳━━━━━━━━━┳──────────────────────────────────────┓
|
|
92
|
+
┃ runtime ┃ status ┃ detail ┃
|
|
93
|
+
┡━━━━━━━━━━╇━━━━━━━━━╇──────────────────────────────────────┩
|
|
94
|
+
│ native │ ran │ 8 checks, 0 failed │
|
|
95
|
+
│ wasm │ ran │ 0 check(s) failed │
|
|
96
|
+
│ browser │ ran │ 19 rows rendered │
|
|
97
|
+
│ terminal │ ran │ tmux 3.7c │
|
|
98
|
+
└──────────┴─────────┴──────────────────────────────────────┘
|
|
99
|
+
identical: terminal and browser render the same
|
|
100
|
+
equivalent across 4 runtime(s)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Nothing the probe measures is asked of your app: the timer is scheduled by the probe, the
|
|
104
|
+
resize is read back off the `Screen` it laid out, and input is judged by what appears on the
|
|
105
|
+
grid. An app that has never heard of this project is measured by exactly the code that
|
|
106
|
+
measures the one that ships with it.
|
|
107
|
+
|
|
108
|
+
## Diagnostics
|
|
109
|
+
|
|
110
|
+
Pyodide's most dangerous failures are the ones that raise nothing. `os.system()` returns 0
|
|
111
|
+
and does nothing; `loop.run_in_executor()` ignores the executor and runs inline on the only
|
|
112
|
+
thread, so code written to keep a UI responsive freezes the page instead.
|
|
113
|
+
|
|
114
|
+
`textual_wasm.diagnostics.install()` manufactures a loud, specific failure for each, naming
|
|
115
|
+
the substitute. `diagnostics.attach(app, driver)` moves crash output off stderr — which under
|
|
116
|
+
Pyodide is a browser console nobody is watching — and into the terminal the user is looking
|
|
117
|
+
at. The build output does both for you.
|
|
118
|
+
|
|
119
|
+
Every one of those claims is **measured, not transcribed**, and the
|
|
120
|
+
[porting matrix](./docs/porting-matrix.md) is generated from the same registry the analyser
|
|
121
|
+
and the guards read. A test fails when it drifts; a characterisation suite re-measures the
|
|
122
|
+
registry inside a real Pyodide. Pyodide's own documentation lists four modules as removed
|
|
123
|
+
that import fine in 314.0.6, which is what a hand-maintained table gets you.
|
|
124
|
+
|
|
125
|
+
## Documentation
|
|
126
|
+
|
|
127
|
+
The [documentation site](https://kaierikniermann.github.io/textual-wasm/) is itself the proof:
|
|
128
|
+
every demo on it is a real build of a real Textual app, running in your browser, served as
|
|
129
|
+
static files by GitHub Pages.
|
|
130
|
+
|
|
131
|
+
- [Quickstart](https://kaierikniermann.github.io/textual-wasm/quickstart.html) — a new app in a
|
|
132
|
+
browser in five minutes.
|
|
133
|
+
- [Porting guide](https://kaierikniermann.github.io/textual-wasm/porting.html) — moving an app
|
|
134
|
+
you already have.
|
|
135
|
+
- [Embedding](https://kaierikniermann.github.io/textual-wasm/embedding.html) — custom pages,
|
|
136
|
+
and Textual inside Svelte, Vue or plain HTML.
|
|
137
|
+
- [Limitations](https://kaierikniermann.github.io/textual-wasm/limitations.html) — what does
|
|
138
|
+
not work, organised by *whose* constraint each one is.
|
|
139
|
+
- [Browser support](https://kaierikniermann.github.io/textual-wasm/browsers.html) — measured
|
|
140
|
+
across three engines, and what that measurement does not cover.
|
|
141
|
+
- [Feasibility study](./textual-wasm-feasability-study.md) — the architecture audit, what the
|
|
142
|
+
spike measured, and the claims it corrected.
|
|
143
|
+
|
|
144
|
+
### Examples
|
|
145
|
+
|
|
146
|
+
| | |
|
|
147
|
+
|---|---|
|
|
148
|
+
| [`simple-app`](./examples/simple-app) | A task list. The smallest complete thing. |
|
|
149
|
+
| [`embedded-page`](./examples/embedded-page) | The terminal as one component of an article, driven by HTML buttons. |
|
|
150
|
+
| [`svelte-app`](./examples/svelte-app) | Mounted in a Svelte 5 component, with Svelte state around it. |
|
|
151
|
+
|
|
152
|
+
Each is a self-contained project with its own `pyproject.toml`, README and linting — copy one
|
|
153
|
+
out and it works.
|
|
154
|
+
|
|
155
|
+
## How the pieces fit
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
bootstrap.py TEXTUAL_* env, applied before the first `import textual` (it caches at import)
|
|
159
|
+
polyfills.py runtime bugs, quarantined and reported
|
|
160
|
+
driver.py WasmDriverBase -> CaptureDriver (sink: a list)
|
|
161
|
+
browser.py WasmDriverBase -> BrowserDriver (sink: xterm.js)
|
|
162
|
+
target.py which app, and how a harness knows it drew
|
|
163
|
+
probe.py the experiment, over any app
|
|
164
|
+
check.py every runtime this machine has, and the comparisons
|
|
165
|
+
substitutions the registry: one source for the analyser, the guards and the docs
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Nothing in the probe, the driver, the app or the report may branch on `sys.platform` — a
|
|
169
|
+
semgrep rule enforces it. Runtime differences are confined to `polyfills.py` and recorded in
|
|
170
|
+
the report, so a WASM run can never look accidentally native.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
poetry install
|
|
176
|
+
pnpm install # only for the wasm and browser legs of `check`
|
|
177
|
+
poetry run pytest # includes a selftest of the semgrep rules
|
|
178
|
+
pnpm lint:all # eslint (css + js), stylelint, principled-css
|
|
179
|
+
poetry run textual-wasm pins # regenerate wasm-requirements.txt
|
|
180
|
+
poetry run textual-wasm matrix -o docs/porting-matrix.md
|
|
181
|
+
git config core.hooksPath .githooks # lint, types, complexity, policy, tests
|
|
182
|
+
./scripts/run-spike.sh # the whole matrix, strictly
|
|
183
|
+
```
|
|
184
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# textual-wasm
|
|
2
|
+
|
|
3
|
+
[](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml)
|
|
4
|
+
[](https://kaierikniermann.github.io/textual-wasm/)
|
|
5
|
+
[](https://kaierikniermann.github.io/textual-wasm/installation.html)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
Ship one [Textual](https://github.com/Textualize/textual) application as **both** a terminal
|
|
9
|
+
TUI and a fully client-side web page — same source, no server-side Python process, no fork of
|
|
10
|
+
Textual.
|
|
11
|
+
|
|
12
|
+
**[Documentation](https://kaierikniermann.github.io/textual-wasm/)** ·
|
|
13
|
+
**[Live demos](https://kaierikniermann.github.io/textual-wasm/examples.html)** ·
|
|
14
|
+
**[Porting guide](https://kaierikniermann.github.io/textual-wasm/porting.html)**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
textual-wasm build myapp.main:App myapp -o dist/ # a static directory
|
|
18
|
+
textual-wasm dev dist/ # http://127.0.0.1:8000
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The output is static files. Pyodide and xterm.js come from a pinned CDN; your application is
|
|
22
|
+
copied in as source. There is no build toolchain at the far end and nothing for the person
|
|
23
|
+
deploying it to install.
|
|
24
|
+
|
|
25
|
+
Textual itself is untouched. The extension point is a public one —
|
|
26
|
+
`TEXTUAL_DRIVER=module:Symbol` (`textual/app.py:1585`) — so there is no patch to rebase.
|
|
27
|
+
|
|
28
|
+
## The four commands
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `textual-wasm doctor <module:App>` | What will break, with a `file:line`. Reads imports, **call sites**, and dependencies. |
|
|
33
|
+
| `textual-wasm build <module:App> <package> -o dist/` | A static site. Bare page by default; `--title` and `--template` if you want otherwise. |
|
|
34
|
+
| `textual-wasm dev dist/` | Serve it locally. Standard library only. |
|
|
35
|
+
| `textual-wasm check --app <module:App>` | Run it on every runtime available and compare. |
|
|
36
|
+
|
|
37
|
+
## What `check` actually checks
|
|
38
|
+
|
|
39
|
+
Four runtimes, and two comparisons that mean something:
|
|
40
|
+
|
|
41
|
+
| Leg | Runtime | Settles |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| native | CPython | The baseline. |
|
|
44
|
+
| wasm | Pyodide under Node | Everything on the Python side, in CI, with no browser. |
|
|
45
|
+
| browser | Chromium, Firefox **and** WebKit over a real `build` | Rendering, and whether the engines agree. |
|
|
46
|
+
| terminal | a real pty via tmux, on Textual's own driver | What a user would actually see. |
|
|
47
|
+
|
|
48
|
+
`native` against `wasm` is compared check-by-check, fact-by-fact and grid-by-grid.
|
|
49
|
+
`terminal` against `browser` is compared cell-by-cell — that one is the render claim, and it
|
|
50
|
+
is made against a real terminal rather than a replay because three different character-width
|
|
51
|
+
tables are involved and they do not share code.
|
|
52
|
+
|
|
53
|
+
A runtime this machine cannot reach is reported as **skipped, with the command that would
|
|
54
|
+
enable it**, and the rest still produce a verdict. `--strict` makes a skip a failure, which
|
|
55
|
+
is what CI wants.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
textual_wasm.app:SpikeApp at 80x24
|
|
59
|
+
┏━━━━━━━━━━┳━━━━━━━━━┳──────────────────────────────────────┓
|
|
60
|
+
┃ runtime ┃ status ┃ detail ┃
|
|
61
|
+
┡━━━━━━━━━━╇━━━━━━━━━╇──────────────────────────────────────┩
|
|
62
|
+
│ native │ ran │ 8 checks, 0 failed │
|
|
63
|
+
│ wasm │ ran │ 0 check(s) failed │
|
|
64
|
+
│ browser │ ran │ 19 rows rendered │
|
|
65
|
+
│ terminal │ ran │ tmux 3.7c │
|
|
66
|
+
└──────────┴─────────┴──────────────────────────────────────┘
|
|
67
|
+
identical: terminal and browser render the same
|
|
68
|
+
equivalent across 4 runtime(s)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Nothing the probe measures is asked of your app: the timer is scheduled by the probe, the
|
|
72
|
+
resize is read back off the `Screen` it laid out, and input is judged by what appears on the
|
|
73
|
+
grid. An app that has never heard of this project is measured by exactly the code that
|
|
74
|
+
measures the one that ships with it.
|
|
75
|
+
|
|
76
|
+
## Diagnostics
|
|
77
|
+
|
|
78
|
+
Pyodide's most dangerous failures are the ones that raise nothing. `os.system()` returns 0
|
|
79
|
+
and does nothing; `loop.run_in_executor()` ignores the executor and runs inline on the only
|
|
80
|
+
thread, so code written to keep a UI responsive freezes the page instead.
|
|
81
|
+
|
|
82
|
+
`textual_wasm.diagnostics.install()` manufactures a loud, specific failure for each, naming
|
|
83
|
+
the substitute. `diagnostics.attach(app, driver)` moves crash output off stderr — which under
|
|
84
|
+
Pyodide is a browser console nobody is watching — and into the terminal the user is looking
|
|
85
|
+
at. The build output does both for you.
|
|
86
|
+
|
|
87
|
+
Every one of those claims is **measured, not transcribed**, and the
|
|
88
|
+
[porting matrix](./docs/porting-matrix.md) is generated from the same registry the analyser
|
|
89
|
+
and the guards read. A test fails when it drifts; a characterisation suite re-measures the
|
|
90
|
+
registry inside a real Pyodide. Pyodide's own documentation lists four modules as removed
|
|
91
|
+
that import fine in 314.0.6, which is what a hand-maintained table gets you.
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
The [documentation site](https://kaierikniermann.github.io/textual-wasm/) is itself the proof:
|
|
96
|
+
every demo on it is a real build of a real Textual app, running in your browser, served as
|
|
97
|
+
static files by GitHub Pages.
|
|
98
|
+
|
|
99
|
+
- [Quickstart](https://kaierikniermann.github.io/textual-wasm/quickstart.html) — a new app in a
|
|
100
|
+
browser in five minutes.
|
|
101
|
+
- [Porting guide](https://kaierikniermann.github.io/textual-wasm/porting.html) — moving an app
|
|
102
|
+
you already have.
|
|
103
|
+
- [Embedding](https://kaierikniermann.github.io/textual-wasm/embedding.html) — custom pages,
|
|
104
|
+
and Textual inside Svelte, Vue or plain HTML.
|
|
105
|
+
- [Limitations](https://kaierikniermann.github.io/textual-wasm/limitations.html) — what does
|
|
106
|
+
not work, organised by *whose* constraint each one is.
|
|
107
|
+
- [Browser support](https://kaierikniermann.github.io/textual-wasm/browsers.html) — measured
|
|
108
|
+
across three engines, and what that measurement does not cover.
|
|
109
|
+
- [Feasibility study](./textual-wasm-feasability-study.md) — the architecture audit, what the
|
|
110
|
+
spike measured, and the claims it corrected.
|
|
111
|
+
|
|
112
|
+
### Examples
|
|
113
|
+
|
|
114
|
+
| | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| [`simple-app`](./examples/simple-app) | A task list. The smallest complete thing. |
|
|
117
|
+
| [`embedded-page`](./examples/embedded-page) | The terminal as one component of an article, driven by HTML buttons. |
|
|
118
|
+
| [`svelte-app`](./examples/svelte-app) | Mounted in a Svelte 5 component, with Svelte state around it. |
|
|
119
|
+
|
|
120
|
+
Each is a self-contained project with its own `pyproject.toml`, README and linting — copy one
|
|
121
|
+
out and it works.
|
|
122
|
+
|
|
123
|
+
## How the pieces fit
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
bootstrap.py TEXTUAL_* env, applied before the first `import textual` (it caches at import)
|
|
127
|
+
polyfills.py runtime bugs, quarantined and reported
|
|
128
|
+
driver.py WasmDriverBase -> CaptureDriver (sink: a list)
|
|
129
|
+
browser.py WasmDriverBase -> BrowserDriver (sink: xterm.js)
|
|
130
|
+
target.py which app, and how a harness knows it drew
|
|
131
|
+
probe.py the experiment, over any app
|
|
132
|
+
check.py every runtime this machine has, and the comparisons
|
|
133
|
+
substitutions the registry: one source for the analyser, the guards and the docs
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Nothing in the probe, the driver, the app or the report may branch on `sys.platform` — a
|
|
137
|
+
semgrep rule enforces it. Runtime differences are confined to `polyfills.py` and recorded in
|
|
138
|
+
the report, so a WASM run can never look accidentally native.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
poetry install
|
|
144
|
+
pnpm install # only for the wasm and browser legs of `check`
|
|
145
|
+
poetry run pytest # includes a selftest of the semgrep rules
|
|
146
|
+
pnpm lint:all # eslint (css + js), stylelint, principled-css
|
|
147
|
+
poetry run textual-wasm pins # regenerate wasm-requirements.txt
|
|
148
|
+
poetry run textual-wasm matrix -o docs/porting-matrix.md
|
|
149
|
+
git config core.hooksPath .githooks # lint, types, complexity, policy, tests
|
|
150
|
+
./scripts/run-spike.sh # the whole matrix, strictly
|
|
151
|
+
```
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "textual-wasm"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Ship a Textual TUI as both a terminal app and a client-side web page."
|
|
5
|
+
authors = [{ name = "KaiErikNiermann", email = "61298289+KaiErikNiermann@users.noreply.github.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = ["textual", "tui", "webassembly", "wasm", "pyodide", "xterm"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Environment :: WebAssembly :: Emscripten",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Software Development :: Build Tools",
|
|
18
|
+
"Topic :: Terminals",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.12,<4.0"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"textual (>=8.2,<9.0)",
|
|
24
|
+
"typer (>=0.15,<1.0)",
|
|
25
|
+
"rich (>=13.0)",
|
|
26
|
+
"pyte (>=0.8.2,<0.9.0)",
|
|
27
|
+
"pyodide-lock (>=0.2.1,<0.3.0)",
|
|
28
|
+
"packaging (>=26.3,<27.0)",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
textual-wasm = "textual_wasm.cli:app"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/KaiErikNiermann/textual-wasm"
|
|
36
|
+
Documentation = "https://kaierikniermann.github.io/textual-wasm/"
|
|
37
|
+
Repository = "https://github.com/KaiErikNiermann/textual-wasm"
|
|
38
|
+
Issues = "https://github.com/KaiErikNiermann/textual-wasm/issues"
|
|
39
|
+
|
|
40
|
+
[tool.poetry]
|
|
41
|
+
packages = [{ include = "textual_wasm", from = "src" }]
|
|
42
|
+
# The page, its stylesheet, the browser entry script and the two JavaScript harnesses are
|
|
43
|
+
# not Python and are all load-bearing: without them `build` writes an empty site and `check`
|
|
44
|
+
# has no wasm or browser leg. Named explicitly rather than relying on the backend's defaults.
|
|
45
|
+
include = [
|
|
46
|
+
{ path = "src/textual_wasm/assets/**/*", format = ["sdist", "wheel"] },
|
|
47
|
+
{ path = "src/textual_wasm/harness/*.mjs", format = ["sdist", "wheel"] },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.poetry.group.dev.dependencies]
|
|
51
|
+
pytest = "^8.3"
|
|
52
|
+
pytest-asyncio = "^1.4"
|
|
53
|
+
ruff = "^0.16"
|
|
54
|
+
pyright = "^1.1"
|
|
55
|
+
radon = "^6.0"
|
|
56
|
+
semgrep = "^1.100"
|
|
57
|
+
pyyaml = "^6.0.3"
|
|
58
|
+
types-pyyaml = "^6.0.12.20260906"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
[tool.poetry.group.docs.dependencies]
|
|
62
|
+
sphinx = "^9.1.0"
|
|
63
|
+
furo = "^2025.12.19"
|
|
64
|
+
myst-parser = "^5.1.0"
|
|
65
|
+
sphinx-copybutton = "^0.5.2"
|
|
66
|
+
sphinx-design = "^0.7.0"
|
|
67
|
+
|
|
68
|
+
[build-system]
|
|
69
|
+
requires = ["poetry-core>=2.0"]
|
|
70
|
+
build-backend = "poetry.core.masonry.api"
|
|
71
|
+
|
|
72
|
+
# --- Ruff -------------------------------------------------------------------
|
|
73
|
+
[tool.ruff]
|
|
74
|
+
src = ["src", "tests"]
|
|
75
|
+
# Deliberately non-compliant code that exists to prove the semgrep rules still fire.
|
|
76
|
+
extend-exclude = ["tests/semgrep/positive", "tests/doctor"]
|
|
77
|
+
line-length = 100
|
|
78
|
+
target-version = "py312"
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint]
|
|
81
|
+
select = [
|
|
82
|
+
"E", "W", # pycodestyle
|
|
83
|
+
"F", # pyflakes
|
|
84
|
+
"I", # isort
|
|
85
|
+
"N", # pep8-naming
|
|
86
|
+
"UP", # pyupgrade
|
|
87
|
+
"B", # bugbear
|
|
88
|
+
"A", # builtins shadowing
|
|
89
|
+
"C4", # comprehensions
|
|
90
|
+
"DTZ", # naive datetimes
|
|
91
|
+
"ISC", # implicit str concat
|
|
92
|
+
"PIE", # misc lints
|
|
93
|
+
"PT", # pytest style
|
|
94
|
+
"RET", # return
|
|
95
|
+
"SIM", # simplify
|
|
96
|
+
"TID", # tidy imports
|
|
97
|
+
"PTH", # use pathlib
|
|
98
|
+
"PL", # pylint
|
|
99
|
+
"TRY", # tryceratops
|
|
100
|
+
"RUF", # ruff-specific
|
|
101
|
+
"ANN", # annotations (pyright-strict's partner)
|
|
102
|
+
"S", # bandit
|
|
103
|
+
]
|
|
104
|
+
ignore = [
|
|
105
|
+
"ANN401", # Any is banned by convention, not by this rule's blunt form
|
|
106
|
+
"TRY003", # long messages inside raise are fine and more useful than a constant
|
|
107
|
+
"ISC001", # conflicts with the formatter
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.per-file-ignores]
|
|
111
|
+
"tests/*" = ["S101", "PLR2004"] # asserts and literal comparisons are the point of a test
|
|
112
|
+
# A Typer command's options *are* its parameters: there is no object to group them into that
|
|
113
|
+
# the decorator would still read. The rule is about functions that grew too many arguments,
|
|
114
|
+
# and a command with four ways to name an application and two to size a grid is not that.
|
|
115
|
+
# PLR0917 is the same judgement for the positional-only count, stabilised in ruff 0.16.
|
|
116
|
+
"src/textual_wasm/cli/*" = ["PLR0913", "PLR0917"]
|
|
117
|
+
|
|
118
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
119
|
+
ban-relative-imports = "all"
|
|
120
|
+
|
|
121
|
+
# --- Pyright ----------------------------------------------------------------
|
|
122
|
+
[tool.pyright]
|
|
123
|
+
include = ["src", "tests", "scripts"]
|
|
124
|
+
exclude = ["tests/semgrep/positive", "tests/doctor"]
|
|
125
|
+
# Hand-written stubs for the Pyodide `js` module, which exists only inside the runtime.
|
|
126
|
+
stubPath = "typings"
|
|
127
|
+
# `js` is stub-only by nature: the module exists only inside the Pyodide runtime, so a
|
|
128
|
+
# missing source is the expected state rather than a finding.
|
|
129
|
+
reportMissingModuleSource = "none"
|
|
130
|
+
pythonVersion = "3.12"
|
|
131
|
+
typeCheckingMode = "strict"
|
|
132
|
+
reportMissingTypeStubs = false
|
|
133
|
+
venvPath = "."
|
|
134
|
+
venv = ".venv"
|
|
135
|
+
|
|
136
|
+
# --- Pytest -----------------------------------------------------------------
|
|
137
|
+
[tool.pytest.ini_options]
|
|
138
|
+
testpaths = ["tests"]
|
|
139
|
+
asyncio_mode = "auto"
|
|
140
|
+
addopts = "-q --strict-markers"
|
|
141
|
+
markers = [
|
|
142
|
+
# Boots a real Pyodide runtime. Measured at ~4s for the whole set, so these run by
|
|
143
|
+
# default: a characterisation test that is deselected by default is a test that rots,
|
|
144
|
+
# which is the exact failure the registry exists to prevent. Deselect with
|
|
145
|
+
# `-m "not slow"` when working offline.
|
|
146
|
+
"slow: re-measures a claim inside a real Pyodide runtime",
|
|
147
|
+
# Needs a Playwright browser *binary*, not just the package - `pnpm exec playwright
|
|
148
|
+
# install` territory. Separate from `slow` because the two have different remedies and
|
|
149
|
+
# because CI runs them in different jobs: only the `check` matrix has engines. Deselect
|
|
150
|
+
# with `-m "not browser"`; a machine without one otherwise sees a launch failure rather
|
|
151
|
+
# than a skip, which is the honest outcome for a measurement that cannot be taken.
|
|
152
|
+
"browser: drives a real browser engine",
|
|
153
|
+
]
|
|
154
|
+
filterwarnings = ["error"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Feasibility spike for running Textual fully client-side under WebAssembly.
|
|
2
|
+
|
|
3
|
+
The package core contains no runtime-specific branching: the same
|
|
4
|
+
:func:`textual_wasm.probe.run_probe` coroutine is executed by the native CLI and by the
|
|
5
|
+
Pyodide harness, and any divergence between the two reports is the finding.
|
|
6
|
+
|
|
7
|
+
Importing this package applies :data:`textual_wasm.bootstrap.REQUIRED_ENVIRONMENT` and any
|
|
8
|
+
runtime shim :mod:`textual_wasm.polyfills` finds necessary. That has
|
|
9
|
+
to happen before the first `import textual` anywhere in the process — see
|
|
10
|
+
:mod:`textual_wasm.bootstrap` for why — and a package `__init__` is the only place that can
|
|
11
|
+
be guaranteed to run before its own submodules.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from textual_wasm.bootstrap import DRIVER_IMPORT_PATH, REQUIRED_ENVIRONMENT, apply_environment
|
|
15
|
+
from textual_wasm.polyfills import IS_EMSCRIPTEN, apply_polyfills
|
|
16
|
+
|
|
17
|
+
apply_environment()
|
|
18
|
+
APPLIED_POLYFILLS = apply_polyfills()
|
|
19
|
+
"""Runtime shims this process needed; surfaced in the probe report."""
|
|
20
|
+
|
|
21
|
+
from textual_wasm.report import ( # noqa: E402 - must follow apply_environment()
|
|
22
|
+
CheckId,
|
|
23
|
+
CheckResult,
|
|
24
|
+
CheckStatus,
|
|
25
|
+
ProbeReport,
|
|
26
|
+
RuntimeFacts,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"APPLIED_POLYFILLS",
|
|
31
|
+
"DRIVER_IMPORT_PATH",
|
|
32
|
+
"IS_EMSCRIPTEN",
|
|
33
|
+
"REQUIRED_ENVIRONMENT",
|
|
34
|
+
"CheckId",
|
|
35
|
+
"CheckResult",
|
|
36
|
+
"CheckStatus",
|
|
37
|
+
"ProbeReport",
|
|
38
|
+
"RuntimeFacts",
|
|
39
|
+
"apply_environment",
|
|
40
|
+
"apply_polyfills",
|
|
41
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The minimal Textual application the probe drives by default.
|
|
2
|
+
|
|
3
|
+
Deliberately tiny, and deliberately ordinary: it uses nothing a WASM host has to special-case,
|
|
4
|
+
so that any failure the probe reports is a failure of the *runtime*, not of the app.
|
|
5
|
+
|
|
6
|
+
It carries no probe instrumentation, and that is the point. The probe schedules its own
|
|
7
|
+
timer, reads the resize off the `Screen`, and judges input by what appears on the grid - so
|
|
8
|
+
this app is measured by exactly the code that measures anyone else's, and a check that only
|
|
9
|
+
passes because the app cooperated cannot exist.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import ClassVar, Final
|
|
15
|
+
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.binding import Binding, BindingType
|
|
18
|
+
from textual.widgets import Label, Static
|
|
19
|
+
|
|
20
|
+
MARKER: Final[str] = "TEXTUAL-WASM-SPIKE"
|
|
21
|
+
"""Text that means this app has drawn - `AppTarget.ready_marker` for the default target."""
|
|
22
|
+
|
|
23
|
+
HINT_ID: Final[str] = "hint"
|
|
24
|
+
"""Widget whose text reports the key-press count, so input is visible and not just asserted."""
|
|
25
|
+
|
|
26
|
+
HINT_TEMPLATE: Final[str] = "press 'a' - pressed {count}"
|
|
27
|
+
"""What that widget says. Module level because `AppTarget.settled_marker` is a substring of
|
|
28
|
+
it, and a test pins the two together - a settled marker that no longer matches what the app
|
|
29
|
+
draws does not fail loudly, it makes every harness wait out its timeout."""
|
|
30
|
+
|
|
31
|
+
WIDTH_SAMPLES: Final[tuple[tuple[str, str], ...]] = (
|
|
32
|
+
("ascii", "abcdef"),
|
|
33
|
+
("box", "\u2500\u2502\u250c\u2510\u2514\u2518\u251c\u2524"),
|
|
34
|
+
("arrows", "\u2190\u2191\u2192\u2193"),
|
|
35
|
+
("braille", "\u2801\u2802\u2803\u2804\u2805"),
|
|
36
|
+
("cjk", "\u4e16\u754c\u65e5\u672c\u8a9e"),
|
|
37
|
+
("combining", "e\u0301a\u0300"),
|
|
38
|
+
("astral", "\U0001f680\U0001f4bb"),
|
|
39
|
+
("vs16", "\u2705\u26a0\ufe0f"),
|
|
40
|
+
("zwj", "\U0001f469\u200d\U0001f4bb"),
|
|
41
|
+
)
|
|
42
|
+
"""Character classes whose cell width three different tables have to agree on.
|
|
43
|
+
|
|
44
|
+
Textual lays out with rich's `cell_len`, `pyte` replays with `wcwidth`, and `xterm.js` uses
|
|
45
|
+
its own - none of them shared code. Each sample is followed by a terminator in
|
|
46
|
+
:data:`WIDTH_TERMINATOR`, so a disagreement moves that character into a different column and
|
|
47
|
+
the screen diff names the row and the column rather than merely failing.
|
|
48
|
+
|
|
49
|
+
Ordered easiest-first: ASCII and box drawing are unanimous, CJK is where a naive table goes
|
|
50
|
+
wrong, and astral-plane characters are where a UTF-16 emulator can miscount.
|
|
51
|
+
|
|
52
|
+
Two classes are deliberately absent, and their absence is a finding rather than an
|
|
53
|
+
omission. `pyte` 0.8.2 **silently discards the remainder of the line** after a zero-width
|
|
54
|
+
joiner (U+200D) or a variation selector (U+FE0F): fed `"\N{WARNING SIGN}\ufe0f|"` it
|
|
55
|
+
renders `"\N{WARNING SIGN}"` and the terminator is gone. `xterm.js` preserves both. So on
|
|
56
|
+
emoji sequences the oracle is wrong and the browser is right, which means pyte cannot
|
|
57
|
+
adjudicate them at all - including them would only measure the measuring instrument.
|
|
58
|
+
Settling emoji needs a different reference, such as a real terminal captured through
|
|
59
|
+
`tmux capture-pane`.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
WIDTH_TERMINATOR: Final[str] = "|"
|
|
63
|
+
"""Marks where the preceding sample ended. A width disagreement shifts it."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SpikeApp(App[int]):
|
|
67
|
+
"""A one-widget app with a keybinding, drawn at a range of awkward character widths."""
|
|
68
|
+
|
|
69
|
+
CSS = """
|
|
70
|
+
Screen { align: center middle; }
|
|
71
|
+
#marker { color: $success; border: round $accent; padding: 1 2; }
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
75
|
+
# `priority` so the binding fires from the app regardless of what holds focus; the
|
|
76
|
+
# probe is testing the input path, not Textual's focus resolution.
|
|
77
|
+
Binding("a", "bump", "Bump the counter", priority=True),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self.bump_count: int = 0
|
|
83
|
+
"""Incremented by the `a` binding, i.e. by bytes that went through `XTermParser`."""
|
|
84
|
+
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
yield Label(MARKER, id="marker")
|
|
87
|
+
yield Static(id=HINT_ID)
|
|
88
|
+
for name, sample in WIDTH_SAMPLES:
|
|
89
|
+
yield Static(f"{name:<10}{sample}{WIDTH_TERMINATOR}", classes="width-sample")
|
|
90
|
+
|
|
91
|
+
def on_mount(self) -> None:
|
|
92
|
+
self._refresh_hint()
|
|
93
|
+
|
|
94
|
+
def action_bump(self) -> None:
|
|
95
|
+
self.bump_count += 1
|
|
96
|
+
self._refresh_hint()
|
|
97
|
+
|
|
98
|
+
def _refresh_hint(self) -> None:
|
|
99
|
+
"""Put the count on screen.
|
|
100
|
+
|
|
101
|
+
On screen rather than only in an attribute: every harness judges input by what it can
|
|
102
|
+
read back off the grid, so a counter that is not drawn is a counter no leg can see.
|
|
103
|
+
"""
|
|
104
|
+
self.query_one(f"#{HINT_ID}", Static).update(HINT_TEMPLATE.format(count=self.bump_count))
|