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
[](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)
+}