vt100wasm 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.
vt100wasm-0.1.0/.envrc ADDED
@@ -0,0 +1,8 @@
1
+ # shellsheck shell=bash
2
+ set -euo pipefail
3
+
4
+ if ! has nix_direnv_version || ! nix_direnv_version 3.0.6; then
5
+ source_url https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=
6
+ fi
7
+ use flake .
8
+ PATH_add bin
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: DeterminateSystems/nix-installer-action@main
15
+ - uses: DeterminateSystems/magic-nix-cache-action@main
16
+
17
+ - name: Build WASM binary
18
+ run: |
19
+ nix build
20
+ cp result/_vt100.wasm src/vt100wasm/_vt100.wasm
21
+
22
+ - uses: astral-sh/setup-uv@v5
23
+
24
+ - name: Build, install, and test
25
+ run: |
26
+ uv build --wheel
27
+ uv run --with dist/*.whl --with pytest --no-project pytest tests/
@@ -0,0 +1,60 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: DeterminateSystems/nix-installer-action@main
19
+ - uses: DeterminateSystems/magic-nix-cache-action@main
20
+
21
+ - name: Build WASM binary
22
+ run: |
23
+ nix build
24
+ cp result/_vt100.wasm src/vt100wasm/_vt100.wasm
25
+
26
+ - name: Set version from tag
27
+ run: |
28
+ VERSION="${GITHUB_REF_NAME#v}"
29
+ sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
30
+
31
+ - uses: astral-sh/setup-uv@v5
32
+
33
+ - name: Build wheel and sdist
34
+ run: uv build
35
+
36
+ - name: Run tests
37
+ run: |
38
+ uv run --with dist/*.whl --with pytest --no-project pytest tests/
39
+
40
+ - uses: actions/upload-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+
45
+ publish:
46
+ needs: build
47
+ runs-on: ubuntu-latest
48
+ environment: pypi
49
+ permissions:
50
+ id-token: write
51
+ steps:
52
+ - uses: actions/download-artifact@v4
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+
57
+ - uses: astral-sh/setup-uv@v5
58
+
59
+ - name: Publish to PyPI
60
+ run: uv publish dist/*
@@ -0,0 +1,7 @@
1
+ /target
2
+ /result
3
+ /dist
4
+ __pycache__
5
+ *.egg-info
6
+ .direnv
7
+ src/vt100wasm/_vt100.wasm
@@ -0,0 +1,55 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "arrayvec"
7
+ version = "0.7.6"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
10
+
11
+ [[package]]
12
+ name = "itoa"
13
+ version = "1.0.17"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
16
+
17
+ [[package]]
18
+ name = "memchr"
19
+ version = "2.8.0"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
22
+
23
+ [[package]]
24
+ name = "unicode-width"
25
+ version = "0.2.2"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
28
+
29
+ [[package]]
30
+ name = "vt100"
31
+ version = "0.16.2"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
34
+ dependencies = [
35
+ "itoa",
36
+ "unicode-width",
37
+ "vte",
38
+ ]
39
+
40
+ [[package]]
41
+ name = "vt100wasm"
42
+ version = "0.1.0"
43
+ dependencies = [
44
+ "vt100",
45
+ ]
46
+
47
+ [[package]]
48
+ name = "vte"
49
+ version = "0.15.0"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
52
+ dependencies = [
53
+ "arrayvec",
54
+ "memchr",
55
+ ]
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "vt100wasm"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+
6
+ [lib]
7
+ crate-type = ["cdylib"]
8
+
9
+ [dependencies]
10
+ vt100 = "0.16"
11
+
12
+ [profile.release]
13
+ opt-level = "s"
14
+ lto = true
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: vt100wasm
3
+ Version: 0.1.0
4
+ Summary: Python bindings for vt100 terminal state processing via WASM
5
+ Requires-Python: <3.13,>=3.10
6
+ Requires-Dist: wasmtime>=20.0.0
@@ -0,0 +1,30 @@
1
+ # vt100wasm
2
+
3
+ Python bindings for the Rust [vt100](https://crates.io/crates/vt100) terminal emulator, compiled to WebAssembly.
4
+
5
+ Provides efficient terminal state tracking (apply deltas, extract screen contents) without native compilation -- the WASM binary is bundled in the wheel.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install vt100wasm
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from vt100wasm import apply_delta, screen_contents, screen_contents_formatted
17
+
18
+ state = apply_delta(None, b"hello world\r\n$ ", rows=24, cols=80)
19
+ print(screen_contents(state))
20
+
21
+ state2 = apply_delta(state, b"\x1b[31mred text\x1b[0m", rows=24, cols=80)
22
+ formatted = screen_contents_formatted(state2)
23
+ ```
24
+
25
+ ## Building the WASM binary
26
+
27
+ ```
28
+ cargo build --target wasm32-wasip1 --release
29
+ cp target/wasm32-wasip1/release/vt100wasm.wasm src/vt100wasm/_vt100.wasm
30
+ ```
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ nix build
5
+ cp result/_vt100.wasm src/vt100wasm/_vt100.wasm
6
+ uv build
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ uv publish dist/*
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ uv run --with dist/*.whl --with pytest --no-project pytest tests/ "$@"
@@ -0,0 +1,64 @@
1
+ {
2
+ "nodes": {
3
+ "crane": {
4
+ "locked": {
5
+ "lastModified": 1773189535,
6
+ "narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
7
+ "owner": "ipetkov",
8
+ "repo": "crane",
9
+ "rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
10
+ "type": "github"
11
+ },
12
+ "original": {
13
+ "owner": "ipetkov",
14
+ "repo": "crane",
15
+ "type": "github"
16
+ }
17
+ },
18
+ "nixpkgs": {
19
+ "locked": {
20
+ "lastModified": 1773476965,
21
+ "narHash": "sha256-Laaj25PvGeoP5SPhMfMGxvWqM6ZjZ6LdUeEhP6b3czY=",
22
+ "owner": "NixOS",
23
+ "repo": "nixpkgs",
24
+ "rev": "f82ce7af0b79ac154b12e27ed800aeb97413723c",
25
+ "type": "github"
26
+ },
27
+ "original": {
28
+ "owner": "NixOS",
29
+ "ref": "nixpkgs-unstable",
30
+ "repo": "nixpkgs",
31
+ "type": "github"
32
+ }
33
+ },
34
+ "root": {
35
+ "inputs": {
36
+ "crane": "crane",
37
+ "nixpkgs": "nixpkgs",
38
+ "rust-overlay": "rust-overlay"
39
+ }
40
+ },
41
+ "rust-overlay": {
42
+ "inputs": {
43
+ "nixpkgs": [
44
+ "nixpkgs"
45
+ ]
46
+ },
47
+ "locked": {
48
+ "lastModified": 1773457417,
49
+ "narHash": "sha256-waABTSxPdbxml4BhcabHhyQF02Qnj27qRU4ard0mTQo=",
50
+ "owner": "oxalica",
51
+ "repo": "rust-overlay",
52
+ "rev": "055977c30249484010750e03074c744dcdaa0d23",
53
+ "type": "github"
54
+ },
55
+ "original": {
56
+ "owner": "oxalica",
57
+ "repo": "rust-overlay",
58
+ "type": "github"
59
+ }
60
+ }
61
+ },
62
+ "root": "root",
63
+ "version": 7
64
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ inputs = {
3
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
4
+ crane.url = "github:ipetkov/crane";
5
+ rust-overlay = {
6
+ url = "github:oxalica/rust-overlay";
7
+ inputs.nixpkgs.follows = "nixpkgs";
8
+ };
9
+ };
10
+
11
+ outputs =
12
+ {
13
+ nixpkgs,
14
+ crane,
15
+ rust-overlay,
16
+ ...
17
+ }:
18
+ let
19
+ supportedSystems = [
20
+ "x86_64-linux"
21
+ "aarch64-linux"
22
+ "x86_64-darwin"
23
+ "aarch64-darwin"
24
+ ];
25
+ forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
26
+ pkgsFor =
27
+ system:
28
+ import nixpkgs {
29
+ inherit system;
30
+ overlays = [ rust-overlay.overlays.default ];
31
+ };
32
+ in
33
+ {
34
+ packages = forAllSystems (
35
+ system:
36
+ let
37
+ pkgs = pkgsFor system;
38
+ rustToolchain = pkgs.rust-bin.stable.latest.default.override {
39
+ targets = [ "wasm32-wasip1" ];
40
+ };
41
+ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
42
+ src = craneLib.cleanCargoSource ./.;
43
+ in
44
+ {
45
+ default = craneLib.buildPackage {
46
+ inherit src;
47
+ CARGO_BUILD_TARGET = "wasm32-wasip1";
48
+ doCheck = false;
49
+ installPhaseCommand = ''
50
+ mkdir -p $out
51
+ cp target/wasm32-wasip1/release/vt100wasm.wasm $out/_vt100.wasm
52
+ '';
53
+ };
54
+ }
55
+ );
56
+
57
+ devShells = forAllSystems (
58
+ system:
59
+ let
60
+ pkgs = pkgsFor system;
61
+ rustToolchain = pkgs.rust-bin.stable.latest.default.override {
62
+ targets = [ "wasm32-wasip1" ];
63
+ };
64
+ in
65
+ {
66
+ default = pkgs.mkShell {
67
+ packages = [
68
+ rustToolchain
69
+ pkgs.uv
70
+ ];
71
+ };
72
+ }
73
+ );
74
+ };
75
+ }
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "vt100wasm"
3
+ version = "0.1.0"
4
+ description = "Python bindings for vt100 terminal state processing via WASM"
5
+ requires-python = ">=3.10,<3.13"
6
+ dependencies = [
7
+ "wasmtime>=20.0.0",
8
+ ]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/vt100wasm"]
16
+
17
+ [tool.hatch.build]
18
+ artifacts = ["src/vt100wasm/_vt100.wasm"]
@@ -0,0 +1,175 @@
1
+ use std::alloc::{alloc, dealloc, Layout};
2
+ use std::slice;
3
+
4
+ const HEADER_LEN: usize = 5;
5
+
6
+ #[unsafe(no_mangle)]
7
+ pub extern "C" fn wasm_alloc(size: u32) -> u32 {
8
+ if size == 0 {
9
+ return 0;
10
+ }
11
+ let layout = Layout::from_size_align(size as usize, 1).unwrap();
12
+ unsafe { alloc(layout) as u32 }
13
+ }
14
+
15
+ #[unsafe(no_mangle)]
16
+ pub extern "C" fn wasm_dealloc(ptr: u32, size: u32) {
17
+ if ptr == 0 || size == 0 {
18
+ return;
19
+ }
20
+ let layout = Layout::from_size_align(size as usize, 1).unwrap();
21
+ unsafe { dealloc(ptr as *mut u8, layout) }
22
+ }
23
+
24
+ fn parse_header(state: &[u8]) -> (u16, u16, u8) {
25
+ let rows = u16::from_le_bytes([state[0], state[1]]);
26
+ let cols = u16::from_le_bytes([state[2], state[3]]);
27
+ let flags = state[4];
28
+ (rows, cols, flags)
29
+ }
30
+
31
+ fn make_header(rows: u16, cols: u16, flags: u8) -> [u8; HEADER_LEN] {
32
+ let mut hdr = [0u8; HEADER_LEN];
33
+ hdr[0..2].copy_from_slice(&rows.to_le_bytes());
34
+ hdr[2..4].copy_from_slice(&cols.to_le_bytes());
35
+ hdr[4] = flags;
36
+ hdr
37
+ }
38
+
39
+ fn restore_parser(state: &[u8], rows: u16, cols: u16) -> vt100::Parser {
40
+ let mut parser = vt100::Parser::new(rows, cols, 0);
41
+ if state.len() <= HEADER_LEN {
42
+ return parser;
43
+ }
44
+
45
+ let flags = state[4];
46
+ if flags & 1 != 0 && state.len() > HEADER_LEN + 4 {
47
+ // Alt screen was active: [header][main_len:4][main_state][alt_state]
48
+ let main_len =
49
+ u32::from_le_bytes([state[5], state[6], state[7], state[8]]) as usize;
50
+ let main_start = HEADER_LEN + 4;
51
+ let main_end = main_start + main_len;
52
+
53
+ if main_len > 0 && main_end <= state.len() {
54
+ parser.process(&state[main_start..main_end]);
55
+ }
56
+ parser.process(b"\x1b[?1049h");
57
+ if main_end < state.len() {
58
+ parser.process(&state[main_end..]);
59
+ }
60
+ } else {
61
+ parser.process(&state[HEADER_LEN..]);
62
+ }
63
+
64
+ parser
65
+ }
66
+
67
+ fn pack_result(ptr: u32, len: u32) -> u64 {
68
+ ((ptr as u64) << 32) | (len as u64)
69
+ }
70
+
71
+ fn return_bytes(data: &[u8]) -> u64 {
72
+ let len = data.len() as u32;
73
+ if len == 0 {
74
+ return 0;
75
+ }
76
+ let ptr = wasm_alloc(len);
77
+ if ptr != 0 {
78
+ unsafe {
79
+ std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as *mut u8, data.len());
80
+ }
81
+ }
82
+ pack_result(ptr, len)
83
+ }
84
+
85
+ fn serialize_state(parser: &mut vt100::Parser, rows: u16, cols: u16) -> Vec<u8> {
86
+ let is_alt = parser.screen().alternate_screen();
87
+ let flags: u8 = if is_alt { 1 } else { 0 };
88
+ let hdr = make_header(rows, cols, flags);
89
+
90
+ if is_alt {
91
+ let alt_formatted = parser.screen().state_formatted();
92
+ parser.process(b"\x1b[?1049l");
93
+ let main_formatted = parser.screen().state_formatted();
94
+ let main_len = (main_formatted.len() as u32).to_le_bytes();
95
+
96
+ let mut out =
97
+ Vec::with_capacity(HEADER_LEN + 4 + main_formatted.len() + alt_formatted.len());
98
+ out.extend_from_slice(&hdr);
99
+ out.extend_from_slice(&main_len);
100
+ out.extend_from_slice(&main_formatted);
101
+ out.extend_from_slice(&alt_formatted);
102
+ out
103
+ } else {
104
+ let formatted = parser.screen().state_formatted();
105
+ let mut out = Vec::with_capacity(HEADER_LEN + formatted.len());
106
+ out.extend_from_slice(&hdr);
107
+ out.extend_from_slice(&formatted);
108
+ out
109
+ }
110
+ }
111
+
112
+ #[unsafe(no_mangle)]
113
+ pub extern "C" fn apply_delta(
114
+ state_ptr: u32,
115
+ state_len: u32,
116
+ delta_ptr: u32,
117
+ delta_len: u32,
118
+ rows: u32,
119
+ cols: u32,
120
+ ) -> u64 {
121
+ let rows = rows as u16;
122
+ let cols = cols as u16;
123
+
124
+ let state: Option<&[u8]> = if state_ptr == 0 || (state_len as usize) < HEADER_LEN {
125
+ None
126
+ } else {
127
+ Some(unsafe { slice::from_raw_parts(state_ptr as *const u8, state_len as usize) })
128
+ };
129
+
130
+ let delta = if delta_ptr == 0 || delta_len == 0 {
131
+ &[]
132
+ } else {
133
+ unsafe { slice::from_raw_parts(delta_ptr as *const u8, delta_len as usize) }
134
+ };
135
+
136
+ let mut parser = match state {
137
+ Some(s) => {
138
+ let (prev_rows, prev_cols, _) = parse_header(s);
139
+ let mut p = restore_parser(s, prev_rows, prev_cols);
140
+ if prev_rows != rows || prev_cols != cols {
141
+ p.screen_mut().set_size(rows, cols);
142
+ }
143
+ p
144
+ }
145
+ None => vt100::Parser::new(rows, cols, 0),
146
+ };
147
+
148
+ parser.process(delta);
149
+
150
+ return_bytes(&serialize_state(&mut parser, rows, cols))
151
+ }
152
+
153
+ #[unsafe(no_mangle)]
154
+ pub extern "C" fn screen_contents(state_ptr: u32, state_len: u32) -> u64 {
155
+ if state_ptr == 0 || (state_len as usize) < HEADER_LEN {
156
+ return 0;
157
+ }
158
+ let state = unsafe { slice::from_raw_parts(state_ptr as *const u8, state_len as usize) };
159
+ let (rows, cols, _) = parse_header(state);
160
+ let parser = restore_parser(state, rows, cols);
161
+ let contents = parser.screen().contents();
162
+ return_bytes(contents.as_bytes())
163
+ }
164
+
165
+ #[unsafe(no_mangle)]
166
+ pub extern "C" fn screen_contents_formatted(state_ptr: u32, state_len: u32) -> u64 {
167
+ if state_ptr == 0 || (state_len as usize) < HEADER_LEN {
168
+ return 0;
169
+ }
170
+ let state = unsafe { slice::from_raw_parts(state_ptr as *const u8, state_len as usize) };
171
+ let (rows, cols, _) = parse_header(state);
172
+ let parser = restore_parser(state, rows, cols);
173
+ let formatted = parser.screen().contents_formatted();
174
+ return_bytes(&formatted)
175
+ }
@@ -0,0 +1,96 @@
1
+ import ctypes
2
+ from pathlib import Path
3
+
4
+ from wasmtime import Engine, Linker, Module, Store, WasiConfig
5
+
6
+ _WASM_PATH = Path(__file__).parent / "_vt100.wasm"
7
+
8
+ _engine = Engine()
9
+ _module = Module.from_file(_engine, str(_WASM_PATH))
10
+
11
+
12
+ def _unpack_result(packed: int) -> tuple[int, int]:
13
+ ptr = (packed >> 32) & 0xFFFFFFFF
14
+ length = packed & 0xFFFFFFFF
15
+ return ptr, length
16
+
17
+
18
+ class _Runtime:
19
+ def __init__(self) -> None:
20
+ self._store = Store(_engine)
21
+ config = WasiConfig()
22
+ self._store.set_wasi(config)
23
+ linker = Linker(_engine)
24
+ linker.define_wasi()
25
+ instance = linker.instantiate(self._store, _module)
26
+ exports = instance.exports(self._store)
27
+ self._memory = exports["memory"]
28
+ self._alloc_fn = exports["wasm_alloc"]
29
+ self._dealloc_fn = exports["wasm_dealloc"]
30
+ self._apply_delta_fn = exports["apply_delta"]
31
+ self._screen_contents_fn = exports["screen_contents"]
32
+ self._screen_contents_formatted_fn = exports["screen_contents_formatted"]
33
+
34
+ def _write_bytes(self, data: bytes) -> tuple[int, int]:
35
+ size = len(data)
36
+ if size == 0:
37
+ return 0, 0
38
+ ptr = self._alloc_fn(self._store, size)
39
+ if ptr == 0:
40
+ raise MemoryError(f"WASM allocation failed for {size} bytes")
41
+ self._memory.write(self._store, data, ptr)
42
+ return ptr, size
43
+
44
+ def _read_result(self, packed: int) -> bytes:
45
+ ptr, length = _unpack_result(packed)
46
+ if ptr == 0 or length == 0:
47
+ return b""
48
+ base = ctypes.addressof(self._memory.data_ptr(self._store).contents)
49
+ result = (ctypes.c_char * length).from_address(base + ptr).raw
50
+ self._dealloc_fn(self._store, ptr, length)
51
+ return result
52
+
53
+ def apply_delta(self, state: bytes | None, delta: bytes, rows: int, cols: int) -> bytes:
54
+ if state is not None:
55
+ s_ptr, s_len = self._write_bytes(state)
56
+ else:
57
+ s_ptr, s_len = 0, 0
58
+ d_ptr, d_len = self._write_bytes(delta)
59
+ packed = self._apply_delta_fn(self._store, s_ptr, s_len, d_ptr, d_len, rows, cols)
60
+ if s_ptr:
61
+ self._dealloc_fn(self._store, s_ptr, s_len)
62
+ if d_ptr:
63
+ self._dealloc_fn(self._store, d_ptr, d_len)
64
+ return self._read_result(packed)
65
+
66
+ def screen_contents(self, state: bytes) -> str:
67
+ s_ptr, s_len = self._write_bytes(state)
68
+ packed = self._screen_contents_fn(self._store, s_ptr, s_len)
69
+ if s_ptr:
70
+ self._dealloc_fn(self._store, s_ptr, s_len)
71
+ return self._read_result(packed).decode("utf-8")
72
+
73
+ def screen_contents_formatted(self, state: bytes) -> bytes:
74
+ s_ptr, s_len = self._write_bytes(state)
75
+ packed = self._screen_contents_formatted_fn(self._store, s_ptr, s_len)
76
+ if s_ptr:
77
+ self._dealloc_fn(self._store, s_ptr, s_len)
78
+ return self._read_result(packed)
79
+
80
+
81
+ _runtime = _Runtime()
82
+
83
+
84
+ def apply_delta(state: bytes | None, delta: bytes, rows: int, cols: int) -> bytes:
85
+ return _runtime.apply_delta(state, delta, rows, cols)
86
+
87
+
88
+ def screen_contents(state: bytes) -> str:
89
+ return _runtime.screen_contents(state)
90
+
91
+
92
+ def screen_contents_formatted(state: bytes) -> bytes:
93
+ return _runtime.screen_contents_formatted(state)
94
+
95
+
96
+ __all__ = ["apply_delta", "screen_contents", "screen_contents_formatted"]
@@ -0,0 +1,265 @@
1
+ from vt100wasm import apply_delta, screen_contents, screen_contents_formatted
2
+
3
+
4
+ class TestApplyDelta:
5
+ def test_initial_state_from_none(self):
6
+ state = apply_delta(None, b"hello", 24, 80)
7
+ assert isinstance(state, bytes)
8
+ assert len(state) > 5
9
+ assert screen_contents(state).startswith("hello")
10
+
11
+ def test_incremental_update(self):
12
+ s1 = apply_delta(None, b"hello", 24, 80)
13
+ s2 = apply_delta(s1, b" world", 24, 80)
14
+ assert "hello world" in screen_contents(s2)
15
+
16
+ def test_empty_delta_preserves_state(self):
17
+ s1 = apply_delta(None, b"hello", 24, 80)
18
+ s2 = apply_delta(s1, b"", 24, 80)
19
+ assert screen_contents(s1) == screen_contents(s2)
20
+
21
+ def test_newline_handling(self):
22
+ state = apply_delta(None, b"line1\r\nline2\r\nline3", 24, 80)
23
+ contents = screen_contents(state)
24
+ assert "line1" in contents
25
+ assert "line2" in contents
26
+ assert "line3" in contents
27
+
28
+ def test_ansi_escape_codes(self):
29
+ state = apply_delta(None, b"\x1b[31mred\x1b[0m normal", 24, 80)
30
+ contents = screen_contents(state)
31
+ assert "red" in contents
32
+ assert "normal" in contents
33
+
34
+ def test_header_encodes_dimensions(self):
35
+ state = apply_delta(None, b"x", 30, 120)
36
+ rows = int.from_bytes(state[0:2], "little")
37
+ cols = int.from_bytes(state[2:4], "little")
38
+ assert rows == 30
39
+ assert cols == 120
40
+
41
+ def test_utf8_content(self):
42
+ state = apply_delta(None, b"hello world", 24, 80)
43
+ contents = screen_contents(state)
44
+ assert "hello" in contents
45
+
46
+ def test_large_output(self):
47
+ data = (b"x" * 79 + b"\r\n") * 100
48
+ state = apply_delta(None, data, 24, 80)
49
+ assert isinstance(state, bytes)
50
+ assert len(state) > 4
51
+
52
+
53
+ class TestResize:
54
+ def test_resize_changes_dimensions(self):
55
+ s1 = apply_delta(None, b"hello", 24, 80)
56
+ s2 = apply_delta(s1, b"", 40, 120)
57
+ rows = int.from_bytes(s2[0:2], "little")
58
+ cols = int.from_bytes(s2[2:4], "little")
59
+ assert rows == 40
60
+ assert cols == 120
61
+
62
+ def test_resize_preserves_content(self):
63
+ s1 = apply_delta(None, b"hello", 24, 80)
64
+ s2 = apply_delta(s1, b"", 40, 120)
65
+ assert "hello" in screen_contents(s2)
66
+
67
+
68
+ class TestScreenContents:
69
+ def test_empty_state(self):
70
+ assert screen_contents(b"") == ""
71
+
72
+ def test_short_state(self):
73
+ assert screen_contents(b"\x00\x00") == ""
74
+
75
+ def test_plain_text(self):
76
+ state = apply_delta(None, b"hello world", 24, 80)
77
+ assert screen_contents(state).startswith("hello world")
78
+
79
+
80
+ class TestScreenContentsFormatted:
81
+ def test_empty_state(self):
82
+ assert screen_contents_formatted(b"") == b""
83
+
84
+ def test_short_state(self):
85
+ assert screen_contents_formatted(b"\x00\x00") == b""
86
+
87
+ def test_roundtrip(self):
88
+ state = apply_delta(None, b"hello\r\nworld", 24, 80)
89
+ formatted = screen_contents_formatted(state)
90
+ assert isinstance(formatted, bytes)
91
+ assert len(formatted) > 0
92
+ roundtrip = apply_delta(None, formatted, 24, 80)
93
+ assert screen_contents(roundtrip) == screen_contents(state)
94
+
95
+ def test_ansi_preserved_in_formatted(self):
96
+ state = apply_delta(None, b"\x1b[1mbold\x1b[0m", 24, 80)
97
+ formatted = screen_contents_formatted(state)
98
+ assert b"bold" in formatted
99
+
100
+
101
+ class TestCursorMovement:
102
+ def test_cursor_home(self):
103
+ state = apply_delta(None, b"overwrite\x1b[Hnew", 24, 80)
104
+ contents = screen_contents(state)
105
+ assert contents.startswith("newrwrite")
106
+
107
+ def test_cursor_absolute_position(self):
108
+ state = apply_delta(None, b"\x1b[3;5Hx", 24, 80)
109
+ lines = screen_contents(state).split("\n")
110
+ assert lines[2][4] == "x"
111
+
112
+ def test_cursor_up(self):
113
+ state = apply_delta(None, b"first\r\nsecond\x1b[1A\r\ninserted", 24, 80)
114
+ contents = screen_contents(state)
115
+ assert "inserted" in contents
116
+ assert "first" in contents
117
+
118
+ def test_cursor_forward_and_back(self):
119
+ # cursor back 3 from end of "abcdef" lands on 'd', forward 1 lands on 'e'
120
+ state = apply_delta(None, b"abcdef\x1b[3D\x1b[1CXYZ", 24, 80)
121
+ contents = screen_contents(state)
122
+ assert "abcdXYZ" in contents
123
+
124
+
125
+ class TestEraseOperations:
126
+ def test_erase_to_end_of_line(self):
127
+ state = apply_delta(None, b"hello world\x1b[H\x1b[5C\x1b[K", 24, 80)
128
+ contents = screen_contents(state)
129
+ assert "hello" in contents
130
+ assert "world" not in contents
131
+
132
+ def test_erase_entire_line(self):
133
+ state = apply_delta(None, b"delete me\x1b[2K", 24, 80)
134
+ first_line = screen_contents(state).split("\n")[0]
135
+ assert first_line.strip() == ""
136
+
137
+ def test_erase_display(self):
138
+ state = apply_delta(None, b"line1\r\nline2\r\nline3\x1b[2J", 24, 80)
139
+ contents = screen_contents(state)
140
+ assert contents.strip() == ""
141
+
142
+ def test_erase_to_beginning_of_line(self):
143
+ state = apply_delta(None, b"hello world\x1b[6G\x1b[1K", 24, 80)
144
+ first_line = screen_contents(state).split("\n")[0]
145
+ assert "world" in first_line
146
+ assert not first_line.startswith("hello")
147
+
148
+
149
+ class TestScrolling:
150
+ def test_scroll_down_past_bottom(self):
151
+ lines = b"".join(f"line{i}\r\n".encode() for i in range(30))
152
+ state = apply_delta(None, lines, 24, 80)
153
+ contents = screen_contents(state)
154
+ assert "line29" in contents
155
+ assert "line0" not in contents
156
+
157
+ def test_scroll_region(self):
158
+ state = apply_delta(None, b"\x1b[5;10r\x1b[10;1H\n\n\n", 24, 80)
159
+ assert isinstance(screen_contents(state), str)
160
+
161
+ def test_reverse_index_scrolls_up(self):
162
+ state = apply_delta(None, b"\x1b[1;1H\x1bM", 24, 80)
163
+ assert isinstance(screen_contents(state), str)
164
+
165
+
166
+ class TestTextAttributes:
167
+ def test_sgr_bold_in_formatted(self):
168
+ state = apply_delta(None, b"\x1b[1mbold text\x1b[0m", 24, 80)
169
+ formatted = screen_contents_formatted(state)
170
+ assert b"\x1b[1m" in formatted
171
+ assert b"bold text" in formatted
172
+
173
+ def test_sgr_colors_in_formatted(self):
174
+ state = apply_delta(None, b"\x1b[31mred\x1b[32mgreen\x1b[0m", 24, 80)
175
+ formatted = screen_contents_formatted(state)
176
+ assert b"red" in formatted
177
+ assert b"green" in formatted
178
+
179
+ def test_sgr_256_color(self):
180
+ state = apply_delta(None, b"\x1b[38;5;196mred256\x1b[0m", 24, 80)
181
+ contents = screen_contents(state)
182
+ assert "red256" in contents
183
+ formatted = screen_contents_formatted(state)
184
+ assert b"\x1b[38;5;196m" in formatted
185
+
186
+ def test_sgr_24bit_color(self):
187
+ state = apply_delta(None, b"\x1b[38;2;255;0;0mtrue red\x1b[0m", 24, 80)
188
+ contents = screen_contents(state)
189
+ assert "true red" in contents
190
+
191
+ def test_multiple_attributes(self):
192
+ state = apply_delta(None, b"\x1b[1;4;31mbold underline red\x1b[0m", 24, 80)
193
+ formatted = screen_contents_formatted(state)
194
+ assert b"bold underline red" in formatted
195
+
196
+ def test_reset_clears_attributes(self):
197
+ state = apply_delta(None, b"\x1b[1;31mcolored\x1b[0m plain", 24, 80)
198
+ contents = screen_contents(state)
199
+ assert "colored" in contents
200
+ assert "plain" in contents
201
+
202
+
203
+ class TestTabsAndSpecialChars:
204
+ def test_tab_stop(self):
205
+ state = apply_delta(None, b"a\tb", 24, 80)
206
+ contents = screen_contents(state)
207
+ assert "a" in contents
208
+ assert "b" in contents
209
+ first_line = contents.split("\n")[0]
210
+ assert first_line.index("b") >= 8
211
+
212
+ def test_backspace(self):
213
+ state = apply_delta(None, b"ab\x08c", 24, 80)
214
+ contents = screen_contents(state)
215
+ assert "ac" in contents
216
+
217
+ def test_carriage_return(self):
218
+ state = apply_delta(None, b"old text\rnew", 24, 80)
219
+ contents = screen_contents(state)
220
+ assert "new" in contents
221
+ assert not contents.startswith("old")
222
+
223
+
224
+ class TestAlternateScreen:
225
+ def test_switch_to_alt_clears(self):
226
+ state = apply_delta(None, b"main screen\x1b[?1049h", 24, 80)
227
+ contents_alt = screen_contents(state)
228
+ assert "main screen" not in contents_alt
229
+
230
+ def test_alt_screen_content(self):
231
+ s1 = apply_delta(None, b"\x1b[?1049halt content", 24, 80)
232
+ assert "alt content" in screen_contents(s1)
233
+
234
+ def test_switch_to_alt_and_back(self):
235
+ s1 = apply_delta(None, b"main screen\x1b[?1049h", 24, 80)
236
+ s2 = apply_delta(s1, b"\x1b[?1049l", 24, 80)
237
+ assert "main screen" in screen_contents(s2)
238
+
239
+ def test_alt_screen_preserves_main_across_deltas(self):
240
+ s1 = apply_delta(None, b"main content\x1b[?1049h", 24, 80)
241
+ s2 = apply_delta(s1, b"alt content", 24, 80)
242
+ assert "alt content" in screen_contents(s2)
243
+ assert "main content" not in screen_contents(s2)
244
+ s3 = apply_delta(s2, b"\x1b[?1049l", 24, 80)
245
+ assert "main content" in screen_contents(s3)
246
+
247
+ def test_alt_flag_in_header(self):
248
+ s_main = apply_delta(None, b"hello", 24, 80)
249
+ assert s_main[4] == 0
250
+ s_alt = apply_delta(None, b"\x1b[?1049h", 24, 80)
251
+ assert s_alt[4] == 1
252
+
253
+
254
+ class TestLineWrapping:
255
+ def test_auto_wrap(self):
256
+ state = apply_delta(None, b"x" * 85, 24, 80)
257
+ contents = screen_contents(state)
258
+ assert "x" * 85 in contents
259
+
260
+ def test_wrap_preserves_all_chars(self):
261
+ text = b"".join(bytes([ord("a") + (i % 26)]) for i in range(85))
262
+ state = apply_delta(None, text, 24, 80)
263
+ contents = screen_contents(state)
264
+ joined = "".join(line.rstrip() for line in contents.split("\n"))
265
+ assert joined == text.decode()