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 +8 -0
- vt100wasm-0.1.0/.github/workflows/ci.yml +27 -0
- vt100wasm-0.1.0/.github/workflows/release.yml +60 -0
- vt100wasm-0.1.0/.gitignore +7 -0
- vt100wasm-0.1.0/Cargo.lock +55 -0
- vt100wasm-0.1.0/Cargo.toml +14 -0
- vt100wasm-0.1.0/PKG-INFO +6 -0
- vt100wasm-0.1.0/README.md +30 -0
- vt100wasm-0.1.0/bin/build +6 -0
- vt100wasm-0.1.0/bin/publish +4 -0
- vt100wasm-0.1.0/bin/test +4 -0
- vt100wasm-0.1.0/flake.lock +64 -0
- vt100wasm-0.1.0/flake.nix +75 -0
- vt100wasm-0.1.0/pyproject.toml +18 -0
- vt100wasm-0.1.0/src/lib.rs +175 -0
- vt100wasm-0.1.0/src/vt100wasm/__init__.py +96 -0
- vt100wasm-0.1.0/src/vt100wasm/_vt100.wasm +0 -0
- vt100wasm-0.1.0/tests/test_vt100.py +265 -0
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,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
|
+
]
|
vt100wasm-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
vt100wasm-0.1.0/bin/test
ADDED
|
@@ -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"]
|
|
Binary file
|
|
@@ -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()
|