evp 0.0.1__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.

Potentially problematic release.


This version of evp might be problematic. Click here for more details.

Files changed (57) hide show
  1. evp-0.0.1/.cargo/config.toml +7 -0
  2. evp-0.0.1/.github/dependabot.yml +8 -0
  3. evp-0.0.1/.github/workflows/build.yml +109 -0
  4. evp-0.0.1/.github/workflows/ci.yml +55 -0
  5. evp-0.0.1/.github/workflows/docs.yml +56 -0
  6. evp-0.0.1/.github/workflows/release.yml +37 -0
  7. evp-0.0.1/.gitignore +33 -0
  8. evp-0.0.1/.pre-commit-config.yaml +31 -0
  9. evp-0.0.1/CLAUDE.md +148 -0
  10. evp-0.0.1/Cargo.lock +204 -0
  11. evp-0.0.1/Cargo.toml +27 -0
  12. evp-0.0.1/LICENSE +21 -0
  13. evp-0.0.1/PKG-INFO +62 -0
  14. evp-0.0.1/PLAN.md +468 -0
  15. evp-0.0.1/README.md +40 -0
  16. evp-0.0.1/benchmarks/README.md +61 -0
  17. evp-0.0.1/benchmarks/ab.py +94 -0
  18. evp-0.0.1/benchmarks/suite.py +278 -0
  19. evp-0.0.1/benchmarks/tips.py +206 -0
  20. evp-0.0.1/benchmarks/workloads.py +112 -0
  21. evp-0.0.1/docs/api.md +73 -0
  22. evp-0.0.1/docs/changelog.md +20 -0
  23. evp-0.0.1/docs/extra.css +83 -0
  24. evp-0.0.1/docs/index.md +76 -0
  25. evp-0.0.1/docs/maintaining.md +61 -0
  26. evp-0.0.1/docs/performance.md +56 -0
  27. evp-0.0.1/justfile +143 -0
  28. evp-0.0.1/mise.toml +15 -0
  29. evp-0.0.1/mkdocs.yml +49 -0
  30. evp-0.0.1/plans/upgrade-libghostty-vt.md +104 -0
  31. evp-0.0.1/pyproject.toml +126 -0
  32. evp-0.0.1/python/evp/__init__.py +67 -0
  33. evp-0.0.1/python/evp/_evp.pyi +750 -0
  34. evp-0.0.1/python/evp/py.typed +0 -0
  35. evp-0.0.1/spike/bench.py +167 -0
  36. evp-0.0.1/spike/counterweight-tests.sh +23 -0
  37. evp-0.0.1/spike/emulator.py +270 -0
  38. evp-0.0.1/src/encode.rs +151 -0
  39. evp-0.0.1/src/enums.rs +167 -0
  40. evp-0.0.1/src/errors.rs +20 -0
  41. evp-0.0.1/src/format.rs +128 -0
  42. evp-0.0.1/src/input.rs +231 -0
  43. evp-0.0.1/src/lib.rs +32 -0
  44. evp-0.0.1/src/render.rs +708 -0
  45. evp-0.0.1/src/style.rs +135 -0
  46. evp-0.0.1/src/terminal.rs +443 -0
  47. evp-0.0.1/tests/strategies.py +113 -0
  48. evp-0.0.1/tests/test_encode.py +85 -0
  49. evp-0.0.1/tests/test_encode_properties.py +284 -0
  50. evp-0.0.1/tests/test_format.py +59 -0
  51. evp-0.0.1/tests/test_input.py +159 -0
  52. evp-0.0.1/tests/test_render_state.py +265 -0
  53. evp-0.0.1/tests/test_render_state_properties.py +218 -0
  54. evp-0.0.1/tests/test_stub.py +62 -0
  55. evp-0.0.1/tests/test_terminal.py +241 -0
  56. evp-0.0.1/tests/test_threads.py +97 -0
  57. evp-0.0.1/uv.lock +1159 -0
@@ -0,0 +1,7 @@
1
+ [build]
2
+ # pyo3's reference pool defers decrefs of `Py<T>` values dropped while detached from the
3
+ # interpreter, and once it exists every call into evp locks its global mutex. evp never detaches,
4
+ # so it never needs the pool. Without it, a `Py<T>` dropped while detached aborts the process.
5
+ # A RUSTFLAGS environment variable replaces this list rather than extending it, so CI repeats the
6
+ # flag in its own rustflags.
7
+ rustflags = ["--cfg", "pyo3_disable_reference_pool"]
@@ -0,0 +1,8 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ day: saturday
8
+ open-pull-requests-limit: 1
@@ -0,0 +1,109 @@
1
+ name: Build
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+ workflow_call:
9
+
10
+ permissions: {}
11
+
12
+ env:
13
+ CARGO_TERM_COLOR: always
14
+
15
+ # The wheels and sdist release.yml publishes, built and tested here on every push too,
16
+ # so a release runs the build a pull request already ran.
17
+ # No caches, unlike ci.yml: each build clones Ghostty afresh,
18
+ # so nothing another run wrote can end up in a published wheel.
19
+
20
+ jobs:
21
+ wheels:
22
+ name: Wheel (${{ matrix.os }})
23
+
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ # macos-15, not macos-latest: on macOS 26, Zig 0.15.2 (Ghostty's, for libghostty-vt 0.2.2) can't link
28
+ # its own build runner against libSystem. A Ghostty that takes a newer Zig can move to macos-latest.
29
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-latest]
30
+
31
+ runs-on: ${{ matrix.os }}
32
+ timeout-minutes: 60
33
+
34
+ permissions:
35
+ contents: read
36
+
37
+ defaults:
38
+ run:
39
+ shell: bash
40
+
41
+ steps:
42
+ - name: Check out repository
43
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44
+ with:
45
+ persist-credentials: false
46
+
47
+ # Zig and Rust for the macOS and Windows builds, which run on the runner, and uv for testing the wheel.
48
+ - name: Install tools
49
+ uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
50
+ with:
51
+ version: 2026.10.12
52
+ cache: false
53
+
54
+ # On Linux this builds in maturin-action's manylinux2014 container, which mounts only the workspace,
55
+ # so the container installs its own mise. The musl build of mise is the one that runs on manylinux2014's glibc 2.17.
56
+ # Keep MISE_VERSION in step with mise-action's `version` above, and maturin-version with uv.lock's maturin.
57
+ - name: Build wheel
58
+ uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
59
+ with:
60
+ maturin-version: v1.15.0
61
+ manylinux: auto
62
+ args: --release --out dist
63
+ before-script-linux: |
64
+ curl -fsSL https://mise.run | MISE_VERSION=v2026.10.12 MISE_INSTALL_MUSL=1 sh
65
+ export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"
66
+ mise trust
67
+ mise install
68
+
69
+ # One abi3 wheel serves every supported Python, so test it against each of ci.yml's.
70
+ - name: Test wheel
71
+ run: |
72
+ for python in 3.13 3.14 3.15; do
73
+ uv sync --locked --no-install-project --python "$python"
74
+ uv pip install dist/*.whl
75
+ uv run --no-sync pytest
76
+ done
77
+
78
+ - name: Upload wheel
79
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
80
+ with:
81
+ name: wheels-${{ matrix.os }}
82
+ path: dist
83
+
84
+ sdist:
85
+ name: Sdist
86
+ runs-on: ubuntu-latest
87
+ timeout-minutes: 15
88
+
89
+ permissions:
90
+ contents: read
91
+
92
+ steps:
93
+ - name: Check out repository
94
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
95
+ with:
96
+ persist-credentials: false
97
+
98
+ - name: Build sdist
99
+ uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
100
+ with:
101
+ maturin-version: v1.15.0
102
+ command: sdist
103
+ args: --out dist
104
+
105
+ - name: Upload sdist
106
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
107
+ with:
108
+ name: sdist
109
+ path: dist
@@ -0,0 +1,55 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ env:
13
+ CARGO_TERM_COLOR: always
14
+
15
+ jobs:
16
+ ci:
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ # macos-15, not macos-latest: on macOS 26, Zig 0.15.2 (Ghostty's, for libghostty-vt 0.2.2) can't link
21
+ # its own build runner against libSystem. A Ghostty that takes a newer Zig can move to macos-latest.
22
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-latest]
23
+ python-version: ["3.13", "3.14", "3.15"]
24
+
25
+ runs-on: ${{ matrix.os }}
26
+
27
+ env:
28
+ UV_PYTHON: ${{ matrix.python-version }}
29
+ # Setting RUSTFLAGS replaces .cargo/config.toml's rustflags, so repeat them here.
30
+ RUSTFLAGS: -D warnings --cfg pyo3_disable_reference_pool
31
+
32
+ steps:
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
+ with:
35
+ persist-credentials: false
36
+
37
+ - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
38
+ with:
39
+ version: 2026.10.12
40
+
41
+ # Without the cache every job clones and builds Ghostty, which libghostty-vt-sys's
42
+ # build.rs does into target/.
43
+ - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
44
+ with:
45
+ path: |
46
+ ~/.cargo/registry
47
+ ~/.cargo/git
48
+ target
49
+ key: cargo-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ hashFiles('Cargo.lock', 'mise.toml') }}
50
+
51
+ - name: Fix
52
+ run: just pre-commit-args="--verbose --all-files --show-diff-on-failure" fix
53
+
54
+ - name: Test
55
+ run: just test
@@ -0,0 +1,56 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions: {}
9
+
10
+ concurrency:
11
+ group: pages
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
+ with:
22
+ persist-credentials: false
23
+
24
+ - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1
25
+ with:
26
+ version: 2026.10.12
27
+
28
+ - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
29
+ with:
30
+ path: |
31
+ ~/.cargo/registry
32
+ ~/.cargo/git
33
+ target
34
+ key: cargo-docs-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'mise.toml') }}
35
+
36
+ - name: Build docs
37
+ run: just docs-build
38
+
39
+ - name: Upload pages artifact
40
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
41
+ with:
42
+ path: site/
43
+
44
+ deploy:
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ permissions:
48
+ pages: write
49
+ id-token: write
50
+ environment:
51
+ name: github-pages
52
+ url: ${{ steps.deployment.outputs.page_url }}
53
+ steps:
54
+ - name: Deploy to GitHub Pages
55
+ id: deployment
56
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
@@ -0,0 +1,37 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions: {}
8
+
9
+ jobs:
10
+ build:
11
+ name: Build
12
+ uses: $/.github/workflows/build.yml
13
+ permissions:
14
+ contents: read
15
+
16
+ publish:
17
+ name: Publish to PyPI
18
+ needs: [build]
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 15
21
+
22
+ environment:
23
+ name: pypi
24
+ url: https://pypi.org/p/evp
25
+
26
+ permissions:
27
+ id-token: write # Trusted publishing: PyPI trusts this workflow and environment, so no API token exists.
28
+
29
+ steps:
30
+ - name: Download wheels and sdist
31
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
32
+ with:
33
+ path: dist
34
+ merge-multiple: true
35
+
36
+ - name: Publish to PyPI
37
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
evp-0.0.1/.gitignore ADDED
@@ -0,0 +1,33 @@
1
+ # Rust
2
+ target/
3
+ **/*.rs.bk
4
+ *.pdb
5
+ **/mutants.out*/
6
+ rustc-ice-*.txt
7
+
8
+ # Zig (libghostty-vt-sys builds Ghostty under target/, but a standalone Ghostty build writes these)
9
+ .zig-cache/
10
+ zig-cache/
11
+ zig-out/
12
+ zig-pkg/
13
+
14
+ # Python
15
+ __pycache__/
16
+ *.py[cod]
17
+ .venv/
18
+ python/evp/*.so
19
+ python/evp/*.pyd
20
+ build/
21
+ dist/
22
+ *.egg-info/
23
+ .mypy_cache/
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ .hypothesis/
27
+ .coverage
28
+ .coverage.*
29
+ htmlcov/
30
+ mutants/
31
+
32
+ # Docs
33
+ site/
@@ -0,0 +1,31 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-merge-conflict
10
+ - id: check-added-large-files
11
+
12
+ - repo: https://github.com/python-jsonschema/check-jsonschema
13
+ rev: 0.38.2
14
+ hooks:
15
+ - id: check-github-workflows
16
+ - id: check-dependabot
17
+
18
+ - repo: local
19
+ hooks:
20
+ - id: just-fmt
21
+ name: Format justfile
22
+ entry: just --fmt --unstable
23
+ language: system
24
+ files: justfile
25
+ pass_filenames: false
26
+
27
+ - id: fix
28
+ name: just fix
29
+ entry: just _fix
30
+ language: system
31
+ pass_filenames: false
evp-0.0.1/CLAUDE.md ADDED
@@ -0,0 +1,148 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ # evp
6
+
7
+ A Python binding to libghostty-vt, Ghostty's terminal emulator library, built with PyO3/maturin on
8
+ [libghostty-rs](https://github.com/Uzaaft/libghostty-rs)'s `libghostty-vt` crate.
9
+ It replaces pyghostty as [counterweight](https://github.com/JoshKarpel/counterweight)'s terminal emulator binding,
10
+ as [waxy](https://github.com/JoshKarpel/waxy) is counterweight's binding to taffy; waxy is the model for this repo's layout, tooling and habits.
11
+
12
+ ## Key Files
13
+
14
+ - `PLAN.md`: the design (why own the binding, the API's shape, language, build and release, the work in steps)
15
+ and what the spike measured. `plans/upgrade-libghostty-vt.md`: what moving past libghostty-vt 0.2.2 gains and changes.
16
+ - `python/evp/_evp.pyi`: the type stub, and the source of the API reference: mkdocstrings renders `docs/api.md` from its docstrings.
17
+ - `spike/`: `emulator.py` is counterweight's `Emulator` rebuilt on evp; `bench.py` times it against counterweight's,
18
+ and `counterweight-tests.sh` runs counterweight's `tests/test_emulator.py` against it.
19
+ Both need a Python with evp and counterweight (from a checkout) installed; their docstrings and header say how to run them.
20
+ - `docs/maintaining.md`: building, releasing, upgrading libghostty-vt (and Zig with it), adding a Python version.
21
+ - `benchmarks/`: the timing suite and build-against-build comparison for hot-path changes;
22
+ its `README.md` covers the workflow (release builds, comparing builds rather than runs, profiling).
23
+ `tips.py` measures the ratios `docs/performance.md` states; rerun it when a change could move them.
24
+ - `tests/test_*_properties.py`: Hypothesis property tests, on the VT output strategies in `tests/strategies.py`.
25
+ The render state's oracle is a twin terminal fed the same calls and read by a fresh `RenderState` each time,
26
+ so whatever `frame` reuses must read as a from-scratch read does.
27
+
28
+ ## Architecture
29
+
30
+ The Rust source (`src/`) exposes a flat PyO3 module `_evp`, which `python/evp/__init__.py` re-exports.
31
+
32
+ | File | Contents |
33
+ |------|----------|
34
+ | `src/lib.rs` | PyO3 module definition, wires each file's `register()`; `Owned`, the `Send` wrapper for libghostty objects |
35
+ | `src/errors.rs` | `GhosttyError`, and `ghostty_error` for `map_err` on every libghostty-vt `Result` |
36
+ | `src/enums.rs` | The Python `IntEnum`s and `IntFlag`s (`Key`, `Mods`, `MouseButton`, ...), built at import from libghostty's C constants |
37
+ | `src/style.rs` | `Style`, a hashable value copy of libghostty's style, with colors as `None`, a palette index or RGB |
38
+ | `src/terminal.rs` | `Terminal`: feeding bytes, resizing, modes, title, colors, viewport, its callbacks; `DeviceAttributes` |
39
+ | `src/render.rs` | `RenderState` and what it returns (`Frame`, `ScrolledOff`, `History`, `Row`) |
40
+ | `src/input.rs` | `KeyEncoder` and `MouseEncoder`, which read the terminal's modes for each event |
41
+ | `src/encode.rs` | The stateless encoders (focus, paste, color scheme, size report) and the width functions |
42
+ | `src/format.rs` | `Formatter` |
43
+
44
+ `RenderState` is where the work is. Three reads share one style table:
45
+
46
+ - **`frame()`** reads the screen. A row libghostty doesn't mark dirty is the last frame's `Row` at that position.
47
+ A dirty row's raw cells are compared with every row of the last frame (`find_unchanged`), so a row a scroll moved
48
+ comes back as the same `Row` object; consumers (counterweight's `Emulator`) reuse derived data by `Row` identity.
49
+ - **`scrolled_off()`** keeps an `Anchor`, a tracked grid reference on the row at the top of the screen at its last call.
50
+ The anchor follows that row into the scrollback, so its history position counts the rows that scrolled off since;
51
+ those rows are read through grid references (`HistoryCells`).
52
+ - **`history()`** reads the whole scrollback the same way.
53
+
54
+ `read_row` reads either source through the `Cells` trait (`RenderStateCells`, `HistoryCells`).
55
+ Styles are interned terminal-wide in `Styles`, and each of the three reads delivers the styles first used since any of them last did,
56
+ so rows name styles by index and a consumer converts each once. Index 0 is always the default style.
57
+
58
+ ## Key Design Decisions
59
+
60
+ - **libghostty-vt 0.2.2, not a git pin of `master`.** 0.2.2's Ghostty has no row ids, no bulk raw cells (`cells_raw`),
61
+ no overscan and no scrollback limit in lines, in its C API as well as the Rust wrapper. evp works around each;
62
+ `plans/upgrade-libghostty-vt.md` lists the workarounds and what upgrading changes. Don't reach for `master`-only APIs.
63
+ - **The scrollback limit is bytes.** libghostty-rs documents 0.2.2's `Options::max_scrollback` as lines, but its Ghostty
64
+ treats it as bytes, frees a page (a few hundred KB) at a time, and keeps at least a page or two above zero.
65
+ So `Terminal` takes `max_scrollback_bytes`, fixed at construction.
66
+ - **Equal raw cells don't mean an equal row**, and `find_unchanged` and `read_row`'s `style_probes` guard both cases:
67
+ a page reuses a freed style id for another style, so each style id's style is read again from the dirty row;
68
+ and a cell's codepoints after its first live outside its raw value, so a row with a multi-codepoint grapheme cluster
69
+ (`style_probes: None`) is never reused by its cells. Tests in `tests/test_render_state.py` reproduce both.
70
+ - **No mirrored row or cell iterators.** A call from Python into Rust per cell is slower than `frame()` plus a Python loop
71
+ over every cell, so the bulk reads are the only way to read cells; PLAN.md's "Why no iterators?" has the measurements.
72
+ A cell a bulk read lacks (a hyperlink) belongs on `Row`, or in a point lookup for sparse reads, not in an iterator.
73
+ - **A terminal has at most one `RenderState`.** A second would take the first's dirty flags, so `RenderState::new`
74
+ refuses it through `Terminal.is_rendered`, an `Arc<AtomicBool>` its `Drop` clears.
75
+ - **No `#[pyclass(unsendable)]`**, as in waxy. libghostty's objects hold raw pointers, so `OwnedTerminal` and `Owned<T>`
76
+ carry `unsafe impl Send + Sync`, which rests on every access to them going through a `&mut self` method
77
+ (or `Py::borrow_mut`), so PyO3's borrow checking makes each access exclusive. A `&self` method that touches them breaks that.
78
+ - **Callback exceptions** are stored in `Terminal.raised` while libghostty runs the callback, then raised when the call
79
+ (`feed`, `resize`, `reset`) returns, first one wins. A callback runs while the terminal is borrowed,
80
+ so it's passed what it needs (the new title, the clipboard's contents) rather than calling back into the terminal.
81
+ - **Enums are Python `IntEnum`s and `IntFlag`s built at import** (`src/enums.rs`) from libghostty-vt-sys's constants,
82
+ so their values have one source. Rust takes them as plain integers and converts with `enums::parse`.
83
+ `tests/test_stub.py` checks the stub's members against them, and that the stub and module name the same things.
84
+ - **`MouseEncoder` syncs from the terminal only when its mouse modes or size changed**:
85
+ libghostty's `set_options_from_terminal` and `set_size` both forget the last cell, which breaks `track_last_cell`.
86
+ - **libghostty's docs and its behavior differ**, so probe before trusting the C header or libghostty-rs's docs.
87
+ Each of these is pinned by a test: device attributes get Ghostty's own reply with no callback, though the header says none;
88
+ `set_options_from_terminal` resets the mouse encoder's `track_last_cell` state, which no doc mentions;
89
+ `Options::max_scrollback` is bytes, not lines; the formatter's `trim` applies to plain text only;
90
+ a resize truncating the alternate screen can leave a wide character with no second column;
91
+ and the C0 controls 0x1c to 0x1f and DEL print into cells.
92
+ - **Ghostty's safety checks abort a debug build, and are undefined behavior in a release one.**
93
+ A value that overflows a conversion inside Ghostty (`@intFromFloat` on a mouse position) panics the whole process,
94
+ so evp bounds what it passes (`input.rs`'s `position`). The property tests are how these turn up:
95
+ a crash kills pytest rather than failing a test, so rerun with `-s -n0` to see Ghostty's panic.
96
+ One remains upstream: resizing with the viewport in the scrollback can fail the page list's integrity check;
97
+ `test_render_state_properties.py`'s `resize` rule works around it.
98
+ - **No ENQ or XTVERSION callbacks**: libghostty-rs's types tie the reply's `&str` to the terminal's lifetime,
99
+ so a Python callable can't supply one without leaking it. Ghostty sends nothing for ENQ and `libghostty` for XTVERSION.
100
+ - **No pyo3 reference pool**: `.cargo/config.toml` sets `--cfg pyo3_disable_reference_pool`, as waxy does, and CI's
101
+ `RUSTFLAGS` replaces that list, so `ci.yml` repeats the flag; keep the two in sync.
102
+ - **`abi3`**: maturin builds one limited-API wheel for CPython 3.13 and newer (`[tool.maturin] features`); the `abi3` Cargo feature is off for plain `cargo`.
103
+ - **Ghostty's Zig is pinned.** `libghostty-vt-sys`'s build script clones the Ghostty commit it pins and runs the `zig` on `PATH`,
104
+ and Ghostty refuses any Zig but the one its `build.zig.zon` names, so `zig` in `mise.toml` follows the libghostty-vt version in `Cargo.lock`.
105
+
106
+ ## Commands
107
+
108
+ Every tool (Rust, Zig, just, uv) comes from `mise.toml`; Python dev tools come from uv's dev group. `just --list` shows the recipes.
109
+
110
+ - `just check` (alias `c`): fix, test, and a strict docs build. Run it before committing.
111
+ - `just test` (alias `t`): `maturin develop`, mypy, pytest, and `cargo test`.
112
+ - `just fix` (alias `f`): `git add --update`, then pre-commit (formatters, ruff, clippy, workflow checks).
113
+ - `just build`: `uv run maturin develop`, a debug build.
114
+ - `just bench`: a release build, then `benchmarks/suite.py`; `just bench-build` only builds.
115
+ - `just docs-build` / `just docs-serve`: the mkdocs site.
116
+
117
+ Override variables pass arguments through:
118
+
119
+ - `just pytest-args="-k scrolled_off" py-test`, or after `just build`, `uv run pytest tests/test_render_state.py::test_name`
120
+ - `just cargo-test-args="tracked_reference" rs-test`
121
+ - `just bench-args="-k frame --compare /tmp/before.json" bench`
122
+ - `just pre-commit-args="--all-files" fix`
123
+
124
+ Build and test gotchas:
125
+
126
+ - A clean build clones and compiles Ghostty (a few minutes), and needs network access.
127
+ - A debug build compiles Ghostty unoptimized too, so a test that feeds tens of thousands of lines takes a minute.
128
+ To fill the scrollback's pages quickly, use a wide terminal (1,000 columns) and fewer lines, as the pruning tests do.
129
+ - An old per-version extension (`python/evp/_evp.cpython-3XX-*.so`) left beside `_evp.abi3.so` shadows it,
130
+ so Python imports the stale build. Delete it.
131
+ - Benchmark only release builds (`just bench-build`); debug numbers say nothing about evp's speed.
132
+ - Editing `pyproject.toml` makes the next `uv run` rebuild evp through maturin's build backend, in release,
133
+ over whatever `just build` installed. `just test` rebuilds first; for anything else, `just build` again,
134
+ or run with `uv run --no-sync`.
135
+
136
+ ## Dependencies
137
+
138
+ Add Python packages with `uv add` and Rust crates with `cargo add`, not by editing `pyproject.toml` or `Cargo.toml`,
139
+ so the lockfiles stay in sync. `pyproject.toml`'s `exclude-newer` and mise's `minimum_release_age` apply a 7-day cooldown.
140
+
141
+ ## Changing the API
142
+
143
+ 1. Add or change the Rust in the right `src/*.rs` file and register new classes in its `register()`.
144
+ 2. Update `python/evp/_evp.pyi`, docstrings included, since they are the API reference, and `python/evp/__init__.py`'s exports.
145
+ 3. Add the class to `docs/api.md` if it's new, and an entry to `docs/changelog.md`.
146
+ 4. If counterweight would see the change, update `spike/emulator.py` and rerun `spike/counterweight-tests.sh`.
147
+
148
+ A new enum goes in `src/enums.rs`'s `ENUMS` and in the stub; `tests/test_stub.py` fails until the two agree.