diff --git a/.gitignore b/.gitignore index 42d75df4..42e4ac07 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ Cargo.lock # Ignore GIF files in the tapes directory tapes/*.gif + +# Ignore test artifacts emitted by zsh-render-parity integration tests +zsh-render-parity/.artifacts/ diff --git a/Cargo.toml b/Cargo.toml index ad10d9ac..dc118ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,14 @@ [workspace] resolver = "2" members = [ - "event-dbg", "examples/*", "promkit", "promkit-core", "promkit-derive", "promkit-widgets", + "termharness", + "zsh-render-parity", + "zsherio", ] [workspace.dependencies] @@ -15,6 +17,7 @@ async-trait = "0.1.89" crossbeam-skiplist = "0.1.3" crossterm = { version = "0.29.0", features = ["use-dev-tty", "event-stream", "serde"] } futures = "0.3.32" +portable-pty = "0.9.0" radix_trie = "0.3.0" rayon = "1.11.0" scopeguard = "1.2.0" @@ -22,4 +25,5 @@ serde = "1.0.228" serde_json = { version = "1.0.149", features = ["preserve_order"] } termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.49.0", features = ["full"] } +thiserror = "2.0.18" unicode-width = "0.2.2" diff --git a/Concept.md b/Concept.md index 575f7fa6..164ae1eb 100644 --- a/Concept.md +++ b/Concept.md @@ -1,68 +1,64 @@ # Concept -## Well-defined boundaries for responsibilities and modularization +## Responsibility Boundaries and Data Flow -The core design principle of promkit is the clear separation of the following three functions, -each implemented in dedicated modules: +promkit is organized around three responsibilities with clear boundaries: -- **Event Handlers**: Define behaviors for keyboard inputs (such as when Enter is pressed) - - **promkit**: Responsible for implementing [Prompt](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html) trait, combining widgets and handling corresponding events - - The new async `Prompt` trait provides `initialize`, `evaluate`, and `finalize` methods for complete lifecycle management - - Event processing is now handled through a singleton `EventStream` for asynchronous event handling +1. **Event orchestration (`promkit`)** + - [`Prompt`](./promkit/src/lib.rs) defines lifecycle hooks: + `initialize -> evaluate -> finalize` + - [`Prompt::run`](./promkit/src/lib.rs) manages terminal setup/teardown + (raw mode, cursor visibility) and drives input events from a singleton + `EVENT_STREAM`. + - Events are processed sequentially. -- **State Updates**: Managing and updating the internal state of widgets - - **promkit-widgets**: Responsible for state management of various widgets and pane generation - - Each widget implements - [PaneFactory](https://docs.rs/promkit-core/0.1.1/promkit_core/trait.PaneFactory.html) - trait to generate panes needed for rendering +2. **State management and UI materialization (`promkit-widgets` + `promkit-core`)** + - Each widget state implements [`Widget`](./promkit-core/src/lib.rs). + - `Widget::create_graphemes(width, height)` returns + [`StyledGraphemes`](./promkit-core/src/grapheme.rs), which is the render-ready + text unit including style and line breaks. + - Widget states focus on state and projection only. > [!IMPORTANT] -> The widgets themselves DO NOT contain event handlers -> - This prevents key operation conflicts -> when combining multiple widgets -> - e.g. When combining a listbox and text editor, -> behavior could potentially conflict -> - navigating the list vs. recalling input history - -- **Rendering**: Processing to visually display the generated panes - - **promkit-core**: Responsible for basic terminal operations and concurrent rendering - - [SharedRenderer](https://docs.rs/promkit-core/0.2.0/promkit_core/render/type.SharedRenderer.html) (`Arc>`) provides thread-safe rendering with `SkipMap` for efficient pane management - - Components now actively trigger rendering (Push-based) rather than being rendered by the event loop - - [Terminal](https://docs.rs/promkit_core/0.1.1/terminal/struct.Terminal.html) handles rendering with `Mutex` for concurrent access - - Currently uses full rendering with plans to implement differential rendering in the future. - - [Pane](https://docs.rs/promkit_core/0.1.1/pane/struct.Pane.html) - defines the data structures for rendering - -This separation allows each component to focus on a single responsibility, -making customization and extension easier. - -### Event-Loop - -These three functions collectively form the core of "event-loop" logic. -Here is the important part of the actual event-loop from the async -[Prompt::run](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html#method.run): +> Widgets intentionally do not own event-loop policies. +> Event handling stays in presets or custom `Prompt` implementations, +> which avoids key-binding conflicts when multiple widgets are combined. + +3. **Rendering (`promkit-core`)** + - [`Renderer`](./promkit-core/src/render.rs) stores ordered grapheme chunks in + `SkipMap`. + - `update` / `remove` modify chunks by index key. + - `render` delegates drawing to [`Terminal`](./promkit-core/src/terminal.rs). + - `Terminal::draw` performs wrapping, clearing, printing, and scrolling. + +This keeps responsibilities explicit: +- prompt = control flow +- widgets = state to graphemes +- core renderer = terminal output + +## Event Loop + +Current core loop in [`Prompt::run`](./promkit/src/lib.rs): ```rust -// Initialize the prompt state self.initialize().await?; -// Start the event loop while let Some(event) = EVENT_STREAM.lock().await.next().await { match event { Ok(event) => { - // Evaluate the event and update state + // Current behavior: skip resize events in run loop. + if event.is_resize() { + continue; + } + if self.evaluate(&event).await? == Signal::Quit { break; } } - Err(e) => { - eprintln!("Error reading event: {}", e); - break; - } + Err(_) => break, } } -// Finalize the prompt and return the result self.finalize() ``` @@ -70,132 +66,104 @@ As a diagram: ```mermaid flowchart LR - Initialize[Initilaize] --> A - subgraph promkit["promkit: event-loop"] - direction LR - A[Observe user input] --> B - B[Interpret as crossterm event] --> C - - subgraph presets["promkit: presets"] - direction LR - C[Run operations corresponding to the observed events] --> D[Update state] - - subgraph widgets["promkit-widgets"] - direction LR - D[Update state] --> |if needed| Y[Generate panes] - end - - Y --> Z[Render widgets] - D --> E{Evaluate} - end - - E -->|Continue| A + Init[Initialize] --> Observe + + subgraph Runtime["promkit: Prompt::run"] + Observe[Read crossterm event] --> Eval[Prompt::evaluate] + Eval --> Continue{Signal} + Continue -->|Continue| Observe end - E -->|Quit| Finalize[Finalize] -``` + subgraph Preset["promkit presets / custom prompt"] + Eval --> UpdateState[Update widget states] + UpdateState --> Build[Widget::create_graphemes] + Build --> Push[Renderer::update] + Push --> Draw[Renderer::render] + end -In the current implementation of promkit, event handling is centralized and async. -All events are processed sequentially within the async -[Prompt::run](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html#method.run) -method and propagated to each implementation through the -[Prompt::evaluate](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html#tymethod.evaluate) method. + Draw --> Continue + Continue -->|Quit| Finalize[Finalize] +``` ## Customizability -promkit allows customization at various levels. -You can choose the appropriate customization method -according to your use case. +promkit supports customization at two levels. + +### 1. Configure existing presets -### Customize as configures +High-level presets (e.g. `Readline`) expose builder-style options such as: -Using high-level APIs, you can easily customize existing preset components. For example, in -[preset::readline::Readline](https://github.com/ynqa/promkit/blob/v0.9.1/promkit/src/preset/readline.rs), -the following customizations are possible: +- title and style +- prefix and cursor styles +- suggestion and history +- masking +- word-break characters +- validator +- text editor visible line count +- evaluator override ```rust -let mut p = Readline::default() - // Set title text - .title("Custom Title") - // Change input prefix - .prefix("$ ") - // Prefix style - .prefix_style(ContentStyle { - foreground_color: Some(Color::DarkRed), - ..Default::default() - }) - // Active character style - .active_char_style(ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }) - // Inactive character style - .inactive_char_style(ContentStyle::default()) - // Enable suggestion feature - .enable_suggest(Suggest::from_iter(["option1", "option2"])) - // Enable history feature - .enable_history() - // Input masking (for password input, etc.) - .mask('*') - // Set word break characters - .word_break_chars(HashSet::from([' ', '-'])) - // Input validation feature - .validator( - |text| text.len() > 3, - |text| format!("Please enter more than 3 characters (current: {} characters)", text.len()), - ) - // Register custom keymap - .register_keymap("custom", my_custom_keymap) - .prompt()?; +use std::collections::HashSet; + +use promkit::{ + Prompt, + core::crossterm::style::{Color, ContentStyle}, + preset::readline::Readline, + suggest::Suggest, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let result = Readline::default() + .title("Custom Title") + .prefix("$ ") + .prefix_style(ContentStyle { + foreground_color: Some(Color::DarkRed), + ..Default::default() + }) + .active_char_style(ContentStyle { + background_color: Some(Color::DarkCyan), + ..Default::default() + }) + .inactive_char_style(ContentStyle::default()) + .enable_suggest(Suggest::from_iter(["option1", "option2"])) + .enable_history() + .mask('*') + .word_break_chars(HashSet::from([' ', '-'])) + .text_editor_lines(3) + .validator( + |text| text.len() > 3, + |text| format!("Please enter more than 3 characters (current: {})", text.len()), + ) + .run() + .await?; + + println!("result: {result}"); + Ok(()) +} ``` -By combining these configuration options, you can significantly customize existing presets. - -### Advanced Customization +### 2. Build your own prompt -Lower-level customization is also possible: +For advanced use cases, combine your own state + evaluator + renderer. -1. **Creating custom widgets**: You can create your own widgets equivalent to `promkit-widgets`. -By implementing -[PaneFactory](https://docs.rs/promkit-core/0.1.1/promkit_core/trait.PaneFactory.html) -trait for your data structure, you can use it like other standard widgets. -e.g. https://github.com/ynqa/empiriqa/blob/v0.1.0/src/queue.rs +- Implement `Widget` for custom state projection +- Implement `Prompt` for lifecycle and event handling +- Use `Renderer::update(...).render().await` whenever UI should change -2. **Defining custom presets**: By combining multiple widgets and implementing your own event handlers, -you can create completely customized presets. In that case, you need to implement the async -[Prompt](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html) trait. +This is the same pattern used in [`examples/byop`](./examples/byop/src/byop.rs), +including async background updates (e.g. spinner/task monitor) that push +grapheme updates directly to the shared renderer. -This allows you to leave event-loop logic to promkit (i.e., you can execute the async -[Prompt::run](https://docs.rs/promkit/0.10.0/promkit/trait.Prompt.html#method.run)) -while implementing your own rendering logic and event handling with full async support. +## Quality Strategy for Rendering Behavior -```rust -// Example of implementing the new Prompt trait -#[async_trait::async_trait] -impl Prompt for MyCustomPrompt { - type Index = MyIndex; - type Return = MyResult; - - fn renderer(&self) -> SharedRenderer { - self.renderer.clone() - } +Ensuring consistent rendering behavior across terminal environments is a key focus. +To achieve this, promkit includes a suite of test tools: - async fn initialize(&mut self) -> anyhow::Result<()> { - // Initialize your prompt state - self.renderer.render().await - } +- [`termharness`](./termharness) +- [`zsherio`](./zsherio) +- [`zsh-render-parity`](./zsh-render-parity) - async fn evaluate(&mut self, event: &Event) -> anyhow::Result { - // Handle events and update state - match event { - // Your event handling logic - _ => Ok(Signal::Continue), - } - } - - fn finalize(&mut self) -> anyhow::Result { - // Produce final result - Ok(self.result.clone()) - } -} -``` +These tools compare prompt behavior against zsh-oriented scenarios +(e.g. wrapping, resize, and cursor movement), helping keep terminal behavior +predictable while the rendering internals evolve. diff --git a/README.md b/README.md index abd4efba..0eb1bbbd 100644 --- a/README.md +++ b/README.md @@ -11,36 +11,36 @@ Put the package in your `Cargo.toml`. ```toml [dependencies] -promkit = "0.11.0" +promkit = "0.11.1" ``` ## Features - Cross-platform support for both UNIX and Windows utilizing [crossterm](https://github.com/crossterm-rs/crossterm) - Modularized architecture - - [promkit-core](https://github.com/ynqa/promkit/tree/v0.11.0/promkit-core/) - - Core functionality for basic terminal operations and pane management - - [promkit-widgets](https://github.com/ynqa/promkit/tree/v0.11.0/promkit-widgets/) + - [promkit-core](./promkit-core/) + - Core functionality for terminal rendering and keyed grapheme chunk management + - [promkit-widgets](./promkit-widgets/) - Various UI components (text, listbox, tree, etc.) - - [promkit](https://github.com/ynqa/promkit/tree/v0.11.0/promkit) + - [promkit](./promkit/) - High-level presets and user interfaces - - [promkit-derive](https://github.com/ynqa/promkit/tree/v0.11.0/promkit-derive/) + - [promkit-derive](./promkit-derive/) - A Derive macro that simplifies interactive form input - Rich preset components - - [Readline](https://github.com/ynqa/promkit/tree/v0.11.0#readline) - Text input with auto-completion - - [Confirm](https://github.com/ynqa/promkit/tree/v0.11.0#confirm) - Yes/no confirmation prompt - - [Password](https://github.com/ynqa/promkit/tree/v0.11.0#password) - Password input with masking and validation - - [Form](https://github.com/ynqa/promkit/tree/v0.11.0#form) - Manage multiple text input fields - - [Listbox](https://github.com/ynqa/promkit/tree/v0.11.0#listbox) - Single selection interface from a list - - [QuerySelector](https://github.com/ynqa/promkit/tree/v0.11.0#queryselector) - Searchable selection interface - - [Checkbox](https://github.com/ynqa/promkit/tree/v0.11.0#checkbox) - Multiple selection checkbox interface - - [Tree](https://github.com/ynqa/promkit/tree/v0.11.0#tree) - Tree display for hierarchical data like file systems - - [JSON](https://github.com/ynqa/promkit/tree/v0.11.0#json) - Parse and interactively display JSON data - - [Text](https://github.com/ynqa/promkit/tree/v0.11.0#text) - Static text display + - [Readline](#readline) - Text input with auto-completion + - [Confirm](#confirm) - Yes/no confirmation prompt + - [Password](#password) - Password input with masking and validation + - [Form](#form) - Manage multiple text input fields + - [Listbox](#listbox) - Single selection interface from a list + - [QuerySelector](#queryselector) - Searchable selection interface + - [Checkbox](#checkbox) - Multiple selection checkbox interface + - [Tree](#tree) - Tree display for hierarchical data like file systems + - [JSON](#json) - Parse and interactively display JSON data + - [Text](#text) - Static text display ## Concept -See [here](https://github.com/ynqa/promkit/tree/v0.11.0/Concept.md). +See [here](./Concept.md). ## Projects using *promkit* @@ -63,38 +63,14 @@ that can be executed immediately below. Command ```bash -cargo run --bin readline --manifest-path examples/readline/Cargo.toml +cargo run --bin readline ``` -
-Code - -```rust,ignore -use promkit::{preset::readline::Readline, suggest::Suggest}; - -fn main() -> anyhow::Result<()> { - let mut p = Readline::default() - .title("Hi!") - .enable_suggest(Suggest::from_iter([ - "apple", - "applet", - "application", - "banana", - ])) - .validator( - |text| text.len() > 10, - |text| format!("Length must be over 10 but got {}", text.len()), - ) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/readline/src/readline.rs) - + ### Confirm @@ -102,26 +78,14 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/confirm/Cargo.toml +cargo run --bin confirm ``` -
-Code +[Code](./examples/confirm/src/confirm.rs) -```rust,ignore -use promkit::preset::confirm::Confirm; - -fn main() -> anyhow::Result<()> { - let mut p = Confirm::new("Do you have a pet?").prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
- - + ### Password @@ -129,32 +93,14 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/password/Cargo.toml +cargo run --bin password ``` -
-Code - -```rust,ignore -use promkit::preset::password::Password; - -fn main() -> anyhow::Result<()> { - let mut p = Password::default() - .title("Put your password") - .validator( - |text| 4 < text.len() && text.len() < 10, - |text| format!("Length must be over 4 and within 10 but got {}", text.len()), - ) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/password/src/password.rs) - + ### Form @@ -162,69 +108,14 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/form/Cargo.toml +cargo run --bin form ``` -
-Code - -```rust,ignore -use promkit::{ - crossterm::style::{Color, ContentStyle}, - preset::form::Form, - promkit_widgets::text_editor, -}; - -fn main() -> anyhow::Result<()> { - let mut p = Form::new([ - text_editor::State { - prefix: String::from("❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkRed), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - text_editor::State { - prefix: String::from("❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkGreen), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - text_editor::State { - prefix: String::from("❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::DarkBlue), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::DarkCyan), - ..Default::default() - }, - ..Default::default() - }, - ]) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` +[Code](./examples/form/src/form.rs) -
- - + ### Listbox @@ -232,27 +123,13 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/listbox/Cargo.toml +cargo run --bin listbox ``` -
-Code - -```rust,ignore -use promkit::preset::listbox::Listbox; - -fn main() -> anyhow::Result<()> { - let mut p = Listbox::new(0..100) - .title("What number do you like?") - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/listbox/src/listbox.rs) - + ### QuerySelector @@ -260,38 +137,13 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/query_selector/Cargo.toml +cargo run --bin query_selector ``` -
-Code - -```rust,ignore -use promkit::preset::query_selector::QuerySelector; - -fn main() -> anyhow::Result<()> { - let mut p = QuerySelector::new(0..100, |text, items| -> Vec { - text.parse::() - .map(|query| { - items - .iter() - .filter(|num| query <= num.parse::().unwrap_or_default()) - .map(|num| num.to_string()) - .collect::>() - }) - .unwrap_or(items.clone()) - }) - .title("What number do you like?") - .listbox_lines(5) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/query_selector/src/query_selector.rs) - + ### Checkbox @@ -299,39 +151,13 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/checkbox/Cargo.toml +cargo run --bin checkbox ``` -
-Code - -```rust,ignore -use promkit::preset::checkbox::Checkbox; - -fn main() -> anyhow::Result<()> { - let mut p = Checkbox::new(vec![ - "Apple", - "Banana", - "Orange", - "Mango", - "Strawberry", - "Pineapple", - "Grape", - "Watermelon", - "Kiwi", - "Pear", - ]) - .title("What are your favorite fruits?") - .checkbox_lines(5) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/checkbox/src/checkbox.rs) - + ### Tree @@ -339,28 +165,13 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/tree/Cargo.toml +cargo run --bin tree ``` -
-Code - -```rust,ignore -use promkit::{preset::tree::Tree, promkit_widgets::tree::node::Node}; - -fn main() -> anyhow::Result<()> { - let mut p = Tree::new(Node::try_from(&std::env::current_dir()?.join("src"))?) - .title("Select a directory or file") - .tree_lines(10) - .prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} -``` -
+[Code](./examples/tree/src/tree.rs) - + ### JSON @@ -368,277 +179,32 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/json/Cargo.toml +cargo run --bin json ${PATH_TO_JSON_FILE} ``` +[Code](./examples/json/src/json.rs) + + + +### Text +
-Code - -```rust,ignore -use promkit::{ - preset::json::Json, - promkit_widgets::{ - jsonstream::JsonStream, - serde_json::{self, Deserializer}, - }, -}; - -fn main() -> anyhow::Result<()> { - let stream = JsonStream::new( - Deserializer::from_str( - r#" - { - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "annotations": { - "kubeadm.kubernetes.io/etcd.advertise-client-urls": "https://172.18.0.2:2379", - "kubernetes.io/config.hash": "9c4c3ba79af7ad68d939c568f053bfff", - "kubernetes.io/config.mirror": "9c4c3ba79af7ad68d939c568f053bfff", - "kubernetes.io/config.seen": "2024-10-12T12:53:27.751706220Z", - "kubernetes.io/config.source": "file" - }, - "creationTimestamp": "2024-10-12T12:53:31Z", - "labels": { - "component": "etcd", - "tier": "control-plane" - }, - "name": "etcd-kind-control-plane", - "namespace": "kube-system", - "ownerReferences": [ - { - "apiVersion": "v1", - "controller": true, - "kind": "Node", - "name": "kind-control-plane", - "uid": "6cb2c3e5-1a73-4932-9cc5-6d69b80a9932" - } - ], - "resourceVersion": "192988", - "uid": "77465839-5a58-43b1-b754-55deed66d5ca" - }, - "spec": { - "containers": [ - { - "command": [ - "etcd", - "--advertise-client-urls=https://172.18.0.2:2379", - "--cert-file=/etc/kubernetes/pki/etcd/server.crt", - "--client-cert-auth=true", - "--data-dir=/var/lib/etcd", - "--experimental-initial-corrupt-check=true", - "--experimental-watch-progress-notify-interval=5s", - "--initial-advertise-peer-urls=https://172.18.0.2:2380", - "--initial-cluster=kind-control-plane=https://172.18.0.2:2380", - "--key-file=/etc/kubernetes/pki/etcd/server.key", - "--listen-client-urls=https://127.0.0.1:2379,https://172.18.0.2:2379", - "--listen-metrics-urls=http://127.0.0.1:2381", - "--listen-peer-urls=https://172.18.0.2:2380", - "--name=kind-control-plane", - "--peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt", - "--peer-client-cert-auth=true", - "--peer-key-file=/etc/kubernetes/pki/etcd/peer.key", - "--peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt", - "--snapshot-count=10000", - "--trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt" - ], - "image": "registry.k8s.io/etcd:3.5.15-0", - "imagePullPolicy": "IfNotPresent", - "livenessProbe": { - "failureThreshold": 8, - "httpGet": { - "host": "127.0.0.1", - "path": "/livez", - "port": 2381, - "scheme": "HTTP" - }, - "initialDelaySeconds": 10, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 15 - }, - "name": "etcd", - "readinessProbe": { - "failureThreshold": 3, - "httpGet": { - "host": "127.0.0.1", - "path": "/readyz", - "port": 2381, - "scheme": "HTTP" - }, - "periodSeconds": 1, - "successThreshold": 1, - "timeoutSeconds": 15 - }, - "resources": { - "requests": { - "cpu": "100m", - "memory": "100Mi" - } - }, - "startupProbe": { - "failureThreshold": 24, - "httpGet": { - "host": "127.0.0.1", - "path": "/readyz", - "port": 2381, - "scheme": "HTTP" - }, - "initialDelaySeconds": 10, - "periodSeconds": 10, - "successThreshold": 1, - "timeoutSeconds": 15 - }, - "terminationMessagePath": "/dev/termination-log", - "terminationMessagePolicy": "File", - "volumeMounts": [ - { - "mountPath": "/var/lib/etcd", - "name": "etcd-data" - }, - { - "mountPath": "/etc/kubernetes/pki/etcd", - "name": "etcd-certs" - } - ] - } - ], - "dnsPolicy": "ClusterFirst", - "enableServiceLinks": true, - "hostNetwork": true, - "nodeName": "kind-control-plane", - "preemptionPolicy": "PreemptLowerPriority", - "priority": 2000001000, - "priorityClassName": "system-node-critical", - "restartPolicy": "Always", - "schedulerName": "default-scheduler", - "securityContext": { - "seccompProfile": { - "type": "RuntimeDefault" - } - }, - "terminationGracePeriodSeconds": 30, - "tolerations": [ - { - "effect": "NoExecute", - "operator": "Exists" - } - ], - "volumes": [ - { - "hostPath": { - "path": "/etc/kubernetes/pki/etcd", - "type": "DirectoryOrCreate" - }, - "name": "etcd-certs" - }, - { - "hostPath": { - "path": "/var/lib/etcd", - "type": "DirectoryOrCreate" - }, - "name": "etcd-data" - } - ] - }, - "status": { - "conditions": [ - { - "lastProbeTime": null, - "lastTransitionTime": "2024-12-06T13:28:35Z", - "status": "True", - "type": "PodReadyToStartContainers" - }, - { - "lastProbeTime": null, - "lastTransitionTime": "2024-12-06T13:28:34Z", - "status": "True", - "type": "Initialized" - }, - { - "lastProbeTime": null, - "lastTransitionTime": "2024-12-06T13:28:50Z", - "status": "True", - "type": "Ready" - }, - { - "lastProbeTime": null, - "lastTransitionTime": "2024-12-06T13:28:50Z", - "status": "True", - "type": "ContainersReady" - }, - { - "lastProbeTime": null, - "lastTransitionTime": "2024-12-06T13:28:34Z", - "status": "True", - "type": "PodScheduled" - } - ], - "containerStatuses": [ - { - "containerID": "containerd://de0d57479a3ac10e213df6ea4fc1d648ad4d70d4ddf1b95a7999d0050171a41e", - "image": "registry.k8s.io/etcd:3.5.15-0", - "imageID": "sha256:27e3830e1402783674d8b594038967deea9d51f0d91b34c93c8f39d2f68af7da", - "lastState": { - "terminated": { - "containerID": "containerd://28d1a65bd9cfa40624a0c17979208f66a5cc7f496a57fa9a879907bb936f57b3", - "exitCode": 255, - "finishedAt": "2024-12-06T13:28:31Z", - "reason": "Unknown", - "startedAt": "2024-11-04T15:14:19Z" - } - }, - "name": "etcd", - "ready": true, - "restartCount": 2, - "started": true, - "state": { - "running": { - "startedAt": "2024-12-06T13:28:35Z" - } - } - } - ], - "hostIP": "172.18.0.2", - "hostIPs": [ - { - "ip": "172.18.0.2" - } - ], - "phase": "Running", - "podIP": "172.18.0.2", - "podIPs": [ - { - "ip": "172.18.0.2" - } - ], - "qosClass": "Burstable", - "startTime": "2024-12-06T13:28:34Z" - } - } - "#, - ) - .into_iter::() - .filter_map(serde_json::Result::ok) - .collect::>() - .iter(), - ); - - let mut p = Json::new(stream).title("JSON viewer").prompt()?; - println!("result: {:?}", p.run()?); - Ok(()) -} +Command + +```bash +cargo run --bin text ``` +
- +[Code](./examples/text/src/text.rs) + + ## License -This project is licensed under the MIT License. -See the [LICENSE](https://github.com/ynqa/promkit/blob/main/LICENSE) -file for details. +[MIT License](./LICENSE) ## Stargazers over time [![Stargazers over time](https://starchart.cc/ynqa/promkit.svg?variant=adaptive)](https://starchart.cc/ynqa/promkit) diff --git a/event-dbg/README.md b/event-dbg/README.md deleted file mode 100644 index b7214c6b..00000000 --- a/event-dbg/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# event-dbg - -A simple tool for debugging terminal events (keyboard, mouse, etc.). -This tool allows you to display and debug events -occurring in the terminal in real-time. - -## Features - -- Display {keyboard, mouse} events -- Show detailed event information (type, code, modifiers, etc.) -- Exit with ESC key diff --git a/event-dbg/src/main.rs b/event-dbg/src/main.rs deleted file mode 100644 index 35d3f7bb..00000000 --- a/event-dbg/src/main.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::io; - -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyEvent}, - execute, - style::Print, - terminal::{self, ClearType, disable_raw_mode, enable_raw_mode}, -}; - -fn main() -> anyhow::Result<()> { - enable_raw_mode()?; - crossterm::execute!( - io::stdout(), - cursor::Hide, - event::EnableMouseCapture, - terminal::Clear(ClearType::All), - cursor::MoveTo(0, 0), - )?; - - loop { - if let Ok(event) = event::read() { - match event { - Event::Key(KeyEvent { - code: KeyCode::Esc, .. - }) => { - break; - } - ev => { - execute!( - io::stdout(), - cursor::MoveToNextLine(1), - Print(format!("{:?}", ev)), - )?; - } - } - } - } - - disable_raw_mode()?; - execute!(io::stdout(), cursor::Show, event::DisableMouseCapture)?; - Ok(()) -} diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 0be7a633..286dd3f7 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -20,8 +20,6 @@ use promkit::{ core::{ crossterm::{self, style::Color}, grapheme::StyledGraphemes, - pane::EMPTY_PANE, - Pane, }, widgets::{ core::{ @@ -30,7 +28,7 @@ use promkit::{ style::ContentStyle, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, spinner::{self, State}, text_editor, @@ -112,16 +110,13 @@ impl TaskMonitor { Ok(input_text) => { let _ = renderer .update([ - (Index::Spinner, EMPTY_PANE.clone()), + (Index::Spinner, StyledGraphemes::default()), ( Index::Result, - Pane::new( - vec![StyledGraphemes::from(format!( - "result: {}", - input_text - ))], - 0, - ), + StyledGraphemes::from(format!( + "result: {}", + input_text + )), ), ]) .render() @@ -130,14 +125,8 @@ impl TaskMonitor { Err(_) => { let _ = renderer .update([ - (Index::Spinner, EMPTY_PANE.clone()), - ( - Index::Result, - Pane::new( - vec![StyledGraphemes::from("Task failed")], - 0, - ), - ), + (Index::Spinner, StyledGraphemes::default()), + (Index::Result, StyledGraphemes::from("Task failed")), ]) .render() .await; @@ -225,8 +214,8 @@ impl Byop { }; let renderer = SharedRenderer::new( - Renderer::try_new_with_panes( - [(Index::Readline, readline.create_pane(size.0, size.1))], + Renderer::try_new_with_graphemes( + [(Index::Readline, readline.create_graphemes(size.0, size.1))], true, ) .await?, @@ -253,7 +242,7 @@ impl Byop { // Clear previous result and show spinner self.renderer - .update([(Index::Result, EMPTY_PANE.clone())]) + .update([(Index::Result, StyledGraphemes::default())]) .render() .await?; @@ -302,10 +291,7 @@ impl Byop { self.renderer .update([( Index::Result, - Pane::new( - vec![StyledGraphemes::from("Task is currently running...")], - 0, - ), + StyledGraphemes::from("Task is currently running..."), )]) .render() .await?; @@ -427,7 +413,10 @@ impl Byop { async fn render(&mut self, width: u16, height: u16) -> anyhow::Result<()> { self.renderer - .update([(Index::Readline, self.readline.create_pane(width, height))]) + .update([( + Index::Readline, + self.readline.create_graphemes(width, height), + )]) .render() .await } diff --git a/examples/json/src/json.rs b/examples/json/src/json.rs index 09940532..8aeef25a 100644 --- a/examples/json/src/json.rs +++ b/examples/json/src/json.rs @@ -21,10 +21,6 @@ use promkit::{ struct Args { /// Optional path to a JSON file. Reads from stdin when omitted or when "-" is specified. input: Option, - - /// Title shown in the JSON viewer. - #[arg(short, long, default_value = "JSON viewer")] - title: String, } /// Read JSON input from a file or stdin based on the provided arguments. @@ -84,7 +80,7 @@ async fn main() -> anyhow::Result<()> { let stream = JsonStream::new(values.iter()); Json::new(stream) - .title(args.title) + .title("JSON Viewer") .overflow_mode(OverflowMode::Wrap) .run() .await diff --git a/examples/tree/src/tree.rs b/examples/tree/src/tree.rs index 9cae10fc..cf65ee61 100644 --- a/examples/tree/src/tree.rs +++ b/examples/tree/src/tree.rs @@ -1,8 +1,11 @@ +use std::path::Path; + use promkit::{preset::tree::Tree, widgets::tree::node::Node, Prompt}; #[tokio::main] async fn main() -> anyhow::Result<()> { - let ret = Tree::new(Node::try_from(&std::env::current_dir()?.join("src"))?) + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../promkit/src"); + let ret = Tree::new(Node::try_from(&root)?) .title("Select a directory or file") .tree_lines(10) .run() diff --git a/promkit-core/src/grapheme.rs b/promkit-core/src/grapheme.rs index 2c08029b..7da61f5a 100644 --- a/promkit-core/src/grapheme.rs +++ b/promkit-core/src/grapheme.rs @@ -1,8 +1,4 @@ -use std::{ - collections::VecDeque, - fmt, - ops::{Deref, DerefMut}, -}; +use std::{collections::VecDeque, fmt}; use crossterm::style::{Attribute, ContentStyle}; use unicode_width::UnicodeWidthChar; @@ -61,19 +57,6 @@ impl StyledGrapheme { #[derive(Clone, Default, PartialEq, Eq)] pub struct StyledGraphemes(pub VecDeque); -impl Deref for StyledGraphemes { - type Target = VecDeque; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for StyledGraphemes { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - impl FromIterator for StyledGraphemes { fn from_iter>(iter: I) -> Self { let concatenated = iter @@ -120,6 +103,7 @@ impl fmt::Debug for StyledGraphemes { } impl StyledGraphemes { + /// Creates styled graphemes from a string with a uniform style. pub fn from_str>(string: S, style: ContentStyle) -> Self { string .as_ref() @@ -128,6 +112,37 @@ impl StyledGraphemes { .collect() } + /// Concatenates rows and inserts `\n` between rows. + pub fn from_lines(lines: I) -> Self + where + I: IntoIterator, + { + let mut merged = StyledGraphemes::default(); + let mut lines = lines.into_iter().peekable(); + + while let Some(mut line) = lines.next() { + merged.append(&mut line); + + if lines.peek().is_some() { + merged.push_back(StyledGrapheme::from('\n')); + } + } + + merged + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + /// Returns a `Vec` containing the characters of all `Grapheme` instances in the collection. pub fn chars(&self) -> Vec { self.0.iter().map(|grapheme| grapheme.ch).collect() @@ -138,41 +153,42 @@ impl StyledGraphemes { self.0.iter().map(|grapheme| grapheme.width).sum() } - /// Replaces all occurrences of a substring `from` with another substring `to` within the `StyledGraphemes`. - pub fn replace>(mut self, from: S, to: S) -> Self { - let from_len = from.as_ref().chars().count(); - let to_len = to.as_ref().chars().count(); + /// Returns a displayable format of the styled graphemes. + pub fn styled_display(&self) -> StyledGraphemesDisplay<'_> { + StyledGraphemesDisplay { + styled_graphemes: self, + } + } - let mut offset = 0; - let diff = from_len.abs_diff(to_len); + pub fn get_mut(&mut self, idx: usize) -> Option<&mut StyledGrapheme> { + self.0.get_mut(idx) + } - let pos = self.find_all(from); + pub fn push_back(&mut self, grapheme: StyledGrapheme) { + self.0.push_back(grapheme); + } - for p in pos { - let adjusted_pos = if to_len > from_len { - p + offset - } else { - p.saturating_sub(offset) - }; - self.replace_range(adjusted_pos..adjusted_pos + from_len, &to); - offset += diff; - } + pub fn pop_back(&mut self) -> Option { + self.0.pop_back() + } - self + pub fn append(&mut self, other: &mut Self) { + self.0.append(&mut other.0); } - /// Replaces the specified range with the given string. - pub fn replace_range>(&mut self, range: std::ops::Range, replacement: S) { - // Remove the specified range. - for _ in range.clone() { - self.0.remove(range.start); - } + pub fn insert(&mut self, idx: usize, grapheme: StyledGrapheme) { + self.0.insert(idx, grapheme); + } - // Insert the replacement at the start of the range. - let replacement_graphemes: StyledGraphemes = replacement.as_ref().into(); - for grapheme in replacement_graphemes.0.iter().rev() { - self.0.insert(range.start, grapheme.clone()); - } + pub fn remove(&mut self, idx: usize) -> Option { + self.0.remove(idx) + } + + pub fn drain( + &mut self, + range: std::ops::Range, + ) -> std::collections::vec_deque::Drain<'_, StyledGrapheme> { + self.0.drain(range) } /// Applies a given style to all `StyledGrapheme` instances within the collection. @@ -191,6 +207,14 @@ impl StyledGraphemes { self } + /// Applies a given attribute to all `StyledGrapheme` instances within the collection. + pub fn apply_attribute(mut self, attr: Attribute) -> Self { + for styled_grapheme in &mut self.0 { + styled_grapheme.style.attributes.set(attr); + } + self + } + /// Finds all occurrences of a query string within the StyledGraphemes and returns their start indices. pub fn find_all>(&self, query: S) -> Vec { let query_str = query.as_ref(); @@ -256,65 +280,84 @@ impl StyledGraphemes { Some(self) } - /// Applies a given attribute to all `StyledGrapheme` instances within the collection. - pub fn apply_attribute(mut self, attr: Attribute) -> Self { - for styled_grapheme in &mut self.0 { - styled_grapheme.style.attributes.set(attr); + /// Replaces all occurrences of a substring `from` with another substring `to` within the `StyledGraphemes`. + pub fn replace>(mut self, from: S, to: S) -> Self { + let from_len = from.as_ref().chars().count(); + let to_len = to.as_ref().chars().count(); + + let mut offset = 0; + let diff = from_len.abs_diff(to_len); + + let pos = self.find_all(from); + + for p in pos { + let adjusted_pos = if to_len > from_len { + p + offset + } else { + p.saturating_sub(offset) + }; + self.replace_range(adjusted_pos..adjusted_pos + from_len, &to); + offset += diff; } + self } - /// Returns a displayable format of the styled graphemes. - pub fn styled_display(&self) -> StyledGraphemesDisplay<'_> { - StyledGraphemesDisplay { - styled_graphemes: self, + /// Replaces the specified range with the given string. + pub fn replace_range>(&mut self, range: std::ops::Range, replacement: S) { + // Remove the specified range. + for _ in range.clone() { + self.0.remove(range.start); + } + + // Insert the replacement at the start of the range. + let replacement_graphemes: StyledGraphemes = replacement.as_ref().into(); + for grapheme in replacement_graphemes.0.iter().rev() { + self.0.insert(range.start, grapheme.clone()); } } - /// Organizes the `StyledGraphemes` into a matrix format based on specified width and height, - /// considering an offset for pagination or scrolling. - pub fn matrixify( - &self, - width: usize, - height: usize, - offset: usize, - ) -> (Vec, usize) { - let mut all = VecDeque::new(); + /// Splits graphemes into display rows by newline and terminal width. + pub fn wrapped_lines(&self, width: usize) -> Vec { + if width == 0 { + return vec![]; + } + + let mut rows = Vec::new(); let mut row = StyledGraphemes::default(); + let mut row_width = 0; + let mut last_was_newline = false; + for styled in self.iter() { - let width_with_next_char = row.iter().fold(0, |mut layout, g| { - layout += g.width; - layout - }) + styled.width; - if !row.is_empty() && width < width_with_next_char { - all.push_back(row); + if styled.ch == '\n' { + rows.push(row); row = StyledGraphemes::default(); + row_width = 0; + last_was_newline = true; + continue; } - if width >= styled.width { - row.push_back(styled.clone()); - } - } - if !row.is_empty() { - all.push_back(row); - } - if all.is_empty() { - return (vec![], 0); - } + last_was_newline = false; - let mut offset = std::cmp::min(offset, all.len().saturating_sub(1)); + if styled.width > width { + continue; + } - // Adjust the start and end rows based on the offset and height - while all.len() > height && offset < all.len() { - if offset > 0 { - all.pop_front(); - offset -= 1; - } else { - all.pop_back(); + if !row.is_empty() && row_width + styled.width > width { + rows.push(row); + row = StyledGraphemes::default(); + row_width = 0; } + + row.push_back(styled.clone()); + row_width += styled.width; + } + + if !row.is_empty() || last_was_newline { + rows.push(row); } - (Vec::from(all), offset) + rows } } @@ -347,6 +390,25 @@ mod test { } } + mod from_lines { + use super::*; + + #[test] + fn test_empty() { + let g = StyledGraphemes::from_lines(Vec::new()); + assert!(g.is_empty()); + } + + #[test] + fn test_join() { + let g = StyledGraphemes::from_lines(vec![ + StyledGraphemes::from("abc"), + StyledGraphemes::from("def"), + ]); + assert_eq!("abc\ndef", g.to_string()); + } + } + mod chars { use super::*; @@ -368,42 +430,14 @@ mod test { } } - mod replace_char { - use super::*; - - #[test] - fn test() { - let graphemes = StyledGraphemes::from("banana"); - assert_eq!("bonono", graphemes.replace("a", "o").to_string()); - } - - #[test] - fn test_with_nonexistent_character() { - let graphemes = StyledGraphemes::from("Hello World"); - assert_eq!("Hello World", graphemes.replace("x", "o").to_string()); - } - - #[test] - fn test_with_empty_string() { - let graphemes = StyledGraphemes::from("Hello World"); - assert_eq!("Hell Wrld", graphemes.replace("o", "").to_string()); - } - - #[test] - fn test_with_multiple_characters() { - let graphemes = StyledGraphemes::from("Hello World"); - assert_eq!("Hellabc Wabcrld", graphemes.replace("o", "abc").to_string()); - } - } - - mod replace_range { + mod styled_display { use super::*; #[test] fn test() { - let mut graphemes = StyledGraphemes::from("Hello"); - graphemes.replace_range(1..5, "i"); - assert_eq!("Hi", graphemes.to_string()); + let graphemes = StyledGraphemes::from("abc"); + let display = graphemes.styled_display(); + assert_eq!(format!("{}", display), "abc"); // Assuming default styles do not alter appearance } } @@ -454,6 +488,21 @@ mod test { } } + mod apply_attribute { + use super::*; + + #[test] + fn test() { + let mut graphemes = StyledGraphemes::from("abc"); + graphemes = graphemes.apply_attribute(Attribute::Bold); + assert!( + graphemes + .iter() + .all(|g| g.style.attributes.has(Attribute::Bold)) + ); + } + } + mod find_all { use super::*; @@ -541,93 +590,80 @@ mod test { } } - mod apply_attribute { + mod replace { use super::*; #[test] fn test() { - let mut graphemes = StyledGraphemes::from("abc"); - graphemes = graphemes.apply_attribute(Attribute::Bold); - assert!( - graphemes - .iter() - .all(|g| g.style.attributes.has(Attribute::Bold)) - ); + let graphemes = StyledGraphemes::from("banana"); + assert_eq!("bonono", graphemes.replace("a", "o").to_string()); } - } - mod styled_display { - use super::*; + #[test] + fn test_with_nonexistent_character() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hello World", graphemes.replace("x", "o").to_string()); + } #[test] - fn test() { - let graphemes = StyledGraphemes::from("abc"); - let display = graphemes.styled_display(); - assert_eq!(format!("{}", display), "abc"); // Assuming default styles do not alter appearance + fn test_with_empty_string() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hell Wrld", graphemes.replace("o", "").to_string()); + } + + #[test] + fn test_with_multiple_characters() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hellabc Wabcrld", graphemes.replace("o", "abc").to_string()); } } - #[cfg(test)] - mod matrixify { + mod replace_range { use super::*; #[test] - fn test_with_empty_input() { - let input = StyledGraphemes::default(); - let (matrix, offset) = input.matrixify(10, 2, 0); - assert_eq!(matrix.len(), 0); - assert_eq!(offset, 0); + fn test() { + let mut graphemes = StyledGraphemes::from("Hello"); + graphemes.replace_range(1..5, "i"); + assert_eq!("Hi", graphemes.to_string()); } + } - #[test] - fn test_with_exact_width_fit() { - let input = StyledGraphemes::from("1234567890"); - let (matrix, offset) = input.matrixify(10, 1, 0); - assert_eq!(matrix.len(), 1); - assert_eq!("1234567890", matrix[0].to_string()); - assert_eq!(offset, 0); - } + mod wrapped_lines { + use super::*; #[test] - fn test_with_narrow_width() { - let input = StyledGraphemes::from("1234567890"); - let (matrix, offset) = input.matrixify(5, 2, 0); - assert_eq!(matrix.len(), 2); - assert_eq!("12345", matrix[0].to_string()); - assert_eq!("67890", matrix[1].to_string()); - assert_eq!(offset, 0); + fn test_empty() { + let input = StyledGraphemes::default(); + let rows = input.wrapped_lines(10); + assert_eq!(rows.len(), 0); } #[test] - fn test_with_offset() { - let input = StyledGraphemes::from("1234567890"); - let (matrix, offset) = input.matrixify(2, 2, 1); - assert_eq!(matrix.len(), 2); - assert_eq!("34", matrix[0].to_string()); - assert_eq!("56", matrix[1].to_string()); - assert_eq!(offset, 0); + fn test_wrap_by_width() { + let input = StyledGraphemes::from("123456"); + let rows = input.wrapped_lines(3); + assert_eq!(rows.len(), 2); + assert_eq!("123", rows[0].to_string()); + assert_eq!("456", rows[1].to_string()); } #[test] - fn test_with_padding() { - let input = StyledGraphemes::from("1234567890"); - let (matrix, offset) = input.matrixify(2, 100, 1); - assert_eq!(matrix.len(), 5); - assert_eq!("12", matrix[0].to_string()); - assert_eq!("34", matrix[1].to_string()); - assert_eq!("56", matrix[2].to_string()); - assert_eq!("78", matrix[3].to_string()); - assert_eq!("90", matrix[4].to_string()); - assert_eq!(offset, 1); + fn test_split_by_newline() { + let input = StyledGraphemes::from("ab\ncd"); + let rows = input.wrapped_lines(10); + assert_eq!(rows.len(), 2); + assert_eq!("ab", rows[0].to_string()); + assert_eq!("cd", rows[1].to_string()); } #[test] - fn test_with_large_offset() { - let input = StyledGraphemes::from("1234567890"); - let (matrix, offset) = input.matrixify(10, 2, 100); // Offset beyond content - assert_eq!(matrix.len(), 1); - assert_eq!("1234567890", matrix[0].to_string()); - assert_eq!(offset, 0); + fn test_trailing_newline() { + let input = StyledGraphemes::from("ab\n"); + let rows = input.wrapped_lines(10); + assert_eq!(rows.len(), 2); + assert_eq!("ab", rows[0].to_string()); + assert_eq!("", rows[1].to_string()); } } } diff --git a/promkit-core/src/lib.rs b/promkit-core/src/lib.rs index 83548ca5..8d4b356f 100644 --- a/promkit-core/src/lib.rs +++ b/promkit-core/src/lib.rs @@ -1,13 +1,11 @@ pub use crossterm; pub mod grapheme; -pub mod pane; -pub use pane::Pane; -// TODO: reconciliation (detecting differences between old and new panes) +// TODO: reconciliation (detecting differences between old and new grapheme trees) pub mod render; pub mod terminal; -pub trait PaneFactory { - /// Creates pane with the given width. - fn create_pane(&self, width: u16, height: u16) -> Pane; +pub trait Widget { + /// Creates styled graphemes with the given width and height. + fn create_graphemes(&self, width: u16, height: u16) -> grapheme::StyledGraphemes; } diff --git a/promkit-core/src/pane.rs b/promkit-core/src/pane.rs deleted file mode 100644 index a8f85b57..00000000 --- a/promkit-core/src/pane.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::sync::LazyLock; - -use crate::grapheme::StyledGraphemes; - -pub static EMPTY_PANE: LazyLock = LazyLock::new(|| Pane::new(vec![], 0)); - -#[derive(Clone)] -pub struct Pane { - /// The layout of graphemes within the pane. - /// This vector stores the styled graphemes that make up the content of the pane. - layout: Vec, - /// The offset from the top of the pane, used when extracting graphemes to display. - /// This value determines the starting point for grapheme extraction, allowing for scrolling behavior. - offset: usize, -} - -impl Pane { - /// Constructs a new `Pane` with the specified layout, offset, and optional fixed height. - /// - `layout`: A vector of `StyledGraphemes` representing the content of the pane. - /// - `offset`: The initial offset from the top of the pane. - pub fn new(layout: Vec, offset: usize) -> Self { - Pane { layout, offset } - } - - pub fn visible_row_count(&self) -> usize { - self.layout.len() - } - - /// Checks if the pane is empty. - pub fn is_empty(&self) -> bool { - self.layout.is_empty() - } - - pub fn extract(&self, viewport_height: usize) -> Vec { - let lines = self.layout.len().min(viewport_height); - let mut start = self.offset; - let end = self.offset + lines; - if end > self.layout.len() { - start = self.layout.len().saturating_sub(lines); - } - - self.layout - .iter() - .enumerate() - .filter(|(i, _)| start <= *i && *i < end) - .map(|(_, row)| row.clone()) - .collect::>() - } -} - -#[cfg(test)] -mod test { - use super::*; - - mod visible_row_count { - use super::*; - - #[test] - fn test() { - let pane = Pane::new(vec![], 0); - assert_eq!(0, pane.visible_row_count()) - } - } - - mod is_empty { - use super::*; - - #[test] - fn test() { - assert_eq!( - true, - Pane { - layout: StyledGraphemes::from("").matrixify(10, 10, 0).0, - offset: 0, - } - .is_empty() - ); - } - } - mod extract { - use super::*; - - #[test] - fn test_with_less_extraction_size_than_layout() { - let expect = vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - ]; - assert_eq!( - expect, - Pane { - layout: vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ], - offset: 0, - } - .extract(3) - ); - } - - #[test] - fn test_with_much_extraction_size_than_layout() { - let expect = vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ]; - assert_eq!( - expect, - Pane { - layout: vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ], - offset: 0, - } - .extract(10) - ); - } - - #[test] - fn test_with_within_extraction_size_and_offset_non_zero() { - let expect = vec![StyledGraphemes::from("cc"), StyledGraphemes::from("dd")]; - assert_eq!( - expect, - Pane { - layout: vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ], - offset: 2, // indicate `cc` - } - .extract(2) - ); - } - - #[test] - fn test_with_beyond_extraction_size_and_offset_non_zero() { - let expect = vec![ - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ]; - assert_eq!( - expect, - Pane { - layout: vec![ - StyledGraphemes::from("aa"), - StyledGraphemes::from("bb"), - StyledGraphemes::from("cc"), - StyledGraphemes::from("dd"), - StyledGraphemes::from("ee"), - ], - offset: 3, // indicate `dd` - } - .extract(3) - ); - } - } -} diff --git a/promkit-core/src/render.rs b/promkit-core/src/render.rs index 9985b5c9..e0d39186 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use crossbeam_skiplist::SkipMap; use tokio::sync::Mutex; -use crate::{Pane, terminal::Terminal}; +use crate::{grapheme::StyledGraphemes, terminal::Terminal}; /// SharedRenderer is a type alias for an Arc-wrapped Renderer, allowing for shared ownership and concurrency. pub type SharedRenderer = Arc>; -/// Renderer is responsible for managing and rendering multiple panes in a terminal. +/// Renderer is responsible for managing and rendering multiple grapheme chunks in a terminal. pub struct Renderer { terminal: Mutex, - panes: SkipMap, + graphemes: SkipMap, } impl Renderer { @@ -20,16 +20,16 @@ impl Renderer { terminal: Mutex::new(Terminal { position: crossterm::cursor::position()?, }), - panes: SkipMap::new(), + graphemes: SkipMap::new(), }) } - pub async fn try_new_with_panes(init_panes: I, draw: bool) -> anyhow::Result + pub async fn try_new_with_graphemes(init: I, draw: bool) -> anyhow::Result where - I: IntoIterator, + I: IntoIterator, { let renderer = Self::try_new()?; - renderer.update(init_panes); + renderer.update(init); if draw { renderer.render().await?; } @@ -38,10 +38,10 @@ impl Renderer { pub fn update(&self, items: I) -> &Self where - I: IntoIterator, + I: IntoIterator, { - items.into_iter().for_each(|(index, pane)| { - self.panes.insert(index, pane); + items.into_iter().for_each(|(index, graphemes)| { + self.graphemes.insert(index, graphemes); }); self } @@ -51,19 +51,19 @@ impl Renderer { I: IntoIterator, { items.into_iter().for_each(|index| { - self.panes.remove(&index); + self.graphemes.remove(&index); }); self } // TODO: Implement diff rendering pub async fn render(&self) -> anyhow::Result<()> { - let panes: Vec = self - .panes + let graphemes: Vec = self + .graphemes .iter() .map(|entry| entry.value().clone()) .collect(); let mut terminal = self.terminal.lock().await; - terminal.draw(&panes) + terminal.draw(&graphemes) } } diff --git a/promkit-core/src/terminal.rs b/promkit-core/src/terminal.rs index e496e7b9..11f383d3 100644 --- a/promkit-core/src/terminal.rs +++ b/promkit-core/src/terminal.rs @@ -1,8 +1,8 @@ use std::io::{self, Write}; use crate::{ - Pane, crossterm::{cursor, style, terminal}, + grapheme::StyledGraphemes, }; pub struct Terminal { @@ -11,15 +11,17 @@ pub struct Terminal { } impl Terminal { - pub fn draw(&mut self, panes: &[Pane]) -> anyhow::Result<()> { - let height = terminal::size()?.1; + pub fn draw(&mut self, graphemes: &[StyledGraphemes]) -> anyhow::Result<()> { + let (width, height) = terminal::size()?; + let visible_height = height.saturating_sub(self.position.1); - let viewable_panes = panes + let viewable_rows = graphemes .iter() - .filter(|pane| !pane.is_empty()) - .collect::>(); + .map(|graphemes| graphemes.wrapped_lines(width as usize)) + .filter(|rows| !rows.is_empty()) + .collect::>>(); - if height < viewable_panes.len() as u16 { + if height < viewable_rows.len() as u16 { return Err(anyhow::anyhow!("Insufficient space to display all panes")); } @@ -31,16 +33,14 @@ impl Terminal { let mut used = 0; - let mut remaining_lines = height.saturating_sub(self.position.1); + let mut remaining_lines = visible_height; - for (pane_index, pane) in viewable_panes.iter().enumerate() { - // We need to ensure each pane gets at least 1 row - let max_rows = 1.max( - (height as usize).saturating_sub(used + viewable_panes.len() - 1 - pane_index), - ); - - let rows = pane.extract(max_rows); - used += rows.len(); + for (pane_index, rows) in viewable_rows.iter().enumerate() { + let max_rows = 1 + .max((height as usize).saturating_sub(used + viewable_rows.len() - 1 - pane_index)); + let rows = rows.iter().take(max_rows).collect::>(); + let row_count = rows.len(); + used += row_count; for (row_index, row) in rows.iter().enumerate() { crossterm::queue!(io::stdout(), style::Print(row.styled_display()))?; @@ -50,8 +50,8 @@ impl Terminal { // Determine if scrolling is needed: // - We need to scroll if we've reached the bottom of the terminal (remaining_lines == 0) // - AND we have more content to display (either more rows in current pane or more panes) - let is_last_pane = pane_index == viewable_panes.len() - 1; - let is_last_row_in_pane = row_index == rows.len() - 1; + let is_last_pane = pane_index == viewable_rows.len() - 1; + let is_last_row_in_pane = row_index == row_count - 1; let has_more_content = !(is_last_pane && is_last_row_in_pane); if has_more_content && remaining_lines == 0 { diff --git a/promkit-widgets/src/checkbox.rs b/promkit-widgets/src/checkbox.rs index e45f3420..f2d276d0 100644 --- a/promkit-widgets/src/checkbox.rs +++ b/promkit-widgets/src/checkbox.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "checkbox/checkbox.rs"] mod inner; @@ -21,8 +21,8 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, _width: u16, height: u16) -> StyledGraphemes { let f = |idx: usize| -> StyledGraphemes { if self.checkbox.picked_indexes().contains(&idx) { StyledGraphemes::from(format!("{} ", self.config.active_mark)) @@ -36,7 +36,7 @@ impl PaneFactory for State { None => height as usize, }; - let matrix = self + let lines = self .checkbox .items() .iter() @@ -62,15 +62,8 @@ impl PaneFactory for State { ]) .apply_style(self.config.inactive_item_style) } - }) - .fold((vec![], 0), |(mut acc, pos), item| { - let rows = item.matrixify(width as usize, height, 0).0; - if pos < self.checkbox.position() + height { - acc.extend(rows); - } - (acc, pos + 1) }); - Pane::new(matrix.0, 0) + StyledGraphemes::from_lines(lines) } } diff --git a/promkit-widgets/src/jsonstream.rs b/promkit-widgets/src/jsonstream.rs index e9ca3f21..ffc01fdb 100644 --- a/promkit-widgets/src/jsonstream.rs +++ b/promkit-widgets/src/jsonstream.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "jsonstream/jsonstream.rs"] mod inner; @@ -22,8 +22,8 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { let height = match self.config.lines { Some(lines) => lines.min(height as usize), None => height as usize, @@ -32,6 +32,6 @@ impl PaneFactory for State { let rows = self.stream.extract_rows_from_current(height); let formatted_rows = self.config.format_for_terminal_display(&rows, width); - Pane::new(formatted_rows, 0) + StyledGraphemes::from_lines(formatted_rows) } } diff --git a/promkit-widgets/src/listbox.rs b/promkit-widgets/src/listbox.rs index a4d23379..ecbac6f2 100644 --- a/promkit-widgets/src/listbox.rs +++ b/promkit-widgets/src/listbox.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "listbox/listbox.rs"] mod inner; @@ -17,14 +17,14 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, _width: u16, height: u16) -> StyledGraphemes { let height = match self.config.lines { Some(lines) => lines.min(height as usize), None => height as usize, }; - let matrix = self + let lines = self .listbox .items() .iter() @@ -54,15 +54,8 @@ impl PaneFactory for State { init } } - }) - .fold((vec![], 0), |(mut acc, pos), item| { - let rows = item.matrixify(width as usize, height, 0).0; - if pos < self.listbox.position() + height { - acc.extend(rows); - } - (acc, pos + 1) }); - Pane::new(matrix.0, 0) + StyledGraphemes::from_lines(lines) } } diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index 8ced5181..b9261a4a 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; +use crate::core::{grapheme::StyledGraphemes, render::SharedRenderer}; pub mod frame; use frame::Frame; @@ -74,13 +74,10 @@ where renderer .update([( index.clone(), - Pane::new( - vec![StyledGraphemes::from(format!( - "{} {}", - spinner.frames[frame_index], spinner.suffix - ))], - 0, - ), + StyledGraphemes::from(format!( + "{} {}", + spinner.frames[frame_index], spinner.suffix + )), )]) .render() .await?; diff --git a/promkit-widgets/src/text.rs b/promkit-widgets/src/text.rs index 2f7271c7..b1ab741d 100644 --- a/promkit-widgets/src/text.rs +++ b/promkit-widgets/src/text.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "text/text.rs"] mod inner; @@ -28,14 +28,14 @@ impl State { } } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, _width: u16, height: u16) -> StyledGraphemes { let height = match self.config.lines { Some(lines) => lines.min(height as usize), None => height as usize, }; - let matrix = self + let lines = self .text .items() .iter() @@ -47,15 +47,8 @@ impl PaneFactory for State { } else { item.clone() } - }) - .fold((vec![], 0), |(mut acc, pos), item| { - let rows = item.matrixify(width as usize, height, 0).0; - if pos < self.text.position() + height { - acc.extend(rows); - } - (acc, pos + 1) }); - Pane::new(matrix.0, 0) + StyledGraphemes::from_lines(lines) } } diff --git a/promkit-widgets/src/text_editor.rs b/promkit-widgets/src/text_editor.rs index 264a8bb6..ed730000 100644 --- a/promkit-widgets/src/text_editor.rs +++ b/promkit-widgets/src/text_editor.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; mod history; pub use history::History; @@ -19,12 +19,17 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { + if width == 0 { + return StyledGraphemes::default(); + } + let mut buf = StyledGraphemes::default(); let mut styled_prefix = StyledGraphemes::from_str(&self.config.prefix, self.config.prefix_style); + let prefix_width = styled_prefix.widths(); buf.append(&mut styled_prefix); @@ -44,14 +49,18 @@ impl PaneFactory for State { None => height as usize, }; - let (matrix, offset) = buf.matrixify( - width as usize, - height, - (StyledGraphemes::from_str(&self.config.prefix, self.config.prefix_style).widths() - + self.texteditor.position()) - / width as usize, - ); + let rows = buf.wrapped_lines(width as usize); + if rows.is_empty() || height == 0 { + return StyledGraphemes::default(); + } + + let lines = rows.len().min(height); + let mut start = (prefix_width + self.texteditor.position()) / width as usize; + let end = start + lines; + if end > rows.len() { + start = rows.len().saturating_sub(lines); + } - Pane::new(matrix, offset) + StyledGraphemes::from_lines(rows.into_iter().skip(start).take(lines)) } } diff --git a/promkit-widgets/src/tree.rs b/promkit-widgets/src/tree.rs index 0fcf8766..afe81ed2 100644 --- a/promkit-widgets/src/tree.rs +++ b/promkit-widgets/src/tree.rs @@ -1,4 +1,4 @@ -use promkit_core::{Pane, PaneFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; pub mod node; use node::Kind; @@ -22,8 +22,8 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl Widget for State { + fn create_graphemes(&self, _width: u16, height: u16) -> StyledGraphemes { let symbol = |kind: &Kind| -> &str { match kind { Kind::Folded { .. } => &self.config.folded_symbol, @@ -50,9 +50,8 @@ impl PaneFactory for State { None => height as usize, }; - let matrix = self - .tree - .kinds() + let kinds = self.tree.kinds(); + let lines = kinds .iter() .enumerate() .filter(|(i, _)| *i >= self.tree.position() && *i < self.tree.position() + height) @@ -73,15 +72,8 @@ impl PaneFactory for State { self.config.inactive_item_style, ) } - }) - .fold((vec![], 0), |(mut acc, pos), item| { - let rows = item.matrixify(width as usize, height, 0).0; - if pos < self.tree.position() + height { - acc.extend(rows); - } - (acc, pos + 1) }); - Pane::new(matrix.0, 0) + StyledGraphemes::from_lines(lines) } } diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index d67ce08a..2f446e92 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -116,6 +116,12 @@ pub trait Prompt { while let Some(event) = EVENT_STREAM.lock().await.next().await { match event { Ok(event) => { + // NOTE: For zsh_pretend/tests/resize_roundtrip_wrap_reflow.rs, skipping + // resize events here + // keeps output closer to zsh than evaluating resize as a normal input event. + if event.is_resize() { + continue; + } // Evaluate the event using the engine if self.evaluate(&event).await? == Signal::Quit { break; diff --git a/promkit/src/preset/checkbox.rs b/promkit/src/preset/checkbox.rs index 5c99c1fa..e81c0616 100644 --- a/promkit/src/preset/checkbox.rs +++ b/promkit/src/preset/checkbox.rs @@ -10,7 +10,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{ @@ -47,10 +47,13 @@ impl crate::Prompt for Checkbox { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Checkbox, self.checkbox.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + ( + Index::Checkbox, + self.checkbox.create_graphemes(size.0, size.1), + ), ], true, ) @@ -175,8 +178,11 @@ impl Checkbox { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Checkbox, self.checkbox.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + ( + Index::Checkbox, + self.checkbox.create_graphemes(width, height), + ), ]) .render() .await diff --git a/promkit/src/preset/form.rs b/promkit/src/preset/form.rs index 8eb5e142..bf04ba85 100644 --- a/promkit/src/preset/form.rs +++ b/promkit/src/preset/form.rs @@ -8,7 +8,7 @@ use crate::{ style::{Attribute, Attributes, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{cursor::Cursor, text_editor}, @@ -49,12 +49,12 @@ impl crate::Prompt for Form { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( self.readlines .contents() .iter() .enumerate() - .map(|(i, state)| (i, state.create_pane(size.0, size.1))), + .map(|(i, state)| (i, state.create_graphemes(size.0, size.1))), true, ) .await?, @@ -140,7 +140,7 @@ impl Form { .contents() .iter() .enumerate() - .map(|(i, state)| (i, state.create_pane(width, height))), + .map(|(i, state)| (i, state.create_graphemes(width, height))), ) .render() .await diff --git a/promkit/src/preset/json.rs b/promkit/src/preset/json.rs index 3f28b3e7..e6816078 100644 --- a/promkit/src/preset/json.rs +++ b/promkit/src/preset/json.rs @@ -8,7 +8,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{ @@ -48,10 +48,10 @@ impl crate::Prompt for Json { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Json, self.json.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + (Index::Json, self.json.create_graphemes(size.0, size.1)), ], true, ) @@ -179,8 +179,8 @@ impl Json { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Json, self.json.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + (Index::Json, self.json.create_graphemes(width, height)), ]) .render() .await diff --git a/promkit/src/preset/listbox.rs b/promkit/src/preset/listbox.rs index dc3d0f5f..abf8543e 100644 --- a/promkit/src/preset/listbox.rs +++ b/promkit/src/preset/listbox.rs @@ -10,7 +10,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{ @@ -46,10 +46,13 @@ impl crate::Prompt for Listbox { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Listbox, self.listbox.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + ( + Index::Listbox, + self.listbox.create_graphemes(size.0, size.1), + ), ], true, ) @@ -157,8 +160,8 @@ impl Listbox { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Listbox, self.listbox.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + (Index::Listbox, self.listbox.create_graphemes(width, height)), ]) .render() .await diff --git a/promkit/src/preset/query_selector.rs b/promkit/src/preset/query_selector.rs index 60fc376f..a1a78148 100644 --- a/promkit/src/preset/query_selector.rs +++ b/promkit/src/preset/query_selector.rs @@ -10,7 +10,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{ @@ -61,11 +61,14 @@ impl crate::Prompt for QuerySelector { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Readline, self.readline.create_pane(size.0, size.1)), - (Index::List, self.list.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + ( + Index::Readline, + self.readline.create_graphemes(size.0, size.1), + ), + (Index::List, self.list.create_graphemes(size.0, size.1)), ], true, ) @@ -261,9 +264,12 @@ impl QuerySelector { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Readline, self.readline.create_pane(width, height)), - (Index::List, self.list.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + ( + Index::Readline, + self.readline.create_graphemes(width, height), + ), + (Index::List, self.list.create_graphemes(width, height)), ]) .render() .await diff --git a/promkit/src/preset/readline.rs b/promkit/src/preset/readline.rs index a22dd9ae..69474d97 100644 --- a/promkit/src/preset/readline.rs +++ b/promkit/src/preset/readline.rs @@ -10,7 +10,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, suggest::Suggest, @@ -140,17 +140,20 @@ impl crate::Prompt for Readline { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Readline, self.readline.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + ( + Index::Readline, + self.readline.create_graphemes(size.0, size.1), + ), ( Index::Suggestion, - self.suggestions.create_pane(size.0, size.1), + self.suggestions.create_graphemes(size.0, size.1), ), ( Index::ErrorMessage, - self.error_message.create_pane(size.0, size.1), + self.error_message.create_graphemes(size.0, size.1), ), ], true, @@ -274,15 +277,18 @@ impl Readline { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Readline, self.readline.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + ( + Index::Readline, + self.readline.create_graphemes(width, height), + ), ( Index::Suggestion, - self.suggestions.create_pane(width, height), + self.suggestions.create_graphemes(width, height), ), ( Index::ErrorMessage, - self.error_message.create_pane(width, height), + self.error_message.create_graphemes(width, height), ), ]) .render() diff --git a/promkit/src/preset/text.rs b/promkit/src/preset/text.rs index d556cf29..54bee818 100644 --- a/promkit/src/preset/text.rs +++ b/promkit/src/preset/text.rs @@ -4,7 +4,7 @@ use crate::{ core::{ crossterm::{self, event::Event, style::ContentStyle}, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::text::{self, config::Config}, @@ -34,8 +34,8 @@ impl crate::Prompt for Text { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( - [(Index::Text, self.text.create_pane(size.0, size.1))], + Renderer::try_new_with_graphemes( + [(Index::Text, self.text.create_graphemes(size.0, size.1))], true, ) .await?, @@ -87,7 +87,7 @@ impl Text { match self.renderer.as_ref() { Some(renderer) => { renderer - .update([(Index::Text, self.text.create_pane(width, height))]) + .update([(Index::Text, self.text.create_graphemes(width, height))]) .render() .await } diff --git a/promkit/src/preset/tree.rs b/promkit/src/preset/tree.rs index 9c735f74..3550e366 100644 --- a/promkit/src/preset/tree.rs +++ b/promkit/src/preset/tree.rs @@ -8,7 +8,7 @@ use crate::{ style::{Attribute, Attributes, Color, ContentStyle}, }, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, preset::Evaluator, widgets::{ @@ -45,10 +45,10 @@ impl crate::Prompt for Tree { async fn initialize(&mut self) -> anyhow::Result<()> { let size = crossterm::terminal::size()?; self.renderer = Some(SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ - (Index::Title, self.title.create_pane(size.0, size.1)), - (Index::Tree, self.tree.create_pane(size.0, size.1)), + (Index::Title, self.title.create_graphemes(size.0, size.1)), + (Index::Tree, self.tree.create_graphemes(size.0, size.1)), ], true, ) @@ -164,8 +164,8 @@ impl Tree { Some(renderer) => { renderer .update([ - (Index::Title, self.title.create_pane(width, height)), - (Index::Tree, self.tree.create_pane(width, height)), + (Index::Title, self.title.create_graphemes(width, height)), + (Index::Tree, self.tree.create_graphemes(width, height)), ]) .render() .await diff --git a/scripts/render_tapes_gif.sh b/scripts/render_tapes_gif.sh new file mode 100755 index 00000000..52fe03e5 --- /dev/null +++ b/scripts/render_tapes_gif.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAPES_DIR="${ROOT_DIR}/tapes" + +if ! command -v vhs >/dev/null 2>&1; then + echo "error: vhs command not found. install it first: https://github.com/charmbracelet/vhs" >&2 + exit 1 +fi + +tape_count=0 + +while IFS= read -r tape; do + [[ -z "${tape}" ]] && continue + + tape_count=$((tape_count + 1)) + rel_path="${tape#${ROOT_DIR}/}" + echo "rendering ${rel_path}" + ( + cd "${ROOT_DIR}" + vhs "${rel_path}" + ) +done < <(find "${TAPES_DIR}" -maxdepth 1 -type f -name '*.tape' | sort) + +if [[ ${tape_count} -eq 0 ]]; then + echo "error: no .tape files found in ${TAPES_DIR}" >&2 + exit 1 +fi + +echo "done: rendered ${tape_count} tape(s)" diff --git a/tapes/checkbox.tape b/tapes/checkbox.tape index 049c7f25..4251dc3e 100644 --- a/tapes/checkbox.tape +++ b/tapes/checkbox.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example checkbox" Enter Sleep 1s +Type@50ms "cargo run -q --bin checkbox" Enter Sleep 2s Down@300ms 3 Sleep 1s Space Sleep 1s Up@300ms 1 Sleep 1s diff --git a/tapes/confirm.tape b/tapes/confirm.tape index c6aa7a83..dca8f694 100644 --- a/tapes/confirm.tape +++ b/tapes/confirm.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example confirm" Enter Sleep 1s +Type@50ms "cargo run -q --bin confirm" Enter Sleep 1s Type "invalid" Sleep 1s Enter Sleep 1s Ctrl+U Type "yes" Sleep 1s diff --git a/tapes/form.tape b/tapes/form.tape index 0559601b..8b9470bd 100644 --- a/tapes/form.tape +++ b/tapes/form.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example form" Enter Sleep 1s +Type@50ms "cargo run -q --bin form" Enter Sleep 2s Type "Hello" Sleep 1s Down 1 Sleep 1s Type "promkit" Sleep 1s diff --git a/tapes/json.tape b/tapes/json.tape index fef6298b..85d16636 100644 --- a/tapes/json.tape +++ b/tapes/json.tape @@ -8,10 +8,9 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example json" Enter Sleep 1s +Type@50ms "cargo run -q --bin json test.json" Enter Sleep 2s Down@300ms 2 Sleep 1s Space Sleep 1s Down@300ms 1 Sleep 1s Space Sleep 1s Up@300ms 2 Sleep 1s -Enter Sleep 2s diff --git a/tapes/listbox.tape b/tapes/listbox.tape index 9409bf15..e34e98e2 100644 --- a/tapes/listbox.tape +++ b/tapes/listbox.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example listbox" Enter Sleep 1s +Type@50ms "cargo run -q --bin listbox" Enter Sleep 2s Down 22 Sleep 1s Up@300ms 4 Sleep 1s Enter Sleep 2s diff --git a/tapes/password.tape b/tapes/password.tape index 6a8897b0..d4ca4eda 100644 --- a/tapes/password.tape +++ b/tapes/password.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example password" Enter Sleep 1s +Type@50ms "cargo run -q --bin password" Enter Sleep 2s Type "abc" Sleep 1s Enter Sleep 1s Ctrl+U Type "password" Sleep 1s diff --git a/tapes/query_selector.tape b/tapes/query_selector.tape index 5b5713c0..65cb340f 100644 --- a/tapes/query_selector.tape +++ b/tapes/query_selector.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example query_selector" Enter Sleep 1s +Type@50ms "cargo run -q --bin query-selector" Enter Sleep 2s Down 6 Sleep 1s Type "8" Sleep 1s Type "8" Sleep 1s diff --git a/tapes/readline.tape b/tapes/readline.tape index 9179c300..da151b92 100644 --- a/tapes/readline.tape +++ b/tapes/readline.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example readline" Enter Sleep 1s +Type@50ms "cargo run -q --bin readline" Enter Sleep 2s Type "Hello promkit!!" Sleep 1s Backspace 15 Type "a" Sleep 1s Tab 1 Sleep 1s diff --git a/tapes/text.tape b/tapes/text.tape new file mode 100644 index 00000000..fb2e2727 --- /dev/null +++ b/tapes/text.tape @@ -0,0 +1,13 @@ +Output tapes/text.gif + +Require cargo + +Set Shell "bash" +Set Theme "Dracula" +Set FontSize 32 +Set Width 1200 +Set Height 600 + +Type@50ms "cargo run -q --bin text" Enter Sleep 2s +Down 7 Sleep 1s +Up@300ms 3 Sleep 1s diff --git a/tapes/tree.tape b/tapes/tree.tape index f2eef84a..3f741ca0 100644 --- a/tapes/tree.tape +++ b/tapes/tree.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example tree" Enter Sleep 1s +Type@50ms "cargo run -q --bin tree" Enter Sleep 2s Sleep 1s Space Sleep 1s Down@300ms 2 Sleep 1s diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml new file mode 100644 index 00000000..cdae2763 --- /dev/null +++ b/termharness/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "termharness" +version = "0.1.0" +authors = ["ynqa "] +edition = "2024" +description = "Test harness for terminal applications" +repository = "https://github.com/ynqa/promkit" +license = "MIT" +readme = "README.md" + +[lib] +name = "termharness" +path = "src/lib.rs" + +[dependencies] +alacritty_terminal = "0.25.1" +anyhow = { workspace = true } +portable-pty = { workspace = true } +unicode-width = { workspace = true } diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs new file mode 100644 index 00000000..1a926638 --- /dev/null +++ b/termharness/src/lib.rs @@ -0,0 +1,2 @@ +pub mod session; +pub mod terminal; diff --git a/termharness/src/session.rs b/termharness/src/session.rs new file mode 100644 index 00000000..1f22760e --- /dev/null +++ b/termharness/src/session.rs @@ -0,0 +1,349 @@ +use std::{ + io::Read, + io::Write, + sync::{Arc, Mutex}, + thread, + thread::JoinHandle, +}; + +use crate::terminal::TerminalSize; +use alacritty_terminal::{ + event::VoidListener, + index::{Column, Line, Point}, + term::{Config, Term, cell::Flags, test::TermSize}, + vte::ansi::Processor, +}; +use anyhow::Result; +use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use unicode_width::UnicodeWidthStr; + +const CURSOR_POSITION_REQUEST: &[u8] = b"\x1b[6n"; +const CURSOR_POSITION_REQUEST_LEN: usize = CURSOR_POSITION_REQUEST.len(); + +fn pad_to_cols(cols: u16, content: &str) -> String { + let width = content.width(); + assert!( + width <= cols as usize, + "line width {width} exceeds terminal width {cols}" + ); + + let mut line = String::from(content); + line.push_str(&" ".repeat(cols as usize - width)); + line +} + +fn cursor_position_request_count(buffer: &[u8]) -> usize { + buffer + .windows(CURSOR_POSITION_REQUEST_LEN) + .filter(|window| *window == CURSOR_POSITION_REQUEST) + .count() +} + +struct Screen { + parser: Processor, + terminal: Term, +} + +impl Screen { + fn new(size: TerminalSize) -> Self { + let size = TermSize::new(size.cols as usize, size.rows as usize); + Self { + parser: Processor::new(), + terminal: Term::new(Config::default(), &size, VoidListener), + } + } + + fn with_cursor(size: TerminalSize, row: u16, col: u16) -> Self { + let mut screen = Self::new(size); + screen.set_cursor_position(row, col); + screen + } + + fn process(&mut self, chunk: &[u8]) { + self.parser.advance(&mut self.terminal, chunk); + } + + fn resize(&mut self, size: TerminalSize) { + let size = TermSize::new(size.cols as usize, size.rows as usize); + self.terminal.resize(size); + } + + fn cursor_position(&self) -> (u16, u16) { + let point = self.terminal.grid().cursor.point; + let row = u16::try_from(point.line.0).expect("cursor row should be non-negative") + 1; + let col = u16::try_from(point.column.0).expect("cursor column should fit in u16") + 1; + (row, col) + } + + fn set_cursor_position(&mut self, row: u16, col: u16) { + let cursor = &mut self.terminal.grid_mut().cursor; + cursor.point = Point::new( + Line(i32::from(row.saturating_sub(1))), + Column(usize::from(col.saturating_sub(1))), + ); + cursor.input_needs_wrap = false; + } + + fn snapshot(&self, size: TerminalSize) -> Vec { + let mut lines = Vec::with_capacity(size.rows as usize); + let mut current_line = None; + + for indexed in self.terminal.grid().display_iter() { + if current_line != Some(indexed.point.line.0) { + lines.push(String::new()); + current_line = Some(indexed.point.line.0); + } + + let line = lines + .last_mut() + .expect("display iterator should yield rows"); + if indexed.cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + + line.push(indexed.cell.c); + if let Some(zerowidth) = indexed.cell.zerowidth() { + for ch in zerowidth { + line.push(*ch); + } + } + } + + lines.resize(size.rows as usize, String::new()); + lines + .into_iter() + .map(|line| pad_to_cols(size.cols, &line)) + .collect() + } +} + +type SharedWriter = Arc>>; + +pub struct Session { + pub child: Box, + pub master: Box, + pub writer: SharedWriter, + pub output: Arc>>, + screen: Arc>, + pub reader_thread: Option>, + pub size: TerminalSize, +} + +impl Session { + /// Spawn a new session by executing the given command + /// in a pseudo-terminal with the specified size and initial cursor position. + pub fn spawn( + mut cmd: CommandBuilder, + term_size: (u16, u16), + cursor_pos: Option<(u16, u16)>, + ) -> Result { + let term_size = TerminalSize::new(term_size.0, term_size.1); + let pty = native_pty_system(); + let pair = pty.openpty(PtySize { + rows: term_size.rows, + cols: term_size.cols, + pixel_width: 0, + pixel_height: 0, + })?; + + // Set the TERM environment variable to ensure consistent terminal behavior. + // Consideration: This should ideally be configurable, + // but for now we hardcode it to ensure tests run reliably. + cmd.env("TERM", "xterm-256color"); + let child = pair.slave.spawn_command(cmd)?; + drop(pair.slave); + + let master = pair.master; + let output = Arc::new(Mutex::new(Vec::new())); + let output_reader = Arc::clone(&output); + let screen = Arc::new(Mutex::new(match cursor_pos { + Some((row, col)) => Screen::with_cursor(term_size, row, col), + None => Screen::new(term_size), + })); + let screen_reader = Arc::clone(&screen); + let writer = Arc::new(Mutex::new(master.take_writer()?)); + let writer_reader = Arc::clone(&writer); + let mut reader = master.try_clone_reader()?; + let reader_thread = thread::spawn(move || { + let mut buf = [0_u8; 4096]; + let mut tail = Vec::new(); + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let chunk = &buf[..n]; + output_reader + .lock() + .expect("failed to lock output buffer") + .extend_from_slice(chunk); + let mut scan = tail; + scan.extend_from_slice(chunk); + + let (response_count, cursor_position) = { + let mut screen = + screen_reader.lock().expect("failed to lock screen parser"); + screen.process(chunk); + ( + cursor_position_request_count(&scan), + screen.cursor_position(), + ) + }; + + if response_count > 0 { + let response = + format!("\x1b[{};{}R", cursor_position.0, cursor_position.1); + let mut writer = + writer_reader.lock().expect("failed to lock session writer"); + for _ in 0..response_count { + if writer.write_all(response.as_bytes()).is_err() + || writer.flush().is_err() + { + return; + } + } + } + + let keep_from = scan.len().saturating_sub(CURSOR_POSITION_REQUEST_LEN - 1); + tail = scan.split_off(keep_from); + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + Ok(Self { + child, + master, + writer, + output, + screen, + reader_thread: Some(reader_thread), + size: term_size, + }) + } + + pub fn resize(&mut self, size: TerminalSize) -> Result<()> { + self.master.resize(PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + })?; + self.screen + .lock() + .expect("failed to lock screen parser") + .resize(size); + self.size = size; + Ok(()) + } + + pub fn screen_snapshot(&self) -> Vec { + self.screen + .lock() + .expect("failed to lock screen parser") + .snapshot(self.size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod session { + use super::*; + + mod spawn { + use super::*; + + #[test] + fn success() -> Result<()> { + let mut cmd = CommandBuilder::new("echo"); + cmd.arg("Hello, world!"); + let mut session = Session::spawn(cmd, (24, 80), None)?; + + // Wait for the child process to exit and the reader thread to finish. + session.child.wait()?; + if let Some(reader_thread) = session.reader_thread.take() { + reader_thread.join().expect("reader thread panicked"); + } + + let output = session.output.lock().unwrap(); + let output = String::from_utf8_lossy(&output); + assert!(output.contains("Hello, world!")); + Ok(()) + } + + #[test] + fn responds_to_cursor_position_requests() -> Result<()> { + let mut cmd = CommandBuilder::new("/bin/bash"); + cmd.arg("-lc"); + cmd.arg(r#"printf 'abc\033[6n'; IFS= read -rsd R pos; printf '%sR' "$pos""#); + let mut session = Session::spawn(cmd, (24, 80), None)?; + + session.child.wait()?; + if let Some(reader_thread) = session.reader_thread.take() { + reader_thread.join().expect("reader thread panicked"); + } + + let output = session.output.lock().unwrap(); + assert!( + String::from_utf8_lossy(&output).contains("\x1b[1;4R"), + "expected DSR response in output, got {:?}", + String::from_utf8_lossy(&output), + ); + Ok(()) + } + + #[test] + fn responds_from_custom_initial_cursor_position() -> Result<()> { + let mut cmd = CommandBuilder::new("/bin/bash"); + cmd.arg("-lc"); + cmd.arg(r#"printf '\033[6n'; IFS= read -rsd R pos; printf '%sR' "$pos""#); + let mut session = Session::spawn(cmd, (24, 80), Some((24, 1)))?; + + session.child.wait()?; + if let Some(reader_thread) = session.reader_thread.take() { + reader_thread.join().expect("reader thread panicked"); + } + + let output = session.output.lock().unwrap(); + assert!( + String::from_utf8_lossy(&output).contains("\x1b[24;1R"), + "expected DSR response in output, got {:?}", + String::from_utf8_lossy(&output), + ); + Ok(()) + } + } + + mod screen { + use super::*; + + #[test] + fn resize_reflows_wrapped_lines() { + let mut screen = Screen::new(TerminalSize::new(3, 8)); + screen.process(b"abcdefghij"); + + assert_eq!( + screen.snapshot(TerminalSize::new(3, 8)), + vec![ + "abcdefgh".to_string(), + "ij ".to_string(), + " ".to_string(), + ] + ); + + screen.resize(TerminalSize::new(3, 6)); + + assert_eq!( + screen.snapshot(TerminalSize::new(3, 6)), + vec![ + "abcdef".to_string(), + "ghij ".to_string(), + " ".to_string(), + ] + ); + } + } + } +} diff --git a/termharness/src/terminal.rs b/termharness/src/terminal.rs new file mode 100644 index 00000000..7541e49d --- /dev/null +++ b/termharness/src/terminal.rs @@ -0,0 +1,12 @@ +/// Represent the size of the terminal in terms of rows and columns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalSize { + pub rows: u16, + pub cols: u16, +} + +impl TerminalSize { + pub fn new(rows: u16, cols: u16) -> Self { + Self { rows, cols } + } +} diff --git a/zsh-render-parity/Cargo.toml b/zsh-render-parity/Cargo.toml new file mode 100644 index 00000000..fc348818 --- /dev/null +++ b/zsh-render-parity/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "zsh-render-parity" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "zsh-pretend" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +promkit = { path = "../promkit", features = ["readline"] } +tokio = { workspace = true } + +[dev-dependencies] +portable-pty = { workspace = true } +termharness = { path = "../termharness" } +zsherio = { path = "../zsherio" } diff --git a/zsh-render-parity/src/main.rs b/zsh-render-parity/src/main.rs new file mode 100644 index 00000000..9195d208 --- /dev/null +++ b/zsh-render-parity/src/main.rs @@ -0,0 +1,53 @@ +use promkit::{ + core::crossterm::{cursor, terminal}, + preset::readline::Readline, + Prompt, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + loop { + match Readline::default().run().await { + Ok(command) => { + // Keep the prompt line intact when the cursor is already on the last row. + let (_, y) = cursor::position()?; + let (_, h) = terminal::size()?; + if y >= h.saturating_sub(1) { + println!(); + } + println!( + "zsh: command not found: {}", + strip_outer_quotes(command.trim()) + ); + } + Err(error) => { + println!("error: {error}"); + break; + } + } + } + + Ok(()) +} + +/// Strip outer quotes from a command string, if present. +/// e.g. `"ls -la"` becomes `ls -la` +fn strip_outer_quotes(command: &str) -> &str { + if command.len() >= 2 { + if let Some(unquoted) = command + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + { + return unquoted; + } + + if let Some(unquoted) = command + .strip_prefix('\'') + .and_then(|inner| inner.strip_suffix('\'')) + { + return unquoted; + } + } + + command +} diff --git a/zsh-render-parity/tests/common/mod.rs b/zsh-render-parity/tests/common/mod.rs new file mode 100644 index 00000000..3534ef82 --- /dev/null +++ b/zsh-render-parity/tests/common/mod.rs @@ -0,0 +1,60 @@ +use std::{path::PathBuf, thread, time::Duration}; + +use termharness::session::Session; +use zsherio::ScenarioRun; + +pub const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); +const PROMPT_WAIT_TIMEOUT: Duration = Duration::from_secs(2); +const PROMPT_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// Wait until the session's screen contains a line that satisfies `is_prompt_line`, +/// or return an error if the timeout is reached. +pub fn wait_for_prompt( + session: &Session, + is_prompt_line: impl Fn(&str) -> bool, +) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + PROMPT_WAIT_TIMEOUT; + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.iter().any(|line| is_prompt_line(line)) { + return Ok(()); + } + thread::sleep(PROMPT_POLL_INTERVAL); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +/// Assert that the two scenario runs match, and if not, +/// return an error with a detailed diff of their outputs. +pub fn assert_scenario_runs_match( + expected: &ScenarioRun, + actual: &ScenarioRun, +) -> anyhow::Result<()> { + if actual.records == expected.records { + return Ok(()); + } + + anyhow::bail!( + "zsh-pretend output diverged from zsh\n\n== expected ==\n{}\n== actual ==\n{}", + render_scenario_run(expected)?, + render_scenario_run(actual)?, + ) +} + +pub fn render_scenario_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} + +pub fn write_scenario_run_artifact(run: &ScenarioRun) -> anyhow::Result<()> { + run.write_to_path(&artifact_path(run)) +} + +fn artifact_path(run: &ScenarioRun) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(".artifacts") + .join(&run.scenario_name) + .join(format!("{}.txt", run.target_name)) +} diff --git a/zsh-render-parity/tests/mid_buffer_insert_wrap.rs b/zsh-render-parity/tests/mid_buffer_insert_wrap.rs new file mode 100644 index 00000000..fa2b066f --- /dev/null +++ b/zsh-render-parity/tests/mid_buffer_insert_wrap.rs @@ -0,0 +1,49 @@ +mod common; + +use std::{thread, time::Duration}; + +use portable_pty::CommandBuilder; +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::mid_buffer_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session, spawn_zsh_session}, + ScenarioRun, +}; + +use crate::common::{ + assert_scenario_runs_match, wait_for_prompt, write_scenario_run_artifact, ZSH_PRETEND_BIN, +}; + +#[test] +fn zsh_pretend_parity_mid_buffer_insert_wrap() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; + + assert_scenario_runs_match(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; + + clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; + thread::sleep(Duration::from_millis(300)); + + scenario().run("zsh", &mut session) +} + +fn run_zsh_pretend() -> anyhow::Result { + let mut session = spawn_session( + CommandBuilder::new(ZSH_PRETEND_BIN), + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), + )?; + + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; + + scenario().run("zsh-pretend", &mut session) +} diff --git a/zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs b/zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs new file mode 100644 index 00000000..fe7d2913 --- /dev/null +++ b/zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs @@ -0,0 +1,49 @@ +mod common; + +use portable_pty::CommandBuilder; +use zsherio::{ + scenarios::prompt_initial_render_at_mid_screen::{ + scenario, START_CURSOR_COL, START_CURSOR_ROW, TERMINAL_COLS, TERMINAL_ROWS, + }, + session::{spawn_session, spawn_zsh_session}, + ScenarioRun, +}; + +use crate::common::{assert_scenario_runs_match, wait_for_prompt, write_scenario_run_artifact}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_parity_prompt_initial_render_at_mid_screen() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; + + assert_scenario_runs_match(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session( + (TERMINAL_ROWS, TERMINAL_COLS), + Some((START_CURSOR_ROW, START_CURSOR_COL)), + )?; + wait_for_prompt(&session, |line| line.contains("❯❯ "))?; + + scenario().run("zsh", &mut session) +} + +fn run_zsh_pretend() -> anyhow::Result { + let mut session = spawn_session( + CommandBuilder::new(ZSH_PRETEND_BIN), + (TERMINAL_ROWS, TERMINAL_COLS), + Some((START_CURSOR_ROW, START_CURSOR_COL)), + )?; + + wait_for_prompt(&session, |line| line.contains("❯❯ "))?; + + scenario().run("zsh-pretend", &mut session) +} diff --git a/zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs b/zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs new file mode 100644 index 00000000..02587af3 --- /dev/null +++ b/zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs @@ -0,0 +1,52 @@ +mod common; + +use std::{thread, time::Duration}; + +use portable_pty::CommandBuilder; +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::resize_roundtrip_wrap_reflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session, spawn_zsh_session}, + ScenarioRun, +}; + +use crate::common::{assert_scenario_runs_match, wait_for_prompt, write_scenario_run_artifact}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +#[ignore = "timing-sensitive and currently unsupported: matching zsh under aggressive \ + resize-wrap is too hard right now; run manually with `cargo test --release --test \ + resize_roundtrip_wrap_reflow`"] +fn zsh_pretend_parity_resize_roundtrip_wrap_reflow() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; + + assert_scenario_runs_match(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; + + clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; + thread::sleep(Duration::from_millis(300)); + + scenario().run("zsh", &mut session) +} + +fn run_zsh_pretend() -> anyhow::Result { + let mut session = spawn_session( + CommandBuilder::new(ZSH_PRETEND_BIN), + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), + )?; + + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; + + scenario().run("zsh-pretend", &mut session) +} diff --git a/zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs b/zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs new file mode 100644 index 00000000..3ae4fc3e --- /dev/null +++ b/zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs @@ -0,0 +1,92 @@ +mod common; + +use std::{thread, time::Duration}; + +use portable_pty::CommandBuilder; +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::tiny_viewport_overflow_wrap_scroll::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session, spawn_zsh_session}, + ScenarioRun, +}; + +use crate::common::{render_scenario_run, wait_for_prompt, write_scenario_run_artifact}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_parity_tiny_viewport_overflow_wrap_scroll() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; + + assert_scenario_runs_match_ignoring_row0(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; + + clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; + thread::sleep(Duration::from_millis(300)); + + scenario().run("zsh", &mut session) +} + +fn run_zsh_pretend() -> anyhow::Result { + let mut session = spawn_session( + CommandBuilder::new(ZSH_PRETEND_BIN), + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), + )?; + + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; + + scenario().run("zsh-pretend", &mut session) +} + +/// In the tiny overflow scenario, real zsh draws a start-ellipsis marker +/// (`>....`) on the first visible row when the logical input starts before +/// the viewport. +/// +/// This marker is emitted by zle refresh internals and could not be disabled +/// via runtime prompt options in this harness, so `zsh` and `zsh-pretend` +/// intentionally differ on the first rendered row (`r00`). +/// +/// Reference: +/// - https://github.com/zsh-users/zsh/blob/zsh-5.9/Src/Zle/zle_refresh.c#L1677 +/// +/// To keep this test focused on wrap/scroll behavior, we require strict +/// equality for scenario shape (step count, labels, row count) and compare +/// screen content from the second row (`r01`) onward. +fn assert_scenario_runs_match_ignoring_row0( + expected: &ScenarioRun, + actual: &ScenarioRun, +) -> anyhow::Result<()> { + let matches = + expected.records.len() == actual.records.len() + && expected.records.iter().zip(&actual.records).all( + |(expected_record, actual_record)| { + expected_record.label == actual_record.label + && expected_record.screen.len() == actual_record.screen.len() + && expected_record + .screen + .iter() + .skip(1) + .eq(actual_record.screen.iter().skip(1)) + }, + ); + + if matches { + Ok(()) + } else { + anyhow::bail!( + "zsh-pretend output diverged from zsh (ignoring first line of each screen)\n\n== expected ==\n{}\n== actual ==\n{}", + render_scenario_run(expected)?, + render_scenario_run(actual)?, + ) + } +} diff --git a/event-dbg/Cargo.toml b/zsherio/Cargo.toml similarity index 52% rename from event-dbg/Cargo.toml rename to zsherio/Cargo.toml index 7265784b..faba240b 100644 --- a/event-dbg/Cargo.toml +++ b/zsherio/Cargo.toml @@ -1,9 +1,10 @@ [package] -name = "event-dbg" +name = "zsherio" version = "0.1.0" edition = "2024" publish = false [dependencies] anyhow = { workspace = true } -crossterm = { workspace = true } +portable-pty = { workspace = true } +termharness = { path = "../termharness" } diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs new file mode 100644 index 00000000..8e635c8c --- /dev/null +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -0,0 +1,18 @@ +use std::{thread, time::Duration}; + +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::mid_buffer_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, + session::spawn_zsh_session, +}; + +fn main() -> anyhow::Result<()> { + let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; + + // Before create scenario, move cursor to bottom. + clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; + thread::sleep(Duration::from_millis(300)); + + let run = scenario().run("zsh", &mut session)?; + run.write_to_stdout() +} diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs new file mode 100644 index 00000000..1524e77b --- /dev/null +++ b/zsherio/src/lib.rs @@ -0,0 +1,5 @@ +pub mod opts; +pub mod scenario; +pub mod scenarios; +pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; +pub mod session; diff --git a/zsherio/src/opts.rs b/zsherio/src/opts.rs new file mode 100644 index 00000000..16abf431 --- /dev/null +++ b/zsherio/src/opts.rs @@ -0,0 +1,47 @@ +use std::io::Write; + +use termharness::session::Session; + +/// Send bytes to the session's stdin. +pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { + let mut writer = session + .writer + .lock() + .expect("failed to lock session writer"); + writer.write_all(bytes)?; + writer.flush()?; + Ok(()) +} + +/// Move the cursor to the given row and column (1-indexed). +pub fn move_cursor_to(session: &mut Session, row: u16, col: u16) -> anyhow::Result<()> { + let command = format!("printf '\\x1b[{};{}H'\r", row, col); + send_bytes(session, command.as_bytes()) +} + +/// Clear the visible screen and move the cursor to the given row and column (1-indexed). +/// +/// This is useful when positioning the prompt via a shell command because the command itself +/// is echoed before it runs. Clearing after execution prevents that setup command from +/// remaining in subsequent screen snapshots. +pub fn clear_screen_and_move_cursor_to( + session: &mut Session, + row: u16, + col: u16, +) -> anyhow::Result<()> { + let command = format!("printf '\\x1b[2J\\x1b[{};{}H'\r", row, col); + send_bytes(session, command.as_bytes()) +} + +/// Move the cursor left by the given number of times. +pub fn move_cursor_left(session: &mut Session, times: usize) -> anyhow::Result<()> { + let mut writer = session + .writer + .lock() + .expect("failed to lock session writer"); + for _ in 0..times { + writer.write_all(b"\x1b[D")?; + } + writer.flush()?; + Ok(()) +} diff --git a/zsherio/src/scenario.rs b/zsherio/src/scenario.rs new file mode 100644 index 00000000..88afed2e --- /dev/null +++ b/zsherio/src/scenario.rs @@ -0,0 +1,218 @@ +use std::{ + fs::File, + io::{self, Write}, + path::Path, + sync::Arc, + thread, + time::Duration, +}; + +use termharness::session::Session; + +pub type StepAction = Arc anyhow::Result<()> + Send + Sync>; + +#[derive(Clone)] +pub struct Scenario { + pub name: String, + pub steps: Vec, +} + +#[derive(Clone)] +pub struct ScenarioStep { + pub label: String, + pub settle: Duration, + pub action: StepAction, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScenarioRecord { + pub label: String, + pub screen: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScenarioRun { + pub scenario_name: String, + pub target_name: String, + pub records: Vec, +} + +impl Scenario { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + steps: Vec::new(), + } + } + + pub fn step(mut self, label: S, settle: Duration, action: F) -> Self + where + F: Fn(&mut Session) -> anyhow::Result<()> + Send + Sync + 'static, + S: Into, + { + self.steps.push(ScenarioStep::new(label, settle, action)); + self + } + + pub fn run( + &self, + target_name: impl Into, + session: &mut Session, + ) -> anyhow::Result { + let mut records = Vec::with_capacity(self.steps.len()); + + for step in &self.steps { + (step.action)(session)?; + thread::sleep(step.settle); + + let screen = session.screen_snapshot(); + records.push(ScenarioRecord { + label: step.label.clone(), + screen: format_screen(&screen, screen.len()), + }); + } + + Ok(ScenarioRun { + scenario_name: self.name.clone(), + target_name: target_name.into(), + records, + }) + } +} + +impl ScenarioStep { + pub fn new(label: S, settle: Duration, action: F) -> Self + where + F: Fn(&mut Session) -> anyhow::Result<()> + Send + Sync + 'static, + S: Into, + { + Self { + label: label.into(), + settle, + action: Arc::new(action), + } + } +} + +impl ScenarioRun { + pub fn write_to(&self, mut writer: W) -> anyhow::Result<()> { + for (index, record) in self.records.iter().enumerate() { + writeln!(writer, "== {} ==", record.label)?; + for line in &record.screen { + writeln!(writer, "{line}")?; + } + if index + 1 != self.records.len() { + writeln!(writer)?; + } + } + Ok(()) + } + + pub fn write_to_path(&self, path: &Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + self.write_to(File::create(path)?) + } + + pub fn write_to_stdout(&self) -> anyhow::Result<()> { + self.write_to(io::stdout()) + } +} + +/// Format a single line of the screen, replacing spaces with a visible character and marking missing lines. +fn format_screen_line(line: Option<&String>) -> String { + match line { + Some(line) => format!("|{}|", line.replace(' ', "·")), + None => "".to_string(), + } +} + +/// Format an entire screen, prefixing each line with its row number and marking differences. +fn format_screen(lines: &[String], total_rows: usize) -> Vec { + (0..total_rows) + .map(|row| format!(" r{row:02} {}", format_screen_line(lines.get(row)))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + mod format_screen_line { + use super::*; + + #[test] + fn replaces_spaces() { + assert_eq!(format_screen_line(Some(&"a b c".to_string())), "|a·b·c|"); + } + + #[test] + fn handles_empty_line() { + assert_eq!(format_screen_line(Some(&"".to_string())), "||"); + } + + #[test] + fn handles_missing_line() { + assert_eq!(format_screen_line(None), ""); + } + } + + mod format_screen { + use super::*; + + #[test] + fn formats_multiple_lines() { + let lines = vec![ + "line 1".to_string(), + "line 2".to_string(), + "line 3".to_string(), + ]; + let formatted = format_screen(&lines, 5); + assert_eq!( + formatted, + vec![ + " r00 |line·1|".to_string(), + " r01 |line·2|".to_string(), + " r02 |line·3|".to_string(), + " r03 ".to_string(), + " r04 ".to_string(), + ] + ); + } + } + + mod scenario_run { + use super::*; + + mod write_to { + use super::*; + + #[test] + fn write_to_matches_print_screen_style() { + let run = ScenarioRun { + scenario_name: "mid_buffer_insert_wrap".to_string(), + target_name: "zsh".to_string(), + records: vec![ + ScenarioRecord { + label: "type text".to_string(), + screen: vec![" r00 |hello|".to_string(), " r01 |world|".to_string()], + }, + ScenarioRecord { + label: "insert text".to_string(), + screen: vec![" r00 |hello again|".to_string()], + }, + ], + }; + + let mut output = Vec::new(); + run.write_to(&mut output).unwrap(); + + assert_eq!( + String::from_utf8(output).unwrap(), + "== type text ==\n r00 |hello|\n r01 |world|\n\n== insert text ==\n r00 |hello again|\n" + ); + } + } + } +} diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs new file mode 100644 index 00000000..ffed32b7 --- /dev/null +++ b/zsherio/src/scenarios.rs @@ -0,0 +1,115 @@ +pub mod mid_buffer_insert_wrap { + use std::time::Duration; + + use crate::{ + Scenario, + opts::{move_cursor_left, send_bytes}, + }; + + pub const TERMINAL_ROWS: u16 = 10; + pub const TERMINAL_COLS: u16 = 40; + pub const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; + pub const INSERTED_TEXT: &str = " and open source maintainer"; + pub const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; + + pub fn scenario() -> Scenario { + Scenario::new("mid_buffer_insert_wrap") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step("type text", Duration::from_millis(100), |session| { + send_bytes(session, INPUT_TEXT.as_bytes()) + }) + .step("move cursor left", Duration::from_millis(100), |session| { + move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) + }) + .step("insert text", Duration::from_millis(100), |session| { + send_bytes(session, INSERTED_TEXT.as_bytes()) + }) + } +} + +pub mod prompt_initial_render_at_mid_screen { + use std::time::Duration; + + use crate::Scenario; + + pub const TERMINAL_ROWS: u16 = 10; + pub const TERMINAL_COLS: u16 = 40; + pub const START_CURSOR_ROW: u16 = TERMINAL_ROWS / 2; + pub const START_CURSOR_COL: u16 = 0; + + pub fn scenario() -> Scenario { + Scenario::new("prompt_initial_render_at_mid_screen").step( + "spawn", + Duration::from_millis(300), + |_session| Ok(()), + ) + } +} + +pub mod resize_roundtrip_wrap_reflow { + use std::time::Duration; + + use termharness::terminal::TerminalSize; + + use crate::{ + Scenario, + opts::{move_cursor_left, send_bytes}, + }; + + pub const TERMINAL_ROWS: u16 = 10; + pub const TERMINAL_COLS: u16 = 40; + pub const RESIZED_TERMINAL_COLS: u16 = 20; + pub const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; + + pub fn scenario() -> Scenario { + let mut scenario = Scenario::new("resize_roundtrip_wrap_reflow") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step("run echo", Duration::from_millis(100), |session| { + send_bytes(session, b"\"ynqa is a software engineer\"\r") + }) + .step("type text", Duration::from_millis(100), |session| { + send_bytes(session, b"this is terminal test suite!") + }); + + // Move the cursor far enough left so resizes do not reflow the active + // input across the visible boundary. + scenario = scenario.step("move cursor left", Duration::from_millis(100), |session| { + move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) + }); + for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { + scenario = scenario.step( + format!("resize -> {cols} cols"), + Duration::from_millis(100), + move |session| session.resize(TerminalSize::new(TERMINAL_ROWS, cols)), + ); + } + for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { + scenario = scenario.step( + format!("resize -> {cols} cols"), + Duration::from_millis(100), + move |session| session.resize(TerminalSize::new(TERMINAL_ROWS, cols)), + ); + } + + scenario + } +} + +pub mod tiny_viewport_overflow_wrap_scroll { + use std::time::Duration; + + use crate::{Scenario, opts::send_bytes}; + + pub const TERMINAL_ROWS: u16 = 4; + pub const TERMINAL_COLS: u16 = 12; + pub const INPUT_TEXT: &str = + "this input should overflow a tiny terminal viewport and keep wrapping"; + + pub fn scenario() -> Scenario { + Scenario::new("tiny_viewport_overflow_wrap_scroll") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step("type long text", Duration::from_millis(100), |session| { + send_bytes(session, INPUT_TEXT.as_bytes()) + }) + } +} diff --git a/zsherio/src/session.rs b/zsherio/src/session.rs new file mode 100644 index 00000000..150994db --- /dev/null +++ b/zsherio/src/session.rs @@ -0,0 +1,25 @@ +use portable_pty::CommandBuilder; +use termharness::session::Session; + +/// Spawn a session with the given command, terminal size, and initial cursor position. +pub fn spawn_session( + cmd: CommandBuilder, + term_size: (u16, u16), + cursor_pos: Option<(u16, u16)>, +) -> anyhow::Result { + Session::spawn(cmd, term_size, cursor_pos) +} + +/// Spawn a zsh session with the given terminal size. +pub fn spawn_zsh_session( + term_size: (u16, u16), + cursor_pos: Option<(u16, u16)>, +) -> anyhow::Result { + let mut cmd = CommandBuilder::new("/bin/zsh"); + cmd.arg("-fi"); + cmd.env("PS1", "❯❯ "); + cmd.env("RPS1", ""); + cmd.env("RPROMPT", ""); + cmd.env("PROMPT_EOL_MARK", ""); + spawn_session(cmd, term_size, cursor_pos) +}