diff --git a/bench/Cargo.toml b/bench/Cargo.toml index 8003840..1e82012 100644 --- a/bench/Cargo.toml +++ b/bench/Cargo.toml @@ -8,26 +8,70 @@ license = "MIT" [lib] bench = false +[[bin]] +name = "runner" +path = "src/bin/runner.rs" + [dependencies] tui-textarea = { package = "tui-textarea-2", path = "..", features = ["no-backend", "search"] } ratatui = { version = "0.30.0", default-features = false } [dev-dependencies] -criterion = "0.5" +criterion = "0.8.2" rand = { version = "0.8.5", features = ["small_rng"] } [[bench]] -name = "insert" +name = "insert_append" harness = false [[bench]] -name = "search" +name = "insert_random" harness = false [[bench]] -name = "cursor" +name = "insert_long" harness = false [[bench]] -name = "delete" +name = "search_forward" +harness = false + +[[bench]] +name = "search_backward" +harness = false + +[[bench]] +name = "cursor_char" +harness = false + +[[bench]] +name = "cursor_word" +harness = false + +[[bench]] +name = "cursor_paragraph" +harness = false + +[[bench]] +name = "cursor_edge" +harness = false + +[[bench]] +name = "delete_char" +harness = false + +[[bench]] +name = "delete_word" +harness = false + +[[bench]] +name = "delete_line" +harness = false + +[[bench]] +name = "wrap_render" +harness = false + +[[bench]] +name = "undo_redo" harness = false diff --git a/bench/README.md b/bench/README.md index b885a14..908c454 100644 --- a/bench/README.md +++ b/bench/README.md @@ -1,34 +1,74 @@ Benchmarks for tui-textarea using [Criterion.rs][criterion]. -To run all benchmarks: +## Running all benchmarks (Rust runner — recommended) + +The Rust runner executes every benchmark target sequentially and prints +per-target start time, exit status, and wall duration. ```sh -cargo bench --benches +cargo run -p tui-textarea-bench --bin runner ``` -To run specific benchmark suite: +Example output: + +``` +[1/12] START insert_append at 2026-02-18T12:00:00Z +[1/12] DONE insert_append status=ok duration=42.31s +[2/12] START insert_random at 2026-02-18T12:00:42Z +... +All 14 benchmark targets completed successfully. +``` + +## Running a single benchmark target ```sh -cargo bench --bench insert +cargo bench -p tui-textarea-bench --bench insert_append ``` -To filter benchmarks: +Available targets: + +| Target | What it measures | +|--------------------|-------------------------------------------| +| `insert_append` | Appending lorem lines sequentially | +| `insert_random` | Inserting lorem lines at random positions | +| `insert_long` | Typing a long line without newlines | +| `search_forward` | Forward regex search | +| `search_backward` | Backward regex search | +| `cursor_char` | Character-level cursor movement | +| `cursor_word` | Word-level cursor movement | +| `cursor_paragraph` | Paragraph-level cursor movement | +| `cursor_edge` | Head/end and top/bottom jumps | +| `delete_char` | Deleting one character at a time | +| `delete_word` | Deleting one word at a time | +| `delete_line` | Deleting to line head | +| `wrap_render` | Rendering with Word/Glyph/WordOrGlyph wrap modes | +| `undo_redo` | Undo and redo traversal at varying history depths | + +## Filtering within a target + +Pass a filter string after `--` to run only matching cases: ```sh -cargo bench append::1_lorem +cargo bench -p tui-textarea-bench --bench insert_append -- append::10_lorem ``` -To compare benchmark results with [critcmp][]: +## Comparing branches with [critcmp][] ```sh git checkout main -cargo bench -- --save-baseline base +cargo bench -p tui-textarea-bench -- --save-baseline base git checkout your-feature -cargo bench -- --save-baseline change +cargo bench -p tui-textarea-bench -- --save-baseline change critcmp base change ``` +## Note on `cargo test -p tui-textarea-bench` + +Running `cargo test` on this package is a smoke test only — it verifies +that the benchmark code compiles and runs without panicking, but produces +no timing data. Use `cargo bench` or the Rust runner for real measurements. + [criterion]: https://github.com/bheisler/criterion.rs [critcmp]: https://github.com/BurntSushi/critcmp diff --git a/bench/benches/cursor.rs b/bench/benches/cursor.rs deleted file mode 100644 index f76d4b1..0000000 --- a/bench/benches/cursor.rs +++ /dev/null @@ -1,171 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use tui_textarea::{CursorMove, TextArea}; -use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; - -#[derive(Clone, Copy)] -enum Restore { - TopLeft, - BottomLeft, - BottomRight, - None, -} - -impl Restore { - fn cursor_move(self) -> Option { - match self { - Self::TopLeft => Some(CursorMove::Jump(0, 0)), - Self::BottomLeft => Some(CursorMove::Jump(u16::MAX, 0)), - Self::BottomRight => Some(CursorMove::Jump(u16::MAX, u16::MAX)), - Self::None => None, - } - } -} - -fn prepare_textarea() -> TextArea<'static> { - let mut lines = Vec::with_capacity(LOREM.len() * 2 + 1); - lines.extend(LOREM.iter().map(|s| s.to_string())); - lines.push("".to_string()); - lines.extend(LOREM.iter().map(|s| s.to_string())); - TextArea::new(lines) -} - -fn run( - mut textarea: TextArea<'_>, - moves: &[CursorMove], - restore: Restore, - repeat: usize, -) -> (usize, usize) { - let mut term = dummy_terminal(); - - let mut prev = textarea.cursor(); - for _ in 0..repeat { - for m in moves { - textarea.move_cursor(*m); - term.draw_textarea(&textarea); - } - if let Some(m) = restore.cursor_move() { - if textarea.cursor() == prev { - textarea.move_cursor(m); - prev = textarea.cursor(); - } - } - } - - textarea.cursor() -} - -fn move_char(c: &mut Criterion) { - let textarea = prepare_textarea(); - c.bench_function("cursor::char::forward", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::Forward], - Restore::TopLeft, - 1000, - )) - }) - }); - c.bench_function("cursor::char::back", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::Back], - Restore::BottomRight, - 1000, - )) - }) - }); - c.bench_function("cursor::char::down", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::Down], - Restore::TopLeft, - 1000, - )) - }) - }); - c.bench_function("cursor::char::up", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::Up], - Restore::BottomLeft, - 1000, - )) - }) - }); -} -fn move_word(c: &mut Criterion) { - let textarea = prepare_textarea(); - c.bench_function("cursor::word::forward", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::WordForward], - Restore::TopLeft, - 1000, - )) - }) - }); - c.bench_function("cursor::word::back", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::WordBack], - Restore::BottomRight, - 1000, - )) - }) - }); -} -fn move_paragraph(c: &mut Criterion) { - let textarea = prepare_textarea(); - c.bench_function("cursor::paragraph::down", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::ParagraphForward], - Restore::TopLeft, - 1000, - )) - }) - }); - c.bench_function("cursor::paragraph::up", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::ParagraphBack], - Restore::BottomLeft, - 1000, - )) - }) - }); -} -fn move_edge(c: &mut Criterion) { - let textarea = prepare_textarea(); - c.bench_function("cursor::edge::head_end", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::End, CursorMove::Head], - Restore::None, - 500, - )) - }) - }); - c.bench_function("cursor::edge::top_bottom", |b| { - b.iter(|| { - black_box(run( - textarea.clone(), - &[CursorMove::Bottom, CursorMove::Top], - Restore::None, - 500, - )) - }) - }); -} - -criterion_group!(cursor, move_char, move_word, move_paragraph, move_edge); -criterion_main!(cursor); diff --git a/bench/benches/cursor_char.rs b/bench/benches/cursor_char.rs new file mode 100644 index 0000000..fcfc437 --- /dev/null +++ b/bench/benches/cursor_char.rs @@ -0,0 +1,50 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::CursorMove; +use tui_textarea_bench::{prepare_cursor_textarea, run_cursor, Restore}; + +fn bench(c: &mut Criterion) { + let textarea = prepare_cursor_textarea(); + c.bench_function("cursor::char::forward", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::Forward], + Restore::TopLeft, + 1000, + )) + }) + }); + c.bench_function("cursor::char::back", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::Back], + Restore::BottomRight, + 1000, + )) + }) + }); + c.bench_function("cursor::char::down", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::Down], + Restore::TopLeft, + 1000, + )) + }) + }); + c.bench_function("cursor::char::up", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::Up], + Restore::BottomLeft, + 1000, + )) + }) + }); +} + +criterion_group!(cursor_char, bench); +criterion_main!(cursor_char); diff --git a/bench/benches/cursor_edge.rs b/bench/benches/cursor_edge.rs new file mode 100644 index 0000000..76857c7 --- /dev/null +++ b/bench/benches/cursor_edge.rs @@ -0,0 +1,30 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::CursorMove; +use tui_textarea_bench::{prepare_cursor_textarea, run_cursor, Restore}; + +fn bench(c: &mut Criterion) { + let textarea = prepare_cursor_textarea(); + c.bench_function("cursor::edge::head_end", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::End, CursorMove::Head], + Restore::None, + 500, + )) + }) + }); + c.bench_function("cursor::edge::top_bottom", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::Bottom, CursorMove::Top], + Restore::None, + 500, + )) + }) + }); +} + +criterion_group!(cursor_edge, bench); +criterion_main!(cursor_edge); diff --git a/bench/benches/cursor_paragraph.rs b/bench/benches/cursor_paragraph.rs new file mode 100644 index 0000000..62ef22a --- /dev/null +++ b/bench/benches/cursor_paragraph.rs @@ -0,0 +1,30 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::CursorMove; +use tui_textarea_bench::{prepare_cursor_textarea, run_cursor, Restore}; + +fn bench(c: &mut Criterion) { + let textarea = prepare_cursor_textarea(); + c.bench_function("cursor::paragraph::down", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::ParagraphForward], + Restore::TopLeft, + 1000, + )) + }) + }); + c.bench_function("cursor::paragraph::up", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::ParagraphBack], + Restore::BottomLeft, + 1000, + )) + }) + }); +} + +criterion_group!(cursor_paragraph, bench); +criterion_main!(cursor_paragraph); diff --git a/bench/benches/cursor_word.rs b/bench/benches/cursor_word.rs new file mode 100644 index 0000000..78c329d --- /dev/null +++ b/bench/benches/cursor_word.rs @@ -0,0 +1,30 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::CursorMove; +use tui_textarea_bench::{prepare_cursor_textarea, run_cursor, Restore}; + +fn bench(c: &mut Criterion) { + let textarea = prepare_cursor_textarea(); + c.bench_function("cursor::word::forward", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::WordForward], + Restore::TopLeft, + 1000, + )) + }) + }); + c.bench_function("cursor::word::back", |b| { + b.iter(|| { + std::hint::black_box(run_cursor( + textarea.clone(), + &[CursorMove::WordBack], + Restore::BottomRight, + 1000, + )) + }) + }); +} + +criterion_group!(cursor_word, bench); +criterion_main!(cursor_word); diff --git a/bench/benches/delete.rs b/bench/benches/delete_char.rs similarity index 56% rename from bench/benches/delete.rs rename to bench/benches/delete_char.rs index 1b3bb0b..a342895 100644 --- a/bench/benches/delete.rs +++ b/bench/benches/delete_char.rs @@ -2,25 +2,13 @@ use criterion::{criterion_group, criterion_main, Criterion}; use tui_textarea::{CursorMove, TextArea}; use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; -#[derive(Clone, Copy)] -enum Kind { - Char, - Word, - Line, -} - #[inline] -fn run(textarea: &TextArea<'_>, kind: Kind) { +fn run(textarea: &TextArea<'_>) { let mut term = dummy_terminal(); let mut t = textarea.clone(); t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); for _ in 0..100 { - let modified = match kind { - Kind::Char => t.delete_char(), - Kind::Word => t.delete_word(), - Kind::Line => t.delete_line_by_head(), - }; - if !modified { + if !t.delete_char() { t = textarea.clone(); t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); } @@ -34,11 +22,8 @@ fn bench(c: &mut Criterion) { lines.extend(LOREM.iter().map(|s| s.to_string())); } let textarea = TextArea::new(lines); - - c.bench_function("delete::char", |b| b.iter(|| run(&textarea, Kind::Char))); - c.bench_function("delete::word", |b| b.iter(|| run(&textarea, Kind::Word))); - c.bench_function("delete::line", |b| b.iter(|| run(&textarea, Kind::Line))); + c.bench_function("delete::char", |b| b.iter(|| run(&textarea))); } -criterion_group!(delete, bench); -criterion_main!(delete); +criterion_group!(delete_char, bench); +criterion_main!(delete_char); diff --git a/bench/benches/delete_line.rs b/bench/benches/delete_line.rs new file mode 100644 index 0000000..01bd4d2 --- /dev/null +++ b/bench/benches/delete_line.rs @@ -0,0 +1,29 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::{CursorMove, TextArea}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +#[inline] +fn run(textarea: &TextArea<'_>) { + let mut term = dummy_terminal(); + let mut t = textarea.clone(); + t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); + for _ in 0..100 { + if !t.delete_line_by_head() { + t = textarea.clone(); + t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); + } + term.draw_textarea(&t); + } +} + +fn bench(c: &mut Criterion) { + let mut lines = vec![]; + for _ in 0..10 { + lines.extend(LOREM.iter().map(|s| s.to_string())); + } + let textarea = TextArea::new(lines); + c.bench_function("delete::line", |b| b.iter(|| run(&textarea))); +} + +criterion_group!(delete_line, bench); +criterion_main!(delete_line); diff --git a/bench/benches/delete_word.rs b/bench/benches/delete_word.rs new file mode 100644 index 0000000..b0df7bc --- /dev/null +++ b/bench/benches/delete_word.rs @@ -0,0 +1,29 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::{CursorMove, TextArea}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +#[inline] +fn run(textarea: &TextArea<'_>) { + let mut term = dummy_terminal(); + let mut t = textarea.clone(); + t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); + for _ in 0..100 { + if !t.delete_word() { + t = textarea.clone(); + t.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX)); + } + term.draw_textarea(&t); + } +} + +fn bench(c: &mut Criterion) { + let mut lines = vec![]; + for _ in 0..10 { + lines.extend(LOREM.iter().map(|s| s.to_string())); + } + let textarea = TextArea::new(lines); + c.bench_function("delete::word", |b| b.iter(|| run(&textarea))); +} + +criterion_group!(delete_word, bench); +criterion_main!(delete_word); diff --git a/bench/benches/insert.rs b/bench/benches/insert.rs deleted file mode 100644 index d2630d5..0000000 --- a/bench/benches/insert.rs +++ /dev/null @@ -1,129 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use rand::rngs::SmallRng; -use rand::{Rng, SeedableRng}; -use tui_textarea::{CursorMove, Input, Key, TextArea}; -use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM, SEED}; - -#[inline] -fn append_lorem(repeat: usize) -> usize { - let mut textarea = TextArea::default(); - let mut term = dummy_terminal(); - for _ in 0..repeat { - for line in LOREM { - for c in line.chars() { - textarea.input(Input { - key: Key::Char(c), - ctrl: false, - alt: false, - shift: false, - }); - term.draw_textarea(&textarea); - } - } - textarea.input(Input { - key: Key::Enter, - ctrl: false, - alt: false, - shift: false, - }); - term.draw_textarea(&textarea); - } - textarea.lines().len() -} - -#[inline] -fn random_lorem(repeat: usize) -> usize { - let mut rng = SmallRng::from_seed(SEED); - let mut textarea = TextArea::default(); - let mut term = dummy_terminal(); - - for _ in 0..repeat { - for line in LOREM { - let row = rng.gen_range(0..textarea.lines().len() as u16); - textarea.move_cursor(CursorMove::Jump(row, 0)); - textarea.move_cursor(CursorMove::End); - - textarea.input(Input { - key: Key::Enter, - ctrl: false, - alt: false, - shift: false, - }); - term.draw_textarea(&textarea); - - for c in line.chars() { - textarea.input(Input { - key: Key::Char(c), - ctrl: false, - alt: false, - shift: false, - }); - term.draw_textarea(&textarea); - } - } - } - - textarea.lines().len() -} - -#[inline] -fn append_long_lorem(repeat: usize) -> usize { - let mut textarea = TextArea::default(); - let mut term = dummy_terminal(); - - for _ in 0..repeat { - for line in LOREM { - for c in line.chars() { - textarea.input(Input { - key: Key::Char(c), - ctrl: false, - alt: false, - shift: false, - }); - term.draw_textarea(&textarea); - } - } - } - - textarea.lines().len() -} - -fn append(c: &mut Criterion) { - c.bench_function("insert::append::1_lorem", |b| { - b.iter(|| black_box(append_lorem(1))) - }); - c.bench_function("insert::append::10_lorem", |b| { - b.iter(|| black_box(append_lorem(10))) - }); - c.bench_function("insert::append::50_lorem", |b| { - b.iter(|| black_box(append_lorem(50))) - }); -} - -fn random(c: &mut Criterion) { - c.bench_function("insert::random::1_lorem", |b| { - b.iter(|| black_box(random_lorem(1))) - }); - c.bench_function("insert::random::10_lorem", |b| { - b.iter(|| black_box(random_lorem(10))) - }); - c.bench_function("insert::random::50_lorem", |b| { - b.iter(|| black_box(random_lorem(50))) - }); -} - -// Inserting a long line is slower than multiple short lines into `TextArea` -fn long(c: &mut Criterion) { - c.bench_function("insert::long::1_lorem", |b| { - b.iter(|| black_box(append_long_lorem(1))) - }); - c.bench_function("insert::long::5_lorem", |b| { - b.iter(|| black_box(append_long_lorem(5))) - }); - c.bench_function("insert::long::10_lorem", |b| { - b.iter(|| black_box(append_long_lorem(10))) - }); -} - -criterion_group!(insert, append, random, long); -criterion_main!(insert); diff --git a/bench/benches/insert_append.rs b/bench/benches/insert_append.rs new file mode 100644 index 0000000..62930c4 --- /dev/null +++ b/bench/benches/insert_append.rs @@ -0,0 +1,57 @@ +use criterion::{criterion_group, criterion_main, Criterion, SamplingMode}; +use tui_textarea::{Input, Key, TextArea}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +#[inline] +fn append_lorem(repeat: usize) -> usize { + let mut textarea = TextArea::default(); + let mut term = dummy_terminal(); + for _ in 0..repeat { + for line in LOREM { + for c in line.chars() { + textarea.input(Input { + key: Key::Char(c), + ctrl: false, + alt: false, + shift: false, + }); + term.draw_textarea(&textarea); + } + } + textarea.input(Input { + key: Key::Enter, + ctrl: false, + alt: false, + shift: false, + }); + term.draw_textarea(&textarea); + } + textarea.lines().len() +} + +fn bench(c: &mut Criterion) { + let mut group = c.benchmark_group("insert::append"); + + // ~16ms/iter — fits 100 samples in ~6s, keep defaults + group.bench_function("1_lorem", |b| { + b.iter(|| std::hint::black_box(append_lorem(1))) + }); + + // ~490ms/iter — 20 samples × 490ms ≈ 10s + group.sampling_mode(SamplingMode::Flat); + group.sample_size(20); + group.bench_function("10_lorem", |b| { + b.iter(|| std::hint::black_box(append_lorem(10))) + }); + + // ~5.9s/iter — 10 samples × 5.9s ≈ 59s (minimum viable) + group.sample_size(10); + group.bench_function("50_lorem", |b| { + b.iter(|| std::hint::black_box(append_lorem(50))) + }); + + group.finish(); +} + +criterion_group!(insert_append, bench); +criterion_main!(insert_append); diff --git a/bench/benches/insert_long.rs b/bench/benches/insert_long.rs new file mode 100644 index 0000000..a89cc26 --- /dev/null +++ b/bench/benches/insert_long.rs @@ -0,0 +1,53 @@ +// Inserting a long line is slower than multiple short lines into TextArea +use criterion::{criterion_group, criterion_main, Criterion, SamplingMode}; +use tui_textarea::{Input, Key, TextArea}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +#[inline] +fn append_long_lorem(repeat: usize) -> usize { + let mut textarea = TextArea::default(); + let mut term = dummy_terminal(); + + for _ in 0..repeat { + for line in LOREM { + for c in line.chars() { + textarea.input(Input { + key: Key::Char(c), + ctrl: false, + alt: false, + shift: false, + }); + term.draw_textarea(&textarea); + } + } + } + + textarea.lines().len() +} + +fn bench(c: &mut Criterion) { + let mut group = c.benchmark_group("insert::long"); + + // ~25ms/iter — fits 100 samples in ~6s, keep defaults + group.bench_function("1_lorem", |b| { + b.iter(|| std::hint::black_box(append_long_lorem(1))) + }); + + // ~324ms/iter — 20 samples × 324ms ≈ 6s + group.sampling_mode(SamplingMode::Flat); + group.sample_size(20); + group.bench_function("5_lorem", |b| { + b.iter(|| std::hint::black_box(append_long_lorem(5))) + }); + + // ~1.1s/iter — 10 samples × 1.1s ≈ 11s + group.sample_size(10); + group.bench_function("10_lorem", |b| { + b.iter(|| std::hint::black_box(append_long_lorem(10))) + }); + + group.finish(); +} + +criterion_group!(insert_long, bench); +criterion_main!(insert_long); diff --git a/bench/benches/insert_random.rs b/bench/benches/insert_random.rs new file mode 100644 index 0000000..831ae04 --- /dev/null +++ b/bench/benches/insert_random.rs @@ -0,0 +1,67 @@ +use criterion::{criterion_group, criterion_main, Criterion, SamplingMode}; +use rand::rngs::SmallRng; +use rand::{Rng, SeedableRng}; +use tui_textarea::{CursorMove, Input, Key, TextArea}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM, SEED}; + +#[inline] +fn random_lorem(repeat: usize) -> usize { + let mut rng = SmallRng::from_seed(SEED); + let mut textarea = TextArea::default(); + let mut term = dummy_terminal(); + + for _ in 0..repeat { + for line in LOREM { + let row = rng.gen_range(0..textarea.lines().len() as u16); + textarea.move_cursor(CursorMove::Jump(row, 0)); + textarea.move_cursor(CursorMove::End); + + textarea.input(Input { + key: Key::Enter, + ctrl: false, + alt: false, + shift: false, + }); + term.draw_textarea(&textarea); + + for c in line.chars() { + textarea.input(Input { + key: Key::Char(c), + ctrl: false, + alt: false, + shift: false, + }); + term.draw_textarea(&textarea); + } + } + } + + textarea.lines().len() +} + +fn bench(c: &mut Criterion) { + let mut group = c.benchmark_group("insert::random"); + + // ~23ms/iter — fits 100 samples in ~5s, keep defaults + group.bench_function("1_lorem", |b| { + b.iter(|| std::hint::black_box(random_lorem(1))) + }); + + // ~419ms/iter — 20 samples × 419ms ≈ 8s + group.sampling_mode(SamplingMode::Flat); + group.sample_size(20); + group.bench_function("10_lorem", |b| { + b.iter(|| std::hint::black_box(random_lorem(10))) + }); + + // ~2.1s/iter — 10 samples × 2.1s ≈ 21s + group.sample_size(10); + group.bench_function("50_lorem", |b| { + b.iter(|| std::hint::black_box(random_lorem(50))) + }); + + group.finish(); +} + +criterion_group!(insert_random, bench); +criterion_main!(insert_random); diff --git a/bench/benches/search_backward.rs b/bench/benches/search_backward.rs new file mode 100644 index 0000000..f29f2ce --- /dev/null +++ b/bench/benches/search_backward.rs @@ -0,0 +1,37 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::TextArea; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +#[inline] +fn run(pat: &str, mut textarea: TextArea<'_>) { + let mut term = dummy_terminal(); + textarea.set_search_pattern(pat).unwrap(); + term.draw_textarea(&textarea); + for _ in 0..100 { + textarea.search_back(false); + term.draw_textarea(&textarea); + } + textarea.set_search_pattern(r"").unwrap(); + term.draw_textarea(&textarea); +} + +fn short(c: &mut Criterion) { + let textarea = TextArea::from(LOREM.iter().map(|s| s.to_string())); + c.bench_function("search::back_short", |b| { + b.iter(|| run(r"\w*i\w*", textarea.clone())) + }); +} + +fn long(c: &mut Criterion) { + let mut lines = vec![]; + for _ in 0..10 { + lines.extend(LOREM.iter().map(|s| s.to_string())); + } + let textarea = TextArea::new(lines); + c.bench_function("search::back_long", |b| { + b.iter(|| run(r"[A-Z]\w*", textarea.clone())) + }); +} + +criterion_group!(search_backward, short, long); +criterion_main!(search_backward); diff --git a/bench/benches/search.rs b/bench/benches/search_forward.rs similarity index 57% rename from bench/benches/search.rs rename to bench/benches/search_forward.rs index 225f5db..7ce6dc0 100644 --- a/bench/benches/search.rs +++ b/bench/benches/search_forward.rs @@ -3,16 +3,12 @@ use tui_textarea::TextArea; use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; #[inline] -fn run(pat: &str, mut textarea: TextArea<'_>, forward: bool) { +fn run(pat: &str, mut textarea: TextArea<'_>) { let mut term = dummy_terminal(); textarea.set_search_pattern(pat).unwrap(); term.draw_textarea(&textarea); for _ in 0..100 { - if forward { - textarea.search_forward(false); - } else { - textarea.search_back(false); - } + textarea.search_forward(false); term.draw_textarea(&textarea); } textarea.set_search_pattern(r"").unwrap(); @@ -22,10 +18,7 @@ fn run(pat: &str, mut textarea: TextArea<'_>, forward: bool) { fn short(c: &mut Criterion) { let textarea = TextArea::from(LOREM.iter().map(|s| s.to_string())); c.bench_function("search::forward_short", |b| { - b.iter(|| run(r"\w*i\w*", textarea.clone(), true)) - }); - c.bench_function("search::back_short", |b| { - b.iter(|| run(r"\w*i\w*", textarea.clone(), false)) + b.iter(|| run(r"\w*i\w*", textarea.clone())) }); } @@ -36,12 +29,9 @@ fn long(c: &mut Criterion) { } let textarea = TextArea::new(lines); c.bench_function("search::forward_long", |b| { - b.iter(|| run(r"[A-Z]\w*", textarea.clone(), true)) - }); - c.bench_function("search::back_long", |b| { - b.iter(|| run(r"[A-Z]\w*", textarea.clone(), false)) + b.iter(|| run(r"[A-Z]\w*", textarea.clone())) }); } -criterion_group!(search, short, long); -criterion_main!(search); +criterion_group!(search_forward, short, long); +criterion_main!(search_forward); diff --git a/bench/benches/undo_redo.rs b/bench/benches/undo_redo.rs new file mode 100644 index 0000000..8148e19 --- /dev/null +++ b/bench/benches/undo_redo.rs @@ -0,0 +1,66 @@ +use criterion::{criterion_group, criterion_main, Criterion, SamplingMode}; +use tui_textarea::TextArea; +use tui_textarea_bench::{dummy_terminal, TerminalExt}; + +/// Build a textarea with `depth` undo-able edits in history using insert_str, +/// interspersed with delete_str to cover the cursor-clamping code path. +fn make_history(depth: usize) -> TextArea<'static> { + let mut ta = TextArea::default(); + for i in 0..depth { + if i % 4 == 3 { + // Every 4th edit is a delete_str — the operation fixed in e6cc8ab. + ta.delete_str(3); + } else { + ta.insert_str("hello "); + } + } + ta +} + +#[inline] +fn undo_all(textarea: &TextArea<'_>) -> usize { + let mut t = textarea.clone(); + let mut term = dummy_terminal(); + let mut count = 0; + while t.undo() { + term.draw_textarea(&t); + count += 1; + } + count +} + +#[inline] +fn redo_all(textarea: &TextArea<'_>) -> usize { + // Start from fully-undone state so redo has work to do. + let mut t = textarea.clone(); + while t.undo() {} + let mut term = dummy_terminal(); + let mut count = 0; + while t.redo() { + term.draw_textarea(&t); + count += 1; + } + count +} + +fn bench(c: &mut Criterion) { + let mut group = c.benchmark_group("undo_redo"); + // undo::50 ran Linear with R²=0.19 (Criterion warned "enable flat sampling"). + // All depths benefit from Flat since history-clone cost varies with depth. + group.sampling_mode(SamplingMode::Flat); + + for depth in [50usize, 200, 500] { + let ta = make_history(depth); + group.bench_function(format!("undo::{}", depth), |b| { + b.iter(|| std::hint::black_box(undo_all(&ta))) + }); + group.bench_function(format!("redo::{}", depth), |b| { + b.iter(|| std::hint::black_box(redo_all(&ta))) + }); + } + + group.finish(); +} + +criterion_group!(undo_redo, bench); +criterion_main!(undo_redo); diff --git a/bench/benches/wrap_render.rs b/bench/benches/wrap_render.rs new file mode 100644 index 0000000..556182b --- /dev/null +++ b/bench/benches/wrap_render.rs @@ -0,0 +1,57 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use tui_textarea::{TextArea, WrapMode}; +use tui_textarea_bench::{dummy_terminal, TerminalExt, LOREM}; + +const MODES: &[WrapMode] = &[ + WrapMode::None, + WrapMode::Word, + WrapMode::Glyph, + WrapMode::WordOrGlyph, +]; + +fn make_textarea(repeat: usize, mode: WrapMode) -> TextArea<'static> { + let mut lines = Vec::with_capacity(LOREM.len() * repeat); + for _ in 0..repeat { + lines.extend(LOREM.iter().map(|s| s.to_string())); + } + let mut ta = TextArea::new(lines); + ta.set_wrap_mode(mode); + ta +} + +// Short text: 7 lines, each ~63 chars. DummyBackend width=40 → all lines wrap. +fn short(c: &mut Criterion) { + for mode in MODES { + let ta = make_textarea(1, *mode); + let name = format!("wrap_render::short::{:?}", mode); + c.bench_function(&name, |b| { + b.iter(|| { + let mut term = dummy_terminal(); + for _ in 0..50 { + term.draw_textarea(&ta); + } + std::hint::black_box(()) + }) + }); + } +} + +// Long text: 70 lines, same wrapping pressure. +fn long(c: &mut Criterion) { + for mode in MODES { + let ta = make_textarea(10, *mode); + let name = format!("wrap_render::long::{:?}", mode); + c.bench_function(&name, |b| { + b.iter(|| { + let mut term = dummy_terminal(); + for _ in 0..50 { + term.draw_textarea(&ta); + } + std::hint::black_box(()) + }) + }); + } +} + +criterion_group!(wrap_render, short, long); +criterion_main!(wrap_render); diff --git a/bench/src/bin/runner.rs b/bench/src/bin/runner.rs new file mode 100644 index 0000000..47491c9 --- /dev/null +++ b/bench/src/bin/runner.rs @@ -0,0 +1,124 @@ +use std::io::{self, Write}; +use std::path::Path; +use std::process::Command; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +const TARGETS: &[&str] = &[ + "insert_append", + "insert_random", + "insert_long", + "search_forward", + "search_backward", + "cursor_char", + "cursor_word", + "cursor_paragraph", + "cursor_edge", + "delete_char", + "delete_word", + "delete_line", + "wrap_render", + "undo_redo", +]; + +fn main() { + let total = TARGETS.len(); + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = Path::new(manifest_dir) + .parent() + .expect("bench package must have a parent workspace directory"); + + let mut failed: Vec<&str> = Vec::new(); + + for (i, target) in TARGETS.iter().enumerate() { + let n = i + 1; + let timestamp = utc_now(); + println!("[{}/{}] START {} at {}", n, total, target, timestamp); + io::stdout().flush().ok(); + + let wall_start = Instant::now(); + let result = Command::new(&cargo) + .args([ + "bench", + "-p", + "tui-textarea-bench", + "--bench", + target, + "--", + "--verbose", + "--noplot", + ]) + .current_dir(workspace_root) + .status(); + let duration = wall_start.elapsed(); + + let (status_str, ok) = match result { + Ok(s) if s.success() => ("ok", true), + Ok(_) => ("err", false), + Err(e) => { + eprintln!(" error: failed to spawn cargo for {}: {}", target, e); + ("err", false) + } + }; + + println!( + "[{}/{}] DONE {} status={} duration={:.2}s", + n, + total, + target, + status_str, + duration.as_secs_f64() + ); + + if !ok { + failed.push(target); + } + } + + println!(); + if failed.is_empty() { + println!("All {} benchmark targets completed successfully.", total); + } else { + eprintln!("Failed targets ({}/{}):", failed.len(), total); + for t in &failed { + eprintln!(" - {}", t); + } + std::process::exit(1); + } +} + +/// Format the current UTC time as `YYYY-MM-DDTHH:MM:SSZ`. +/// Uses only `std::time` — no external date crate needed. +fn utc_now() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let secs = (ts % 60) as u32; + let mins = ((ts / 60) % 60) as u32; + let hours = ((ts / 3600) % 24) as u32; + let days = ts / 86400; + + let (year, month, day) = days_since_epoch_to_ymd(days); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, month, day, hours, mins, secs + ) +} + +/// Convert days since Unix epoch (1970-01-01) to (year, month, day). +/// Algorithm: http://howardhinnant.github.io/date_algorithms.html +fn days_since_epoch_to_ymd(days: u64) -> (u32, u32, u32) { + let z = days as i64 + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u32, m as u32, d as u32) +} diff --git a/bench/src/lib.rs b/bench/src/lib.rs index e7b9d11..480df6a 100644 --- a/bench/src/lib.rs +++ b/bench/src/lib.rs @@ -7,7 +7,7 @@ use ratatui::layout::{Position, Size}; use ratatui::prelude::backend::ClearType; use ratatui::Terminal; use std::io; -use tui_textarea::TextArea; +use tui_textarea::{CursorMove, TextArea}; pub const LOREM: &[&str] = &[ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do", @@ -138,3 +138,55 @@ impl TerminalExt for Terminal { self.draw(|f| f.render_widget(textarea, f.area())).unwrap(); } } + +// --- Cursor benchmark helpers --- + +#[derive(Clone, Copy)] +pub enum Restore { + TopLeft, + BottomLeft, + BottomRight, + None, +} + +impl Restore { + pub fn cursor_move(self) -> Option { + match self { + Self::TopLeft => Some(CursorMove::Jump(0, 0)), + Self::BottomLeft => Some(CursorMove::Jump(u16::MAX, 0)), + Self::BottomRight => Some(CursorMove::Jump(u16::MAX, u16::MAX)), + Self::None => None, + } + } +} + +pub fn prepare_cursor_textarea() -> TextArea<'static> { + let mut lines = Vec::with_capacity(LOREM.len() * 2 + 1); + lines.extend(LOREM.iter().map(|s| s.to_string())); + lines.push("".to_string()); + lines.extend(LOREM.iter().map(|s| s.to_string())); + TextArea::new(lines) +} + +pub fn run_cursor( + mut textarea: TextArea<'_>, + moves: &[CursorMove], + restore: Restore, + repeat: usize, +) -> (usize, usize) { + let mut term = dummy_terminal(); + let mut prev = textarea.cursor(); + for _ in 0..repeat { + for m in moves { + textarea.move_cursor(*m); + term.draw_textarea(&textarea); + } + if let Some(m) = restore.cursor_move() { + if textarea.cursor() == prev { + textarea.move_cursor(m); + prev = textarea.cursor(); + } + } + } + textarea.cursor() +}