From 56d8fac45612866a3e9722af375c0178a38aaeee Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 9 Mar 2026 21:07:37 +0900 Subject: [PATCH 01/81] wip; tests using vterm --- examples/readline/Cargo.toml | 4 + .../tests/cases/middle_insert_wrap.after.txt | 6 + .../tests/cases/middle_insert_wrap.before.txt | 6 + examples/readline/tests/pty_linewrap.rs | 243 ++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 examples/readline/tests/cases/middle_insert_wrap.after.txt create mode 100644 examples/readline/tests/cases/middle_insert_wrap.before.txt create mode 100644 examples/readline/tests/pty_linewrap.rs diff --git a/examples/readline/Cargo.toml b/examples/readline/Cargo.toml index cede310c..95a9f4bc 100644 --- a/examples/readline/Cargo.toml +++ b/examples/readline/Cargo.toml @@ -16,3 +16,7 @@ path = "src/readline.rs" [[bin]] name = "readline-loop" path = "src/readline_loop.rs" + +[dev-dependencies] +portable-pty = "0.9.0" +vt100 = "0.16.2" diff --git a/examples/readline/tests/cases/middle_insert_wrap.after.txt b/examples/readline/tests/cases/middle_insert_wrap.after.txt new file mode 100644 index 00000000..039b37c0 --- /dev/null +++ b/examples/readline/tests/cases/middle_insert_wrap.after.txt @@ -0,0 +1,6 @@ + + + +Hi! +❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijk +lmnopqr diff --git a/examples/readline/tests/cases/middle_insert_wrap.before.txt b/examples/readline/tests/cases/middle_insert_wrap.before.txt new file mode 100644 index 00000000..d33ff3e5 --- /dev/null +++ b/examples/readline/tests/cases/middle_insert_wrap.before.txt @@ -0,0 +1,6 @@ + + + + +Hi! +❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr diff --git a/examples/readline/tests/pty_linewrap.rs b/examples/readline/tests/pty_linewrap.rs new file mode 100644 index 00000000..c9b2efc6 --- /dev/null +++ b/examples/readline/tests/pty_linewrap.rs @@ -0,0 +1,243 @@ +use std::{ + fs, + io::{Read, Write}, + path::PathBuf, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + +const TERMINAL_ROWS: u16 = 6; +const TERMINAL_COLS: u16 = 80; +const INITIAL_CURSOR_ROW: u16 = 6; // 1-based CPR +const INITIAL_CURSOR_COL: u16 = 1; // 1-based CPR + +const BEFORE_TEXT: &str = + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; +const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; +const LEFT_MOVES: usize = 20; +const EXPECTED_RESULT: &str = + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijklmnopqr"; + +#[derive(Clone, Copy)] +enum InputEvent<'a> { + Type(&'a str), + Left(usize), + Enter, +} + +fn run_events(writer: &mut dyn Write, events: &[InputEvent<'_>]) -> anyhow::Result<()> { + for event in events { + match event { + InputEvent::Type(text) => writer.write_all(text.as_bytes())?, + InputEvent::Left(times) => { + for _ in 0..*times { + writer.write_all(b"\x1b[D")?; + } + } + InputEvent::Enter => writer.write_all(b"\r")?, + } + writer.flush()?; + thread::sleep(Duration::from_millis(120)); + } + Ok(()) +} + +fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { + let content = fs::read_to_string(path)?; + let lines = content + .lines() + .map(|line| line.trim_end().to_string()) + .collect::>(); + if lines.is_empty() { + return Err(anyhow::anyhow!("empty expected screen file: {}", path.display())); + } + Ok(lines) +} + +fn assert_screen( + snapshot: &[u8], + rows: u16, + cols: u16, + expected: &[String], + expect_wrapped: bool, + stage: &str, +) -> anyhow::Result<()> { + let mut parser = vt100::Parser::new(rows, cols, 0); + parser.process(snapshot); + let screen = parser.screen(); + + let actual = screen + .rows(0, cols) + .map(|row| row.trim_end().to_string()) + .collect::>(); + + if std::env::var("PROMKIT_TEST_DUMP_SCREEN") + .ok() + .as_deref() + == Some("1") + { + eprintln!("[{stage}] screen dump:"); + for (i, row) in actual.iter().enumerate() { + eprintln!("r{i:02}: {row:?}"); + } + } + + assert!( + expected.len() <= actual.len(), + "[{stage}] expected lines overflow: expected={}, actual={}", + expected.len(), + actual.len() + ); + + for (i, exp) in expected.iter().enumerate() { + assert_eq!( + actual[i], *exp, + "[{stage}] row mismatch at {}: expected {:?}, got {:?}", + i, exp, actual[i] + ); + } + + // Ensure line-wrap behavior matches expectation. + let prompt_row = actual + .iter() + .position(|row| row.starts_with("❯❯ ")) + .ok_or_else(|| anyhow::anyhow!("[{stage}] prompt row not found"))?; + if expect_wrapped { + let next_row = actual.get(prompt_row + 1).ok_or_else(|| { + anyhow::anyhow!("[{stage}] row after prompt not found; wrapping did not happen") + })?; + assert!( + !next_row.trim().is_empty(), + "[{stage}] expected wrapped text on row {}", + prompt_row + 1 + ); + } else { + if let Some(next_row) = actual.get(prompt_row + 1) { + assert!( + next_row.trim().is_empty(), + "[{stage}] expected no wrapped text but got {:?} on row {}", + next_row, + prompt_row + 1 + ); + } + } + + Ok(()) +} + +fn spawn_readline() -> anyhow::Result<( + Box, + Box, + Arc>>, + thread::JoinHandle<()>, +)> { + let pty = native_pty_system(); + let pair = pty.openpty(PtySize { + rows: TERMINAL_ROWS, + cols: TERMINAL_COLS, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); + cmd.env("TERM", "xterm-256color"); + let child = pair.slave.spawn_command(cmd)?; + drop(pair.slave); + + let output = Arc::new(Mutex::new(Vec::::new())); + let output_reader = Arc::clone(&output); + let mut reader = pair.master.try_clone_reader()?; + let reader_thread = thread::spawn(move || { + let mut buf = [0_u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => output_reader + .lock() + .expect("failed to lock output buffer") + .extend_from_slice(&buf[..n]), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let writer = pair.master.take_writer()?; + Ok((child, writer, output, reader_thread)) +} + +#[test] +fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<()> { + let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let before_file = case_dir.join("middle_insert_wrap.before.txt"); + let after_file = case_dir.join("middle_insert_wrap.after.txt"); + let expected_before = read_expected_screen(&before_file)?; + let expected_after = read_expected_screen(&after_file)?; + + let (mut child, mut writer, output, reader_thread) = spawn_readline()?; + + // crossterm asks CPR with ESC[6n on startup. + let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); + writer.write_all(cpr.as_bytes())?; + writer.flush()?; + thread::sleep(Duration::from_millis(200)); + + run_events(&mut writer, &[InputEvent::Type(BEFORE_TEXT)])?; + thread::sleep(Duration::from_millis(250)); + let before_snapshot = output + .lock() + .expect("failed to lock output buffer") + .clone(); + assert_screen( + &before_snapshot, + TERMINAL_ROWS, + TERMINAL_COLS, + &expected_before, + false, + "before", + )?; + + run_events( + &mut writer, + &[InputEvent::Left(LEFT_MOVES), InputEvent::Type(INSERTED_TEXT)], + )?; + thread::sleep(Duration::from_millis(250)); + let after_snapshot = output + .lock() + .expect("failed to lock output buffer") + .clone(); + assert_screen( + &after_snapshot, + TERMINAL_ROWS, + TERMINAL_COLS, + &expected_after, + true, + "after", + )?; + + run_events(&mut writer, &[InputEvent::Enter])?; + drop(writer); + + let status = child.wait()?; + reader_thread.join().expect("reader thread panicked"); + let all_output = output + .lock() + .expect("failed to lock output buffer") + .clone(); + let full_text = String::from_utf8_lossy(&all_output); + + assert!( + status.success(), + "readline exited with code {}: {full_text:?}", + status.exit_code() + ); + assert!( + full_text.contains(&format!("result: \"{EXPECTED_RESULT}\"")), + "unexpected final output: {full_text:?}" + ); + + Ok(()) +} From 7ca6b62a743fe51400413714b53e1afe37d2b132 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 9 Mar 2026 21:37:25 +0900 Subject: [PATCH 02/81] wip; tests for resizing --- .../tests/cases/resize_wrap.after.txt | 6 + .../tests/cases/resize_wrap.before.txt | 6 + examples/readline/tests/pty_linewrap.rs | 168 +++++++++++++----- 3 files changed, 138 insertions(+), 42 deletions(-) create mode 100644 examples/readline/tests/cases/resize_wrap.after.txt create mode 100644 examples/readline/tests/cases/resize_wrap.before.txt diff --git a/examples/readline/tests/cases/resize_wrap.after.txt b/examples/readline/tests/cases/resize_wrap.after.txt new file mode 100644 index 00000000..27bd8d96 --- /dev/null +++ b/examples/readline/tests/cases/resize_wrap.after.txt @@ -0,0 +1,6 @@ + + + +Hi! +❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopq +r diff --git a/examples/readline/tests/cases/resize_wrap.before.txt b/examples/readline/tests/cases/resize_wrap.before.txt new file mode 100644 index 00000000..d33ff3e5 --- /dev/null +++ b/examples/readline/tests/cases/resize_wrap.before.txt @@ -0,0 +1,6 @@ + + + + +Hi! +❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr diff --git a/examples/readline/tests/pty_linewrap.rs b/examples/readline/tests/pty_linewrap.rs index c9b2efc6..c9e9d6e0 100644 --- a/examples/readline/tests/pty_linewrap.rs +++ b/examples/readline/tests/pty_linewrap.rs @@ -7,15 +7,15 @@ use std::{ time::Duration, }; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; const INITIAL_CURSOR_ROW: u16 = 6; // 1-based CPR const INITIAL_CURSOR_COL: u16 = 1; // 1-based CPR +const RESIZED_TERMINAL_COLS: u16 = 72; -const BEFORE_TEXT: &str = - "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; +const BEFORE_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; const LEFT_MOVES: usize = 20; const EXPECTED_RESULT: &str = @@ -52,7 +52,10 @@ fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { .map(|line| line.trim_end().to_string()) .collect::>(); if lines.is_empty() { - return Err(anyhow::anyhow!("empty expected screen file: {}", path.display())); + return Err(anyhow::anyhow!( + "empty expected screen file: {}", + path.display() + )); } Ok(lines) } @@ -65,20 +68,11 @@ fn assert_screen( expect_wrapped: bool, stage: &str, ) -> anyhow::Result<()> { - let mut parser = vt100::Parser::new(rows, cols, 0); - parser.process(snapshot); - let screen = parser.screen(); + let actual = render_screen(snapshot, rows, cols); + let expected_dump = format_screen_dump(expected); + let actual_dump = format_screen_dump(&actual); - let actual = screen - .rows(0, cols) - .map(|row| row.trim_end().to_string()) - .collect::>(); - - if std::env::var("PROMKIT_TEST_DUMP_SCREEN") - .ok() - .as_deref() - == Some("1") - { + if std::env::var("PROMKIT_TEST_DUMP_SCREEN").ok().as_deref() == Some("1") { eprintln!("[{stage}] screen dump:"); for (i, row) in actual.iter().enumerate() { eprintln!("r{i:02}: {row:?}"); @@ -87,16 +81,18 @@ fn assert_screen( assert!( expected.len() <= actual.len(), - "[{stage}] expected lines overflow: expected={}, actual={}", + "[{stage}] expected lines overflow: expected={}, actual={}\nexpected:\n{}\nactual:\n{}", expected.len(), - actual.len() + actual.len(), + expected_dump, + actual_dump ); for (i, exp) in expected.iter().enumerate() { assert_eq!( actual[i], *exp, - "[{stage}] row mismatch at {}: expected {:?}, got {:?}", - i, exp, actual[i] + "[{stage}] row mismatch at {}: expected {:?}, got {:?}\nexpected:\n{}\nactual:\n{}", + i, exp, actual[i], expected_dump, actual_dump ); } @@ -111,16 +107,20 @@ fn assert_screen( })?; assert!( !next_row.trim().is_empty(), - "[{stage}] expected wrapped text on row {}", - prompt_row + 1 + "[{stage}] expected wrapped text on row {}\nexpected:\n{}\nactual:\n{}", + prompt_row + 1, + expected_dump, + actual_dump ); } else { if let Some(next_row) = actual.get(prompt_row + 1) { assert!( next_row.trim().is_empty(), - "[{stage}] expected no wrapped text but got {:?} on row {}", + "[{stage}] expected no wrapped text but got {:?} on row {}\nexpected:\n{}\nactual:\n{}", next_row, - prompt_row + 1 + prompt_row + 1, + expected_dump, + actual_dump ); } } @@ -128,8 +128,28 @@ fn assert_screen( Ok(()) } +fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { + let mut parser = vt100::Parser::new(rows, cols, 0); + parser.process(snapshot); + parser + .screen() + .rows(0, cols) + .map(|row| row.trim_end().to_string()) + .collect::>() +} + +fn format_screen_dump(lines: &[String]) -> String { + lines + .iter() + .enumerate() + .map(|(i, line)| format!("r{i:02}: {:?}", line)) + .collect::>() + .join("\n") +} + fn spawn_readline() -> anyhow::Result<( Box, + Box, Box, Arc>>, thread::JoinHandle<()>, @@ -147,9 +167,10 @@ fn spawn_readline() -> anyhow::Result<( 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 mut reader = pair.master.try_clone_reader()?; + let mut reader = master.try_clone_reader()?; let reader_thread = thread::spawn(move || { let mut buf = [0_u8; 4096]; loop { @@ -165,8 +186,8 @@ fn spawn_readline() -> anyhow::Result<( } }); - let writer = pair.master.take_writer()?; - Ok((child, writer, output, reader_thread)) + let writer = master.take_writer()?; + Ok((child, master, writer, output, reader_thread)) } #[test] @@ -177,7 +198,7 @@ fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<( let expected_before = read_expected_screen(&before_file)?; let expected_after = read_expected_screen(&after_file)?; - let (mut child, mut writer, output, reader_thread) = spawn_readline()?; + let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; // crossterm asks CPR with ESC[6n on startup. let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); @@ -187,10 +208,7 @@ fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<( run_events(&mut writer, &[InputEvent::Type(BEFORE_TEXT)])?; thread::sleep(Duration::from_millis(250)); - let before_snapshot = output - .lock() - .expect("failed to lock output buffer") - .clone(); + let before_snapshot = output.lock().expect("failed to lock output buffer").clone(); assert_screen( &before_snapshot, TERMINAL_ROWS, @@ -202,13 +220,13 @@ fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<( run_events( &mut writer, - &[InputEvent::Left(LEFT_MOVES), InputEvent::Type(INSERTED_TEXT)], + &[ + InputEvent::Left(LEFT_MOVES), + InputEvent::Type(INSERTED_TEXT), + ], )?; thread::sleep(Duration::from_millis(250)); - let after_snapshot = output - .lock() - .expect("failed to lock output buffer") - .clone(); + let after_snapshot = output.lock().expect("failed to lock output buffer").clone(); assert_screen( &after_snapshot, TERMINAL_ROWS, @@ -223,10 +241,7 @@ fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<( let status = child.wait()?; reader_thread.join().expect("reader thread panicked"); - let all_output = output - .lock() - .expect("failed to lock output buffer") - .clone(); + let all_output = output.lock().expect("failed to lock output buffer").clone(); let full_text = String::from_utf8_lossy(&all_output); assert!( @@ -241,3 +256,72 @@ fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<( Ok(()) } + +#[test] +fn does_not_duplicate_title_when_resizing_to_wrap_bottom_prompt() -> anyhow::Result<()> { + let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let before_file = case_dir.join("resize_wrap.before.txt"); + let after_file = case_dir.join("resize_wrap.after.txt"); + let expected_before = read_expected_screen(&before_file)?; + let expected_after = read_expected_screen(&after_file)?; + + let (mut child, master, mut writer, output, reader_thread) = spawn_readline()?; + + let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); + writer.write_all(cpr.as_bytes())?; + writer.flush()?; + thread::sleep(Duration::from_millis(200)); + + run_events(&mut writer, &[InputEvent::Type(BEFORE_TEXT)])?; + thread::sleep(Duration::from_millis(250)); + + let before_snapshot = output.lock().expect("failed to lock output buffer").clone(); + assert_screen( + &before_snapshot, + TERMINAL_ROWS, + TERMINAL_COLS, + &expected_before, + false, + "before-resize", + )?; + + for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { + master.resize(PtySize { + rows: TERMINAL_ROWS, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + thread::sleep(Duration::from_millis(150)); + } + + let resized_snapshot = output.lock().expect("failed to lock output buffer").clone(); + assert_screen( + &resized_snapshot, + TERMINAL_ROWS, + RESIZED_TERMINAL_COLS, + &expected_after, + true, + "after-resize", + )?; + + run_events(&mut writer, &[InputEvent::Enter])?; + drop(writer); + + let status = child.wait()?; + reader_thread.join().expect("reader thread panicked"); + let all_output = output.lock().expect("failed to lock output buffer").clone(); + let full_text = String::from_utf8_lossy(&all_output); + + assert!( + status.success(), + "readline exited with code {}: {full_text:?}", + status.exit_code() + ); + assert!( + full_text.contains(&format!("result: \"{BEFORE_TEXT}\"")), + "unexpected final output: {full_text:?}" + ); + + Ok(()) +} From 9441b1e92c93ff27570f29ba53cf9d219eb719ae Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 9 Mar 2026 23:01:08 +0900 Subject: [PATCH 03/81] wip; tests for middle_insert --- ...rap.after.txt => resize_wrap.expected.txt} | 0 examples/readline/tests/middle_insert_wrap.rs | 137 ++++++++++ examples/readline/tests/pty_linewrap.rs | 245 ++---------------- 3 files changed, 164 insertions(+), 218 deletions(-) rename examples/readline/tests/cases/{resize_wrap.after.txt => resize_wrap.expected.txt} (100%) create mode 100644 examples/readline/tests/middle_insert_wrap.rs diff --git a/examples/readline/tests/cases/resize_wrap.after.txt b/examples/readline/tests/cases/resize_wrap.expected.txt similarity index 100% rename from examples/readline/tests/cases/resize_wrap.after.txt rename to examples/readline/tests/cases/resize_wrap.expected.txt diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs new file mode 100644 index 00000000..85f72090 --- /dev/null +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -0,0 +1,137 @@ +use std::{ + fs, + io::{Read, Write}, + path::PathBuf, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; + +const TERMINAL_ROWS: u16 = 6; +const TERMINAL_COLS: u16 = 80; +const INITIAL_CURSOR_ROW: u16 = 6; +const INITIAL_CURSOR_COL: u16 = 1; +const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; +const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; +const LEFT_MOVES: usize = 20; + +fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { + let content = fs::read_to_string(path)?; + Ok(content + .lines() + .map(|line| line.trim_end().to_string()) + .collect()) +} + +fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { + let mut parser = vt100::Parser::new(rows, cols, 0); + parser.process(snapshot); + parser + .screen() + .rows(0, cols) + .map(|row| row.trim_end().to_string()) + .collect() +} + +fn format_screen_dump(lines: &[String]) -> String { + lines + .iter() + .enumerate() + .map(|(i, line)| format!("r{i:02}: {:?}", line)) + .collect::>() + .join("\n") +} + +fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { + let actual = render_screen(snapshot, rows, cols); + assert_eq!( + actual, + expected, + "screen mismatch\nexpected:\n{}\nactual:\n{}", + format_screen_dump(expected), + format_screen_dump(&actual) + ); +} + +fn spawn_readline() -> anyhow::Result<( + Box, + Box, + Box, + Arc>>, + thread::JoinHandle<()>, +)> { + let pty = native_pty_system(); + let pair = pty.openpty(PtySize { + rows: TERMINAL_ROWS, + cols: TERMINAL_COLS, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); + 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 mut reader = master.try_clone_reader()?; + let reader_thread = thread::spawn(move || { + let mut buf = [0_u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => output_reader + .lock() + .expect("failed to lock output buffer") + .extend_from_slice(&buf[..n]), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let writer = master.take_writer()?; + Ok((child, master, writer, output, reader_thread)) +} + +#[test] +fn middle_insert_wrap() -> anyhow::Result<()> { + let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let expected = read_expected_screen(&case_dir.join("middle_insert_wrap.after.txt"))?; + + let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; + + let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); + writer.write_all(cpr.as_bytes())?; + writer.flush()?; + thread::sleep(Duration::from_millis(200)); + + writer.write_all(INPUT_TEXT.as_bytes())?; + writer.flush()?; + thread::sleep(Duration::from_millis(120)); + + for _ in 0..LEFT_MOVES { + writer.write_all(b"\x1b[D")?; + writer.flush()?; + thread::sleep(Duration::from_millis(20)); + } + + writer.write_all(INSERTED_TEXT.as_bytes())?; + writer.flush()?; + thread::sleep(Duration::from_millis(250)); + + let snapshot = output.lock().expect("failed to lock output buffer").clone(); + assert_screen(&snapshot, TERMINAL_ROWS, TERMINAL_COLS, &expected); + + writer.write_all(b"\r")?; + writer.flush()?; + drop(writer); + + let _ = child.wait()?; + reader_thread.join().expect("reader thread panicked"); + Ok(()) +} diff --git a/examples/readline/tests/pty_linewrap.rs b/examples/readline/tests/pty_linewrap.rs index c9e9d6e0..2b5f36ef 100644 --- a/examples/readline/tests/pty_linewrap.rs +++ b/examples/readline/tests/pty_linewrap.rs @@ -11,121 +11,17 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; -const INITIAL_CURSOR_ROW: u16 = 6; // 1-based CPR -const INITIAL_CURSOR_COL: u16 = 1; // 1-based CPR const RESIZED_TERMINAL_COLS: u16 = 72; - -const BEFORE_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; -const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; -const LEFT_MOVES: usize = 20; -const EXPECTED_RESULT: &str = - "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijklmnopqr"; - -#[derive(Clone, Copy)] -enum InputEvent<'a> { - Type(&'a str), - Left(usize), - Enter, -} - -fn run_events(writer: &mut dyn Write, events: &[InputEvent<'_>]) -> anyhow::Result<()> { - for event in events { - match event { - InputEvent::Type(text) => writer.write_all(text.as_bytes())?, - InputEvent::Left(times) => { - for _ in 0..*times { - writer.write_all(b"\x1b[D")?; - } - } - InputEvent::Enter => writer.write_all(b"\r")?, - } - writer.flush()?; - thread::sleep(Duration::from_millis(120)); - } - Ok(()) -} +const INITIAL_CURSOR_ROW: u16 = 6; +const INITIAL_CURSOR_COL: u16 = 1; +const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { let content = fs::read_to_string(path)?; - let lines = content + Ok(content .lines() .map(|line| line.trim_end().to_string()) - .collect::>(); - if lines.is_empty() { - return Err(anyhow::anyhow!( - "empty expected screen file: {}", - path.display() - )); - } - Ok(lines) -} - -fn assert_screen( - snapshot: &[u8], - rows: u16, - cols: u16, - expected: &[String], - expect_wrapped: bool, - stage: &str, -) -> anyhow::Result<()> { - let actual = render_screen(snapshot, rows, cols); - let expected_dump = format_screen_dump(expected); - let actual_dump = format_screen_dump(&actual); - - if std::env::var("PROMKIT_TEST_DUMP_SCREEN").ok().as_deref() == Some("1") { - eprintln!("[{stage}] screen dump:"); - for (i, row) in actual.iter().enumerate() { - eprintln!("r{i:02}: {row:?}"); - } - } - - assert!( - expected.len() <= actual.len(), - "[{stage}] expected lines overflow: expected={}, actual={}\nexpected:\n{}\nactual:\n{}", - expected.len(), - actual.len(), - expected_dump, - actual_dump - ); - - for (i, exp) in expected.iter().enumerate() { - assert_eq!( - actual[i], *exp, - "[{stage}] row mismatch at {}: expected {:?}, got {:?}\nexpected:\n{}\nactual:\n{}", - i, exp, actual[i], expected_dump, actual_dump - ); - } - - // Ensure line-wrap behavior matches expectation. - let prompt_row = actual - .iter() - .position(|row| row.starts_with("❯❯ ")) - .ok_or_else(|| anyhow::anyhow!("[{stage}] prompt row not found"))?; - if expect_wrapped { - let next_row = actual.get(prompt_row + 1).ok_or_else(|| { - anyhow::anyhow!("[{stage}] row after prompt not found; wrapping did not happen") - })?; - assert!( - !next_row.trim().is_empty(), - "[{stage}] expected wrapped text on row {}\nexpected:\n{}\nactual:\n{}", - prompt_row + 1, - expected_dump, - actual_dump - ); - } else { - if let Some(next_row) = actual.get(prompt_row + 1) { - assert!( - next_row.trim().is_empty(), - "[{stage}] expected no wrapped text but got {:?} on row {}\nexpected:\n{}\nactual:\n{}", - next_row, - prompt_row + 1, - expected_dump, - actual_dump - ); - } - } - - Ok(()) + .collect()) } fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { @@ -135,7 +31,7 @@ fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { .screen() .rows(0, cols) .map(|row| row.trim_end().to_string()) - .collect::>() + .collect() } fn format_screen_dump(lines: &[String]) -> String { @@ -147,6 +43,17 @@ fn format_screen_dump(lines: &[String]) -> String { .join("\n") } +fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { + let actual = render_screen(snapshot, rows, cols); + assert_eq!( + actual, + expected, + "screen mismatch\nexpected:\n{}\nactual:\n{}", + format_screen_dump(expected), + format_screen_dump(&actual) + ); +} + fn spawn_readline() -> anyhow::Result<( Box, Box, @@ -168,7 +75,7 @@ fn spawn_readline() -> anyhow::Result<( drop(pair.slave); let master = pair.master; - let output = Arc::new(Mutex::new(Vec::::new())); + let output = Arc::new(Mutex::new(Vec::new())); let output_reader = Arc::clone(&output); let mut reader = master.try_clone_reader()?; let reader_thread = thread::spawn(move || { @@ -191,79 +98,9 @@ fn spawn_readline() -> anyhow::Result<( } #[test] -fn wraps_when_inserting_in_middle_with_bottom_cursor_start() -> anyhow::Result<()> { +fn resize_wrap() -> anyhow::Result<()> { let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); - let before_file = case_dir.join("middle_insert_wrap.before.txt"); - let after_file = case_dir.join("middle_insert_wrap.after.txt"); - let expected_before = read_expected_screen(&before_file)?; - let expected_after = read_expected_screen(&after_file)?; - - let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; - - // crossterm asks CPR with ESC[6n on startup. - let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); - writer.write_all(cpr.as_bytes())?; - writer.flush()?; - thread::sleep(Duration::from_millis(200)); - - run_events(&mut writer, &[InputEvent::Type(BEFORE_TEXT)])?; - thread::sleep(Duration::from_millis(250)); - let before_snapshot = output.lock().expect("failed to lock output buffer").clone(); - assert_screen( - &before_snapshot, - TERMINAL_ROWS, - TERMINAL_COLS, - &expected_before, - false, - "before", - )?; - - run_events( - &mut writer, - &[ - InputEvent::Left(LEFT_MOVES), - InputEvent::Type(INSERTED_TEXT), - ], - )?; - thread::sleep(Duration::from_millis(250)); - let after_snapshot = output.lock().expect("failed to lock output buffer").clone(); - assert_screen( - &after_snapshot, - TERMINAL_ROWS, - TERMINAL_COLS, - &expected_after, - true, - "after", - )?; - - run_events(&mut writer, &[InputEvent::Enter])?; - drop(writer); - - let status = child.wait()?; - reader_thread.join().expect("reader thread panicked"); - let all_output = output.lock().expect("failed to lock output buffer").clone(); - let full_text = String::from_utf8_lossy(&all_output); - - assert!( - status.success(), - "readline exited with code {}: {full_text:?}", - status.exit_code() - ); - assert!( - full_text.contains(&format!("result: \"{EXPECTED_RESULT}\"")), - "unexpected final output: {full_text:?}" - ); - - Ok(()) -} - -#[test] -fn does_not_duplicate_title_when_resizing_to_wrap_bottom_prompt() -> anyhow::Result<()> { - let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); - let before_file = case_dir.join("resize_wrap.before.txt"); - let after_file = case_dir.join("resize_wrap.after.txt"); - let expected_before = read_expected_screen(&before_file)?; - let expected_after = read_expected_screen(&after_file)?; + let expected = read_expected_screen(&case_dir.join("resize_wrap.expected.txt"))?; let (mut child, master, mut writer, output, reader_thread) = spawn_readline()?; @@ -272,19 +109,10 @@ fn does_not_duplicate_title_when_resizing_to_wrap_bottom_prompt() -> anyhow::Res writer.flush()?; thread::sleep(Duration::from_millis(200)); - run_events(&mut writer, &[InputEvent::Type(BEFORE_TEXT)])?; + writer.write_all(INPUT_TEXT.as_bytes())?; + writer.flush()?; thread::sleep(Duration::from_millis(250)); - let before_snapshot = output.lock().expect("failed to lock output buffer").clone(); - assert_screen( - &before_snapshot, - TERMINAL_ROWS, - TERMINAL_COLS, - &expected_before, - false, - "before-resize", - )?; - for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { master.resize(PtySize { rows: TERMINAL_ROWS, @@ -295,33 +123,14 @@ fn does_not_duplicate_title_when_resizing_to_wrap_bottom_prompt() -> anyhow::Res thread::sleep(Duration::from_millis(150)); } - let resized_snapshot = output.lock().expect("failed to lock output buffer").clone(); - assert_screen( - &resized_snapshot, - TERMINAL_ROWS, - RESIZED_TERMINAL_COLS, - &expected_after, - true, - "after-resize", - )?; + let snapshot = output.lock().expect("failed to lock output buffer").clone(); + assert_screen(&snapshot, TERMINAL_ROWS, RESIZED_TERMINAL_COLS, &expected); - run_events(&mut writer, &[InputEvent::Enter])?; + writer.write_all(b"\r")?; + writer.flush()?; drop(writer); - let status = child.wait()?; + let _ = child.wait()?; reader_thread.join().expect("reader thread panicked"); - let all_output = output.lock().expect("failed to lock output buffer").clone(); - let full_text = String::from_utf8_lossy(&all_output); - - assert!( - status.success(), - "readline exited with code {}: {full_text:?}", - status.exit_code() - ); - assert!( - full_text.contains(&format!("result: \"{BEFORE_TEXT}\"")), - "unexpected final output: {full_text:?}" - ); - Ok(()) } From a8587a295a5982862725a19089ecdd403fd167db Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 9 Mar 2026 23:05:19 +0900 Subject: [PATCH 04/81] wip; --- examples/readline/tests/{pty_linewrap.rs => resize_wrap.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/readline/tests/{pty_linewrap.rs => resize_wrap.rs} (100%) diff --git a/examples/readline/tests/pty_linewrap.rs b/examples/readline/tests/resize_wrap.rs similarity index 100% rename from examples/readline/tests/pty_linewrap.rs rename to examples/readline/tests/resize_wrap.rs From 2b0d29d1dadf288e6fc716eb3026a653097f26d5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 01:50:58 +0900 Subject: [PATCH 05/81] wip; define screen --- examples/readline/Cargo.toml | 1 + examples/readline/tests/middle_insert_wrap.rs | 42 +++------- examples/readline/tests/resize_wrap.rs | 42 +++------- examples/readline/tests/support/mod.rs | 82 +++++++++++++++++++ 4 files changed, 109 insertions(+), 58 deletions(-) create mode 100644 examples/readline/tests/support/mod.rs diff --git a/examples/readline/Cargo.toml b/examples/readline/Cargo.toml index 95a9f4bc..a76d42f7 100644 --- a/examples/readline/Cargo.toml +++ b/examples/readline/Cargo.toml @@ -19,4 +19,5 @@ path = "src/readline_loop.rs" [dev-dependencies] portable-pty = "0.9.0" +unicode-width = { workspace = true } vt100 = "0.16.2" diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs index 85f72090..6c90f3be 100644 --- a/examples/readline/tests/middle_insert_wrap.rs +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -1,13 +1,14 @@ +mod support; + use std::{ - fs, io::{Read, Write}, - path::PathBuf, sync::{Arc, Mutex}, thread, time::Duration, }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use support::{assert_screen_eq, pad_to_cols, Screen}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; @@ -17,42 +18,19 @@ const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzab const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; const LEFT_MOVES: usize = 20; -fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { - let content = fs::read_to_string(path)?; - Ok(content - .lines() - .map(|line| line.trim_end().to_string()) - .collect()) -} - fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { let mut parser = vt100::Parser::new(rows, cols, 0); parser.process(snapshot); parser .screen() .rows(0, cols) - .map(|row| row.trim_end().to_string()) + .map(|row| pad_to_cols(cols, &row)) .collect() } -fn format_screen_dump(lines: &[String]) -> String { - lines - .iter() - .enumerate() - .map(|(i, line)| format!("r{i:02}: {:?}", line)) - .collect::>() - .join("\n") -} - fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { let actual = render_screen(snapshot, rows, cols); - assert_eq!( - actual, - expected, - "screen mismatch\nexpected:\n{}\nactual:\n{}", - format_screen_dump(expected), - format_screen_dump(&actual) - ); + assert_screen_eq(expected, &actual); } fn spawn_readline() -> anyhow::Result<( @@ -100,8 +78,14 @@ fn spawn_readline() -> anyhow::Result<( #[test] fn middle_insert_wrap() -> anyhow::Result<()> { - let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); - let expected = read_expected_screen(&case_dir.join("middle_insert_wrap.after.txt"))?; + let expected = Screen::new(TERMINAL_COLS, TERMINAL_ROWS) + .line(3, "Hi!") + .line( + 4, + "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijk", + ) + .line(5, "lmnopqr") + .build(); let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs index 2b5f36ef..66702c08 100644 --- a/examples/readline/tests/resize_wrap.rs +++ b/examples/readline/tests/resize_wrap.rs @@ -1,13 +1,14 @@ +mod support; + use std::{ - fs, io::{Read, Write}, - path::PathBuf, sync::{Arc, Mutex}, thread, time::Duration, }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use support::{assert_screen_eq, pad_to_cols, Screen}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; @@ -16,42 +17,19 @@ const INITIAL_CURSOR_ROW: u16 = 6; const INITIAL_CURSOR_COL: u16 = 1; const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; -fn read_expected_screen(path: &PathBuf) -> anyhow::Result> { - let content = fs::read_to_string(path)?; - Ok(content - .lines() - .map(|line| line.trim_end().to_string()) - .collect()) -} - fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { let mut parser = vt100::Parser::new(rows, cols, 0); parser.process(snapshot); parser .screen() .rows(0, cols) - .map(|row| row.trim_end().to_string()) + .map(|row| pad_to_cols(cols, &row)) .collect() } -fn format_screen_dump(lines: &[String]) -> String { - lines - .iter() - .enumerate() - .map(|(i, line)| format!("r{i:02}: {:?}", line)) - .collect::>() - .join("\n") -} - fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { let actual = render_screen(snapshot, rows, cols); - assert_eq!( - actual, - expected, - "screen mismatch\nexpected:\n{}\nactual:\n{}", - format_screen_dump(expected), - format_screen_dump(&actual) - ); + assert_screen_eq(expected, &actual); } fn spawn_readline() -> anyhow::Result<( @@ -99,8 +77,14 @@ fn spawn_readline() -> anyhow::Result<( #[test] fn resize_wrap() -> anyhow::Result<()> { - let case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); - let expected = read_expected_screen(&case_dir.join("resize_wrap.expected.txt"))?; + let expected = Screen::new(RESIZED_TERMINAL_COLS, TERMINAL_ROWS) + .line(3, "Hi!") + .line( + 4, + "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopq", + ) + .line(5, "r") + .build(); let (mut child, master, mut writer, output, reader_thread) = spawn_readline()?; diff --git a/examples/readline/tests/support/mod.rs b/examples/readline/tests/support/mod.rs new file mode 100644 index 00000000..61417c82 --- /dev/null +++ b/examples/readline/tests/support/mod.rs @@ -0,0 +1,82 @@ +use unicode_width::UnicodeWidthStr; + +#[derive(Debug)] +pub struct Screen { + cols: u16, + rows: u16, + lines: Vec>, +} + +impl Screen { + pub fn new(cols: u16, rows: u16) -> Self { + Self { + cols, + rows, + lines: vec![None; rows as usize], + } + } + + pub fn line(mut self, row: u16, content: &str) -> Self { + let row = row as usize; + assert!(row < self.rows as usize, "row {row} is out of bounds"); + + self.lines[row] = Some(pad_to_cols(self.cols, content)); + self + } + + pub fn build(self) -> Vec { + let blank = " ".repeat(self.cols as usize); + self.lines + .into_iter() + .map(|line| line.unwrap_or_else(|| blank.clone())) + .collect() + } +} + +pub 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 +} + +pub fn assert_screen_eq(expected: &[String], actual: &[String]) { + if actual == expected { + return; + } + + panic!("{}", format_screen_diff(expected, actual)); +} + +fn format_screen_diff(expected: &[String], actual: &[String]) -> String { + let total_rows = expected.len().max(actual.len()); + let differing_rows = (0..total_rows) + .filter(|&row| expected.get(row) != actual.get(row)) + .count(); + + let mut lines = vec![format!("screen mismatch ({differing_rows} differing rows)")]; + lines.push("expected:".to_string()); + lines.extend(format_screen(expected, total_rows)); + lines.push("actual:".to_string()); + lines.extend(format_screen(actual, total_rows)); + + lines.join("\n") +} + +fn format_screen_line(line: Option<&String>) -> String { + match line { + Some(line) => format!("|{}|", line.replace(' ', "·")), + None => "".to_string(), + } +} + +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() +} From b142ee0e7b070e5abc421bd8e387d2d2b2435c1d Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:00:14 +0900 Subject: [PATCH 06/81] chore: remove examples --- examples/readline/tests/cases/middle_insert_wrap.after.txt | 6 ------ examples/readline/tests/cases/middle_insert_wrap.before.txt | 6 ------ examples/readline/tests/cases/resize_wrap.before.txt | 6 ------ examples/readline/tests/cases/resize_wrap.expected.txt | 6 ------ 4 files changed, 24 deletions(-) delete mode 100644 examples/readline/tests/cases/middle_insert_wrap.after.txt delete mode 100644 examples/readline/tests/cases/middle_insert_wrap.before.txt delete mode 100644 examples/readline/tests/cases/resize_wrap.before.txt delete mode 100644 examples/readline/tests/cases/resize_wrap.expected.txt diff --git a/examples/readline/tests/cases/middle_insert_wrap.after.txt b/examples/readline/tests/cases/middle_insert_wrap.after.txt deleted file mode 100644 index 039b37c0..00000000 --- a/examples/readline/tests/cases/middle_insert_wrap.after.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - -Hi! -❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijk -lmnopqr diff --git a/examples/readline/tests/cases/middle_insert_wrap.before.txt b/examples/readline/tests/cases/middle_insert_wrap.before.txt deleted file mode 100644 index d33ff3e5..00000000 --- a/examples/readline/tests/cases/middle_insert_wrap.before.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - - -Hi! -❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr diff --git a/examples/readline/tests/cases/resize_wrap.before.txt b/examples/readline/tests/cases/resize_wrap.before.txt deleted file mode 100644 index d33ff3e5..00000000 --- a/examples/readline/tests/cases/resize_wrap.before.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - - -Hi! -❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr diff --git a/examples/readline/tests/cases/resize_wrap.expected.txt b/examples/readline/tests/cases/resize_wrap.expected.txt deleted file mode 100644 index 27bd8d96..00000000 --- a/examples/readline/tests/cases/resize_wrap.expected.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - -Hi! -❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopq -r From 51ca0a19a2f452b516cd3b153eda424af2db0a4a Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:00:49 +0900 Subject: [PATCH 07/81] feat: create termharness proj --- Cargo.toml | 1 + termharness/Cargo.toml | 19 +++++++++++++++++++ termharness/src/lib.rs | 0 3 files changed, 20 insertions(+) create mode 100644 termharness/Cargo.toml create mode 100644 termharness/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ad10d9ac..2a15d50b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "promkit-core", "promkit-derive", "promkit-widgets", + "termharness", ] [workspace.dependencies] diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml new file mode 100644 index 00000000..49fb5c7a --- /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] +anyhow = { workspace = true } +portable-pty = "0.9.0" +unicode-width = { workspace = true } +vt100 = "0.16.2" diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs new file mode 100644 index 00000000..e69de29b From 2de4a0c24da74cb2b25bd9dfbf614868399dccb6 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:06:59 +0900 Subject: [PATCH 08/81] chore: use termharness to test terminal screen after operations for readline --- examples/readline/Cargo.toml | 1 + examples/readline/tests/middle_insert_wrap.rs | 4 +--- examples/readline/tests/resize_wrap.rs | 4 +--- termharness/src/lib.rs | 1 + .../tests/support/mod.rs => termharness/src/screen.rs | 4 ++++ 5 files changed, 8 insertions(+), 6 deletions(-) rename examples/readline/tests/support/mod.rs => termharness/src/screen.rs (86%) diff --git a/examples/readline/Cargo.toml b/examples/readline/Cargo.toml index a76d42f7..134f7412 100644 --- a/examples/readline/Cargo.toml +++ b/examples/readline/Cargo.toml @@ -19,5 +19,6 @@ path = "src/readline_loop.rs" [dev-dependencies] portable-pty = "0.9.0" +termharness = { path = "../../termharness" } unicode-width = { workspace = true } vt100 = "0.16.2" diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs index 6c90f3be..8bd3d04c 100644 --- a/examples/readline/tests/middle_insert_wrap.rs +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -1,5 +1,3 @@ -mod support; - use std::{ io::{Read, Write}, sync::{Arc, Mutex}, @@ -8,7 +6,7 @@ use std::{ }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use support::{assert_screen_eq, pad_to_cols, Screen}; +use termharness::screen::{assert_screen_eq, pad_to_cols, Screen}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs index 66702c08..3ef66432 100644 --- a/examples/readline/tests/resize_wrap.rs +++ b/examples/readline/tests/resize_wrap.rs @@ -1,5 +1,3 @@ -mod support; - use std::{ io::{Read, Write}, sync::{Arc, Mutex}, @@ -8,7 +6,7 @@ use std::{ }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use support::{assert_screen_eq, pad_to_cols, Screen}; +use termharness::screen::{assert_screen_eq, pad_to_cols, Screen}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index e69de29b..63ac656e 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -0,0 +1 @@ +pub mod screen; diff --git a/examples/readline/tests/support/mod.rs b/termharness/src/screen.rs similarity index 86% rename from examples/readline/tests/support/mod.rs rename to termharness/src/screen.rs index 61417c82..0af9f9d8 100644 --- a/examples/readline/tests/support/mod.rs +++ b/termharness/src/screen.rs @@ -1,5 +1,6 @@ use unicode_width::UnicodeWidthStr; +/// A builder for constructing a screen representation for testing purposes. #[derive(Debug)] pub struct Screen { cols: u16, @@ -8,6 +9,7 @@ pub struct Screen { } impl Screen { + // Create a new screen with the specified dimensions, initialized with blank lines. pub fn new(cols: u16, rows: u16) -> Self { Self { cols, @@ -16,6 +18,7 @@ impl Screen { } } + // Set the content of a specific row, padding it to the terminal width. pub fn line(mut self, row: u16, content: &str) -> Self { let row = row as usize; assert!(row < self.rows as usize, "row {row} is out of bounds"); @@ -24,6 +27,7 @@ impl Screen { self } + // Build the final screen representation as a vector of strings, filling in blank lines where necessary. pub fn build(self) -> Vec { let blank = " ".repeat(self.cols as usize); self.lines From 272e26b77cfc5369acdec9011c9f480ec887ea36 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:16:09 +0900 Subject: [PATCH 09/81] chore: def error for Screen::line --- Cargo.toml | 1 + examples/readline/tests/middle_insert_wrap.rs | 6 +++--- examples/readline/tests/resize_wrap.rs | 6 +++--- termharness/Cargo.toml | 1 + termharness/src/error.rs | 8 ++++++++ termharness/src/lib.rs | 1 + termharness/src/screen.rs | 15 ++++++++++----- 7 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 termharness/src/error.rs diff --git a/Cargo.toml b/Cargo.toml index 2a15d50b..370ea297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,4 +23,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/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs index 8bd3d04c..1d4018e9 100644 --- a/examples/readline/tests/middle_insert_wrap.rs +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -77,12 +77,12 @@ fn spawn_readline() -> anyhow::Result<( #[test] fn middle_insert_wrap() -> anyhow::Result<()> { let expected = Screen::new(TERMINAL_COLS, TERMINAL_ROWS) - .line(3, "Hi!") + .line(3, "Hi!")? .line( 4, "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijk", - ) - .line(5, "lmnopqr") + )? + .line(5, "lmnopqr")? .build(); let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs index 3ef66432..004dd9fd 100644 --- a/examples/readline/tests/resize_wrap.rs +++ b/examples/readline/tests/resize_wrap.rs @@ -76,12 +76,12 @@ fn spawn_readline() -> anyhow::Result<( #[test] fn resize_wrap() -> anyhow::Result<()> { let expected = Screen::new(RESIZED_TERMINAL_COLS, TERMINAL_ROWS) - .line(3, "Hi!") + .line(3, "Hi!")? .line( 4, "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopq", - ) - .line(5, "r") + )? + .line(5, "r")? .build(); let (mut child, master, mut writer, output, reader_thread) = spawn_readline()?; diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml index 49fb5c7a..079e6a21 100644 --- a/termharness/Cargo.toml +++ b/termharness/Cargo.toml @@ -15,5 +15,6 @@ path = "src/lib.rs" [dependencies] anyhow = { workspace = true } portable-pty = "0.9.0" +thiserror = { workspace = true } unicode-width = { workspace = true } vt100 = "0.16.2" diff --git a/termharness/src/error.rs b/termharness/src/error.rs new file mode 100644 index 00000000..2b0b8732 --- /dev/null +++ b/termharness/src/error.rs @@ -0,0 +1,8 @@ +use thiserror::Error; + +/// Errors that can occur when constructing a screen representation for testing. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ScreenError { + #[error("row {row} is out of bounds for screen height {rows}")] + RowOutOfBounds { row: u16, rows: u16 }, +} diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 63ac656e..605b1a76 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1 +1,2 @@ +pub mod error; pub mod screen; diff --git a/termharness/src/screen.rs b/termharness/src/screen.rs index 0af9f9d8..b59f6eca 100644 --- a/termharness/src/screen.rs +++ b/termharness/src/screen.rs @@ -1,3 +1,4 @@ +use crate::error::ScreenError; use unicode_width::UnicodeWidthStr; /// A builder for constructing a screen representation for testing purposes. @@ -19,12 +20,16 @@ impl Screen { } // Set the content of a specific row, padding it to the terminal width. - pub fn line(mut self, row: u16, content: &str) -> Self { - let row = row as usize; - assert!(row < self.rows as usize, "row {row} is out of bounds"); + pub fn line(mut self, row: u16, content: &str) -> Result { + if row >= self.rows { + return Err(ScreenError::RowOutOfBounds { + row, + rows: self.rows, + }); + } - self.lines[row] = Some(pad_to_cols(self.cols, content)); - self + self.lines[row as usize] = Some(pad_to_cols(self.cols, content)); + Ok(self) } // Build the final screen representation as a vector of strings, filling in blank lines where necessary. From 62f1c38180e9e6c9c29e7a4776e3f2231dd66e67 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:19:52 +0900 Subject: [PATCH 10/81] tests: for Screen::* --- termharness/src/screen.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/termharness/src/screen.rs b/termharness/src/screen.rs index b59f6eca..9831b1ec 100644 --- a/termharness/src/screen.rs +++ b/termharness/src/screen.rs @@ -89,3 +89,31 @@ fn format_screen(lines: &[String], total_rows: usize) -> Vec { .map(|row| format!(" r{row:02} {}", format_screen_line(lines.get(row)))) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + mod screen { + use super::*; + + #[test] + fn build() { + let screen = Screen::new(5, 3) + .line(0, "Hi") + .unwrap() + .line(2, "Bye") + .unwrap() + .build(); + + assert_eq!( + screen, + vec![ + "Hi ".to_string(), + " ".to_string(), + "Bye ".to_string(), + ] + ); + } + } +} From 86e88cb6405cf61102e5cd7232315cceae45755a Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:31:56 +0900 Subject: [PATCH 11/81] chore: mk formatting.rs --- examples/readline/tests/middle_insert_wrap.rs | 5 ++- examples/readline/tests/resize_wrap.rs | 5 ++- termharness/src/formatting.rs | 27 +++++++++++++ termharness/src/lib.rs | 10 +++++ termharness/src/screen.rs | 38 +------------------ 5 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 termharness/src/formatting.rs diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs index 1d4018e9..28908d40 100644 --- a/examples/readline/tests/middle_insert_wrap.rs +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -6,7 +6,10 @@ use std::{ }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use termharness::screen::{assert_screen_eq, pad_to_cols, Screen}; +use termharness::{ + assert_screen_eq, + screen::{pad_to_cols, Screen}, +}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs index 004dd9fd..cfd95410 100644 --- a/examples/readline/tests/resize_wrap.rs +++ b/examples/readline/tests/resize_wrap.rs @@ -6,7 +6,10 @@ use std::{ }; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use termharness::screen::{assert_screen_eq, pad_to_cols, Screen}; +use termharness::{ + assert_screen_eq, + screen::{pad_to_cols, Screen}, +}; const TERMINAL_ROWS: u16 = 6; const TERMINAL_COLS: u16 = 80; diff --git a/termharness/src/formatting.rs b/termharness/src/formatting.rs new file mode 100644 index 00000000..a27332a8 --- /dev/null +++ b/termharness/src/formatting.rs @@ -0,0 +1,27 @@ +pub fn format_screen_diff(expected: &[String], actual: &[String]) -> String { + let total_rows = expected.len().max(actual.len()); + let differing_rows = (0..total_rows) + .filter(|&row| expected.get(row) != actual.get(row)) + .count(); + + let mut lines = vec![format!("screen mismatch ({differing_rows} differing rows)")]; + lines.push("expected:".to_string()); + lines.extend(format_screen(expected, total_rows)); + lines.push("actual:".to_string()); + lines.extend(format_screen(actual, total_rows)); + + lines.join("\n") +} + +pub fn format_screen_line(line: Option<&String>) -> String { + match line { + Some(line) => format!("|{}|", line.replace(' ', "·")), + None => "".to_string(), + } +} + +pub 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() +} diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 605b1a76..262cbaf4 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1,2 +1,12 @@ pub mod error; +pub mod formatting; +use formatting::format_screen_diff; pub mod screen; + +pub fn assert_screen_eq(expected: &[String], actual: &[String]) { + if actual == expected { + return; + } + + panic!("{}", format_screen_diff(expected, actual)); +} diff --git a/termharness/src/screen.rs b/termharness/src/screen.rs index 9831b1ec..dca9673d 100644 --- a/termharness/src/screen.rs +++ b/termharness/src/screen.rs @@ -43,7 +43,7 @@ impl Screen { } pub fn pad_to_cols(cols: u16, content: &str) -> String { - let width = content.width(); + let width: usize = content.width(); assert!( width <= cols as usize, "line width {width} exceeds terminal width {cols}" @@ -54,42 +54,6 @@ pub fn pad_to_cols(cols: u16, content: &str) -> String { line } -pub fn assert_screen_eq(expected: &[String], actual: &[String]) { - if actual == expected { - return; - } - - panic!("{}", format_screen_diff(expected, actual)); -} - -fn format_screen_diff(expected: &[String], actual: &[String]) -> String { - let total_rows = expected.len().max(actual.len()); - let differing_rows = (0..total_rows) - .filter(|&row| expected.get(row) != actual.get(row)) - .count(); - - let mut lines = vec![format!("screen mismatch ({differing_rows} differing rows)")]; - lines.push("expected:".to_string()); - lines.extend(format_screen(expected, total_rows)); - lines.push("actual:".to_string()); - lines.extend(format_screen(actual, total_rows)); - - lines.join("\n") -} - -fn format_screen_line(line: Option<&String>) -> String { - match line { - Some(line) => format!("|{}|", line.replace(' ', "·")), - None => "".to_string(), - } -} - -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::*; From f3e046f0cfc6402bb3b83bd1d3dbcd4749742fae Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:33:52 +0900 Subject: [PATCH 12/81] chore: formatting.rs => screen_diff.rs --- termharness/src/lib.rs | 4 ++-- termharness/src/{formatting.rs => screen_diff.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename termharness/src/{formatting.rs => screen_diff.rs} (100%) diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 262cbaf4..29a9a985 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1,6 +1,6 @@ pub mod error; -pub mod formatting; -use formatting::format_screen_diff; +pub mod screen_diff; +use screen_diff::format_screen_diff; pub mod screen; pub fn assert_screen_eq(expected: &[String], actual: &[String]) { diff --git a/termharness/src/formatting.rs b/termharness/src/screen_diff.rs similarity index 100% rename from termharness/src/formatting.rs rename to termharness/src/screen_diff.rs From c7eefb228b98e04d3674642c5114cf0eacab3b60 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:36:48 +0900 Subject: [PATCH 13/81] chore: merge into screen_assert --- termharness/src/lib.rs | 12 ++---------- .../src/{screen_diff.rs => screen_assert.rs} | 14 +++++++++++--- 2 files changed, 13 insertions(+), 13 deletions(-) rename termharness/src/{screen_diff.rs => screen_assert.rs} (65%) diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 29a9a985..7c6371e4 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1,12 +1,4 @@ pub mod error; -pub mod screen_diff; -use screen_diff::format_screen_diff; +pub mod screen_assert; +pub use screen_assert::assert_screen_eq; pub mod screen; - -pub fn assert_screen_eq(expected: &[String], actual: &[String]) { - if actual == expected { - return; - } - - panic!("{}", format_screen_diff(expected, actual)); -} diff --git a/termharness/src/screen_diff.rs b/termharness/src/screen_assert.rs similarity index 65% rename from termharness/src/screen_diff.rs rename to termharness/src/screen_assert.rs index a27332a8..ddc4ef7e 100644 --- a/termharness/src/screen_diff.rs +++ b/termharness/src/screen_assert.rs @@ -1,4 +1,12 @@ -pub fn format_screen_diff(expected: &[String], actual: &[String]) -> String { +pub fn assert_screen_eq(expected: &[String], actual: &[String]) { + if actual == expected { + return; + } + + panic!("{}", format_screen_diff(expected, actual)); +} + +fn format_screen_diff(expected: &[String], actual: &[String]) -> String { let total_rows = expected.len().max(actual.len()); let differing_rows = (0..total_rows) .filter(|&row| expected.get(row) != actual.get(row)) @@ -13,14 +21,14 @@ pub fn format_screen_diff(expected: &[String], actual: &[String]) -> String { lines.join("\n") } -pub fn format_screen_line(line: Option<&String>) -> String { +fn format_screen_line(line: Option<&String>) -> String { match line { Some(line) => format!("|{}|", line.replace(' ', "·")), None => "".to_string(), } } -pub fn format_screen(lines: &[String], total_rows: usize) -> Vec { +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() From afc8d38e7aaefcdaab9badd097dfd3df229e1d7d Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 02:38:39 +0900 Subject: [PATCH 14/81] tests: for screen_assert --- termharness/src/screen_assert.rs | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/termharness/src/screen_assert.rs b/termharness/src/screen_assert.rs index ddc4ef7e..584b8ca9 100644 --- a/termharness/src/screen_assert.rs +++ b/termharness/src/screen_assert.rs @@ -1,3 +1,4 @@ +/// Assert that two screens are equal, and if not, panic with a detailed diff of the two screens. pub fn assert_screen_eq(expected: &[String], actual: &[String]) { if actual == expected { return; @@ -6,6 +7,7 @@ pub fn assert_screen_eq(expected: &[String], actual: &[String]) { panic!("{}", format_screen_diff(expected, actual)); } +/// Format a diff of two screens, showing the number of differing rows and the contents of each screen with differences highlighted. fn format_screen_diff(expected: &[String], actual: &[String]) -> String { let total_rows = expected.len().max(actual.len()); let differing_rows = (0..total_rows) @@ -21,6 +23,7 @@ fn format_screen_diff(expected: &[String], actual: &[String]) -> String { lines.join("\n") } +/// 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(' ', "·")), @@ -28,8 +31,57 @@ fn format_screen_line(line: Option<&String>) -> 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(), + ] + ); + } + } +} From 121d19fe19629fcec88897aec8d690285a5e65ac Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 03:28:59 +0900 Subject: [PATCH 15/81] chore: create session and terminal --- termharness/src/lib.rs | 2 ++ termharness/src/screen.rs | 17 ++++++++--------- termharness/src/session.rs | 17 +++++++++++++++++ termharness/src/terminal.rs | 12 ++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 termharness/src/session.rs create mode 100644 termharness/src/terminal.rs diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 7c6371e4..48be9f7b 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -2,3 +2,5 @@ pub mod error; pub mod screen_assert; pub use screen_assert::assert_screen_eq; pub mod screen; +pub mod session; +pub mod terminal; diff --git a/termharness/src/screen.rs b/termharness/src/screen.rs index dca9673d..c81d30d4 100644 --- a/termharness/src/screen.rs +++ b/termharness/src/screen.rs @@ -1,11 +1,11 @@ -use crate::error::ScreenError; use unicode_width::UnicodeWidthStr; +use crate::{error::ScreenError, terminal::TerminalSize}; + /// A builder for constructing a screen representation for testing purposes. #[derive(Debug)] pub struct Screen { - cols: u16, - rows: u16, + size: TerminalSize, lines: Vec>, } @@ -13,28 +13,27 @@ impl Screen { // Create a new screen with the specified dimensions, initialized with blank lines. pub fn new(cols: u16, rows: u16) -> Self { Self { - cols, - rows, + size: TerminalSize::new(rows, cols), lines: vec![None; rows as usize], } } // Set the content of a specific row, padding it to the terminal width. pub fn line(mut self, row: u16, content: &str) -> Result { - if row >= self.rows { + if row >= self.size.rows { return Err(ScreenError::RowOutOfBounds { row, - rows: self.rows, + rows: self.size.rows, }); } - self.lines[row as usize] = Some(pad_to_cols(self.cols, content)); + self.lines[row as usize] = Some(pad_to_cols(self.size.cols, content)); Ok(self) } // Build the final screen representation as a vector of strings, filling in blank lines where necessary. pub fn build(self) -> Vec { - let blank = " ".repeat(self.cols as usize); + let blank = " ".repeat(self.size.cols as usize); self.lines .into_iter() .map(|line| line.unwrap_or_else(|| blank.clone())) diff --git a/termharness/src/session.rs b/termharness/src/session.rs new file mode 100644 index 00000000..ae3db6e9 --- /dev/null +++ b/termharness/src/session.rs @@ -0,0 +1,17 @@ +use std::{ + io::Write, + sync::{Arc, Mutex}, + thread::JoinHandle, +}; + +use crate::terminal::TerminalSize; +use portable_pty::{Child, MasterPty}; + +pub struct Session { + pub child: Box, + pub master: Box, + pub writer: Box, + pub output: Arc>>, + pub reader_thread: Option>, + pub size: TerminalSize, +} 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 } + } +} From 49d128aeb69b025f86ab5327de50e167c61732b5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 03:45:33 +0900 Subject: [PATCH 16/81] chore: create Session::spawn to start pseudo-terminal --- termharness/src/session.rs | 55 +++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index ae3db6e9..12e5ebb7 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -1,11 +1,15 @@ use std::{ + io::Read, io::Write, sync::{Arc, Mutex}, + thread, thread::JoinHandle, }; +use anyhow::Result; +use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; + use crate::terminal::TerminalSize; -use portable_pty::{Child, MasterPty}; pub struct Session { pub child: Box, @@ -15,3 +19,52 @@ pub struct Session { 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. + pub fn spawn(mut cmd: CommandBuilder, size: TerminalSize) -> Result { + let pty = native_pty_system(); + let pair = pty.openpty(PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + })?; + + // Set the TERM environment variable to ensure consistent terminal behavior. + // Considaration: 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 mut reader = master.try_clone_reader()?; + let reader_thread = thread::spawn(move || { + let mut buf = [0_u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => output_reader + .lock() + .expect("failed to lock output buffer") + .extend_from_slice(&buf[..n]), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let writer = master.take_writer()?; + Ok(Self { + child, + master, + writer, + output, + reader_thread: Some(reader_thread), + size, + }) + } +} From b40f8459e3b9369f1cc6e7675e0ed5640e72257f Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 04:05:25 +0900 Subject: [PATCH 17/81] tests: for Session::spawn --- termharness/src/session.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 12e5ebb7..333909d7 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -68,3 +68,34 @@ impl Session { }) } } + +#[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, TerminalSize::new(24, 80))?; + + // 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(()) + } + } + } +} From 893af1b97da85e3e672e9c15c420dabea4d68ad0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 04:12:55 +0900 Subject: [PATCH 18/81] chore: use Session::spawn instead --- examples/readline/tests/middle_insert_wrap.rs | 96 ++++++------------- examples/readline/tests/resize_wrap.rs | 90 ++++++----------- 2 files changed, 57 insertions(+), 129 deletions(-) diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs index 28908d40..305fa7c2 100644 --- a/examples/readline/tests/middle_insert_wrap.rs +++ b/examples/readline/tests/middle_insert_wrap.rs @@ -1,14 +1,11 @@ -use std::{ - io::{Read, Write}, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; +use std::{io::Write, thread, time::Duration}; -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use portable_pty::CommandBuilder; use termharness::{ assert_screen_eq, screen::{pad_to_cols, Screen}, + session::Session, + terminal::TerminalSize, }; const TERMINAL_ROWS: u16 = 6; @@ -34,49 +31,6 @@ fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { assert_screen_eq(expected, &actual); } -fn spawn_readline() -> anyhow::Result<( - Box, - Box, - Box, - Arc>>, - thread::JoinHandle<()>, -)> { - let pty = native_pty_system(); - let pair = pty.openpty(PtySize { - rows: TERMINAL_ROWS, - cols: TERMINAL_COLS, - pixel_width: 0, - pixel_height: 0, - })?; - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); - 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 mut reader = master.try_clone_reader()?; - let reader_thread = thread::spawn(move || { - let mut buf = [0_u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => output_reader - .lock() - .expect("failed to lock output buffer") - .extend_from_slice(&buf[..n]), - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - }); - - let writer = master.take_writer()?; - Ok((child, master, writer, output, reader_thread)) -} - #[test] fn middle_insert_wrap() -> anyhow::Result<()> { let expected = Screen::new(TERMINAL_COLS, TERMINAL_ROWS) @@ -88,35 +42,45 @@ fn middle_insert_wrap() -> anyhow::Result<()> { .line(5, "lmnopqr")? .build(); - let (mut child, _master, mut writer, output, reader_thread) = spawn_readline()?; + let cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); + let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); - writer.write_all(cpr.as_bytes())?; - writer.flush()?; + session.writer.write_all(cpr.as_bytes())?; + session.writer.flush()?; thread::sleep(Duration::from_millis(200)); - writer.write_all(INPUT_TEXT.as_bytes())?; - writer.flush()?; + session.writer.write_all(INPUT_TEXT.as_bytes())?; + session.writer.flush()?; thread::sleep(Duration::from_millis(120)); for _ in 0..LEFT_MOVES { - writer.write_all(b"\x1b[D")?; - writer.flush()?; + session.writer.write_all(b"\x1b[D")?; + session.writer.flush()?; thread::sleep(Duration::from_millis(20)); } - writer.write_all(INSERTED_TEXT.as_bytes())?; - writer.flush()?; + session.writer.write_all(INSERTED_TEXT.as_bytes())?; + session.writer.flush()?; thread::sleep(Duration::from_millis(250)); - let snapshot = output.lock().expect("failed to lock output buffer").clone(); + let snapshot = session + .output + .lock() + .expect("failed to lock output buffer") + .clone(); assert_screen(&snapshot, TERMINAL_ROWS, TERMINAL_COLS, &expected); - writer.write_all(b"\r")?; - writer.flush()?; - drop(writer); - - let _ = child.wait()?; - reader_thread.join().expect("reader thread panicked"); + session.writer.write_all(b"\r")?; + session.writer.flush()?; + drop(session.writer); + + let _ = session.child.wait()?; + session + .reader_thread + .take() + .expect("reader thread should exist") + .join() + .expect("reader thread panicked"); Ok(()) } diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs index cfd95410..4c04a4ce 100644 --- a/examples/readline/tests/resize_wrap.rs +++ b/examples/readline/tests/resize_wrap.rs @@ -1,14 +1,11 @@ -use std::{ - io::{Read, Write}, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; +use std::{io::Write, thread, time::Duration}; -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use portable_pty::{CommandBuilder, PtySize}; use termharness::{ assert_screen_eq, screen::{pad_to_cols, Screen}, + session::Session, + terminal::TerminalSize, }; const TERMINAL_ROWS: u16 = 6; @@ -33,49 +30,6 @@ fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { assert_screen_eq(expected, &actual); } -fn spawn_readline() -> anyhow::Result<( - Box, - Box, - Box, - Arc>>, - thread::JoinHandle<()>, -)> { - let pty = native_pty_system(); - let pair = pty.openpty(PtySize { - rows: TERMINAL_ROWS, - cols: TERMINAL_COLS, - pixel_width: 0, - pixel_height: 0, - })?; - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); - 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 mut reader = master.try_clone_reader()?; - let reader_thread = thread::spawn(move || { - let mut buf = [0_u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => output_reader - .lock() - .expect("failed to lock output buffer") - .extend_from_slice(&buf[..n]), - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - }); - - let writer = master.take_writer()?; - Ok((child, master, writer, output, reader_thread)) -} - #[test] fn resize_wrap() -> anyhow::Result<()> { let expected = Screen::new(RESIZED_TERMINAL_COLS, TERMINAL_ROWS) @@ -87,19 +41,20 @@ fn resize_wrap() -> anyhow::Result<()> { .line(5, "r")? .build(); - let (mut child, master, mut writer, output, reader_thread) = spawn_readline()?; + let cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); + let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); - writer.write_all(cpr.as_bytes())?; - writer.flush()?; + session.writer.write_all(cpr.as_bytes())?; + session.writer.flush()?; thread::sleep(Duration::from_millis(200)); - writer.write_all(INPUT_TEXT.as_bytes())?; - writer.flush()?; + session.writer.write_all(INPUT_TEXT.as_bytes())?; + session.writer.flush()?; thread::sleep(Duration::from_millis(250)); for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { - master.resize(PtySize { + session.master.resize(PtySize { rows: TERMINAL_ROWS, cols, pixel_width: 0, @@ -108,14 +63,23 @@ fn resize_wrap() -> anyhow::Result<()> { thread::sleep(Duration::from_millis(150)); } - let snapshot = output.lock().expect("failed to lock output buffer").clone(); + let snapshot = session + .output + .lock() + .expect("failed to lock output buffer") + .clone(); assert_screen(&snapshot, TERMINAL_ROWS, RESIZED_TERMINAL_COLS, &expected); - writer.write_all(b"\r")?; - writer.flush()?; - drop(writer); - - let _ = child.wait()?; - reader_thread.join().expect("reader thread panicked"); + session.writer.write_all(b"\r")?; + session.writer.flush()?; + drop(session.writer); + + let _ = session.child.wait()?; + session + .reader_thread + .take() + .expect("reader thread should exist") + .join() + .expect("reader thread panicked"); Ok(()) } From 84527e006e11765dc06c8b2c018054cfd6962b09 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 05:38:45 +0900 Subject: [PATCH 19/81] fix: zsh reference --- examples/zsh_reference_capture/Cargo.toml | 11 +++ examples/zsh_reference_capture/src/main.rs | 102 +++++++++++++++++++++ termharness/src/screen_assert.rs | 2 +- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 examples/zsh_reference_capture/Cargo.toml create mode 100644 examples/zsh_reference_capture/src/main.rs diff --git a/examples/zsh_reference_capture/Cargo.toml b/examples/zsh_reference_capture/Cargo.toml new file mode 100644 index 00000000..94e57738 --- /dev/null +++ b/examples/zsh_reference_capture/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "zsh-reference-capture" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = { workspace = true } +portable-pty = "0.9.0" +termharness = { path = "../../termharness" } +vt100 = "0.16.2" diff --git a/examples/zsh_reference_capture/src/main.rs b/examples/zsh_reference_capture/src/main.rs new file mode 100644 index 00000000..6bbad816 --- /dev/null +++ b/examples/zsh_reference_capture/src/main.rs @@ -0,0 +1,102 @@ +use std::{io::Write, thread, time::Duration}; + +use portable_pty::{CommandBuilder, PtySize}; +use termharness::{ + screen::pad_to_cols, + screen_assert::format_screen, + session::Session, + terminal::TerminalSize, +}; + +const TERMINAL_ROWS: u16 = 10; +const TERMINAL_COLS: u16 = 32; +const RESIZED_TERMINAL_COLS: u16 = 28; + +fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { + let mut parser = vt100::Parser::new(rows, cols, 0); + parser.process(snapshot); + parser + .screen() + .rows(0, cols) + .map(|row| pad_to_cols(cols, &row)) + .collect() +} + +fn print_screen(label: &str, session: &Session) { + let snapshot = session + .output + .lock() + .expect("failed to lock output buffer") + .clone(); + let screen = render_screen(&snapshot, TERMINAL_ROWS, TERMINAL_COLS); + + println!("== {label} =="); + for line in format_screen(&screen, TERMINAL_ROWS as usize) { + println!("{line}"); + } +} + +fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { + session.master.resize(PtySize { + rows: TERMINAL_ROWS, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + thread::sleep(Duration::from_millis(120)); + print_screen(&format!("resize -> {cols} cols"), session); + Ok(()) +} + +fn main() -> 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", ""); + let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; + + thread::sleep(Duration::from_millis(300)); + print_screen("spawn", &session); + + let move_cursor_to_bottom = format!("printf '\\x1b[{};1H'\r", TERMINAL_ROWS); + session.writer.write_all(move_cursor_to_bottom.as_bytes())?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(300)); + print_screen("move cursor to bottom", &session); + + session + .writer + .write_all(b"echo \"ynqa is a software engineer\"\r")?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(300)); + print_screen("run echo", &session); + + session + .writer + .write_all(b"this is terminal test suite!")?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(200)); + print_screen("type text", &session); + + for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { + resize(&mut session, cols)?; + } + for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { + resize(&mut session, cols)?; + } + + session.writer.write_all(b"\x03exit\r")?; + session.writer.flush()?; + drop(session.writer); + let _ = session.child.wait()?; + session + .reader_thread + .take() + .expect("reader thread should exist") + .join() + .expect("reader thread panicked"); + + Ok(()) +} diff --git a/termharness/src/screen_assert.rs b/termharness/src/screen_assert.rs index 584b8ca9..50f48c5d 100644 --- a/termharness/src/screen_assert.rs +++ b/termharness/src/screen_assert.rs @@ -32,7 +32,7 @@ fn format_screen_line(line: Option<&String>) -> String { } /// Format an entire screen, prefixing each line with its row number and marking differences. -fn format_screen(lines: &[String], total_rows: usize) -> Vec { +pub 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() From 596228ceee40bd98b38a4d0eae4ca39703ee913f Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 05:50:10 +0900 Subject: [PATCH 20/81] fix: zsh reference --- examples/zsh_reference_capture/src/main.rs | 27 ++----------- termharness/src/session.rs | 47 ++++++++++++++++++++-- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/examples/zsh_reference_capture/src/main.rs b/examples/zsh_reference_capture/src/main.rs index 6bbad816..e0a9b249 100644 --- a/examples/zsh_reference_capture/src/main.rs +++ b/examples/zsh_reference_capture/src/main.rs @@ -1,8 +1,7 @@ use std::{io::Write, thread, time::Duration}; -use portable_pty::{CommandBuilder, PtySize}; +use portable_pty::CommandBuilder; use termharness::{ - screen::pad_to_cols, screen_assert::format_screen, session::Session, terminal::TerminalSize, @@ -12,23 +11,8 @@ const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 32; const RESIZED_TERMINAL_COLS: u16 = 28; -fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { - let mut parser = vt100::Parser::new(rows, cols, 0); - parser.process(snapshot); - parser - .screen() - .rows(0, cols) - .map(|row| pad_to_cols(cols, &row)) - .collect() -} - fn print_screen(label: &str, session: &Session) { - let snapshot = session - .output - .lock() - .expect("failed to lock output buffer") - .clone(); - let screen = render_screen(&snapshot, TERMINAL_ROWS, TERMINAL_COLS); + let screen = session.screen_snapshot(); println!("== {label} =="); for line in format_screen(&screen, TERMINAL_ROWS as usize) { @@ -37,12 +21,7 @@ fn print_screen(label: &str, session: &Session) { } fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { - session.master.resize(PtySize { - rows: TERMINAL_ROWS, - cols, - pixel_width: 0, - pixel_height: 0, - })?; + session.resize(TerminalSize::new(TERMINAL_ROWS, cols))?; thread::sleep(Duration::from_millis(120)); print_screen(&format!("resize -> {cols} cols"), session); Ok(()) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 333909d7..35729f31 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -8,7 +8,9 @@ use std::{ use anyhow::Result; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use vt100::Parser; +use crate::screen::pad_to_cols; use crate::terminal::TerminalSize; pub struct Session { @@ -16,6 +18,7 @@ pub struct Session { pub master: Box, pub writer: Box, pub output: Arc>>, + screen: Arc>, pub reader_thread: Option>, pub size: TerminalSize, } @@ -41,16 +44,25 @@ impl Session { let master = pair.master; let output = Arc::new(Mutex::new(Vec::new())); let output_reader = Arc::clone(&output); + let screen = Arc::new(Mutex::new(Parser::new(size.rows, size.cols, 0))); + let screen_reader = Arc::clone(&screen); let mut reader = master.try_clone_reader()?; let reader_thread = thread::spawn(move || { let mut buf = [0_u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) => break, - Ok(n) => output_reader - .lock() - .expect("failed to lock output buffer") - .extend_from_slice(&buf[..n]), + Ok(n) => { + let chunk = &buf[..n]; + output_reader + .lock() + .expect("failed to lock output buffer") + .extend_from_slice(chunk); + screen_reader + .lock() + .expect("failed to lock screen parser") + .process(chunk); + } Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, } @@ -63,10 +75,37 @@ impl Session { master, writer, output, + screen, reader_thread: Some(reader_thread), 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") + .screen_mut() + .set_size(size.rows, size.cols); + self.size = size; + Ok(()) + } + + pub fn screen_snapshot(&self) -> Vec { + let screen = self.screen.lock().expect("failed to lock screen parser"); + let (_, cols) = screen.screen().size(); + screen + .screen() + .rows(0, cols) + .map(|row| pad_to_cols(cols, &row)) + .collect() + } } #[cfg(test)] From 388c6f8d5f99d1c343a01ae0284dd1d87b9ec006 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 18:50:13 +0900 Subject: [PATCH 21/81] chore: use alacritty_terminal instead --- examples/zsh_reference_capture/src/main.rs | 10 +- termharness/Cargo.toml | 2 +- termharness/src/session.rs | 110 ++++++++++++++++++--- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/examples/zsh_reference_capture/src/main.rs b/examples/zsh_reference_capture/src/main.rs index e0a9b249..0c0186c6 100644 --- a/examples/zsh_reference_capture/src/main.rs +++ b/examples/zsh_reference_capture/src/main.rs @@ -1,11 +1,7 @@ use std::{io::Write, thread, time::Duration}; use portable_pty::CommandBuilder; -use termharness::{ - screen_assert::format_screen, - session::Session, - terminal::TerminalSize, -}; +use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 32; @@ -52,9 +48,7 @@ fn main() -> anyhow::Result<()> { thread::sleep(Duration::from_millis(300)); print_screen("run echo", &session); - session - .writer - .write_all(b"this is terminal test suite!")?; + session.writer.write_all(b"this is terminal test suite!")?; session.writer.flush()?; thread::sleep(Duration::from_millis(200)); print_screen("type text", &session); diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml index 079e6a21..98cc7bdc 100644 --- a/termharness/Cargo.toml +++ b/termharness/Cargo.toml @@ -13,8 +13,8 @@ name = "termharness" path = "src/lib.rs" [dependencies] +alacritty_terminal = "0.25.1" anyhow = { workspace = true } portable-pty = "0.9.0" thiserror = { workspace = true } unicode-width = { workspace = true } -vt100 = "0.16.2" diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 35729f31..27cc1a8e 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -6,19 +6,79 @@ use std::{ thread::JoinHandle, }; +use alacritty_terminal::{ + event::VoidListener, + term::{Config, Term, cell::Flags, test::TermSize}, + vte::ansi::Processor, +}; use anyhow::Result; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; -use vt100::Parser; use crate::screen::pad_to_cols; use crate::terminal::TerminalSize; +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 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 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() + } +} + pub struct Session { pub child: Box, pub master: Box, pub writer: Box, pub output: Arc>>, - screen: Arc>, + screen: Arc>, pub reader_thread: Option>, pub size: TerminalSize, } @@ -44,7 +104,7 @@ impl Session { let master = pair.master; let output = Arc::new(Mutex::new(Vec::new())); let output_reader = Arc::clone(&output); - let screen = Arc::new(Mutex::new(Parser::new(size.rows, size.cols, 0))); + let screen = Arc::new(Mutex::new(Screen::new(size))); let screen_reader = Arc::clone(&screen); let mut reader = master.try_clone_reader()?; let reader_thread = thread::spawn(move || { @@ -91,20 +151,16 @@ impl Session { self.screen .lock() .expect("failed to lock screen parser") - .screen_mut() - .set_size(size.rows, size.cols); + .resize(size); self.size = size; Ok(()) } pub fn screen_snapshot(&self) -> Vec { - let screen = self.screen.lock().expect("failed to lock screen parser"); - let (_, cols) = screen.screen().size(); - screen - .screen() - .rows(0, cols) - .map(|row| pad_to_cols(cols, &row)) - .collect() + self.screen + .lock() + .expect("failed to lock screen parser") + .snapshot(self.size) } } @@ -136,5 +192,35 @@ mod tests { 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(), + ] + ); + } + } } } From d56ecc6993e9cce416d48061e3e3954803b84486 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 18:51:07 +0900 Subject: [PATCH 22/81] tests: move left sometimes before experiments --- examples/zsh_reference_capture/src/main.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/examples/zsh_reference_capture/src/main.rs b/examples/zsh_reference_capture/src/main.rs index 0c0186c6..9ba45b30 100644 --- a/examples/zsh_reference_capture/src/main.rs +++ b/examples/zsh_reference_capture/src/main.rs @@ -4,8 +4,9 @@ use portable_pty::CommandBuilder; use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 32; -const RESIZED_TERMINAL_COLS: u16 = 28; +const TERMINAL_COLS: u16 = 40; +const RESIZED_TERMINAL_COLS: u16 = 20; +const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; fn print_screen(label: &str, session: &Session) { let screen = session.screen_snapshot(); @@ -43,7 +44,7 @@ fn main() -> anyhow::Result<()> { session .writer - .write_all(b"echo \"ynqa is a software engineer\"\r")?; + .write_all(b"\"ynqa is a software engineer\"\r")?; session.writer.flush()?; thread::sleep(Duration::from_millis(300)); print_screen("run echo", &session); @@ -53,6 +54,13 @@ fn main() -> anyhow::Result<()> { thread::sleep(Duration::from_millis(200)); print_screen("type text", &session); + + for _ in 0..TIMES_TO_MOVE_CURSOR_LEFT { + session.writer.write_all(b"\x1b[D")?; + } + session.writer.flush()?; + thread::sleep(Duration::from_millis(200)); + print_screen("move cursor left", &session); for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { resize(&mut session, cols)?; } From c3d66ba1029cbc000d1ac970959961cfa9d65d08 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 19:25:21 +0900 Subject: [PATCH 23/81] docs: why move left sometimes? --- examples/zsh_reference_capture/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/zsh_reference_capture/src/main.rs b/examples/zsh_reference_capture/src/main.rs index 9ba45b30..3056351f 100644 --- a/examples/zsh_reference_capture/src/main.rs +++ b/examples/zsh_reference_capture/src/main.rs @@ -54,7 +54,10 @@ fn main() -> anyhow::Result<()> { thread::sleep(Duration::from_millis(200)); print_screen("type text", &session); - + // IMPORTANT: Move the zsh edit cursor far enough left so that, at the + // narrowest resized width, it is no longer sitting on the active input's + // reflow boundary. Otherwise, growing the terminal back can reflow the + // active input differently and push older wrapped output out of view. for _ in 0..TIMES_TO_MOVE_CURSOR_LEFT { session.writer.write_all(b"\x1b[D")?; } From b5e46742fb42c4e358b93c831e3446a38f3e6814 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 19:27:47 +0900 Subject: [PATCH 24/81] fix: move zsh_reference_capture to top --- Cargo.toml | 1 + .../zsh_reference_capture => zsh_reference_capture}/Cargo.toml | 2 +- .../zsh_reference_capture => zsh_reference_capture}/src/main.rs | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename {examples/zsh_reference_capture => zsh_reference_capture}/Cargo.toml (79%) rename {examples/zsh_reference_capture => zsh_reference_capture}/src/main.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 370ea297..098a2841 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "promkit-derive", "promkit-widgets", "termharness", + "zsh_reference_capture", ] [workspace.dependencies] diff --git a/examples/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml similarity index 79% rename from examples/zsh_reference_capture/Cargo.toml rename to zsh_reference_capture/Cargo.toml index 94e57738..77830a1e 100644 --- a/examples/zsh_reference_capture/Cargo.toml +++ b/zsh_reference_capture/Cargo.toml @@ -7,5 +7,5 @@ publish = false [dependencies] anyhow = { workspace = true } portable-pty = "0.9.0" -termharness = { path = "../../termharness" } +termharness = { path = "../termharness" } vt100 = "0.16.2" diff --git a/examples/zsh_reference_capture/src/main.rs b/zsh_reference_capture/src/main.rs similarity index 100% rename from examples/zsh_reference_capture/src/main.rs rename to zsh_reference_capture/src/main.rs From 5d804685e7e08904e318c35bc23d9a02708ef091 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 19:46:54 +0900 Subject: [PATCH 25/81] fix: main => zsh_resize_wrap --- zsh_reference_capture/Cargo.toml | 4 ++++ zsh_reference_capture/src/{main.rs => zsh_resize_wrap.rs} | 0 2 files changed, 4 insertions(+) rename zsh_reference_capture/src/{main.rs => zsh_resize_wrap.rs} (100%) diff --git a/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml index 77830a1e..1e662e4d 100644 --- a/zsh_reference_capture/Cargo.toml +++ b/zsh_reference_capture/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" publish = false +[[bin]] +name = "zsh_resize_wrap" +path = "src/zsh_resize_wrap.rs" + [dependencies] anyhow = { workspace = true } portable-pty = "0.9.0" diff --git a/zsh_reference_capture/src/main.rs b/zsh_reference_capture/src/zsh_resize_wrap.rs similarity index 100% rename from zsh_reference_capture/src/main.rs rename to zsh_reference_capture/src/zsh_resize_wrap.rs From df62da0092bc562990fc8c462a8ba046cdc0daac Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 19:54:12 +0900 Subject: [PATCH 26/81] chore: mk zsh_middle_insert_wrap --- zsh_reference_capture/Cargo.toml | 4 ++ .../src/zsh_middle_insert_wrap.rs | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 zsh_reference_capture/src/zsh_middle_insert_wrap.rs diff --git a/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml index 1e662e4d..eae7e6e0 100644 --- a/zsh_reference_capture/Cargo.toml +++ b/zsh_reference_capture/Cargo.toml @@ -8,6 +8,10 @@ publish = false name = "zsh_resize_wrap" path = "src/zsh_resize_wrap.rs" +[[bin]] +name = "zsh_middle_insert_wrap" +path = "src/zsh_middle_insert_wrap.rs" + [dependencies] anyhow = { workspace = true } portable-pty = "0.9.0" diff --git a/zsh_reference_capture/src/zsh_middle_insert_wrap.rs b/zsh_reference_capture/src/zsh_middle_insert_wrap.rs new file mode 100644 index 00000000..e760fbb7 --- /dev/null +++ b/zsh_reference_capture/src/zsh_middle_insert_wrap.rs @@ -0,0 +1,68 @@ +use std::{io::Write, thread, time::Duration}; + +use portable_pty::CommandBuilder; +use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; + +const TERMINAL_ROWS: u16 = 10; +const TERMINAL_COLS: u16 = 40; +const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; +const INSERTED_TEXT: &str = " and open source maintainer"; +const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; + +fn print_screen(label: &str, session: &Session) { + let screen = session.screen_snapshot(); + + println!("== {label} =="); + for line in format_screen(&screen, TERMINAL_ROWS as usize) { + println!("{line}"); + } +} + +fn main() -> 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", ""); + let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; + + thread::sleep(Duration::from_millis(300)); + print_screen("spawn", &session); + + let move_cursor_to_bottom = format!("printf '\\x1b[{};1H'\r", TERMINAL_ROWS); + session.writer.write_all(move_cursor_to_bottom.as_bytes())?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(300)); + print_screen("move cursor to bottom", &session); + + session.writer.write_all(INPUT_TEXT.as_bytes())?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(200)); + print_screen("type text", &session); + + for _ in 0..TIMES_TO_MOVE_CURSOR_LEFT { + session.writer.write_all(b"\x1b[D")?; + } + session.writer.flush()?; + thread::sleep(Duration::from_millis(200)); + print_screen("move cursor left", &session); + + session.writer.write_all(INSERTED_TEXT.as_bytes())?; + session.writer.flush()?; + thread::sleep(Duration::from_millis(250)); + print_screen("insert text", &session); + + session.writer.write_all(b"\x03exit\r")?; + session.writer.flush()?; + drop(session.writer); + let _ = session.child.wait()?; + session + .reader_thread + .take() + .expect("reader thread should exist") + .join() + .expect("reader thread panicked"); + + Ok(()) +} From f6874a70201c6694c73d33acce5d579638c23a25 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 20:10:32 +0900 Subject: [PATCH 27/81] fix: commodity for testing --- zsh_reference_capture/src/capture.rs | 42 ++++++++++++ zsh_reference_capture/src/lib.rs | 1 + .../src/zsh_middle_insert_wrap.rs | 60 ++++------------- zsh_reference_capture/src/zsh_resize_wrap.rs | 65 +++++-------------- 4 files changed, 73 insertions(+), 95 deletions(-) create mode 100644 zsh_reference_capture/src/capture.rs create mode 100644 zsh_reference_capture/src/lib.rs diff --git a/zsh_reference_capture/src/capture.rs b/zsh_reference_capture/src/capture.rs new file mode 100644 index 00000000..d07b8ba2 --- /dev/null +++ b/zsh_reference_capture/src/capture.rs @@ -0,0 +1,42 @@ +use std::io::Write; + +use portable_pty::CommandBuilder; +use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; + +pub fn spawn_zsh_session(rows: u16, cols: 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", ""); + Session::spawn(cmd, TerminalSize::new(rows, cols)) +} + +pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { + session.writer.write_all(bytes)?; + session.writer.flush()?; + Ok(()) +} + +pub fn print_screen(label: &str, session: &Session, rows: usize) { + let screen = session.screen_snapshot(); + + println!("== {label} =="); + for line in format_screen(&screen, rows) { + println!("{line}"); + } +} + +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()) +} + +pub fn move_cursor_left(session: &mut Session, times: usize) -> anyhow::Result<()> { + for _ in 0..times { + session.writer.write_all(b"\x1b[D")?; + } + session.writer.flush()?; + Ok(()) +} diff --git a/zsh_reference_capture/src/lib.rs b/zsh_reference_capture/src/lib.rs new file mode 100644 index 00000000..8833300b --- /dev/null +++ b/zsh_reference_capture/src/lib.rs @@ -0,0 +1 @@ +pub mod capture; diff --git a/zsh_reference_capture/src/zsh_middle_insert_wrap.rs b/zsh_reference_capture/src/zsh_middle_insert_wrap.rs index e760fbb7..17f6e762 100644 --- a/zsh_reference_capture/src/zsh_middle_insert_wrap.rs +++ b/zsh_reference_capture/src/zsh_middle_insert_wrap.rs @@ -1,7 +1,8 @@ -use std::{io::Write, thread, time::Duration}; +use std::{thread, time::Duration}; -use portable_pty::CommandBuilder; -use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; +use zsh_reference_capture::capture::{ + move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_zsh_session, +}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; @@ -9,60 +10,27 @@ const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools const INSERTED_TEXT: &str = " and open source maintainer"; const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; -fn print_screen(label: &str, session: &Session) { - let screen = session.screen_snapshot(); - - println!("== {label} =="); - for line in format_screen(&screen, TERMINAL_ROWS as usize) { - println!("{line}"); - } -} - fn main() -> 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", ""); - let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; thread::sleep(Duration::from_millis(300)); - print_screen("spawn", &session); + print_screen("spawn", &session, TERMINAL_ROWS as usize); - let move_cursor_to_bottom = format!("printf '\\x1b[{};1H'\r", TERMINAL_ROWS); - session.writer.write_all(move_cursor_to_bottom.as_bytes())?; - session.writer.flush()?; + move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; thread::sleep(Duration::from_millis(300)); - print_screen("move cursor to bottom", &session); + print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); - session.writer.write_all(INPUT_TEXT.as_bytes())?; - session.writer.flush()?; + send_bytes(&mut session, INPUT_TEXT.as_bytes())?; thread::sleep(Duration::from_millis(200)); - print_screen("type text", &session); + print_screen("type text", &session, TERMINAL_ROWS as usize); - for _ in 0..TIMES_TO_MOVE_CURSOR_LEFT { - session.writer.write_all(b"\x1b[D")?; - } - session.writer.flush()?; + move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; thread::sleep(Duration::from_millis(200)); - print_screen("move cursor left", &session); + print_screen("move cursor left", &session, TERMINAL_ROWS as usize); - session.writer.write_all(INSERTED_TEXT.as_bytes())?; - session.writer.flush()?; + send_bytes(&mut session, INSERTED_TEXT.as_bytes())?; thread::sleep(Duration::from_millis(250)); - print_screen("insert text", &session); - - session.writer.write_all(b"\x03exit\r")?; - session.writer.flush()?; - drop(session.writer); - let _ = session.child.wait()?; - session - .reader_thread - .take() - .expect("reader thread should exist") - .join() - .expect("reader thread panicked"); + print_screen("insert text", &session, TERMINAL_ROWS as usize); Ok(()) } diff --git a/zsh_reference_capture/src/zsh_resize_wrap.rs b/zsh_reference_capture/src/zsh_resize_wrap.rs index 3056351f..a1d0478a 100644 --- a/zsh_reference_capture/src/zsh_resize_wrap.rs +++ b/zsh_reference_capture/src/zsh_resize_wrap.rs @@ -1,69 +1,47 @@ -use std::{io::Write, thread, time::Duration}; +use std::{thread, time::Duration}; -use portable_pty::CommandBuilder; -use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; +use termharness::{session::Session, terminal::TerminalSize}; +use zsh_reference_capture::capture::{ + move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_zsh_session, +}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; const RESIZED_TERMINAL_COLS: u16 = 20; const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; -fn print_screen(label: &str, session: &Session) { - let screen = session.screen_snapshot(); - - println!("== {label} =="); - for line in format_screen(&screen, TERMINAL_ROWS as usize) { - println!("{line}"); - } -} - fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { session.resize(TerminalSize::new(TERMINAL_ROWS, cols))?; thread::sleep(Duration::from_millis(120)); - print_screen(&format!("resize -> {cols} cols"), session); + print_screen(&format!("resize -> {cols} cols"), session, TERMINAL_ROWS as usize); Ok(()) } fn main() -> 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", ""); - let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; thread::sleep(Duration::from_millis(300)); - print_screen("spawn", &session); + print_screen("spawn", &session, TERMINAL_ROWS as usize); - let move_cursor_to_bottom = format!("printf '\\x1b[{};1H'\r", TERMINAL_ROWS); - session.writer.write_all(move_cursor_to_bottom.as_bytes())?; - session.writer.flush()?; + move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; thread::sleep(Duration::from_millis(300)); - print_screen("move cursor to bottom", &session); + print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); - session - .writer - .write_all(b"\"ynqa is a software engineer\"\r")?; - session.writer.flush()?; + send_bytes(&mut session, b"\"ynqa is a software engineer\"\r")?; thread::sleep(Duration::from_millis(300)); - print_screen("run echo", &session); + print_screen("run echo", &session, TERMINAL_ROWS as usize); - session.writer.write_all(b"this is terminal test suite!")?; - session.writer.flush()?; + send_bytes(&mut session, b"this is terminal test suite!")?; thread::sleep(Duration::from_millis(200)); - print_screen("type text", &session); + print_screen("type text", &session, TERMINAL_ROWS as usize); // IMPORTANT: Move the zsh edit cursor far enough left so that, at the // narrowest resized width, it is no longer sitting on the active input's // reflow boundary. Otherwise, growing the terminal back can reflow the // active input differently and push older wrapped output out of view. - for _ in 0..TIMES_TO_MOVE_CURSOR_LEFT { - session.writer.write_all(b"\x1b[D")?; - } - session.writer.flush()?; + move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; thread::sleep(Duration::from_millis(200)); - print_screen("move cursor left", &session); + print_screen("move cursor left", &session, TERMINAL_ROWS as usize); for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { resize(&mut session, cols)?; } @@ -71,16 +49,5 @@ fn main() -> anyhow::Result<()> { resize(&mut session, cols)?; } - session.writer.write_all(b"\x03exit\r")?; - session.writer.flush()?; - drop(session.writer); - let _ = session.child.wait()?; - session - .reader_thread - .take() - .expect("reader thread should exist") - .join() - .expect("reader thread panicked"); - Ok(()) } From e947d91459e194efbe85af36d4a5342be6b31739 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 20:31:49 +0900 Subject: [PATCH 28/81] chore: move portable-pty to top --- Cargo.toml | 1 + termharness/Cargo.toml | 2 +- zsh_reference_capture/Cargo.toml | 3 +-- zsh_reference_capture/src/zsh_resize_wrap.rs | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 098a2841..cf54f578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,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" diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml index 98cc7bdc..87ca7663 100644 --- a/termharness/Cargo.toml +++ b/termharness/Cargo.toml @@ -15,6 +15,6 @@ path = "src/lib.rs" [dependencies] alacritty_terminal = "0.25.1" anyhow = { workspace = true } -portable-pty = "0.9.0" +portable-pty = { workspace = true } thiserror = { workspace = true } unicode-width = { workspace = true } diff --git a/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml index eae7e6e0..5fcc439b 100644 --- a/zsh_reference_capture/Cargo.toml +++ b/zsh_reference_capture/Cargo.toml @@ -14,6 +14,5 @@ path = "src/zsh_middle_insert_wrap.rs" [dependencies] anyhow = { workspace = true } -portable-pty = "0.9.0" +portable-pty = { workspace = true } termharness = { path = "../termharness" } -vt100 = "0.16.2" diff --git a/zsh_reference_capture/src/zsh_resize_wrap.rs b/zsh_reference_capture/src/zsh_resize_wrap.rs index a1d0478a..02afa9f0 100644 --- a/zsh_reference_capture/src/zsh_resize_wrap.rs +++ b/zsh_reference_capture/src/zsh_resize_wrap.rs @@ -13,7 +13,11 @@ const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { session.resize(TerminalSize::new(TERMINAL_ROWS, cols))?; thread::sleep(Duration::from_millis(120)); - print_screen(&format!("resize -> {cols} cols"), session, TERMINAL_ROWS as usize); + print_screen( + &format!("resize -> {cols} cols"), + session, + TERMINAL_ROWS as usize, + ); Ok(()) } From b0e5e900217d7b46c8ca6ddf01aba84bdcf54519 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 20:36:27 +0900 Subject: [PATCH 29/81] chore: remove screen from termharness --- termharness/Cargo.toml | 1 - termharness/src/error.rs | 8 ---- termharness/src/lib.rs | 2 - termharness/src/screen.rs | 82 -------------------------------------- termharness/src/session.rs | 17 ++++++-- 5 files changed, 14 insertions(+), 96 deletions(-) delete mode 100644 termharness/src/error.rs delete mode 100644 termharness/src/screen.rs diff --git a/termharness/Cargo.toml b/termharness/Cargo.toml index 87ca7663..cdae2763 100644 --- a/termharness/Cargo.toml +++ b/termharness/Cargo.toml @@ -16,5 +16,4 @@ path = "src/lib.rs" alacritty_terminal = "0.25.1" anyhow = { workspace = true } portable-pty = { workspace = true } -thiserror = { workspace = true } unicode-width = { workspace = true } diff --git a/termharness/src/error.rs b/termharness/src/error.rs deleted file mode 100644 index 2b0b8732..00000000 --- a/termharness/src/error.rs +++ /dev/null @@ -1,8 +0,0 @@ -use thiserror::Error; - -/// Errors that can occur when constructing a screen representation for testing. -#[derive(Debug, Error, PartialEq, Eq)] -pub enum ScreenError { - #[error("row {row} is out of bounds for screen height {rows}")] - RowOutOfBounds { row: u16, rows: u16 }, -} diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index 48be9f7b..c0868e89 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1,6 +1,4 @@ -pub mod error; pub mod screen_assert; pub use screen_assert::assert_screen_eq; -pub mod screen; pub mod session; pub mod terminal; diff --git a/termharness/src/screen.rs b/termharness/src/screen.rs deleted file mode 100644 index c81d30d4..00000000 --- a/termharness/src/screen.rs +++ /dev/null @@ -1,82 +0,0 @@ -use unicode_width::UnicodeWidthStr; - -use crate::{error::ScreenError, terminal::TerminalSize}; - -/// A builder for constructing a screen representation for testing purposes. -#[derive(Debug)] -pub struct Screen { - size: TerminalSize, - lines: Vec>, -} - -impl Screen { - // Create a new screen with the specified dimensions, initialized with blank lines. - pub fn new(cols: u16, rows: u16) -> Self { - Self { - size: TerminalSize::new(rows, cols), - lines: vec![None; rows as usize], - } - } - - // Set the content of a specific row, padding it to the terminal width. - pub fn line(mut self, row: u16, content: &str) -> Result { - if row >= self.size.rows { - return Err(ScreenError::RowOutOfBounds { - row, - rows: self.size.rows, - }); - } - - self.lines[row as usize] = Some(pad_to_cols(self.size.cols, content)); - Ok(self) - } - - // Build the final screen representation as a vector of strings, filling in blank lines where necessary. - pub fn build(self) -> Vec { - let blank = " ".repeat(self.size.cols as usize); - self.lines - .into_iter() - .map(|line| line.unwrap_or_else(|| blank.clone())) - .collect() - } -} - -pub fn pad_to_cols(cols: u16, content: &str) -> String { - let width: usize = 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 -} - -#[cfg(test)] -mod tests { - use super::*; - - mod screen { - use super::*; - - #[test] - fn build() { - let screen = Screen::new(5, 3) - .line(0, "Hi") - .unwrap() - .line(2, "Bye") - .unwrap() - .build(); - - assert_eq!( - screen, - vec![ - "Hi ".to_string(), - " ".to_string(), - "Bye ".to_string(), - ] - ); - } - } -} diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 27cc1a8e..d3ed1b1e 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -6,6 +6,7 @@ use std::{ thread::JoinHandle, }; +use crate::terminal::TerminalSize; use alacritty_terminal::{ event::VoidListener, term::{Config, Term, cell::Flags, test::TermSize}, @@ -13,9 +14,19 @@ use alacritty_terminal::{ }; use anyhow::Result; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; - -use crate::screen::pad_to_cols; -use crate::terminal::TerminalSize; +use unicode_width::UnicodeWidthStr; + +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 +} struct Screen { parser: Processor, From ad94b26cd91284960a5887b2d63ae62fa6860458 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 20:50:23 +0900 Subject: [PATCH 30/81] tests: remove tests in readline once --- examples/readline/Cargo.toml | 6 -- examples/readline/tests/middle_insert_wrap.rs | 86 ------------------- examples/readline/tests/resize_wrap.rs | 85 ------------------ 3 files changed, 177 deletions(-) delete mode 100644 examples/readline/tests/middle_insert_wrap.rs delete mode 100644 examples/readline/tests/resize_wrap.rs diff --git a/examples/readline/Cargo.toml b/examples/readline/Cargo.toml index 134f7412..cede310c 100644 --- a/examples/readline/Cargo.toml +++ b/examples/readline/Cargo.toml @@ -16,9 +16,3 @@ path = "src/readline.rs" [[bin]] name = "readline-loop" path = "src/readline_loop.rs" - -[dev-dependencies] -portable-pty = "0.9.0" -termharness = { path = "../../termharness" } -unicode-width = { workspace = true } -vt100 = "0.16.2" diff --git a/examples/readline/tests/middle_insert_wrap.rs b/examples/readline/tests/middle_insert_wrap.rs deleted file mode 100644 index 305fa7c2..00000000 --- a/examples/readline/tests/middle_insert_wrap.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::{io::Write, thread, time::Duration}; - -use portable_pty::CommandBuilder; -use termharness::{ - assert_screen_eq, - screen::{pad_to_cols, Screen}, - session::Session, - terminal::TerminalSize, -}; - -const TERMINAL_ROWS: u16 = 6; -const TERMINAL_COLS: u16 = 80; -const INITIAL_CURSOR_ROW: u16 = 6; -const INITIAL_CURSOR_COL: u16 = 1; -const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; -const INSERTED_TEXT: &str = "HELLOWORLD!!!!"; -const LEFT_MOVES: usize = 20; - -fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { - let mut parser = vt100::Parser::new(rows, cols, 0); - parser.process(snapshot); - parser - .screen() - .rows(0, cols) - .map(|row| pad_to_cols(cols, &row)) - .collect() -} - -fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { - let actual = render_screen(snapshot, rows, cols); - assert_screen_eq(expected, &actual); -} - -#[test] -fn middle_insert_wrap() -> anyhow::Result<()> { - let expected = Screen::new(TERMINAL_COLS, TERMINAL_ROWS) - .line(3, "Hi!")? - .line( - 4, - "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxHELLOWORLD!!!!yzabcdefghijk", - )? - .line(5, "lmnopqr")? - .build(); - - let cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); - let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; - - let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); - session.writer.write_all(cpr.as_bytes())?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(200)); - - session.writer.write_all(INPUT_TEXT.as_bytes())?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(120)); - - for _ in 0..LEFT_MOVES { - session.writer.write_all(b"\x1b[D")?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(20)); - } - - session.writer.write_all(INSERTED_TEXT.as_bytes())?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(250)); - - let snapshot = session - .output - .lock() - .expect("failed to lock output buffer") - .clone(); - assert_screen(&snapshot, TERMINAL_ROWS, TERMINAL_COLS, &expected); - - session.writer.write_all(b"\r")?; - session.writer.flush()?; - drop(session.writer); - - let _ = session.child.wait()?; - session - .reader_thread - .take() - .expect("reader thread should exist") - .join() - .expect("reader thread panicked"); - Ok(()) -} diff --git a/examples/readline/tests/resize_wrap.rs b/examples/readline/tests/resize_wrap.rs deleted file mode 100644 index 4c04a4ce..00000000 --- a/examples/readline/tests/resize_wrap.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::{io::Write, thread, time::Duration}; - -use portable_pty::{CommandBuilder, PtySize}; -use termharness::{ - assert_screen_eq, - screen::{pad_to_cols, Screen}, - session::Session, - terminal::TerminalSize, -}; - -const TERMINAL_ROWS: u16 = 6; -const TERMINAL_COLS: u16 = 80; -const RESIZED_TERMINAL_COLS: u16 = 72; -const INITIAL_CURSOR_ROW: u16 = 6; -const INITIAL_CURSOR_COL: u16 = 1; -const INPUT_TEXT: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr"; - -fn render_screen(snapshot: &[u8], rows: u16, cols: u16) -> Vec { - let mut parser = vt100::Parser::new(rows, cols, 0); - parser.process(snapshot); - parser - .screen() - .rows(0, cols) - .map(|row| pad_to_cols(cols, &row)) - .collect() -} - -fn assert_screen(snapshot: &[u8], rows: u16, cols: u16, expected: &[String]) { - let actual = render_screen(snapshot, rows, cols); - assert_screen_eq(expected, &actual); -} - -#[test] -fn resize_wrap() -> anyhow::Result<()> { - let expected = Screen::new(RESIZED_TERMINAL_COLS, TERMINAL_ROWS) - .line(3, "Hi!")? - .line( - 4, - "❯❯ abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopq", - )? - .line(5, "r")? - .build(); - - let cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_readline")); - let mut session = Session::spawn(cmd, TerminalSize::new(TERMINAL_ROWS, TERMINAL_COLS))?; - - let cpr = format!("\x1b[{};{}R", INITIAL_CURSOR_ROW, INITIAL_CURSOR_COL); - session.writer.write_all(cpr.as_bytes())?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(200)); - - session.writer.write_all(INPUT_TEXT.as_bytes())?; - session.writer.flush()?; - thread::sleep(Duration::from_millis(250)); - - for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { - session.master.resize(PtySize { - rows: TERMINAL_ROWS, - cols, - pixel_width: 0, - pixel_height: 0, - })?; - thread::sleep(Duration::from_millis(150)); - } - - let snapshot = session - .output - .lock() - .expect("failed to lock output buffer") - .clone(); - assert_screen(&snapshot, TERMINAL_ROWS, RESIZED_TERMINAL_COLS, &expected); - - session.writer.write_all(b"\r")?; - session.writer.flush()?; - drop(session.writer); - - let _ = session.child.wait()?; - session - .reader_thread - .take() - .expect("reader thread should exist") - .join() - .expect("reader thread panicked"); - Ok(()) -} From 435173e3af7b8b9a49251fcb161ec9efe66c721d Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 21:27:29 +0900 Subject: [PATCH 31/81] chore: create zsh_pretend but no command execution --- Cargo.toml | 1 + zsh_reference_capture/zsh_pretend/Cargo.toml | 10 +++++++ zsh_reference_capture/zsh_pretend/src/lib.rs | 27 +++++++++++++++++++ zsh_reference_capture/zsh_pretend/src/main.rs | 4 +++ 4 files changed, 42 insertions(+) create mode 100644 zsh_reference_capture/zsh_pretend/Cargo.toml create mode 100644 zsh_reference_capture/zsh_pretend/src/lib.rs create mode 100644 zsh_reference_capture/zsh_pretend/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index cf54f578..79126ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "promkit-widgets", "termharness", "zsh_reference_capture", + "zsh_reference_capture/zsh_pretend", ] [workspace.dependencies] diff --git a/zsh_reference_capture/zsh_pretend/Cargo.toml b/zsh_reference_capture/zsh_pretend/Cargo.toml new file mode 100644 index 00000000..1f73bd7e --- /dev/null +++ b/zsh_reference_capture/zsh_pretend/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zsh-pretend" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = { workspace = true } +promkit = { path = "../../promkit", features = ["readline"] } +tokio = { workspace = true } diff --git a/zsh_reference_capture/zsh_pretend/src/lib.rs b/zsh_reference_capture/zsh_pretend/src/lib.rs new file mode 100644 index 00000000..fa75c9af --- /dev/null +++ b/zsh_reference_capture/zsh_pretend/src/lib.rs @@ -0,0 +1,27 @@ +use promkit::{ + core::crossterm::{cursor, terminal}, + preset::readline::Readline, + Prompt, +}; + +pub async fn run() -> 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: {command}"); + } + Err(error) => { + println!("error: {error}"); + break; + } + } + } + + Ok(()) +} diff --git a/zsh_reference_capture/zsh_pretend/src/main.rs b/zsh_reference_capture/zsh_pretend/src/main.rs new file mode 100644 index 00000000..9bb8b1c4 --- /dev/null +++ b/zsh_reference_capture/zsh_pretend/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + zsh_pretend::run().await +} From 4533a2c00e9cb2f13095ba5a642d2d86fa2457d8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 22:11:30 +0900 Subject: [PATCH 32/81] chore: define spawn session --- zsh_reference_capture/src/capture.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/zsh_reference_capture/src/capture.rs b/zsh_reference_capture/src/capture.rs index d07b8ba2..8194d5da 100644 --- a/zsh_reference_capture/src/capture.rs +++ b/zsh_reference_capture/src/capture.rs @@ -3,6 +3,10 @@ use std::io::Write; use portable_pty::CommandBuilder; use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; +pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Result { + Session::spawn(cmd, TerminalSize::new(rows, cols)) +} + pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { let mut cmd = CommandBuilder::new("/bin/zsh"); cmd.arg("-fi"); @@ -10,7 +14,7 @@ pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { cmd.env("RPS1", ""); cmd.env("RPROMPT", ""); cmd.env("PROMPT_EOL_MARK", ""); - Session::spawn(cmd, TerminalSize::new(rows, cols)) + spawn_session(cmd, rows, cols) } pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { From ee674d21ab53b0e9ad8d5da53dbb26da1888bbd9 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 23:43:37 +0900 Subject: [PATCH 33/81] on-bug: not work middle_insert_wrap execution --- zsh_reference_capture/zsh_pretend/Cargo.toml | 11 +++ zsh_reference_capture/zsh_pretend/src/main.rs | 6 ++ .../zsh_pretend/src/middle_insert_wrap.rs | 68 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs diff --git a/zsh_reference_capture/zsh_pretend/Cargo.toml b/zsh_reference_capture/zsh_pretend/Cargo.toml index 1f73bd7e..c31d8f3d 100644 --- a/zsh_reference_capture/zsh_pretend/Cargo.toml +++ b/zsh_reference_capture/zsh_pretend/Cargo.toml @@ -4,7 +4,18 @@ version = "0.1.0" edition = "2021" publish = false +[[bin]] +name = "zsh-pretend" +path = "src/main.rs" + +[[bin]] +name = "middle_insert_wrap" +path = "src/middle_insert_wrap.rs" + [dependencies] anyhow = { workspace = true } +portable-pty = { workspace = true } promkit = { path = "../../promkit", features = ["readline"] } +termharness = { path = "../../termharness" } tokio = { workspace = true } +zsh-reference-capture = { path = ".." } diff --git a/zsh_reference_capture/zsh_pretend/src/main.rs b/zsh_reference_capture/zsh_pretend/src/main.rs index 9bb8b1c4..db49ff65 100644 --- a/zsh_reference_capture/zsh_pretend/src/main.rs +++ b/zsh_reference_capture/zsh_pretend/src/main.rs @@ -1,4 +1,10 @@ +use std::{env, io}; + +use promkit::core::crossterm::{self, cursor, terminal}; + #[tokio::main] async fn main() -> anyhow::Result<()> { + let (_, rows) = terminal::size()?; + crossterm::execute!(io::stdout(), cursor::MoveTo(0, rows.saturating_sub(1)))?; zsh_pretend::run().await } diff --git a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs new file mode 100644 index 00000000..8531cf9d --- /dev/null +++ b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs @@ -0,0 +1,68 @@ +use std::{path::PathBuf, process::Command, thread, time::Duration}; + +use portable_pty::CommandBuilder; +use zsh_reference_capture::capture::{move_cursor_left, print_screen, send_bytes, spawn_session}; + +const TERMINAL_ROWS: u16 = 10; +const TERMINAL_COLS: u16 = 40; +const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; +const INSERTED_TEXT: &str = " and open source maintainer"; +const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; + +fn main() -> anyhow::Result<()> { + build_zsh_pretend()?; + + let mut session = spawn_session( + CommandBuilder::new(zsh_pretend_binary_path()?), + TERMINAL_ROWS, + TERMINAL_COLS, + )?; + + wait_for_prompt(&session)?; + print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); + + send_bytes(&mut session, INPUT_TEXT.as_bytes())?; + thread::sleep(Duration::from_millis(200)); + print_screen("type text", &session, TERMINAL_ROWS as usize); + + move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; + thread::sleep(Duration::from_millis(200)); + print_screen("move cursor left", &session, TERMINAL_ROWS as usize); + + send_bytes(&mut session, INSERTED_TEXT.as_bytes())?; + thread::sleep(Duration::from_millis(250)); + print_screen("insert text", &session, TERMINAL_ROWS as usize); + + Ok(()) +} + +fn wait_for_prompt(session: &termharness::session::Session) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.last().is_some_and(|line| line.starts_with("❯❯ ")) { + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +fn build_zsh_pretend() -> anyhow::Result<()> { + let status = Command::new("cargo") + .args(["build", "-q", "-p", "zsh-pretend", "--bin", "zsh-pretend"]) + .status()?; + if !status.success() { + return Err(anyhow::anyhow!("failed to build zsh-pretend")); + } + Ok(()) +} + +fn zsh_pretend_binary_path() -> anyhow::Result { + let current_exe = std::env::current_exe()?; + let parent = current_exe + .parent() + .ok_or_else(|| anyhow::anyhow!("failed to resolve executable directory"))?; + Ok(parent.join("zsh-pretend")) +} From 00ae1ad4b18c0f5974682709503713789ae75288 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 10 Mar 2026 23:56:58 +0900 Subject: [PATCH 34/81] fix: output middle_insert_wrap for zsh pretend --- zsh_reference_capture/zsh_pretend/src/main.rs | 2 +- .../zsh_pretend/src/middle_insert_wrap.rs | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/zsh_reference_capture/zsh_pretend/src/main.rs b/zsh_reference_capture/zsh_pretend/src/main.rs index db49ff65..b3416da4 100644 --- a/zsh_reference_capture/zsh_pretend/src/main.rs +++ b/zsh_reference_capture/zsh_pretend/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, io}; +use std::io; use promkit::core::crossterm::{self, cursor, terminal}; diff --git a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs index 8531cf9d..23c4372e 100644 --- a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs +++ b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs @@ -18,8 +18,9 @@ fn main() -> anyhow::Result<()> { TERMINAL_COLS, )?; + respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; wait_for_prompt(&session)?; - print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); + print_screen("startup", &session, TERMINAL_ROWS as usize); send_bytes(&mut session, INPUT_TEXT.as_bytes())?; thread::sleep(Duration::from_millis(200)); @@ -36,11 +37,38 @@ fn main() -> anyhow::Result<()> { Ok(()) } +fn respond_to_cursor_position_request( + session: &mut termharness::session::Session, + row: u16, + col: u16, +) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let requested = { + let output = session + .output + .lock() + .expect("failed to lock session output buffer"); + output.windows(4).any(|window| window == b"\x1b[6n") + }; + if requested { + let response = format!("\x1b[{row};{col}R"); + send_bytes(session, response.as_bytes())?; + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!( + "timed out waiting for cursor position request" + )) +} + fn wait_for_prompt(session: &termharness::session::Session) -> anyhow::Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(2); while std::time::Instant::now() < deadline { let screen = session.screen_snapshot(); - if screen.last().is_some_and(|line| line.starts_with("❯❯ ")) { + if screen.iter().any(|line| line.starts_with("❯❯ ")) { return Ok(()); } thread::sleep(Duration::from_millis(20)); From e564f27f7048c56089f65c4c54bb9f58c91f9536 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 00:56:51 +0900 Subject: [PATCH 35/81] feat: zsherio for comparing zsh to zsh-pretend --- Cargo.toml | 1 + zsherio/Cargo.toml | 10 +++ zsherio/src/lib.rs | 3 + zsherio/src/scenario.rs | 184 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 zsherio/Cargo.toml create mode 100644 zsherio/src/lib.rs create mode 100644 zsherio/src/scenario.rs diff --git a/Cargo.toml b/Cargo.toml index 79126ba6..ef6da6a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "termharness", "zsh_reference_capture", "zsh_reference_capture/zsh_pretend", + "zsherio", ] [workspace.dependencies] diff --git a/zsherio/Cargo.toml b/zsherio/Cargo.toml new file mode 100644 index 00000000..faba240b --- /dev/null +++ b/zsherio/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zsherio" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +anyhow = { workspace = true } +portable-pty = { workspace = true } +termharness = { path = "../termharness" } diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs new file mode 100644 index 00000000..a88cc415 --- /dev/null +++ b/zsherio/src/lib.rs @@ -0,0 +1,3 @@ +pub mod scenario; + +pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; diff --git a/zsherio/src/scenario.rs b/zsherio/src/scenario.rs new file mode 100644 index 00000000..de1070e7 --- /dev/null +++ b/zsherio/src/scenario.rs @@ -0,0 +1,184 @@ +use std::{ + fmt, + fs::File, + io::{self, Write}, + path::Path, + sync::Arc, + thread, + time::Duration, +}; + +use anyhow::{bail, ensure}; +use termharness::screen_assert::format_screen; +use termharness::session::Session; + +pub type StepAction = Arc anyhow::Result<()> + Send + Sync>; + +#[derive(Clone)] +pub struct Scenario { + pub name: &'static str, + pub steps: Vec, +} + +#[derive(Clone)] +pub struct ScenarioStep { + pub label: &'static str, + pub compare: bool, + pub settle: Duration, + pub action: StepAction, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScenarioRecord { + pub label: String, + pub compare: bool, + 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: &'static str) -> Self { + Self { + name, + steps: Vec::new(), + } + } + + pub fn step( + mut self, + label: &'static str, + compare: bool, + settle: Duration, + action: F, + ) -> Self + where + F: Fn(&mut Session) -> anyhow::Result<()> + Send + Sync + 'static, + { + self.steps + .push(ScenarioStep::new(label, compare, 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.to_string(), + compare: step.compare, + screen: format_screen(&screen, screen.len()), + }); + } + + Ok(ScenarioRun { + scenario_name: self.name.to_string(), + target_name: target_name.into(), + records, + }) + } +} + +impl ScenarioStep { + pub fn new(label: &'static str, compare: bool, settle: Duration, action: F) -> Self + where + F: Fn(&mut Session) -> anyhow::Result<()> + Send + Sync + 'static, + { + Self { + label, + compare, + settle, + action: Arc::new(action), + } + } +} + +impl ScenarioRun { + pub fn compare(&self, other: &Self) -> anyhow::Result<()> { + ensure!( + self.scenario_name == other.scenario_name, + "scenario mismatch: '{}' != '{}'", + self.scenario_name, + other.scenario_name + ); + + let expected = self.comparable_records(); + let actual = other.comparable_records(); + + ensure!( + expected.len() == actual.len(), + "comparable step count mismatch for scenario '{}': '{}' has {}, '{}' has {}", + self.scenario_name, + self.target_name, + expected.len(), + other.target_name, + actual.len() + ); + + for (index, (expected, actual)) in expected.iter().zip(actual.iter()).enumerate() { + ensure!( + expected.label == actual.label, + "step label mismatch at comparable step {index} for scenario '{}': '{}' != '{}'", + self.scenario_name, + expected.label, + actual.label + ); + + if expected.screen != actual.screen { + bail!( + "screen mismatch for scenario '{}' at step '{}' ({} vs {})", + self.scenario_name, + expected.label, + self.target_name, + other.target_name, + ); + } + } + + Ok(()) + } + + 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()) + } + + fn comparable_records(&self) -> Vec<&ScenarioRecord> { + self.records + .iter() + .filter(|record| record.compare) + .collect() + } +} From de490a280437e47e03581e1275a8c2a5656094ee Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 01:20:01 +0900 Subject: [PATCH 36/81] chore: into zsherio --- zsh_reference_capture/Cargo.toml | 12 +- zsh_reference_capture/src/lib.rs | 3 +- .../src/zsh_middle_insert_wrap.rs | 36 ------ zsh_reference_capture/src/zsh_resize_wrap.rs | 57 --------- zsh_reference_capture/zsh_pretend/Cargo.toml | 2 +- .../zsh_pretend/src/middle_insert_wrap.rs | 2 +- zsherio/examples/zsh_middle_insert_wrap.rs | 35 ++++++ zsherio/examples/zsh_resize_wrap.rs | 58 +++++++++ .../src/capture.rs | 0 zsherio/src/lib.rs | 4 + zsherio/src/scenario.rs | 113 +++++++----------- 11 files changed, 143 insertions(+), 179 deletions(-) delete mode 100644 zsh_reference_capture/src/zsh_middle_insert_wrap.rs delete mode 100644 zsh_reference_capture/src/zsh_resize_wrap.rs create mode 100644 zsherio/examples/zsh_middle_insert_wrap.rs create mode 100644 zsherio/examples/zsh_resize_wrap.rs rename {zsh_reference_capture => zsherio}/src/capture.rs (100%) diff --git a/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml index 5fcc439b..47ff53a5 100644 --- a/zsh_reference_capture/Cargo.toml +++ b/zsh_reference_capture/Cargo.toml @@ -4,15 +4,5 @@ version = "0.1.0" edition = "2021" publish = false -[[bin]] -name = "zsh_resize_wrap" -path = "src/zsh_resize_wrap.rs" - -[[bin]] -name = "zsh_middle_insert_wrap" -path = "src/zsh_middle_insert_wrap.rs" - [dependencies] -anyhow = { workspace = true } -portable-pty = { workspace = true } -termharness = { path = "../termharness" } +zsherio = { path = "../zsherio" } diff --git a/zsh_reference_capture/src/lib.rs b/zsh_reference_capture/src/lib.rs index 8833300b..183513ff 100644 --- a/zsh_reference_capture/src/lib.rs +++ b/zsh_reference_capture/src/lib.rs @@ -1 +1,2 @@ -pub mod capture; +pub use zsherio::capture; +pub use zsherio::scenario; diff --git a/zsh_reference_capture/src/zsh_middle_insert_wrap.rs b/zsh_reference_capture/src/zsh_middle_insert_wrap.rs deleted file mode 100644 index 17f6e762..00000000 --- a/zsh_reference_capture/src/zsh_middle_insert_wrap.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::{thread, time::Duration}; - -use zsh_reference_capture::capture::{ - move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_zsh_session, -}; - -const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 40; -const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; -const INSERTED_TEXT: &str = " and open source maintainer"; -const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; - -fn main() -> anyhow::Result<()> { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; - - thread::sleep(Duration::from_millis(300)); - print_screen("spawn", &session, TERMINAL_ROWS as usize); - - move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; - thread::sleep(Duration::from_millis(300)); - print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, INPUT_TEXT.as_bytes())?; - thread::sleep(Duration::from_millis(200)); - print_screen("type text", &session, TERMINAL_ROWS as usize); - - move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; - thread::sleep(Duration::from_millis(200)); - print_screen("move cursor left", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, INSERTED_TEXT.as_bytes())?; - thread::sleep(Duration::from_millis(250)); - print_screen("insert text", &session, TERMINAL_ROWS as usize); - - Ok(()) -} diff --git a/zsh_reference_capture/src/zsh_resize_wrap.rs b/zsh_reference_capture/src/zsh_resize_wrap.rs deleted file mode 100644 index 02afa9f0..00000000 --- a/zsh_reference_capture/src/zsh_resize_wrap.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::{thread, time::Duration}; - -use termharness::{session::Session, terminal::TerminalSize}; -use zsh_reference_capture::capture::{ - move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_zsh_session, -}; - -const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 40; -const RESIZED_TERMINAL_COLS: u16 = 20; -const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; - -fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { - session.resize(TerminalSize::new(TERMINAL_ROWS, cols))?; - thread::sleep(Duration::from_millis(120)); - print_screen( - &format!("resize -> {cols} cols"), - session, - TERMINAL_ROWS as usize, - ); - Ok(()) -} - -fn main() -> anyhow::Result<()> { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; - - thread::sleep(Duration::from_millis(300)); - print_screen("spawn", &session, TERMINAL_ROWS as usize); - - move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; - thread::sleep(Duration::from_millis(300)); - print_screen("move cursor to bottom", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, b"\"ynqa is a software engineer\"\r")?; - thread::sleep(Duration::from_millis(300)); - print_screen("run echo", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, b"this is terminal test suite!")?; - thread::sleep(Duration::from_millis(200)); - print_screen("type text", &session, TERMINAL_ROWS as usize); - - // IMPORTANT: Move the zsh edit cursor far enough left so that, at the - // narrowest resized width, it is no longer sitting on the active input's - // reflow boundary. Otherwise, growing the terminal back can reflow the - // active input differently and push older wrapped output out of view. - move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; - thread::sleep(Duration::from_millis(200)); - print_screen("move cursor left", &session, TERMINAL_ROWS as usize); - for cols in (RESIZED_TERMINAL_COLS..TERMINAL_COLS).rev() { - resize(&mut session, cols)?; - } - for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { - resize(&mut session, cols)?; - } - - Ok(()) -} diff --git a/zsh_reference_capture/zsh_pretend/Cargo.toml b/zsh_reference_capture/zsh_pretend/Cargo.toml index c31d8f3d..3d95d2b0 100644 --- a/zsh_reference_capture/zsh_pretend/Cargo.toml +++ b/zsh_reference_capture/zsh_pretend/Cargo.toml @@ -18,4 +18,4 @@ portable-pty = { workspace = true } promkit = { path = "../../promkit", features = ["readline"] } termharness = { path = "../../termharness" } tokio = { workspace = true } -zsh-reference-capture = { path = ".." } +zsherio = { path = "../../zsherio" } diff --git a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs index 23c4372e..e4c61e6a 100644 --- a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs +++ b/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs @@ -1,7 +1,7 @@ use std::{path::PathBuf, process::Command, thread, time::Duration}; use portable_pty::CommandBuilder; -use zsh_reference_capture::capture::{move_cursor_left, print_screen, send_bytes, spawn_session}; +use zsherio::capture::{move_cursor_left, print_screen, send_bytes, spawn_session}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs new file mode 100644 index 00000000..91954a64 --- /dev/null +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -0,0 +1,35 @@ +use std::time::Duration; + +use zsherio::Scenario; +use zsherio::capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_zsh_session}; + +const TERMINAL_ROWS: u16 = 10; +const TERMINAL_COLS: u16 = 40; +const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; +const INSERTED_TEXT: &str = " and open source maintainer"; +const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; + +fn scenario() -> Scenario { + Scenario::new("middle_insert_wrap") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step( + "move cursor to bottom", + Duration::from_millis(300), + |session| move_cursor_to(session, TERMINAL_ROWS, 1), + ) + .step("type text", Duration::from_millis(200), |session| { + send_bytes(session, INPUT_TEXT.as_bytes()) + }) + .step("move cursor left", Duration::from_millis(200), |session| { + move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) + }) + .step("insert text", Duration::from_millis(250), |session| { + send_bytes(session, INSERTED_TEXT.as_bytes()) + }) +} + +fn main() -> anyhow::Result<()> { + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + let run = scenario().run("zsh", &mut session)?; + run.write_to_stdout() +} diff --git a/zsherio/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs new file mode 100644 index 00000000..5e74dda3 --- /dev/null +++ b/zsherio/examples/zsh_resize_wrap.rs @@ -0,0 +1,58 @@ +use std::time::Duration; + +use termharness::{session::Session, terminal::TerminalSize}; +use zsherio::Scenario; +use zsherio::capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_zsh_session}; + +const TERMINAL_ROWS: u16 = 10; +const TERMINAL_COLS: u16 = 40; +const RESIZED_TERMINAL_COLS: u16 = 20; +const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; + +fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { + session.resize(TerminalSize::new(TERMINAL_ROWS, cols)) +} + +fn scenario() -> Scenario { + let mut scenario = Scenario::new("zsh_resize_wrap") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step( + "move cursor to bottom", + Duration::from_millis(300), + |session| move_cursor_to(session, TERMINAL_ROWS, 1), + ) + .step("run echo", Duration::from_millis(300), |session| { + send_bytes(session, b"\"ynqa is a software engineer\"\r") + }) + .step("type text", Duration::from_millis(200), |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(200), |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(120), + move |session| resize(session, cols), + ); + } + for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { + scenario = scenario.step( + format!("resize -> {cols} cols"), + Duration::from_millis(120), + move |session| resize(session, cols), + ); + } + + scenario +} + +fn main() -> anyhow::Result<()> { + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + let run = scenario().run("zsh", &mut session)?; + run.write_to_stdout() +} diff --git a/zsh_reference_capture/src/capture.rs b/zsherio/src/capture.rs similarity index 100% rename from zsh_reference_capture/src/capture.rs rename to zsherio/src/capture.rs diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index a88cc415..70c06986 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -1,3 +1,7 @@ +pub mod capture; pub mod scenario; +pub use capture::{ + move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_session, spawn_zsh_session, +}; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; diff --git a/zsherio/src/scenario.rs b/zsherio/src/scenario.rs index de1070e7..e283dabc 100644 --- a/zsherio/src/scenario.rs +++ b/zsherio/src/scenario.rs @@ -1,5 +1,4 @@ use std::{ - fmt, fs::File, io::{self, Write}, path::Path, @@ -8,7 +7,6 @@ use std::{ time::Duration, }; -use anyhow::{bail, ensure}; use termharness::screen_assert::format_screen; use termharness::session::Session; @@ -16,14 +14,13 @@ pub type StepAction = Arc anyhow::Result<()> + Send + Sy #[derive(Clone)] pub struct Scenario { - pub name: &'static str, + pub name: String, pub steps: Vec, } #[derive(Clone)] pub struct ScenarioStep { - pub label: &'static str, - pub compare: bool, + pub label: String, pub settle: Duration, pub action: StepAction, } @@ -31,7 +28,6 @@ pub struct ScenarioStep { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScenarioRecord { pub label: String, - pub compare: bool, pub screen: Vec, } @@ -43,25 +39,19 @@ pub struct ScenarioRun { } impl Scenario { - pub fn new(name: &'static str) -> Self { + pub fn new(name: impl Into) -> Self { Self { - name, + name: name.into(), steps: Vec::new(), } } - pub fn step( - mut self, - label: &'static str, - compare: bool, - settle: Duration, - action: F, - ) -> Self + 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, compare, settle, action)); + self.steps.push(ScenarioStep::new(label, settle, action)); self } @@ -78,14 +68,13 @@ impl Scenario { let screen = session.screen_snapshot(); records.push(ScenarioRecord { - label: step.label.to_string(), - compare: step.compare, + label: step.label.clone(), screen: format_screen(&screen, screen.len()), }); } Ok(ScenarioRun { - scenario_name: self.name.to_string(), + scenario_name: self.name.clone(), target_name: target_name.into(), records, }) @@ -93,13 +82,13 @@ impl Scenario { } impl ScenarioStep { - pub fn new(label: &'static str, compare: bool, settle: Duration, action: F) -> Self + pub fn new(label: S, settle: Duration, action: F) -> Self where F: Fn(&mut Session) -> anyhow::Result<()> + Send + Sync + 'static, + S: Into, { Self { - label, - compare, + label: label.into(), settle, action: Arc::new(action), } @@ -107,50 +96,6 @@ impl ScenarioStep { } impl ScenarioRun { - pub fn compare(&self, other: &Self) -> anyhow::Result<()> { - ensure!( - self.scenario_name == other.scenario_name, - "scenario mismatch: '{}' != '{}'", - self.scenario_name, - other.scenario_name - ); - - let expected = self.comparable_records(); - let actual = other.comparable_records(); - - ensure!( - expected.len() == actual.len(), - "comparable step count mismatch for scenario '{}': '{}' has {}, '{}' has {}", - self.scenario_name, - self.target_name, - expected.len(), - other.target_name, - actual.len() - ); - - for (index, (expected, actual)) in expected.iter().zip(actual.iter()).enumerate() { - ensure!( - expected.label == actual.label, - "step label mismatch at comparable step {index} for scenario '{}': '{}' != '{}'", - self.scenario_name, - expected.label, - actual.label - ); - - if expected.screen != actual.screen { - bail!( - "screen mismatch for scenario '{}' at step '{}' ({} vs {})", - self.scenario_name, - expected.label, - self.target_name, - other.target_name, - ); - } - } - - Ok(()) - } - pub fn write_to(&self, mut writer: W) -> anyhow::Result<()> { for (index, record) in self.records.iter().enumerate() { writeln!(writer, "== {} ==", record.label)?; @@ -174,11 +119,35 @@ impl ScenarioRun { pub fn write_to_stdout(&self) -> anyhow::Result<()> { self.write_to(io::stdout()) } +} - fn comparable_records(&self) -> Vec<&ScenarioRecord> { - self.records - .iter() - .filter(|record| record.compare) - .collect() +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_to_matches_print_screen_style() { + let run = ScenarioRun { + scenario_name: "middle_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" + ); } } From e1800072b3cd5f7726512417344061d05aa22b0b Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 01:31:12 +0900 Subject: [PATCH 37/81] docs: about capture --- zsherio/src/capture.rs | 16 ++++++---------- zsherio/src/lib.rs | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/zsherio/src/capture.rs b/zsherio/src/capture.rs index 8194d5da..b242d738 100644 --- a/zsherio/src/capture.rs +++ b/zsherio/src/capture.rs @@ -1,12 +1,14 @@ use std::io::Write; use portable_pty::CommandBuilder; -use termharness::{screen_assert::format_screen, session::Session, terminal::TerminalSize}; +use termharness::{session::Session, terminal::TerminalSize}; +/// Spawn a session with the given command and terminal size. pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Result { Session::spawn(cmd, TerminalSize::new(rows, cols)) } +/// Spawn a zsh session with the given terminal size. pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { let mut cmd = CommandBuilder::new("/bin/zsh"); cmd.arg("-fi"); @@ -17,26 +19,20 @@ pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { spawn_session(cmd, rows, cols) } +/// Send bytes to the session's stdin. pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { session.writer.write_all(bytes)?; session.writer.flush()?; Ok(()) } -pub fn print_screen(label: &str, session: &Session, rows: usize) { - let screen = session.screen_snapshot(); - - println!("== {label} =="); - for line in format_screen(&screen, rows) { - println!("{line}"); - } -} - +/// 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()) } +/// Move the cursor left by the given number of times. pub fn move_cursor_left(session: &mut Session, times: usize) -> anyhow::Result<()> { for _ in 0..times { session.writer.write_all(b"\x1b[D")?; diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index 70c06986..f4629d87 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -2,6 +2,6 @@ pub mod capture; pub mod scenario; pub use capture::{ - move_cursor_left, move_cursor_to, print_screen, send_bytes, spawn_session, spawn_zsh_session, + move_cursor_left, move_cursor_to, send_bytes, spawn_session, spawn_zsh_session, }; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; From 46be559c21fc1d0b147f8bfc2755baf8011cd1a5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 02:16:35 +0900 Subject: [PATCH 38/81] chore: mv zsh_reference_capture/zsh_pretend => zsh_pretend --- Cargo.toml | 3 +- zsh_pretend/Cargo.toml | 14 ++++ .../src/lib.rs => zsh_pretend/src/main.rs | 3 +- zsh_reference_capture/Cargo.toml | 8 --- zsh_reference_capture/src/lib.rs | 2 - zsh_reference_capture/zsh_pretend/Cargo.toml | 21 ------ zsh_reference_capture/zsh_pretend/src/main.rs | 10 --- .../zsh_pretend_middle_insert_wrap.rs | 72 ++++++++++++------- zsherio/src/lib.rs | 4 +- 9 files changed, 64 insertions(+), 73 deletions(-) create mode 100644 zsh_pretend/Cargo.toml rename zsh_reference_capture/zsh_pretend/src/lib.rs => zsh_pretend/src/main.rs (92%) delete mode 100644 zsh_reference_capture/Cargo.toml delete mode 100644 zsh_reference_capture/src/lib.rs delete mode 100644 zsh_reference_capture/zsh_pretend/Cargo.toml delete mode 100644 zsh_reference_capture/zsh_pretend/src/main.rs rename zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs => zsherio/examples/zsh_pretend_middle_insert_wrap.rs (53%) diff --git a/Cargo.toml b/Cargo.toml index ef6da6a3..64ddfa3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,7 @@ members = [ "promkit-derive", "promkit-widgets", "termharness", - "zsh_reference_capture", - "zsh_reference_capture/zsh_pretend", + "zsh_pretend", "zsherio", ] diff --git a/zsh_pretend/Cargo.toml b/zsh_pretend/Cargo.toml new file mode 100644 index 00000000..14f9e0e8 --- /dev/null +++ b/zsh_pretend/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "zsh-pretend" +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 } diff --git a/zsh_reference_capture/zsh_pretend/src/lib.rs b/zsh_pretend/src/main.rs similarity index 92% rename from zsh_reference_capture/zsh_pretend/src/lib.rs rename to zsh_pretend/src/main.rs index fa75c9af..ce45e590 100644 --- a/zsh_reference_capture/zsh_pretend/src/lib.rs +++ b/zsh_pretend/src/main.rs @@ -4,7 +4,8 @@ use promkit::{ Prompt, }; -pub async fn run() -> anyhow::Result<()> { +#[tokio::main] +async fn main() -> anyhow::Result<()> { loop { match Readline::default().run().await { Ok(command) => { diff --git a/zsh_reference_capture/Cargo.toml b/zsh_reference_capture/Cargo.toml deleted file mode 100644 index 47ff53a5..00000000 --- a/zsh_reference_capture/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "zsh-reference-capture" -version = "0.1.0" -edition = "2021" -publish = false - -[dependencies] -zsherio = { path = "../zsherio" } diff --git a/zsh_reference_capture/src/lib.rs b/zsh_reference_capture/src/lib.rs deleted file mode 100644 index 183513ff..00000000 --- a/zsh_reference_capture/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub use zsherio::capture; -pub use zsherio::scenario; diff --git a/zsh_reference_capture/zsh_pretend/Cargo.toml b/zsh_reference_capture/zsh_pretend/Cargo.toml deleted file mode 100644 index 3d95d2b0..00000000 --- a/zsh_reference_capture/zsh_pretend/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "zsh-pretend" -version = "0.1.0" -edition = "2021" -publish = false - -[[bin]] -name = "zsh-pretend" -path = "src/main.rs" - -[[bin]] -name = "middle_insert_wrap" -path = "src/middle_insert_wrap.rs" - -[dependencies] -anyhow = { workspace = true } -portable-pty = { workspace = true } -promkit = { path = "../../promkit", features = ["readline"] } -termharness = { path = "../../termharness" } -tokio = { workspace = true } -zsherio = { path = "../../zsherio" } diff --git a/zsh_reference_capture/zsh_pretend/src/main.rs b/zsh_reference_capture/zsh_pretend/src/main.rs deleted file mode 100644 index b3416da4..00000000 --- a/zsh_reference_capture/zsh_pretend/src/main.rs +++ /dev/null @@ -1,10 +0,0 @@ -use std::io; - -use promkit::core::crossterm::{self, cursor, terminal}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let (_, rows) = terminal::size()?; - crossterm::execute!(io::stdout(), cursor::MoveTo(0, rows.saturating_sub(1)))?; - zsh_pretend::run().await -} diff --git a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs b/zsherio/examples/zsh_pretend_middle_insert_wrap.rs similarity index 53% rename from zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs rename to zsherio/examples/zsh_pretend_middle_insert_wrap.rs index e4c61e6a..e78985bb 100644 --- a/zsh_reference_capture/zsh_pretend/src/middle_insert_wrap.rs +++ b/zsherio/examples/zsh_pretend_middle_insert_wrap.rs @@ -1,7 +1,9 @@ -use std::{path::PathBuf, process::Command, thread, time::Duration}; +use std::{ffi::OsStr, path::PathBuf, process::Command, thread, time::Duration}; use portable_pty::CommandBuilder; -use zsherio::capture::{move_cursor_left, print_screen, send_bytes, spawn_session}; +use termharness::session::Session; +use zsherio::Scenario; +use zsherio::capture::{move_cursor_left, send_bytes, spawn_session}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; @@ -9,6 +11,23 @@ const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools const INSERTED_TEXT: &str = " and open source maintainer"; const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; +fn scenario() -> Scenario { + Scenario::new("middle_insert_wrap") + .step("startup", Duration::ZERO, |session| { + respond_to_cursor_position_request(session, TERMINAL_ROWS, 1)?; + wait_for_prompt(session) + }) + .step("type text", Duration::from_millis(200), |session| { + send_bytes(session, INPUT_TEXT.as_bytes()) + }) + .step("move cursor left", Duration::from_millis(200), |session| { + move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) + }) + .step("insert text", Duration::from_millis(250), |session| { + send_bytes(session, INSERTED_TEXT.as_bytes()) + }) +} + fn main() -> anyhow::Result<()> { build_zsh_pretend()?; @@ -18,27 +37,12 @@ fn main() -> anyhow::Result<()> { TERMINAL_COLS, )?; - respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; - wait_for_prompt(&session)?; - print_screen("startup", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, INPUT_TEXT.as_bytes())?; - thread::sleep(Duration::from_millis(200)); - print_screen("type text", &session, TERMINAL_ROWS as usize); - - move_cursor_left(&mut session, TIMES_TO_MOVE_CURSOR_LEFT)?; - thread::sleep(Duration::from_millis(200)); - print_screen("move cursor left", &session, TERMINAL_ROWS as usize); - - send_bytes(&mut session, INSERTED_TEXT.as_bytes())?; - thread::sleep(Duration::from_millis(250)); - print_screen("insert text", &session, TERMINAL_ROWS as usize); - - Ok(()) + let run = scenario().run("zsh-pretend", &mut session)?; + run.write_to_stdout() } fn respond_to_cursor_position_request( - session: &mut termharness::session::Session, + session: &mut Session, row: u16, col: u16, ) -> anyhow::Result<()> { @@ -64,7 +68,7 @@ fn respond_to_cursor_position_request( )) } -fn wait_for_prompt(session: &termharness::session::Session) -> anyhow::Result<()> { +fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(2); while std::time::Instant::now() < deadline { let screen = session.screen_snapshot(); @@ -78,9 +82,13 @@ fn wait_for_prompt(session: &termharness::session::Session) -> anyhow::Result<() } fn build_zsh_pretend() -> anyhow::Result<()> { - let status = Command::new("cargo") - .args(["build", "-q", "-p", "zsh-pretend", "--bin", "zsh-pretend"]) - .status()?; + let mut command = Command::new("cargo"); + command.args(["build", "-q", "-p", "zsh-pretend", "--bin", "zsh-pretend"]); + if target_binary_dir()?.file_name() == Some(OsStr::new("release")) { + command.arg("--release"); + } + + let status = command.status()?; if !status.success() { return Err(anyhow::anyhow!("failed to build zsh-pretend")); } @@ -88,9 +96,21 @@ fn build_zsh_pretend() -> anyhow::Result<()> { } fn zsh_pretend_binary_path() -> anyhow::Result { + Ok(target_binary_dir()?.join("zsh-pretend")) +} + +fn target_binary_dir() -> anyhow::Result { let current_exe = std::env::current_exe()?; - let parent = current_exe + let executable_dir = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("failed to resolve executable directory"))?; - Ok(parent.join("zsh-pretend")) + + if executable_dir.file_name() == Some(OsStr::new("examples")) { + return executable_dir + .parent() + .map(|path| path.to_path_buf()) + .ok_or_else(|| anyhow::anyhow!("failed to resolve target directory")); + } + + Ok(executable_dir.to_path_buf()) } diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index f4629d87..a52084a6 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -1,7 +1,5 @@ pub mod capture; pub mod scenario; -pub use capture::{ - move_cursor_left, move_cursor_to, send_bytes, spawn_session, spawn_zsh_session, -}; +pub use capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; From 07a54e4ef8fb38faa2986bb580fe8a9bcff373d8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 20:32:45 +0900 Subject: [PATCH 39/81] fix: clear and move_to --- zsherio/examples/zsh_middle_insert_wrap.rs | 15 +++++++++------ zsherio/examples/zsh_resize_wrap.rs | 6 ++++-- zsherio/src/capture.rs | 14 ++++++++++++++ zsherio/src/lib.rs | 5 ++++- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index 91954a64..c29c7907 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -1,7 +1,10 @@ +use std::thread; use std::time::Duration; use zsherio::Scenario; -use zsherio::capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_zsh_session}; +use zsherio::capture::{ + clear_screen_and_move_cursor_to, move_cursor_left, send_bytes, spawn_zsh_session, +}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; @@ -12,11 +15,6 @@ const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; fn scenario() -> Scenario { Scenario::new("middle_insert_wrap") .step("spawn", Duration::from_millis(300), |_session| Ok(())) - .step( - "move cursor to bottom", - Duration::from_millis(300), - |session| move_cursor_to(session, TERMINAL_ROWS, 1), - ) .step("type text", Duration::from_millis(200), |session| { send_bytes(session, INPUT_TEXT.as_bytes()) }) @@ -30,6 +28,11 @@ fn scenario() -> Scenario { fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + + // Before create scenaro, 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/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs index 5e74dda3..d1e5f4f4 100644 --- a/zsherio/examples/zsh_resize_wrap.rs +++ b/zsherio/examples/zsh_resize_wrap.rs @@ -2,7 +2,9 @@ use std::time::Duration; use termharness::{session::Session, terminal::TerminalSize}; use zsherio::Scenario; -use zsherio::capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_zsh_session}; +use zsherio::capture::{ + clear_screen_and_move_cursor_to, move_cursor_left, send_bytes, spawn_zsh_session, +}; const TERMINAL_ROWS: u16 = 10; const TERMINAL_COLS: u16 = 40; @@ -19,7 +21,7 @@ fn scenario() -> Scenario { .step( "move cursor to bottom", Duration::from_millis(300), - |session| move_cursor_to(session, TERMINAL_ROWS, 1), + |session| clear_screen_and_move_cursor_to(session, TERMINAL_ROWS, 1), ) .step("run echo", Duration::from_millis(300), |session| { send_bytes(session, b"\"ynqa is a software engineer\"\r") diff --git a/zsherio/src/capture.rs b/zsherio/src/capture.rs index b242d738..5a12a5b5 100644 --- a/zsherio/src/capture.rs +++ b/zsherio/src/capture.rs @@ -32,6 +32,20 @@ pub fn move_cursor_to(session: &mut Session, row: u16, col: u16) -> anyhow::Resu 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<()> { for _ in 0..times { diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index a52084a6..7ad4b748 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -1,5 +1,8 @@ pub mod capture; pub mod scenario; -pub use capture::{move_cursor_left, move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}; +pub use capture::{ + clear_screen_and_move_cursor_to, move_cursor_left, move_cursor_to, send_bytes, spawn_session, + spawn_zsh_session, +}; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; From 8f07c220311baf8634b5c494ce5c883d469f0226 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 20:45:51 +0900 Subject: [PATCH 40/81] fix: shorter duration --- zsherio/examples/zsh_middle_insert_wrap.rs | 6 +++--- zsherio/examples/zsh_resize_wrap.rs | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index c29c7907..957c3c7c 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -15,13 +15,13 @@ const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; fn scenario() -> Scenario { Scenario::new("middle_insert_wrap") .step("spawn", Duration::from_millis(300), |_session| Ok(())) - .step("type text", Duration::from_millis(200), |session| { + .step("type text", Duration::from_millis(100), |session| { send_bytes(session, INPUT_TEXT.as_bytes()) }) - .step("move cursor left", Duration::from_millis(200), |session| { + .step("move cursor left", Duration::from_millis(100), |session| { move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) }) - .step("insert text", Duration::from_millis(250), |session| { + .step("insert text", Duration::from_millis(100), |session| { send_bytes(session, INSERTED_TEXT.as_bytes()) }) } diff --git a/zsherio/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs index d1e5f4f4..2948fc72 100644 --- a/zsherio/examples/zsh_resize_wrap.rs +++ b/zsherio/examples/zsh_resize_wrap.rs @@ -1,3 +1,4 @@ +use std::thread; use std::time::Duration; use termharness::{session::Session, terminal::TerminalSize}; @@ -18,34 +19,29 @@ fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { fn scenario() -> Scenario { let mut scenario = Scenario::new("zsh_resize_wrap") .step("spawn", Duration::from_millis(300), |_session| Ok(())) - .step( - "move cursor to bottom", - Duration::from_millis(300), - |session| clear_screen_and_move_cursor_to(session, TERMINAL_ROWS, 1), - ) - .step("run echo", Duration::from_millis(300), |session| { + .step("run echo", Duration::from_millis(100), |session| { send_bytes(session, b"\"ynqa is a software engineer\"\r") }) - .step("type text", Duration::from_millis(200), |session| { + .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(200), |session| { + 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(120), + Duration::from_millis(100), move |session| resize(session, cols), ); } for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { scenario = scenario.step( format!("resize -> {cols} cols"), - Duration::from_millis(120), + Duration::from_millis(100), move |session| resize(session, cols), ); } @@ -55,6 +51,11 @@ fn scenario() -> Scenario { fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + + // Before create scenaro, 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() } From ad0cffc5e1904ac33c7cfeb0ffe45371da75f616 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 21:16:40 +0900 Subject: [PATCH 41/81] tests: zsh vs zsh pretend --- zsh_pretend/Cargo.toml | 5 + zsh_pretend/tests/middle_insert_wrap.rs | 95 ++++++++++++++ zsherio/examples/zsh_middle_insert_wrap.rs | 26 +--- .../zsh_pretend_middle_insert_wrap.rs | 116 ------------------ zsherio/src/lib.rs | 1 + zsherio/src/scenarios.rs | 28 +++++ 6 files changed, 131 insertions(+), 140 deletions(-) create mode 100644 zsh_pretend/tests/middle_insert_wrap.rs delete mode 100644 zsherio/examples/zsh_pretend_middle_insert_wrap.rs create mode 100644 zsherio/src/scenarios.rs diff --git a/zsh_pretend/Cargo.toml b/zsh_pretend/Cargo.toml index 14f9e0e8..7974f3ed 100644 --- a/zsh_pretend/Cargo.toml +++ b/zsh_pretend/Cargo.toml @@ -12,3 +12,8 @@ path = "src/main.rs" 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_pretend/tests/middle_insert_wrap.rs b/zsh_pretend/tests/middle_insert_wrap.rs new file mode 100644 index 00000000..a76c3816 --- /dev/null +++ b/zsh_pretend/tests/middle_insert_wrap.rs @@ -0,0 +1,95 @@ +use std::{thread, time::Duration}; + +use portable_pty::CommandBuilder; +use termharness::session::Session; +use zsherio::{ + ScenarioRun, + capture::{clear_screen_and_move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}, + scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, +}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + assert_eq!( + actual.records, + expected.records, + "zsh-pretend output diverged from zsh\n\nexpected:\n{}\nactual:\n{}", + render_run(&expected)?, + render_run(&actual)?, + ); + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + + 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, + )?; + + respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; + wait_for_prompt(&session)?; + + scenario().run("zsh-pretend", &mut session) +} + +fn respond_to_cursor_position_request( + session: &mut Session, + row: u16, + col: u16, +) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let requested = { + let output = session + .output + .lock() + .expect("failed to lock session output buffer"); + output.windows(4).any(|window| window == b"\x1b[6n") + }; + if requested { + let response = format!("\x1b[{row};{col}R"); + send_bytes(session, response.as_bytes())?; + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!( + "timed out waiting for cursor position request" + )) +} + +fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.iter().any(|line| line.starts_with("❯❯ ")) { + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +fn render_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index 957c3c7c..adbd30af 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -1,30 +1,8 @@ use std::thread; use std::time::Duration; -use zsherio::Scenario; -use zsherio::capture::{ - clear_screen_and_move_cursor_to, move_cursor_left, send_bytes, spawn_zsh_session, -}; - -const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 40; -const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; -const INSERTED_TEXT: &str = " and open source maintainer"; -const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; - -fn scenario() -> Scenario { - Scenario::new("middle_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()) - }) -} +use zsherio::capture::{clear_screen_and_move_cursor_to, spawn_zsh_session}; +use zsherio::scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}; fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; diff --git a/zsherio/examples/zsh_pretend_middle_insert_wrap.rs b/zsherio/examples/zsh_pretend_middle_insert_wrap.rs deleted file mode 100644 index e78985bb..00000000 --- a/zsherio/examples/zsh_pretend_middle_insert_wrap.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::{ffi::OsStr, path::PathBuf, process::Command, thread, time::Duration}; - -use portable_pty::CommandBuilder; -use termharness::session::Session; -use zsherio::Scenario; -use zsherio::capture::{move_cursor_left, send_bytes, spawn_session}; - -const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 40; -const INPUT_TEXT: &str = "ynqa is a software engineer who writes terminal tools every day"; -const INSERTED_TEXT: &str = " and open source maintainer"; -const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; - -fn scenario() -> Scenario { - Scenario::new("middle_insert_wrap") - .step("startup", Duration::ZERO, |session| { - respond_to_cursor_position_request(session, TERMINAL_ROWS, 1)?; - wait_for_prompt(session) - }) - .step("type text", Duration::from_millis(200), |session| { - send_bytes(session, INPUT_TEXT.as_bytes()) - }) - .step("move cursor left", Duration::from_millis(200), |session| { - move_cursor_left(session, TIMES_TO_MOVE_CURSOR_LEFT) - }) - .step("insert text", Duration::from_millis(250), |session| { - send_bytes(session, INSERTED_TEXT.as_bytes()) - }) -} - -fn main() -> anyhow::Result<()> { - build_zsh_pretend()?; - - let mut session = spawn_session( - CommandBuilder::new(zsh_pretend_binary_path()?), - TERMINAL_ROWS, - TERMINAL_COLS, - )?; - - let run = scenario().run("zsh-pretend", &mut session)?; - run.write_to_stdout() -} - -fn respond_to_cursor_position_request( - session: &mut Session, - row: u16, - col: u16, -) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let requested = { - let output = session - .output - .lock() - .expect("failed to lock session output buffer"); - output.windows(4).any(|window| window == b"\x1b[6n") - }; - if requested { - let response = format!("\x1b[{row};{col}R"); - send_bytes(session, response.as_bytes())?; - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!( - "timed out waiting for cursor position request" - )) -} - -fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let screen = session.screen_snapshot(); - if screen.iter().any(|line| line.starts_with("❯❯ ")) { - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!("timed out waiting for prompt")) -} - -fn build_zsh_pretend() -> anyhow::Result<()> { - let mut command = Command::new("cargo"); - command.args(["build", "-q", "-p", "zsh-pretend", "--bin", "zsh-pretend"]); - if target_binary_dir()?.file_name() == Some(OsStr::new("release")) { - command.arg("--release"); - } - - let status = command.status()?; - if !status.success() { - return Err(anyhow::anyhow!("failed to build zsh-pretend")); - } - Ok(()) -} - -fn zsh_pretend_binary_path() -> anyhow::Result { - Ok(target_binary_dir()?.join("zsh-pretend")) -} - -fn target_binary_dir() -> anyhow::Result { - let current_exe = std::env::current_exe()?; - let executable_dir = current_exe - .parent() - .ok_or_else(|| anyhow::anyhow!("failed to resolve executable directory"))?; - - if executable_dir.file_name() == Some(OsStr::new("examples")) { - return executable_dir - .parent() - .map(|path| path.to_path_buf()) - .ok_or_else(|| anyhow::anyhow!("failed to resolve target directory")); - } - - Ok(executable_dir.to_path_buf()) -} diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index 7ad4b748..ffa03726 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -1,5 +1,6 @@ pub mod capture; pub mod scenario; +pub mod scenarios; pub use capture::{ clear_screen_and_move_cursor_to, move_cursor_left, move_cursor_to, send_bytes, spawn_session, diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs new file mode 100644 index 00000000..943fdde2 --- /dev/null +++ b/zsherio/src/scenarios.rs @@ -0,0 +1,28 @@ +pub mod middle_insert_wrap { + use std::time::Duration; + + use crate::{ + Scenario, + capture::{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("middle_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()) + }) + } +} From 1714dccc040db9f7ecd607a25d8e1897a7f32d18 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 22:07:39 +0900 Subject: [PATCH 42/81] tests: save as artifacts --- .gitignore | 3 +++ zsh_pretend/tests/middle_insert_wrap.rs | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 42d75df4..854cc061 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_pretend integration tests +zsh_pretend/.artifacts/ diff --git a/zsh_pretend/tests/middle_insert_wrap.rs b/zsh_pretend/tests/middle_insert_wrap.rs index a76c3816..3643c3ff 100644 --- a/zsh_pretend/tests/middle_insert_wrap.rs +++ b/zsh_pretend/tests/middle_insert_wrap.rs @@ -1,4 +1,4 @@ -use std::{thread, time::Duration}; +use std::{path::PathBuf, thread, time::Duration}; use portable_pty::CommandBuilder; use termharness::session::Session; @@ -15,6 +15,9 @@ fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; + write_run_artifact(&expected)?; + write_run_artifact(&actual)?; + assert_eq!( actual.records, expected.records, @@ -93,3 +96,14 @@ fn render_run(run: &ScenarioRun) -> anyhow::Result { run.write_to(&mut output)?; Ok(String::from_utf8(output)?) } + +fn write_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)) +} From dc75c6d151db285f441a5cbc38729a75dd774ca5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 11 Mar 2026 23:34:21 +0900 Subject: [PATCH 43/81] tests: resize wrap testing --- zsh_pretend/tests/resize_wrap.rs | 109 ++++++++++++++++++++++++++++ zsherio/examples/zsh_resize_wrap.rs | 49 +------------ zsherio/src/scenarios.rs | 49 +++++++++++++ 3 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 zsh_pretend/tests/resize_wrap.rs diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs new file mode 100644 index 00000000..c359ce5b --- /dev/null +++ b/zsh_pretend/tests/resize_wrap.rs @@ -0,0 +1,109 @@ +use std::{path::PathBuf, thread, time::Duration}; + +use portable_pty::CommandBuilder; +use termharness::session::Session; +use zsherio::{ + ScenarioRun, + capture::{clear_screen_and_move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}, + scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, +}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_run_artifact(&expected)?; + write_run_artifact(&actual)?; + + assert_eq!( + actual.records, + expected.records, + "zsh-pretend output diverged from zsh\n\nexpected:\n{}\nactual:\n{}", + render_run(&expected)?, + render_run(&actual)?, + ); + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + + 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, + )?; + + respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; + wait_for_prompt(&session)?; + + scenario().run("zsh-pretend", &mut session) +} + +fn respond_to_cursor_position_request( + session: &mut Session, + row: u16, + col: u16, +) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let requested = { + let output = session + .output + .lock() + .expect("failed to lock session output buffer"); + output.windows(4).any(|window| window == b"\x1b[6n") + }; + if requested { + let response = format!("\x1b[{row};{col}R"); + send_bytes(session, response.as_bytes())?; + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!( + "timed out waiting for cursor position request" + )) +} + +fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.iter().any(|line| line.starts_with("❯❯ ")) { + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +fn render_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} + +fn write_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/zsherio/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs index 2948fc72..8335a6e2 100644 --- a/zsherio/examples/zsh_resize_wrap.rs +++ b/zsherio/examples/zsh_resize_wrap.rs @@ -1,53 +1,8 @@ use std::thread; use std::time::Duration; -use termharness::{session::Session, terminal::TerminalSize}; -use zsherio::Scenario; -use zsherio::capture::{ - clear_screen_and_move_cursor_to, move_cursor_left, send_bytes, spawn_zsh_session, -}; - -const TERMINAL_ROWS: u16 = 10; -const TERMINAL_COLS: u16 = 40; -const RESIZED_TERMINAL_COLS: u16 = 20; -const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; - -fn resize(session: &mut Session, cols: u16) -> anyhow::Result<()> { - session.resize(TerminalSize::new(TERMINAL_ROWS, cols)) -} - -fn scenario() -> Scenario { - let mut scenario = Scenario::new("zsh_resize_wrap") - .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| resize(session, cols), - ); - } - for cols in (RESIZED_TERMINAL_COLS + 1)..=TERMINAL_COLS { - scenario = scenario.step( - format!("resize -> {cols} cols"), - Duration::from_millis(100), - move |session| resize(session, cols), - ); - } - - scenario -} +use zsherio::capture::{clear_screen_and_move_cursor_to, spawn_zsh_session}; +use zsherio::scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}; fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index 943fdde2..efa3ccf8 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -26,3 +26,52 @@ pub mod middle_insert_wrap { }) } } + +pub mod resize_wrap { + use std::time::Duration; + + use termharness::terminal::TerminalSize; + + use crate::{ + Scenario, + capture::{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_wrap") + .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 + } +} From 9e7b75b64271692164e865fe702f7fdb68c7aade Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 00:50:07 +0900 Subject: [PATCH 44/81] tests: improve visibilities --- zsh_pretend/tests/middle_insert_wrap.rs | 20 +++++++++++++------- zsh_pretend/tests/resize_wrap.rs | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/zsh_pretend/tests/middle_insert_wrap.rs b/zsh_pretend/tests/middle_insert_wrap.rs index 3643c3ff..b8dcdeb1 100644 --- a/zsh_pretend/tests/middle_insert_wrap.rs +++ b/zsh_pretend/tests/middle_insert_wrap.rs @@ -18,13 +18,7 @@ fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { write_run_artifact(&expected)?; write_run_artifact(&actual)?; - assert_eq!( - actual.records, - expected.records, - "zsh-pretend output diverged from zsh\n\nexpected:\n{}\nactual:\n{}", - render_run(&expected)?, - render_run(&actual)?, - ); + assert_runs_match(&expected, &actual)?; Ok(()) } @@ -97,6 +91,18 @@ fn render_run(run: &ScenarioRun) -> anyhow::Result { Ok(String::from_utf8(output)?) } +fn assert_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_run(expected)?, + render_run(actual)?, + ) +} + fn write_run_artifact(run: &ScenarioRun) -> anyhow::Result<()> { run.write_to_path(&artifact_path(run)) } diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs index c359ce5b..8cf49542 100644 --- a/zsh_pretend/tests/resize_wrap.rs +++ b/zsh_pretend/tests/resize_wrap.rs @@ -18,13 +18,7 @@ fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { write_run_artifact(&expected)?; write_run_artifact(&actual)?; - assert_eq!( - actual.records, - expected.records, - "zsh-pretend output diverged from zsh\n\nexpected:\n{}\nactual:\n{}", - render_run(&expected)?, - render_run(&actual)?, - ); + assert_runs_match(&expected, &actual)?; Ok(()) } @@ -97,6 +91,18 @@ fn render_run(run: &ScenarioRun) -> anyhow::Result { Ok(String::from_utf8(output)?) } +fn assert_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_run(expected)?, + render_run(actual)?, + ) +} + fn write_run_artifact(run: &ScenarioRun) -> anyhow::Result<()> { run.write_to_path(&artifact_path(run)) } From e9541deb709abac0b47dcb5702729d74be8cd81c Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 01:09:12 +0900 Subject: [PATCH 45/81] fix: enable to capture cursor position on --- termharness/src/session.rs | 130 ++++++++++++++++++++++-- zsh_pretend/tests/middle_insert_wrap.rs | 36 +------ zsh_pretend/tests/resize_wrap.rs | 36 +------ zsherio/src/capture.rs | 31 +++++- zsherio/src/lib.rs | 2 +- 5 files changed, 159 insertions(+), 76 deletions(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index d3ed1b1e..450bd20b 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -9,6 +9,7 @@ use std::{ use crate::terminal::TerminalSize; use alacritty_terminal::{ event::VoidListener, + index::{Column, Line, Point}, term::{Config, Term, cell::Flags, test::TermSize}, vte::ansi::Processor, }; @@ -28,6 +29,13 @@ fn pad_to_cols(cols: u16, content: &str) -> String { line } +fn cursor_position_request_count(buffer: &[u8]) -> usize { + buffer + .windows(4) + .filter(|window| *window == b"\x1b[6n") + .count() +} + struct Screen { parser: Processor, terminal: Term, @@ -42,6 +50,12 @@ impl Screen { } } + 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); } @@ -51,6 +65,22 @@ impl Screen { 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; @@ -84,10 +114,12 @@ impl Screen { } } +type SharedWriter = Arc>>; + pub struct Session { pub child: Box, pub master: Box, - pub writer: Box, + pub writer: SharedWriter, pub output: Arc>>, screen: Arc>, pub reader_thread: Option>, @@ -96,7 +128,15 @@ pub struct Session { impl Session { /// Spawn a new session by executing the given command in a pseudo-terminal with the specified size. - pub fn spawn(mut cmd: CommandBuilder, size: TerminalSize) -> Result { + pub fn spawn(cmd: CommandBuilder, size: TerminalSize) -> Result { + Self::spawn_with_cursor(cmd, size, None) + } + + pub fn spawn_with_cursor( + mut cmd: CommandBuilder, + size: TerminalSize, + cursor_position: Option<(u16, u16)>, + ) -> Result { let pty = native_pty_system(); let pair = pty.openpty(PtySize { rows: size.rows, @@ -115,11 +155,17 @@ impl Session { let master = pair.master; let output = Arc::new(Mutex::new(Vec::new())); let output_reader = Arc::clone(&output); - let screen = Arc::new(Mutex::new(Screen::new(size))); + let screen = Arc::new(Mutex::new(match cursor_position { + Some((row, col)) => Screen::with_cursor(size, row, col), + None => Screen::new(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, @@ -129,18 +175,41 @@ impl Session { .lock() .expect("failed to lock output buffer") .extend_from_slice(chunk); - screen_reader - .lock() - .expect("failed to lock screen parser") - .process(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(3); + tail = scan.split_off(keep_from); } Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, } } }); - - let writer = master.take_writer()?; Ok(Self { child, master, @@ -202,6 +271,49 @@ mod tests { 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, TerminalSize::new(24, 80))?; + + 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_with_cursor(cmd, TerminalSize::new(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 { diff --git a/zsh_pretend/tests/middle_insert_wrap.rs b/zsh_pretend/tests/middle_insert_wrap.rs index b8dcdeb1..3958f89c 100644 --- a/zsh_pretend/tests/middle_insert_wrap.rs +++ b/zsh_pretend/tests/middle_insert_wrap.rs @@ -3,9 +3,9 @@ use std::{path::PathBuf, thread, time::Duration}; use portable_pty::CommandBuilder; use termharness::session::Session; use zsherio::{ + capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + scenarios::middle_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, ScenarioRun, - capture::{clear_screen_and_move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}, - scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, }; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -33,45 +33,19 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session( + let mut session = spawn_session_with_cursor( CommandBuilder::new(ZSH_PRETEND_BIN), TERMINAL_ROWS, TERMINAL_COLS, + TERMINAL_ROWS, + 1, )?; - respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; wait_for_prompt(&session)?; scenario().run("zsh-pretend", &mut session) } -fn respond_to_cursor_position_request( - session: &mut Session, - row: u16, - col: u16, -) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let requested = { - let output = session - .output - .lock() - .expect("failed to lock session output buffer"); - output.windows(4).any(|window| window == b"\x1b[6n") - }; - if requested { - let response = format!("\x1b[{row};{col}R"); - send_bytes(session, response.as_bytes())?; - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!( - "timed out waiting for cursor position request" - )) -} - fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(2); while std::time::Instant::now() < deadline { diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs index 8cf49542..c9fb7fa4 100644 --- a/zsh_pretend/tests/resize_wrap.rs +++ b/zsh_pretend/tests/resize_wrap.rs @@ -3,9 +3,9 @@ use std::{path::PathBuf, thread, time::Duration}; use portable_pty::CommandBuilder; use termharness::session::Session; use zsherio::{ + capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + scenarios::resize_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, ScenarioRun, - capture::{clear_screen_and_move_cursor_to, send_bytes, spawn_session, spawn_zsh_session}, - scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, }; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -33,45 +33,19 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session( + let mut session = spawn_session_with_cursor( CommandBuilder::new(ZSH_PRETEND_BIN), TERMINAL_ROWS, TERMINAL_COLS, + TERMINAL_ROWS, + 1, )?; - respond_to_cursor_position_request(&mut session, TERMINAL_ROWS, 1)?; wait_for_prompt(&session)?; scenario().run("zsh-pretend", &mut session) } -fn respond_to_cursor_position_request( - session: &mut Session, - row: u16, - col: u16, -) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let requested = { - let output = session - .output - .lock() - .expect("failed to lock session output buffer"); - output.windows(4).any(|window| window == b"\x1b[6n") - }; - if requested { - let response = format!("\x1b[{row};{col}R"); - send_bytes(session, response.as_bytes())?; - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!( - "timed out waiting for cursor position request" - )) -} - fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(2); while std::time::Instant::now() < deadline { diff --git a/zsherio/src/capture.rs b/zsherio/src/capture.rs index 5a12a5b5..56160fc0 100644 --- a/zsherio/src/capture.rs +++ b/zsherio/src/capture.rs @@ -8,6 +8,21 @@ pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Resul Session::spawn(cmd, TerminalSize::new(rows, cols)) } +/// Spawn a session with the given command, terminal size, and initial cursor position. +pub fn spawn_session_with_cursor( + cmd: CommandBuilder, + rows: u16, + cols: u16, + cursor_row: u16, + cursor_col: u16, +) -> anyhow::Result { + Session::spawn_with_cursor( + cmd, + TerminalSize::new(rows, cols), + Some((cursor_row, cursor_col)), + ) +} + /// Spawn a zsh session with the given terminal size. pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { let mut cmd = CommandBuilder::new("/bin/zsh"); @@ -21,8 +36,12 @@ pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { /// Send bytes to the session's stdin. pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { - session.writer.write_all(bytes)?; - session.writer.flush()?; + let mut writer = session + .writer + .lock() + .expect("failed to lock session writer"); + writer.write_all(bytes)?; + writer.flush()?; Ok(()) } @@ -48,9 +67,13 @@ pub fn clear_screen_and_move_cursor_to( /// 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 { - session.writer.write_all(b"\x1b[D")?; + writer.write_all(b"\x1b[D")?; } - session.writer.flush()?; + writer.flush()?; Ok(()) } diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index ffa03726..17790311 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -4,6 +4,6 @@ pub mod scenarios; pub use capture::{ clear_screen_and_move_cursor_to, move_cursor_left, move_cursor_to, send_bytes, spawn_session, - spawn_zsh_session, + spawn_session_with_cursor, spawn_zsh_session, }; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; From 488868a4787825265b00db162a15c677f10149ad Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 01:17:33 +0900 Subject: [PATCH 46/81] chore: more understandable --- termharness/src/session.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 450bd20b..1ae59819 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -17,6 +17,9 @@ 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!( @@ -31,8 +34,8 @@ fn pad_to_cols(cols: u16, content: &str) -> String { fn cursor_position_request_count(buffer: &[u8]) -> usize { buffer - .windows(4) - .filter(|window| *window == b"\x1b[6n") + .windows(CURSOR_POSITION_REQUEST_LEN) + .filter(|window| *window == CURSOR_POSITION_REQUEST) .count() } @@ -202,7 +205,8 @@ impl Session { } } - let keep_from = scan.len().saturating_sub(3); + 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, From b5d53ecebfba0325630565a30fe67edec9142703 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 02:11:32 +0900 Subject: [PATCH 47/81] tests: start at current_position --- zsh_pretend/tests/middle_prompt_start.rs | 101 +++++++++++++++++++++++ zsherio/src/scenarios.rs | 20 ++++- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 zsh_pretend/tests/middle_prompt_start.rs diff --git a/zsh_pretend/tests/middle_prompt_start.rs b/zsh_pretend/tests/middle_prompt_start.rs new file mode 100644 index 00000000..c0470b55 --- /dev/null +++ b/zsh_pretend/tests/middle_prompt_start.rs @@ -0,0 +1,101 @@ +use std::{path::PathBuf, thread, time::Duration}; + +use portable_pty::CommandBuilder; +use termharness::session::Session; +use zsherio::{ + capture::spawn_session_with_cursor, + scenarios::middle_prompt_start::{ + scenario, START_CURSOR_COL, START_CURSOR_ROW, TERMINAL_COLS, TERMINAL_ROWS, + }, + ScenarioRun, +}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_matches_zsh_for_middle_prompt_start() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_run_artifact(&expected)?; + write_run_artifact(&actual)?; + + assert_runs_match(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> 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", ""); + let mut session = spawn_session_with_cursor( + cmd, + TERMINAL_ROWS, + TERMINAL_COLS, + START_CURSOR_ROW, + START_CURSOR_COL, + )?; + wait_for_prompt(&session)?; + + scenario().run("zsh", &mut session) +} + +fn run_zsh_pretend() -> anyhow::Result { + let mut session = spawn_session_with_cursor( + CommandBuilder::new(ZSH_PRETEND_BIN), + TERMINAL_ROWS, + TERMINAL_COLS, + START_CURSOR_ROW, + START_CURSOR_COL, + )?; + + wait_for_prompt(&session)?; + + scenario().run("zsh-pretend", &mut session) +} + +fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.iter().any(|line| line.contains("❯❯ ")) { + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +fn render_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} + +fn assert_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_run(expected)?, + render_run(actual)?, + ) +} + +fn write_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/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index efa3ccf8..6aa67af3 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -2,8 +2,8 @@ pub mod middle_insert_wrap { use std::time::Duration; use crate::{ - Scenario, capture::{move_cursor_left, send_bytes}, + Scenario, }; pub const TERMINAL_ROWS: u16 = 10; @@ -27,14 +27,30 @@ pub mod middle_insert_wrap { } } +pub mod middle_prompt_start { + 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("middle_prompt_start") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + } +} + pub mod resize_wrap { use std::time::Duration; use termharness::terminal::TerminalSize; use crate::{ - Scenario, capture::{move_cursor_left, send_bytes}, + Scenario, }; pub const TERMINAL_ROWS: u16 = 10; From 7b66438454c569b23ec64670435ea4a7d3890dab Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 04:04:20 +0900 Subject: [PATCH 48/81] tests: zsh_pretend_matches_zsh_for_small_terminal_overflow --- zsh_pretend/tests/small_terminal_overflow.rs | 89 ++++++++++++++++++++ zsherio/src/scenarios.rs | 18 ++++ 2 files changed, 107 insertions(+) create mode 100644 zsh_pretend/tests/small_terminal_overflow.rs diff --git a/zsh_pretend/tests/small_terminal_overflow.rs b/zsh_pretend/tests/small_terminal_overflow.rs new file mode 100644 index 00000000..9bf6b895 --- /dev/null +++ b/zsh_pretend/tests/small_terminal_overflow.rs @@ -0,0 +1,89 @@ +use std::{path::PathBuf, thread, time::Duration}; + +use portable_pty::CommandBuilder; +use termharness::session::Session; +use zsherio::{ + capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + scenarios::small_terminal_overflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + ScenarioRun, +}; + +const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); + +#[test] +fn zsh_pretend_matches_zsh_for_small_terminal_overflow() -> anyhow::Result<()> { + let expected = run_zsh()?; + let actual = run_zsh_pretend()?; + + write_run_artifact(&expected)?; + write_run_artifact(&actual)?; + + assert_runs_match(&expected, &actual)?; + + Ok(()) +} + +fn run_zsh() -> anyhow::Result { + let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + + 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_with_cursor( + CommandBuilder::new(ZSH_PRETEND_BIN), + TERMINAL_ROWS, + TERMINAL_COLS, + TERMINAL_ROWS, + 1, + )?; + + wait_for_prompt(&session)?; + + scenario().run("zsh-pretend", &mut session) +} + +fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + let screen = session.screen_snapshot(); + if screen.iter().any(|line| line.starts_with("❯❯ ")) { + return Ok(()); + } + thread::sleep(Duration::from_millis(20)); + } + + Err(anyhow::anyhow!("timed out waiting for prompt")) +} + +fn render_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} + +fn assert_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_run(expected)?, + render_run(actual)?, + ) +} + +fn write_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/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index 6aa67af3..49e2bccf 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -91,3 +91,21 @@ pub mod resize_wrap { scenario } } + +pub mod small_terminal_overflow { + use std::time::Duration; + + use crate::{capture::send_bytes, Scenario}; + + 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("small_terminal_overflow") + .step("spawn", Duration::from_millis(300), |_session| Ok(())) + .step("type long text", Duration::from_millis(100), |session| { + send_bytes(session, INPUT_TEXT.as_bytes()) + }) + } +} From 22df6e8d4b08a655db1340634bae73dd61f0a08a Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 04:37:56 +0900 Subject: [PATCH 49/81] tests: zsh_pretend_matches_zsh_for_small_terminal_overflow (workaround) --- zsh_pretend/tests/small_terminal_overflow.rs | 36 +++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/zsh_pretend/tests/small_terminal_overflow.rs b/zsh_pretend/tests/small_terminal_overflow.rs index 9bf6b895..1427417a 100644 --- a/zsh_pretend/tests/small_terminal_overflow.rs +++ b/zsh_pretend/tests/small_terminal_overflow.rs @@ -65,8 +65,42 @@ fn render_run(run: &ScenarioRun) -> anyhow::Result { Ok(String::from_utf8(output)?) } +/// 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 runs_match_from_second_line(expected: &ScenarioRun, actual: &ScenarioRun) -> bool { + if expected.records.len() != actual.records.len() { + return false; + } + + 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)) + }) +} + fn assert_runs_match(expected: &ScenarioRun, actual: &ScenarioRun) -> anyhow::Result<()> { - if actual.records == expected.records { + if actual.records == expected.records || runs_match_from_second_line(expected, actual) { return Ok(()); } From 5cc1e2024834cb0008e8b76f794ded23916a8f8d Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 03:31:22 +0900 Subject: [PATCH 50/81] tests: ignore zsh_pretend_matches_zsh_for_resize_wrap --- zsh_pretend/tests/resize_wrap.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs index c9fb7fa4..8fef68c3 100644 --- a/zsh_pretend/tests/resize_wrap.rs +++ b/zsh_pretend/tests/resize_wrap.rs @@ -11,6 +11,7 @@ use zsherio::{ const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] +#[ignore = "timing-sensitive"] fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; From 8ebf240fb5cfe0313021d1355078bc4118581760 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 03:37:27 +0900 Subject: [PATCH 51/81] chore: remove Pane and matrixify --- examples/byop/src/byop.rs | 43 +++---- promkit-core/src/grapheme.rs | 163 +++++++++++++------------ promkit-core/src/lib.rs | 10 +- promkit-core/src/pane.rs | 173 --------------------------- promkit-core/src/render.rs | 28 ++--- promkit-core/src/terminal.rs | 37 +++--- promkit-widgets/src/checkbox.rs | 17 +-- promkit-widgets/src/jsonstream.rs | 8 +- promkit-widgets/src/listbox.rs | 17 +-- promkit-widgets/src/spinner.rs | 13 +- promkit-widgets/src/text.rs | 17 +-- promkit-widgets/src/text_editor.rs | 22 +--- promkit-widgets/src/tree.rs | 20 +--- promkit/src/preset/checkbox.rs | 18 ++- promkit/src/preset/form.rs | 8 +- promkit/src/preset/json.rs | 12 +- promkit/src/preset/listbox.rs | 15 ++- promkit/src/preset/query_selector.rs | 22 ++-- promkit/src/preset/readline.rs | 26 ++-- promkit/src/preset/text.rs | 8 +- promkit/src/preset/tree.rs | 12 +- termharness/src/session.rs | 3 +- zsh_pretend/tests/resize_wrap.rs | 2 +- zsherio/src/scenarios.rs | 4 +- 24 files changed, 248 insertions(+), 450 deletions(-) delete mode 100644 promkit-core/src/pane.rs diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 0be7a633..a99ac856 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, + GraphemeFactory, }, 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/promkit-core/src/grapheme.rs b/promkit-core/src/grapheme.rs index 2c08029b..0d5a77e1 100644 --- a/promkit-core/src/grapheme.rs +++ b/promkit-core/src/grapheme.rs @@ -271,50 +271,66 @@ impl StyledGraphemes { } } - /// 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(); + /// 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 + } + + /// 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 } } @@ -567,67 +583,60 @@ mod test { } } - #[cfg(test)] - mod matrixify { + mod from_lines { 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_empty() { + let g = StyledGraphemes::from_lines(Vec::new()); + assert!(g.is_empty()); } #[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); + fn test_join() { + let g = StyledGraphemes::from_lines(vec![ + StyledGraphemes::from("abc"), + StyledGraphemes::from("def"), + ]); + assert_eq!("abc\ndef", g.to_string()); } + } + + 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..7bf1b188 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 GraphemeFactory { + /// 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..bf0a8db6 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,15 @@ 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 +51,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..5ffbfce0 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::{GraphemeFactory, 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 GraphemeFactory 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..ecba48bd 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::{GraphemeFactory, 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 GraphemeFactory 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..cbef8524 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::{GraphemeFactory, 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 GraphemeFactory 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..78e263fb 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::{GraphemeFactory, 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 GraphemeFactory 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..7ed10161 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::{GraphemeFactory, grapheme::StyledGraphemes}; mod history; pub use history::History; @@ -19,8 +19,8 @@ pub struct State { pub config: Config, } -impl PaneFactory for State { - fn create_pane(&self, width: u16, height: u16) -> Pane { +impl GraphemeFactory for State { + fn create_graphemes(&self, _width: u16, _height: u16) -> StyledGraphemes { let mut buf = StyledGraphemes::default(); let mut styled_prefix = @@ -38,20 +38,6 @@ impl PaneFactory for State { .apply_style_at(self.texteditor.position(), self.config.active_char_style); buf.append(&mut styled); - - let height = match self.config.lines { - Some(lines) => lines.min(height as usize), - 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, - ); - - Pane::new(matrix, offset) + buf } } diff --git a/promkit-widgets/src/tree.rs b/promkit-widgets/src/tree.rs index 0fcf8766..66c0db7b 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::{GraphemeFactory, 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 GraphemeFactory 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/preset/checkbox.rs b/promkit/src/preset/checkbox.rs index 5c99c1fa..141d9614 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, + GraphemeFactory, }, 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..518f3c0b 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, + GraphemeFactory, }, 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..d06cb010 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, + GraphemeFactory, }, 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..8a206e5e 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, + GraphemeFactory, }, 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..d2c55491 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, + GraphemeFactory, }, 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..c79b5289 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, + GraphemeFactory, }, 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..d01f9070 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, + GraphemeFactory, }, 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..6312e895 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, + GraphemeFactory, }, 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/termharness/src/session.rs b/termharness/src/session.rs index 1ae59819..7198cb44 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -205,8 +205,7 @@ impl Session { } } - let keep_from = - scan.len().saturating_sub(CURSOR_POSITION_REQUEST_LEN - 1); + 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, diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs index 8fef68c3..34ae5c0f 100644 --- a/zsh_pretend/tests/resize_wrap.rs +++ b/zsh_pretend/tests/resize_wrap.rs @@ -11,7 +11,7 @@ use zsherio::{ const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] -#[ignore = "timing-sensitive"] +#[ignore = "timing-sensitive; run with `cargo test --release --test resize_wrap`"] fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index 49e2bccf..a852fac1 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -2,8 +2,8 @@ pub mod middle_insert_wrap { use std::time::Duration; use crate::{ - capture::{move_cursor_left, send_bytes}, Scenario, + capture::{move_cursor_left, send_bytes}, }; pub const TERMINAL_ROWS: u16 = 10; @@ -49,8 +49,8 @@ pub mod resize_wrap { use termharness::terminal::TerminalSize; use crate::{ - capture::{move_cursor_left, send_bytes}, Scenario, + capture::{move_cursor_left, send_bytes}, }; pub const TERMINAL_ROWS: u16 = 10; From 2eb8f73dbcce093aea0072dccb84bf60166f3e87 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 03:54:33 +0900 Subject: [PATCH 52/81] chore: GraphemeFactory => Widget --- examples/byop/src/byop.rs | 2 +- promkit-core/src/lib.rs | 2 +- promkit-core/src/terminal.rs | 5 ++--- promkit-widgets/src/checkbox.rs | 4 ++-- promkit-widgets/src/jsonstream.rs | 4 ++-- promkit-widgets/src/listbox.rs | 4 ++-- promkit-widgets/src/text.rs | 4 ++-- promkit-widgets/src/text_editor.rs | 4 ++-- promkit-widgets/src/tree.rs | 4 ++-- promkit/src/preset/checkbox.rs | 2 +- promkit/src/preset/form.rs | 2 +- promkit/src/preset/json.rs | 2 +- promkit/src/preset/listbox.rs | 2 +- promkit/src/preset/query_selector.rs | 2 +- promkit/src/preset/readline.rs | 2 +- promkit/src/preset/text.rs | 2 +- promkit/src/preset/tree.rs | 2 +- 17 files changed, 24 insertions(+), 25 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index a99ac856..286dd3f7 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -28,7 +28,7 @@ use promkit::{ style::ContentStyle, }, render::{Renderer, SharedRenderer}, - GraphemeFactory, + Widget, }, spinner::{self, State}, text_editor, diff --git a/promkit-core/src/lib.rs b/promkit-core/src/lib.rs index 7bf1b188..8d4b356f 100644 --- a/promkit-core/src/lib.rs +++ b/promkit-core/src/lib.rs @@ -5,7 +5,7 @@ pub mod grapheme; pub mod render; pub mod terminal; -pub trait GraphemeFactory { +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/terminal.rs b/promkit-core/src/terminal.rs index bf0a8db6..11f383d3 100644 --- a/promkit-core/src/terminal.rs +++ b/promkit-core/src/terminal.rs @@ -36,9 +36,8 @@ impl Terminal { let mut remaining_lines = visible_height; 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 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; diff --git a/promkit-widgets/src/checkbox.rs b/promkit-widgets/src/checkbox.rs index 5ffbfce0..f2d276d0 100644 --- a/promkit-widgets/src/checkbox.rs +++ b/promkit-widgets/src/checkbox.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "checkbox/checkbox.rs"] mod inner; @@ -21,7 +21,7 @@ pub struct State { pub config: Config, } -impl GraphemeFactory for State { +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) { diff --git a/promkit-widgets/src/jsonstream.rs b/promkit-widgets/src/jsonstream.rs index ecba48bd..ffc01fdb 100644 --- a/promkit-widgets/src/jsonstream.rs +++ b/promkit-widgets/src/jsonstream.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "jsonstream/jsonstream.rs"] mod inner; @@ -22,7 +22,7 @@ pub struct State { pub config: Config, } -impl GraphemeFactory for State { +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), diff --git a/promkit-widgets/src/listbox.rs b/promkit-widgets/src/listbox.rs index cbef8524..ecbac6f2 100644 --- a/promkit-widgets/src/listbox.rs +++ b/promkit-widgets/src/listbox.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "listbox/listbox.rs"] mod inner; @@ -17,7 +17,7 @@ pub struct State { pub config: Config, } -impl GraphemeFactory for State { +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), diff --git a/promkit-widgets/src/text.rs b/promkit-widgets/src/text.rs index 78e263fb..b1ab741d 100644 --- a/promkit-widgets/src/text.rs +++ b/promkit-widgets/src/text.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; #[path = "text/text.rs"] mod inner; @@ -28,7 +28,7 @@ impl State { } } -impl GraphemeFactory for State { +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), diff --git a/promkit-widgets/src/text_editor.rs b/promkit-widgets/src/text_editor.rs index 7ed10161..423d9d0c 100644 --- a/promkit-widgets/src/text_editor.rs +++ b/promkit-widgets/src/text_editor.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; mod history; pub use history::History; @@ -19,7 +19,7 @@ pub struct State { pub config: Config, } -impl GraphemeFactory for State { +impl Widget for State { fn create_graphemes(&self, _width: u16, _height: u16) -> StyledGraphemes { let mut buf = StyledGraphemes::default(); diff --git a/promkit-widgets/src/tree.rs b/promkit-widgets/src/tree.rs index 66c0db7b..afe81ed2 100644 --- a/promkit-widgets/src/tree.rs +++ b/promkit-widgets/src/tree.rs @@ -1,4 +1,4 @@ -use promkit_core::{GraphemeFactory, grapheme::StyledGraphemes}; +use promkit_core::{Widget, grapheme::StyledGraphemes}; pub mod node; use node::Kind; @@ -22,7 +22,7 @@ pub struct State { pub config: Config, } -impl GraphemeFactory for State { +impl Widget for State { fn create_graphemes(&self, _width: u16, height: u16) -> StyledGraphemes { let symbol = |kind: &Kind| -> &str { match kind { diff --git a/promkit/src/preset/checkbox.rs b/promkit/src/preset/checkbox.rs index 141d9614..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{ diff --git a/promkit/src/preset/form.rs b/promkit/src/preset/form.rs index 518f3c0b..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{cursor::Cursor, text_editor}, diff --git a/promkit/src/preset/json.rs b/promkit/src/preset/json.rs index d06cb010..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{ diff --git a/promkit/src/preset/listbox.rs b/promkit/src/preset/listbox.rs index 8a206e5e..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{ diff --git a/promkit/src/preset/query_selector.rs b/promkit/src/preset/query_selector.rs index d2c55491..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{ diff --git a/promkit/src/preset/readline.rs b/promkit/src/preset/readline.rs index c79b5289..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, suggest::Suggest, diff --git a/promkit/src/preset/text.rs b/promkit/src/preset/text.rs index d01f9070..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::text::{self, config::Config}, diff --git a/promkit/src/preset/tree.rs b/promkit/src/preset/tree.rs index 6312e895..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}, - GraphemeFactory, + Widget, }, preset::Evaluator, widgets::{ From dc0114541f935c76fd80b3ea623fa37d5431b6f7 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 04:50:26 +0900 Subject: [PATCH 53/81] fix: calc wrap_lines on text_editor create_graphemes --- promkit-widgets/src/text_editor.rs | 27 +++++++++++++++++++++++++-- zsherio/src/scenarios.rs | 5 +++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/promkit-widgets/src/text_editor.rs b/promkit-widgets/src/text_editor.rs index 423d9d0c..ed730000 100644 --- a/promkit-widgets/src/text_editor.rs +++ b/promkit-widgets/src/text_editor.rs @@ -20,11 +20,16 @@ pub struct State { } impl Widget for State { - fn create_graphemes(&self, _width: u16, _height: u16) -> StyledGraphemes { + 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); @@ -38,6 +43,24 @@ impl Widget for State { .apply_style_at(self.texteditor.position(), self.config.active_char_style); buf.append(&mut styled); - buf + + let height = match self.config.lines { + Some(lines) => lines.min(height as usize), + None => height 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); + } + + StyledGraphemes::from_lines(rows.into_iter().skip(start).take(lines)) } } diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index a852fac1..b0b1bdc6 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -95,11 +95,12 @@ pub mod resize_wrap { pub mod small_terminal_overflow { use std::time::Duration; - use crate::{capture::send_bytes, Scenario}; + use crate::{Scenario, capture::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 const INPUT_TEXT: &str = + "this input should overflow a tiny terminal viewport and keep wrapping"; pub fn scenario() -> Scenario { Scenario::new("small_terminal_overflow") From 173680695ec05478acac68c18bfb3b7fe0198995 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 05:22:46 +0900 Subject: [PATCH 54/81] chore: remove Deref/DerefMut for StyledGraphemes --- promkit-core/src/grapheme.rs | 288 ++++++++++++++++++++--------------- 1 file changed, 161 insertions(+), 127 deletions(-) diff --git a/promkit-core/src/grapheme.rs b/promkit-core/src/grapheme.rs index 0d5a77e1..97e86adb 100644 --- a/promkit-core/src/grapheme.rs +++ b/promkit-core/src/grapheme.rs @@ -1,7 +1,6 @@ use std::{ collections::VecDeque, fmt, - ops::{Deref, DerefMut}, }; use crossterm::style::{Attribute, ContentStyle}; @@ -61,19 +60,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 +106,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 +115,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 +156,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 +210,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,38 +283,45 @@ 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); } - } - /// 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')); - } + // 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()); } - - merged } /// Splits graphemes into display rows by newline and terminal width. @@ -363,6 +397,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::*; @@ -384,42 +437,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 } } @@ -470,6 +495,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::*; @@ -557,48 +597,42 @@ 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() { - 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_nonexistent_character() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hello World", graphemes.replace("x", "o").to_string()); } - } - mod from_lines { - use super::*; + #[test] + fn test_with_empty_string() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hell Wrld", graphemes.replace("o", "").to_string()); + } #[test] - fn test_empty() { - let g = StyledGraphemes::from_lines(Vec::new()); - assert!(g.is_empty()); + fn test_with_multiple_characters() { + let graphemes = StyledGraphemes::from("Hello World"); + assert_eq!("Hellabc Wabcrld", graphemes.replace("o", "abc").to_string()); } + } + + mod replace_range { + use super::*; #[test] - fn test_join() { - let g = StyledGraphemes::from_lines(vec![ - StyledGraphemes::from("abc"), - StyledGraphemes::from("def"), - ]); - assert_eq!("abc\ndef", g.to_string()); + fn test() { + let mut graphemes = StyledGraphemes::from("Hello"); + graphemes.replace_range(1..5, "i"); + assert_eq!("Hi", graphemes.to_string()); } } From 3b41dee6e5cd03a56aab5fc074d035aca5904db7 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 07:04:08 +0900 Subject: [PATCH 55/81] chore: quote for zsh-pretend result --- zsh_pretend/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zsh_pretend/src/main.rs b/zsh_pretend/src/main.rs index ce45e590..3bb4a1de 100644 --- a/zsh_pretend/src/main.rs +++ b/zsh_pretend/src/main.rs @@ -15,7 +15,7 @@ async fn main() -> anyhow::Result<()> { if y >= h.saturating_sub(1) { println!(); } - println!("zsh: command not found: {command}"); + println!("zsh: command not found: \"{command}\""); } Err(error) => { println!("error: {error}"); From eca1cccf9a6913bb968f30ac68432f89b0499488 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 07:19:05 +0900 Subject: [PATCH 56/81] tests: strip_outer_quotes --- zsh_pretend/src/main.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/zsh_pretend/src/main.rs b/zsh_pretend/src/main.rs index 3bb4a1de..9195d208 100644 --- a/zsh_pretend/src/main.rs +++ b/zsh_pretend/src/main.rs @@ -15,7 +15,10 @@ async fn main() -> anyhow::Result<()> { if y >= h.saturating_sub(1) { println!(); } - println!("zsh: command not found: \"{command}\""); + println!( + "zsh: command not found: {}", + strip_outer_quotes(command.trim()) + ); } Err(error) => { println!("error: {error}"); @@ -26,3 +29,25 @@ async fn main() -> anyhow::Result<()> { 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 +} From 435161f245fa4f9dd735870851ea048e04615c0c Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 18:24:37 +0900 Subject: [PATCH 57/81] chore(workaround): nothing on resize --- promkit/src/lib.rs | 5 +++++ zsh_pretend/tests/resize_wrap.rs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index d67ce08a..a5c975d8 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -116,6 +116,11 @@ pub trait Prompt { while let Some(event) = EVENT_STREAM.lock().await.next().await { match event { Ok(event) => { + // NOTE: For zsh_pretend/tests/resize_wrap.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/zsh_pretend/tests/resize_wrap.rs b/zsh_pretend/tests/resize_wrap.rs index 34ae5c0f..65935a98 100644 --- a/zsh_pretend/tests/resize_wrap.rs +++ b/zsh_pretend/tests/resize_wrap.rs @@ -11,7 +11,9 @@ use zsherio::{ const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] -#[ignore = "timing-sensitive; run with `cargo test --release --test resize_wrap`"] +#[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_wrap`"] fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; From 7c4a5a5009ab9040f33e00b2e6fc126877ab01d5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 18:31:01 +0900 Subject: [PATCH 58/81] tests: remove assert_eq from termharness --- termharness/src/lib.rs | 2 - termharness/src/screen_assert.rs | 87 ----------------------- zsherio/src/scenario.rs | 115 ++++++++++++++++++++++++------- 3 files changed, 90 insertions(+), 114 deletions(-) delete mode 100644 termharness/src/screen_assert.rs diff --git a/termharness/src/lib.rs b/termharness/src/lib.rs index c0868e89..1a926638 100644 --- a/termharness/src/lib.rs +++ b/termharness/src/lib.rs @@ -1,4 +1,2 @@ -pub mod screen_assert; -pub use screen_assert::assert_screen_eq; pub mod session; pub mod terminal; diff --git a/termharness/src/screen_assert.rs b/termharness/src/screen_assert.rs deleted file mode 100644 index 50f48c5d..00000000 --- a/termharness/src/screen_assert.rs +++ /dev/null @@ -1,87 +0,0 @@ -/// Assert that two screens are equal, and if not, panic with a detailed diff of the two screens. -pub fn assert_screen_eq(expected: &[String], actual: &[String]) { - if actual == expected { - return; - } - - panic!("{}", format_screen_diff(expected, actual)); -} - -/// Format a diff of two screens, showing the number of differing rows and the contents of each screen with differences highlighted. -fn format_screen_diff(expected: &[String], actual: &[String]) -> String { - let total_rows = expected.len().max(actual.len()); - let differing_rows = (0..total_rows) - .filter(|&row| expected.get(row) != actual.get(row)) - .count(); - - let mut lines = vec![format!("screen mismatch ({differing_rows} differing rows)")]; - lines.push("expected:".to_string()); - lines.extend(format_screen(expected, total_rows)); - lines.push("actual:".to_string()); - lines.extend(format_screen(actual, total_rows)); - - lines.join("\n") -} - -/// 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. -pub 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(), - ] - ); - } - } -} diff --git a/zsherio/src/scenario.rs b/zsherio/src/scenario.rs index e283dabc..f5c05ba6 100644 --- a/zsherio/src/scenario.rs +++ b/zsherio/src/scenario.rs @@ -7,7 +7,6 @@ use std::{ time::Duration, }; -use termharness::screen_assert::format_screen; use termharness::session::Session; pub type StepAction = Arc anyhow::Result<()> + Send + Sync>; @@ -121,33 +120,99 @@ impl ScenarioRun { } } +/// 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::*; - #[test] - fn write_to_matches_print_screen_style() { - let run = ScenarioRun { - scenario_name: "middle_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" - ); + 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: "middle_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" + ); + } + } } } From 681b79db75fd12050b127a5011227705994840b0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 18:41:10 +0900 Subject: [PATCH 59/81] chore: zsh_pretend => zsh-render-parity --- .gitignore | 4 ++-- Cargo.toml | 2 +- {zsh_pretend => zsh-render-parity}/Cargo.toml | 2 +- {zsh_pretend => zsh-render-parity}/src/main.rs | 0 .../tests/middle_insert_wrap.rs | 0 .../tests/middle_prompt_start.rs | 0 {zsh_pretend => zsh-render-parity}/tests/resize_wrap.rs | 0 .../tests/small_terminal_overflow.rs | 0 8 files changed, 4 insertions(+), 4 deletions(-) rename {zsh_pretend => zsh-render-parity}/Cargo.toml (93%) rename {zsh_pretend => zsh-render-parity}/src/main.rs (100%) rename {zsh_pretend => zsh-render-parity}/tests/middle_insert_wrap.rs (100%) rename {zsh_pretend => zsh-render-parity}/tests/middle_prompt_start.rs (100%) rename {zsh_pretend => zsh-render-parity}/tests/resize_wrap.rs (100%) rename {zsh_pretend => zsh-render-parity}/tests/small_terminal_overflow.rs (100%) diff --git a/.gitignore b/.gitignore index 854cc061..42e4ac07 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,5 @@ Cargo.lock # Ignore GIF files in the tapes directory tapes/*.gif -# Ignore test artifacts emitted by zsh_pretend integration tests -zsh_pretend/.artifacts/ +# Ignore test artifacts emitted by zsh-render-parity integration tests +zsh-render-parity/.artifacts/ diff --git a/Cargo.toml b/Cargo.toml index 64ddfa3d..58efaaa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ "promkit-derive", "promkit-widgets", "termharness", - "zsh_pretend", + "zsh-render-parity", "zsherio", ] diff --git a/zsh_pretend/Cargo.toml b/zsh-render-parity/Cargo.toml similarity index 93% rename from zsh_pretend/Cargo.toml rename to zsh-render-parity/Cargo.toml index 7974f3ed..fc348818 100644 --- a/zsh_pretend/Cargo.toml +++ b/zsh-render-parity/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "zsh-pretend" +name = "zsh-render-parity" version = "0.1.0" edition = "2021" publish = false diff --git a/zsh_pretend/src/main.rs b/zsh-render-parity/src/main.rs similarity index 100% rename from zsh_pretend/src/main.rs rename to zsh-render-parity/src/main.rs diff --git a/zsh_pretend/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs similarity index 100% rename from zsh_pretend/tests/middle_insert_wrap.rs rename to zsh-render-parity/tests/middle_insert_wrap.rs diff --git a/zsh_pretend/tests/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs similarity index 100% rename from zsh_pretend/tests/middle_prompt_start.rs rename to zsh-render-parity/tests/middle_prompt_start.rs diff --git a/zsh_pretend/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs similarity index 100% rename from zsh_pretend/tests/resize_wrap.rs rename to zsh-render-parity/tests/resize_wrap.rs diff --git a/zsh_pretend/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs similarity index 100% rename from zsh_pretend/tests/small_terminal_overflow.rs rename to zsh-render-parity/tests/small_terminal_overflow.rs From 21bde8eba5d2f24cf2854049f6750b8533294965 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 18:47:49 +0900 Subject: [PATCH 60/81] chore: common for testing --- zsh-render-parity/tests/common/mod.rs | 52 +++++++++++++++++++ zsh-render-parity/tests/middle_insert_wrap.rs | 51 +++--------------- .../tests/middle_prompt_start.rs | 51 ++---------------- zsh-render-parity/tests/resize_wrap.rs | 51 +++--------------- .../tests/small_terminal_overflow.rs | 49 ++++------------- 5 files changed, 79 insertions(+), 175 deletions(-) create mode 100644 zsh-render-parity/tests/common/mod.rs diff --git a/zsh-render-parity/tests/common/mod.rs b/zsh-render-parity/tests/common/mod.rs new file mode 100644 index 00000000..3aeb09dd --- /dev/null +++ b/zsh-render-parity/tests/common/mod.rs @@ -0,0 +1,52 @@ +use std::{path::PathBuf, thread, time::Duration}; + +use termharness::session::Session; +use zsherio::ScenarioRun; + +const PROMPT_WAIT_TIMEOUT: Duration = Duration::from_secs(2); +const PROMPT_POLL_INTERVAL: Duration = Duration::from_millis(20); + +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")) +} + +pub fn assert_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_run(expected)?, + render_run(actual)?, + ) +} + +pub fn render_run(run: &ScenarioRun) -> anyhow::Result { + let mut output = Vec::new(); + run.write_to(&mut output)?; + Ok(String::from_utf8(output)?) +} + +pub fn write_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/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs index 3958f89c..6a85696a 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/middle_insert_wrap.rs @@ -1,13 +1,16 @@ -use std::{path::PathBuf, thread, time::Duration}; +mod common; + +use std::{thread, time::Duration}; use portable_pty::CommandBuilder; -use termharness::session::Session; use zsherio::{ capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, scenarios::middle_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, ScenarioRun, }; +use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; + const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] @@ -41,49 +44,7 @@ fn run_zsh_pretend() -> anyhow::Result { 1, )?; - wait_for_prompt(&session)?; + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; scenario().run("zsh-pretend", &mut session) } - -fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let screen = session.screen_snapshot(); - if screen.iter().any(|line| line.starts_with("❯❯ ")) { - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!("timed out waiting for prompt")) -} - -fn render_run(run: &ScenarioRun) -> anyhow::Result { - let mut output = Vec::new(); - run.write_to(&mut output)?; - Ok(String::from_utf8(output)?) -} - -fn assert_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_run(expected)?, - render_run(actual)?, - ) -} - -fn write_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/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs index c0470b55..920ce2a6 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/middle_prompt_start.rs @@ -1,7 +1,6 @@ -use std::{path::PathBuf, thread, time::Duration}; +mod common; use portable_pty::CommandBuilder; -use termharness::session::Session; use zsherio::{ capture::spawn_session_with_cursor, scenarios::middle_prompt_start::{ @@ -10,6 +9,8 @@ use zsherio::{ ScenarioRun, }; +use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; + const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] @@ -39,7 +40,7 @@ fn run_zsh() -> anyhow::Result { START_CURSOR_ROW, START_CURSOR_COL, )?; - wait_for_prompt(&session)?; + wait_for_prompt(&session, |line| line.contains("❯❯ "))?; scenario().run("zsh", &mut session) } @@ -53,49 +54,7 @@ fn run_zsh_pretend() -> anyhow::Result { START_CURSOR_COL, )?; - wait_for_prompt(&session)?; + wait_for_prompt(&session, |line| line.contains("❯❯ "))?; scenario().run("zsh-pretend", &mut session) } - -fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let screen = session.screen_snapshot(); - if screen.iter().any(|line| line.contains("❯❯ ")) { - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!("timed out waiting for prompt")) -} - -fn render_run(run: &ScenarioRun) -> anyhow::Result { - let mut output = Vec::new(); - run.write_to(&mut output)?; - Ok(String::from_utf8(output)?) -} - -fn assert_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_run(expected)?, - render_run(actual)?, - ) -} - -fn write_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/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs index 65935a98..08f6d9bc 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_wrap.rs @@ -1,13 +1,16 @@ -use std::{path::PathBuf, thread, time::Duration}; +mod common; + +use std::{thread, time::Duration}; use portable_pty::CommandBuilder; -use termharness::session::Session; use zsherio::{ capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, scenarios::resize_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, ScenarioRun, }; +use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; + const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] @@ -44,49 +47,7 @@ fn run_zsh_pretend() -> anyhow::Result { 1, )?; - wait_for_prompt(&session)?; + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; scenario().run("zsh-pretend", &mut session) } - -fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let screen = session.screen_snapshot(); - if screen.iter().any(|line| line.starts_with("❯❯ ")) { - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!("timed out waiting for prompt")) -} - -fn render_run(run: &ScenarioRun) -> anyhow::Result { - let mut output = Vec::new(); - run.write_to(&mut output)?; - Ok(String::from_utf8(output)?) -} - -fn assert_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_run(expected)?, - render_run(actual)?, - ) -} - -fn write_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/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs index 1427417a..dddc500c 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/small_terminal_overflow.rs @@ -1,13 +1,18 @@ -use std::{path::PathBuf, thread, time::Duration}; +mod common; + +use std::{thread, time::Duration}; use portable_pty::CommandBuilder; -use termharness::session::Session; use zsherio::{ capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, scenarios::small_terminal_overflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, ScenarioRun, }; +use self::common::{ + assert_runs_match as assert_runs_match_strict, wait_for_prompt, write_run_artifact, +}; + const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] @@ -41,30 +46,11 @@ fn run_zsh_pretend() -> anyhow::Result { 1, )?; - wait_for_prompt(&session)?; + wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; scenario().run("zsh-pretend", &mut session) } -fn wait_for_prompt(session: &Session) -> anyhow::Result<()> { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - let screen = session.screen_snapshot(); - if screen.iter().any(|line| line.starts_with("❯❯ ")) { - return Ok(()); - } - thread::sleep(Duration::from_millis(20)); - } - - Err(anyhow::anyhow!("timed out waiting for prompt")) -} - -fn render_run(run: &ScenarioRun) -> anyhow::Result { - let mut output = Vec::new(); - run.write_to(&mut output)?; - Ok(String::from_utf8(output)?) -} - /// 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. @@ -100,24 +86,9 @@ fn runs_match_from_second_line(expected: &ScenarioRun, actual: &ScenarioRun) -> } fn assert_runs_match(expected: &ScenarioRun, actual: &ScenarioRun) -> anyhow::Result<()> { - if actual.records == expected.records || runs_match_from_second_line(expected, actual) { + if runs_match_from_second_line(expected, actual) { return Ok(()); } - anyhow::bail!( - "zsh-pretend output diverged from zsh\n\n== expected ==\n{}\n== actual ==\n{}", - render_run(expected)?, - render_run(actual)?, - ) -} - -fn write_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)) + assert_runs_match_strict(expected, actual) } From 621b970d69436a6a6ac193413017d0fe69a27b21 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 18:58:48 +0900 Subject: [PATCH 61/81] chore: tidy up functions for testing --- zsh-render-parity/tests/common/mod.rs | 13 +++++++++---- zsh-render-parity/tests/middle_insert_wrap.rs | 6 ++---- zsh-render-parity/tests/middle_prompt_start.rs | 4 ++-- zsh-render-parity/tests/resize_wrap.rs | 4 ++-- zsh-render-parity/tests/small_terminal_overflow.rs | 12 +++++++----- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/zsh-render-parity/tests/common/mod.rs b/zsh-render-parity/tests/common/mod.rs index 3aeb09dd..dc1ac9ea 100644 --- a/zsh-render-parity/tests/common/mod.rs +++ b/zsh-render-parity/tests/common/mod.rs @@ -3,9 +3,12 @@ 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, @@ -22,19 +25,21 @@ pub fn wait_for_prompt( Err(anyhow::anyhow!("timed out waiting for prompt")) } -pub fn assert_runs_match(expected: &ScenarioRun, actual: &ScenarioRun) -> anyhow::Result<()> { +/// 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_run(expected)?, - render_run(actual)?, + render_scenario_run(expected)?, + render_scenario_run(actual)?, ) } -pub fn render_run(run: &ScenarioRun) -> anyhow::Result { +pub fn render_scenario_run(run: &ScenarioRun) -> anyhow::Result { let mut output = Vec::new(); run.write_to(&mut output)?; Ok(String::from_utf8(output)?) diff --git a/zsh-render-parity/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs index 6a85696a..d2f33725 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/middle_insert_wrap.rs @@ -9,9 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; - -const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); +use crate::common::{ZSH_PRETEND_BIN, assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; #[test] fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { @@ -21,7 +19,7 @@ fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { write_run_artifact(&expected)?; write_run_artifact(&actual)?; - assert_runs_match(&expected, &actual)?; + assert_scenario_runs_match(&expected, &actual)?; Ok(()) } diff --git a/zsh-render-parity/tests/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs index 920ce2a6..a21daf1c 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/middle_prompt_start.rs @@ -9,7 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; +use self::common::{assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -21,7 +21,7 @@ fn zsh_pretend_matches_zsh_for_middle_prompt_start() -> anyhow::Result<()> { write_run_artifact(&expected)?; write_run_artifact(&actual)?; - assert_runs_match(&expected, &actual)?; + assert_scenario_runs_match(&expected, &actual)?; Ok(()) } diff --git a/zsh-render-parity/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs index 08f6d9bc..683e72b9 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_wrap.rs @@ -9,7 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{assert_runs_match, wait_for_prompt, write_run_artifact}; +use self::common::{assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -24,7 +24,7 @@ fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { write_run_artifact(&expected)?; write_run_artifact(&actual)?; - assert_runs_match(&expected, &actual)?; + assert_scenario_runs_match(&expected, &actual)?; Ok(()) } diff --git a/zsh-render-parity/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs index dddc500c..a926a740 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/small_terminal_overflow.rs @@ -9,9 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{ - assert_runs_match as assert_runs_match_strict, wait_for_prompt, write_run_artifact, -}; +use self::common::{wait_for_prompt, write_run_artifact, render_scenario_run}; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -88,7 +86,11 @@ fn runs_match_from_second_line(expected: &ScenarioRun, actual: &ScenarioRun) -> fn assert_runs_match(expected: &ScenarioRun, actual: &ScenarioRun) -> anyhow::Result<()> { if runs_match_from_second_line(expected, actual) { return 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)?, + ) } - - assert_runs_match_strict(expected, actual) } From 5e7a2bcdf38b2b9e60a90e3bdf0b907793bbe858 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:07:05 +0900 Subject: [PATCH 62/81] chore: tidy up functions for testing --- promkit-core/src/grapheme.rs | 11 +--- zsh-render-parity/tests/common/mod.rs | 7 ++- zsh-render-parity/tests/middle_insert_wrap.rs | 8 +-- .../tests/middle_prompt_start.rs | 6 +-- zsh-render-parity/tests/resize_wrap.rs | 6 +-- .../tests/small_terminal_overflow.rs | 51 +++++++++---------- 6 files changed, 42 insertions(+), 47 deletions(-) diff --git a/promkit-core/src/grapheme.rs b/promkit-core/src/grapheme.rs index 97e86adb..7da61f5a 100644 --- a/promkit-core/src/grapheme.rs +++ b/promkit-core/src/grapheme.rs @@ -1,7 +1,4 @@ -use std::{ - collections::VecDeque, - fmt, -}; +use std::{collections::VecDeque, fmt}; use crossterm::style::{Attribute, ContentStyle}; use unicode_width::UnicodeWidthChar; @@ -307,11 +304,7 @@ impl StyledGraphemes { } /// Replaces the specified range with the given string. - pub fn replace_range>( - &mut self, - range: std::ops::Range, - replacement: S, - ) { + 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); diff --git a/zsh-render-parity/tests/common/mod.rs b/zsh-render-parity/tests/common/mod.rs index dc1ac9ea..3534ef82 100644 --- a/zsh-render-parity/tests/common/mod.rs +++ b/zsh-render-parity/tests/common/mod.rs @@ -27,7 +27,10 @@ pub fn wait_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<()> { +pub fn assert_scenario_runs_match( + expected: &ScenarioRun, + actual: &ScenarioRun, +) -> anyhow::Result<()> { if actual.records == expected.records { return Ok(()); } @@ -45,7 +48,7 @@ pub fn render_scenario_run(run: &ScenarioRun) -> anyhow::Result { Ok(String::from_utf8(output)?) } -pub fn write_run_artifact(run: &ScenarioRun) -> anyhow::Result<()> { +pub fn write_scenario_run_artifact(run: &ScenarioRun) -> anyhow::Result<()> { run.write_to_path(&artifact_path(run)) } diff --git a/zsh-render-parity/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs index d2f33725..989d5810 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/middle_insert_wrap.rs @@ -9,15 +9,17 @@ use zsherio::{ ScenarioRun, }; -use crate::common::{ZSH_PRETEND_BIN, assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; +use crate::common::{ + assert_scenario_runs_match, wait_for_prompt, write_scenario_run_artifact, ZSH_PRETEND_BIN, +}; #[test] fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; - write_run_artifact(&expected)?; - write_run_artifact(&actual)?; + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; assert_scenario_runs_match(&expected, &actual)?; diff --git a/zsh-render-parity/tests/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs index a21daf1c..cb5c5c07 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/middle_prompt_start.rs @@ -9,7 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; +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"); @@ -18,8 +18,8 @@ fn zsh_pretend_matches_zsh_for_middle_prompt_start() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; - write_run_artifact(&expected)?; - write_run_artifact(&actual)?; + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; assert_scenario_runs_match(&expected, &actual)?; diff --git a/zsh-render-parity/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs index 683e72b9..647b92d8 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_wrap.rs @@ -9,7 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{assert_scenario_runs_match, wait_for_prompt, write_run_artifact}; +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"); @@ -21,8 +21,8 @@ fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; - write_run_artifact(&expected)?; - write_run_artifact(&actual)?; + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; assert_scenario_runs_match(&expected, &actual)?; diff --git a/zsh-render-parity/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs index a926a740..eb2b5bc0 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/small_terminal_overflow.rs @@ -9,7 +9,7 @@ use zsherio::{ ScenarioRun, }; -use self::common::{wait_for_prompt, write_run_artifact, render_scenario_run}; +use crate::common::{render_scenario_run, wait_for_prompt, write_scenario_run_artifact}; const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); @@ -18,10 +18,10 @@ fn zsh_pretend_matches_zsh_for_small_terminal_overflow() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; - write_run_artifact(&expected)?; - write_run_artifact(&actual)?; + write_scenario_run_artifact(&expected)?; + write_scenario_run_artifact(&actual)?; - assert_runs_match(&expected, &actual)?; + assert_scenario_runs_match_from_second_line(&expected, &actual)?; Ok(()) } @@ -63,29 +63,26 @@ fn run_zsh_pretend() -> anyhow::Result { /// 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 runs_match_from_second_line(expected: &ScenarioRun, actual: &ScenarioRun) -> bool { - if expected.records.len() != actual.records.len() { - return false; - } - - 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)) - }) -} - -fn assert_runs_match(expected: &ScenarioRun, actual: &ScenarioRun) -> anyhow::Result<()> { - if runs_match_from_second_line(expected, actual) { - return Ok(()); +fn assert_scenario_runs_match_from_second_line( + 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{}", From ef3d197ef36e30602e35184a40612cd54ce03982 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:25:40 +0900 Subject: [PATCH 63/81] chore: tidy up functions for testing --- zsh-render-parity/tests/middle_insert_wrap.rs | 3 +- .../tests/middle_prompt_start.rs | 2 +- zsh-render-parity/tests/resize_wrap.rs | 3 +- .../tests/small_terminal_overflow.rs | 3 +- zsherio/examples/zsh_middle_insert_wrap.rs | 10 +++--- zsherio/examples/zsh_resize_wrap.rs | 10 +++--- zsherio/src/lib.rs | 8 ++--- zsherio/src/{capture.rs => opts.rs} | 34 +------------------ zsherio/src/scenarios.rs | 6 ++-- zsherio/src/session.rs | 33 ++++++++++++++++++ 10 files changed, 58 insertions(+), 54 deletions(-) rename zsherio/src/{capture.rs => opts.rs} (58%) create mode 100644 zsherio/src/session.rs diff --git a/zsh-render-parity/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs index 989d5810..d653a25a 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/middle_insert_wrap.rs @@ -4,8 +4,9 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ - capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + opts::clear_screen_and_move_cursor_to, scenarios::middle_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session_with_cursor, spawn_zsh_session}, ScenarioRun, }; diff --git a/zsh-render-parity/tests/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs index cb5c5c07..824790bb 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/middle_prompt_start.rs @@ -2,10 +2,10 @@ mod common; use portable_pty::CommandBuilder; use zsherio::{ - capture::spawn_session_with_cursor, scenarios::middle_prompt_start::{ scenario, START_CURSOR_COL, START_CURSOR_ROW, TERMINAL_COLS, TERMINAL_ROWS, }, + session::spawn_session_with_cursor, ScenarioRun, }; diff --git a/zsh-render-parity/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs index 647b92d8..145e0189 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_wrap.rs @@ -4,8 +4,9 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ - capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + opts::clear_screen_and_move_cursor_to, scenarios::resize_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session_with_cursor, spawn_zsh_session}, ScenarioRun, }; diff --git a/zsh-render-parity/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs index eb2b5bc0..61e94e7b 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/small_terminal_overflow.rs @@ -4,8 +4,9 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ - capture::{clear_screen_and_move_cursor_to, spawn_session_with_cursor, spawn_zsh_session}, + opts::clear_screen_and_move_cursor_to, scenarios::small_terminal_overflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + session::{spawn_session_with_cursor, spawn_zsh_session}, ScenarioRun, }; diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index adbd30af..ea815292 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -1,8 +1,10 @@ -use std::thread; -use std::time::Duration; +use std::{thread, time::Duration}; -use zsherio::capture::{clear_screen_and_move_cursor_to, spawn_zsh_session}; -use zsherio::scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}; +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, + session::{spawn_session_with_cursor, spawn_zsh_session}, +}; fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; diff --git a/zsherio/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs index 8335a6e2..ab50320b 100644 --- a/zsherio/examples/zsh_resize_wrap.rs +++ b/zsherio/examples/zsh_resize_wrap.rs @@ -1,8 +1,10 @@ -use std::thread; -use std::time::Duration; +use std::{thread, time::Duration}; -use zsherio::capture::{clear_screen_and_move_cursor_to, spawn_zsh_session}; -use zsherio::scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}; +use zsherio::{ + opts::clear_screen_and_move_cursor_to, + scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, + session::{spawn_session_with_cursor, spawn_zsh_session}, +}; fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; diff --git a/zsherio/src/lib.rs b/zsherio/src/lib.rs index 17790311..1524e77b 100644 --- a/zsherio/src/lib.rs +++ b/zsherio/src/lib.rs @@ -1,9 +1,5 @@ -pub mod capture; +pub mod opts; pub mod scenario; pub mod scenarios; - -pub use capture::{ - clear_screen_and_move_cursor_to, move_cursor_left, move_cursor_to, send_bytes, spawn_session, - spawn_session_with_cursor, spawn_zsh_session, -}; pub use scenario::{Scenario, ScenarioRecord, ScenarioRun, ScenarioStep, StepAction}; +pub mod session; diff --git a/zsherio/src/capture.rs b/zsherio/src/opts.rs similarity index 58% rename from zsherio/src/capture.rs rename to zsherio/src/opts.rs index 56160fc0..16abf431 100644 --- a/zsherio/src/capture.rs +++ b/zsherio/src/opts.rs @@ -1,38 +1,6 @@ use std::io::Write; -use portable_pty::CommandBuilder; -use termharness::{session::Session, terminal::TerminalSize}; - -/// Spawn a session with the given command and terminal size. -pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Result { - Session::spawn(cmd, TerminalSize::new(rows, cols)) -} - -/// Spawn a session with the given command, terminal size, and initial cursor position. -pub fn spawn_session_with_cursor( - cmd: CommandBuilder, - rows: u16, - cols: u16, - cursor_row: u16, - cursor_col: u16, -) -> anyhow::Result { - Session::spawn_with_cursor( - cmd, - TerminalSize::new(rows, cols), - Some((cursor_row, cursor_col)), - ) -} - -/// Spawn a zsh session with the given terminal size. -pub fn spawn_zsh_session(rows: u16, cols: 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, rows, cols) -} +use termharness::session::Session; /// Send bytes to the session's stdin. pub fn send_bytes(session: &mut Session, bytes: &[u8]) -> anyhow::Result<()> { diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index b0b1bdc6..f18da4b4 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -3,7 +3,7 @@ pub mod middle_insert_wrap { use crate::{ Scenario, - capture::{move_cursor_left, send_bytes}, + opts::{move_cursor_left, send_bytes}, }; pub const TERMINAL_ROWS: u16 = 10; @@ -50,7 +50,7 @@ pub mod resize_wrap { use crate::{ Scenario, - capture::{move_cursor_left, send_bytes}, + opts::{move_cursor_left, send_bytes}, }; pub const TERMINAL_ROWS: u16 = 10; @@ -95,7 +95,7 @@ pub mod resize_wrap { pub mod small_terminal_overflow { use std::time::Duration; - use crate::{Scenario, capture::send_bytes}; + use crate::{Scenario, opts::send_bytes}; pub const TERMINAL_ROWS: u16 = 4; pub const TERMINAL_COLS: u16 = 12; diff --git a/zsherio/src/session.rs b/zsherio/src/session.rs new file mode 100644 index 00000000..6deb0d79 --- /dev/null +++ b/zsherio/src/session.rs @@ -0,0 +1,33 @@ +use portable_pty::CommandBuilder; +use termharness::{session::Session, terminal::TerminalSize}; + +/// Spawn a session with the given command and terminal size. +pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Result { + Session::spawn(cmd, TerminalSize::new(rows, cols)) +} + +/// Spawn a session with the given command, terminal size, and initial cursor position. +pub fn spawn_session_with_cursor( + cmd: CommandBuilder, + rows: u16, + cols: u16, + cursor_row: u16, + cursor_col: u16, +) -> anyhow::Result { + Session::spawn_with_cursor( + cmd, + TerminalSize::new(rows, cols), + Some((cursor_row, cursor_col)), + ) +} + +/// Spawn a zsh session with the given terminal size. +pub fn spawn_zsh_session(rows: u16, cols: 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, rows, cols) +} From 8b68cc066b2bf2900e237bd502c90660602398dd Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:34:11 +0900 Subject: [PATCH 64/81] on-err: tidy up functions for testing --- termharness/src/session.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 7198cb44..bebf4546 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -130,20 +130,17 @@ pub struct Session { } impl Session { - /// Spawn a new session by executing the given command in a pseudo-terminal with the specified size. - pub fn spawn(cmd: CommandBuilder, size: TerminalSize) -> Result { - Self::spawn_with_cursor(cmd, size, None) - } - - pub fn spawn_with_cursor( + /// 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, - size: TerminalSize, - cursor_position: Option<(u16, u16)>, + term_size: TerminalSize, + cursor_pos: Option<(u16, u16)>, ) -> Result { let pty = native_pty_system(); let pair = pty.openpty(PtySize { - rows: size.rows, - cols: size.cols, + rows: term_size.rows, + cols: term_size.cols, pixel_width: 0, pixel_height: 0, })?; @@ -158,9 +155,9 @@ impl Session { 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_position { - Some((row, col)) => Screen::with_cursor(size, row, col), - None => Screen::new(size), + 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()?)); @@ -220,7 +217,7 @@ impl Session { output, screen, reader_thread: Some(reader_thread), - size, + size: term_size, }) } @@ -261,7 +258,7 @@ mod tests { fn success() -> Result<()> { let mut cmd = CommandBuilder::new("echo"); cmd.arg("Hello, world!"); - let mut session = Session::spawn(cmd, TerminalSize::new(24, 80))?; + let mut session = Session::spawn(cmd, TerminalSize::new(24, 80), None)?; // Wait for the child process to exit and the reader thread to finish. session.child.wait()?; @@ -280,7 +277,7 @@ mod tests { 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, TerminalSize::new(24, 80))?; + let mut session = Session::spawn(cmd, TerminalSize::new(24, 80), None)?; session.child.wait()?; if let Some(reader_thread) = session.reader_thread.take() { @@ -301,8 +298,7 @@ mod tests { 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_with_cursor(cmd, TerminalSize::new(24, 80), Some((24, 1)))?; + let mut session = Session::spawn(cmd, TerminalSize::new(24, 80), Some((24, 1)))?; session.child.wait()?; if let Some(reader_thread) = session.reader_thread.take() { From 95bbbabe995a53542feb207560b76407e9a9ea4d Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:40:47 +0900 Subject: [PATCH 65/81] on-err: tidy up functions for testing --- termharness/src/session.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index bebf4546..9aee21d9 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -10,7 +10,7 @@ use crate::terminal::TerminalSize; use alacritty_terminal::{ event::VoidListener, index::{Column, Line, Point}, - term::{Config, Term, cell::Flags, test::TermSize}, + term::{self, Config, Term, cell::Flags, test::TermSize}, vte::ansi::Processor, }; use anyhow::Result; @@ -134,9 +134,10 @@ impl Session { /// in a pseudo-terminal with the specified size and initial cursor position. pub fn spawn( mut cmd: CommandBuilder, - term_size: TerminalSize, + 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, @@ -258,7 +259,7 @@ mod tests { fn success() -> Result<()> { let mut cmd = CommandBuilder::new("echo"); cmd.arg("Hello, world!"); - let mut session = Session::spawn(cmd, TerminalSize::new(24, 80), None)?; + 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()?; @@ -277,7 +278,7 @@ mod tests { 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, TerminalSize::new(24, 80), None)?; + let mut session = Session::spawn(cmd, (24, 80), None)?; session.child.wait()?; if let Some(reader_thread) = session.reader_thread.take() { @@ -298,7 +299,7 @@ mod tests { 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, TerminalSize::new(24, 80), Some((24, 1)))?; + let mut session = Session::spawn(cmd, (24, 80), Some((24, 1)))?; session.child.wait()?; if let Some(reader_thread) = session.reader_thread.take() { From 8ea943a3d8308d4d9093fb120b5da5ac96a5ed46 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:50:37 +0900 Subject: [PATCH 66/81] on-err: tidy up zsherio for testing --- termharness/src/session.rs | 2 +- zsherio/examples/zsh_middle_insert_wrap.rs | 4 ++-- zsherio/examples/zsh_resize_wrap.rs | 18 -------------- zsherio/src/session.rs | 28 ++++++++-------------- 4 files changed, 13 insertions(+), 39 deletions(-) delete mode 100644 zsherio/examples/zsh_resize_wrap.rs diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 9aee21d9..449f334a 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -10,7 +10,7 @@ use crate::terminal::TerminalSize; use alacritty_terminal::{ event::VoidListener, index::{Column, Line, Point}, - term::{self, Config, Term, cell::Flags, test::TermSize}, + term::{Config, Term, cell::Flags, test::TermSize}, vte::ansi::Processor, }; use anyhow::Result; diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index ea815292..fb421aaf 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -3,11 +3,11 @@ use std::{thread, time::Duration}; use zsherio::{ opts::clear_screen_and_move_cursor_to, scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, - session::{spawn_session_with_cursor, spawn_zsh_session}, + session::spawn_zsh_session, }; fn main() -> anyhow::Result<()> { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; // Before create scenaro, move cursor to bottom. clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; diff --git a/zsherio/examples/zsh_resize_wrap.rs b/zsherio/examples/zsh_resize_wrap.rs deleted file mode 100644 index ab50320b..00000000 --- a/zsherio/examples/zsh_resize_wrap.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::{thread, time::Duration}; - -use zsherio::{ - opts::clear_screen_and_move_cursor_to, - scenarios::resize_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, - session::{spawn_session_with_cursor, spawn_zsh_session}, -}; - -fn main() -> anyhow::Result<()> { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; - - // Before create scenaro, 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/session.rs b/zsherio/src/session.rs index 6deb0d79..150994db 100644 --- a/zsherio/src/session.rs +++ b/zsherio/src/session.rs @@ -1,33 +1,25 @@ use portable_pty::CommandBuilder; -use termharness::{session::Session, terminal::TerminalSize}; - -/// Spawn a session with the given command and terminal size. -pub fn spawn_session(cmd: CommandBuilder, rows: u16, cols: u16) -> anyhow::Result { - Session::spawn(cmd, TerminalSize::new(rows, cols)) -} +use termharness::session::Session; /// Spawn a session with the given command, terminal size, and initial cursor position. -pub fn spawn_session_with_cursor( +pub fn spawn_session( cmd: CommandBuilder, - rows: u16, - cols: u16, - cursor_row: u16, - cursor_col: u16, + term_size: (u16, u16), + cursor_pos: Option<(u16, u16)>, ) -> anyhow::Result { - Session::spawn_with_cursor( - cmd, - TerminalSize::new(rows, cols), - Some((cursor_row, cursor_col)), - ) + Session::spawn(cmd, term_size, cursor_pos) } /// Spawn a zsh session with the given terminal size. -pub fn spawn_zsh_session(rows: u16, cols: u16) -> anyhow::Result { +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, rows, cols) + spawn_session(cmd, term_size, cursor_pos) } From a6d2deca6ece87dd279cad3db6d52bf270e9f770 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 19:54:38 +0900 Subject: [PATCH 67/81] chore: tidy up functions for testing --- zsh-render-parity/tests/middle_insert_wrap.rs | 12 ++++----- .../tests/middle_prompt_start.rs | 25 ++++++------------- zsh-render-parity/tests/resize_wrap.rs | 12 ++++----- .../tests/small_terminal_overflow.rs | 12 ++++----- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/zsh-render-parity/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/middle_insert_wrap.rs index d653a25a..161224cb 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/middle_insert_wrap.rs @@ -6,7 +6,7 @@ use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, scenarios::middle_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, - session::{spawn_session_with_cursor, spawn_zsh_session}, + session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -28,7 +28,7 @@ fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { } fn run_zsh() -> anyhow::Result { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + 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)); @@ -37,12 +37,10 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session_with_cursor( + let mut session = spawn_session( CommandBuilder::new(ZSH_PRETEND_BIN), - TERMINAL_ROWS, - TERMINAL_COLS, - TERMINAL_ROWS, - 1, + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), )?; wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; diff --git a/zsh-render-parity/tests/middle_prompt_start.rs b/zsh-render-parity/tests/middle_prompt_start.rs index 824790bb..2ec57156 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/middle_prompt_start.rs @@ -5,7 +5,7 @@ use zsherio::{ scenarios::middle_prompt_start::{ scenario, START_CURSOR_COL, START_CURSOR_ROW, TERMINAL_COLS, TERMINAL_ROWS, }, - session::spawn_session_with_cursor, + session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -27,18 +27,9 @@ fn zsh_pretend_matches_zsh_for_middle_prompt_start() -> anyhow::Result<()> { } fn run_zsh() -> 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", ""); - let mut session = spawn_session_with_cursor( - cmd, - TERMINAL_ROWS, - TERMINAL_COLS, - START_CURSOR_ROW, - START_CURSOR_COL, + let mut session = spawn_zsh_session( + (TERMINAL_ROWS, TERMINAL_COLS), + Some((START_CURSOR_ROW, START_CURSOR_COL)), )?; wait_for_prompt(&session, |line| line.contains("❯❯ "))?; @@ -46,12 +37,10 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session_with_cursor( + let mut session = spawn_session( CommandBuilder::new(ZSH_PRETEND_BIN), - TERMINAL_ROWS, - TERMINAL_COLS, - START_CURSOR_ROW, - START_CURSOR_COL, + (TERMINAL_ROWS, TERMINAL_COLS), + Some((START_CURSOR_ROW, START_CURSOR_COL)), )?; wait_for_prompt(&session, |line| line.contains("❯❯ "))?; diff --git a/zsh-render-parity/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_wrap.rs index 145e0189..3d34ff9d 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_wrap.rs @@ -6,7 +6,7 @@ use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, scenarios::resize_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, - session::{spawn_session_with_cursor, spawn_zsh_session}, + session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -31,7 +31,7 @@ fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { } fn run_zsh() -> anyhow::Result { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + 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)); @@ -40,12 +40,10 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session_with_cursor( + let mut session = spawn_session( CommandBuilder::new(ZSH_PRETEND_BIN), - TERMINAL_ROWS, - TERMINAL_COLS, - TERMINAL_ROWS, - 1, + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), )?; wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; diff --git a/zsh-render-parity/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/small_terminal_overflow.rs index 61e94e7b..891106db 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/small_terminal_overflow.rs @@ -6,7 +6,7 @@ use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, scenarios::small_terminal_overflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, - session::{spawn_session_with_cursor, spawn_zsh_session}, + session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -28,7 +28,7 @@ fn zsh_pretend_matches_zsh_for_small_terminal_overflow() -> anyhow::Result<()> { } fn run_zsh() -> anyhow::Result { - let mut session = spawn_zsh_session(TERMINAL_ROWS, TERMINAL_COLS)?; + 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)); @@ -37,12 +37,10 @@ fn run_zsh() -> anyhow::Result { } fn run_zsh_pretend() -> anyhow::Result { - let mut session = spawn_session_with_cursor( + let mut session = spawn_session( CommandBuilder::new(ZSH_PRETEND_BIN), - TERMINAL_ROWS, - TERMINAL_COLS, - TERMINAL_ROWS, - 1, + (TERMINAL_ROWS, TERMINAL_COLS), + Some((TERMINAL_ROWS, 1)), )?; wait_for_prompt(&session, |line| line.starts_with("❯❯ "))?; From 2ec3ccc95718c8626af76f3495cc7fe95ed70075 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 20:23:05 +0900 Subject: [PATCH 68/81] chore: rename tests for more strictly and properly --- promkit/src/lib.rs | 3 ++- ..._insert_wrap.rs => mid_buffer_insert_wrap.rs} | 4 ++-- ...rs => prompt_initial_render_at_mid_screen.rs} | 4 ++-- ...e_wrap.rs => resize_roundtrip_wrap_reflow.rs} | 6 +++--- ....rs => tiny_viewport_overflow_wrap_scroll.rs} | 8 ++++---- zsherio/examples/zsh_middle_insert_wrap.rs | 2 +- zsherio/src/scenario.rs | 2 +- zsherio/src/scenarios.rs | 16 ++++++++-------- 8 files changed, 23 insertions(+), 22 deletions(-) rename zsh-render-parity/tests/{middle_insert_wrap.rs => mid_buffer_insert_wrap.rs} (88%) rename zsh-render-parity/tests/{middle_prompt_start.rs => prompt_initial_render_at_mid_screen.rs} (90%) rename zsh-render-parity/tests/{resize_wrap.rs => resize_roundtrip_wrap_reflow.rs} (87%) rename zsh-render-parity/tests/{small_terminal_overflow.rs => tiny_viewport_overflow_wrap_scroll.rs} (90%) diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index a5c975d8..2f446e92 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -116,7 +116,8 @@ pub trait Prompt { while let Some(event) = EVENT_STREAM.lock().await.next().await { match event { Ok(event) => { - // NOTE: For zsh_pretend/tests/resize_wrap.rs, skipping resize events here + // 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; diff --git a/zsh-render-parity/tests/middle_insert_wrap.rs b/zsh-render-parity/tests/mid_buffer_insert_wrap.rs similarity index 88% rename from zsh-render-parity/tests/middle_insert_wrap.rs rename to zsh-render-parity/tests/mid_buffer_insert_wrap.rs index 161224cb..fa2b066f 100644 --- a/zsh-render-parity/tests/middle_insert_wrap.rs +++ b/zsh-render-parity/tests/mid_buffer_insert_wrap.rs @@ -5,7 +5,7 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, - scenarios::middle_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + scenarios::mid_buffer_insert_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -15,7 +15,7 @@ use crate::common::{ }; #[test] -fn zsh_pretend_matches_zsh_for_middle_insert_wrap() -> anyhow::Result<()> { +fn zsh_pretend_parity_mid_buffer_insert_wrap() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; diff --git a/zsh-render-parity/tests/middle_prompt_start.rs b/zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs similarity index 90% rename from zsh-render-parity/tests/middle_prompt_start.rs rename to zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs index 2ec57156..fe7d2913 100644 --- a/zsh-render-parity/tests/middle_prompt_start.rs +++ b/zsh-render-parity/tests/prompt_initial_render_at_mid_screen.rs @@ -2,7 +2,7 @@ mod common; use portable_pty::CommandBuilder; use zsherio::{ - scenarios::middle_prompt_start::{ + scenarios::prompt_initial_render_at_mid_screen::{ scenario, START_CURSOR_COL, START_CURSOR_ROW, TERMINAL_COLS, TERMINAL_ROWS, }, session::{spawn_session, spawn_zsh_session}, @@ -14,7 +14,7 @@ use crate::common::{assert_scenario_runs_match, wait_for_prompt, write_scenario_ const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] -fn zsh_pretend_matches_zsh_for_middle_prompt_start() -> anyhow::Result<()> { +fn zsh_pretend_parity_prompt_initial_render_at_mid_screen() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; diff --git a/zsh-render-parity/tests/resize_wrap.rs b/zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs similarity index 87% rename from zsh-render-parity/tests/resize_wrap.rs rename to zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs index 3d34ff9d..02587af3 100644 --- a/zsh-render-parity/tests/resize_wrap.rs +++ b/zsh-render-parity/tests/resize_roundtrip_wrap_reflow.rs @@ -5,7 +5,7 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, - scenarios::resize_wrap::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + scenarios::resize_roundtrip_wrap_reflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -17,8 +17,8 @@ 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_wrap`"] -fn zsh_pretend_matches_zsh_for_resize_wrap() -> anyhow::Result<()> { + resize_roundtrip_wrap_reflow`"] +fn zsh_pretend_parity_resize_roundtrip_wrap_reflow() -> anyhow::Result<()> { let expected = run_zsh()?; let actual = run_zsh_pretend()?; diff --git a/zsh-render-parity/tests/small_terminal_overflow.rs b/zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs similarity index 90% rename from zsh-render-parity/tests/small_terminal_overflow.rs rename to zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs index 891106db..3ae4fc3e 100644 --- a/zsh-render-parity/tests/small_terminal_overflow.rs +++ b/zsh-render-parity/tests/tiny_viewport_overflow_wrap_scroll.rs @@ -5,7 +5,7 @@ use std::{thread, time::Duration}; use portable_pty::CommandBuilder; use zsherio::{ opts::clear_screen_and_move_cursor_to, - scenarios::small_terminal_overflow::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, + scenarios::tiny_viewport_overflow_wrap_scroll::{scenario, TERMINAL_COLS, TERMINAL_ROWS}, session::{spawn_session, spawn_zsh_session}, ScenarioRun, }; @@ -15,14 +15,14 @@ use crate::common::{render_scenario_run, wait_for_prompt, write_scenario_run_art const ZSH_PRETEND_BIN: &str = env!("CARGO_BIN_EXE_zsh-pretend"); #[test] -fn zsh_pretend_matches_zsh_for_small_terminal_overflow() -> anyhow::Result<()> { +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_from_second_line(&expected, &actual)?; + assert_scenario_runs_match_ignoring_row0(&expected, &actual)?; Ok(()) } @@ -62,7 +62,7 @@ fn run_zsh_pretend() -> anyhow::Result { /// 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_from_second_line( +fn assert_scenario_runs_match_ignoring_row0( expected: &ScenarioRun, actual: &ScenarioRun, ) -> anyhow::Result<()> { diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index fb421aaf..584fccbb 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -2,7 +2,7 @@ use std::{thread, time::Duration}; use zsherio::{ opts::clear_screen_and_move_cursor_to, - scenarios::middle_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, + scenarios::mid_buffer_insert_wrap::{TERMINAL_COLS, TERMINAL_ROWS, scenario}, session::spawn_zsh_session, }; diff --git a/zsherio/src/scenario.rs b/zsherio/src/scenario.rs index f5c05ba6..88afed2e 100644 --- a/zsherio/src/scenario.rs +++ b/zsherio/src/scenario.rs @@ -191,7 +191,7 @@ mod tests { #[test] fn write_to_matches_print_screen_style() { let run = ScenarioRun { - scenario_name: "middle_insert_wrap".to_string(), + scenario_name: "mid_buffer_insert_wrap".to_string(), target_name: "zsh".to_string(), records: vec![ ScenarioRecord { diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index f18da4b4..914ae5ce 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -1,4 +1,4 @@ -pub mod middle_insert_wrap { +pub mod mid_buffer_insert_wrap { use std::time::Duration; use crate::{ @@ -13,7 +13,7 @@ pub mod middle_insert_wrap { pub const TIMES_TO_MOVE_CURSOR_LEFT: usize = 36; pub fn scenario() -> Scenario { - Scenario::new("middle_insert_wrap") + 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()) @@ -27,7 +27,7 @@ pub mod middle_insert_wrap { } } -pub mod middle_prompt_start { +pub mod prompt_initial_render_at_mid_screen { use std::time::Duration; use crate::Scenario; @@ -38,12 +38,12 @@ pub mod middle_prompt_start { pub const START_CURSOR_COL: u16 = 0; pub fn scenario() -> Scenario { - Scenario::new("middle_prompt_start") + Scenario::new("prompt_initial_render_at_mid_screen") .step("spawn", Duration::from_millis(300), |_session| Ok(())) } } -pub mod resize_wrap { +pub mod resize_roundtrip_wrap_reflow { use std::time::Duration; use termharness::terminal::TerminalSize; @@ -59,7 +59,7 @@ pub mod resize_wrap { pub const TIMES_TO_MOVE_CURSOR_LEFT: usize = 30; pub fn scenario() -> Scenario { - let mut scenario = Scenario::new("resize_wrap") + 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") @@ -92,7 +92,7 @@ pub mod resize_wrap { } } -pub mod small_terminal_overflow { +pub mod tiny_viewport_overflow_wrap_scroll { use std::time::Duration; use crate::{Scenario, opts::send_bytes}; @@ -103,7 +103,7 @@ pub mod small_terminal_overflow { "this input should overflow a tiny terminal viewport and keep wrapping"; pub fn scenario() -> Scenario { - Scenario::new("small_terminal_overflow") + 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()) From 4a26769dfe5139a9eaef5ee89f88ae9784a11171 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 20:56:22 +0900 Subject: [PATCH 69/81] docs: Concept.md --- Concept.md | 276 +++++++++++++++++++++++------------------------------ 1 file changed, 122 insertions(+), 154 deletions(-) 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. From ac9310222f14c20dad8e16966e3f31672759563c Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 21:25:22 +0900 Subject: [PATCH 70/81] docs: README.md --- README.md | 516 +++----------------------------------- examples/json/src/json.rs | 6 +- 2 files changed, 35 insertions(+), 487 deletions(-) diff --git a/README.md b/README.md index abd4efba..2301b5b6 100644 --- a/README.md +++ b/README.md @@ -11,36 +11,35 @@ 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](https://github.com/ynqa/promkit/tree/main/promkit-core/) + - Core functionality for terminal rendering and keyed grapheme chunk management + - [promkit-widgets](https://github.com/ynqa/promkit/tree/main/promkit-widgets/) - Various UI components (text, listbox, tree, etc.) - - [promkit](https://github.com/ynqa/promkit/tree/v0.11.0/promkit) + - [promkit](https://github.com/ynqa/promkit/tree/main/promkit) - High-level presets and user interfaces - - [promkit-derive](https://github.com/ynqa/promkit/tree/v0.11.0/promkit-derive/) + - [promkit-derive](https://github.com/ynqa/promkit/tree/main/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](https://github.com/ynqa/promkit/tree/main#readline) - Text input with auto-completion + - [Confirm](https://github.com/ynqa/promkit/tree/main#confirm) - Yes/no confirmation prompt + - [Password](https://github.com/ynqa/promkit/tree/main#password) - Password input with masking and validation + - [Form](https://github.com/ynqa/promkit/tree/main#form) - Manage multiple text input fields + - [Listbox](https://github.com/ynqa/promkit/tree/main#listbox) - Single selection interface from a list + - [QuerySelector](https://github.com/ynqa/promkit/tree/main#queryselector) - Searchable selection interface + - [Checkbox](https://github.com/ynqa/promkit/tree/main#checkbox) - Multiple selection checkbox interface + - [Tree](https://github.com/ynqa/promkit/tree/main#tree) - Tree display for hierarchical data like file systems + - [JSON](https://github.com/ynqa/promkit/tree/main#json) - Parse and interactively display JSON data ## Concept -See [here](https://github.com/ynqa/promkit/tree/v0.11.0/Concept.md). +See [here](https://github.com/ynqa/promkit/tree/main/Concept.md). ## Projects using *promkit* @@ -63,36 +62,12 @@ 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) @@ -102,24 +77,12 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/confirm/Cargo.toml +cargo run --bin confirm ``` -
-Code - -```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(()) -} -``` -
+[Code](./examples/confirm/src/confirm.rs) @@ -129,30 +92,12 @@ 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) @@ -162,67 +107,12 @@ 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) @@ -232,25 +122,11 @@ 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) @@ -260,36 +136,11 @@ 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) @@ -299,37 +150,11 @@ 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) @@ -339,26 +164,11 @@ 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) @@ -368,269 +178,11 @@ fn main() -> anyhow::Result<()> { Command ```bash -cargo run --manifest-path examples/json/Cargo.toml +cargo run --bin json ${PATH_TO_JSON_FILE} ``` -
-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(()) -} -``` -
+[Code](./examples/json/src/json.rs) 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 From 41acc9e16c093c3a5f08ec1c145670ceea58465b Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 21:29:40 +0900 Subject: [PATCH 71/81] chore: update tapes --- tapes/checkbox.tape | 2 +- tapes/confirm.tape | 2 +- tapes/form.tape | 2 +- tapes/json.tape | 2 +- tapes/listbox.tape | 2 +- tapes/password.tape | 2 +- tapes/query_selector.tape | 2 +- tapes/readline.tape | 2 +- tapes/tree.tape | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tapes/checkbox.tape b/tapes/checkbox.tape index 049c7f25..1fda4c5c 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 --bin checkbox" Enter Sleep 1s 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..dc05d7e7 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 --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..843ea95c 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 --bin form" Enter Sleep 1s Type "Hello" Sleep 1s Down 1 Sleep 1s Type "promkit" Sleep 1s diff --git a/tapes/json.tape b/tapes/json.tape index fef6298b..486b12f3 100644 --- a/tapes/json.tape +++ b/tapes/json.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --example json" Enter Sleep 1s +Type@50ms "cargo run --bin json test.json" Enter Sleep 1s Down@300ms 2 Sleep 1s Space Sleep 1s Down@300ms 1 Sleep 1s diff --git a/tapes/listbox.tape b/tapes/listbox.tape index 9409bf15..1555b51d 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 --bin listbox" Enter Sleep 1s Down 22 Sleep 1s Up@300ms 4 Sleep 1s Enter Sleep 2s diff --git a/tapes/password.tape b/tapes/password.tape index 6a8897b0..c1d6291f 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 --bin password" Enter Sleep 1s 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..e034f258 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 --bin query_selector" Enter Sleep 1s Down 6 Sleep 1s Type "8" Sleep 1s Type "8" Sleep 1s diff --git a/tapes/readline.tape b/tapes/readline.tape index 9179c300..faaea4a3 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 --bin readline" Enter Sleep 1s Type "Hello promkit!!" Sleep 1s Backspace 15 Type "a" Sleep 1s Tab 1 Sleep 1s diff --git a/tapes/tree.tape b/tapes/tree.tape index f2eef84a..460293bd 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 --bin tree" Enter Sleep 1s Sleep 1s Space Sleep 1s Down@300ms 2 Sleep 1s From 9fbfb0502f6e467082a0f65bbe9256526eb2f175 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 21:45:43 +0900 Subject: [PATCH 72/81] chore: create script for tape => gif --- README.md | 16 ++++++++++++++++ scripts/render_tapes_gif.sh | 32 ++++++++++++++++++++++++++++++++ tapes/checkbox.tape | 2 +- tapes/confirm.tape | 2 +- tapes/form.tape | 2 +- tapes/json.tape | 2 +- tapes/listbox.tape | 2 +- tapes/password.tape | 2 +- tapes/query_selector.tape | 2 +- tapes/readline.tape | 2 +- tapes/text.tape | 14 ++++++++++++++ tapes/tree.tape | 2 +- 12 files changed, 71 insertions(+), 9 deletions(-) create mode 100755 scripts/render_tapes_gif.sh create mode 100644 tapes/text.tape diff --git a/README.md b/README.md index 2301b5b6..9ae88bb3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ promkit = "0.11.1" - [Checkbox](https://github.com/ynqa/promkit/tree/main#checkbox) - Multiple selection checkbox interface - [Tree](https://github.com/ynqa/promkit/tree/main#tree) - Tree display for hierarchical data like file systems - [JSON](https://github.com/ynqa/promkit/tree/main#json) - Parse and interactively display JSON data + - [Text](https://github.com/ynqa/promkit/tree/main#text) - Static text display ## Concept @@ -186,6 +187,21 @@ cargo run --bin json ${PATH_TO_JSON_FILE} +## Text + +
+Command + +```bash +cargo run --bin text +``` + +
+ +[Code](./examples/text/src/text.rs) + + + ## License This project is licensed under the MIT License. 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 1fda4c5c..04cf8ff6 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 --bin checkbox" Enter Sleep 1s +Type@50ms "cargo run -q --bin checkbox" Enter Sleep 1s Down@300ms 3 Sleep 1s Space Sleep 1s Up@300ms 1 Sleep 1s diff --git a/tapes/confirm.tape b/tapes/confirm.tape index dc05d7e7..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 --bin 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 843ea95c..1b70ad8f 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 --bin form" Enter Sleep 1s +Type@50ms "cargo run -q --bin form" Enter Sleep 1s Type "Hello" Sleep 1s Down 1 Sleep 1s Type "promkit" Sleep 1s diff --git a/tapes/json.tape b/tapes/json.tape index 486b12f3..47420497 100644 --- a/tapes/json.tape +++ b/tapes/json.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run --bin json test.json" Enter Sleep 1s +Type@50ms "cargo run -q --bin json test.json" Enter Sleep 1s Down@300ms 2 Sleep 1s Space Sleep 1s Down@300ms 1 Sleep 1s diff --git a/tapes/listbox.tape b/tapes/listbox.tape index 1555b51d..8848ad38 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 --bin listbox" Enter Sleep 1s +Type@50ms "cargo run -q --bin listbox" Enter Sleep 1s Down 22 Sleep 1s Up@300ms 4 Sleep 1s Enter Sleep 2s diff --git a/tapes/password.tape b/tapes/password.tape index c1d6291f..c3dc202d 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 --bin password" Enter Sleep 1s +Type@50ms "cargo run -q --bin password" Enter Sleep 1s 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 e034f258..119ca02a 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 --bin query_selector" Enter Sleep 1s +Type@50ms "cargo run -q --bin query-selector" Enter Sleep 1s Down 6 Sleep 1s Type "8" Sleep 1s Type "8" Sleep 1s diff --git a/tapes/readline.tape b/tapes/readline.tape index faaea4a3..3f9136bc 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 --bin readline" Enter Sleep 1s +Type@50ms "cargo run -q --bin readline" Enter Sleep 1s 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..5273960e --- /dev/null +++ b/tapes/text.tape @@ -0,0 +1,14 @@ +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 1s +Down 7 Sleep 1s +Up@300ms 3 Sleep 1s +Enter Sleep 2s diff --git a/tapes/tree.tape b/tapes/tree.tape index 460293bd..797e673d 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 --bin tree" Enter Sleep 1s +Type@50ms "cargo run -q --bin tree" Enter Sleep 1s Sleep 1s Space Sleep 1s Down@300ms 2 Sleep 1s From 075914ec959539be8dd7b1da7a368461f1431f4a Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 21:56:46 +0900 Subject: [PATCH 73/81] fix: tree dir --- examples/tree/src/tree.rs | 5 ++++- tapes/checkbox.tape | 2 +- tapes/form.tape | 2 +- tapes/json.tape | 2 +- tapes/listbox.tape | 2 +- tapes/password.tape | 2 +- tapes/query_selector.tape | 2 +- tapes/readline.tape | 2 +- tapes/text.tape | 2 +- tapes/tree.tape | 2 +- 10 files changed, 13 insertions(+), 10 deletions(-) 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/tapes/checkbox.tape b/tapes/checkbox.tape index 04cf8ff6..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 --bin 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/form.tape b/tapes/form.tape index 1b70ad8f..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 --bin 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 47420497..573fd3ca 100644 --- a/tapes/json.tape +++ b/tapes/json.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --bin json test.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 diff --git a/tapes/listbox.tape b/tapes/listbox.tape index 8848ad38..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 --bin 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 c3dc202d..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 --bin 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 119ca02a..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 --bin 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 3f9136bc..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 --bin 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 index 5273960e..edac9e6e 100644 --- a/tapes/text.tape +++ b/tapes/text.tape @@ -8,7 +8,7 @@ Set FontSize 32 Set Width 1200 Set Height 600 -Type@50ms "cargo run -q --bin text" Enter Sleep 1s +Type@50ms "cargo run -q --bin text" Enter Sleep 2s Down 7 Sleep 1s Up@300ms 3 Sleep 1s Enter Sleep 2s diff --git a/tapes/tree.tape b/tapes/tree.tape index 797e673d..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 --bin tree" Enter Sleep 1s +Type@50ms "cargo run -q --bin tree" Enter Sleep 2s Sleep 1s Space Sleep 1s Down@300ms 2 Sleep 1s From 35e9e147472ce93f7370fbe61b4384f36933e984 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 21:57:57 +0900 Subject: [PATCH 74/81] cargo-fmt --- zsherio/src/scenarios.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/zsherio/src/scenarios.rs b/zsherio/src/scenarios.rs index 914ae5ce..ffed32b7 100644 --- a/zsherio/src/scenarios.rs +++ b/zsherio/src/scenarios.rs @@ -38,8 +38,11 @@ pub mod prompt_initial_render_at_mid_screen { 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(())) + Scenario::new("prompt_initial_render_at_mid_screen").step( + "spawn", + Duration::from_millis(300), + |_session| Ok(()), + ) } } From a495b45b8ab6d371e5ebd1e1bf82996869236679 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 22:20:34 +0900 Subject: [PATCH 75/81] fix: no-enter on tapes --- tapes/json.tape | 1 - tapes/text.tape | 1 - 2 files changed, 2 deletions(-) diff --git a/tapes/json.tape b/tapes/json.tape index 573fd3ca..85d16636 100644 --- a/tapes/json.tape +++ b/tapes/json.tape @@ -14,4 +14,3 @@ Space Sleep 1s Down@300ms 1 Sleep 1s Space Sleep 1s Up@300ms 2 Sleep 1s -Enter Sleep 2s diff --git a/tapes/text.tape b/tapes/text.tape index edac9e6e..fb2e2727 100644 --- a/tapes/text.tape +++ b/tapes/text.tape @@ -11,4 +11,3 @@ Set Height 600 Type@50ms "cargo run -q --bin text" Enter Sleep 2s Down 7 Sleep 1s Up@300ms 3 Sleep 1s -Enter Sleep 2s From 6c435c59f2269fa730b81b3bf102a21a5f9cc29f Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 22:28:18 +0900 Subject: [PATCH 76/81] chore: remove event-dbg --- Cargo.toml | 1 - event-dbg/Cargo.toml | 9 --------- event-dbg/README.md | 11 ----------- event-dbg/src/main.rs | 43 ------------------------------------------- 4 files changed, 64 deletions(-) delete mode 100644 event-dbg/Cargo.toml delete mode 100644 event-dbg/README.md delete mode 100644 event-dbg/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 58efaaa9..dc118ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = [ - "event-dbg", "examples/*", "promkit", "promkit-core", diff --git a/event-dbg/Cargo.toml b/event-dbg/Cargo.toml deleted file mode 100644 index 7265784b..00000000 --- a/event-dbg/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "event-dbg" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -anyhow = { workspace = true } -crossterm = { workspace = true } 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(()) -} From 06e354b83272da005ee99ca993a9a77eb2f8e33e Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 22:32:49 +0900 Subject: [PATCH 77/81] docs: renewal demo --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9ae88bb3..81bcc5dc 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ cargo run --bin readline [Code](./examples/readline/src/readline.rs) - + ### Confirm @@ -85,7 +85,7 @@ cargo run --bin confirm [Code](./examples/confirm/src/confirm.rs) - + ### Password @@ -100,7 +100,7 @@ cargo run --bin password [Code](./examples/password/src/password.rs) - + ### Form @@ -115,7 +115,7 @@ cargo run --bin form [Code](./examples/form/src/form.rs) - + ### Listbox @@ -129,7 +129,7 @@ cargo run --bin listbox [Code](./examples/listbox/src/listbox.rs) - + ### QuerySelector @@ -143,7 +143,7 @@ cargo run --bin query_selector [Code](./examples/query_selector/src/query_selector.rs) - + ### Checkbox @@ -157,7 +157,7 @@ cargo run --bin checkbox [Code](./examples/checkbox/src/checkbox.rs) - + ### Tree @@ -171,7 +171,7 @@ cargo run --bin tree [Code](./examples/tree/src/tree.rs) - + ### JSON @@ -185,7 +185,7 @@ cargo run --bin json ${PATH_TO_JSON_FILE} [Code](./examples/json/src/json.rs) - + ## Text @@ -200,7 +200,7 @@ cargo run --bin text [Code](./examples/text/src/text.rs) - + ## License From 0be3697aa23c44a53db1506c29174c56a70ffc5a Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 12 Mar 2026 22:36:37 +0900 Subject: [PATCH 78/81] docs: use filepath instead of github links --- README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 81bcc5dc..06cfb9d6 100644 --- a/README.md +++ b/README.md @@ -18,29 +18,29 @@ promkit = "0.11.1" - 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/main/promkit-core/) + - [promkit-core](./promkit-core/) - Core functionality for terminal rendering and keyed grapheme chunk management - - [promkit-widgets](https://github.com/ynqa/promkit/tree/main/promkit-widgets/) + - [promkit-widgets](./promkit-widgets/) - Various UI components (text, listbox, tree, etc.) - - [promkit](https://github.com/ynqa/promkit/tree/main/promkit) + - [promkit](./promkit/) - High-level presets and user interfaces - - [promkit-derive](https://github.com/ynqa/promkit/tree/main/promkit-derive/) + - [promkit-derive](./promkit-derive/) - A Derive macro that simplifies interactive form input - Rich preset components - - [Readline](https://github.com/ynqa/promkit/tree/main#readline) - Text input with auto-completion - - [Confirm](https://github.com/ynqa/promkit/tree/main#confirm) - Yes/no confirmation prompt - - [Password](https://github.com/ynqa/promkit/tree/main#password) - Password input with masking and validation - - [Form](https://github.com/ynqa/promkit/tree/main#form) - Manage multiple text input fields - - [Listbox](https://github.com/ynqa/promkit/tree/main#listbox) - Single selection interface from a list - - [QuerySelector](https://github.com/ynqa/promkit/tree/main#queryselector) - Searchable selection interface - - [Checkbox](https://github.com/ynqa/promkit/tree/main#checkbox) - Multiple selection checkbox interface - - [Tree](https://github.com/ynqa/promkit/tree/main#tree) - Tree display for hierarchical data like file systems - - [JSON](https://github.com/ynqa/promkit/tree/main#json) - Parse and interactively display JSON data - - [Text](https://github.com/ynqa/promkit/tree/main#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/main/Concept.md). +See [here](./Concept.md). ## Projects using *promkit* @@ -204,9 +204,7 @@ cargo run --bin text ## License -This project is licensed under the MIT License. -See the [LICENSE](https://github.com/ynqa/promkit/blob/main/LICENSE) -file for details. +[MIT License](./LICENSE) ## Stargazers over time [![Stargazers over time](https://starchart.cc/ynqa/promkit.svg?variant=adaptive)](https://starchart.cc/ynqa/promkit) From 4f3e4396be2e09734b9f345e53ec39f0086eefa6 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 13 Mar 2026 18:52:37 +0900 Subject: [PATCH 79/81] fix: typo Consideration --- termharness/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termharness/src/session.rs b/termharness/src/session.rs index 449f334a..1f22760e 100644 --- a/termharness/src/session.rs +++ b/termharness/src/session.rs @@ -147,7 +147,7 @@ impl Session { })?; // Set the TERM environment variable to ensure consistent terminal behavior. - // Considaration: This should ideally be configurable, + // 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)?; From cb553f853f28a8e20101ff3687e4e67f379326b1 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 13 Mar 2026 18:53:42 +0900 Subject: [PATCH 80/81] fix: typo scenario --- zsherio/examples/zsh_middle_insert_wrap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zsherio/examples/zsh_middle_insert_wrap.rs b/zsherio/examples/zsh_middle_insert_wrap.rs index 584fccbb..8e635c8c 100644 --- a/zsherio/examples/zsh_middle_insert_wrap.rs +++ b/zsherio/examples/zsh_middle_insert_wrap.rs @@ -9,7 +9,7 @@ use zsherio::{ fn main() -> anyhow::Result<()> { let mut session = spawn_zsh_session((TERMINAL_ROWS, TERMINAL_COLS), None)?; - // Before create scenaro, move cursor to bottom. + // Before create scenario, move cursor to bottom. clear_screen_and_move_cursor_to(&mut session, TERMINAL_ROWS, 1)?; thread::sleep(Duration::from_millis(300)); From 3a65cd896b81af292dbf6d8d3d2aa30c09a1b638 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 13 Mar 2026 18:54:40 +0900 Subject: [PATCH 81/81] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 06cfb9d6..0eb1bbbd 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ cargo run --bin json ${PATH_TO_JSON_FILE} -## Text +### Text
Command