diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 252a13e..227ac4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-24.04, windows-latest] - adapter: [bubbles, huh] + adapter: [bubbles, huh, tview] defaults: run: shell: bash @@ -70,13 +70,17 @@ jobs: widgets-rust: runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + adapter: [cursive, tui-input, tui-textarea] env: CARGO_PROFILE_DEV_DEBUG: '0' CARGO_INCREMENTAL: '0' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - run: rustup toolchain install 1.99.1 --profile minimal --component rustfmt,clippy - - run: cargo +1.99.1 fmt --check --manifest-path adapters/cursive/Cargo.toml - - run: cargo +1.99.1 build --locked --manifest-path adapters/cursive/Cargo.toml - - run: cargo +1.99.1 clippy --locked --manifest-path adapters/cursive/Cargo.toml --all-targets -- -D warnings - - run: python3 interaction_check.py scenarios/cursive/*.json -- adapters/cursive/target/debug/interaction-check-cursive + - run: cargo +1.99.1 fmt --check --manifest-path adapters/${{ matrix.adapter }}/Cargo.toml + - run: cargo +1.99.1 build --locked --manifest-path adapters/${{ matrix.adapter }}/Cargo.toml + - run: cargo +1.99.1 clippy --locked --manifest-path adapters/${{ matrix.adapter }}/Cargo.toml --all-targets -- -D warnings + - run: python3 interaction_check.py scenarios/${{ matrix.adapter }}/*.json -- adapters/${{ matrix.adapter }}/target/debug/interaction-check-${{ matrix.adapter }} diff --git a/README.md b/README.md index 572d468..1e1b3c5 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,13 @@ See [reproduction instructions](https://github.com/PerchAnton/pdfcraft/blob/535d | [Bubbles](adapters/bubbles/README.md) | Real textinput update/layout, Unicode rune cursor, focus and resize | 3 / 20 steps | | [Huh](adapters/huh/README.md) | Real two-field form, validation, Tab/Shift+Tab, submit and abort | 3 / 19 steps | | [Cursive](adapters/cursive/README.md) | Real EditView, UTF-8 byte cursor, graphemes, layout and callbacks | 3 / 17 steps | +| [tview](adapters/tview/README.md) | Real InputField callbacks and tcell SimulationScreen | 3 / 15 steps | +| [tui-input](adapters/tui-input/README.md) | Native requests, grapheme editing, codepoint and display-column positions | 3 / 16 steps | +| [tui-textarea](adapters/tui-textarea/README.md) | Native selection/deletion/Undo/Redo; proposed #121 fix | 3 / 14 steps | -These are external prototypes, not accepted upstream integrations. Preliminary discussions +These are external prototypes, not accepted upstream integrations. The tui-textarea adapter +pins our proposed [fix #126](https://github.com/rhysd/tui-textarea/pull/126); all three +traces fail on upstream main and pass on the patch. It is not a claim that upstream main is fixed. Preliminary discussions are [Bubbles #1076](https://github.com/charmbracelet/bubbles/discussions/1076) and [Huh #838](https://github.com/charmbracelet/huh/discussions/838). Each adapter has its own pinned dependencies, documented boundary and replay instructions. No runtime integration diff --git a/adapters/tui-input/Cargo.lock b/adapters/tui-input/Cargo.lock new file mode 100644 index 0000000..1eef598 --- /dev/null +++ b/adapters/tui-input/Cargo.lock @@ -0,0 +1,129 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "interaction-check-tui-input" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tui-input", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tui-input" +version = "0.15.5" +source = "git+https://github.com/sayanarijit/tui-input?rev=7e615bb87db7a1611f3b00718788950defcbc2ee#7e615bb87db7a1611f3b00718788950defcbc2ee" +dependencies = [ + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/adapters/tui-input/Cargo.toml b/adapters/tui-input/Cargo.toml new file mode 100644 index 0000000..eb5c3c3 --- /dev/null +++ b/adapters/tui-input/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "interaction-check-tui-input" +version = "0.1.0" +edition = "2024" +publish = false +license = "MIT OR Apache-2.0" + +[dependencies] +tui-input = { git = "https://github.com/sayanarijit/tui-input", rev = "7e615bb87db7a1611f3b00718788950defcbc2ee", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/adapters/tui-input/README.md b/adapters/tui-input/README.md new file mode 100644 index 0000000..59afc16 --- /dev/null +++ b/adapters/tui-input/README.md @@ -0,0 +1,18 @@ +# tui-input external adapter + +Experimental adapter maintained by PerchAnton; upstream adoption is not claimed. +Source: [sayanarijit/tui-input](https://github.com/sayanarijit/tui-input/tree/7e615bb87db7a1611f3b00718788950defcbc2ee), pinned in Cargo.toml and Cargo.lock. + +Input::handle receives native requests. Cursor positions count Unicode codepoints, movements/deletions use graphemes, and visual_cursor/visual_scroll report display columns. No terminal backend is initialized. + +With Rust 1.99.1 and Python 3.10+, from the repository root: + +```sh +cargo build --locked --manifest-path adapters/tui-input/Cargo.toml +cargo clippy --locked --manifest-path adapters/tui-input/Cargo.toml --all-targets -- -D warnings +python3 interaction_check.py scenarios/tui-input/*.json -- adapters/tui-input/target/debug/interaction-check-tui-input +``` + +Use `.exe` on Windows. Three scenarios / 16 steps; every scenario starts a fresh adapter. Expected values remain in the runner, never sent to the adapter. Input records are bounded to 64 KiB. The adapter translates events and observes state; it does not reimplement the widget. + +The upstream crate is MIT licensed. This adapter is MIT OR Apache-2.0. No upstream source is copied into the adapter. diff --git a/adapters/tui-input/src/main.rs b/adapters/tui-input/src/main.rs new file mode 100644 index 0000000..8062237 --- /dev/null +++ b/adapters/tui-input/src/main.rs @@ -0,0 +1,74 @@ +use serde::Deserialize; +use serde_json::json; +use tui_input::{Input, InputRequest}; +#[path = "../../widget_protocol.rs"] +mod protocol; + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Event { + New { text: String, width: usize }, + Text { text: String }, + Key { key: String }, + Resize { width: usize }, +} + +fn main() { + let mut model: Option = None; + let mut width = 1; + let result = protocol::serve(|event| { + match event { + Event::New { text, width: w } => { + if model.is_some() { + return Err("new is only allowed once".into()); + } + if !(1..=1000).contains(&w) { + return Err("width must be 1..1000".into()); + } + model = Some(Input::new(text)); + width = w; + } + event => { + let input = model.as_mut().ok_or("first event must be new")?; + match event { + Event::Text { text } => { + for c in text.chars() { + input.handle(InputRequest::InsertChar(c)); + } + } + Event::Key { key } => { + let req = match key.as_str() { + "left" => InputRequest::GoToPrevChar, + "right" => InputRequest::GoToNextChar, + "home" => InputRequest::GoToStart, + "end" => InputRequest::GoToEnd, + "backspace" => InputRequest::DeletePrevChar, + "delete" => InputRequest::DeleteNextChar, + "ctrl+w" => InputRequest::DeletePrevWord, + "ctrl+k" => InputRequest::DeleteTillEnd, + "ctrl+y" => InputRequest::Yank, + _ => return Err(format!("unsupported key {key}")), + }; + input.handle(req); + } + Event::Resize { width: w } => { + if !(1..=1000).contains(&w) { + return Err("width must be 1..1000".into()); + } + width = w; + } + Event::New { .. } => return Err("unexpected new".into()), + } + } + } + let input = model.as_ref().ok_or("model unavailable")?; + Ok( + json!({"value":input.value(),"position_codepoints":input.cursor(), + "visual_cursor":input.visual_cursor(),"scroll_columns":input.visual_scroll(width),"width":width}), + ) + }); + if let Err(err) = result { + eprintln!("{err}"); + std::process::exit(1); + } +} diff --git a/adapters/tui-textarea/Cargo.lock b/adapters/tui-textarea/Cargo.lock new file mode 100644 index 0000000..6ae1f0a --- /dev/null +++ b/adapters/tui-textarea/Cargo.lock @@ -0,0 +1,391 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "interaction-check-tui-textarea" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tui-textarea", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "git+https://github.com/PerchAnton/tui-textarea?rev=1a0276461f31a57a74187d22f9d6513a9c539c37#1a0276461f31a57a74187d22f9d6513a9c539c37" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/adapters/tui-textarea/Cargo.toml b/adapters/tui-textarea/Cargo.toml new file mode 100644 index 0000000..25ed107 --- /dev/null +++ b/adapters/tui-textarea/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "interaction-check-tui-textarea" +version = "0.1.0" +edition = "2024" +publish = false +license = "MIT OR Apache-2.0" + +[dependencies] +tui-textarea = { git = "https://github.com/PerchAnton/tui-textarea", rev = "1a0276461f31a57a74187d22f9d6513a9c539c37", default-features = false, features = ["no-backend"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/adapters/tui-textarea/README.md b/adapters/tui-textarea/README.md new file mode 100644 index 0000000..09b11fc --- /dev/null +++ b/adapters/tui-textarea/README.md @@ -0,0 +1,18 @@ +# tui-textarea external adapter + +Experimental adapter maintained by PerchAnton; upstream adoption is not claimed. +Source: [PerchAnton/tui-textarea](https://github.com/PerchAnton/tui-textarea/tree/1a0276461f31a57a74187d22f9d6513a9c539c37), pinned in Cargo.toml and Cargo.lock. + +TextArea receives native Input events, selection and deletion operations. Cursor is [row, codepoint column]. This tests the proposed fix in [upstream PR #126](https://github.com/rhysd/tui-textarea/pull/126), not upstream main: all three traces fail on upstream 4d18622eeac13b309e0ff6a55a46ac6706da68cf and pass on the pinned fix. The host bug is reported in issue #121. No renderer, terminal or clipboard is exercised. + +With Rust 1.99.1 and Python 3.10+, from the repository root: + +```sh +cargo build --locked --manifest-path adapters/tui-textarea/Cargo.toml +cargo clippy --locked --manifest-path adapters/tui-textarea/Cargo.toml --all-targets -- -D warnings +python3 interaction_check.py scenarios/tui-textarea/*.json -- adapters/tui-textarea/target/debug/interaction-check-tui-textarea +``` + +Use `.exe` on Windows. Three scenarios / 14 steps; every scenario starts a fresh adapter. Expected values remain in the runner, never sent to the adapter. Input records are bounded to 64 KiB. The adapter translates events and observes state; it does not reimplement the widget. + +The upstream crate is MIT licensed. This adapter is MIT OR Apache-2.0. No upstream source is copied into the adapter. diff --git a/adapters/tui-textarea/src/main.rs b/adapters/tui-textarea/src/main.rs new file mode 100644 index 0000000..e2d6c1e --- /dev/null +++ b/adapters/tui-textarea/src/main.rs @@ -0,0 +1,77 @@ +use serde::Deserialize; +use serde_json::json; +use tui_textarea::{Input, Key, TextArea}; +#[path = "../../widget_protocol.rs"] +mod protocol; + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Event { + New { lines: Vec }, + Text { text: String }, + Key { key: String }, + SelectAll, + Delete { chars: Option }, +} + +fn main() { + let mut model: Option> = None; + let result = protocol::serve(|event| { + let mut changed = false; + match event { + Event::New { lines } => { + if model.is_some() { + return Err("new is only allowed once".into()); + } + model = Some(TextArea::new(lines)); + } + event => { + let area = model.as_mut().ok_or("first event must be new")?; + match event { + Event::Text { text } => { + for c in text.chars() { + changed |= area.input(Input { + key: Key::Char(c), + ..Input::default() + }); + } + } + Event::Key { key } => { + let (key, ctrl) = match key.as_str() { + "left" => (Key::Left, false), + "right" => (Key::Right, false), + "home" => (Key::Home, false), + "end" => (Key::End, false), + "up" => (Key::Up, false), + "down" => (Key::Down, false), + "backspace" => (Key::Backspace, false), + "delete" => (Key::Delete, false), + "enter" => (Key::Enter, false), + "undo" => (Key::Char('u'), true), + "redo" => (Key::Char('r'), true), + _ => return Err(format!("unsupported key {key}")), + }; + changed = area.input(Input { + key, + ctrl, + ..Input::default() + }); + } + Event::SelectAll => area.select_all(), + Event::Delete { chars } => { + changed = area.delete_str(chars.unwrap_or(usize::MAX)) + } + Event::New { .. } => return Err("unexpected new".into()), + } + } + } + let area = model.as_ref().ok_or("model unavailable")?; + Ok( + json!({"lines":area.lines(),"cursor":area.cursor(),"selection":area.selection_range(),"changed":changed}), + ) + }); + if let Err(err) = result { + eprintln!("{err}"); + std::process::exit(1); + } +} diff --git a/adapters/tview/README.md b/adapters/tview/README.md new file mode 100644 index 0000000..f7b3c64 --- /dev/null +++ b/adapters/tview/README.md @@ -0,0 +1,17 @@ +# tview external adapter + +Experimental adapter maintained by PerchAnton; no upstream adoption is claimed. tview is pinned to `c15b79fa47f27032f26647a75cb5a81720255076` by its Go pseudo-version and go.sum. + +Keys go through the real InputField.InputHandler. Acceptance, changed and done callbacks are the native callbacks. Draw uses a tcell SimulationScreen: a memory-only screen, not a user's terminal. This covers basic layout during resize but not PTY escape sequences, OS input, concurrent Application.Run or focus traversal between controls. + +From the root with Go 1.27.3 and Python 3.10+: + +```sh +mkdir -p .artifacts +(cd adapters/tview && go test -race ./... && go vet ./... && go build -o ../../.artifacts/tview-probe .) +python3 interaction_check.py scenarios/tview/*.json -- .artifacts/tview-probe +``` + +Use `.exe` on Windows. Three scenarios / 15 steps cover Unicode editing, rejected input without changed callbacks, resize and completion keys. The fixture's digits-only validator is illustrative application policy. Expected values are never sent to the adapter. Protocol tests independently reject malformed requests. + +tview/tcell are MIT and Apache-2.0 licensed respectively. This adapter is MIT OR Apache-2.0; it does not copy upstream source. diff --git a/adapters/tview/adapter.go b/adapters/tview/adapter.go new file mode 100644 index 0000000..b594cfe --- /dev/null +++ b/adapters/tview/adapter.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "strconv" + "unicode" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// adapter owns the native field and a memory-only terminal screen. +type adapter struct { + field *tview.InputField + screen tcell.SimulationScreen + changes int + done []string + width int +} + +// close releases the simulated screen without touching a user's terminal. +func (a *adapter) close() { + if a.screen != nil { + a.screen.Fini() + } +} + +// step routes keys through InputHandler and observes real changed/done callbacks. +func (a *adapter) step(e event) (any, error) { + if e.Op == "new" { + if a.field != nil { + return nil, fmt.Errorf("new is only allowed once") + } + if e.Width < 1 || e.Width > 1000 { + return nil, fmt.Errorf("width must be 1..1000") + } + a.width = e.Width + a.screen = tcell.NewSimulationScreen("UTF-8") + if err := a.screen.Init(); err != nil { + return nil, err + } + a.screen.SetSize(a.width, 3) + a.field = tview.NewInputField().SetText(e.Text) + a.done = []string{} + a.field.SetChangedFunc(func(string) { a.changes++ }) + a.field.SetDoneFunc(func(k tcell.Key) { + name := map[tcell.Key]string{tcell.KeyEnter: "enter", tcell.KeyEscape: "escape", tcell.KeyTab: "tab", tcell.KeyBacktab: "shift+tab"}[k] + if name == "" { + name = strconv.Itoa(int(k)) + } + a.done = append(a.done, name) + }) + if e.DigitsOnly { + a.field.SetAcceptanceFunc(func(value string, _ rune) bool { + for _, r := range value { + if !unicode.IsDigit(r) { + return false + } + } + return true + }) + } + a.field.Focus(func(tview.Primitive) {}) + } else { + if a.field == nil { + return nil, fmt.Errorf("first event must be new") + } + handler := a.field.InputHandler() + switch e.Op { + case "text": + for _, r := range e.Text { + handler(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone), func(tview.Primitive) {}) + } + case "key": + k, err := keyMessage(e.Key) + if err != nil { + return nil, err + } + handler(k, func(tview.Primitive) {}) + case "resize": + if e.Width < 1 || e.Width > 1000 { + return nil, fmt.Errorf("width must be 1..1000") + } + a.width = e.Width + a.screen.SetSize(a.width, 3) + default: + return nil, fmt.Errorf("unsupported op %q", e.Op) + } + } + a.field.SetRect(0, 0, a.width, 1) + a.screen.Clear() + a.field.Draw(a.screen) + a.screen.Show() + x, y, visible := a.screen.GetCursor() + return struct { + Value string `json:"value"` + Changes int `json:"changes"` + Done []string `json:"done"` + Width int `json:"width"` + CursorX int `json:"cursor_x"` + CursorY int `json:"cursor_y"` + CursorVisible bool `json:"cursor_visible"` + }{a.field.GetText(), a.changes, a.done, a.width, x, y, visible}, nil +} diff --git a/adapters/tview/go.mod b/adapters/tview/go.mod new file mode 100644 index 0000000..a747df9 --- /dev/null +++ b/adapters/tview/go.mod @@ -0,0 +1,18 @@ +module github.com/PerchAnton/interaction-check/adapters/tview + +go 1.25.0 + +require ( + github.com/gdamore/tcell/v2 v2.8.1 + github.com/rivo/tview v0.42.1-0.20260811193840-c15b79fa47f2 +) + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/adapters/tview/go.sum b/adapters/tview/go.sum new file mode 100644 index 0000000..233b814 --- /dev/null +++ b/adapters/tview/go.sum @@ -0,0 +1,81 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/tview v0.42.1-0.20260811193840-c15b79fa47f2 h1:qSwULmKSvSCCqu+t/HsZMoFSzAZpBNs8xozKV8v8Hds= +github.com/rivo/tview v0.42.1-0.20260811193840-c15b79fa47f2/go.mod h1:aSE98tht06EmmRDJGtiP567KDaJQiOWNVEpSTO55zXM= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/adapters/tview/main.go b/adapters/tview/main.go new file mode 100644 index 0000000..5a51132 --- /dev/null +++ b/adapters/tview/main.go @@ -0,0 +1,88 @@ +// This executable adapts native widget APIs to interaction-check JSONL. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "unicode/utf8" + + "github.com/gdamore/tcell/v2" +) + +type event struct { + Op string `json:"op"` + Text string `json:"text,omitempty"` + Key string `json:"key,omitempty"` + Width int `json:"width,omitempty"` + Position int `json:"position,omitempty"` + DigitsOnly bool `json:"digits_only,omitempty"` +} +type request struct { + Protocol int `json:"protocol"` + ID uint64 `json:"id"` + Event event `json:"event"` +} + +// keyMessage permits deterministic editing keys without clipboard access. +func keyMessage(name string) (*tcell.EventKey, error) { + keys := map[string]tcell.Key{ + "left": tcell.KeyLeft, "right": tcell.KeyRight, "home": tcell.KeyHome, "end": tcell.KeyEnd, + "backspace": tcell.KeyBackspace2, "delete": tcell.KeyDelete, "tab": tcell.KeyTab, + "shift+tab": tcell.KeyBacktab, "enter": tcell.KeyEnter, "escape": tcell.KeyEscape, + "ctrl+z": tcell.KeyCtrlZ, "ctrl+y": tcell.KeyCtrlY, + } + if key, ok := keys[name]; ok { + return tcell.NewEventKey(key, 0, tcell.ModNone), nil + } + return nil, fmt.Errorf("unsupported key %q", name) +} + +// run keeps protocol output separate from diagnostics and bounds each input line. +func run(in io.Reader, out io.Writer) error { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 4096), 64*1024) + encoder := json.NewEncoder(out) + var model adapter + defer model.close() + for scanner.Scan() { + if !utf8.Valid(scanner.Bytes()) { + return fmt.Errorf("request is not UTF-8") + } + var req request + decoder := json.NewDecoder(bytes.NewReader(scanner.Bytes())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return fmt.Errorf("expected one JSON object per line") + } + if req.Protocol != 1 { + return fmt.Errorf("unsupported protocol %d", req.Protocol) + } + state, err := model.step(req.Event) + if err != nil { + return fmt.Errorf("event %d: %w", req.ID, err) + } + if err := encoder.Encode(struct { + Protocol int `json:"protocol"` + ID uint64 `json:"id"` + State any `json:"state"` + }{1, req.ID, state}); err != nil { + return err + } + } + return scanner.Err() +} + +func main() { + if err := run(os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/adapters/tview/main_test.go b/adapters/tview/main_test.go new file mode 100644 index 0000000..cee551e --- /dev/null +++ b/adapters/tview/main_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRejectInvalidProtocol(t *testing.T) { + cases := []string{ + `{"protocol":2,"id":0,"event":{"op":"new","width":40}}`, + `{"protocol":1,"id":0,"event":{"op":"text","text":"x"}}`, + `{"protocol":1,"id":0,"event":{"op":"new","width":-1}}`, + `{"protocol":1,"id":0,"event":{"op":"new","width":40},"expect":{}}`, + `{"protocol":1,"id":0,"event":{"op":"new","width":40}} {}`, + strings.Repeat("x", 65537), + } + for _, line := range cases { + var output bytes.Buffer + if err := run(strings.NewReader(line), &output); err == nil { + t.Fatalf("accepted invalid request %.80s", line) + } + if output.Len() != 0 { + t.Fatalf("invalid request produced response: %s", output.String()) + } + } +} + +func TestPreserveRequestID(t *testing.T) { + var output bytes.Buffer + if err := run(strings.NewReader(`{"protocol":1,"id":123,"event":{"op":"new","width":40}}`), &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), `"id":123`) { + t.Fatalf("request ID lost: %s", output.String()) + } +} diff --git a/adapters/widget_protocol.rs b/adapters/widget_protocol.rs new file mode 100644 index 0000000..c286470 --- /dev/null +++ b/adapters/widget_protocol.rs @@ -0,0 +1,43 @@ +//! Bounded JSONL transport shared by the external Rust widget probes. +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::Value; +use std::io::{self, BufRead, Read, Write}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + protocol: u8, + id: u64, + event: E, +} + +pub fn serve( + mut step: impl FnMut(E) -> Result, +) -> Result<(), Box> { + let mut input = io::stdin().lock(); + let mut output = io::stdout().lock(); + loop { + let mut line = Vec::new(); + if Read::by_ref(&mut input) + .take(65537) + .read_until(b'\n', &mut line)? + == 0 + { + break; + } + if line.len() > 65536 { + return Err("request exceeds 64 KiB".into()); + } + let req: Request = serde_json::from_slice(&line)?; + if req.protocol != 1 { + return Err("unsupported protocol".into()); + } + let state = step(req.event)?; + serde_json::to_writer( + &mut output, + &serde_json::json!({"protocol":1,"id":req.id,"state":state}), + )?; + writeln!(output)?; + } + Ok(()) +} diff --git a/scenarios/tui-input/01-graphemes.json b/scenarios/tui-input/01-graphemes.json new file mode 100644 index 0000000..dc748f0 --- /dev/null +++ b/scenarios/tui-input/01-graphemes.json @@ -0,0 +1,68 @@ +{ + "schema": 1, + "name": "Move and delete by grapheme while reporting codepoint offsets", + "steps": [ + { + "event": { + "op": "new", + "text": "xé界", + "width": 10 + }, + "expect": { + "value": "xé界", + "position_codepoints": 4, + "visual_cursor": 4 + } + }, + { + "event": { + "op": "key", + "key": "left" + }, + "expect": { + "position_codepoints": 3, + "visual_cursor": 2 + } + }, + { + "event": { + "op": "key", + "key": "left" + }, + "expect": { + "position_codepoints": 1, + "visual_cursor": 1 + } + }, + { + "event": { + "op": "key", + "key": "delete" + }, + "expect": { + "value": "x界", + "position_codepoints": 1 + } + }, + { + "event": { + "op": "key", + "key": "end" + }, + "expect": { + "position_codepoints": 2, + "visual_cursor": 3 + } + }, + { + "event": { + "op": "key", + "key": "backspace" + }, + "expect": { + "value": "x", + "position_codepoints": 1 + } + } + ] +} diff --git a/scenarios/tui-input/02-resize.json b/scenarios/tui-input/02-resize.json new file mode 100644 index 0000000..29adb13 --- /dev/null +++ b/scenarios/tui-input/02-resize.json @@ -0,0 +1,50 @@ +{ + "schema": 1, + "name": "Wide character scrolling preserves text and logical cursor", + "steps": [ + { + "event": { + "op": "new", + "text": "a界bc", + "width": 3 + }, + "expect": { + "value": "a界bc", + "position_codepoints": 4, + "visual_cursor": 5, + "scroll_columns": 3 + } + }, + { + "event": { + "op": "resize", + "width": 6 + }, + "expect": { + "value": "a界bc", + "position_codepoints": 4, + "scroll_columns": 0 + } + }, + { + "event": { + "op": "key", + "key": "home" + }, + "expect": { + "position_codepoints": 0, + "visual_cursor": 0 + } + }, + { + "event": { + "op": "text", + "text": "!r" + }, + "expect": { + "value": "!ra界bc", + "position_codepoints": 2 + } + } + ] +} diff --git a/scenarios/tui-input/03-boundaries.json b/scenarios/tui-input/03-boundaries.json new file mode 100644 index 0000000..106aa7a --- /dev/null +++ b/scenarios/tui-input/03-boundaries.json @@ -0,0 +1,66 @@ +{ + "schema": 1, + "name": "Empty boundaries and delete to end remain stable", + "steps": [ + { + "event": { + "op": "new", + "text": "", + "width": 5 + }, + "expect": { + "value": "", + "position_codepoints": 0 + } + }, + { + "event": { + "op": "key", + "key": "delete" + }, + "expect": { + "value": "", + "position_codepoints": 0 + } + }, + { + "event": { + "op": "key", + "key": "backspace" + }, + "expect": { + "value": "", + "position_codepoints": 0 + } + }, + { + "event": { + "op": "text", + "text": "abc" + }, + "expect": { + "value": "abc", + "position_codepoints": 3 + } + }, + { + "event": { + "op": "key", + "key": "home" + }, + "expect": { + "position_codepoints": 0 + } + }, + { + "event": { + "op": "key", + "key": "ctrl+k" + }, + "expect": { + "value": "", + "position_codepoints": 0 + } + } + ] +} diff --git a/scenarios/tui-textarea/01-empty-clear.json b/scenarios/tui-textarea/01-empty-clear.json new file mode 100644 index 0000000..609f1a5 --- /dev/null +++ b/scenarios/tui-textarea/01-empty-clear.json @@ -0,0 +1,69 @@ +{ + "schema": 1, + "name": "Clearing an empty selection does not invent an undo step", + "steps": [ + { + "event": { + "op": "new", + "lines": [ + "" + ] + }, + "expect": { + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "select_all" + }, + "expect": { + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "delete", + "chars": null + }, + "expect": { + "changed": false, + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "key", + "key": "undo" + }, + "expect": { + "changed": false, + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + } + ] +} diff --git a/scenarios/tui-textarea/02-oversized-delete.json b/scenarios/tui-textarea/02-oversized-delete.json new file mode 100644 index 0000000..9d3292c --- /dev/null +++ b/scenarios/tui-textarea/02-oversized-delete.json @@ -0,0 +1,70 @@ +{ + "schema": 1, + "name": "Oversized deletion restores a valid Unicode endpoint on undo", + "steps": [ + { + "event": { + "op": "new", + "lines": [ + "ab", + "界🙂" + ] + }, + "expect": { + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "delete", + "chars": null + }, + "expect": { + "changed": true, + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "key", + "key": "undo" + }, + "expect": { + "changed": true, + "lines": [ + "ab", + "界🙂" + ], + "cursor": [ + 1, + 2 + ] + } + }, + { + "event": { + "op": "key", + "key": "redo" + }, + "expect": { + "changed": true, + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + } + ] +} diff --git a/scenarios/tui-textarea/03-redo.json b/scenarios/tui-textarea/03-redo.json new file mode 100644 index 0000000..ddb7aca --- /dev/null +++ b/scenarios/tui-textarea/03-redo.json @@ -0,0 +1,87 @@ +{ + "schema": 1, + "name": "No-op clear preserves the previous redo operation", + "steps": [ + { + "event": { + "op": "new", + "lines": [ + "" + ] + }, + "expect": { + "lines": [ + "" + ] + } + }, + { + "event": { + "op": "text", + "text": "界" + }, + "expect": { + "lines": [ + "界" + ], + "cursor": [ + 0, + 1 + ] + } + }, + { + "event": { + "op": "key", + "key": "undo" + }, + "expect": { + "lines": [ + "" + ], + "cursor": [ + 0, + 0 + ] + } + }, + { + "event": { + "op": "select_all" + }, + "expect": { + "lines": [ + "" + ] + } + }, + { + "event": { + "op": "delete", + "chars": null + }, + "expect": { + "changed": false, + "lines": [ + "" + ] + } + }, + { + "event": { + "op": "key", + "key": "redo" + }, + "expect": { + "changed": true, + "lines": [ + "界" + ], + "cursor": [ + 0, + 1 + ] + } + } + ] +} diff --git a/scenarios/tview/01-unicode.json b/scenarios/tview/01-unicode.json new file mode 100644 index 0000000..2c91f33 --- /dev/null +++ b/scenarios/tview/01-unicode.json @@ -0,0 +1,68 @@ +{ + "schema": 1, + "name": "Native InputField handles Unicode editing and completion callbacks", + "steps": [ + { + "event": { + "op": "new", + "width": 20, + "text": "a界🙂z" + }, + "expect": { + "value": "a界🙂z", + "changes": 0, + "done": [] + } + }, + { + "event": { + "op": "key", + "key": "end" + }, + "expect": { + "value": "a界🙂z" + } + }, + { + "event": { + "op": "key", + "key": "left" + }, + "expect": { + "value": "a界🙂z" + } + }, + { + "event": { + "op": "key", + "key": "backspace" + }, + "expect": { + "value": "a界z", + "changes": 1 + } + }, + { + "event": { + "op": "text", + "text": "é" + }, + "expect": { + "value": "a界éz", + "changes": 2 + } + }, + { + "event": { + "op": "key", + "key": "enter" + }, + "expect": { + "done": [ + "enter" + ], + "value": "a界éz" + } + } + ] +} diff --git a/scenarios/tview/02-validation.json b/scenarios/tview/02-validation.json new file mode 100644 index 0000000..942ffce --- /dev/null +++ b/scenarios/tview/02-validation.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "name": "Rejected input does not fire the changed callback", + "steps": [ + { + "event": { + "op": "new", + "width": 12, + "digits_only": true + }, + "expect": { + "value": "", + "changes": 0 + } + }, + { + "event": { + "op": "text", + "text": "1a2" + }, + "expect": { + "value": "12", + "changes": 2 + } + }, + { + "event": { + "op": "key", + "key": "backspace" + }, + "expect": { + "value": "1", + "changes": 3 + } + }, + { + "event": { + "op": "key", + "key": "tab" + }, + "expect": { + "done": [ + "tab" + ], + "value": "1" + } + } + ] +} diff --git a/scenarios/tview/03-resize.json b/scenarios/tview/03-resize.json new file mode 100644 index 0000000..dd92f9f --- /dev/null +++ b/scenarios/tview/03-resize.json @@ -0,0 +1,58 @@ +{ + "schema": 1, + "name": "Native redraw after resize retains text and accepts editing", + "steps": [ + { + "event": { + "op": "new", + "width": 8, + "text": "abcdefgh" + }, + "expect": { + "value": "abcdefgh", + "width": 8 + } + }, + { + "event": { + "op": "key", + "key": "end" + }, + "expect": { + "value": "abcdefgh" + } + }, + { + "event": { + "op": "resize", + "width": 4 + }, + "expect": { + "value": "abcdefgh", + "width": 4 + } + }, + { + "event": { + "op": "key", + "key": "backspace" + }, + "expect": { + "value": "abcdefg", + "changes": 1 + } + }, + { + "event": { + "op": "key", + "key": "escape" + }, + "expect": { + "done": [ + "escape" + ], + "value": "abcdefg" + } + } + ] +}