ghostty-textual 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.
- ghostty_textual-0.1.0/.gitignore +10 -0
- ghostty_textual-0.1.0/LICENSE +21 -0
- ghostty_textual-0.1.0/PKG-INFO +210 -0
- ghostty_textual-0.1.0/README.md +183 -0
- ghostty_textual-0.1.0/benchmarks/feed_bursts.py +58 -0
- ghostty_textual-0.1.0/benchmarks/frame_extraction.py +114 -0
- ghostty_textual-0.1.0/benchmarks/widget_rendering.py +40 -0
- ghostty_textual-0.1.0/docs/performance.md +81 -0
- ghostty_textual-0.1.0/docs/superpowers/plans/2026-07-25-ghostty-textual-library.md +1986 -0
- ghostty_textual-0.1.0/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md +901 -0
- ghostty_textual-0.1.0/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md +449 -0
- ghostty_textual-0.1.0/docs/superpowers/specs/sol-5.6-textual-design-review.md +795 -0
- ghostty_textual-0.1.0/pyproject.toml +78 -0
- ghostty_textual-0.1.0/src/ghostty_textual/__init__.py +69 -0
- ghostty_textual-0.1.0/src/ghostty_textual/_native.py +277 -0
- ghostty_textual-0.1.0/src/ghostty_textual/_render.py +440 -0
- ghostty_textual-0.1.0/src/ghostty_textual/cells.py +111 -0
- ghostty_textual-0.1.0/src/ghostty_textual/emulator.py +832 -0
- ghostty_textual-0.1.0/src/ghostty_textual/keys.py +51 -0
- ghostty_textual-0.1.0/src/ghostty_textual/py.typed +1 -0
- ghostty_textual-0.1.0/src/ghostty_textual/theme.py +68 -0
- ghostty_textual-0.1.0/src/ghostty_textual/widget.py +682 -0
- ghostty_textual-0.1.0/tests/__init__.py +0 -0
- ghostty_textual-0.1.0/tests/conftest.py +157 -0
- ghostty_textual-0.1.0/tests/test_abi.py +133 -0
- ghostty_textual-0.1.0/tests/test_cells.py +43 -0
- ghostty_textual-0.1.0/tests/test_contract_regressions.py +149 -0
- ghostty_textual-0.1.0/tests/test_effects.py +63 -0
- ghostty_textual-0.1.0/tests/test_emulator.py +194 -0
- ghostty_textual-0.1.0/tests/test_frame_scheduling.py +135 -0
- ghostty_textual-0.1.0/tests/test_fuzz.py +27 -0
- ghostty_textual-0.1.0/tests/test_packaging.py +68 -0
- ghostty_textual-0.1.0/tests/test_performance.py +61 -0
- ghostty_textual-0.1.0/tests/test_render.py +92 -0
- ghostty_textual-0.1.0/tests/test_render_optimizations.py +163 -0
- ghostty_textual-0.1.0/tests/test_reset.py +111 -0
- ghostty_textual-0.1.0/tests/test_resize.py +60 -0
- ghostty_textual-0.1.0/tests/test_security.py +147 -0
- ghostty_textual-0.1.0/tests/test_sync_output.py +53 -0
- ghostty_textual-0.1.0/tests/test_widget.py +302 -0
- ghostty_textual-0.1.0/tests/test_writer_lifecycle.py +120 -0
- ghostty_textual-0.1.0/tests/widget_harness.py +61 -0
- ghostty_textual-0.1.0/uv.lock +392 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ghostty-textual contributors
|
|
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,210 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ghostty-textual
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Textual terminal widget backed by libghostty-vt
|
|
5
|
+
Project-URL: Homepage, https://github.com/ayder/ghostty-textual
|
|
6
|
+
Project-URL: Documentation, https://github.com/ayder/ghostty-textual#readme
|
|
7
|
+
Project-URL: Source, https://github.com/ayder/ghostty-textual
|
|
8
|
+
Project-URL: Issues, https://github.com/ayder/ghostty-textual/issues
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: cffi>=2.1.1; python_version >= '3.15'
|
|
19
|
+
Requires-Dist: pyghostty==0.1.4
|
|
20
|
+
Requires-Dist: textual<9,>=8.2.8
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: packaging>=24.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# ghostty-textual
|
|
29
|
+
|
|
30
|
+
A [Textual](https://textual.textualize.io/) terminal widget backed by
|
|
31
|
+
[libghostty-vt](https://libghostty.tip.ghostty.org/), Ghostty's embeddable
|
|
32
|
+
terminal emulation core.
|
|
33
|
+
|
|
34
|
+
The headless emulator and `TerminalView` widget are implemented. The public
|
|
35
|
+
renderer uses libghostty's render-state and cell accessor APIs, keeps Ghostty's
|
|
36
|
+
viewport authoritative, and routes terminal replies and user input through one
|
|
37
|
+
ordered transport queue.
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Existing Python terminal emulators force a choice: `pyte` is unmaintained and
|
|
42
|
+
structurally unprepared for modern escape sequences, while newer pure-Python
|
|
43
|
+
parsers are young enough that their own scrollback support is still in progress.
|
|
44
|
+
`libghostty-vt` is neither — it is Ghostty's shipping core, extracted as a
|
|
45
|
+
zero-dependency C library with scrollback, reflow, and grapheme handling built
|
|
46
|
+
in.
|
|
47
|
+
|
|
48
|
+
## Design principle
|
|
49
|
+
|
|
50
|
+
**The library never owns a process, PTY, or transport.** Bytes in, bytes out.
|
|
51
|
+
|
|
52
|
+
That constraint is what makes it usable by applications that already own their
|
|
53
|
+
process lifecycle — SSH clients with password injection and process-group
|
|
54
|
+
cleanup, for example — and it is why existing batteries-included Textual
|
|
55
|
+
terminal widgets were not an option.
|
|
56
|
+
|
|
57
|
+
Two layers, one native boundary:
|
|
58
|
+
|
|
59
|
+
| Module | Role |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `_native.py` | Loader, ABI verification, union-by-value shims. The only file that knows `pyghostty` exists. |
|
|
62
|
+
| `_render.py` | Private public-ABI render-state extraction and dirty acknowledgement. |
|
|
63
|
+
| `emulator.py` | `Terminal` — headless, no Textual import. Absorbs libghostty API churn. |
|
|
64
|
+
| `widget.py` | `TerminalView` — rendering, input, selection. No C knowledge. |
|
|
65
|
+
|
|
66
|
+
## Minimal use
|
|
67
|
+
|
|
68
|
+
Install the package from PyPI:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install ghostty-textual
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The distribution name is `ghostty-textual`; the Python import is `ghostty_textual`.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from ghostty_textual import TerminalView
|
|
78
|
+
|
|
79
|
+
view = TerminalView(
|
|
80
|
+
send=session.send,
|
|
81
|
+
resize_transport=lambda cols, rows: session.resize(rows, cols),
|
|
82
|
+
)
|
|
83
|
+
session.on_output = view.feed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`feed()` parses bytes and queues replies immediately, then coalesces frame
|
|
87
|
+
extraction until the next event-loop turn. Call `view.refresh_frame()` after
|
|
88
|
+
feeding when you need to inspect the rendered frame synchronously. Synchronized
|
|
89
|
+
output still withholds frames until release or timeout; `refresh_frame(force=True)`
|
|
90
|
+
explicitly bypasses that hold.
|
|
91
|
+
|
|
92
|
+
The application remains responsible for process and PTY lifecycle. For
|
|
93
|
+
reconnects, close the old process, `await view.reset_io()`, call
|
|
94
|
+
`view.hard_reset()`, then start the replacement process.
|
|
95
|
+
|
|
96
|
+
## Design documents
|
|
97
|
+
|
|
98
|
+
- [`docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md)
|
|
99
|
+
— current design
|
|
100
|
+
- [`docs/superpowers/specs/sol-5.6-textual-design-review.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/sol-5.6-textual-design-review.md)
|
|
101
|
+
— review that produced it
|
|
102
|
+
- [`docs/superpowers/specs/2026-07-25-ghostty-textual-design.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md)
|
|
103
|
+
— superseded v1
|
|
104
|
+
|
|
105
|
+
## Supported platforms
|
|
106
|
+
|
|
107
|
+
Requires Python 3.12+ and pins `pyghostty==0.1.4`, which bundles libghostty-vt
|
|
108
|
+
built for baseline CPUs. No native compilation is needed to install it.
|
|
109
|
+
|
|
110
|
+
| OS | Architectures | Minimum OS / libc |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| Linux | `amd64` / `x86_64`, `aarch64` / `arm64` | glibc 2.27 |
|
|
113
|
+
| macOS | `amd64` / `x86_64` (Intel), `arm64` / `aarch64` (Apple Silicon) | macOS 13 |
|
|
114
|
+
|
|
115
|
+
`amd64` and `x86_64` name the same architecture; so do `aarch64` and `arm64`.
|
|
116
|
+
Use a 64-bit OS and Python on ARM devices such as Raspberry Pi 5. Upstream
|
|
117
|
+
does not publish Windows, musl/Alpine Linux, or 32-bit ARM wheels.
|
|
118
|
+
|
|
119
|
+
CI runs the native ABI and functional tests, plus a clean wheel installation
|
|
120
|
+
and terminal smoke test, on Linux and macOS with both CPU architectures using
|
|
121
|
+
Python 3.12. An additional Linux x86_64 job runs the same checks with Python 3.15.
|
|
122
|
+
Python 3.15 requires CFFI 2.1.1 or newer to avoid an upstream callback ABI crash;
|
|
123
|
+
this minimum is enforced by the package dependencies.
|
|
124
|
+
|
|
125
|
+
The bundled native library retains a cell's base codepoint plus at most 64
|
|
126
|
+
additional grapheme codepoints. Further combining codepoints are ignored to
|
|
127
|
+
bound memory and processing costs. This applies across fragmented input too.
|
|
128
|
+
The scheduled ABI drift job tests the latest upstream binding separately from
|
|
129
|
+
the exact dependency used by normal CI and released distributions.
|
|
130
|
+
|
|
131
|
+
## Develop
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv sync --all-extras
|
|
135
|
+
uv run pytest
|
|
136
|
+
uv run ruff check src tests
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The full-frame extraction benchmark defaults to a 10 ms p95 budget for a
|
|
140
|
+
120×40 terminal. GitHub CI enforces 100 ms on Linux and macOS ARM64. Hosted macOS
|
|
141
|
+
Intel records median/p95 and budget violations without failing on timing alone;
|
|
142
|
+
that runner has exceeded 100 ms even on the pre-optimization baseline. Functional,
|
|
143
|
+
memory, ABI, and packaging checks remain mandatory on every platform. Native
|
|
144
|
+
extraction errors still fail the timing step, including on Intel.
|
|
145
|
+
|
|
146
|
+
Set `GHOSTTY_TEXTUAL_FRAME_BUDGET_MS` to override the budget locally. The default
|
|
147
|
+
`GHOSTTY_TEXTUAL_FRAME_BUDGET_MODE=enforce` asserts the p95 budget; `report` records
|
|
148
|
+
timings without asserting the cutoff. CI runs the timing test separately with
|
|
149
|
+
output capture disabled so successful runs also retain measurements in their logs.
|
|
150
|
+
|
|
151
|
+
Additional benchmarks cover styled and linked extraction, row rendering,
|
|
152
|
+
selection, and fragmented input through a mounted widget:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
uv run python benchmarks/frame_extraction.py
|
|
156
|
+
uv run python benchmarks/widget_rendering.py
|
|
157
|
+
uv run python benchmarks/feed_bursts.py
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See [performance measurements and cache design](https://github.com/ayder/ghostty-textual/blob/main/docs/performance.md) for results
|
|
161
|
+
and measurement scope.
|
|
162
|
+
|
|
163
|
+
## Releasing
|
|
164
|
+
|
|
165
|
+
Update the version in `pyproject.toml` and `uv.lock`, merge the release PR, then
|
|
166
|
+
push a matching tag such as `v0.1.0`. The tag must point to the merged commit that
|
|
167
|
+
contains that version and the release workflow.
|
|
168
|
+
|
|
169
|
+
CI scans the full Git history with Gitleaks, then requires that scan and the
|
|
170
|
+
four-platform test matrix to pass before releasing. It also checks distribution
|
|
171
|
+
metadata and README rendering with `twine check --strict`.
|
|
172
|
+
|
|
173
|
+
CI builds and smoke-tests distributions on all four supported platforms. The
|
|
174
|
+
Linux x86_64 job retains its wheel and source archive as the `python-dist` Actions
|
|
175
|
+
artifact on every run. After all platform jobs pass on a `v*` tag push, the release
|
|
176
|
+
job verifies the tag, package version, filenames, and archive metadata, then
|
|
177
|
+
attaches the tested files to a GitHub Release. It creates a draft when needed and
|
|
178
|
+
publishes only after the uploads succeed; an existing release keeps its notes.
|
|
179
|
+
A separate job then publishes the same tested artifacts to PyPI as
|
|
180
|
+
`ghostty-textual` through Trusted Publishing, with provenance attestations.
|
|
181
|
+
|
|
182
|
+
The wheel is `ghostty_textual-<version>-py3-none-any.whl`: one wheel supports all
|
|
183
|
+
the listed platforms, with the native binary supplied by the pinned `pyghostty`
|
|
184
|
+
dependency. The source archive is `ghostty_textual-<version>.tar.gz`.
|
|
185
|
+
|
|
186
|
+
Retry a failed release job from Actions. Already uploaded assets are downloaded
|
|
187
|
+
and compared before being reused; differing files fail rather than replacing a
|
|
188
|
+
published asset. Only the GitHub release job receives `contents: write`; only the
|
|
189
|
+
PyPI job receives `id-token: write`. The PyPI job downloads the tested artifact
|
|
190
|
+
without checking out or building repository code. No PyPI API token is stored.
|
|
191
|
+
|
|
192
|
+
Before the first PyPI release:
|
|
193
|
+
|
|
194
|
+
1. Create the GitHub Actions environment `pypi` in `ayder/ghostty-textual`.
|
|
195
|
+
Restrict its deployment tags to `v*`; add required reviewers if desired.
|
|
196
|
+
2. In the intended owner's [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
|
|
197
|
+
add a pending Trusted Publisher with project name `ghostty-textual`, owner
|
|
198
|
+
`ayder`, repository `ghostty-textual`, workflow `ci.yml`, and environment `pypi`.
|
|
199
|
+
For an existing project, add the publisher in that project's publishing settings.
|
|
200
|
+
3. Merge the release PR and push `v0.1.0`. PR and branch builds validate packages
|
|
201
|
+
but do not upload to either registry. A failed PyPI job can be rerun after
|
|
202
|
+
fixing publisher configuration. Already published versions cannot be replaced;
|
|
203
|
+
use a new version for subsequent releases.
|
|
204
|
+
|
|
205
|
+
For a local secret scan, install [Gitleaks](https://github.com/gitleaks/gitleaks)
|
|
206
|
+
and run `gitleaks git --redact --no-banner --log-opts=--all .`.
|
|
207
|
+
|
|
208
|
+
## Licence
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# ghostty-textual
|
|
2
|
+
|
|
3
|
+
A [Textual](https://textual.textualize.io/) terminal widget backed by
|
|
4
|
+
[libghostty-vt](https://libghostty.tip.ghostty.org/), Ghostty's embeddable
|
|
5
|
+
terminal emulation core.
|
|
6
|
+
|
|
7
|
+
The headless emulator and `TerminalView` widget are implemented. The public
|
|
8
|
+
renderer uses libghostty's render-state and cell accessor APIs, keeps Ghostty's
|
|
9
|
+
viewport authoritative, and routes terminal replies and user input through one
|
|
10
|
+
ordered transport queue.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Existing Python terminal emulators force a choice: `pyte` is unmaintained and
|
|
15
|
+
structurally unprepared for modern escape sequences, while newer pure-Python
|
|
16
|
+
parsers are young enough that their own scrollback support is still in progress.
|
|
17
|
+
`libghostty-vt` is neither — it is Ghostty's shipping core, extracted as a
|
|
18
|
+
zero-dependency C library with scrollback, reflow, and grapheme handling built
|
|
19
|
+
in.
|
|
20
|
+
|
|
21
|
+
## Design principle
|
|
22
|
+
|
|
23
|
+
**The library never owns a process, PTY, or transport.** Bytes in, bytes out.
|
|
24
|
+
|
|
25
|
+
That constraint is what makes it usable by applications that already own their
|
|
26
|
+
process lifecycle — SSH clients with password injection and process-group
|
|
27
|
+
cleanup, for example — and it is why existing batteries-included Textual
|
|
28
|
+
terminal widgets were not an option.
|
|
29
|
+
|
|
30
|
+
Two layers, one native boundary:
|
|
31
|
+
|
|
32
|
+
| Module | Role |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `_native.py` | Loader, ABI verification, union-by-value shims. The only file that knows `pyghostty` exists. |
|
|
35
|
+
| `_render.py` | Private public-ABI render-state extraction and dirty acknowledgement. |
|
|
36
|
+
| `emulator.py` | `Terminal` — headless, no Textual import. Absorbs libghostty API churn. |
|
|
37
|
+
| `widget.py` | `TerminalView` — rendering, input, selection. No C knowledge. |
|
|
38
|
+
|
|
39
|
+
## Minimal use
|
|
40
|
+
|
|
41
|
+
Install the package from PyPI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install ghostty-textual
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The distribution name is `ghostty-textual`; the Python import is `ghostty_textual`.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ghostty_textual import TerminalView
|
|
51
|
+
|
|
52
|
+
view = TerminalView(
|
|
53
|
+
send=session.send,
|
|
54
|
+
resize_transport=lambda cols, rows: session.resize(rows, cols),
|
|
55
|
+
)
|
|
56
|
+
session.on_output = view.feed
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`feed()` parses bytes and queues replies immediately, then coalesces frame
|
|
60
|
+
extraction until the next event-loop turn. Call `view.refresh_frame()` after
|
|
61
|
+
feeding when you need to inspect the rendered frame synchronously. Synchronized
|
|
62
|
+
output still withholds frames until release or timeout; `refresh_frame(force=True)`
|
|
63
|
+
explicitly bypasses that hold.
|
|
64
|
+
|
|
65
|
+
The application remains responsible for process and PTY lifecycle. For
|
|
66
|
+
reconnects, close the old process, `await view.reset_io()`, call
|
|
67
|
+
`view.hard_reset()`, then start the replacement process.
|
|
68
|
+
|
|
69
|
+
## Design documents
|
|
70
|
+
|
|
71
|
+
- [`docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md)
|
|
72
|
+
— current design
|
|
73
|
+
- [`docs/superpowers/specs/sol-5.6-textual-design-review.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/sol-5.6-textual-design-review.md)
|
|
74
|
+
— review that produced it
|
|
75
|
+
- [`docs/superpowers/specs/2026-07-25-ghostty-textual-design.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md)
|
|
76
|
+
— superseded v1
|
|
77
|
+
|
|
78
|
+
## Supported platforms
|
|
79
|
+
|
|
80
|
+
Requires Python 3.12+ and pins `pyghostty==0.1.4`, which bundles libghostty-vt
|
|
81
|
+
built for baseline CPUs. No native compilation is needed to install it.
|
|
82
|
+
|
|
83
|
+
| OS | Architectures | Minimum OS / libc |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Linux | `amd64` / `x86_64`, `aarch64` / `arm64` | glibc 2.27 |
|
|
86
|
+
| macOS | `amd64` / `x86_64` (Intel), `arm64` / `aarch64` (Apple Silicon) | macOS 13 |
|
|
87
|
+
|
|
88
|
+
`amd64` and `x86_64` name the same architecture; so do `aarch64` and `arm64`.
|
|
89
|
+
Use a 64-bit OS and Python on ARM devices such as Raspberry Pi 5. Upstream
|
|
90
|
+
does not publish Windows, musl/Alpine Linux, or 32-bit ARM wheels.
|
|
91
|
+
|
|
92
|
+
CI runs the native ABI and functional tests, plus a clean wheel installation
|
|
93
|
+
and terminal smoke test, on Linux and macOS with both CPU architectures using
|
|
94
|
+
Python 3.12. An additional Linux x86_64 job runs the same checks with Python 3.15.
|
|
95
|
+
Python 3.15 requires CFFI 2.1.1 or newer to avoid an upstream callback ABI crash;
|
|
96
|
+
this minimum is enforced by the package dependencies.
|
|
97
|
+
|
|
98
|
+
The bundled native library retains a cell's base codepoint plus at most 64
|
|
99
|
+
additional grapheme codepoints. Further combining codepoints are ignored to
|
|
100
|
+
bound memory and processing costs. This applies across fragmented input too.
|
|
101
|
+
The scheduled ABI drift job tests the latest upstream binding separately from
|
|
102
|
+
the exact dependency used by normal CI and released distributions.
|
|
103
|
+
|
|
104
|
+
## Develop
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv sync --all-extras
|
|
108
|
+
uv run pytest
|
|
109
|
+
uv run ruff check src tests
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The full-frame extraction benchmark defaults to a 10 ms p95 budget for a
|
|
113
|
+
120×40 terminal. GitHub CI enforces 100 ms on Linux and macOS ARM64. Hosted macOS
|
|
114
|
+
Intel records median/p95 and budget violations without failing on timing alone;
|
|
115
|
+
that runner has exceeded 100 ms even on the pre-optimization baseline. Functional,
|
|
116
|
+
memory, ABI, and packaging checks remain mandatory on every platform. Native
|
|
117
|
+
extraction errors still fail the timing step, including on Intel.
|
|
118
|
+
|
|
119
|
+
Set `GHOSTTY_TEXTUAL_FRAME_BUDGET_MS` to override the budget locally. The default
|
|
120
|
+
`GHOSTTY_TEXTUAL_FRAME_BUDGET_MODE=enforce` asserts the p95 budget; `report` records
|
|
121
|
+
timings without asserting the cutoff. CI runs the timing test separately with
|
|
122
|
+
output capture disabled so successful runs also retain measurements in their logs.
|
|
123
|
+
|
|
124
|
+
Additional benchmarks cover styled and linked extraction, row rendering,
|
|
125
|
+
selection, and fragmented input through a mounted widget:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run python benchmarks/frame_extraction.py
|
|
129
|
+
uv run python benchmarks/widget_rendering.py
|
|
130
|
+
uv run python benchmarks/feed_bursts.py
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
See [performance measurements and cache design](https://github.com/ayder/ghostty-textual/blob/main/docs/performance.md) for results
|
|
134
|
+
and measurement scope.
|
|
135
|
+
|
|
136
|
+
## Releasing
|
|
137
|
+
|
|
138
|
+
Update the version in `pyproject.toml` and `uv.lock`, merge the release PR, then
|
|
139
|
+
push a matching tag such as `v0.1.0`. The tag must point to the merged commit that
|
|
140
|
+
contains that version and the release workflow.
|
|
141
|
+
|
|
142
|
+
CI scans the full Git history with Gitleaks, then requires that scan and the
|
|
143
|
+
four-platform test matrix to pass before releasing. It also checks distribution
|
|
144
|
+
metadata and README rendering with `twine check --strict`.
|
|
145
|
+
|
|
146
|
+
CI builds and smoke-tests distributions on all four supported platforms. The
|
|
147
|
+
Linux x86_64 job retains its wheel and source archive as the `python-dist` Actions
|
|
148
|
+
artifact on every run. After all platform jobs pass on a `v*` tag push, the release
|
|
149
|
+
job verifies the tag, package version, filenames, and archive metadata, then
|
|
150
|
+
attaches the tested files to a GitHub Release. It creates a draft when needed and
|
|
151
|
+
publishes only after the uploads succeed; an existing release keeps its notes.
|
|
152
|
+
A separate job then publishes the same tested artifacts to PyPI as
|
|
153
|
+
`ghostty-textual` through Trusted Publishing, with provenance attestations.
|
|
154
|
+
|
|
155
|
+
The wheel is `ghostty_textual-<version>-py3-none-any.whl`: one wheel supports all
|
|
156
|
+
the listed platforms, with the native binary supplied by the pinned `pyghostty`
|
|
157
|
+
dependency. The source archive is `ghostty_textual-<version>.tar.gz`.
|
|
158
|
+
|
|
159
|
+
Retry a failed release job from Actions. Already uploaded assets are downloaded
|
|
160
|
+
and compared before being reused; differing files fail rather than replacing a
|
|
161
|
+
published asset. Only the GitHub release job receives `contents: write`; only the
|
|
162
|
+
PyPI job receives `id-token: write`. The PyPI job downloads the tested artifact
|
|
163
|
+
without checking out or building repository code. No PyPI API token is stored.
|
|
164
|
+
|
|
165
|
+
Before the first PyPI release:
|
|
166
|
+
|
|
167
|
+
1. Create the GitHub Actions environment `pypi` in `ayder/ghostty-textual`.
|
|
168
|
+
Restrict its deployment tags to `v*`; add required reviewers if desired.
|
|
169
|
+
2. In the intended owner's [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
|
|
170
|
+
add a pending Trusted Publisher with project name `ghostty-textual`, owner
|
|
171
|
+
`ayder`, repository `ghostty-textual`, workflow `ci.yml`, and environment `pypi`.
|
|
172
|
+
For an existing project, add the publisher in that project's publishing settings.
|
|
173
|
+
3. Merge the release PR and push `v0.1.0`. PR and branch builds validate packages
|
|
174
|
+
but do not upload to either registry. A failed PyPI job can be rerun after
|
|
175
|
+
fixing publisher configuration. Already published versions cannot be replaced;
|
|
176
|
+
use a new version for subsequent releases.
|
|
177
|
+
|
|
178
|
+
For a local secret scan, install [Gitleaks](https://github.com/gitleaks/gitleaks)
|
|
179
|
+
and run `gitleaks git --redact --no-banner --log-opts=--all .`.
|
|
180
|
+
|
|
181
|
+
## Licence
|
|
182
|
+
|
|
183
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Measure feed bursts through a mounted widget, including deferred extraction.
|
|
2
|
+
|
|
3
|
+
The timed region ends after one loop turn. It excludes Textual composition and
|
|
4
|
+
terminal output; use widget_rendering.py to measure row rendering separately.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import statistics
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
|
|
16
|
+
from ghostty_textual.widget import TerminalView
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BenchmarkApp(App):
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
self.view = TerminalView(send=self.send)
|
|
22
|
+
yield self.view
|
|
23
|
+
|
|
24
|
+
async def send(self, data: bytes) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def run(samples: int) -> None:
|
|
29
|
+
app = BenchmarkApp()
|
|
30
|
+
async with app.run_test(size=(120, 40)) as pilot:
|
|
31
|
+
for chunk_size in (1, 64, 4096):
|
|
32
|
+
chunks = [b"x" * chunk_size] * (4096 // chunk_size)
|
|
33
|
+
values = []
|
|
34
|
+
for index in range(samples + 5):
|
|
35
|
+
app.view.hard_reset()
|
|
36
|
+
await pilot.pause()
|
|
37
|
+
start = time.perf_counter()
|
|
38
|
+
for chunk in chunks:
|
|
39
|
+
app.view.feed(chunk)
|
|
40
|
+
await asyncio.sleep(0)
|
|
41
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
42
|
+
if index >= 5:
|
|
43
|
+
values.append(elapsed)
|
|
44
|
+
print(
|
|
45
|
+
f"4KiB widget feed, chunks={chunk_size}: "
|
|
46
|
+
f"median {statistics.median(values):.3f} ms "
|
|
47
|
+
f"p95 {sorted(values)[min(int(samples * 0.95), samples - 1)]:.3f} ms",
|
|
48
|
+
flush=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
54
|
+
parser.add_argument("--samples", type=int, default=10)
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
if args.samples < 1:
|
|
57
|
+
parser.error("sample count must be positive")
|
|
58
|
+
asyncio.run(run(args.samples))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Extraction and feed benchmarks; run from the repository's Python environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import statistics
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from ghostty_textual.emulator import Terminal
|
|
11
|
+
|
|
12
|
+
COLS, ROWS = 120, 40
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def screen(workload: str) -> bytes:
|
|
16
|
+
line = {
|
|
17
|
+
"plain": b"x" * COLS,
|
|
18
|
+
"styled": b"\x1b[1;38;2;120;80;160m" + b"x" * COLS + b"\x1b[0m",
|
|
19
|
+
"alternating": b"\x1b[31mx\x1b[32mx" * (COLS // 2) + b"\x1b[0m",
|
|
20
|
+
"linked": b"\x1b]8;;https://example.com\x1b\\" + b"x" * COLS + b"\x1b]8;;\x1b\\",
|
|
21
|
+
}[workload]
|
|
22
|
+
return b"\x1b[?25l" + b"".join(b"\x1b[%d;1H" % (y + 1) + line for y in range(ROWS))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def measure(
|
|
26
|
+
name: str,
|
|
27
|
+
operation: Callable[[], object],
|
|
28
|
+
count: int,
|
|
29
|
+
*,
|
|
30
|
+
prepare: Callable[[], object] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
values: list[float] = []
|
|
33
|
+
for index in range(count + 5):
|
|
34
|
+
if prepare is not None:
|
|
35
|
+
prepare()
|
|
36
|
+
start = time.perf_counter()
|
|
37
|
+
operation()
|
|
38
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
39
|
+
if index >= 5:
|
|
40
|
+
values.append(elapsed)
|
|
41
|
+
ordered = sorted(values)
|
|
42
|
+
print(
|
|
43
|
+
f"{name}: median {statistics.median(values):.3f} ms "
|
|
44
|
+
f"p95 {ordered[min(int(count * 0.95), count - 1)]:.3f} ms",
|
|
45
|
+
flush=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
51
|
+
parser.add_argument("--samples", type=int, default=100)
|
|
52
|
+
parser.add_argument("--fragment-samples", type=int, default=10)
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
if min(args.samples, args.fragment_samples) < 1:
|
|
55
|
+
parser.error("sample counts must be positive")
|
|
56
|
+
|
|
57
|
+
for workload in ("plain", "styled", "alternating", "linked"):
|
|
58
|
+
with Terminal(COLS, ROWS) as terminal:
|
|
59
|
+
terminal.feed(screen(workload))
|
|
60
|
+
measure(
|
|
61
|
+
workload + " full extraction", lambda: terminal.snapshot(force=True), args.samples
|
|
62
|
+
)
|
|
63
|
+
with Terminal(COLS, ROWS) as terminal:
|
|
64
|
+
terminal.feed(screen("plain"))
|
|
65
|
+
terminal.snapshot()
|
|
66
|
+
measure("idle snapshot", terminal.snapshot, args.samples)
|
|
67
|
+
measure(
|
|
68
|
+
"single dirty row",
|
|
69
|
+
terminal.snapshot,
|
|
70
|
+
args.samples,
|
|
71
|
+
prepare=lambda: terminal.feed(b"\x1b[1;1Hx"),
|
|
72
|
+
)
|
|
73
|
+
measure(
|
|
74
|
+
"cursor-only snapshot",
|
|
75
|
+
terminal.snapshot,
|
|
76
|
+
args.samples,
|
|
77
|
+
prepare=lambda: terminal.feed(
|
|
78
|
+
b"\x1b[1;1H" if terminal._last_cursor.x else b"\x1b[1;2H"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
measure(
|
|
82
|
+
"suppressed sync snapshot",
|
|
83
|
+
terminal.snapshot,
|
|
84
|
+
args.samples,
|
|
85
|
+
prepare=lambda: terminal.feed(b"\x1b[?2026l\x1b[?2026hx"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
for chunk_size, every_chunk in ((1, True), (64, True), (4096, True), (1, False)):
|
|
89
|
+
with Terminal(COLS, ROWS) as terminal:
|
|
90
|
+
chunks = [b"x" * chunk_size] * (4096 // chunk_size)
|
|
91
|
+
|
|
92
|
+
def run(chunks=chunks, every_chunk=every_chunk) -> None:
|
|
93
|
+
for chunk in chunks:
|
|
94
|
+
terminal.feed(chunk)
|
|
95
|
+
if every_chunk:
|
|
96
|
+
terminal.snapshot()
|
|
97
|
+
if not every_chunk:
|
|
98
|
+
terminal.snapshot()
|
|
99
|
+
|
|
100
|
+
def reset() -> None:
|
|
101
|
+
terminal.hard_reset()
|
|
102
|
+
terminal.snapshot()
|
|
103
|
+
|
|
104
|
+
label = "each chunk" if every_chunk else "once"
|
|
105
|
+
measure(
|
|
106
|
+
f"4KiB feed, chunks={chunk_size}, snapshot {label}",
|
|
107
|
+
run,
|
|
108
|
+
args.fragment_samples,
|
|
109
|
+
prepare=reset,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Measure row rendering separately from native frame extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from frame_extraction import COLS, ROWS, measure, screen
|
|
8
|
+
|
|
9
|
+
from ghostty_textual.emulator import Terminal
|
|
10
|
+
from ghostty_textual.widget import TerminalView
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def send(data: bytes) -> None:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
19
|
+
parser.add_argument("--samples", type=int, default=100)
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
if args.samples < 1:
|
|
22
|
+
parser.error("sample count must be positive")
|
|
23
|
+
for workload in ("plain", "styled", "alternating", "linked"):
|
|
24
|
+
with Terminal(COLS, ROWS) as terminal:
|
|
25
|
+
terminal.feed(screen(workload))
|
|
26
|
+
view = TerminalView(send=send, terminal=terminal)
|
|
27
|
+
view._apply_frame(terminal.snapshot(force=True))
|
|
28
|
+
|
|
29
|
+
def render(view=view) -> None:
|
|
30
|
+
for y in range(ROWS):
|
|
31
|
+
view.render_line(y)
|
|
32
|
+
|
|
33
|
+
measure(workload + " render", render, args.samples)
|
|
34
|
+
view._selection_anchor = (0, 0)
|
|
35
|
+
view._selection_end = (COLS - 1, ROWS - 1)
|
|
36
|
+
measure(workload + " selected render", render, args.samples)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|