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.
Files changed (57) hide show
  1. textual_wasm-0.1.0/LICENSE +21 -0
  2. textual_wasm-0.1.0/PKG-INFO +184 -0
  3. textual_wasm-0.1.0/README.md +151 -0
  4. textual_wasm-0.1.0/pyproject.toml +154 -0
  5. textual_wasm-0.1.0/src/textual_wasm/__init__.py +41 -0
  6. textual_wasm-0.1.0/src/textual_wasm/__main__.py +9 -0
  7. textual_wasm-0.1.0/src/textual_wasm/app.py +104 -0
  8. textual_wasm-0.1.0/src/textual_wasm/assets/boot.mjs +146 -0
  9. textual_wasm-0.1.0/src/textual_wasm/assets/entry.py +51 -0
  10. textual_wasm-0.1.0/src/textual_wasm/assets/index.html +47 -0
  11. textual_wasm-0.1.0/src/textual_wasm/assets/main.mjs +360 -0
  12. textual_wasm-0.1.0/src/textual_wasm/assets/styles/base.css +45 -0
  13. textual_wasm-0.1.0/src/textual_wasm/assets/styles/index.css +21 -0
  14. textual_wasm-0.1.0/src/textual_wasm/assets/styles/layout.css +79 -0
  15. textual_wasm-0.1.0/src/textual_wasm/assets/styles/tokens.css +63 -0
  16. textual_wasm-0.1.0/src/textual_wasm/assets/worker.mjs +203 -0
  17. textual_wasm-0.1.0/src/textual_wasm/bootstrap.py +43 -0
  18. textual_wasm-0.1.0/src/textual_wasm/browser.py +170 -0
  19. textual_wasm-0.1.0/src/textual_wasm/bundler.py +371 -0
  20. textual_wasm-0.1.0/src/textual_wasm/capabilities.py +152 -0
  21. textual_wasm-0.1.0/src/textual_wasm/check.py +519 -0
  22. textual_wasm-0.1.0/src/textual_wasm/cli/__init__.py +16 -0
  23. textual_wasm-0.1.0/src/textual_wasm/cli/_app.py +15 -0
  24. textual_wasm-0.1.0/src/textual_wasm/cli/_render.py +201 -0
  25. textual_wasm-0.1.0/src/textual_wasm/cli/_target.py +32 -0
  26. textual_wasm-0.1.0/src/textual_wasm/cli/build.py +138 -0
  27. textual_wasm-0.1.0/src/textual_wasm/cli/check.py +95 -0
  28. textual_wasm-0.1.0/src/textual_wasm/cli/experiment.py +148 -0
  29. textual_wasm-0.1.0/src/textual_wasm/cli/port.py +121 -0
  30. textual_wasm-0.1.0/src/textual_wasm/compare.py +169 -0
  31. textual_wasm-0.1.0/src/textual_wasm/diagnostics/__init__.py +43 -0
  32. textual_wasm-0.1.0/src/textual_wasm/diagnostics/errors.py +76 -0
  33. textual_wasm-0.1.0/src/textual_wasm/diagnostics/guards.py +224 -0
  34. textual_wasm-0.1.0/src/textual_wasm/diagnostics/surface.py +87 -0
  35. textual_wasm-0.1.0/src/textual_wasm/diagnostics/translate.py +29 -0
  36. textual_wasm-0.1.0/src/textual_wasm/docs.py +171 -0
  37. textual_wasm-0.1.0/src/textual_wasm/doctor/__init__.py +109 -0
  38. textual_wasm-0.1.0/src/textual_wasm/doctor/deps.py +174 -0
  39. textual_wasm-0.1.0/src/textual_wasm/doctor/scan.py +215 -0
  40. textual_wasm-0.1.0/src/textual_wasm/driver.py +267 -0
  41. textual_wasm-0.1.0/src/textual_wasm/harness/_collect.mjs +162 -0
  42. textual_wasm-0.1.0/src/textual_wasm/harness/browser-check.mjs +146 -0
  43. textual_wasm-0.1.0/src/textual_wasm/harness/pyodide-probe.mjs +127 -0
  44. textual_wasm-0.1.0/src/textual_wasm/harness/responsiveness.mjs +104 -0
  45. textual_wasm-0.1.0/src/textual_wasm/harness/safari-check.mjs +46 -0
  46. textual_wasm-0.1.0/src/textual_wasm/harness/wasm_entry.py +62 -0
  47. textual_wasm-0.1.0/src/textual_wasm/node.py +175 -0
  48. textual_wasm-0.1.0/src/textual_wasm/pins.py +120 -0
  49. textual_wasm-0.1.0/src/textual_wasm/polyfills.py +55 -0
  50. textual_wasm-0.1.0/src/textual_wasm/probe.py +372 -0
  51. textual_wasm-0.1.0/src/textual_wasm/reference.py +47 -0
  52. textual_wasm-0.1.0/src/textual_wasm/report.py +253 -0
  53. textual_wasm-0.1.0/src/textual_wasm/responsiveness.py +148 -0
  54. textual_wasm-0.1.0/src/textual_wasm/screen.py +113 -0
  55. textual_wasm-0.1.0/src/textual_wasm/substitutions.py +433 -0
  56. textual_wasm-0.1.0/src/textual_wasm/target.py +319 -0
  57. 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
+ [![ci](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml)
36
+ [![docs](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/pages.yml/badge.svg)](https://kaierikniermann.github.io/textual-wasm/)
37
+ [![python](https://img.shields.io/badge/python-3.12%2B-blue)](https://kaierikniermann.github.io/textual-wasm/installation.html)
38
+ [![license](https://img.shields.io/badge/license-MIT-green)](./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
+ [![ci](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/ci.yml)
4
+ [![docs](https://github.com/KaiErikNiermann/textual-wasm/actions/workflows/pages.yml/badge.svg)](https://kaierikniermann.github.io/textual-wasm/)
5
+ [![python](https://img.shields.io/badge/python-3.12%2B-blue)](https://kaierikniermann.github.io/textual-wasm/installation.html)
6
+ [![license](https://img.shields.io/badge/license-MIT-green)](./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,9 @@
1
+ """Entry point for `python -m textual_wasm`.
2
+
3
+ The commands live in :mod:`textual_wasm.cli`; this module only starts them.
4
+ """
5
+
6
+ from textual_wasm.cli import app
7
+
8
+ if __name__ == "__main__":
9
+ app()
@@ -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))