diff --git a/Cargo.lock b/Cargo.lock index b615241e..e2d10af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "assert_cmd" version = "2.2.2" @@ -2742,6 +2748,7 @@ dependencies = [ "time", "toml", "unicode-width", + "vt100", ] [[package]] @@ -2862,6 +2869,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tui-input", + "unicode-segmentation", "unicode-width", ] @@ -3579,6 +3587,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 7abec8d1..bcdddc34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,8 +72,10 @@ tree-sitter-powershell = "0.26.4" tree-sitter-python = "0.25.0" tree-sitter-typescript = "0.23.2" unicode-ident = "1.0.24" +unicode-segmentation = "1.12.0" unicode-width = "0.2.2" uuid = { version = "1.24.0", features = ["serde", "v4"] } +vt100 = "0.16.2" sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } walkdir = "2.5.0" wait-timeout = "0.2.1" diff --git a/crates/skit-cli/Cargo.toml b/crates/skit-cli/Cargo.toml index 26b0a443..627591ff 100644 --- a/crates/skit-cli/Cargo.toml +++ b/crates/skit-cli/Cargo.toml @@ -54,6 +54,7 @@ predicates.workspace = true portable-pty.workspace = true syn.workspace = true tempfile.workspace = true +vt100.workspace = true [lints] workspace = true diff --git a/crates/skit-cli/src/cli.rs b/crates/skit-cli/src/cli.rs index b3deb88b..da38fd39 100644 --- a/crates/skit-cli/src/cli.rs +++ b/crates/skit-cli/src/cli.rs @@ -2724,8 +2724,21 @@ fn colour_is_welcome_for( /// A stream that is not a terminal keeps the plain text, which is why every recorded output in /// this repository is unchanged: those runs are redirected. fn paint_for_output(text: &str, style: HumanStyle, width: Option) -> String { + paint_for_output_with_colour(text, style, width, colour_is_welcome()) +} + +/// The same formatter for a caller that already knows if colour is available. +/// +/// Keeping this input explicit lets tests own the terminal capability without changing the +/// process environment that concurrent tests share. +fn paint_for_output_with_colour( + text: &str, + style: HumanStyle, + width: Option, + colour_is_welcome: bool, +) -> String { let folded = fold_for_output(text, width); - if width.is_none() || style == HumanStyle::Plain || !colour_is_welcome() { + if width.is_none() || style == HumanStyle::Plain || !colour_is_welcome { return folded; } format!("{}{folded}\x1b[0m", style.prefix()) diff --git a/crates/skit-cli/src/cli/tests.rs b/crates/skit-cli/src/cli/tests.rs index d2ee0da7..2be6a594 100644 --- a/crates/skit-cli/src/cli/tests.rs +++ b/crates/skit-cli/src/cli/tests.rs @@ -12200,23 +12200,27 @@ fn a_printed_line_wears_its_sense_and_drops_it_where_it_cannot_show() { // A terminal wears the sense; a redirected stream keeps the plain text. assert_eq!( - super::paint_for_output("done", super::HumanStyle::Green, Some(40)), + super::paint_for_output_with_colour("done", super::HumanStyle::Green, Some(40), true,), "\u{1b}[32mdone\u{1b}[0m" ); assert_eq!( - super::paint_for_output("done", super::HumanStyle::Green, None), + super::paint_for_output_with_colour("done", super::HumanStyle::Green, None, true), "done" ); // A line that states a fact wears nothing, terminal or not. assert_eq!( - super::paint_for_output("done", super::HumanStyle::Plain, Some(40)), + super::paint_for_output_with_colour("done", super::HumanStyle::Plain, Some(40), true,), "done" ); // The colour follows the fold, so a folded line wears one sequence around the whole answer. assert_eq!( - super::paint_for_output("aaa bbb", super::HumanStyle::Red, Some(3)), + super::paint_for_output_with_colour("aaa bbb", super::HumanStyle::Red, Some(3), true,), "\u{1b}[31maaa\nbbb\u{1b}[0m" ); + assert_eq!( + super::paint_for_output_with_colour("done", super::HumanStyle::Green, Some(40), false,), + "done" + ); // Rich drops every style for either answer, and keeps it otherwise. assert!(super::colour_is_welcome_for(None, None)); diff --git a/crates/skit-cli/tests/port_test_tui_responsive_manifest.rs b/crates/skit-cli/tests/port_test_tui_responsive_manifest.rs index 2dccdf69..20d33743 100644 --- a/crates/skit-cli/tests/port_test_tui_responsive_manifest.rs +++ b/crates/skit-cli/tests/port_test_tui_responsive_manifest.rs @@ -1,8 +1,9 @@ //! Completeness guard for Python `tests/test_tui_responsive.py` at `main@206f9ef`. //! -//! Seventeen contracts have executable terminal-geometry equivalents. Two assert widget structures -//! that do not exist in the Ratatui frontend and stay architecture-closed rather than being -//! represented by a weaker test of a different widget. +//! Seventeen contracts have executable terminal-geometry equivalents. Three Rust-only contracts +//! cover frontend adapter invariants. Two Python contracts assert widget structures that do not +//! exist in the Ratatui frontend and stay architecture-closed rather than being represented by a +//! weaker test of a different widget. use std::{collections::BTreeSet, fs, path::Path}; @@ -30,6 +31,12 @@ const EXECUTABLE: &[&str] = &[ "test_add_source_fields_stay_reachable_on_short_terminals", ]; +const RUST_ADDITIVE: &[&str] = &[ + "test_growing_across_height_tiers_never_shrinks_the_primary_viewport", + "test_footer_minimum_structure_is_monotonic_and_keeps_status_out_of_hits", + "test_root_hit_rectangles_stay_inside_every_boundary_viewport", +]; + const ARCHITECTURE_CLOSED: &[(&str, &str)] = &[ ( "test_run_form_stacks_preset_row_and_choices_when_narrow", @@ -75,8 +82,8 @@ fn every_executable_responsive_contract_has_exactly_one_rust_oracle() { let names = test_names(&source); assert_eq!( names.len(), - EXECUTABLE.len(), - "responsive target added or lost a parity test: {names:#?}" + EXECUTABLE.len() + RUST_ADDITIVE.len(), + "responsive target added or lost a declared test: {names:#?}" ); let actual = names.iter().cloned().collect::>(); assert_eq!( @@ -86,9 +93,10 @@ fn every_executable_responsive_contract_has_exactly_one_rust_oracle() { ); let expected = EXECUTABLE .iter() + .chain(RUST_ADDITIVE) .map(|name| (*name).to_owned()) .collect::>(); - assert_eq!(actual, expected, "responsive executable mapping drifted"); + assert_eq!(actual, expected, "responsive test inventory drifted"); } #[test] diff --git a/crates/skit-cli/tests/support/pty.rs b/crates/skit-cli/tests/support/pty.rs index f6880850..8e3b555b 100644 --- a/crates/skit-cli/tests/support/pty.rs +++ b/crates/skit-cli/tests/support/pty.rs @@ -31,15 +31,13 @@ //! then reports an end of input the child never sent. //! 5. **The reader thread fills a channel and is never joined on end-of-input.** The drain is //! detached; nothing downstream depends on it finishing. -//! 6. **Read the visible text, never the control stream.** A pseudo-console writes its own -//! sequences into the same stream as the child's output: Windows opens a session with a -//! cursor question, mode switches, and a window title carrying the whole binary path -//! (`\x1b]0;D:\a\...\skit.exe\x07`). An assertion that measures or matches raw bytes reads -//! that chrome as if the product had printed it. [`strip_terminal_control`] gives the text a -//! person sees (wave 4 fold owners). An assertion about colour cannot drop every sequence, so -//! [`visible_with_styles`] and [`styles_over`] read the same stream one layer up: the visible -//! lines, each with the styles the terminal painted over its own characters (round 5 colour -//! owner). +//! 6. **Use the correct view of terminal output.** A pseudo-console writes its own sequences into +//! the same stream as the child's output. Windows opens a session with a cursor question, mode +//! switches, and a window title that contains the binary path +//! (`\x1b]0;D:\a\...\skit.exe\x07`). [`strip_terminal_control`] gives a text history for a +//! line-oriented assertion. It does not apply cursor movement or erase commands. A full-screen +//! assertion must use [`final_terminal_screen`]. A color assertion uses +//! [`visible_with_styles`] and [`styles_over`] to read the styles over each history line. //! //! Two harness families share these rules. [`PtyChild`] is the full channel-driven harness. //! The free functions ([`keystrokes`], [`settle_buffer`], [`wait_for_exit`]) serve the bespoke @@ -296,6 +294,11 @@ impl PtyChild { let _ = self.writer.flush(); } + /// Change the terminal window size while the child owns it. + pub(crate) fn resize(&mut self, size: PtySize) { + self.master.resize(size).unwrap(); + } + /// Wait for `prompt`, then type `answer` (invariants 1 and 2 together). pub(crate) fn send_after_prompt(&mut self, prompt: &str, answer: &[u8]) { self.expect(prompt); @@ -483,12 +486,12 @@ pub(crate) fn wait_for_exit(child: &mut Box String { let bytes = input.as_bytes(); let mut output = Vec::with_capacity(bytes.len()); @@ -536,6 +539,16 @@ pub(crate) fn strip_terminal_control(input: &str) -> String { String::from_utf8_lossy(&output).into_owned() } +/// Replay terminal output and return only the current terminal grid. +/// +/// This applies cursor movement, erase commands, and alternate-screen changes. Use it for a +/// full-screen assertion. [`strip_terminal_control`] is only a history view. +pub(crate) fn final_terminal_screen(input: &[u8], rows: u16, columns: u16) -> String { + let mut terminal = vt100::Parser::new(rows, columns, 0); + terminal.process(input); + terminal.screen().contents() +} + /// One line a terminal showed, with the styles it painted over that line's own characters. #[derive(Debug)] pub(crate) struct StyledLine { diff --git a/crates/skit-cli/tests/terminal_pty.rs b/crates/skit-cli/tests/terminal_pty.rs index 5d3b0966..746fa87f 100644 --- a/crates/skit-cli/tests/terminal_pty.rs +++ b/crates/skit-cli/tests/terminal_pty.rs @@ -32,7 +32,7 @@ use unicode_width::UnicodeWidthStr as _; #[path = "support/pty.rs"] mod pty; -use pty::{AnswerQueries, PtyChild, strip_terminal_control, styles_over}; +use pty::{AnswerQueries, PtyChild, final_terminal_screen, strip_terminal_control, styles_over}; fn write_command_entry(data: &Path, with_parameter: bool) { let directory = data.join("scripts/demo"); @@ -466,6 +466,31 @@ impl LiveTui { config: &Path, home: &Path, locale: &str, + ) -> Self { + Self::spawn_command_at_size( + args, + data, + state, + config, + home, + locale, + PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }, + ) + } + + fn spawn_command_at_size( + args: &[&str], + data: &Path, + state: &Path, + config: &Path, + home: &Path, + locale: &str, + size: PtySize, ) -> Self { let mut command = CommandBuilder::new(PathBuf::from(env!("CARGO_BIN_EXE_skit"))); command.args(args); @@ -479,16 +504,7 @@ impl LiveTui { command.env("HOME", home); command.env("USERPROFILE", home); Self { - pty: PtyChild::spawn( - command, - PtySize { - rows: 40, - cols: 120, - pixel_width: 0, - pixel_height: 0, - }, - AnswerQueries::On, - ), + pty: PtyChild::spawn(command, size, AnswerQueries::On), } } @@ -514,6 +530,27 @@ impl LiveTui { self.pty.checkpoint() } + fn raw_after(&mut self, checkpoint: usize) -> Vec { + self.pty.raw_after(checkpoint) + } + + fn settle(&mut self) { + self.pty.settle(); + } + + fn write_raw(&mut self, bytes: &[u8]) { + self.pty.write_raw(bytes); + } + + fn resize(&mut self, rows: u16, columns: u16) { + self.pty.resize(PtySize { + rows, + cols: columns, + pixel_width: 0, + pixel_height: 0, + }); + } + fn wait_for(&mut self, needle: &str) -> String { self.wait_for_after(0, needle) } @@ -587,6 +624,164 @@ fn bare_invocation_runs_the_first_run_gate_and_returns_after_the_library_quits() assert_eq!(tree_snapshot(config.path()), config_before); } +// The SGR mouse protocol and cursor query used by this test are Unix PTY byte protocols. +#[cfg(unix)] +#[test] +fn responsive_library_accepts_real_sgr_mouse_and_uses_selective_redraws() { + let data = TempDir::new().unwrap(); + let state = TempDir::new().unwrap(); + let config = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + FileConfigStore::new(config.path().to_path_buf()) + .mark_mirror_configured() + .unwrap(); + write_command_entry(data.path(), false); + + let mut tui = LiveTui::spawn_command_at_size( + &["tui"], + data.path(), + state.path(), + config.path(), + home.path(), + "en", + PtySize { + rows: 12, + cols: 46, + pixel_width: 0, + pixel_height: 0, + }, + ); + tui.answer_cursor_query_after(0); + tui.wait_for("Library"); + tui.settle(); + + let startup = tui.raw_after(0); + assert!( + startup + .windows(b"\x1b[?1003h".len()) + .any(|part| part == b"\x1b[?1003h"), + "the terminal did not enable all-motion mouse capture" + ); + assert!( + startup + .windows(b"\x1b[?1006h".len()) + .any(|part| part == b"\x1b[?1006h"), + "the terminal did not enable SGR mouse coordinates" + ); + + let consumed = tui.checkpoint(); + tui.send(&[0x03]); + tui.settle(); + assert!( + !tui.raw_after(consumed).is_empty(), + "the consumed first Ctrl+C did not redraw its quit notice" + ); + + // Column and row numbers in SGR mouse input are one-based. The short layout puts its flat + // Search input on terminal row 1. This writes the terminal protocol, not a Rust Event. + let press = tui.checkpoint(); + tui.write_raw(b"\x1b[<0;2;1M"); + tui.settle(); + assert!( + !tui.raw_after(press).is_empty(), + "the consumed SGR primary press did not redraw its armed state" + ); + tui.write_raw(b"\x1b[<0;2;1m"); + tui.settle(); + tui.send(b"Z"); + tui.settle(); + let raw = tui.raw_after(0); + let history = strip_terminal_control(&String::from_utf8_lossy(&raw)); + let final_screen = final_terminal_screen(&raw, 12, 46); + assert!( + history.contains("Demo"), + "the terminal history did not contain the entry before search" + ); + assert!( + final_screen.contains('Z'), + "search did not receive focus from the SGR Down/Up exchange:\n{final_screen}" + ); + assert!( + !final_screen.contains("Demo"), + "the final grid kept an entry that the active search removed:\n{final_screen}" + ); + + let resized = tui.checkpoint(); + tui.resize(12, 47); + tui.settle(); + assert!( + !tui.raw_after(resized).is_empty(), + "a real PTY resize did not draw the new responsive layout" + ); + + tui.send(b"\x1b"); + tui.settle(); + let quit = tui.checkpoint(); + tui.send(b"q"); + let (code, output) = tui.wait_for_exit_status_after(quit); + assert_eq!(code, 0, "{output}"); + let shutdown = tui.raw_after(quit); + for disabled in [b"\x1b[?1006l".as_slice(), b"\x1b[?1003l".as_slice()] { + assert!( + shutdown + .windows(disabled.len()) + .any(|part| part == disabled), + "the terminal did not disable mouse capture: {shutdown:?}" + ); + } +} + +#[test] +fn terminal_grid_oracle_applies_overwrite_and_erase_sequences() { + let history = b"\x1b[?1049hstale\x1b[1;1Hnow\x1b[K"; + + assert_eq!(final_terminal_screen(history, 3, 12), "now"); + assert!( + strip_terminal_control(&String::from_utf8_lossy(history)).contains("stale"), + "the synthetic case must prove that a stripped history keeps stale text" + ); +} + +#[cfg(unix)] +#[test] +fn ignored_sgr_pointer_move_produces_no_terminal_frame() { + let data = TempDir::new().unwrap(); + let state = TempDir::new().unwrap(); + let config = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + FileConfigStore::new(config.path().to_path_buf()) + .mark_mirror_configured() + .unwrap(); + write_command_entry(data.path(), false); + + let mut tui = LiveTui::spawn_command_at_size( + &["tui"], + data.path(), + state.path(), + config.path(), + home.path(), + "en", + PtySize { + rows: 12, + cols: 46, + pixel_width: 0, + pixel_height: 0, + }, + ); + tui.answer_cursor_query_after(0); + tui.wait_for("Library"); + tui.settle(); + + let moved = tui.checkpoint(); + tui.write_raw(b"\x1b[<35;2;1M"); + tui.settle(); + let unexpected = tui.raw_after(moved); + assert!( + unexpected.is_empty(), + "an ignored SGR pointer move triggered terminal output: {unexpected:?}" + ); +} + // Synchronizes through the LiveTui cursor-position handshake, which exists only on unix // (see the note on LiveTui::spawn). #[cfg(unix)] @@ -975,8 +1170,10 @@ fn test_selected_prompt_runner_preflight_failure_returns_to_library() { assert!(!form_state.exists()); let submit = tui.checkpoint(); tui.send_effect_key(&[0x12]); - let refused = tui.wait_for_after(submit, "program"); let program_error = format!("required program was not found: {MISSING_RUNNER}"); + // Wait for the unique suffix. A wait for an earlier word can return while the PTY still has + // the rest of this line in another read chunk. + let refused = tui.wait_for_after(submit, MISSING_RUNNER); assert!( compact_terminal_text(&refused).contains(&compact_terminal_text(&program_error)), "the selected runner refusal lost its exact typed error: {refused}" @@ -992,9 +1189,11 @@ fn test_selected_prompt_runner_preflight_failure_returns_to_library() { let library = tui.wait_for_after(submit, "Library"); let expected = format!("Error: {program_error}"); + tui.settle(); + let final_screen = final_terminal_screen(&tui.raw_after(0), 40, 120); assert!( - compact_terminal_text(&library).contains(&compact_terminal_text(&expected)), - "the Library lost the exact localized refusal: {library}" + compact_terminal_text(&final_screen).contains(&compact_terminal_text(&expected)), + "the Library lost the exact localized refusal:\n{final_screen}\nhistory:\n{library}" ); let quit = tui.checkpoint(); tui.send(b"q"); @@ -3001,6 +3200,8 @@ fn a_printed_line_wears_the_colour_of_its_sense_only_on_a_terminal() { command.env("SKIT_STATE_DIR", state.path()); command.env("SKIT_CONFIG_DIR", config.path()); command.env("SKIT_LANG", "en"); + command.env_remove("NO_COLOR"); + command.env("TERM", "xterm-256color"); // An empty PATH leaves uv unfound, which is the report's red sense. command.env("PATH", data.path()); for (name, value) in extra { @@ -3055,6 +3256,8 @@ fn a_printed_line_wears_the_colour_of_its_sense_only_on_a_terminal() { removal.env("SKIT_STATE_DIR", state.path()); removal.env("SKIT_CONFIG_DIR", config.path()); removal.env("SKIT_LANG", "en"); + removal.env_remove("NO_COLOR"); + removal.env("TERM", "xterm-256color"); let removed = PtyChild::spawn( removal, PtySize { diff --git a/crates/skit-tui/Cargo.toml b/crates/skit-tui/Cargo.toml index c88fa822..702aaf90 100644 --- a/crates/skit-tui/Cargo.toml +++ b/crates/skit-tui/Cargo.toml @@ -16,6 +16,7 @@ ratatui-crossterm.workspace = true ratatui-textarea.workspace = true tui-input.workspace = true thiserror.workspace = true +unicode-segmentation.workspace = true unicode-width.workspace = true skit-application.workspace = true skit-form.workspace = true diff --git a/crates/skit-tui/src/footer.rs b/crates/skit-tui/src/footer.rs index c4bd304c..51bfd9c9 100644 --- a/crates/skit-tui/src/footer.rs +++ b/crates/skit-tui/src/footer.rs @@ -18,7 +18,11 @@ use skit_i18n::{Locale, format_text, render as localize, text}; use skit_ui::{CommandContext, LibraryState, Screen, UiCommand, UiKey, command_specs}; use unicode_width::UnicodeWidthStr as _; -use crate::{HitRegion, HitTarget}; +use crate::{ + HitRegion, HitTarget, + layout::ViewportProfile, + pointer::{ClickDispatch, ClickOutcome, ClickTracker}, +}; const PILL_BACKGROUND: Color = Color::Rgb(0x2A, 0x21, 0x1C); const PILL_FOREGROUND: Color = Color::Rgb(0xD9, 0x77, 0x57); @@ -90,6 +94,8 @@ impl Default for ActionFooterStyle { pub(crate) enum ActionFooterMouse { /// The user clicked a visible command chip. Action(A), + /// A primary press armed a command and needs a repaint-free consume. + Armed, /// The footer scrolled to another row of commands. Scrolled, /// The event was outside the footer or had no effect. @@ -98,25 +104,32 @@ pub(crate) enum ActionFooterMouse { /// Persistent mature scroll and click state for a typed local action footer. #[derive(Debug)] -pub(crate) struct ActionFooterSession { +pub(crate) struct ActionFooterSession { scroll: ScrollableContentState, viewport: Rect, visible_height: usize, clicks: ratatui_interact::traits::ClickRegionRegistry, + click: ClickTracker, } -impl Default for ActionFooterSession { +impl Default for ActionFooterSession { fn default() -> Self { Self { scroll: ScrollableContentState::default(), viewport: Rect::default(), visible_height: 0, clicks: ratatui_interact::traits::ClickRegionRegistry::default(), + click: ClickTracker::default(), } } } -impl ActionFooterSession { +impl ActionFooterSession { + /// Cancel an armed command before an owner or layout transition. + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + } + /// Render all commands with wrapping and vertical scrolling. pub(crate) fn render( &mut self, @@ -132,9 +145,8 @@ impl ActionFooterSession { let (chips, rows) = action_footer_chips(items, content_width); self.scroll.set_lines(vec![String::new(); rows]); let maximum_offset = rows.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum_offset { - self.scroll.set_scroll_offset(maximum_offset); - } + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum_offset)); let offset = self.scroll.scroll_offset(); let end = offset.saturating_add(self.visible_height); @@ -178,17 +190,31 @@ impl ActionFooterSession { /// Dispatch a click or wheel event through the footer's mature state. pub(crate) fn handle_mouse(&mut self, mouse: &MouseEvent) -> ActionFooterMouse { - if matches!(mouse.kind, MouseEventKind::Down(_)) { - return self - .clicks - .handle_click(mouse.column, mouse.row) - .cloned() - .map_or(ActionFooterMouse::Ignored, ActionFooterMouse::Action); + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + self.click.cancel(); + return if handle_footer_scroll( + &mut self.scroll, + mouse, + self.viewport, + self.visible_height, + ) { + ActionFooterMouse::Scrolled + } else { + ActionFooterMouse::Ignored + }; } - if handle_footer_scroll(&mut self.scroll, mouse, self.viewport, self.visible_height) { - return ActionFooterMouse::Scrolled; + let target = self.clicks.handle_click(mouse.column, mouse.row); + match self.click.dispatch(mouse, target) { + ClickDispatch::Captured(outcome) => match outcome { + ClickOutcome::Activated(action) => ActionFooterMouse::Action(action), + ClickOutcome::Armed => ActionFooterMouse::Armed, + ClickOutcome::Ignored => ActionFooterMouse::Ignored, + }, + ClickDispatch::Unowned => ActionFooterMouse::Ignored, } - ActionFooterMouse::Ignored } } @@ -229,7 +255,7 @@ fn action_footer_chips( ) .unwrap_or(u16::MAX) .min(width); - if x > 0 && x.saturating_add(chip_width) > width { + if x.saturating_add(chip_width) > width { row = row.saturating_add(1); x = 0; } @@ -270,38 +296,65 @@ struct Chip { } pub(crate) fn required_height( - width: u16, - terminal_height: u16, - header_height: u16, + profile: ViewportProfile, state: &LibraryState, locale: Locale, ) -> u16 { - if is_suppressed(state) { - return 0; - } - let available = terminal_height - .saturating_sub(header_height) - .saturating_sub(u16::from(terminal_height > header_height)); - if available == 0 { + if is_suppressed(state) || profile.width() == 0 || profile.height() == 0 { return 0; } - let inner_width = width.saturating_sub(2); + let decorated = !profile.is_short_or_tiny(); + let inner_width = profile + .width() + .saturating_sub(if decorated { 2 } else { 0 }); if inner_width == 0 { - return 2.min(available); + return 0; } let (_, rows) = chips(state, locale, inner_width); - let tiny = terminal_height <= 6; - let visible_rows = rows.min(if tiny { - 1 - } else { - row_budget(terminal_height, state.command_context()) - }); - let border_rows = if tiny || available <= 2 { 0 } else { 2 }; - let desired = u16::try_from(visible_rows) + let library = matches!( + state.command_context(), + CommandContext::LibraryBrowse | CommandContext::LibrarySearch + ); + let visible_rows = rows.min(profile.footer_row_budget(library)); + u16::try_from(visible_rows) .unwrap_or(u16::MAX) .saturating_add(u16::from(has_note(state, inner_width))) - .saturating_add(border_rows); - desired.min(available).max(1) + .saturating_add(if decorated { 2 } else { 0 }) + .max(1) +} + +/// Return the smallest footer that can show one command row and its note without overlap. +pub(crate) fn minimum_height( + profile: ViewportProfile, + state: &LibraryState, + locale: Locale, +) -> u16 { + if is_suppressed(state) || profile.width() == 0 || profile.height() == 0 { + return 0; + } + minimum_content_height(state, locale, profile.width()) +} + +/// Return the smallest complete bordered footer, or zero when decoration is unavailable. +pub(crate) fn decorated_minimum_height( + profile: ViewportProfile, + state: &LibraryState, + locale: Locale, +) -> u16 { + if is_suppressed(state) + || profile.is_short_or_tiny() + || profile.width() <= 2 + || profile.height() == 0 + { + return 0; + } + let inner_width = profile.width().saturating_sub(2); + minimum_content_height(state, locale, inner_width).saturating_add(2) +} + +fn minimum_content_height(state: &LibraryState, locale: Locale, inner_width: u16) -> u16 { + let (_, rows) = chips(state, locale, inner_width); + u16::from(rows > 0).saturating_add(u16::from(has_note(state, inner_width))) } pub(crate) fn is_suppressed(state: &LibraryState) -> bool { @@ -310,11 +363,16 @@ pub(crate) fn is_suppressed(state: &LibraryState) -> bool { Screen::Add(_) | Screen::Health(_) | Screen::Runners(_) ) || matches!( state.modal(), - Some(skit_ui::ModalState::RunnerEditor { .. }) + Some( + skit_ui::ModalState::ConfirmRemove { .. } + | skit_ui::ModalState::ConfirmDiscardChanges + | skit_ui::ModalState::RunnerEditor { .. } + ) ) } impl FooterSession { + #[cfg(test)] pub(crate) fn render( &mut self, frame: &mut Frame, @@ -322,7 +380,23 @@ impl FooterSession { state: &LibraryState, locale: Locale, ) -> Vec { - let compact = area.height <= 2; + self.render_with_decoration(frame, area, state, locale, area.height > 2) + } + + pub(crate) fn render_with_decoration( + &mut self, + frame: &mut Frame, + area: Rect, + state: &LibraryState, + locale: Locale, + decorated: bool, + ) -> Vec { + if area.width == 0 || area.height == 0 { + self.viewport = area; + self.visible_height = 0; + return Vec::new(); + } + let compact = !decorated; let base_block = Block::default() .borders(if compact { Borders::NONE } else { Borders::ALL }) .border_type(BorderType::Rounded); @@ -343,9 +417,8 @@ impl FooterSession { ); self.scroll.set_lines(vec![String::new(); rows]); let maximum_offset = rows.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum_offset { - self.scroll.set_scroll_offset(maximum_offset); - } + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum_offset)); let indicator = match ( self.scroll.is_at_top(), self.scroll.is_at_bottom(self.visible_height), @@ -476,7 +549,7 @@ fn chips(state: &LibraryState, locale: Locale, inner_width: u16) -> (Vec, let width = u16::try_from(key.width().saturating_add(label.width()).saturating_add(3)) .unwrap_or(u16::MAX) .min(inner_width); - if x > 0 && x.saturating_add(width) > inner_width { + if x.saturating_add(width) > inner_width { row = row.saturating_add(1); x = 0; } @@ -508,8 +581,7 @@ fn footer_groups(state: &LibraryState, locale: Locale) -> Vec 1 - { + ) { let keys = spec .bindings .iter() @@ -553,20 +625,6 @@ fn has_note(state: &LibraryState, _inner_width: u16) -> bool { matches!(state.screen(), Screen::Library) || state.status().is_some() } -fn row_budget(terminal_height: u16, context: CommandContext) -> usize { - let library = matches!( - context, - CommandContext::LibraryBrowse | CommandContext::LibrarySearch - ); - match (terminal_height, library) { - (28.., _) => usize::MAX, - (16..=27, true) => 6, - (16..=27, false) => 3, - (10..=15, true) => 2, - _ => 1, - } -} - fn default_library_status(state: &LibraryState, locale: Locale) -> String { if state.entry_count() == 0 { return text(locale, "Your entries will appear here.").into_owned(); @@ -591,8 +649,12 @@ mod tests { use ratatui_crossterm::crossterm::event::{ KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; - use skit_domain::parameters::{ParamDecl, ParameterValue}; - use skit_ui::{Action, RunFormView}; + use skit_application::{Diagnostic, DiagnosticCode, LibraryScan}; + use skit_domain::{ + EntryKind, EntrySummary, Slug, StorageMode, + parameters::{ParamDecl, ParameterValue}, + }; + use skit_ui::{Action, AddWorkflowState, RunFormView, Screen}; use super::*; use crate::screens::management::{ @@ -617,6 +679,15 @@ mod tests { } } + fn release(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + fn scroll_down(column: u16, row: u16) -> MouseEvent { MouseEvent { kind: MouseEventKind::ScrollDown, @@ -626,6 +697,73 @@ mod tests { } } + fn command_library(diagnostics: Vec) -> LibraryState { + LibraryState::from_scan(LibraryScan { + entries: vec![EntrySummary { + slug: Slug::parse("alpha").unwrap(), + name: "Alpha".to_owned(), + kind: EntryKind::parse("command").unwrap(), + mode: StorageMode::Copy, + description: String::new(), + target: None, + }], + diagnostics, + }) + } + + fn row_text(terminal: &Terminal, row: u16) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect() + } + + #[test] + fn zero_height_footer_clears_geometry_without_painting_a_status_row() { + let mut session = FooterSession { + viewport: Rect::new(2, 1, 8, 1), + visible_height: 1, + ..FooterSession::default() + }; + session.scroll.set_lines(vec![String::new(); 3]); + session.scroll.set_scroll_offset(2); + let state = LibraryState::default(); + let mut terminal = Terminal::new(TestBackend::new(20, 2)).unwrap(); + + let mut hits = Vec::new(); + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new("BODY-SENTINEL"), + Rect::new(0, 0, frame.area().width, 1), + ); + hits = session.render_with_decoration( + frame, + Rect::new(0, 1, 20, 0), + &state, + Locale::En, + false, + ); + }) + .unwrap(); + + assert!(hits.is_empty()); + assert_eq!(session.viewport, Rect::new(0, 1, 20, 0)); + assert_eq!(session.visible_height, 0); + assert_eq!(session.scroll.scroll_offset(), 2); + assert!( + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::() + .starts_with("BODY-SENTINEL") + ); + assert!(!session.handle_mouse(&scroll_down(2, 1))); + } + #[test] fn action_footer_wraps_every_chip_and_keeps_each_visible_chip_clickable() { let items = [ @@ -648,8 +786,12 @@ mod tests { .into_iter() .enumerate() { - assert_eq!( + assert_ne!( session.handle_mouse(&click(1, u16::try_from(row).unwrap())), + ActionFooterMouse::Action(action.clone()) + ); + assert_eq!( + session.handle_mouse(&release(1, u16::try_from(row).unwrap())), ActionFooterMouse::Action(action) ); } @@ -726,10 +868,14 @@ mod tests { session.render(frame, frame.area(), &items, ActionFooterStyle::default()); }) .unwrap(); - assert_eq!( + assert_ne!( session.handle_mouse(&click(1, 0)), ActionFooterMouse::Action(TestAction::Fourth) ); + assert_eq!( + session.handle_mouse(&release(1, 0)), + ActionFooterMouse::Action(TestAction::Fourth) + ); let mut tall = Terminal::new(TestBackend::new(22, 5)).unwrap(); tall.draw(|frame| { @@ -739,6 +885,138 @@ mod tests { assert_eq!(session.scroll.scroll_offset(), 0); } + #[test] + fn action_footer_never_paints_or_owns_the_row_after_its_half_open_area() { + let items = [ + ActionFooterItem::new("1", "First action", TestAction::First), + ActionFooterItem::new("2", "Second action", TestAction::Second), + ]; + assert_eq!(action_footer_required_height(22, &items), 2); + + let mut terminal = Terminal::new(TestBackend::new(22, 2)).unwrap(); + let mut session = ActionFooterSession::default(); + terminal + .draw(|frame| { + frame.render_widget(Paragraph::new("BODY-SENTINEL"), Rect::new(0, 1, 22, 1)); + session.render( + frame, + Rect::new(0, 0, 22, 1), + &items, + ActionFooterStyle::default(), + ); + }) + .unwrap(); + + assert!(row_text(&terminal, 1).starts_with("BODY-SENTINEL")); + assert_eq!(session.handle_mouse(&click(1, 0)), ActionFooterMouse::Armed); + assert_eq!( + session.handle_mouse(&release(1, 0)), + ActionFooterMouse::Action(TestAction::First) + ); + assert_eq!( + session.handle_mouse(&click(1, 1)), + ActionFooterMouse::Ignored + ); + assert_eq!( + session.handle_mouse(&release(1, 1)), + ActionFooterMouse::Ignored + ); + } + + #[test] + fn action_footer_release_outside_cancels_the_arm_before_a_late_release() { + let items = [ActionFooterItem::new( + "1", + "First action", + TestAction::First, + )]; + let mut terminal = Terminal::new(TestBackend::new(22, 2)).unwrap(); + let mut session = ActionFooterSession::default(); + terminal + .draw(|frame| { + session.render( + frame, + Rect::new(0, 0, 22, 1), + &items, + ActionFooterStyle::default(), + ); + }) + .unwrap(); + + assert_eq!(session.handle_mouse(&click(1, 0)), ActionFooterMouse::Armed); + assert_eq!( + session.handle_mouse(&release(1, 1)), + ActionFooterMouse::Ignored + ); + assert_eq!( + session.handle_mouse(&release(1, 0)), + ActionFooterMouse::Ignored, + "a late release resurrected an action cancelled outside the footer" + ); + } + + #[test] + fn action_footer_keeps_a_chip_that_ends_exactly_at_the_content_boundary() { + let items = [ + ActionFooterItem::new("1", "A", TestAction::First), + ActionFooterItem::new("2", "B", TestAction::Second), + ]; + assert_eq!(action_footer_required_height(14, &items), 1); + + let mut terminal = Terminal::new(TestBackend::new(14, 1)).unwrap(); + let mut session = ActionFooterSession::default(); + terminal + .draw(|frame| { + session.render(frame, frame.area(), &items, ActionFooterStyle::default()); + }) + .unwrap(); + assert_eq!(session.handle_mouse(&click(8, 0)), ActionFooterMouse::Armed); + assert_eq!( + session.handle_mouse(&release(8, 0)), + ActionFooterMouse::Action(TestAction::Second) + ); + } + + #[test] + fn action_footer_indicator_reports_top_middle_bottom_and_no_overflow() { + let items = [ + ActionFooterItem::new("1", "First action", TestAction::First), + ActionFooterItem::new("2", "Second action", TestAction::Second), + ActionFooterItem::new("3", "Third action", TestAction::Third), + ActionFooterItem::new("4", "Fourth action", TestAction::Fourth), + ]; + let mut terminal = Terminal::new(TestBackend::new(22, 1)).unwrap(); + let mut session = ActionFooterSession::default(); + let mut render = |session: &mut ActionFooterSession| { + terminal + .draw(|frame| { + session.render(frame, frame.area(), &items, ActionFooterStyle::default()); + }) + .unwrap(); + terminal.backend().buffer()[(20, 0)].symbol().to_owned() + }; + + assert_eq!(render(&mut session), "↓"); + assert_eq!( + session.handle_mouse(&scroll_down(1, 0)), + ActionFooterMouse::Scrolled + ); + assert_eq!(render(&mut session), "↕"); + for _ in 0..8 { + let _ = session.handle_mouse(&scroll_down(1, 0)); + } + assert_eq!(render(&mut session), "↑"); + + let mut grown = Terminal::new(TestBackend::new(22, 4)).unwrap(); + grown + .draw(|frame| { + session.render(frame, frame.area(), &items, ActionFooterStyle::default()); + }) + .unwrap(); + assert_eq!(session.scroll.scroll_offset(), 0); + assert_eq!(grown.backend().buffer()[(20, 0)].symbol(), " "); + } + #[test] fn action_footer_group_starts_on_a_new_row() { let items = [ @@ -773,8 +1051,10 @@ mod tests { .unwrap(); for y in 0..height { for x in 0..width { - if let ActionFooterMouse::Action(action) = - session.handle_mouse(&click(x, y)) + let pressed = session.handle_mouse(&click(x, y)); + if pressed == ActionFooterMouse::Armed + && let ActionFooterMouse::Action(action) = + session.handle_mouse(&release(x, y)) && !seen.contains(&action) { seen.push(action); @@ -876,20 +1156,7 @@ mod tests { #[test] fn complete_footer_wheel_clamps_when_the_terminal_grows() { - use skit_application::LibraryScan; - use skit_domain::{EntryKind, EntrySummary, Slug, StorageMode}; - - let state = LibraryState::from_scan(LibraryScan { - entries: vec![EntrySummary { - slug: Slug::parse("alpha").unwrap(), - name: "Alpha".to_owned(), - kind: EntryKind::parse("command").unwrap(), - mode: StorageMode::Copy, - description: String::new(), - target: None, - }], - diagnostics: Vec::new(), - }); + let state = command_library(Vec::new()); let mut session = FooterSession::default(); let mut terminal = Terminal::new(TestBackend::new(20, 4)).unwrap(); terminal @@ -922,6 +1189,189 @@ mod tests { })); } + #[test] + fn compact_footer_reserves_the_indicator_column_and_clips_wheel_ownership() { + let mut state = command_library(Vec::new()); + state.update(Action::BeginSearch); + let mut session = FooterSession::default(); + let mut terminal = Terminal::new(TestBackend::new(5, 2)).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + hits = + session.render_with_decoration(frame, frame.area(), &state, Locale::En, false); + }) + .unwrap(); + + assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), "↓"); + assert!(hits.iter().all(|hit| hit.rect.right() <= 4)); + assert!(session.handle_mouse(&scroll_down(3, 0))); + assert!(!session.handle_mouse(&scroll_down(4, 0))); + } + + #[test] + fn compact_footer_keeps_a_global_chip_at_its_exact_width_boundary() { + let mut state = command_library(Vec::new()); + state.update(Action::BeginSearch); + let (_, rows) = chips(&state, Locale::En, 31); + assert_eq!(rows, 1); + + let mut session = FooterSession::default(); + let mut terminal = Terminal::new(TestBackend::new(31, 2)).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + hits = + session.render_with_decoration(frame, frame.area(), &state, Locale::En, false); + }) + .unwrap(); + + assert!(hits.iter().any(|hit| { + matches!(hit.action, HitTarget::Command(UiCommand::LeaveSearch)) && hit.rect.y == 0 + })); + } + + #[test] + fn footer_diagnostic_note_uses_the_documented_strict_width_breakpoint() { + let diagnostic = Diagnostic::plain( + DiagnosticCode::CorruptMetadata, + Some("bad".to_owned()), + "bad TOML".to_owned(), + ); + for (width, expected) in [(50, false), (51, true)] { + let state = command_library(vec![diagnostic.clone()]); + let mut session = FooterSession::default(); + let mut terminal = Terminal::new(TestBackend::new(width, 2)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render_with_decoration( + frame, + frame.area(), + &state, + Locale::En, + false, + ); + }) + .unwrap(); + assert_eq!( + row_text(&terminal, 1).contains("damaged entries hidden"), + expected + ); + } + + let state = command_library(Vec::new()); + let mut session = FooterSession::default(); + let mut terminal = Terminal::new(TestBackend::new(51, 2)).unwrap(); + terminal + .draw(|frame| { + let _ = + session.render_with_decoration(frame, frame.area(), &state, Locale::En, false); + }) + .unwrap(); + assert!(!row_text(&terminal, 1).contains("damaged entries hidden")); + } + + #[test] + fn footer_scroll_owns_only_its_half_open_viewport() { + let mut scroll = ScrollableContentState::default(); + scroll.set_lines(vec![String::new(); 10]); + scroll.set_scroll_offset(2); + let viewport = Rect::new(2, 3, 4, 2); + + for (column, row) in [(1, 3), (6, 3), (2, 2), (2, 5)] { + assert!(!handle_footer_scroll( + &mut scroll, + &scroll_down(column, row), + viewport, + 2 + )); + assert_eq!(scroll.scroll_offset(), 2); + } + assert!(handle_footer_scroll( + &mut scroll, + &scroll_down(2, 3), + viewport, + 2 + )); + assert_eq!(scroll.scroll_offset(), 4); + } + + #[test] + fn footer_minimums_distinguish_suppression_geometry_and_command_content() { + let state = command_library(Vec::new()); + let normal = ViewportProfile::new(Rect::new(0, 0, 100, 16)); + assert_eq!(minimum_height(normal, &state, Locale::En), 2); + assert_eq!(decorated_minimum_height(normal, &state, Locale::En), 4); + assert!(required_height(normal, &state, Locale::En) >= 4); + + for area in [Rect::new(0, 0, 0, 16), Rect::new(0, 0, 100, 0)] { + let profile = ViewportProfile::new(area); + assert_eq!(required_height(profile, &state, Locale::En), 0); + assert_eq!(minimum_height(profile, &state, Locale::En), 0); + assert_eq!(decorated_minimum_height(profile, &state, Locale::En), 0); + } + for area in [ + Rect::new(0, 0, 100, 15), + Rect::new(0, 0, 2, 16), + Rect::new(0, 0, 100, 0), + ] { + assert_eq!( + decorated_minimum_height(ViewportProfile::new(area), &state, Locale::En), + 0 + ); + } + + let mut suppressed = LibraryState::default(); + suppressed.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + for minimum in [ + required_height(normal, &suppressed, Locale::En), + minimum_height(normal, &suppressed, Locale::En), + decorated_minimum_height(normal, &suppressed, Locale::En), + ] { + assert_eq!(minimum, 0); + } + assert_eq!(minimum_content_height(&suppressed, Locale::En, 80), 0); + suppressed.update(Action::SetStatus("Saved".to_owned())); + assert_eq!(minimum_content_height(&suppressed, Locale::En, 80), 1); + } + + #[test] + fn normal_height_non_library_footer_uses_its_three_command_row_budget() { + let mut declaration = ParamDecl::new("name"); + declaration.default = Some(ParameterValue::String("World".to_owned())); + let form = RunFormView::from_declarations( + "greet", + "greet", + &[declaration], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Run(Box::new(form)))); + let profile = ViewportProfile::new(Rect::new(0, 0, 20, 16)); + assert_eq!(required_height(profile, &state, Locale::En), 5); + + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = FooterSession::default(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + hits = + session.render_with_decoration(frame, frame.area(), &state, Locale::En, true); + }) + .unwrap(); + let rows = hits + .iter() + .map(|hit| hit.rect.y) + .collect::>(); + assert_eq!(rows.len(), 3); + } + #[test] fn one_line_settings_receipts_render_the_warning_in_three_locales() { for (locale, status, warning) in [ diff --git a/crates/skit-tui/src/layout.rs b/crates/skit-tui/src/layout.rs index cf7ed9dd..22b18f8a 100644 --- a/crates/skit-tui/src/layout.rs +++ b/crates/skit-tui/src/layout.rs @@ -1,6 +1,80 @@ //! Responsive top-level terminal geometry. -use ratatui_core::layout::{Constraint, Direction, Layout, Rect}; +use ratatui_core::layout::Rect; + +const NORMAL_WIDTH: u16 = 80; +const SHORT_HEIGHT: u16 = 10; +const NORMAL_HEIGHT: u16 = 16; +const TALL_HEIGHT: u16 = 28; + +/// The stable height tiers used by every root-level layout decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HeightTier { + Tiny, + Short, + Normal, + Tall, +} + +/// Terminal dimensions classified once for the current frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ViewportProfile { + width: u16, + height: u16, + height_tier: HeightTier, +} + +impl ViewportProfile { + pub(crate) const fn new(area: Rect) -> Self { + let height_tier = match area.height { + ..SHORT_HEIGHT => HeightTier::Tiny, + SHORT_HEIGHT..NORMAL_HEIGHT => HeightTier::Short, + NORMAL_HEIGHT..TALL_HEIGHT => HeightTier::Normal, + TALL_HEIGHT.. => HeightTier::Tall, + }; + Self { + width: area.width, + height: area.height, + height_tier, + } + } + + pub(crate) const fn width(self) -> u16 { + self.width + } + + pub(crate) const fn height(self) -> u16 { + self.height + } + + pub(crate) const fn is_narrow(self) -> bool { + self.width < NORMAL_WIDTH + } + + pub(crate) const fn is_short_or_tiny(self) -> bool { + matches!(self.height_tier, HeightTier::Tiny | HeightTier::Short) + } + + pub(crate) const fn footer_row_budget(self, library: bool) -> usize { + match (self.height_tier, library) { + (HeightTier::Tall, _) => usize::MAX, + (HeightTier::Normal, true) => 6, + (HeightTier::Normal, false) => 3, + (HeightTier::Short, true) => 2, + _ => 1, + } + } + + /// Return the last height in the preceding tier. + pub(crate) const fn previous_tier_max_height(self) -> Option { + match self.height_tier { + HeightTier::Tiny => None, + HeightTier::Short => Some(SHORT_HEIGHT - 1), + HeightTier::Normal => Some(NORMAL_HEIGHT - 1), + HeightTier::Tall => Some(TALL_HEIGHT - 1), + } + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ViewAreas { @@ -9,35 +83,141 @@ pub(crate) struct ViewAreas { pub(crate) footer: Rect, } +/// One root allocation with its resolved footer presentation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct RootLayoutPlan { + pub(crate) areas: ViewAreas, + pub(crate) footer_decorated: bool, +} + +impl RootLayoutPlan { + /// Preserve the primary body, allocate the header, and use only spare rows for the footer. + pub(crate) const fn new( + area: Rect, + preferred_header_height: u16, + preferred_footer_height: u16, + minimum_body_height: u16, + decorated_footer_minimum: u16, + ) -> Self { + let body_floor = if minimum_body_height < area.height { + minimum_body_height + } else { + area.height + }; + let header_capacity = area.height.saturating_sub(body_floor); + let header_height = if preferred_header_height < header_capacity { + preferred_header_height + } else { + header_capacity + }; + let after_header = area.height.saturating_sub(header_height); + let footer_capacity = after_header.saturating_sub(body_floor); + let footer_height = if preferred_footer_height < footer_capacity { + preferred_footer_height + } else { + footer_capacity + }; + let body_height = after_header.saturating_sub(footer_height); + let header = Rect::new(area.x, area.y, area.width, header_height); + let body = Rect::new( + area.x, + area.y.saturating_add(header_height), + area.width, + body_height, + ); + let footer = Rect::new( + area.x, + body.y.saturating_add(body_height), + area.width, + footer_height, + ); + Self { + areas: ViewAreas { + header, + body, + footer, + }, + footer_decorated: decorated_footer_minimum > 0 + && footer_height >= decorated_footer_minimum, + } + } +} + /// Report whether the terminal is below the normal-width tier. pub(crate) const fn is_narrow(width: u16) -> bool { - width < 80 + ViewportProfile::new(Rect::new(0, 0, width, 0)).is_narrow() } /// Report whether the terminal is below the normal-height tier. pub(crate) const fn is_short(height: u16) -> bool { - height < 16 -} - -/// Split the terminal with an explicit header budget. -/// -/// A screen that already titles itself takes no header. Version 0.4 gives the run form the whole -/// area above the footer and writes `Run ` on the panel border -/// (`src/skit/tui_form.py:606-611`), so a header row there would print the same title twice and -/// cost three rows of form. -pub(crate) fn split_with_header(area: Rect, footer_height: u16, header_height: u16) -> ViewAreas { - let header = header_height.min(area.height); - let areas = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(header), - Constraint::Min(0), - Constraint::Length(footer_height.min(area.height.saturating_sub(header))), - ]) - .split(area); - ViewAreas { - header: areas[0], - body: areas[1], - footer: areas[2], + ViewportProfile::new(Rect::new(0, 0, 0, height)).is_short_or_tiny() +} + +#[cfg(test)] +mod reviewer_contracts { + use super::*; + + #[test] + fn primary_body_has_priority_when_root_chrome_does_not_fit() { + for height in 0..=6 { + let plan = RootLayoutPlan::new(Rect::new(0, 0, 80, height), 3, 9, 6, 3); + assert_eq!(plan.areas.body.height, height); + assert_eq!(plan.areas.header.height, 0); + assert_eq!(plan.areas.footer.height, 0); + } + } + + #[test] + fn height_tier_budgets_change_only_at_the_documented_boundaries() { + let profile = |height| ViewportProfile::new(Rect::new(0, 0, 80, height)); + assert_eq!(profile(9).footer_row_budget(true), 1); + assert_eq!(profile(9).footer_row_budget(false), 1); + assert_eq!(profile(10).footer_row_budget(true), 2); + assert_eq!(profile(10).footer_row_budget(false), 1); + assert_eq!(profile(15).footer_row_budget(true), 2); + assert_eq!(profile(15).footer_row_budget(false), 1); + assert_eq!(profile(16).footer_row_budget(true), 6); + assert_eq!(profile(16).footer_row_budget(false), 3); + assert_eq!(profile(27).footer_row_budget(true), 6); + assert_eq!(profile(27).footer_row_budget(false), 3); + assert_eq!(profile(28).footer_row_budget(true), usize::MAX); + assert_eq!(profile(28).footer_row_budget(false), usize::MAX); + } + + #[test] + fn each_height_tier_reports_the_exact_preceding_endpoint() { + let profile = |height| ViewportProfile::new(Rect::new(0, 0, 80, height)); + assert_eq!(profile(0).previous_tier_max_height(), None); + assert_eq!(profile(9).previous_tier_max_height(), None); + assert_eq!(profile(10).previous_tier_max_height(), Some(9)); + assert_eq!(profile(15).previous_tier_max_height(), Some(9)); + assert_eq!(profile(16).previous_tier_max_height(), Some(15)); + assert_eq!(profile(27).previous_tier_max_height(), Some(15)); + assert_eq!(profile(28).previous_tier_max_height(), Some(27)); + } + + #[test] + fn root_plan_tiles_every_zero_through_boundary_dimension() { + for width in 0..=80 { + for height in 0..=28 { + for (header, footer, body) in [(0, 0, 0), (1, 3, 6), (3, 9, 6)] { + let area = Rect::new(7, 11, width, height); + let plan = RootLayoutPlan::new(area, header, footer, body, 3); + let [head, main, foot] = + [plan.areas.header, plan.areas.body, plan.areas.footer]; + assert_eq!(head.x, area.x); + assert_eq!(main.x, area.x); + assert_eq!(foot.x, area.x); + assert_eq!(head.width, area.width); + assert_eq!(main.width, area.width); + assert_eq!(foot.width, area.width); + assert_eq!(head.y, area.y); + assert_eq!(main.y, head.bottom()); + assert_eq!(foot.y, main.bottom()); + assert_eq!(foot.bottom(), area.bottom()); + assert!(main.height >= body.min(area.height)); + } + } + } } } diff --git a/crates/skit-tui/src/lib.rs b/crates/skit-tui/src/lib.rs index 5e135ced..76793b78 100644 --- a/crates/skit-tui/src/lib.rs +++ b/crates/skit-tui/src/lib.rs @@ -4,15 +4,17 @@ mod footer; mod layout; +mod pointer; mod rowclip; mod screens; mod session; mod terminal; mod theme; +mod viewport; use ratatui_core::{layout::Rect, terminal::Frame}; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }; use skit_i18n::{Locale, format_text}; use skit_ui::{ @@ -20,6 +22,8 @@ use skit_ui::{ UiBinding, UiCommand, UiKey, UiModifiers, command_specs, }; +use layout::{RootLayoutPlan, ViewportProfile}; +use pointer::contains; pub use screens::add::{ AddControlId, AddHitRegion, AddScreenEvent, AddScreenGeometry, AddScreenSession, AddTextField, render_add, @@ -51,13 +55,24 @@ pub enum HitTarget { RunFieldCommand { /// Field index in the active typed launch form. field: usize, - /// Shared command identity rendered by the chip. - command: UiCommand, + /// Field-local command rendered by the chip. + command: RunFieldCommand, }, /// Focus one form row. FocusField(usize), } +/// One command that retains the active launch-field identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RunFieldCommand { + /// Open the filesystem picker for the field. + BrowsePath, + /// Open the insertion menu for the field. + InsertValue, + /// Restore the field's declared default. + ResetDefault, +} + /// One clickable rectangular target. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct HitRegion { @@ -102,20 +117,15 @@ pub fn render_with_session( session: &mut TuiSession, ) -> ViewGeometry { session.begin_render(state); - let header_height = header_height(state, frame.area().height); - let footer_height = footer::required_height( - frame.area().width, - frame.area().height, - header_height, - state, - locale, - ); - let areas = layout::split_with_header(frame.area(), footer_height, header_height); - + let profile = ViewportProfile::new(frame.area()); let header = header_kind(state); - if areas.header.height > 0 - && let Some(kind) = header - { + let minimum_body_height = minimum_body_height(state); + let body_floor = + monotonic_body_floor(frame.area(), state, locale, profile, minimum_body_height); + let plan = root_layout_plan(frame.area(), state, locale, body_floor); + let areas = plan.areas; + + if let Some(kind) = header { session.render_header(frame, areas.header, kind, locale); } let mut geometry = match state.modal() { @@ -141,87 +151,129 @@ pub fn render_with_session( } None => render_screen(frame, areas.body, state, locale, session), }; - if areas.footer.height > 0 { - geometry - .hits - .extend(session.render_footer(frame, areas.footer, state, locale)); - } + geometry.hits.extend(session.render_footer( + frame, + areas.footer, + state, + locale, + plan.footer_decorated, + )); session.register_geometry(&geometry); session.render_quit_toast(frame, locale); geometry } fn header_kind(state: &LibraryState) -> Option> { - match state.modal() { - Some(ModalState::Help) => Some(HeaderKind::Help), - Some(ModalState::ConfirmRemove { .. }) => Some(HeaderKind::ConfirmRemove), - Some(ModalState::ConfirmDiscardChanges) => Some(HeaderKind::ConfirmDiscardChanges), - Some(ModalState::RunPresetName { .. }) => Some(HeaderKind::RunPresetName), - Some(ModalState::RunTokenMenu { .. }) => Some(HeaderKind::RunTokenMenu), - Some(ModalState::RunEnvironmentPicker { .. }) => Some(HeaderKind::RunEnvironmentPicker), - Some(ModalState::RunFilePicker { .. }) => Some(HeaderKind::RunFilePicker), - Some(ModalState::RunnerEditor { view, .. }) => Some(HeaderKind::RunnerEditor(view.mode())), - None => match state.screen() { - Screen::Library => Some(HeaderKind::Library { - query: state.query(), - search: state.input_mode() == InputMode::Search, - }), - Screen::Preferences(_) => Some(HeaderKind::Preferences), - Screen::Add(_) => Some(HeaderKind::Add), - Screen::Health(_) => Some(HeaderKind::Health), - Screen::Runners(_) => Some(HeaderKind::Runners), - Screen::Report(report) => Some(HeaderKind::Report(&report.title)), - Screen::Run(_) | Screen::Form(_) | Screen::Settings(_) => None, - }, + if state.modal().is_some() { + return None; + } + match state.screen() { + Screen::Library => Some(HeaderKind::Library { + query: state.query(), + search: state.input_mode() == InputMode::Search, + }), + Screen::Report(report) => Some(HeaderKind::Report(&report.title)), + Screen::Preferences(_) + | Screen::Add(_) + | Screen::Health(_) + | Screen::Runners(_) + | Screen::Run(_) + | Screen::Form(_) + | Screen::Settings(_) => None, } } -/// Return the rows the shared header takes on one screen. -/// -/// A screen that titles its own panel gets the whole body (`src/skit/tui_form.py:606-611`, -/// `src/skit/tui_settings.py:869-871`). Drawing the header above it prints the same title twice and -/// spends three rows saying so — on entry settings that was three of the rows the parameter section -/// needed to be on screen at all. Most modals keep the header. The compact environment picker owns -/// its title and uses those rows for its input. -fn header_height(state: &LibraryState, terminal_height: u16) -> u16 { - // The environment picker owns a titled panel. On short and tiny terminals, - // omit the duplicate global title so its bordered input and the global - // Cancel chip both fit. This matches the short-tier modal chrome budget. - if terminal_height < 16 - && matches!(state.modal(), Some(ModalState::RunEnvironmentPicker { .. })) - { +fn preferred_header_height(header: Option<&HeaderKind<'_>>, profile: ViewportProfile) -> u16 { + match header { + Some(HeaderKind::Library { .. }) if profile.height() <= 16 => 1, + Some(_) => 3, + None => 0, + } +} + +fn minimum_body_height(state: &LibraryState) -> u16 { + if state.modal().is_some() { + return 3; + } + match state.screen() { + // The Library panel needs its border, column headings, and up to three primary rows. + Screen::Library => u16::try_from(state.visible_entry_count()) + .unwrap_or(u16::MAX) + .clamp(1, 3) + .saturating_add(3), + // Keep the first three-row Preferences control inside its titled panel. + Screen::Preferences(_) => 5, + Screen::Run(_) + | Screen::Form(_) + | Screen::Settings(_) + | Screen::Add(_) + | Screen::Health(_) + | Screen::Runners(_) + | Screen::Report(_) => 3, + } +} + +fn root_layout_plan( + area: Rect, + state: &LibraryState, + locale: Locale, + body_floor: u16, +) -> RootLayoutPlan { + let profile = ViewportProfile::new(area); + let header = header_kind(state); + let preferred_header = preferred_header_height(header.as_ref(), profile); + let preferred_footer = footer::required_height(profile, state, locale); + let minimum_footer = footer::minimum_height(profile, state, locale); + let chrome_capacity = area.height.saturating_sub(body_floor.min(area.height)); + let header_height = allocated_header_height( + preferred_header, + chrome_capacity, + minimum_footer.min(chrome_capacity), + ); + RootLayoutPlan::new( + area, + header_height, + preferred_footer, + body_floor, + footer::decorated_minimum_height(profile, state, locale), + ) +} + +const fn allocated_header_height(preferred: u16, chrome_capacity: u16, minimum_footer: u16) -> u16 { + if preferred == 0 { return 0; } - if terminal_height <= 6 - && matches!( - state.modal(), - Some( - ModalState::RunPresetName { .. } - | ModalState::RunTokenMenu { .. } - | ModalState::RunFilePicker { .. } - ) - ) - { + if preferred <= 1 { + if chrome_capacity >= preferred.saturating_add(minimum_footer) { + return preferred; + } return 0; } - if state.modal().is_none() - && matches!( - state.screen(), - Screen::Run(_) | Screen::Form(_) | Screen::Settings(_) - ) - { - 0 - } else if state.modal().is_none() - && matches!(state.screen(), Screen::Library) - && state.input_mode() == InputMode::Search - && layout::is_short(terminal_height) - { + let preferred_budget = preferred.saturating_add(minimum_footer); + if chrome_capacity >= preferred_budget { + preferred + } else if chrome_capacity >= 1_u16.saturating_add(minimum_footer) { 1 } else { - 3 + 0 } } +fn monotonic_body_floor( + area: Rect, + state: &LibraryState, + locale: Locale, + profile: ViewportProfile, + minimum: u16, +) -> u16 { + let Some(previous_height) = profile.previous_tier_max_height() else { + return minimum; + }; + let previous_area = Rect::new(area.x, area.y, area.width, previous_height); + let previous_plan = root_layout_plan(previous_area, state, locale, minimum); + minimum.max(previous_plan.areas.body.height) +} + fn render_screen( frame: &mut Frame, area: Rect, @@ -238,7 +290,7 @@ fn render_screen( Screen::Health(view) => session.render_health(frame, area, view, locale), Screen::Runners(view) => session.render_runners(frame, area, view, locale), Screen::Form(form) => session.render_form(frame, area, form, locale), - Screen::Report(report) => screens::report::render(frame, area, report, locale), + Screen::Report(report) => session.render_report(frame, area, report, locale), } } @@ -312,7 +364,7 @@ fn map_key(key: KeyEvent, state: &LibraryState, geometry: &ViewGeometry) -> Opti ) { return None; } - return Some(command_action(spec.command, geometry)); + return Some(command_action(spec.command, context, geometry)); } None } @@ -365,33 +417,17 @@ fn map_mouse(mouse: MouseEvent, state: &LibraryState, geometry: &ViewGeometry) - CommandContext::LibraryBrowse | CommandContext::LibrarySearch ); match mouse.kind { - MouseEventKind::ScrollUp if library_context => Some(Action::Previous), - MouseEventKind::ScrollDown if library_context => Some(Action::Next), - MouseEventKind::Down(MouseButton::Left) => { - if let Some(hit) = geometry - .hits - .iter() - .find(|hit| contains(hit.rect, mouse.column, mouse.row)) - { - return Some(match hit.action { - HitTarget::Command(command) => command_action(command, geometry), - HitTarget::RunFieldCommand { field, command } => { - run_field_command_action(field, command) - } - HitTarget::FocusField(index) => Action::FocusField(index), - }); - } - library_context - .then_some(()) - .filter(|()| contains(geometry.rows, mouse.column, mouse.row)) - .map(|()| { - Action::SelectVisible( - geometry.first_visible + usize::from(mouse.row - geometry.rows.y), - ) - }) + MouseEventKind::ScrollUp + if library_context && contains(geometry.rows, mouse.column, mouse.row) => + { + Some(Action::Previous) + } + MouseEventKind::ScrollDown + if library_context && contains(geometry.rows, mouse.column, mouse.row) => + { + Some(Action::Next) } - MouseEventKind::Down(MouseButton::Right) - | MouseEventKind::Down(MouseButton::Middle) + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved @@ -402,36 +438,88 @@ fn map_mouse(mouse: MouseEvent, state: &LibraryState, geometry: &ViewGeometry) - } } -pub(crate) fn command_action(command: UiCommand, geometry: &ViewGeometry) -> Action { - if matches!(command, UiCommand::ToggleDetail) { - Action::ToggleDetail { - currently_visible: geometry.detail_pane_visible, +pub(crate) fn command_action( + command: UiCommand, + context: CommandContext, + geometry: &ViewGeometry, +) -> Action { + match (context, command) { + (CommandContext::Settings, UiCommand::NewRunner) => { + Action::Settings(skit_ui::SettingsAction::NewRunner) } - } else { - command + (_, UiCommand::ToggleDetail) => Action::ToggleDetail { + currently_visible: geometry.detail_pane_visible, + }, + _ => command .direct_action() - .expect("only detail commands need rendered state") + .expect("only detail commands need rendered state"), } } -fn run_field_command_action(field: usize, command: UiCommand) -> Action { +const fn run_field_command_action(field: usize, command: RunFieldCommand) -> Action { match command { - UiCommand::BrowsePath => Action::OpenRunFilePicker(field), - UiCommand::InsertValue => Action::OpenRunTokenMenuFor(field), - UiCommand::ResetDefault => Action::ResetRunField(field), - _ => command - .direct_action() - .expect("detail commands are not run-field commands"), + RunFieldCommand::BrowsePath => Action::OpenRunFilePicker(field), + RunFieldCommand::InsertValue => Action::OpenRunTokenMenuFor(field), + RunFieldCommand::ResetDefault => Action::ResetRunField(field), } } -fn contains(rect: Rect, column: u16, row: u16) -> bool { - column >= rect.x - && column < rect_right(rect) - && row >= rect.y - && row < rect.y.saturating_add(rect.height) -} +#[cfg(test)] +mod tests { + use super::*; -const fn rect_right(rect: Rect) -> u16 { - rect.x.saturating_add(rect.width) + #[test] + fn header_allocation_keeps_only_complete_header_forms() { + assert_eq!(allocated_header_height(1, 2, 0), 1); + assert_eq!(allocated_header_height(1, 0, 0), 0); + assert_eq!(allocated_header_height(3, 1, 1), 0); + assert_eq!(allocated_header_height(3, 3, 2), 1); + } + + #[test] + fn minimum_body_height_preserves_screen_and_modal_content() { + use skit_application::preferences::{ + AfterRunChoice, InteractiveFormChoice, JavascriptChoice, MirrorConfiguration, + PreferencesDraft, PreferencesSnapshot, + }; + use skit_ui::{FormPurpose, PreferencesView}; + + let library = LibraryState::default(); + assert_eq!(minimum_body_height(&library), 4); + + let mut modal = LibraryState::default(); + modal.update(Action::OpenHelp); + assert_eq!(minimum_body_height(&modal), 3); + + let preferences = + PreferencesView::new(PreferencesDraft::from_snapshot(PreferencesSnapshot { + language: String::new(), + available_languages: vec!["en".into(), "zh-CN".into(), "zh-TW".into()], + effective_language: "en".into(), + editor: String::new(), + editor_fallback: Some("vi".into()), + form: InteractiveFormChoice::Tui, + after_run: AfterRunChoice::Exit, + javascript: JavascriptChoice::Automatic, + bash_path: None, + runner_names: Vec::new(), + mirror: MirrorConfiguration::default(), + })); + let mut preferences_state = LibraryState::default(); + preferences_state.update(Action::Present(Screen::Preferences(Box::new(preferences)))); + assert_eq!(minimum_body_height(&preferences_state), 5); + + let mut form_state = LibraryState::default(); + form_state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Rename, + title: "Rename".into(), + title_arguments: Vec::new(), + translate_title: false, + selector: Some("entry".into()), + fields: vec![FormField::text_raw("name", "Name", "Entry")], + focused: 0, + submit_label: "Save".into(), + }))); + assert_eq!(minimum_body_height(&form_state), 3); + } } diff --git a/crates/skit-tui/src/pointer.rs b/crates/skit-tui/src/pointer.rs new file mode 100644 index 00000000..9c9af0b5 --- /dev/null +++ b/crates/skit-tui/src/pointer.rs @@ -0,0 +1,618 @@ +//! Shared terminal pointer geometry. + +use ratatui_core::layout::Rect; +use ratatui_crossterm::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui_textarea::{CursorMove, TextArea as RichTextArea}; +use tui_input::{Input, InputRequest}; +use unicode_segmentation::UnicodeSegmentation as _; +use unicode_width::UnicodeWidthStr as _; + +/// A typed hit map. The last rendered region is the topmost region. +#[derive(Debug)] +pub(crate) struct HitMap { + regions: Vec<(Rect, T)>, +} + +/// Result of one pointer event sent through a press-release tracker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClickOutcome { + /// A primary press armed a semantic target. + Armed, + /// A primary release matched the semantic target that was pressed. + Activated(T), + /// The event did not complete a click. + Ignored, +} + +/// Whether one pointer event belonged to this tracker or can continue to a lower owner. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClickDispatch { + /// The tracker or one of its rendered targets owned the event. + Captured(ClickOutcome), + /// No target and no active press owned the event. + Unowned, +} + +/// Keep one semantic target stable across a primary press and release. +#[derive(Debug)] +pub(crate) struct ClickTracker { + pressed: Option, +} + +impl Default for ClickTracker { + fn default() -> Self { + Self { pressed: None } + } +} + +impl ClickTracker { + pub(crate) fn cancel(&mut self) { + self.pressed = None; + } + + /// Update the lifecycle and report whether a lower pointer owner can see this event. + pub(crate) fn dispatch(&mut self, mouse: &MouseEvent, target: Option<&T>) -> ClickDispatch { + let had_press = self.pressed.is_some(); + let captured = target.is_some() + || (had_press && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))); + let outcome = self.update(mouse, target); + if captured { + ClickDispatch::Captured(outcome) + } else { + ClickDispatch::Unowned + } + } + + pub(crate) fn update(&mut self, mouse: &MouseEvent, target: Option<&T>) -> ClickOutcome { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.pressed = target.cloned(); + if self.pressed.is_some() { + ClickOutcome::Armed + } else { + ClickOutcome::Ignored + } + } + MouseEventKind::Up(MouseButton::Left) => { + let pressed = self.pressed.take(); + match (pressed, target) { + (Some(pressed), Some(released)) if &pressed == released => { + ClickOutcome::Activated(pressed) + } + _ => ClickOutcome::Ignored, + } + } + MouseEventKind::Down(MouseButton::Right | MouseButton::Middle) + | MouseEventKind::Up(MouseButton::Right | MouseButton::Middle) + | MouseEventKind::Drag(_) => { + self.pressed = None; + ClickOutcome::Ignored + } + _ => ClickOutcome::Ignored, + } + } +} + +impl Default for HitMap { + fn default() -> Self { + Self { + regions: Vec::new(), + } + } +} + +impl HitMap { + pub(crate) fn clear(&mut self) { + self.regions.clear(); + } + + pub(crate) fn register(&mut self, rect: Rect, target: T) { + self.regions.push((rect, target)); + } + + pub(crate) fn topmost(&self, column: u16, row: u16) -> Option<&T> { + self.regions + .iter() + .rev() + .find(|(rect, _)| contains(*rect, column, row)) + .map(|(_, target)| target) + } +} + +/// Exact rendered cells that can place a cursor in one single-line input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct EditableGeometry { + content: Rect, + visual_scroll: usize, + secret: bool, +} + +impl EditableGeometry { + pub(crate) const fn new(content: Rect, visual_scroll: usize, secret: bool) -> Self { + Self { + content, + visual_scroll, + secret, + } + } + + /// Move the input cursor to the nearest grapheme boundary at the clicked cell. + pub(crate) fn place_cursor(&self, input: &mut Input, column: u16, row: u16) -> bool { + if !contains(self.content, column, row) { + return false; + } + let visual_cell = self + .visual_scroll + .saturating_add(usize::from(column.saturating_sub(self.content.x))); + let cursor = cursor_for_visual_cell(input.value(), visual_cell, self.secret); + let _ = input.handle(InputRequest::SetCursor(cursor)); + true + } +} + +/// Persistent viewport coordinates for one multiline editor. +/// +/// `ratatui-textarea` keeps these coordinates private. Keep the same alignment rule here so a +/// terminal cell can map back to the logical line and character that the editor rendered. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct TextAreaViewport { + top_row: usize, + left_cell: usize, +} + +/// Exact rendered cells that can place a cursor in one multiline editor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct TextAreaGeometry { + content: Rect, + first_row: usize, + left_cell: usize, + tab_length: usize, +} + +impl TextAreaViewport { + pub(crate) fn align(&mut self, state: &RichTextArea<'_>, width: usize, height: usize) { + let cursor = state.cursor(); + let (row, column) = (cursor.0, cursor.1); + let visual_column = + textarea_display_column(&state.lines()[row], column, usize::from(state.tab_length())); + self.top_row = aligned_scroll(self.top_row, row, height); + self.left_cell = textarea_scroll_boundary( + &state.lines()[row], + aligned_scroll(self.left_cell, visual_column, width), + usize::from(state.tab_length()), + ); + } + + pub(crate) const fn top_row(self) -> usize { + self.top_row + } + + pub(crate) const fn left_cell(self) -> usize { + self.left_cell + } + + pub(crate) const fn geometry( + self, + content: Rect, + skipped_rows: usize, + tab_length: usize, + ) -> TextAreaGeometry { + TextAreaGeometry { + content, + first_row: self.top_row.saturating_add(skipped_rows), + left_cell: self.left_cell, + tab_length, + } + } +} + +impl TextAreaGeometry { + /// Move the editor cursor to the nearest character boundary at the clicked cell. + pub(crate) fn place_cursor(self, state: &mut RichTextArea<'_>, column: u16, row: u16) -> bool { + if !contains(self.content, column, row) { + return false; + } + let logical_row = self + .first_row + .saturating_add(usize::from(row.saturating_sub(self.content.y))) + .min(state.lines().len().saturating_sub(1)); + let visual_cell = self + .left_cell + .saturating_add(usize::from(column.saturating_sub(self.content.x))); + let logical_column = cursor_for_textarea_visual_cell( + &state.lines()[logical_row], + visual_cell, + self.tab_length, + ); + let first_row = logical_row.min(usize::from(u16::MAX)); + state.move_cursor(CursorMove::Jump( + u16::try_from(first_row).expect("the clamped row fits"), + 0, + )); + for _ in first_row..logical_row { + state.move_cursor(CursorMove::Down); + } + for _ in 0..logical_column { + state.move_cursor(CursorMove::Forward); + } + true + } +} + +pub(crate) fn textarea_display_column( + line: &str, + logical_column: usize, + tab_length: usize, +) -> usize { + let mut display_column = 0_usize; + let mut codepoints = 0_usize; + for grapheme in line.graphemes(true) { + if codepoints >= logical_column { + break; + } + let next_codepoints = codepoints.saturating_add(grapheme.chars().count()); + if logical_column < next_codepoints { + break; + } + codepoints = next_codepoints; + display_column = if grapheme == "\t" && tab_length > 0 { + display_column.saturating_add(tab_length - (display_column % tab_length)) + } else { + display_column.saturating_add(grapheme.width()) + }; + } + display_column +} + +fn textarea_scroll_boundary(line: &str, wanted: usize, tab_length: usize) -> usize { + let mut boundary = 0_usize; + for grapheme in line.graphemes(true) { + let width = if grapheme == "\t" && tab_length > 0 { + tab_length - (boundary % tab_length) + } else { + grapheme.width() + }; + let next = boundary.saturating_add(width); + if next > wanted { + break; + } + boundary = next; + } + boundary +} + +fn cursor_for_textarea_visual_cell(value: &str, visual_cell: usize, tab_length: usize) -> usize { + let mut cells = 0_usize; + let mut codepoints = 0_usize; + for grapheme in value.graphemes(true) { + let width = if grapheme == "\t" && tab_length > 0 { + tab_length - (cells % tab_length) + } else { + grapheme.width() + }; + let grapheme_codepoints = grapheme.chars().count(); + let next = cells.saturating_add(width); + if grapheme == "\t" { + if visual_cell < next { + return codepoints; + } + } else if width > 0 && visual_cell <= next { + return if visual_cell.saturating_sub(cells) * 2 >= width { + codepoints.saturating_add(grapheme_codepoints) + } else { + codepoints + }; + } + cells = next; + codepoints = codepoints.saturating_add(grapheme_codepoints); + } + codepoints +} + +fn aligned_scroll(previous: usize, cursor: usize, length: usize) -> usize { + if length == 0 { + return cursor; + } + if previous.saturating_add(length) <= cursor { + cursor.saturating_add(1).saturating_sub(length) + } else { + previous.min(cursor) + } +} + +pub(crate) const fn is_primary_down(mouse: &MouseEvent) -> bool { + matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) +} + +pub(crate) const fn contains(rect: Rect, column: u16, row: u16) -> bool { + column >= rect.x && column < rect.right() && row >= rect.y && row < rect.bottom() +} + +pub(crate) fn display_cursor(value: &str, cursor: usize, secret: bool) -> usize { + let prefix: String = value.chars().take(cursor).collect(); + if secret { + prefix.graphemes(true).count() + } else { + prefix.width() + } +} + +pub(crate) fn display_scroll(value: &str, cursor: usize, width: usize, secret: bool) -> usize { + let wanted = display_cursor(value, cursor, secret).saturating_sub(width); + let mut scroll = 0_usize; + for grapheme in value.graphemes(true) { + if scroll >= wanted { + break; + } + scroll = scroll.saturating_add(if secret { 1 } else { grapheme.width() }); + } + scroll +} + +pub(crate) fn secret_display(value: &str) -> String { + "•".repeat(value.graphemes(true).count()) +} + +fn cursor_for_visual_cell(value: &str, visual_cell: usize, secret: bool) -> usize { + let mut cells = 0_usize; + let mut codepoints = 0_usize; + for grapheme in value.graphemes(true) { + let width = if secret { 1 } else { grapheme.width() }; + let grapheme_codepoints = grapheme.chars().count(); + if width == 0 { + codepoints = codepoints.saturating_add(grapheme_codepoints); + continue; + } + let next = cells.saturating_add(width); + if visual_cell <= next { + return if visual_cell.saturating_sub(cells) * 2 >= width { + codepoints.saturating_add(grapheme_codepoints) + } else { + codepoints + }; + } + cells = next; + codepoints = codepoints.saturating_add(grapheme_codepoints); + } + codepoints +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_scroll_matches_tui_input_without_splitting_a_grapheme() { + for (value, width, secret) in [ + ("abcdef", 3, false), + ("a界b", 2, false), + ("界a", 2, false), + ("e\u{301}xyz", 2, false), + ("👨‍👩‍👧‍👦xy", 2, false), + ("界a", 1, true), + ] { + let input = Input::new(value.to_owned()); + let expected = if secret { + secret_display(value).width().saturating_sub(width) + } else { + input.visual_scroll(width) + }; + assert_eq!( + display_scroll(value, input.cursor(), width, secret), + expected, + "value={value:?} width={width} secret={secret}" + ); + } + } + + #[test] + fn visual_cell_matrix_returns_only_complete_grapheme_boundaries() { + for (value, secret, expected) in [ + ("abc", false, vec![0, 1, 2, 3]), + ("a界b", false, vec![0, 1, 2, 2, 3]), + ("e\u{301}x", false, vec![0, 2, 3]), + ("👨‍👩‍👧‍👦x", false, vec![0, 7, 7, 8]), + ("a界b", true, vec![0, 1, 2, 3]), + ] { + let boundaries = value + .grapheme_indices(true) + .map(|(byte, _)| value[..byte].chars().count()) + .chain(std::iter::once(value.chars().count())) + .collect::>(); + for (cell, expected_cursor) in expected.into_iter().enumerate() { + let cursor = cursor_for_visual_cell(value, cell, secret); + assert_eq!(cursor, expected_cursor, "value={value:?} cell={cell}"); + assert!( + boundaries.contains(&cursor), + "value={value:?} cell={cell} split at codepoint {cursor}" + ); + } + } + } + + #[test] + fn cursor_mapping_never_splits_a_wide_or_combining_grapheme() { + assert_eq!(cursor_for_visual_cell("ab", 1, false), 1); + assert_eq!(cursor_for_visual_cell("a界b", 1, false), 1); + assert_eq!(cursor_for_visual_cell("a界b", 2, false), 2); + assert_eq!(cursor_for_visual_cell("a界b", 3, false), 2); + assert_eq!(cursor_for_visual_cell("e\u{301}x", 0, false), 0); + assert_eq!(cursor_for_visual_cell("e\u{301}x", 1, false), 2); + assert_eq!(cursor_for_visual_cell("\u{301}x", 0, false), 1); + } + + #[test] + fn textarea_alignment_follows_the_cursor_across_both_viewport_edges() { + assert_eq!(aligned_scroll(4, 2, 3), 2); + assert_eq!(aligned_scroll(2, 2, 3), 2); + assert_eq!(aligned_scroll(2, 5, 3), 3); + assert_eq!(aligned_scroll(1, 4, 3), 2); + assert_eq!(aligned_scroll(1, 2, 3), 1); + assert_eq!(aligned_scroll(4, 9, 0), 9); + } + + #[test] + fn topmost_hit_wins() { + let mut hits = HitMap::default(); + hits.register(Rect::new(0, 0, 2, 1), 1); + hits.register(Rect::new(1, 0, 2, 1), 2); + assert_eq!(hits.topmost(1, 0), Some(&2)); + } + + #[test] + fn hit_map_drops_the_previous_frame() { + let mut hits = HitMap::default(); + hits.register(Rect::new(1, 1, 2, 2), 1); + hits.clear(); + + assert_eq!(hits.topmost(1, 1), None); + } + + #[test] + fn hit_testing_excludes_the_right_and_bottom_edges() { + let rect = Rect::new(2, 3, 4, 5); + assert!(contains(rect, 5, 7)); + assert!(!contains(rect, rect.right(), 7)); + assert!(!contains(rect, 5, rect.bottom())); + } + + #[test] + fn textarea_viewport_tracks_a_cursor_beyond_both_visible_edges() { + let mut state = RichTextArea::new(vec![ + "zero".to_owned(), + "one".to_owned(), + "two".to_owned(), + "0123456789".to_owned(), + ]); + state.move_cursor(CursorMove::Jump(3, 8)); + let mut viewport = TextAreaViewport::default(); + + viewport.align(&state, 4, 2); + + assert_eq!(viewport.geometry(Rect::new(4, 5, 4, 2), 1, 4).first_row, 3); + assert_eq!(viewport.left_cell, 5); + } + + #[test] + fn click_tracker_cancels_drag_nonprimary_and_semantic_mismatch() { + let target = 1; + let other = 2; + let event = |kind| MouseEvent { + kind, + column: 1, + row: 1, + modifiers: ratatui_crossterm::crossterm::event::KeyModifiers::NONE, + }; + let mut tracker = ClickTracker::default(); + + assert_eq!( + tracker.update( + &event(MouseEventKind::Down(MouseButton::Left)), + Some(&target) + ), + ClickOutcome::Armed + ); + assert_eq!( + tracker.update( + &event(MouseEventKind::Drag(MouseButton::Left)), + Some(&target) + ), + ClickOutcome::Ignored + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(MouseButton::Left)), Some(&target)), + ClickOutcome::Ignored, + "a drag must cancel the armed click" + ); + + for button in [MouseButton::Right, MouseButton::Middle] { + assert_eq!( + tracker.update( + &event(MouseEventKind::Down(MouseButton::Left)), + Some(&target) + ), + ClickOutcome::Armed + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(button)), Some(&target)), + ClickOutcome::Ignored + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(MouseButton::Left)), Some(&target)), + ClickOutcome::Ignored, + "{button:?} Up retained the primary arm" + ); + } + + assert_eq!( + tracker.update( + &event(MouseEventKind::Down(MouseButton::Left)), + Some(&target) + ), + ClickOutcome::Armed + ); + assert_eq!( + tracker.update( + &event(MouseEventKind::Down(MouseButton::Middle)), + Some(&target) + ), + ClickOutcome::Ignored + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(MouseButton::Left)), Some(&target)), + ClickOutcome::Ignored + ); + + assert_eq!( + tracker.update( + &event(MouseEventKind::Down(MouseButton::Left)), + Some(&target) + ), + ClickOutcome::Armed + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(MouseButton::Left)), Some(&other)), + ClickOutcome::Ignored + ); + assert_eq!( + tracker.update(&event(MouseEventKind::Up(MouseButton::Left)), Some(&target)), + ClickOutcome::Ignored, + "a semantic mismatch retained the primary arm" + ); + } + + #[test] + fn click_dispatch_captures_an_outside_release_but_not_an_unowned_press() { + let target = 1; + let event = |kind| MouseEvent { + kind, + column: 1, + row: 1, + modifiers: ratatui_crossterm::crossterm::event::KeyModifiers::NONE, + }; + let mut tracker = ClickTracker::default(); + + assert_eq!( + tracker.dispatch(&event(MouseEventKind::Down(MouseButton::Left)), None), + ClickDispatch::Unowned, + "a blank primary press must remain available to a lower owner" + ); + assert_eq!( + tracker.dispatch( + &event(MouseEventKind::Down(MouseButton::Left)), + Some(&target) + ), + ClickDispatch::Captured(ClickOutcome::Armed) + ); + assert_eq!( + tracker.dispatch(&event(MouseEventKind::Up(MouseButton::Left)), None), + ClickDispatch::Captured(ClickOutcome::Ignored), + "the tracker must consume the release that cancels its active press" + ); + assert_eq!( + tracker.dispatch(&event(MouseEventKind::Up(MouseButton::Left)), Some(&target)), + ClickDispatch::Captured(ClickOutcome::Ignored), + "a late release resurrected a target cancelled outside" + ); + } +} diff --git a/crates/skit-tui/src/rowclip.rs b/crates/skit-tui/src/rowclip.rs index 05b16591..cb279ac1 100644 --- a/crates/skit-tui/src/rowclip.rs +++ b/crates/skit-tui/src/rowclip.rs @@ -1,7 +1,16 @@ //! Paint a visible row band without materializing the hidden rows. -use ratatui_core::{buffer::Buffer, layout::Rect, style::Style, text::Line, widgets::Widget}; +use ratatui_core::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span, Text}, + widgets::Widget, +}; +use ratatui_textarea::TextArea as RichTextArea; use ratatui_widgets::{block::Block, borders::Borders, paragraph::Paragraph}; +use unicode_segmentation::UnicodeSegmentation as _; +use unicode_width::UnicodeWidthStr as _; /// Return the virtual screen row occupied by one editor cursor. pub(crate) const fn editor_cursor_virtual_row(control_start: usize, cursor_row: usize) -> usize { @@ -12,13 +21,13 @@ pub(crate) const fn editor_cursor_virtual_row(control_start: usize, cursor_row: #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct RowClip { full_height: usize, - top: u16, + top: usize, area: Rect, } impl RowClip { /// Describe the visible band of an item with its complete virtual height. - pub(crate) const fn new(full_height: usize, top: u16, area: Rect) -> Self { + pub(crate) const fn new(full_height: usize, top: usize, area: Rect) -> Self { Self { full_height, top, @@ -33,7 +42,12 @@ impl RowClip { /// Return the first source row in the band. pub(crate) const fn top(self) -> usize { - self.top as usize + self.top + } + + /// Return the complete virtual height of the item. + pub(crate) const fn full_height(self) -> usize { + self.full_height } /// Report whether the complete item is visible. @@ -56,29 +70,61 @@ impl RowClip { /// Paint a wrapped paragraph at the source offset of this band. pub(crate) fn paint_paragraph(self, buffer: &mut Buffer, paragraph: Paragraph<'_>) { - paragraph.scroll((self.top, 0)).render(self.area, buffer); + paragraph + .scroll((u16::try_from(self.top).unwrap_or(u16::MAX), 0)) + .render(self.area, buffer); } - /// Paint a clipped third-party editor with all of its native cell styles. + /// Paint text that already contains only this clip's visible content lines. /// - /// Editors alone use this bounded scratch. `ratatui-textarea` keeps its cursor, selection, and - /// active-line span builder private, so the adapter renders one editor at the height of that - /// field's content and copies only the visible rows. Collection-backed rows never use it. - pub(crate) fn paint_bounded_stateful_editor(self, buffer: &mut Buffer, editor: W) { - let full = Rect::new( - self.area.x, - 0, - self.area.width, - u16::try_from(self.full_height).expect("the editor height fits Ratatui geometry"), - ); - let mut scratch = Buffer::empty(full); - editor.render(full, &mut scratch); - for (source, target_row) in self.rows() { - let source_y = u16::try_from(source).expect("the editor row fits Ratatui geometry"); - for column in 0..target_row.width { - buffer[(target_row.x + column, target_row.y)] = - scratch[(full.x + column, source_y)].clone(); - } + /// This keeps the scratch-free path independent of the virtual source offset. The caller can + /// borrow or build at most `area.height` lines even when the source contains more rows than a + /// terminal coordinate can represent. + pub(crate) fn paint_bordered_lines( + self, + buffer: &mut Buffer, + lines: Vec>, + title: Line<'_>, + border_style: Style, + ) { + if let Some(top) = self.row(0) { + Block::default() + .borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)) + .border_style(border_style) + .title(title) + .render(top, buffer); + } + + let bottom_source = self.full_height.saturating_sub(1); + let content_start = self.top().max(1); + let content_sources = content_start..bottom_source; + let content_rows = self + .rows() + .filter(|(source, _)| content_sources.contains(source)) + .count(); + if content_rows > 0 { + let area = self + .row(content_start) + .expect("the content band starts on a visible row"); + let sides = Rect::new( + area.x, + area.y, + area.width, + u16::try_from(content_rows).expect("the content band fits its terminal area"), + ); + let block = Block::default() + .borders(Borders::LEFT.union(Borders::RIGHT)) + .border_style(border_style); + let inner = block.inner(sides); + block.render(sides, buffer); + Paragraph::new(Text::from(lines)).render(inner, buffer); + } + + if let Some(bottom) = self.row(bottom_source) { + Block::default() + .borders(Borders::BOTTOM.union(Borders::LEFT).union(Borders::RIGHT)) + .border_style(border_style) + .render(bottom, buffer); } } @@ -93,16 +139,20 @@ impl RowClip { ) { if let Some(top) = self.row(0) { Block::default() - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) + .borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)) .border_style(border_style) .title(title) .render(top, buffer); } + let bottom_source = self.full_height.saturating_sub(1); let content_start = self.top().max(1); - let content_end = (self.top() + usize::from(self.area.height)).min(self.full_height - 1); - if content_start < content_end { - let content_rows = content_end - content_start; + let content_sources = content_start..bottom_source; + let content_rows = self + .rows() + .filter(|(source, _)| content_sources.contains(source)) + .count(); + if content_rows > 0 { let area = self .rows() .find_map(|(source, area)| (source == content_start).then_some(area)) @@ -114,7 +164,7 @@ impl RowClip { u16::try_from(content_rows).expect("the content band fits its terminal area"), ); let block = Block::default() - .borders(Borders::LEFT | Borders::RIGHT) + .borders(Borders::LEFT.union(Borders::RIGHT)) .border_style(border_style); let inner = block.inner(sides); block.render(sides, buffer); @@ -127,11 +177,283 @@ impl RowClip { .render(inner, buffer); } - if let Some(bottom) = self.row(self.full_height - 1) { + if let Some(bottom) = self.row(bottom_source) { Block::default() - .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) + .borders(Borders::BOTTOM.union(Borders::LEFT).union(Borders::RIGHT)) .border_style(border_style) .render(bottom, buffer); } } } + +/// Build only the terminal-width fragments of visible textarea rows. +/// +/// The textarea keeps the complete value and edit history. This adapter reads at most the requested +/// logical rows and allocates at most `width` display cells for each result row. It also keeps the +/// native cursor and selection precedence. +pub(crate) fn bounded_textarea_lines( + state: &RichTextArea<'_>, + first_row: usize, + row_count: usize, + left_cell: usize, + width: usize, + selection_style: Style, +) -> Vec> { + let cursor = state.cursor(); + let context = TextAreaLineContext { + cursor: (cursor.0, cursor.1), + selection: state.selection_range(), + left_cell, + right_cell: left_cell.saturating_add(width), + tab_length: usize::from(state.tab_length()), + base_style: state.style(), + cursor_line_style: state.cursor_line_style(), + cursor_style: state.cursor_style(), + selection_style, + }; + state + .lines() + .iter() + .enumerate() + .skip(first_row) + .take(row_count) + .map(|(row, line)| visible_textarea_line(line, row, &context)) + .collect() +} + +struct TextAreaLineContext { + cursor: (usize, usize), + selection: Option<((usize, usize), (usize, usize))>, + left_cell: usize, + right_cell: usize, + tab_length: usize, + base_style: Style, + cursor_line_style: Style, + cursor_style: Style, + selection_style: Style, +} + +fn visible_textarea_line(line: &str, row: usize, context: &TextAreaLineContext) -> Line<'static> { + let base_style = if row == context.cursor.0 { + context.base_style.patch(context.cursor_line_style) + } else { + context.base_style + }; + let mut display_column = 0_usize; + let mut spans = Vec::new(); + let mut graphemes = line.graphemes(true).peekable(); + let mut logical_column = 0_usize; + let mut line_length = 0_usize; + let mut reached_end = true; + while let Some(grapheme) = graphemes.next() { + let grapheme_start = logical_column; + logical_column = logical_column.saturating_add(grapheme.chars().count()); + line_length = logical_column; + let grapheme_width = if grapheme == "\t" && context.tab_length > 0 { + context.tab_length - (display_column % context.tab_length) + } else { + grapheme.width() + }; + let grapheme_end = display_column.saturating_add(grapheme_width); + if grapheme_end > context.left_cell && display_column < context.right_cell { + let visible_start = display_column.max(context.left_cell); + let visible_end = grapheme_end.min(context.right_cell); + let style = + textarea_grapheme_style(row, grapheme_start, logical_column, context, base_style); + let fully_visible = visible_start == display_column && visible_end == grapheme_end; + if fully_visible && grapheme != "\t" { + push_textarea_span(&mut spans, grapheme.to_owned(), style); + } else if visible_start < visible_end { + push_textarea_span(&mut spans, " ".repeat(visible_end - visible_start), style); + } + } + display_column = grapheme_end; + if display_column >= context.right_cell { + reached_end = graphemes.peek().is_none(); + break; + } + } + + let end_style = if reached_end && context.cursor == (row, line_length) { + Some(context.cursor_style) + } else if reached_end + && context + .selection + .is_some_and(|(start, end)| start <= (row, line_length) && (row, line_length) < end) + { + Some(context.selection_style) + } else { + None + }; + if let Some(style) = end_style + && display_column >= context.left_cell + && display_column < context.right_cell + { + push_textarea_span(&mut spans, " ".to_owned(), style); + } + Line::from(spans) +} + +fn textarea_grapheme_style( + row: usize, + start_column: usize, + end_column: usize, + context: &TextAreaLineContext, + base_style: Style, +) -> Style { + let start = (row, start_column); + let end = (row, end_column); + if context.cursor >= start && context.cursor < end { + context.cursor_style + } else if context + .selection + .is_some_and(|(selection_start, selection_end)| { + selection_start < end && start < selection_end + }) + { + context.selection_style + } else { + base_style + } +} + +fn push_textarea_span(spans: &mut Vec>, text: String, style: Style) { + if let Some(previous) = spans.last_mut() + && previous.style == style + { + previous.content.to_mut().push_str(&text); + } else { + spans.push(Span::styled(text, style)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui_core::style::{Color, Modifier}; + use ratatui_textarea::CursorMove; + + fn row_text(buffer: &Buffer, row: u16) -> String { + (0..buffer.area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect() + } + + #[test] + fn exact_capacity_is_full_and_rows_stop_at_the_virtual_height() { + let exact = RowClip::new(4, 0, Rect::new(0, 0, 8, 4)); + assert!(exact.is_full()); + assert_eq!(exact.rows().count(), 4); + assert!(exact.row(3).is_some()); + assert!(exact.row(4).is_none()); + + let oversized = RowClip::new(4, 0, Rect::new(0, 0, 8, 5)); + assert!(oversized.is_full()); + assert_eq!(oversized.rows().count(), 4); + assert!(!RowClip::new(4, 1, Rect::new(0, 0, 8, 4)).is_full()); + } + + #[test] + fn virtual_rows_above_terminal_coordinates_keep_a_terminal_sized_band() { + let top = usize::from(u16::MAX) + 100; + let clip = RowClip::new(top + 10, top, Rect::new(3, 4, 12, 3)); + let rows = clip.rows().collect::>(); + + assert_eq!( + rows.iter().map(|(source, _)| *source).collect::>(), + [top, top + 1, top + 2] + ); + assert_eq!( + rows.iter().map(|(_, area)| *area).collect::>(), + [ + Rect::new(3, 4, 12, 1), + Rect::new(3, 5, 12, 1), + Rect::new(3, 6, 12, 1), + ] + ); + } + + #[test] + fn one_visible_grapheme_does_not_allocate_one_span_per_codepoint() { + let value = format!("a{}", "\u{301}".repeat(10_000)); + let mut state = RichTextArea::new(vec![value]); + state.move_cursor(CursorMove::Head); + + let lines = bounded_textarea_lines(&state, 0, 1, 0, 4, Style::default().bg(Color::Blue)); + + assert_eq!(lines.len(), 1); + let span_count = lines[0].spans.len(); + assert_eq!(span_count, 1, "one terminal grapheme must use one span"); + } + + #[test] + fn bordered_paragraph_paints_only_the_surviving_content_and_border_rows() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 3)); + Paragraph::new("SENTINEL").render(Rect::new(0, 2, 8, 1), &mut buffer); + RowClip::new(4, 2, Rect::new(0, 0, 8, 3)).paint_bordered_paragraph( + &mut buffer, + Paragraph::new("A\nB"), + Line::from("Field"), + Style::default(), + 0, + ); + + assert!(row_text(&buffer, 0).contains('B')); + assert!(row_text(&buffer, 1).contains('└')); + assert_eq!(row_text(&buffer, 2), "SENTINEL"); + + let mut top_clipped = Buffer::empty(Rect::new(0, 0, 8, 2)); + RowClip::new(4, 1, Rect::new(0, 0, 8, 2)).paint_bordered_paragraph( + &mut top_clipped, + Paragraph::new("A\nB"), + Line::from("Field"), + Style::default(), + 0, + ); + assert!(row_text(&top_clipped, 0).contains('A')); + assert!(row_text(&top_clipped, 1).contains('B')); + } + + #[test] + fn bordered_paragraph_keeps_every_visible_content_row_at_a_nonzero_offset() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 5)); + Paragraph::new("..........\n..........\n..........\n..........\n..........") + .render(buffer.area, &mut buffer); + + RowClip::new(6, 2, Rect::new(1, 1, 8, 3)).paint_bordered_paragraph( + &mut buffer, + Paragraph::new("A\nB\nC\nD"), + Line::from("Field"), + Style::default(), + 0, + ); + + for (row, expected) in [(1, "B"), (2, "C"), (3, "D")] { + assert_eq!(buffer[(1, row)].symbol(), "│"); + assert_eq!(buffer[(2, row)].symbol(), expected); + assert_eq!(buffer[(8, row)].symbol(), "│"); + assert_eq!(buffer[(0, row)].symbol(), "."); + assert_eq!(buffer[(9, row)].symbol(), "."); + } + assert_eq!(row_text(&buffer, 4), ".........."); + } + + #[test] + fn bottom_border_does_not_inherit_the_hidden_content_style() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 4)); + RowClip::new(4, 0, buffer.area).paint_bordered_paragraph( + &mut buffer, + Paragraph::new("A\nB\nHIDDEN") + .style(Style::default().bg(Color::Red).add_modifier(Modifier::BOLD)), + Line::from("Field"), + Style::default().fg(Color::Green), + 0, + ); + + let bottom = &buffer[(3, 3)]; + assert_eq!(bottom.symbol(), "─"); + assert_eq!(bottom.fg, Color::Green); + assert_eq!(bottom.bg, Color::Reset); + assert!(!bottom.modifier.contains(Modifier::BOLD)); + } +} diff --git a/crates/skit-tui/src/screens/add.rs b/crates/skit-tui/src/screens/add.rs index 8d891967..00a0cd02 100644 --- a/crates/skit-tui/src/screens/add.rs +++ b/crates/skit-tui/src/screens/add.rs @@ -1,15 +1,21 @@ //! Add, source-review, executable-review, and prompt-review widgets. -use std::{collections::BTreeMap, hash::Hash, path::PathBuf}; +use std::{ + cmp::Ordering, + collections::BTreeMap, + hash::Hash, + num::{NonZeroU16, NonZeroUsize}, + path::PathBuf, +}; use ratatui_core::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::Rect, style::{Color, Modifier, Style}, terminal::Frame, text::{Line, Span}, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui_interact::{ components::{ @@ -19,22 +25,24 @@ use ratatui_interact::{ }, state::FocusManager, }; -use ratatui_widgets::paragraph::Paragraph; +use ratatui_widgets::paragraph::{Paragraph, Wrap}; use skit_domain::StorageMode; use skit_i18n::{Locale, format_text, kind_choice_label, text}; use skit_ui::{ AddAction, AddNotice, AddProblem, AddStage, AddWorkflowState, DependencySurface, DraftKind, - KnownEntryKind, PROMPT_LIST_PREVIEW_LIMIT, PathOutputPolicy, PathPickerState, - PathSelectionMode, PickerPurpose, ReviewLane, + KnownEntryKind, PathOutputPolicy, PathPickerState, PathSelectionMode, PickerPurpose, + ReviewLane, }; use tui_input::{Input as LineInput, InputRequest, backend::crossterm::EventHandler as _}; use unicode_width::UnicodeWidthStr as _; use crate::{ footer::handle_footer_scroll, + pointer::{ClickDispatch, ClickOutcome, ClickTracker, EditableGeometry}, rowclip::RowClip, session::render_line_input_band, theme::{ACCENT, BOX_MAROON, SELECT_BG, SELECT_FG, panel_block}, + viewport::AlignmentSignature, }; /// Typed editable control identity. @@ -175,9 +183,16 @@ pub struct AddScreenSession { footer_viewport: Rect, footer_visible_height: usize, row_spans: BTreeMap, + editables: BTreeMap, + alignment: Option>, + click: ClickTracker, } impl AddScreenSession { + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + } + /// Current typed focus. #[must_use] pub fn focused(&self) -> Option<&AddControlId> { @@ -206,6 +221,7 @@ impl AddScreenSession { self.sync_values(state); return; } + self.click.cancel(); self.signature = Some(signature); self.focus.clear(); self.inputs.clear(); @@ -231,8 +247,9 @@ impl AddScreenSession { } self.focus .register_all([AddControlId::NewScript, AddControlId::NewPrompt]); - if !source.listed_drafts().is_empty() { - self.focus.register(AddControlId::DeleteDraft); + match source.listed_drafts() { + [] => {} + [_first, ..] => self.focus.register(AddControlId::DeleteDraft), } self.focus .register_all([AddControlId::Continue, AddControlId::Cancel]); @@ -263,24 +280,29 @@ impl AddScreenSession { .expect("the typed Review stage owns its review state"); self.insert_input(AddTextField::ReviewName, review.name()); self.insert_input(AddTextField::ReviewDescription, review.description()); - if !review.is_fresh() && review.lane() != ReviewLane::Executable { + if let (false, ReviewLane::Script | ReviewLane::Prompt) = + (review.is_fresh(), review.lane()) + { self.focus.register(AddControlId::Storage); self.storage = SelectState::with_selected( 2, - usize::from(review.storage() == StorageMode::Reference), + usize::from(matches!(review.storage(), StorageMode::Reference)), ); } - match review.dependency_surface() { - DependencySurface::Python => { + match (review.dependency_surface(), review.storage()) { + (DependencySurface::Python, _) => { self.insert_input(AddTextField::Dependencies, review.dependencies_text()); self.insert_input(AddTextField::PythonConstraint, review.requires_python()); } - DependencySurface::Npm if review.storage() == StorageMode::Copy => { + (DependencySurface::Npm, StorageMode::Copy) => { self.insert_input(AddTextField::Dependencies, review.dependencies_text()); } - DependencySurface::None - | DependencySurface::Npm - | DependencySurface::PythonOwned(_) => {} + ( + DependencySurface::None + | DependencySurface::Npm + | DependencySurface::PythonOwned(_), + _, + ) => {} } if review.storage() == StorageMode::Copy { for candidate in review.candidates() { @@ -308,7 +330,7 @@ impl AddScreenSession { let runner_index = review .runner_names() .iter() - .position(|runner| runner == review.runner()) + .position(|runner| runner.as_str().eq(review.runner())) .map_or(0, |index| index.saturating_add(1)); self.runner = SelectState::with_selected( review.runner_names().len().saturating_add(1), @@ -316,16 +338,14 @@ impl AddScreenSession { ); self.focus.register(AddControlId::NewRunner); } - if review.interpolate() - && review.prompt_candidates().len() > review.prompt_preview().len() - { + if let (true, true) = (review.interpolate(), has_more_prompt_candidates(review)) { self.focus.register(AddControlId::Continue); } - self.focus.register_all([ - AddControlId::EditSource, - AddControlId::Save, - AddControlId::Cancel, - ]); + if matches!(review.lane(), ReviewLane::Script | ReviewLane::Prompt) { + self.focus.register(AddControlId::EditSource); + } + self.focus + .register_all([AddControlId::Save, AddControlId::Cancel]); } AddStage::ConfirmDraftDelete => self .focus @@ -366,25 +386,38 @@ impl AddScreenSession { { return Some(AddScreenEvent::Changed); } - if matches!(mouse.kind, MouseEventKind::Down(_)) { - let target = geometry - .hits - .iter() - .find(|hit| hit.area.contains((mouse.column, mouse.row).into())) - .map(|hit| hit.target.clone())?; - if !matches!( - target, - AddControlId::PickFocusedKind - | AddControlId::ToggleFocused - | AddControlId::NextField - | AddControlId::PreviousField - ) { - self.focus.set(target.clone()); - self.ensure_focus_visible(); - } - return self.activate(target, state); + let target = geometry + .hits + .iter() + .rev() + .find(|hit| hit.area.contains((mouse.column, mouse.row).into())) + .map(|hit| &hit.target); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && let Some(AddControlId::Text(field)) = target + && let Some(editable) = self.editables.get(field).copied() + && let Some(input) = self.inputs.get_mut(field) + { + let _ = editable.place_cursor(input, mouse.column, mouse.row); } - return None; + let ClickDispatch::Captured(outcome) = self.click.dispatch(mouse, target) else { + return None; + }; + let target = match outcome { + ClickOutcome::Activated(target) => target, + ClickOutcome::Armed => return Some(AddScreenEvent::Changed), + ClickOutcome::Ignored => return None, + }; + if !matches!( + target, + AddControlId::PickFocusedKind + | AddControlId::ToggleFocused + | AddControlId::NextField + | AddControlId::PreviousField + ) { + self.focus.set(target.clone()); + self.ensure_focus_visible(); + } + return self.activate(target, state); } let Event::Key(key) = event else { return None; @@ -427,7 +460,7 @@ impl AddScreenSession { // Ctrl+O is a no-op. (KeyCode::Char('o'), AddStage::Review) if state.review().is_some_and(|review| { - review.prompt_candidates().len() > PROMPT_LIST_PREVIEW_LIMIT + review.interpolate() && has_more_prompt_candidates(review) }) => { Some(AddScreenEvent::OpenPromptCandidates) @@ -446,13 +479,6 @@ impl AddScreenSession { self.ensure_focus_visible(); return Some(self.focus_event()); } - if key.code == KeyCode::Esc { - return Some(AddScreenEvent::Action(match state.stage() { - AddStage::Kind => AddAction::PickKind(None), - AddStage::ConfirmDraftDelete => AddAction::ConfirmDraftDelete(false), - _ => AddAction::Cancel, - })); - } if state.stage() == AddStage::Kind { match key.code { KeyCode::Up => self.kind_picker.select_prev(), @@ -463,6 +489,9 @@ impl AddScreenSession { let index = self.kind_picker.selected_index; return self.activate(AddControlId::Kind(index), state); } + KeyCode::Esc => { + return Some(AddScreenEvent::Action(AddAction::PickKind(None))); + } _ => return None, } self.focus @@ -475,10 +504,10 @@ impl AddScreenSession { return Some(AddScreenEvent::Action(AddAction::Continue)); } let input = self.inputs.get_mut(&field)?; - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.code == KeyCode::Char('d') - && state.stage() == AddStage::Source - { + if let (true, (KeyCode::Char('d'), AddStage::Source)) = ( + key.modifiers.contains(KeyModifiers::CONTROL), + (key.code, state.stage()), + ) { let _ = input.handle(InputRequest::DeleteNextChar); return Some(AddScreenEvent::Action(text_action(field, input.value()))); } @@ -490,14 +519,16 @@ impl AddScreenSession { let action = handle_select_key(&key, &mut self.storage); if let Some(SelectAction::Select(index)) = action { return Some(AddScreenEvent::Action(AddAction::SetReviewStorage( - if index == 0 { - StorageMode::Copy - } else { - StorageMode::Reference + match index { + 0 => StorageMode::Copy, + _ => StorageMode::Reference, }, ))); } - if action.is_some() || self.storage.is_open { + if action.is_some() { + return Some(AddScreenEvent::Changed); + } + if self.storage.is_open { return Some(AddScreenEvent::Changed); } } @@ -518,7 +549,10 @@ impl AddScreenSession { picked: true, })); } - if action.is_some() || self.runner.is_open { + if action.is_some() { + return Some(AddScreenEvent::Changed); + } + if self.runner.is_open { return Some(AddScreenEvent::Changed); } } @@ -532,6 +566,12 @@ impl AddScreenSession { return self.activate(id, state); } } + if key.code == KeyCode::Esc { + return Some(AddScreenEvent::Action(match state.stage() { + AddStage::ConfirmDraftDelete => AddAction::ConfirmDraftDelete(false), + _ => AddAction::Cancel, + })); + } if matches!(state.stage(), AddStage::Source | AddStage::Review) { match key.code { KeyCode::Down => { @@ -605,10 +645,9 @@ impl AddScreenSession { AddControlId::StorageOption(index) => { self.storage.select(index); Some(AddScreenEvent::Action(AddAction::SetReviewStorage( - if index == 0 { - StorageMode::Copy - } else { - StorageMode::Reference + match index { + 0 => StorageMode::Copy, + _ => StorageMode::Reference, }, ))) } @@ -671,7 +710,41 @@ impl AddScreenSession { self.focus.register(AddControlId::Text(field)); } + fn sync_input_value(&mut self, field: AddTextField, value: &str) { + let Some(input) = self.inputs.get_mut(&field) else { + return; + }; + if input.value() != value { + *input = LineInput::new(value.to_owned()); + } + } + fn sync_values(&mut self, state: &AddWorkflowState) { + match state.stage() { + AddStage::Source => { + let source = state.source(); + self.sync_input_value(AddTextField::SourcePath, &source.path); + self.sync_input_value(AddTextField::CommandTemplate, &source.command_template); + self.sync_input_value(AddTextField::CommandName, &source.command_name); + self.sync_input_value( + AddTextField::CommandDescription, + &source.command_description, + ); + } + AddStage::Review => { + let review = state + .review() + .expect("the typed Review stage owns its review state"); + self.sync_input_value(AddTextField::ReviewName, review.name()); + self.sync_input_value(AddTextField::ReviewDescription, review.description()); + self.sync_input_value(AddTextField::Dependencies, review.dependencies_text()); + self.sync_input_value(AddTextField::PythonConstraint, review.requires_python()); + } + AddStage::Kind + | AddStage::ConfirmDraftDelete + | AddStage::Complete + | AddStage::Cancelled => {} + } if let Some(review) = state.review() { for candidate in review.candidates() { if let Some(check) = self @@ -708,17 +781,14 @@ impl AddScreenSession { return; }; let row_end = row_start.saturating_add(row_height); - if row_start < self.scroll.scroll_offset() { - self.scroll.set_scroll_offset(row_start); - } else if row_end - > self - .scroll - .scroll_offset() - .saturating_add(self.visible_height) - { - self.scroll - .set_scroll_offset(row_end.saturating_sub(self.visible_height)); - } + let offset = self.scroll.scroll_offset(); + let aligned = match row_start.cmp(&offset) { + Ordering::Less => row_start, + Ordering::Equal | Ordering::Greater => { + offset.max(row_end.saturating_sub(self.visible_height)) + } + }; + self.scroll.set_scroll_offset(aligned); } } @@ -731,12 +801,21 @@ pub fn render_add( locale: Locale, ) -> AddScreenGeometry { session.sync(state); + session.editables.clear(); + if area.is_empty() { + session.viewport = area; + session.visible_height = 0; + session.footer_viewport = area; + session.footer_visible_height = 0; + return AddScreenGeometry { + body: area, + first_visible: session.scroll.scroll_offset(), + hits: Vec::new(), + }; + } let short = area.height < 14; let footer_height = if short { 1 } else { 2 }.min(area.height); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(footer_height)]) - .split(area); + let chunks = crate::viewport::Viewport::split_footer(area, footer_height); let title = match state.stage() { AddStage::Source => text(locale, "Add an entry").into_owned(), AddStage::Kind => state @@ -761,65 +840,61 @@ pub fn render_add( let body = body_block.inner(chunks[0]); frame.render_widget(body_block, chunks[0]); let rows = build_rows(state, locale); - let total_height = rows.iter().map(RenderRow::height).sum::(); + let total_height = rows.iter().map(|row| row.height(body.width)).sum::(); session .scroll .set_lines(vec![String::new(); total_height.max(1)]); session.viewport = body; - session.visible_height = usize::from(body.height).max(1); + session.visible_height = usize::from(body.height); session.row_spans.clear(); let mut logical = 0; for row in &rows { if let Some(id) = row.id() { session .row_spans - .insert(id.clone(), (logical, row.height())); - } - logical = logical.saturating_add(row.height()); + .insert(id.clone(), (logical, row.height(body.width))); + } + logical = logical.saturating_add(row.height(body.width)); + } + let focused = session.focus.current().cloned(); + let target = focused + .as_ref() + .and_then(|focused| session.row_spans.get(focused).copied()); + let reflow = target.map_or((total_height, 0, 0), |(start, height)| { + (total_height, start, height) + }); + let alignment_changed = focused.is_some_and(|focused| { + AlignmentSignature::update(&mut session.alignment, focused, body, reflow) + }); + if alignment_changed { + session.ensure_focus_visible(); } - session.ensure_focus_visible(); let maximum = total_height.saturating_sub(session.visible_height); - if session.scroll.scroll_offset() > maximum { - session.scroll.set_scroll_offset(maximum); - } + session + .scroll + .set_scroll_offset(session.scroll.scroll_offset().min(maximum)); let offset = session.scroll.scroll_offset(); let mut hits = Vec::new(); let mut row_start = 0_usize; - let mut select_overlays = Vec::new(); for row in &rows { - let height = row.height(); + let height = row.height(body.width); let row_end = row_start.saturating_add(height); - if row_end > offset && row_start < offset.saturating_add(session.visible_height) { - let clipped_top = offset.saturating_sub(row_start); + let visible_start = row_start.max(offset); + let visible_end = row_end.min(offset.saturating_add(session.visible_height)); + if let Some(visible_height) = NonZeroUsize::new(visible_end.saturating_sub(visible_start)) { + let clipped_top = visible_start.saturating_sub(row_start); let y = body.y.saturating_add( - u16::try_from(row_start.saturating_sub(offset)) + u16::try_from(visible_start.saturating_sub(offset)) .expect("the Add row starts inside its viewport"), ); - let visible_height = height.saturating_sub(clipped_top).min( - session - .visible_height - .saturating_sub(row_start.saturating_sub(offset)), - ); let rect = Rect::new( body.x, y, body.width, - u16::try_from(visible_height).expect("the Add row band fits its viewport"), - ); - let clip = RowClip::new( - height, - u16::try_from(clipped_top).expect("the row offset fits the visible item"), - rect, - ); - render_row( - frame, - clip, - row, - state, - session, - locale, - &mut select_overlays, + u16::try_from(visible_height.get()).expect("the Add row band fits its viewport"), ); + let clip = RowClip::new(height, clipped_top, rect); + render_row(frame, clip, row, state, session, locale); if let Some(id) = row.id() { hits.push(AddHitRegion { area: clip.area(), @@ -829,10 +904,19 @@ pub fn render_add( } row_start = row_end; } - for overlay in select_overlays { - hits.extend(overlay); - } hits.extend(render_footer(frame, chunks[1], state, session, locale)); + for select in [AddSelectControl::Storage, AddSelectControl::Runner] { + let Some(area) = hits + .iter() + .find(|hit| &hit.target == select.control_id()) + .map(|hit| hit.area) + else { + continue; + }; + hits.extend(render_select_overlay( + frame, area, select, state, session, locale, + )); + } AddScreenGeometry { body, first_visible: offset, @@ -845,15 +929,41 @@ enum RenderRow { Input(AddTextField, String), Button(AddControlId, String), Check(AddControlId, String), - Select(AddControlId, String), + Select(AddSelectControl, String), Note(String, Style), } +#[derive(Clone, Copy, Debug)] +enum AddSelectControl { + Storage, + Runner, +} + +impl AddSelectControl { + const fn control_id(self) -> &'static AddControlId { + match self { + Self::Storage => &STORAGE_ID, + Self::Runner => &RUNNER_ID, + } + } + + const fn option_id(self, index: usize) -> AddControlId { + match self { + Self::Storage => AddControlId::StorageOption(index), + Self::Runner => AddControlId::RunnerOption(index), + } + } +} + impl RenderRow { - const fn height(&self) -> usize { + fn height(&self, width: u16) -> usize { match self { Self::Input(..) | Self::Select(..) => 3, - Self::Button(..) | Self::Check(..) | Self::Note(..) => 1, + Self::Button(..) | Self::Check(..) => 1, + Self::Note(message, _) => Paragraph::new(message.as_str()) + .wrap(Wrap { trim: false }) + .line_count(width.max(1)) + .max(1), } } @@ -869,7 +979,8 @@ impl RenderRow { AddTextField::Dependencies => &DEPENDENCIES_ID, AddTextField::PythonConstraint => &PYTHON_ID, }), - Self::Button(id, _) | Self::Check(id, _) | Self::Select(id, _) => Some(id), + Self::Button(id, _) | Self::Check(id, _) => Some(id), + Self::Select(select, _) => Some(select.control_id()), Self::Note(..) => None, } } @@ -883,6 +994,8 @@ const REVIEW_NAME_ID: AddControlId = AddControlId::Text(AddTextField::ReviewName const REVIEW_DESCRIPTION_ID: AddControlId = AddControlId::Text(AddTextField::ReviewDescription); const DEPENDENCIES_ID: AddControlId = AddControlId::Text(AddTextField::Dependencies); const PYTHON_ID: AddControlId = AddControlId::Text(AddTextField::PythonConstraint); +const STORAGE_ID: AddControlId = AddControlId::Storage; +const RUNNER_ID: AddControlId = AddControlId::Runner; fn build_rows(state: &AddWorkflowState, locale: Locale) -> Vec { let mut rows = match state.stage() { @@ -944,9 +1057,9 @@ fn source_rows(state: &AddWorkflowState, locale: Locale) -> Vec { .map_or_else(String::new, |value| value.to_string_lossy().into_owned()), )); } - if source.draft_overflow() > 0 { + if let Some(overflow) = NonZeroUsize::new(source.draft_overflow()) { rows.push(RenderRow::Note( - format_text(locale, "…and {} more", &[&source.draft_overflow()]), + format_text(locale, "…and {} more", &[&overflow.get()]), Style::default().add_modifier(Modifier::DIM), )); } @@ -1061,9 +1174,9 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { text(locale, "Description").into_owned(), ), ]; - if !review.is_fresh() && review.lane() != ReviewLane::Executable { + if let (false, ReviewLane::Script | ReviewLane::Prompt) = (review.is_fresh(), review.lane()) { rows.push(RenderRow::Select( - AddControlId::Storage, + AddSelectControl::Storage, text(locale, "Storage mode").into_owned(), )); } @@ -1109,7 +1222,10 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { format_text(locale, "installs {}", &[dependency]) ))); } - if metadata.requires_python.is_empty() && metadata.dependencies.is_empty() { + if let (true, true) = ( + metadata.requires_python.is_empty(), + metadata.dependencies.is_empty(), + ) { rows.push(hint(text(locale, "(none declared)").into_owned())); } } @@ -1125,23 +1241,24 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { DependencySurface::Npm => {} DependencySurface::None => {} } - if let Some(count) = review.modeled_cli_field_count() - && count > 0 - { + if let Some(count) = review.modeled_cli_field_count().and_then(NonZeroUsize::new) { rows.push(RenderRow::Note( format_text( locale, - if count == 1 { + if count.get() == 1 { "✓ skit read this script's own arguments ({} field). Running it opens a form — nothing to memorize." } else { "✓ skit read this script's own arguments ({} fields). Running it opens a form — nothing to memorize." }, - &[&count], + &[&count.get()], ), Style::default().fg(Color::Green), )); } - if review.modeled_cli_field_count().is_none() && review.onboarding().uses_cli_framework() { + if let (None, true) = ( + review.modeled_cli_field_count(), + review.onboarding().uses_cli_framework(), + ) { rows.push(RenderRow::Note( format_text( locale, @@ -1158,7 +1275,8 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { )); for candidate in review.candidates() { let mut label = candidate.declaration.name.clone(); - if candidate.declaration.binding == skit_domain::parameters::ParameterBinding::Input { + if let skit_domain::parameters::ParameterBinding::Input = candidate.declaration.binding + { label = format!( "input() #{}: {}", candidate.declaration.order + 1, @@ -1207,7 +1325,8 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { locale, if review .modeled_cli_field_count() - .is_some_and(|count| count > 0) + .and_then(NonZeroUsize::new) + .is_some() { "Link the original: skit never writes to the file." } else { @@ -1278,7 +1397,7 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { }, )); } - if review.prompt_candidates().len() > review.prompt_preview().len() { + if has_more_prompt_candidates(review) { rows.push(RenderRow::Button( AddControlId::Continue, text(locale, "Choose variables…").into_owned(), @@ -1286,7 +1405,7 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { } } rows.push(RenderRow::Select( - AddControlId::Runner, + AddSelectControl::Runner, text(locale, "Prompt runner").into_owned(), )); rows.push(RenderRow::Button( @@ -1294,7 +1413,7 @@ fn review_rows(state: &AddWorkflowState, locale: Locale) -> Vec { format!("{} {}", text(locale, "Add"), text(locale, "Runner")), )); } - if review.lane() != ReviewLane::Executable { + if matches!(review.lane(), ReviewLane::Script | ReviewLane::Prompt) { rows.push(RenderRow::Button( AddControlId::EditSource, text(locale, "Edit").into_owned(), @@ -1314,13 +1433,12 @@ fn render_row( state: &AddWorkflowState, session: &mut AddScreenSession, locale: Locale, - overlays: &mut Vec>, ) { let area = clip.area(); match row { RenderRow::Input(field, label) => { - if let Some(input) = session.inputs.get(field) { - render_line_input_band( + if let Some(input) = session.inputs.get(field) + && let Some(editable) = render_line_input_band( frame, clip, input, @@ -1328,7 +1446,9 @@ fn render_row( session.focus.is_focused(&AddControlId::Text(*field)), label, None, - ); + ) + { + session.editables.insert(*field, editable); } } RenderRow::Button(id, label) => { @@ -1359,18 +1479,19 @@ fn render_row( frame.render_widget(CheckBox::new(label, check), area); } } - RenderRow::Select(id, label) => { - let (options, select_state) = if *id == AddControlId::Storage { - (storage_options(state, locale), &session.storage) - } else { - let mut options = vec![text(locale, "ask on the run form").into_owned()]; - options.extend( - state - .review() - .into_iter() - .flat_map(|review| review.runner_names().iter().cloned()), - ); - (options, &session.runner) + RenderRow::Select(select, label) => { + let (options, select_state) = match select { + AddSelectControl::Storage => (storage_options(state, locale), &session.storage), + AddSelectControl::Runner => { + let mut options = vec![text(locale, "ask on the run form").into_owned()]; + options.extend( + state + .review() + .into_iter() + .flat_map(|review| review.runner_names().iter().cloned()), + ); + (options, &session.runner) + } }; if clip.is_full() { let select = Select::new(&options, select_state).label(label); @@ -1397,37 +1518,70 @@ fn render_row( 0, ); } - if select_state.is_open { - let select = Select::new(&options, select_state).label(label); - let regions = select.render_dropdown(frame, area, frame.area()); - let mut dropdown_hits = Vec::new(); - for region in regions { - // `Select::render_dropdown` registers only option-selection regions. Find the - // typed option without accepting Focus/Open/Close regions from another seam. - let index = (0..options.len()) - .find(|index| region.data == SelectAction::Select(*index)) - .expect("a dropdown region owns one rendered option"); - dropdown_hits.push(AddHitRegion { - area: region.area, - target: if *id == AddControlId::Storage { - AddControlId::StorageOption(index) - } else { - AddControlId::RunnerOption(index) - }, - }); - } - overlays.push(dropdown_hits); - } } RenderRow::Note(message, style) => { clip.paint_paragraph( frame.buffer_mut(), - Paragraph::new(message.as_str()).style(*style), + Paragraph::new(message.as_str()) + .wrap(Wrap { trim: false }) + .style(*style), ); } } } +fn render_select_overlay( + frame: &mut Frame, + area: Rect, + select: AddSelectControl, + state: &AddWorkflowState, + session: &AddScreenSession, + locale: Locale, +) -> Vec { + let (options, select_state, label) = match select { + AddSelectControl::Storage => ( + storage_options(state, locale), + &session.storage, + text(locale, "Storage mode").into_owned(), + ), + AddSelectControl::Runner => { + let mut options = vec![text(locale, "ask on the run form").into_owned()]; + options.extend( + state + .review() + .into_iter() + .flat_map(|review| review.runner_names().iter().cloned()), + ); + ( + options, + &session.runner, + text(locale, "Prompt runner").into_owned(), + ) + } + }; + if !select_state.is_open { + return Vec::new(); + } + let first_visible = usize::from(select_state.scroll_offset); + Select::new(&options, select_state) + .label(&label) + .render_dropdown(frame, area, frame.area()) + .into_iter() + .zip(first_visible..) + .map(|(region, index)| AddHitRegion { + area: region.area, + target: select.option_id(index), + }) + .collect() +} + +fn has_more_prompt_candidates(review: &skit_ui::ReviewState) -> bool { + review + .prompt_candidates() + .get(review.prompt_preview().len()) + .is_some() +} + fn signature(state: &AddWorkflowState) -> AddSignature { AddSignature { stage: state.stage(), @@ -1571,13 +1725,11 @@ fn footer_chips(state: &AddWorkflowState, locale: Locale) -> Vec "Ctrl+E", text( locale, - if state - .review() - .is_some_and(|review| review.lane() == ReviewLane::Prompt) - { - "Edit prompt" - } else { - "Edit script" + match state.review().map(|review| review.lane()) { + Some(ReviewLane::Prompt) => "Edit prompt", + Some(ReviewLane::Script | ReviewLane::Executable) | None => { + "Edit script" + } }, ) .into_owned(), @@ -1633,13 +1785,20 @@ fn render_footer( let chips = footer_chips(state, locale); let (mut positioned, mut rows) = position_footer_chips(chips, area.width); let mut content_width = area.width; - if rows > usize::from(area.height) && area.width > 1 { - content_width = area.width.saturating_sub(1); + let mut indicator_column = None; + if let (Some(_overflow), Some(narrower)) = ( + NonZeroUsize::new(rows.saturating_sub(usize::from(area.height))), + NonZeroU16::new(area.width.saturating_sub(1)), + ) { + content_width = narrower.get(); + indicator_column = Some(area.right().saturating_sub(1)); (positioned, rows) = position_footer_chips(footer_chips(state, locale), content_width); } session.footer_visible_height = usize::from(area.height); session.footer_viewport = Rect::new(area.x, area.y, content_width, area.height); session.footer_scroll.set_lines(vec![String::new(); rows]); + crate::viewport::Viewport::new(session.footer_viewport, rows) + .clamp_scroll(&mut session.footer_scroll); let offset = session.footer_scroll.scroll_offset(); let end = offset.saturating_add(session.footer_visible_height); let mut hits = Vec::new(); @@ -1661,7 +1820,10 @@ fn render_footer( target: item.chip.target, }); } - if rows > session.footer_visible_height { + if let (Some(indicator_x), Some(_overflow)) = ( + indicator_column, + NonZeroUsize::new(rows.saturating_sub(session.footer_visible_height)), + ) { let indicator = if session.footer_scroll.is_at_top() { "↓" } else if session @@ -1674,7 +1836,7 @@ fn render_footer( }; frame.render_widget( Paragraph::new(indicator).style(Style::default().add_modifier(Modifier::DIM)), - Rect::new(area.right().saturating_sub(1), area.y, 1, 1), + Rect::new(indicator_x, area.y, 1, 1), ); } hits @@ -1699,7 +1861,7 @@ fn position_footer_chips( ) .unwrap_or(u16::MAX) .min(width); - if x > 0 && x.saturating_add(desired) > width { + if NonZeroU16::new(x.saturating_add(desired).saturating_sub(width)).is_some() { row = row.saturating_add(1); x = 0; } @@ -1715,13 +1877,13 @@ fn position_footer_chips( } fn storage_options(state: &AddWorkflowState, locale: Locale) -> Vec { - let reference = if state - .review() - .is_some_and(|review| review.lane() == ReviewLane::Prompt) - { - "Link the original — edits take effect immediately; skit never writes to the file" - } else { - "Link the original — edits take effect immediately, but skit won't write to the file, so parameter definitions are yours to maintain" + let reference = match state.review().map(|review| review.lane()) { + Some(ReviewLane::Prompt) => { + "Link the original — edits take effect immediately; skit never writes to the file" + } + Some(ReviewLane::Script | ReviewLane::Executable) | None => { + "Link the original — edits take effect immediately, but skit won't write to the file, so parameter definitions are yours to maintain" + } }; vec![ text( @@ -1822,6 +1984,28 @@ mod tests { Event::Key(KeyEvent::new(code, modifiers)) } + fn tab_until( + session: &mut AddScreenSession, + state: &AddWorkflowState, + geometry: &AddScreenGeometry, + description: &str, + reached: impl Fn(Option<&AddControlId>) -> bool, + ) { + let attempts = session.focus.elements().len().saturating_add(1); + let mut found = reached(session.focused()); + for _ in 0..attempts { + if found { + break; + } + let _ = session.handle_event(key(KeyCode::Tab, KeyModifiers::NONE), state, geometry); + found = reached(session.focused()); + } + assert!( + found, + "focus did not reach {description} in {attempts} Tab presses" + ); + } + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { kind, @@ -1831,6 +2015,28 @@ mod tests { }) } + fn click_control( + session: &mut AddScreenSession, + state: &AddWorkflowState, + geometry: &AddScreenGeometry, + column: u16, + row: u16, + ) -> Option { + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), column, row), + state, + geometry, + ), + Some(AddScreenEvent::Changed), + ); + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ) + } + fn ambiguous(path: &str, bytes: &[u8]) -> AddWorkflowState { let mut state = AddWorkflowState::new(Vec::new()); let _ = state.reduce(AddAction::SetSourcePath(path.to_owned())); @@ -1907,6 +2113,41 @@ mod tests { .collect() } + #[test] + fn focus_alignment_distinguishes_above_equal_and_below_row_start() { + let id = AddControlId::Cancel; + let mut session = AddScreenSession::default(); + session.focus.register(id.clone()); + session.focus.set(id.clone()); + session.row_spans.insert(id, (5, 3)); + session.visible_height = 2; + session.scroll.set_lines(vec![String::new(); 12]); + + session.scroll.set_scroll_offset(6); + session.ensure_focus_visible(); + assert_eq!( + session.scroll.scroll_offset(), + 5, + "a focused row above the viewport aligns its leading edge" + ); + + session.scroll.set_scroll_offset(5); + session.ensure_focus_visible(); + assert_eq!( + session.scroll.scroll_offset(), + 6, + "a row taller than the viewport tail-aligns when its start equals the offset" + ); + + session.scroll.set_scroll_offset(4); + session.ensure_focus_visible(); + assert_eq!( + session.scroll.scroll_offset(), + 6, + "a focused row below the viewport aligns its trailing edge" + ); + } + /// A short viewport can cut the top row from a bordered input. /// /// The surviving band starts with the value row. It must not restart the @@ -1945,11 +2186,10 @@ mod tests { render_row( frame, RowClip::new(3, 1, frame.area()), - &RenderRow::Select(AddControlId::Runner, "Prompt runner".to_owned()), + &RenderRow::Select(AddSelectControl::Runner, "Prompt runner".to_owned()), &state, &mut session, Locale::En, - &mut Vec::new(), ); }) .unwrap(); @@ -2021,14 +2261,14 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::BrowseSource) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: browse.area.x, - row: browse.area.y, - modifiers: KeyModifiers::NONE, - }); assert!(matches!( - session.handle_event(mouse, &state, &geometry), + click_control( + &mut session, + &state, + &geometry, + browse.area.x, + browse.area.y + ), Some(AddScreenEvent::OpenPathPicker(_)) )); assert_eq!( @@ -2046,14 +2286,14 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::NewScript) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: write_script.area.x, - row: write_script.area.y, - modifiers: KeyModifiers::NONE, - }); assert_eq!( - session.handle_event(mouse, &state, &geometry), + click_control( + &mut session, + &state, + &geometry, + write_script.area.x, + write_script.area.y, + ), Some(AddScreenEvent::Action(AddAction::NewDraft( DraftKind::Script ))) @@ -2092,6 +2332,36 @@ mod tests { ); } + #[test] + fn add_line_input_click_places_the_caret_before_typing() { + let mut state = AddWorkflowState::new(Vec::new()); + let _ = state.reduce(AddAction::SetSourcePath("abcdef".to_owned())); + let mut session = AddScreenSession::default(); + let (_, geometry) = draw(&state, &mut session, 80, 20); + let hit = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Text(AddTextField::SourcePath)) + .unwrap(); + let _ = click_control( + &mut session, + &state, + &geometry, + hit.area.x.saturating_add(3), + hit.area.y.saturating_add(1), + ); + assert_eq!( + session.handle_event( + key(KeyCode::Char('X'), KeyModifiers::NONE), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::SetSourcePath( + "abXcdef".to_owned() + ))) + ); + } + #[test] fn review_candidate_space_and_save_shortcut_emit_reducer_actions() { let state = source( @@ -2101,12 +2371,13 @@ mod tests { ); let mut session = AddScreenSession::default(); let (_, geometry) = draw(&state, &mut session, 80, 20); - while !matches!(session.focused(), Some(AddControlId::Candidate(_))) { - assert_eq!( - session.handle_event(key(KeyCode::Tab, KeyModifiers::NONE), &state, &geometry), - Some(AddScreenEvent::Changed) - ); - } + tab_until( + &mut session, + &state, + &geometry, + "a review candidate", + |focused| matches!(focused, Some(AddControlId::Candidate(_))), + ); assert!(matches!( session.handle_event( key(KeyCode::Char(' '), KeyModifiers::NONE), @@ -2126,9 +2397,9 @@ mod tests { ), Some(AddScreenEvent::Action(AddAction::Save)) ); - while session.focused() != Some(&AddControlId::Save) { - let _ = session.handle_event(key(KeyCode::Tab, KeyModifiers::NONE), &state, &geometry); - } + tab_until(&mut session, &state, &geometry, "Save", |focused| { + focused == Some(&AddControlId::Save) + }); assert_eq!( session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry), Some(AddScreenEvent::Action(AddAction::Save)), @@ -2234,14 +2505,8 @@ mod tests { .iter() .find(|hit| hit.target == target) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: hit.area.x, - row: hit.area.y, - modifiers: KeyModifiers::NONE, - }); assert_eq!( - session.handle_event(mouse, &state, &geometry), + click_control(&mut session, &state, &geometry, hit.area.x, hit.area.y), Some(expected) ); } @@ -2375,17 +2640,7 @@ mod tests { for hit in geometry.hits.clone() { assert!( - session - .handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y - ), - &state, - &geometry, - ) - .is_some(), + click_control(&mut session, &state, &geometry, hit.area.x, hit.area.y).is_some(), "visible source control has no mouse action: {:?}", hit.target ); @@ -2404,6 +2659,15 @@ mod tests { &state, &geometry, ); + let _ = session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + cancel.area.x, + cancel.area.y, + ), + &state, + &geometry, + ); assert_eq!( session.handle_event( key(KeyCode::Char('x'), KeyModifiers::NONE), @@ -2461,17 +2725,14 @@ mod tests { .find(|hit| hit.target == target) .unwrap(); assert!( - session - .handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y - ), - &state, - &confirm_geometry, - ) - .is_some() + click_control( + &mut session, + &state, + &confirm_geometry, + hit.area.x, + hit.area.y, + ) + .is_some() ); } assert!(matches!( @@ -2504,14 +2765,12 @@ mod tests { ); } for hit in kind_geometry.hits.clone() { - let _ = kind_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y, - ), + let _ = click_control( + &mut kind_session, &kind_state, &kind_geometry, + hit.area.x, + hit.area.y, ); } } @@ -2547,15 +2806,7 @@ mod tests { let mut session = AddScreenSession::default(); let (_, geometry) = draw(&state, &mut session, 80, 55); for hit in geometry.hits.clone() { - let result = session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y, - ), - &state, - &geometry, - ); + let result = click_control(&mut session, &state, &geometry, hit.area.x, hit.area.y); if hit.target != AddControlId::ToggleFocused { assert!(result.is_some(), "review hit is inert: {:?}", hit.target); } @@ -2566,14 +2817,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Interpolate) .unwrap(); - let _ = session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - interpolate.area.x, - interpolate.area.y, - ), + let _ = click_control( + &mut session, &state, &geometry, + interpolate.area.x, + interpolate.area.y, ); let toggle = geometry .hits @@ -2581,17 +2830,14 @@ mod tests { .find(|hit| hit.target == AddControlId::ToggleFocused) .unwrap(); assert!( - session - .handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.area.x, - toggle.area.y, - ), - &state, - &geometry, - ) - .is_some() + click_control( + &mut session, + &state, + &geometry, + toggle.area.x, + toggle.area.y, + ) + .is_some() ); for target in [ @@ -2612,9 +2858,9 @@ mod tests { ] { let _ = session.activate(target, &state); } - while session.focused() != Some(&AddControlId::Runner) { - let _ = session.handle_event(key(KeyCode::Tab, KeyModifiers::NONE), &state, &geometry); - } + tab_until(&mut session, &state, &geometry, "Runner", |focused| { + focused == Some(&AddControlId::Runner) + }); for code in [KeyCode::Enter, KeyCode::Down, KeyCode::Up, KeyCode::Esc] { let _ = session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry); } @@ -2628,14 +2874,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Storage) .unwrap(); - let _ = python_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - storage.area.x, - storage.area.y, - ), + let _ = click_control( + &mut python_session, &python, &python_geometry, + storage.area.x, + storage.area.y, ); for code in [KeyCode::Up, KeyCode::Enter] { let _ = python_session.handle_event( @@ -2665,14 +2909,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Storage) .unwrap(); - let _ = copy_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - storage.area.x, - storage.area.y, - ), + let _ = click_control( + &mut copy_session, ©_python, ©_geometry, + storage.area.x, + storage.area.y, ); let _ = copy_session.handle_event( key(KeyCode::Down, KeyModifiers::NONE), @@ -2787,14 +3029,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Text(field)) .unwrap(); - let _ = analyzer_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y, - ), + let _ = click_control( + &mut analyzer_session, &analyzer, &geometry, + hit.area.x, + hit.area.y, ); assert!(matches!( analyzer_session.handle_event( @@ -2851,14 +3091,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Runner) .unwrap(); - let _ = prompt_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - runner.area.x, - runner.area.y, - ), + let _ = click_control( + &mut prompt_session, &prompt, &closed, + runner.area.x, + runner.area.y, ); assert_eq!(prompt_session.focused(), Some(&AddControlId::Runner)); for code in [KeyCode::Down, KeyCode::Enter] { @@ -2884,17 +3122,14 @@ mod tests { .filter(|hit| matches!(hit.target, AddControlId::RunnerOption(_))) { assert!( - prompt_session - .handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - option.area.x, - option.area.y, - ), - &prompt, - &open, - ) - .is_some() + click_control( + &mut prompt_session, + &prompt, + &open, + option.area.x, + option.area.y, + ) + .is_some() ); } @@ -2904,6 +3139,15 @@ mod tests { assert!(footer_chips(&cancelled, Locale::En).is_empty()); let mut cancelled_session = AddScreenSession::default(); let (_, cancelled_geometry) = draw(&cancelled, &mut cancelled_session, 1, 1); + assert_eq!( + cancelled_session.handle_event( + key(KeyCode::Tab, KeyModifiers::NONE), + &cancelled, + &cancelled_geometry, + ), + Some(AddScreenEvent::Changed) + ); + assert_eq!(cancelled_session.focused(), None); assert_eq!( cancelled_session.handle_event( key(KeyCode::Char('x'), KeyModifiers::NONE), @@ -2944,14 +3188,12 @@ mod tests { .iter() .find(|hit| hit.target == AddControlId::Text(field)) .unwrap(); - let _ = command_session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.area.x, - hit.area.y, - ), + let _ = click_control( + &mut command_session, &command, &command_geometry, + hit.area.x, + hit.area.y, ); assert!(matches!( command_session.handle_event( @@ -2991,6 +3233,360 @@ mod tests { assert!(text_of(&terminal).contains("secret")); } + #[test] + fn add_review_selects_route_keys_and_overlays_to_their_rendered_owner() { + let mut runner_names = vec!["alpha".to_owned(), "beta".to_owned()]; + runner_names.extend((2..12).map(|index| format!("runner-{index}"))); + let defaults = ReviewDefaults { + runner_names, + ..ReviewDefaults::default() + }; + let state = AddWorkflowState::from_review(ReviewState::from_source( + SourceSnapshot { + path: "task.prompt.md".into(), + source_record: "task.prompt.md".to_owned(), + bytes: b"Hello {{name}}".to_vec(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft: false, + identity: None, + }, + KnownEntryKind::Prompt, + defaults, + )); + let mut session = AddScreenSession::default(); + let (_, closed) = draw(&state, &mut session, 96, 40); + + let storage = closed + .hits + .iter() + .find(|hit| hit.target == AddControlId::Storage) + .expect("the rendered review owns its Storage select") + .area; + tab_until(&mut session, &state, &closed, "Storage mode", |focused| { + focused == Some(&AddControlId::Storage) + }); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Changed) + ); + let (_, keyboard_open) = draw(&state, &mut session, 96, 40); + assert!( + keyboard_open + .hits + .iter() + .any(|hit| matches!(hit.target, AddControlId::StorageOption(_))) + ); + assert_eq!( + session.handle_event( + key(KeyCode::Esc, KeyModifiers::NONE), + &state, + &keyboard_open + ), + Some(AddScreenEvent::Changed) + ); + assert!(click_control(&mut session, &state, &closed, storage.x, storage.y).is_some()); + assert_eq!( + session.handle_event(key(KeyCode::Home, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Action(AddAction::SetReviewStorage( + StorageMode::Copy + ))) + ); + assert!(click_control(&mut session, &state, &closed, storage.x, storage.y).is_some()); + assert_eq!( + session.handle_event(key(KeyCode::End, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Action(AddAction::SetReviewStorage( + StorageMode::Reference + ))) + ); + + let runner = closed + .hits + .iter() + .find(|hit| hit.target == AddControlId::Runner) + .expect("the rendered review owns its Runner select") + .area; + assert!(click_control(&mut session, &state, &closed, runner.x, runner.y).is_some()); + assert_eq!( + session.handle_event(key(KeyCode::Home, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Action(AddAction::SetPromptRunner { + name: String::new(), + picked: true, + })) + ); + assert!(click_control(&mut session, &state, &closed, runner.x, runner.y).is_some()); + assert_eq!( + session.handle_event(key(KeyCode::Down, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &closed), + Some(AddScreenEvent::Action(AddAction::SetPromptRunner { + name: "alpha".to_owned(), + picked: true, + })) + ); + assert!(click_control(&mut session, &state, &closed, runner.x, runner.y).is_some()); + tab_until(&mut session, &state, &closed, "Add Runner", |focused| { + focused == Some(&AddControlId::NewRunner) + }); + assert_eq!( + session.handle_event(key(KeyCode::Char('x'), KeyModifiers::NONE), &state, &closed,), + None, + "a non-Runner control must not inherit an open Runner select" + ); + + for (owner, expected_options) in [ + ( + AddControlId::Storage, + vec![(AddControlId::StorageOption(0), 0, "Keep a copy")], + ), + ( + AddControlId::Runner, + vec![(AddControlId::RunnerOption(1), 1, "alpha")], + ), + ] { + let mut overlay_session = AddScreenSession::default(); + let (_, base) = draw(&state, &mut overlay_session, 96, 40); + let anchor = base + .hits + .iter() + .find(|hit| hit.target == owner) + .expect("the select anchor is rendered") + .area; + assert!( + click_control(&mut overlay_session, &state, &base, anchor.x, anchor.y,).is_some() + ); + let (terminal, open) = draw(&state, &mut overlay_session, 96, 40); + for (target, index, label) in expected_options { + let option = open + .hits + .iter() + .find(|hit| hit.target == target) + .expect("the open select owns the typed option") + .area; + assert_eq!(option.x, anchor.x.saturating_add(1)); + assert_eq!( + option.y, + anchor + .bottom() + .saturating_add(1) + .saturating_add(u16::try_from(index).unwrap()) + ); + assert_eq!(option.width, anchor.width.saturating_sub(2)); + assert!(hit_text(&terminal, option).contains(label)); + } + } + + let mut scrolled = AddScreenSession::default(); + let (_, base) = draw(&state, &mut scrolled, 96, 40); + let runner = base + .hits + .iter() + .find(|hit| hit.target == AddControlId::Runner) + .expect("the Runner select is rendered") + .area; + assert!(click_control(&mut scrolled, &state, &base, runner.x, runner.y).is_some()); + assert_eq!( + scrolled.handle_event(key(KeyCode::End, KeyModifiers::NONE), &state, &base), + Some(AddScreenEvent::Changed) + ); + let (terminal, open) = draw(&state, &mut scrolled, 96, 40); + let last = open + .hits + .iter() + .find(|hit| hit.target == AddControlId::RunnerOption(12)) + .expect("the scrolled dropdown owns its last semantic option") + .area; + assert!(hit_text(&terminal, last).contains("runner-11")); + assert_eq!( + click_control(&mut scrolled, &state, &open, last.x, last.y), + Some(AddScreenEvent::Action(AddAction::SetPromptRunner { + name: "runner-11".to_owned(), + picked: true, + })) + ); + } + + #[test] + fn add_review_body_rows_are_visible_positive_and_non_overlapping() { + let defaults = ReviewDefaults { + runner_names: vec!["alpha".to_owned()], + ..ReviewDefaults::default() + }; + let state = AddWorkflowState::from_review(ReviewState::from_source( + SourceSnapshot { + path: "task.prompt.md".into(), + source_record: "task.prompt.md".to_owned(), + bytes: b"Hello {{name}}".to_vec(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft: false, + identity: None, + }, + KnownEntryKind::Prompt, + defaults, + )); + let mut session = AddScreenSession::default(); + let (terminal, geometry) = draw(&state, &mut session, 96, 40); + let expected = [ + AddControlId::Text(AddTextField::ReviewName), + AddControlId::Text(AddTextField::ReviewDescription), + AddControlId::Storage, + AddControlId::Interpolate, + AddControlId::PromptCandidate("name".to_owned()), + AddControlId::Runner, + AddControlId::NewRunner, + AddControlId::EditSource, + ]; + let mut previous_bottom = geometry.body.y; + for target in expected { + let hit = geometry + .hits + .iter() + .find(|hit| hit.target == target) + .unwrap_or_else(|| panic!("missing visible body row: {target:?}")); + assert!(hit.area.width > 0 && hit.area.height > 0, "{target:?}"); + assert!( + geometry.body.contains((hit.area.x, hit.area.y).into()), + "{target:?}" + ); + assert!(hit.area.bottom() <= geometry.body.bottom(), "{target:?}"); + assert!( + hit.area.y >= previous_bottom, + "rows overlap before {target:?}" + ); + assert!( + !hit_text(&terminal, hit.area).trim().is_empty(), + "{target:?}" + ); + previous_bottom = hit.area.bottom(); + } + } + + #[test] + fn add_recompose_cancels_changed_option_identity_but_preserves_equal_identity() { + let state_with_runner = |runner: &str| { + AddWorkflowState::from_review(ReviewState::from_source( + SourceSnapshot { + path: "task.prompt.md".into(), + source_record: "task.prompt.md".to_owned(), + bytes: b"Hello".to_vec(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft: false, + identity: None, + }, + KnownEntryKind::Prompt, + ReviewDefaults { + runner_names: vec![runner.to_owned()], + ..ReviewDefaults::default() + }, + )) + }; + + let alpha = state_with_runner("alpha"); + let beta = state_with_runner("beta"); + let mut changed = AddScreenSession::default(); + let (_, closed) = draw(&alpha, &mut changed, 80, 30); + let runner = closed + .hits + .iter() + .find(|hit| hit.target == AddControlId::Runner) + .unwrap() + .area; + assert!(click_control(&mut changed, &alpha, &closed, runner.x, runner.y).is_some()); + let (_, alpha_open) = draw(&alpha, &mut changed, 80, 30); + let alpha_option = alpha_open + .hits + .iter() + .find(|hit| hit.target == AddControlId::RunnerOption(1)) + .unwrap() + .area; + assert_eq!( + changed.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + alpha_option.x, + alpha_option.y, + ), + &alpha, + &alpha_open, + ), + Some(AddScreenEvent::Changed) + ); + let (_, beta_open) = draw(&beta, &mut changed, 80, 30); + assert_eq!( + changed.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + alpha_option.x, + alpha_option.y, + ), + &beta, + &beta_open, + ), + None, + "a press on alpha must not activate beta after the runner inventory changes" + ); + + let mut equal = AddScreenSession::default(); + let (_, closed) = draw(&alpha, &mut equal, 80, 30); + let runner = closed + .hits + .iter() + .find(|hit| hit.target == AddControlId::Runner) + .unwrap() + .area; + assert!(click_control(&mut equal, &alpha, &closed, runner.x, runner.y).is_some()); + let (_, before) = draw(&alpha, &mut equal, 80, 30); + let option = before + .hits + .iter() + .find(|hit| hit.target == AddControlId::RunnerOption(1)) + .unwrap() + .area; + assert_eq!( + equal.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), option.x, option.y), + &alpha, + &before, + ), + Some(AddScreenEvent::Changed) + ); + let (_, after) = draw(&alpha, &mut equal, 80, 30); + assert_eq!( + equal.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), option.x, option.y), + &alpha, + &after, + ), + Some(AddScreenEvent::Action(AddAction::SetPromptRunner { + name: "alpha".to_owned(), + picked: true, + })), + "an equal semantic rerender must preserve a valid matching release" + ); + } + #[test] fn add_footer_clamps_after_growth_and_empty_inventory_has_no_rows() { let (empty, rows) = position_footer_chips(Vec::new(), 20); diff --git a/crates/skit-tui/src/screens/library.rs b/crates/skit-tui/src/screens/library.rs index 475a4a3c..4e01c342 100644 --- a/crates/skit-tui/src/screens/library.rs +++ b/crates/skit-tui/src/screens/library.rs @@ -7,7 +7,7 @@ use ratatui_core::{ text::{Line, Span}, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }; use ratatui_interact::{ components::{ @@ -22,12 +22,13 @@ use ratatui_widgets::{ use skit_domain::{EntrySummary, StorageMode}; use skit_i18n::{Locale, format_text, kind_label, text}; use skit_ui::{ - DetailPaneMode, LibraryEntryDetail, LibraryLastRun, LibraryPromptRunner, LibraryRunAge, + Action, DetailPaneMode, LibraryEntryDetail, LibraryLastRun, LibraryPromptRunner, LibraryRunAge, LibraryState, }; use crate::{ ViewGeometry, + pointer::contains, theme::{ACCENT, BOX_GREEN, BOX_INDIGO, SELECT_BG, SELECT_FG, padded_panel, panel_block}, }; @@ -37,11 +38,26 @@ enum LibraryPane { Detail, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LibraryClickTarget { + Row(usize), + Detail, +} + +/// Result of one pointer event owned by the Library body. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum LibraryPointerHandling { + Action(Action), + Consumed, + Ignored, +} + /// Persistent focus and scroll state for the Library panes. #[derive(Debug)] pub(crate) struct LibraryScreenSession { detail_scroll: ScrollableContentState, detail_area: Rect, + detail_hit_area: Rect, detail_height: usize, focus: FocusManager, detail_signature: Option<(Option, u16)>, @@ -54,6 +70,7 @@ impl Default for LibraryScreenSession { Self { detail_scroll: ScrollableContentState::empty(), detail_area: Rect::default(), + detail_hit_area: Rect::default(), detail_height: 0, focus, detail_signature: None, @@ -71,12 +88,12 @@ impl LibraryScreenSession { ) -> ViewGeometry { let narrow = crate::layout::is_narrow(area.width); let short = crate::layout::is_short(frame.area().height); - let show_detail = match state.detail_pane_mode() { + let detail_requested = match state.detail_pane_mode() { DetailPaneMode::PinnedOpen => true, DetailPaneMode::PinnedClosed => false, DetailPaneMode::Automatic => !narrow || !short, }; - let panes = if !show_detail { + let panes = if !detail_requested { Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(100), Constraint::Length(0)]) @@ -92,6 +109,10 @@ impl LibraryScreenSession { .constraints([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]) .split(area) }; + let show_detail = detail_requested && !panes[1].is_empty(); + if !show_detail { + self.focus.set(LibraryPane::List); + } let list_block = panel_block(text(locale, "Library").into_owned(), BOX_GREEN); let table_inner = list_block.inner(panes[0]); @@ -169,6 +190,73 @@ impl LibraryScreenSession { } } + /// Route one wheel event to the Library pane under the pointer. + pub(crate) fn handle_wheel( + &mut self, + mouse: &MouseEvent, + geometry: &ViewGeometry, + ) -> LibraryPointerHandling { + if contains(self.detail_area, mouse.column, mouse.row) { + self.focus.set(LibraryPane::Detail); + let _ = handle_scrollable_content_mouse( + &mut self.detail_scroll, + mouse, + self.detail_area, + self.detail_height, + ); + return LibraryPointerHandling::Consumed; + } + if contains(geometry.rows, mouse.column, mouse.row) { + self.focus.set(LibraryPane::List); + return LibraryPointerHandling::Action(if mouse.kind == MouseEventKind::ScrollUp { + Action::Previous + } else { + Action::Next + }); + } + LibraryPointerHandling::Ignored + } + + /// Return the semantic Library target under one pointer coordinate. + pub(crate) fn click_target( + &self, + mouse: &MouseEvent, + geometry: &ViewGeometry, + ) -> Option { + if contains(self.detail_hit_area, mouse.column, mouse.row) { + Some(LibraryClickTarget::Detail) + } else if contains(geometry.rows, mouse.column, mouse.row) { + let index = geometry + .first_visible + .saturating_add(usize::from(mouse.row.saturating_sub(geometry.rows.y))); + Some(LibraryClickTarget::Row(index)) + } else { + None + } + } + + /// Activate a semantic Library target after the shared tracker matches its release. + pub(crate) fn activate_click( + &mut self, + target: LibraryClickTarget, + state: &LibraryState, + ) -> Option { + match target { + LibraryClickTarget::Detail => { + self.focus.set(LibraryPane::Detail); + None + } + LibraryClickTarget::Row(index) => { + self.focus.set(LibraryPane::List); + Some(if state.selected_visible_index() == Some(index) { + Action::OpenRun + } else { + Action::SelectVisible(index) + }) + } + } + } + fn render_detail( &mut self, frame: &mut Frame, @@ -177,8 +265,9 @@ impl LibraryScreenSession { state: &LibraryState, locale: Locale, ) { - if area.width == 0 || area.height == 0 { + if area.is_empty() { self.detail_area = Rect::default(); + self.detail_hit_area = Rect::default(); self.detail_height = 0; return; } @@ -195,10 +284,10 @@ impl LibraryScreenSession { .set_lines(vec![String::new(); line_count]); self.detail_height = usize::from(inner.height); self.detail_area = inner; + self.detail_hit_area = area; let maximum = line_count.saturating_sub(self.detail_height); - if self.detail_scroll.scroll_offset() > maximum { - self.detail_scroll.set_scroll_offset(maximum); - } + self.detail_scroll + .set_scroll_offset(self.detail_scroll.scroll_offset().min(maximum)); let indicator = match ( self.detail_scroll.is_at_top(), self.detail_scroll.is_at_bottom(self.detail_height), @@ -222,40 +311,9 @@ impl LibraryScreenSession { ); } - /// Dispatch detail-pane focus and scrolling through the mature scroll component. + /// Dispatch detail-pane keyboard scrolling through the mature scroll component. pub(crate) fn handle_event(&mut self, event: &Event) -> bool { match event { - Event::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => - { - if !self.detail_area.contains((mouse.column, mouse.row).into()) { - return false; - } - self.focus.set(LibraryPane::Detail); - handle_scrollable_content_mouse( - &mut self.detail_scroll, - mouse, - self.detail_area, - self.detail_height, - ) - .is_some() - } - Event::Mouse(mouse) - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && self.detail_area.contains((mouse.column, mouse.row).into()) => - { - self.focus.set(LibraryPane::Detail); - true - } - Event::Mouse(mouse) - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) => - { - self.focus.set(LibraryPane::List); - false - } Event::Key(key) if key.kind != KeyEventKind::Release && self.focus.is_focused(&LibraryPane::Detail) @@ -514,3 +572,56 @@ fn supports_modes(kind: &str) -> bool { | "prompt" ) } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui_core::{backend::TestBackend, terminal::Terminal}; + use ratatui_crossterm::crossterm::event::KeyEvent; + use skit_application::LibraryScan; + use skit_domain::{EntryKind, Slug}; + + #[test] + fn a_zero_area_detail_is_not_visible_and_releases_keyboard_focus() { + let state = LibraryState::from_scan(LibraryScan { + entries: vec![EntrySummary { + slug: Slug::parse("entry").unwrap(), + name: "Entry".to_owned(), + kind: EntryKind::parse("python").unwrap(), + mode: StorageMode::Copy, + description: "Detail".to_owned(), + target: None, + }], + diagnostics: Vec::new(), + }); + let mut session = LibraryScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(100, 18)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &state, Locale::En); + }) + .unwrap(); + assert_eq!( + session.activate_click(LibraryClickTarget::Detail, &state), + None + ); + + let mut geometry = ViewGeometry::default(); + terminal + .draw(|frame| { + geometry = session.render(frame, Rect::new(0, 0, 100, 0), &state, Locale::En); + }) + .unwrap(); + assert!( + !session.handle_event(&Event::Key(KeyEvent::new( + KeyCode::Down, + KeyModifiers::NONE, + ))), + "a zero-area detail consumed Library-list navigation" + ); + assert!( + !geometry.detail_pane_visible, + "a zero-area detail was advertised as visible" + ); + } +} diff --git a/crates/skit-tui/src/screens/management.rs b/crates/skit-tui/src/screens/management.rs index d28874c6..b0e3d345 100644 --- a/crates/skit-tui/src/screens/management.rs +++ b/crates/skit-tui/src/screens/management.rs @@ -7,14 +7,13 @@ use ratatui_core::{ text::Line, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui_interact::{ components::{ ListPicker, ListPickerState, ListPickerStyle, ScrollableContentState, handle_scrollable_content_key, handle_scrollable_content_mouse, }, - state::FocusManager, traits::ClickRegionRegistry, }; use ratatui_widgets::{clear::Clear, paragraph::Paragraph, paragraph::Wrap}; @@ -32,6 +31,7 @@ use crate::{ ActionFooterItem, ActionFooterMouse, ActionFooterSession, ActionFooterStyle, action_footer_required_height, }, + pointer::{ClickOutcome, ClickTracker, EditableGeometry}, session::render_line_input, theme::{ACCENT, BOX_DIM, BOX_GREEN, BOX_MAROON, padded_panel}, }; @@ -49,7 +49,22 @@ pub(crate) enum HealthEventHandling { Ignored, } -#[derive(Clone, Debug)] +fn cancels_pointer_press(event: &Event) -> bool { + match event { + Event::Resize(_, _) => true, + Event::Mouse(mouse) => matches!( + mouse.kind, + MouseEventKind::Drag(_) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight + ), + Event::FocusGained | Event::FocusLost | Event::Key(_) | Event::Paste(_) => false, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] enum HealthHit { Issue(usize), } @@ -63,11 +78,18 @@ pub(crate) struct HealthScreenSession { summary_height: usize, issue_height: usize, clicks: ClickRegionRegistry, + click: ClickTracker, issue_areas: Vec<(usize, Rect)>, footer: ActionFooterSession, } impl HealthScreenSession { + /// Cancel armed issue and footer targets before a pointer discontinuity. + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + self.footer.cancel_click(); + } + /// Render the complete actionable Health report. pub(crate) fn render( &mut self, @@ -214,14 +236,13 @@ impl HealthScreenSession { .cloned() .map(|problem| Line::styled(problem, Style::default().fg(Color::Yellow))), ); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let content_height = paragraph.line_count(area.width.max(1)); self.summary_scroll - .set_lines(vec![String::new(); lines.len()]); - let maximum = lines.len().saturating_sub(self.summary_height); - if self.summary_scroll.scroll_offset() > maximum { - self.summary_scroll.set_scroll_offset(maximum); - } + .set_lines(vec![String::new(); content_height]); + crate::viewport::Viewport::new(area, content_height).clamp_scroll(&mut self.summary_scroll); frame.render_widget( - Paragraph::new(lines).wrap(Wrap { trim: false }).scroll(( + paragraph.scroll(( u16::try_from(self.summary_scroll.scroll_offset()).unwrap_or(u16::MAX), 0, )), @@ -264,6 +285,9 @@ impl HealthScreenSession { /// Dispatch keyboard and mouse through mature list/scroll state. pub(crate) fn handle_event(&mut self, event: Event, view: &HealthView) -> HealthEventHandling { + if cancels_pointer_press(&event) { + self.cancel_click(); + } match event { Event::Key(key) if key.kind != KeyEventKind::Release => { if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) { @@ -300,15 +324,27 @@ impl HealthScreenSession { HealthEventHandling::Ignored } } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - if let ActionFooterMouse::Action(action) = self.footer.handle_mouse(&mouse) { - return HealthEventHandling::Action(action); + Event::Mouse(mouse) + if matches!(mouse.kind, MouseEventKind::Down(_) | MouseEventKind::Up(_)) => + { + match self.footer.handle_mouse(&mouse) { + ActionFooterMouse::Action(action) => { + self.click.cancel(); + return HealthEventHandling::Action(action); + } + ActionFooterMouse::Armed => { + self.click.cancel(); + return HealthEventHandling::Consumed; + } + ActionFooterMouse::Scrolled | ActionFooterMouse::Ignored => {} } - match self.clicks.handle_click(mouse.column, mouse.row).cloned() { - Some(HealthHit::Issue(index)) => { + let target = self.clicks.handle_click(mouse.column, mouse.row); + match self.click.update(&mouse, target) { + ClickOutcome::Armed => HealthEventHandling::Consumed, + ClickOutcome::Activated(HealthHit::Issue(index)) => { HealthEventHandling::Action(HealthAction::ActivateIssue(index)) } - None => HealthEventHandling::Ignored, + ClickOutcome::Ignored => HealthEventHandling::Ignored, } } Event::Mouse(mouse) @@ -321,6 +357,7 @@ impl HealthScreenSession { self.footer.handle_mouse(&mouse), ActionFooterMouse::Scrolled ) { + self.click.cancel(); HealthEventHandling::Consumed } else if self .issue_areas @@ -370,7 +407,7 @@ pub(crate) enum RunnerEditorEventHandling { Ignored, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] enum RunnerEditorHit { Field(RunnerEditorField), } @@ -380,13 +417,21 @@ enum RunnerEditorHit { pub(crate) struct RunnerEditorSession { name: LineInput, command: LineInput, - focus: FocusManager, signature: Option<(String, String, bool)>, clicks: ClickRegionRegistry, + click: ClickTracker, + name_editable: Option, + command_editable: Option, footer: ActionFooterSession, } impl RunnerEditorSession { + /// Cancel armed field and footer targets before a pointer discontinuity. + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + self.footer.cancel_click(); + } + /// Render the shared editor as a modal overlay. pub(crate) fn render( &mut self, @@ -426,7 +471,7 @@ impl RunnerEditorSession { } else { text(locale, "Name, e.g. aider").into_owned() }; - render_line_input( + self.name_editable = render_line_input( frame, name, &self.name, @@ -434,7 +479,7 @@ impl RunnerEditorSession { view.focused() == RunnerEditorField::Name, &name_label, ); - render_line_input( + self.command_editable = render_line_input( frame, command, &self.command, @@ -481,6 +526,9 @@ impl RunnerEditorSession { view: &RunnerEditorView, ) -> RunnerEditorEventHandling { self.sync(view); + if cancels_pointer_press(&event) { + self.cancel_click(); + } match event { Event::Key(key) if key.kind != KeyEventKind::Release => { match key.code { @@ -538,37 +586,62 @@ impl RunnerEditorSession { } }) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - if let ActionFooterMouse::Action(action) = self.footer.handle_mouse(&mouse) { - return RunnerEditorEventHandling::Action(action); - } - match self.clicks.handle_click(mouse.column, mouse.row).cloned() { - Some(RunnerEditorHit::Field(field)) => { - RunnerEditorEventHandling::Action(RunnerEditorAction::Focus(field)) + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::Down(_) | MouseEventKind::Up(_) => { + match self.footer.handle_mouse(&mouse) { + ActionFooterMouse::Action(action) => { + self.click.cancel(); + return RunnerEditorEventHandling::Action(action); + } + ActionFooterMouse::Armed => { + self.click.cancel(); + return RunnerEditorEventHandling::Consumed; + } + ActionFooterMouse::Scrolled | ActionFooterMouse::Ignored => {} + } + let target = self.clicks.handle_click(mouse.column, mouse.row); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && let Some(RunnerEditorHit::Field(field)) = target + { + let editable = match field { + RunnerEditorField::Name => self.name_editable, + RunnerEditorField::Command => self.command_editable, + }; + let input = match field { + RunnerEditorField::Name => &mut self.name, + RunnerEditorField::Command => &mut self.command, + }; + if let Some(editable) = editable { + let _ = editable.place_cursor(input, mouse.column, mouse.row); + } + } + match self.click.update(&mouse, target) { + ClickOutcome::Armed => RunnerEditorEventHandling::Consumed, + ClickOutcome::Activated(RunnerEditorHit::Field(field)) => { + RunnerEditorEventHandling::Action(RunnerEditorAction::Focus(field)) + } + ClickOutcome::Ignored => RunnerEditorEventHandling::Ignored, } - None => RunnerEditorEventHandling::Ignored, } - } - Event::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => - { - if matches!( - self.footer.handle_mouse(&mouse), - ActionFooterMouse::Scrolled - ) { - RunnerEditorEventHandling::Consumed - } else { - RunnerEditorEventHandling::Ignored + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + if matches!( + self.footer.handle_mouse(&mouse), + ActionFooterMouse::Scrolled + ) { + self.click.cancel(); + RunnerEditorEventHandling::Consumed + } else { + RunnerEditorEventHandling::Ignored + } } + MouseEventKind::Moved + | MouseEventKind::Drag(_) + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => RunnerEditorEventHandling::Ignored, + }, + Event::FocusGained | Event::FocusLost | Event::Key(_) | Event::Resize(_, _) => { + RunnerEditorEventHandling::Ignored } - Event::FocusGained - | Event::FocusLost - | Event::Mouse(_) - | Event::Key(_) - | Event::Resize(_, _) => RunnerEditorEventHandling::Ignored, } } @@ -583,12 +656,6 @@ impl RunnerEditorSession { self.command = LineInput::new(view.command().to_owned()); self.signature = Some(signature); } - self.focus.clear(); - if !view.name_is_locked() { - self.focus.register(RunnerEditorField::Name); - } - self.focus.register(RunnerEditorField::Command); - self.focus.set(view.focused()); } } @@ -603,7 +670,7 @@ pub(crate) enum RunnerManagerEventHandling { Ignored, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] enum RunnerHit { Row(usize), } @@ -611,15 +678,24 @@ enum RunnerHit { /// Mature list, buttons, and shared editor for complete runner management. #[derive(Debug, Default)] pub(crate) struct RunnerManagerSession { + interaction_view: Option, rows: ListPickerState, row_height: usize, clicks: ClickRegionRegistry, + click: ClickTracker, row_areas: Vec<(usize, Rect)>, editor: RunnerEditorSession, footer: ActionFooterSession, } impl RunnerManagerSession { + /// Cancel armed row, editor, and footer targets before a pointer discontinuity. + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + self.editor.cancel_click(); + self.footer.cancel_click(); + } + /// Render the registry and its active typed overlay. pub(crate) fn render( &mut self, @@ -628,6 +704,10 @@ impl RunnerManagerSession { view: &RunnerManagerView, locale: Locale, ) { + if self.interaction_view.as_ref() != Some(view) { + self.cancel_click(); + self.interaction_view = Some(view.clone()); + } self.clicks.clear(); self.row_areas.clear(); let block = padded_panel( @@ -831,6 +911,9 @@ impl RunnerManagerSession { event: Event, view: &RunnerManagerView, ) -> RunnerManagerEventHandling { + if cancels_pointer_press(&event) { + self.cancel_click(); + } if let Some(editor) = view.editor() { return match self.editor.handle_event(event, editor) { RunnerEditorEventHandling::Action(action) => RunnerManagerEventHandling::Action( @@ -847,9 +930,13 @@ impl RunnerManagerSession { if let Event::Mouse(mouse) = &event { match self.footer.handle_mouse(mouse) { ActionFooterMouse::Action(action) => { + self.click.cancel(); return RunnerManagerEventHandling::Action(action); } - ActionFooterMouse::Scrolled => return RunnerManagerEventHandling::Consumed, + ActionFooterMouse::Scrolled | ActionFooterMouse::Armed => { + self.click.cancel(); + return RunnerManagerEventHandling::Consumed; + } ActionFooterMouse::Ignored => {} } } @@ -862,7 +949,7 @@ impl RunnerManagerSession { self.handle_list_event(event) } - fn handle_list_event(&self, event: Event) -> RunnerManagerEventHandling { + fn handle_list_event(&mut self, event: Event) -> RunnerManagerEventHandling { match event { Event::Key(key) if key.kind != KeyEventKind::Release => { if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) { @@ -888,12 +975,16 @@ impl RunnerManagerSession { RunnerManagerEventHandling::Action, ) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - match self.clicks.handle_click(mouse.column, mouse.row).cloned() { - Some(RunnerHit::Row(index)) => { + Event::Mouse(mouse) + if matches!(mouse.kind, MouseEventKind::Down(_) | MouseEventKind::Up(_)) => + { + let target = self.clicks.handle_click(mouse.column, mouse.row); + match self.click.update(&mouse, target) { + ClickOutcome::Armed => RunnerManagerEventHandling::Consumed, + ClickOutcome::Activated(RunnerHit::Row(index)) => { RunnerManagerEventHandling::Action(RunnerManagerAction::ActivateRow(index)) } - None => RunnerManagerEventHandling::Ignored, + ClickOutcome::Ignored => RunnerManagerEventHandling::Ignored, } } Event::Mouse(mouse) @@ -1181,6 +1272,7 @@ mod tests { HealthIssue, HealthIssueKind, HealthRebuildOutcome, HealthSnapshot, HealthView, MirrorHealth, RunnerRow, RunnerRowIdentity, UvHealth, }; + use unicode_width::UnicodeWidthStr as _; fn key(code: KeyCode) -> Event { Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) @@ -1217,6 +1309,63 @@ mod tests { }) } + fn health_click( + session: &mut HealthScreenSession, + view: &HealthView, + column: u16, + row: u16, + ) -> HealthEventHandling { + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), column, row), + view + ), + HealthEventHandling::Consumed, + ); + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), column, row), + view, + ) + } + + fn editor_click( + session: &mut RunnerEditorSession, + view: &RunnerEditorView, + column: u16, + row: u16, + ) -> RunnerEditorEventHandling { + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), column, row), + view + ), + RunnerEditorEventHandling::Consumed, + ); + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), column, row), + view, + ) + } + + fn manager_click( + session: &mut RunnerManagerSession, + view: &RunnerManagerView, + column: u16, + row: u16, + ) -> RunnerManagerEventHandling { + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), column, row), + view + ), + RunnerManagerEventHandling::Consumed, + ); + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), column, row), + view, + ) + } + fn text_position(buffer: &ratatui_core::buffer::Buffer, needle: &str) -> (u16, u16) { (0..buffer.area.height) .find_map(|y| { @@ -1241,6 +1390,42 @@ mod tests { .join("\n") } + fn row_text(buffer: &ratatui_core::buffer::Buffer, area: Rect) -> String { + let mut rendered = String::new(); + for y in area.y..area.bottom() { + let mut x = area.x; + while x < area.right() { + let symbol = buffer[(x, y)].symbol(); + rendered.push_str(symbol); + x = x.saturating_add( + u16::try_from(symbol.width().max(1)).expect("one cell symbol fits a row"), + ); + } + rendered.push('\n'); + } + rendered + } + + fn styled_area(buffer: &ratatui_core::buffer::Buffer, foreground: Color) -> Option { + let cells = (0..buffer.area.height).flat_map(|y| { + (0..buffer.area.width) + .filter(move |&x| buffer[(x, y)].fg == foreground) + .map(move |x| (x, y)) + }); + let (minimum_x, minimum_y, maximum_x, maximum_y) = + cells.fold(None, |bounds: Option<(u16, u16, u16, u16)>, (x, y)| { + Some(bounds.map_or((x, y, x, y), |(min_x, min_y, max_x, max_y)| { + (min_x.min(x), min_y.min(y), max_x.max(x), max_y.max(y)) + })) + })?; + Some(Rect::new( + minimum_x, + minimum_y, + maximum_x.saturating_sub(minimum_x).saturating_add(1), + maximum_y.saturating_sub(minimum_y).saturating_add(1), + )) + } + fn health() -> HealthView { HealthView::new(HealthSnapshot { uv: UvHealth::Missing, @@ -1436,7 +1621,17 @@ mod tests { let (index, area) = session.issue_areas[1]; assert_eq!(index, 1); assert_eq!( - session.handle_event(mouse(area.x, area.y), &view), + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), area.x, area.y), + &view, + ), + HealthEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), area.x, area.y), + &view, + ), HealthEventHandling::Action(HealthAction::ActivateIssue(1)) ); for (needle, expected) in [ @@ -1446,13 +1641,194 @@ mod tests { ] { let (x, y) = text_position(terminal.backend().buffer(), needle); assert_eq!( - session.handle_event(mouse(x, y), &view), + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), x, y), + &view, + ), + HealthEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), x, y), + &view, + ), HealthEventHandling::Action(expected), "visible Health chip must be clickable: {needle}" ); } } + #[test] + fn health_footer_press_cancels_an_armed_issue_row() { + let view = health(); + let mut session = HealthScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(100, 25)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let issue = session.issue_areas[0].1; + let footer = text_position(terminal.backend().buffer(), "Enter Jump to entry"); + + assert_eq!( + session.handle_event(mouse(issue.x, issue.y), &view), + HealthEventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(footer.0, footer.1), &view), + HealthEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), footer.0, footer.1), + &view, + ), + HealthEventHandling::Action(HealthAction::Jump) + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), issue.x, issue.y), + &view, + ), + HealthEventHandling::Ignored, + "the earlier issue press stayed armed after the footer completed a click" + ); + } + + #[test] + fn management_owners_cancel_armed_targets_before_a_late_release() { + let health_view = health(); + let mut health_session = HealthScreenSession::default(); + let mut health_terminal = Terminal::new(TestBackend::new(100, 25)).unwrap(); + health_terminal + .draw(|frame| { + health_session.render(frame, frame.area(), &health_view, Locale::En); + }) + .unwrap(); + let first_issue = health_session.issue_areas[0].1; + let second_issue = health_session.issue_areas[1].1; + assert_eq!( + health_session.handle_event(mouse(first_issue.x, first_issue.y), &health_view), + HealthEventHandling::Consumed + ); + assert_eq!( + health_session.handle_event( + mouse_event( + MouseEventKind::Drag(MouseButton::Left), + second_issue.x, + second_issue.y, + ), + &health_view, + ), + HealthEventHandling::Ignored + ); + assert_eq!( + health_session.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + first_issue.x, + first_issue.y, + ), + &health_view, + ), + HealthEventHandling::Ignored, + "a Health issue activated after a drag cancelled its press" + ); + + let health_footer = + text_position(health_terminal.backend().buffer(), "Enter Jump to entry"); + assert_eq!( + health_session.handle_event(mouse(health_footer.0, health_footer.1), &health_view), + HealthEventHandling::Consumed + ); + assert_eq!( + health_session.handle_event(Event::Resize(80, 18), &health_view), + HealthEventHandling::Ignored + ); + assert_eq!( + health_session.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + health_footer.0, + health_footer.1, + ), + &health_view, + ), + HealthEventHandling::Ignored, + "a Health footer action survived a resize" + ); + + let editor_view = RunnerEditorView::new(); + let mut editor_session = RunnerEditorSession::default(); + let mut editor_terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + editor_terminal + .draw(|frame| { + editor_session.render(frame, frame.area(), &editor_view, Locale::En); + }) + .unwrap(); + let editor_footer = text_position(editor_terminal.backend().buffer(), "Enter Save"); + assert_eq!( + editor_session.handle_event(mouse(editor_footer.0, editor_footer.1), &editor_view,), + RunnerEditorEventHandling::Consumed + ); + assert_eq!( + editor_session.handle_event( + mouse_event( + MouseEventKind::Drag(MouseButton::Left), + editor_footer.0, + editor_footer.1, + ), + &editor_view, + ), + RunnerEditorEventHandling::Ignored + ); + assert_eq!( + editor_session.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + editor_footer.0, + editor_footer.1, + ), + &editor_view, + ), + RunnerEditorEventHandling::Ignored, + "a RunnerEditor footer action survived a drag" + ); + + let manager_view = RunnerManagerView::new(vec![row(0, None, 0), row(1, None, 0)]); + let mut manager_session = RunnerManagerSession::default(); + let mut manager_terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + manager_terminal + .draw(|frame| { + manager_session.render(frame, frame.area(), &manager_view, Locale::En); + }) + .unwrap(); + let first_row = manager_session.row_areas[0].1; + let second_row = manager_session.row_areas[1].1; + assert_eq!( + manager_session.handle_event(mouse(first_row.x, first_row.y), &manager_view), + RunnerManagerEventHandling::Consumed + ); + assert_eq!( + manager_session.handle_event( + mouse_event(MouseEventKind::ScrollDown, second_row.x, second_row.y), + &manager_view, + ), + RunnerManagerEventHandling::Action(RunnerManagerAction::Next) + ); + assert_eq!( + manager_session.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + first_row.x, + first_row.y, + ), + &manager_view, + ), + RunnerManagerEventHandling::Ignored, + "a RunnerManager row activated after its wheel cancellation" + ); + } + #[test] fn healthy_report_keeps_jump_visible_and_scrolls_the_summary() { let mut snapshot = health().snapshot().clone(); @@ -1468,7 +1844,7 @@ mod tests { let (x, y) = text_position(terminal.backend().buffer(), "Enter Jump to entry"); assert_eq!( - session.handle_event(mouse(x, y), &view), + health_click(&mut session, &view, x, y), HealthEventHandling::Action(HealthAction::Jump) ); assert_eq!( @@ -1502,7 +1878,7 @@ mod tests { ); let (index, area) = session.row_areas[1]; assert_eq!( - session.handle_event(mouse(area.x, area.y), &view), + manager_click(&mut session, &view, area.x, area.y), RunnerManagerEventHandling::Action(RunnerManagerAction::ActivateRow(index)) ); @@ -1513,6 +1889,86 @@ mod tests { assert!(lines(terminal.backend().buffer()).contains("No agents configured yet.")); } + #[test] + fn runner_rows_reject_mismatch_outside_and_nonprimary_activation() { + let view = RunnerManagerView::new(vec![row(0, None, 0), row(1, None, 0)]); + let mut session = RunnerManagerSession::default(); + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let first = session.row_areas[0].1; + let second = session.row_areas[1].1; + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), first.x, first.y), + &view, + ), + RunnerManagerEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), second.x, second.y), + &view, + ), + RunnerManagerEventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Right), first.x, first.y), + &view, + ), + RunnerManagerEventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), first.x, first.y), + &view, + ), + RunnerManagerEventHandling::Ignored + ); + } + + #[test] + fn health_issues_reject_mismatch_and_nonprimary_activation() { + let view = health(); + let mut session = HealthScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(100, 25)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let first = session.issue_areas[0].1; + let second = session.issue_areas[1].1; + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Right), first.x, first.y), + &view, + ), + HealthEventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), first.x, first.y), + &view, + ), + HealthEventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Down(MouseButton::Left), first.x, first.y), + &view, + ), + HealthEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), second.x, second.y), + &view, + ), + HealthEventHandling::Ignored + ); + } + #[test] fn reusable_editor_uses_mature_inputs_and_every_visible_chip_is_clickable() { let mut view = RunnerEditorView::new(); @@ -1553,19 +2009,19 @@ mod tests { ] { let (x, y) = text_position(terminal.backend().buffer(), needle); assert_eq!( - session.handle_event(mouse(x, y), &view), + editor_click(&mut session, &view, x, y), RunnerEditorEventHandling::Action(expected), "visible editor chip must be clickable: {needle}" ); } let save = text_position(terminal.backend().buffer(), "Enter Save"); assert_eq!( - session.handle_event(mouse(save.0, save.1), &view), + editor_click(&mut session, &view, save.0, save.1), RunnerEditorEventHandling::Action(RunnerEditorAction::Submit) ); let cancel = text_position(terminal.backend().buffer(), "Esc Cancel"); assert_eq!( - session.handle_event(mouse(cancel.0, cancel.1), &view), + editor_click(&mut session, &view, cancel.0, cancel.1), RunnerEditorEventHandling::Action(RunnerEditorAction::Cancel) ); for (code, expected) in [ @@ -1581,6 +2037,36 @@ mod tests { } } + #[test] + fn runner_editor_line_input_click_places_each_caret_before_typing() { + let mut view = RunnerEditorView::new(); + view.reduce(RunnerEditorAction::SetName("abcdef".to_owned())); + let mut session = RunnerEditorSession::default(); + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let (x, y) = text_position(terminal.backend().buffer(), "abcdef"); + let _ = editor_click(&mut session, &view, x.saturating_add(2), y); + assert_eq!( + session.handle_event(key(KeyCode::Char('X')), &view), + RunnerEditorEventHandling::Action(RunnerEditorAction::SetName("abXcdef".to_owned())) + ); + + view.reduce(RunnerEditorAction::SetName("name".to_owned())); + view.reduce(RunnerEditorAction::SetCommand("abcdef".to_owned())); + view.reduce(RunnerEditorAction::Focus(RunnerEditorField::Command)); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let (x, y) = text_position(terminal.backend().buffer(), "abcdef"); + let _ = editor_click(&mut session, &view, x.saturating_add(2), y); + assert_eq!( + session.handle_event(key(KeyCode::Char('X')), &view), + RunnerEditorEventHandling::Action(RunnerEditorAction::SetCommand("abXcdef".to_owned())) + ); + } + #[test] fn existing_runner_name_is_visibly_locked_and_cannot_take_focus() { let view = RunnerEditorView::edit(&row(0, None, 2)); @@ -1618,7 +2104,7 @@ mod tests { ] { let (x, y) = text_position(terminal.backend().buffer(), needle); assert_eq!( - session.handle_event(mouse(x, y), &view), + manager_click(&mut session, &view, x, y), RunnerManagerEventHandling::Action(expected), "visible row-action chip must be clickable: {needle}" ); @@ -1643,7 +2129,7 @@ mod tests { ] { let (x, y) = text_position(terminal.backend().buffer(), needle); assert_eq!( - session.handle_event(mouse(x, y), &view), + manager_click(&mut session, &view, x, y), RunnerManagerEventHandling::Action(expected), "visible confirmation chip must be clickable: {needle}" ); @@ -1666,7 +2152,7 @@ mod tests { ] { let (x, y) = text_position(terminal.backend().buffer(), needle); assert_eq!( - session.handle_event(mouse(x, y), &view), + manager_click(&mut session, &view, x, y), RunnerManagerEventHandling::Action(expected), "visible manager chip must be clickable: {needle}" ); @@ -1939,6 +2425,306 @@ mod tests { ); } + #[test] + fn runner_action_overlay_ignores_release_and_accepts_each_pressed_key() { + let mut view = RunnerManagerView::new(vec![row(0, None, 0)]); + view.reduce(RunnerManagerAction::ActivateSelected); + let mut session = RunnerManagerSession::default(); + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, Locale::En)) + .unwrap(); + let rendered = lines(terminal.backend().buffer()); + for (code, label, expected) in [ + ( + KeyCode::Char('e'), + "e Edit", + RunnerManagerAction::EditSelected, + ), + ( + KeyCode::Char('d'), + "d Remove", + RunnerManagerAction::RemoveSelected, + ), + (KeyCode::Esc, "Esc Back", RunnerManagerAction::CloseActions), + ] { + assert!(rendered.contains(label), "missing visible owner: {label}"); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new_with_kind( + code, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &view, + ), + RunnerManagerEventHandling::Ignored, + "a released {code:?} key must not activate the action overlay" + ); + assert_eq!( + session.handle_event(key(code), &view), + RunnerManagerEventHandling::Action(expected), + "a pressed {code:?} key must keep its visible action" + ); + } + } + + #[test] + fn every_runner_reason_renders_its_localized_text_on_its_own_row() { + let cases = [ + ( + "prompt-section-not-table", + "the prompt value is not a table; repair it before runner management", + ), + ( + "runners-not-list", + "the prompt.runners value is not a list; repair it before runner management", + ), + ( + "empty", + "Type the agent's command, e.g. mycli run {{prompt}}", + ), + ( + "prompt-slot-count", + "The command needs the {{prompt}} slot exactly once — that's where the rendered prompt lands.", + ), + ( + "prompt-in-binary", + "{{prompt}} can't be the command itself — the first word must be the program to run.", + ), + ( + "stray-hole", + "Runner commands take only the {{prompt}} slot — single-brace text is literal, and other {{holes}} aren't supported.", + ), + ("name", "A name is required."), + ("argv-type", "The command must be a list of text arguments."), + ("row-not-table", "This runner row isn't a table."), + ("duplicate", "Another row already uses this runner name."), + ("future-code", "This runner row is malformed."), + ]; + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for (code, source) in cases { + let view = RunnerManagerView::new(vec![row(0, Some(code), 0)]); + let mut session = RunnerManagerSession::default(); + let mut terminal = Terminal::new(TestBackend::new(240, 10)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, locale)) + .unwrap(); + let area = session.row_areas[0].1; + let buffer = terminal.backend().buffer(); + let rendered = row_text(buffer, area); + let expected = text(locale, source); + assert!( + rendered.contains(expected.as_ref()), + "reason={code:?}, locale={locale:?}, row={rendered:?}, expected={expected:?}" + ); + } + } + } + + #[test] + fn every_runner_editor_error_renders_exact_localized_red_text() { + let cases = [ + ( + "", + "agent {{prompt}}", + RunnerEditorError::NameRequired, + "A name is required.", + ), + ( + "name", + "\"", + RunnerEditorError::UnbalancedQuotes, + "Unbalanced quotes in the command.", + ), + ( + "name", + "", + RunnerEditorError::EmptyCommand, + "Type the agent's command, e.g. mycli run {{prompt}}", + ), + ( + "name", + "agent", + RunnerEditorError::PromptSlotCount, + "The command needs the {{prompt}} slot exactly once — that's where the rendered prompt lands.", + ), + ( + "name", + "{{prompt}}", + RunnerEditorError::PromptInProgram, + "{{prompt}} can't be the command itself — the first word must be the program to run.", + ), + ( + "name", + "agent {{other}} {{prompt}}", + RunnerEditorError::UnsupportedHole, + "Runner commands take only the {{prompt}} slot — single-brace text is literal, and other {{holes}} aren't supported.", + ), + ]; + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for (name, command, error, source) in &cases { + let mut view = RunnerEditorView::new(); + view.reduce(RunnerEditorAction::SetName((*name).to_owned())); + view.reduce(RunnerEditorAction::SetCommand((*command).to_owned())); + view.reduce(RunnerEditorAction::Submit); + assert_eq!(view.error(), Some(error)); + assert_eq!(view.host_error(), None); + + let mut session = RunnerEditorSession::default(); + let mut terminal = Terminal::new(TestBackend::new(100, 18)).unwrap(); + terminal + .draw(|frame| session.render(frame, frame.area(), &view, locale)) + .unwrap(); + let expected = text(locale, source); + let red_area = styled_area(terminal.backend().buffer(), Color::Red) + .expect("a validation error owns a visible red band"); + let actual = row_text(terminal.backend().buffer(), red_area); + let mut expected_terminal = + Terminal::new(TestBackend::new(red_area.width, red_area.height)).unwrap(); + expected_terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new(expected.as_ref()) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::Red)), + frame.area(), + ); + }) + .unwrap(); + let expected_grid = row_text( + expected_terminal.backend().buffer(), + expected_terminal.backend().buffer().area, + ); + assert_eq!( + actual, expected_grid, + "error={error:?}, locale={locale:?}, expected={expected:?}" + ); + } + } + } + + #[test] + fn runner_manager_recompose_cancels_changed_row_and_action_owners() { + let mut alpha_row = row(0, None, 0); + alpha_row.name = Some("alpha".to_owned()); + alpha_row.identity.snapshot_token = "alpha-row".to_owned(); + let mut beta_row = row(0, None, 0); + beta_row.name = Some("beta".to_owned()); + beta_row.identity.snapshot_token = "beta-row".to_owned(); + let alpha = RunnerManagerView::new(vec![alpha_row.clone()]); + let beta = RunnerManagerView::new(vec![beta_row.clone()]); + + let mut changed = RunnerManagerSession::default(); + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| changed.render(frame, frame.area(), &alpha, Locale::En)) + .unwrap(); + let row_area = changed.row_areas[0].1; + assert_eq!( + changed.handle_event(mouse(row_area.x, row_area.y), &alpha), + RunnerManagerEventHandling::Consumed + ); + terminal + .draw(|frame| changed.render(frame, frame.area(), &beta, Locale::En)) + .unwrap(); + assert_eq!( + changed.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + row_area.x, + row_area.y, + ), + &beta, + ), + RunnerManagerEventHandling::Ignored, + "a press on alpha must not activate beta after the row identity changes" + ); + + let mut equal = RunnerManagerSession::default(); + terminal + .draw(|frame| equal.render(frame, frame.area(), &alpha, Locale::En)) + .unwrap(); + let row_area = equal.row_areas[0].1; + assert_eq!( + equal.handle_event(mouse(row_area.x, row_area.y), &alpha), + RunnerManagerEventHandling::Consumed + ); + terminal + .draw(|frame| equal.render(frame, frame.area(), &alpha, Locale::En)) + .unwrap(); + assert_eq!( + equal.handle_event( + mouse_event( + MouseEventKind::Up(MouseButton::Left), + row_area.x, + row_area.y, + ), + &alpha, + ), + RunnerManagerEventHandling::Action(RunnerManagerAction::ActivateRow(0)) + ); + + let mut alpha_actions = RunnerManagerView::new(vec![alpha_row]); + alpha_actions.reduce(RunnerManagerAction::ActivateSelected); + let mut beta_actions = RunnerManagerView::new(vec![beta_row]); + beta_actions.reduce(RunnerManagerAction::ActivateSelected); + let mut action_session = RunnerManagerSession::default(); + terminal + .draw(|frame| { + action_session.render(frame, frame.area(), &alpha_actions, Locale::En); + }) + .unwrap(); + let edit = text_position(terminal.backend().buffer(), "e Edit"); + assert_eq!( + action_session.handle_event(mouse(edit.0, edit.1), &alpha_actions), + RunnerManagerEventHandling::Consumed + ); + terminal + .draw(|frame| { + action_session.render(frame, frame.area(), &beta_actions, Locale::En); + }) + .unwrap(); + assert_eq!( + action_session.handle_event( + mouse_event(MouseEventKind::Up(MouseButton::Left), edit.0, edit.1), + &beta_actions, + ), + RunnerManagerEventHandling::Ignored, + "an alpha action-footer press must not edit beta after owner replacement" + ); + } + + #[test] + fn runner_removal_overlay_confirms_only_on_a_live_command_event() { + let mut view = RunnerManagerView::new(vec![row(0, None, 0)]); + view.reduce(RunnerManagerAction::RemoveSelected); + assert!(view.removal().is_some()); + let mut session = RunnerManagerSession::default(); + + assert_eq!( + session.handle_event(key(KeyCode::Char('y')), &view), + RunnerManagerEventHandling::Action(RunnerManagerAction::ConfirmRemove) + ); + for event in [ + Event::FocusGained, + Event::FocusLost, + mouse_event(MouseEventKind::Moved, 0, 0), + Event::Paste("ignored".to_owned()), + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('y'), + KeyModifiers::NONE, + KeyEventKind::Release, + )), + Event::Resize(1, 1), + ] { + assert_eq!( + session.handle_event(event, &view), + RunnerManagerEventHandling::Ignored + ); + } + } + #[test] fn remaining_management_variants_keep_real_render_and_event_ownership() { let mut snapshot = health().snapshot().clone(); @@ -2157,7 +2943,7 @@ mod tests { ); let name = text_position(editor_terminal.backend().buffer(), "Name, e.g. aider"); assert_eq!( - editor_session.handle_event(mouse(name.0, name.1), &editor_view), + editor_click(&mut editor_session, &editor_view, name.0, name.1), RunnerEditorEventHandling::Action(RunnerEditorAction::Focus(RunnerEditorField::Name)) ); editor_view.reduce(RunnerEditorAction::Focus(RunnerEditorField::Command)); diff --git a/crates/skit-tui/src/screens/modal.rs b/crates/skit-tui/src/screens/modal.rs index 870cc618..d43f4a24 100644 --- a/crates/skit-tui/src/screens/modal.rs +++ b/crates/skit-tui/src/screens/modal.rs @@ -7,11 +7,12 @@ use ratatui_core::{ text::{Line, Span}, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui_interact::components::{ - Button, ButtonState, ButtonStyle, ButtonVariant, DialogConfig, DialogState, PopupDialog, - ScrollableContentState, handle_scrollable_content_key, handle_scrollable_content_mouse, + Button, ButtonState, ButtonStyle, ButtonVariant, DialogConfig, DialogFocusTarget, DialogState, + PopupDialog, ScrollableContentState, handle_scrollable_content_key, + handle_scrollable_content_mouse, }; use ratatui_interact::traits::{ContainerAction, EventResult}; use ratatui_widgets::{ @@ -25,6 +26,7 @@ use unicode_width::UnicodeWidthStr as _; use crate::{ HitRegion, HitTarget, ViewGeometry, + pointer::{ClickOutcome, ClickTracker}, theme::{ACCENT, BOX_DIM, padded_panel}, }; @@ -43,6 +45,8 @@ pub(crate) struct ConfirmRemoveSession { dialog: DialogState<()>, config: Option, screen: Rect, + click: ClickTracker, + identity: Option<(String, bool)>, } impl ConfirmRemoveSession { @@ -53,6 +57,11 @@ impl ConfirmRemoveSession { original_file_preserved: bool, locale: Locale, ) -> ViewGeometry { + let identity = (name.to_owned(), original_file_preserved); + if self.identity.as_ref() != Some(&identity) { + self.click.cancel(); + self.identity = Some(identity); + } if self.dialog.focus.is_empty() { self.dialog.register_button(1); self.dialog.register_button(0); @@ -94,15 +103,47 @@ impl ConfirmRemoveSession { let Some(config) = self.config.as_ref() else { return ConfirmRemoveEvent::Ignored; }; - let mut popup = PopupDialog::new(config, &mut self.dialog, |_, _, ()| {}); + if matches!( + event, + Event::FocusGained + | Event::FocusLost + | Event::Key(_) + | Event::Paste(_) + | Event::Resize(_, _) + | Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight, + .. + }) + ) { + self.click.cancel(); + } let result = match event { Event::Key(key) if key.kind != KeyEventKind::Release && matches!(key.code, KeyCode::Tab | KeyCode::BackTab) => { + let mut popup = PopupDialog::new(config, &mut self.dialog, |_, _, ()| {}); popup.handle_key(*key) } - Event::Mouse(mouse) => popup.handle_mouse_with_screen(*mouse, self.screen), + Event::Mouse(mouse) => { + let target = self + .dialog + .click_regions + .handle_click(mouse.column, mouse.row); + match self.click.update(mouse, target) { + ClickOutcome::Armed => EventResult::Consumed, + ClickOutcome::Activated(_) => { + let mut activation = *mouse; + activation.kind = MouseEventKind::Down(MouseButton::Left); + let mut popup = PopupDialog::new(config, &mut self.dialog, |_, _, ()| {}); + popup.handle_mouse_with_screen(activation, self.screen) + } + ClickOutcome::Ignored => EventResult::NotHandled, + } + } Event::FocusGained | Event::FocusLost | Event::Key(_) @@ -159,9 +200,8 @@ impl HelpScreenSession { let line_count = paragraph.line_count(self.viewport.width); self.scroll.set_lines(vec![String::new(); line_count]); let maximum = line_count.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum { - self.scroll.set_scroll_offset(maximum); - } + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum)); let indicator = match ( self.scroll.is_at_top(), self.scroll.is_at_bottom(self.visible_height), @@ -211,24 +251,16 @@ impl HelpScreenSession { { handle_scrollable_content_key(&mut self.scroll, key, self.visible_height).is_some() } - Event::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => - { - handle_scrollable_content_mouse( - &mut self.scroll, - mouse, - self.viewport, - self.visible_height, - ) - .is_some() - } + Event::Mouse(mouse) => handle_scrollable_content_mouse( + &mut self.scroll, + mouse, + self.viewport, + self.visible_height, + ) + .is_some(), Event::FocusGained | Event::FocusLost | Event::Key(_) - | Event::Mouse(_) | Event::Paste(_) | Event::Resize(_, _) => false, } @@ -354,15 +386,22 @@ mod tests { } assert_eq!( session.handle_event(&mouse(remove, MouseEventKind::Down(MouseButton::Left))), + ConfirmRemoveEvent::Consumed + ); + assert_eq!( + session.handle_event(&mouse(remove, MouseEventKind::Up(MouseButton::Left))), ConfirmRemoveEvent::Submit ); assert_eq!( session.handle_event(&mouse(keep, MouseEventKind::Down(MouseButton::Left))), + ConfirmRemoveEvent::Consumed + ); + assert_eq!( + session.handle_event(&mouse(keep, MouseEventKind::Up(MouseButton::Left))), ConfirmRemoveEvent::Close ); for event in [ mouse(remove, MouseEventKind::Moved), - mouse(remove, MouseEventKind::Up(MouseButton::Left)), key(KeyCode::Enter), Event::Paste("ignored".to_owned()), Event::FocusGained, @@ -385,6 +424,110 @@ mod tests { assert!(!find_text(terminal.backend().buffer(), "Beta").is_empty()); } + #[test] + fn confirm_remove_leaves_non_navigation_keys_and_releases_to_the_root_owner() { + let mut session = ConfirmRemoveSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, "Alpha", true, Locale::En); + }) + .unwrap(); + + assert_eq!( + session.handle_event(&key(KeyCode::Enter)), + ConfirmRemoveEvent::Ignored, + "Enter belongs to the root confirmation command, not the focus navigator", + ); + assert_eq!( + session.handle_event(&Event::Key(KeyEvent::new_with_kind( + KeyCode::Tab, + KeyModifiers::NONE, + KeyEventKind::Release, + ))), + ConfirmRemoveEvent::Ignored, + "a key release must not move dialog focus", + ); + assert_eq!( + session.handle_event(&key(KeyCode::Tab)), + ConfirmRemoveEvent::Consumed, + "a Tab press must still move dialog focus", + ); + } + + #[test] + fn confirm_remove_cancels_a_press_when_another_event_owner_intervenes() { + let mut session = ConfirmRemoveSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, "Alpha", true, Locale::En); + }) + .unwrap(); + let remove = find_text(terminal.backend().buffer(), "Remove"); + + for (interrupt, expected) in [ + (Event::Resize(40, 10), ConfirmRemoveEvent::Ignored), + ( + mouse(remove, MouseEventKind::ScrollDown), + ConfirmRemoveEvent::Ignored, + ), + (key(KeyCode::Tab), ConfirmRemoveEvent::Consumed), + ] { + assert_eq!( + session.handle_event(&mouse(remove, MouseEventKind::Down(MouseButton::Left))), + ConfirmRemoveEvent::Consumed + ); + assert_eq!(session.handle_event(&interrupt), expected); + assert_eq!( + session.handle_event(&mouse(remove, MouseEventKind::Up(MouseButton::Left))), + ConfirmRemoveEvent::Ignored, + "{interrupt:?} left a stale Remove press armed", + ); + } + } + + #[test] + fn confirm_remove_cancels_an_armed_button_when_the_entry_identity_changes() { + let mut session = ConfirmRemoveSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, "Alpha", true, Locale::En); + }) + .unwrap(); + let remove = find_text(terminal.backend().buffer(), "Remove"); + assert_eq!( + session.handle_event(&mouse(remove, MouseEventKind::Down(MouseButton::Left))), + ConfirmRemoveEvent::Consumed + ); + terminal + .draw(|frame| { + let _ = session.render(frame, "Beta", false, Locale::En); + }) + .unwrap(); + let replacement = find_text(terminal.backend().buffer(), "Remove"); + assert_eq!(replacement, remove); + assert_eq!( + session.handle_event(&mouse(replacement, MouseEventKind::Up(MouseButton::Left))), + ConfirmRemoveEvent::Ignored + ); + + assert_eq!( + session.handle_event(&mouse(replacement, MouseEventKind::Down(MouseButton::Left))), + ConfirmRemoveEvent::Consumed + ); + terminal + .draw(|frame| { + let _ = session.render(frame, "Beta", false, Locale::En); + }) + .unwrap(); + assert_eq!( + session.handle_event(&mouse(replacement, MouseEventKind::Up(MouseButton::Left))), + ConfirmRemoveEvent::Submit + ); + } + #[test] fn help_scrolls_by_every_advertised_key_and_wheel_then_clamps_on_growth() { let mut session = HelpScreenSession::default(); @@ -406,6 +549,12 @@ mod tests { } let viewport = session.viewport; assert!(session.handle_event(&mouse(viewport, MouseEventKind::ScrollDown))); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), Locale::ZhCn); + }) + .unwrap(); + assert!(!find_text(terminal.backend().buffer(), "↑↓").is_empty()); assert!(session.handle_event(&mouse(viewport, MouseEventKind::ScrollUp))); for event in [ mouse(viewport, MouseEventKind::Moved), @@ -427,6 +576,44 @@ mod tests { .unwrap(); } + #[test] + fn help_assigns_the_double_interrupt_hint_only_to_the_quit_row() { + let mut session = HelpScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), Locale::En); + }) + .unwrap(); + + let rows = terminal + .backend() + .buffer() + .content() + .chunks(120) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>(); + let quit_label = command_specs(CommandContext::LibraryBrowse) + .find(|spec| spec.command == UiCommand::Quit) + .map(|spec| text(Locale::En, spec.label).into_owned()) + .expect("Help has a Quit command"); + let other_label = command_specs(CommandContext::LibraryBrowse) + .find(|spec| spec.help && spec.command != UiCommand::Quit) + .map(|spec| text(Locale::En, spec.label).into_owned()) + .expect("Help has a non-Quit command"); + let quit_row = rows + .iter() + .find(|row| row.contains(&quit_label)) + .expect("Quit is visible in the tall Help viewport"); + let other_row = rows + .iter() + .find(|row| row.contains(&other_label)) + .expect("a non-Quit command is visible"); + + assert!(quit_row.contains("Ctrl+C Ctrl+C / Esc"), "{quit_row}"); + assert!(!other_row.contains("Ctrl+C Ctrl+C / Esc"), "{other_row}"); + } + #[test] fn discard_overlay_keeps_both_visible_chips_clickable_in_every_locale() { for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { diff --git a/crates/skit-tui/src/screens/picker.rs b/crates/skit-tui/src/screens/picker.rs index 5d73fa6f..5c562c91 100644 --- a/crates/skit-tui/src/screens/picker.rs +++ b/crates/skit-tui/src/screens/picker.rs @@ -9,17 +9,13 @@ use ratatui_core::{ text::{Line, Span}, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui_interact::components::{ EntryType, FileEntry, FileExplorerState, ListPicker, ListPickerState, ListPickerStyle, ScrollableContentState, }; -use ratatui_widgets::{ - block::Block, - borders::Borders, - paragraph::{Paragraph, Wrap}, -}; +use ratatui_widgets::paragraph::{Paragraph, Wrap}; use skit_i18n::{Locale, text}; use skit_ui::{ChoicePicker, PathPickerState, PathSelectionMode, PickerPurpose, PickerResult}; use tui_input::{Input as LineInput, InputRequest}; @@ -27,7 +23,9 @@ use unicode_width::UnicodeWidthStr as _; use crate::{ footer::handle_footer_scroll, - theme::{ACCENT, BOX_INDIGO, SELECT_BG, SELECT_FG, panel_block}, + pointer::{ClickOutcome, ClickTracker, EditableGeometry}, + session::render_search_line_input, + theme::{BOX_INDIGO, SELECT_BG, SELECT_FG, panel_block}, }; /// Mouse target in the complete prompt-variable picker. @@ -86,9 +84,15 @@ pub struct PromptCandidatePickerSession { footer_scroll: ScrollableContentState, footer_viewport: Rect, footer_visible_height: usize, + click: ClickTracker, + query_editable: Option, } impl PromptCandidatePickerSession { + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + } + /// Open one isolated working selection from `ReviewState::prompt_picker`. #[must_use] pub fn new(picker: ChoicePicker) -> Self { @@ -101,6 +105,8 @@ impl PromptCandidatePickerSession { footer_scroll: ScrollableContentState::default(), footer_viewport: Rect::default(), footer_visible_height: 0, + click: ClickTracker::default(), + query_editable: None, } } @@ -122,14 +128,42 @@ impl PromptCandidatePickerSession { geometry: &ChoicePickerGeometry, ) -> Option { match event { - Event::Key(key) if key.kind != KeyEventKind::Release => self.handle_choice_key(key), + Event::Key(key) => { + self.click.cancel(); + if key.kind == KeyEventKind::Release { + None + } else { + self.handle_choice_key(key) + } + } Event::Paste(value) => { + self.click.cancel(); for character in value.chars() { self.query.handle(InputRequest::InsertChar(character)); } self.sync_choice_filter(); Some(PromptCandidatePickerEvent::Changed) } + Event::FocusGained | Event::FocusLost => { + self.click.cancel(); + None + } + Event::Mouse(mouse) + if geometry.rows.contains((mouse.column, mouse.row).into()) + && matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + self.click.cancel(); + if mouse.kind == MouseEventKind::ScrollUp { + self.list.select_prev(); + } else { + self.list.select_next(); + } + self.list.ensure_visible(self.visible_height); + Some(PromptCandidatePickerEvent::Changed) + } Event::Mouse(mouse) if handle_footer_scroll( &mut self.footer_scroll, @@ -140,13 +174,24 @@ impl PromptCandidatePickerSession { { Some(PromptCandidatePickerEvent::Changed) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { + Event::Mouse(mouse) => { let target = geometry .hits .iter() + .rev() .find(|hit| hit.area.contains((mouse.column, mouse.row).into())) - .map(|hit| hit.target.clone())?; - self.handle_choice_hit(target) + .map(|hit| &hit.target); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && matches!(target, Some(ChoicePickerHit::Search)) + && let Some(editable) = self.query_editable + { + let _ = editable.place_cursor(&mut self.query, mouse.column, mouse.row); + } + match self.click.update(&mouse, target) { + ClickOutcome::Armed => Some(PromptCandidatePickerEvent::Changed), + ClickOutcome::Activated(target) => self.handle_choice_hit(target), + ClickOutcome::Ignored => None, + } } _ => None, } @@ -257,6 +302,17 @@ pub fn render_prompt_candidate_picker( session: &mut PromptCandidatePickerSession, locale: Locale, ) -> ChoicePickerGeometry { + session.query_editable = None; + if area.is_empty() { + session.visible_height = 0; + session.footer_viewport = area; + session.footer_visible_height = 0; + return ChoicePickerGeometry { + search: area, + rows: area, + hits: Vec::new(), + }; + } let compact = area.height < 12 || area.width < 52; let outer = panel_block( text(locale, "Choose prompt variables").into_owned(), @@ -264,6 +320,16 @@ pub fn render_prompt_candidate_picker( ); let inner = outer.inner(area); frame.render_widget(outer, area); + if inner.is_empty() { + session.visible_height = 0; + session.footer_viewport = inner; + session.footer_visible_height = 0; + return ChoicePickerGeometry { + search: inner, + rows: inner, + hits: Vec::new(), + }; + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints(if compact { @@ -286,17 +352,11 @@ pub fn render_prompt_candidate_picker( let all = chunks[1]; let rows = chunks[2]; let footer = chunks[3]; - let search_block = if compact { - Block::default() - } else { - Block::default() - .borders(Borders::ALL) - .title(text(locale, "type to filter…")) - .border_style(Style::default().fg(ACCENT)) - }; - frame.render_widget( - Paragraph::new(session.query.value()).block(search_block), + session.query_editable = render_search_line_input( + frame, search, + &session.query, + &text(locale, "type to filter…"), ); frame.render_widget( Paragraph::new(format!( @@ -468,9 +528,15 @@ pub struct FilePickerSession { footer_scroll: ScrollableContentState, footer_viewport: Rect, footer_visible_height: usize, + click: ClickTracker, + query_editable: Option, } impl FilePickerSession { + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + } + /// Open the nearest readable ancestor of the requested start directory. #[must_use] pub fn new(contract: PathPickerState) -> Self { @@ -497,6 +563,8 @@ impl FilePickerSession { footer_scroll: ScrollableContentState::default(), footer_viewport: Rect::default(), footer_visible_height: 0, + click: ClickTracker::default(), + query_editable: None, } } @@ -526,7 +594,35 @@ impl FilePickerSession { geometry: &FilePickerGeometry, ) -> Option { match event { - Event::Key(key) if key.kind != KeyEventKind::Release => self.handle_key(key), + Event::Key(key) => { + self.click.cancel(); + if key.kind == KeyEventKind::Release { + None + } else { + self.handle_key(key) + } + } + Event::FocusGained | Event::FocusLost => { + self.click.cancel(); + None + } + Event::Mouse(mouse) + if geometry.rows.contains((mouse.column, mouse.row).into()) + && matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + self.click.cancel(); + self.handle_key(KeyEvent::new( + if mouse.kind == MouseEventKind::ScrollUp { + KeyCode::Up + } else { + KeyCode::Down + }, + KeyModifiers::NONE, + )) + } Event::Mouse(mouse) if handle_footer_scroll( &mut self.footer_scroll, @@ -537,13 +633,24 @@ impl FilePickerSession { { Some(FilePickerEvent::Changed) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { + Event::Mouse(mouse) => { let target = geometry .hits .iter() + .rev() .find(|hit| hit.area.contains((mouse.column, mouse.row).into())) - .map(|hit| hit.target.clone())?; - self.handle_hit(target) + .map(|hit| &hit.target); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && matches!(target, Some(FilePickerHit::Search)) + && let Some(editable) = self.query_editable + { + let _ = editable.place_cursor(&mut self.query, mouse.column, mouse.row); + } + match self.click.update(&mouse, target) { + ClickOutcome::Armed => Some(FilePickerEvent::Changed), + ClickOutcome::Activated(target) => self.handle_hit(target), + ClickOutcome::Ignored => None, + } } _ => None, } @@ -789,6 +896,17 @@ pub fn render_file_picker( session: &mut FilePickerSession, locale: Locale, ) -> FilePickerGeometry { + session.query_editable = None; + if area.is_empty() { + session.visible_height = 0; + session.footer_viewport = area; + session.footer_visible_height = 0; + return FilePickerGeometry { + search: area, + rows: area, + hits: Vec::new(), + }; + } let compact = area.height < 12 || area.width < 52; let outer = panel_block( match session.contract.purpose() { @@ -801,6 +919,16 @@ pub fn render_file_picker( ); let inner = outer.inner(area); frame.render_widget(outer, area); + if inner.is_empty() { + session.visible_height = 0; + session.footer_viewport = inner; + session.footer_visible_height = 0; + return FilePickerGeometry { + search: inner, + rows: inner, + hits: Vec::new(), + }; + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints(if compact { @@ -822,18 +950,8 @@ pub fn render_file_picker( let path_row = if compact { Rect::default() } else { chunks[1] }; let rows = if compact { chunks[1] } else { chunks[2] }; let footer = *chunks.last().unwrap_or(&Rect::default()); - let search_block = if compact { - Block::default() - } else { - Block::default() - .borders(Borders::ALL) - .title(text(locale, "Search")) - .border_style(Style::default().fg(ACCENT)) - }; - frame.render_widget( - Paragraph::new(session.query.value()).block(search_block), - search, - ); + session.query_editable = + render_search_line_input(frame, search, &session.query, &text(locale, "Search")); if !compact { frame.render_widget( Paragraph::new(session.explorer.current_dir.display().to_string()) @@ -976,6 +1094,8 @@ fn scrollable_picker_footer( (positioned, rows) = position_picker_footer_items(items, content_width); } scroll.set_lines(vec![String::new(); rows]); + crate::viewport::Viewport::new(Rect::new(area.x, area.y, content_width, area.height), rows) + .clamp_scroll(scroll); (positioned, rows, content_width) } @@ -1032,7 +1152,7 @@ fn position_picker_footer_items( let mut positioned = Vec::with_capacity(items.len()); for (label, target) in items { let desired = u16::try_from(label.width()).unwrap_or(u16::MAX).min(width); - if x > 0 && x.saturating_add(desired) > width { + if x.saturating_add(desired) > width { row = row.saturating_add(1); x = 0; } @@ -1159,6 +1279,211 @@ mod tests { }) } + fn prompt_click( + session: &mut PromptCandidatePickerSession, + geometry: &ChoicePickerGeometry, + area: Rect, + ) -> Option { + assert_eq!( + session.handle_event( + mouse(area, MouseEventKind::Down(MouseButton::Left)), + geometry, + ), + Some(PromptCandidatePickerEvent::Changed), + ); + session.handle_event(mouse(area, MouseEventKind::Up(MouseButton::Left)), geometry) + } + + fn file_click( + session: &mut FilePickerSession, + geometry: &FilePickerGeometry, + area: Rect, + ) -> Option { + assert_eq!( + session.handle_event( + mouse(area, MouseEventKind::Down(MouseButton::Left)), + geometry, + ), + Some(FilePickerEvent::Changed), + ); + session.handle_event(mouse(area, MouseEventKind::Up(MouseButton::Left)), geometry) + } + + #[test] + fn prompt_picker_cancels_an_armed_row_when_filtering_changes_its_identity() { + let picker = ChoicePicker::new( + PickerMode::Multiple, + vec![ + PickerItem::new("alpha".to_owned(), "alpha"), + PickerItem::new("beta".to_owned(), "beta"), + ], + Vec::new(), + ); + let mut session = PromptCandidatePickerSession::new(picker); + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + let mut geometry = ChoicePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = + render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let old = geometry + .hits + .iter() + .find(|hit| hit.target == ChoicePickerHit::Row(0)) + .expect("alpha row is visible") + .area; + assert_eq!(session.visible_names(), ["alpha", "beta"]); + assert_eq!( + session.handle_event( + mouse(old, MouseEventKind::Down(MouseButton::Left)), + &geometry, + ), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('b')), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + terminal + .draw(|frame| { + geometry = + render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let replacement = geometry + .hits + .iter() + .find(|hit| hit.target == ChoicePickerHit::Row(0)) + .expect("beta replaced alpha on the first row") + .area; + assert_eq!(replacement, old); + assert_eq!(session.visible_names(), ["beta"]); + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Up(MouseButton::Left)), + &geometry, + ), + None, + "a release must not toggle a new item that reused the old visual row", + ); + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Down(MouseButton::Left)), + &geometry, + ), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!(session.handle_event(Event::FocusLost, &geometry), None); + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Up(MouseButton::Left)), + &geometry, + ), + None, + "focus loss must cancel an armed picker row", + ); + } + + #[test] + fn file_picker_cancels_an_armed_entry_when_filtering_changes_its_identity() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("alpha.txt"), b"").unwrap(); + fs::write(dir.path().join("beta.txt"), b"").unwrap(); + let contract = PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::File, + skit_ui::PathOutputPolicy::Absolute, + false, + ); + let mut session = FilePickerSession::new(contract); + let mut terminal = Terminal::new(TestBackend::new(70, 14)).unwrap(); + let mut geometry = FilePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let parent = geometry + .hits + .iter() + .find(|hit| { + matches!(hit.target, FilePickerHit::Entry(index) if visible_entries(session.explorer()).get(index).is_some_and(|entry| entry.name == "..")) + }) + .expect("parent row is visible") + .area; + assert_eq!( + session.handle_event( + mouse(parent, MouseEventKind::Down(MouseButton::Left)), + &geometry, + ), + Some(FilePickerEvent::Changed) + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('a')), &geometry), + Some(FilePickerEvent::Changed) + ); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let alpha = geometry + .hits + .iter() + .find(|hit| { + matches!(hit.target, FilePickerHit::Entry(index) if visible_entries(session.explorer()).get(index).is_some_and(|entry| entry.name == "alpha.txt")) + }) + .expect("alpha file row replaced the parent") + .area; + assert_eq!(alpha, parent); + assert_eq!( + session.handle_event( + mouse(alpha, MouseEventKind::Up(MouseButton::Left)), + &geometry, + ), + None, + "a release must not accept a new file that reused the old visual row", + ); + + for focus_event in [Event::FocusLost, Event::FocusGained] { + assert_eq!( + session.handle_event( + mouse(alpha, MouseEventKind::Down(MouseButton::Left)), + &geometry, + ), + Some(FilePickerEvent::Changed) + ); + assert_eq!(session.handle_event(focus_event, &geometry), None); + assert_eq!( + session.handle_event( + mouse(alpha, MouseEventKind::Up(MouseButton::Left)), + &geometry, + ), + None, + "a focus transition left a stale file row armed", + ); + } + assert_eq!( + session.handle_event(Event::Resize(70, 14), &geometry), + None, + "the picker leaves terminal resize ownership to the root", + ); + } + + #[test] + fn empty_or_zero_width_picker_footers_have_no_positions() { + let (positioned, rows) = position_picker_footer_items::<()>(Vec::new(), 8); + assert!(positioned.is_empty()); + assert_eq!(rows, 0); + + let (positioned, rows) = position_picker_footer_items(vec![("Save".to_owned(), ())], 0); + assert!(positioned.is_empty()); + assert_eq!(rows, 0); + } + #[test] fn keyboard_filter_keeps_arrows_live_and_enter_descends_before_picking() { let dir = tempdir().unwrap(); @@ -1288,14 +1613,8 @@ mod tests { .is_some_and(|entry| entry.name == "tool.py") }) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: row.area.x, - row: row.area.y, - modifiers: KeyModifiers::NONE, - }); assert_eq!( - session.handle_event(mouse, &geometry), + file_click(&mut session, &geometry, row.area), Some(FilePickerEvent::Accepted(vec![dir.path().join("tool.py")])) ); } @@ -1424,13 +1743,10 @@ mod tests { .iter() .find(|hit| hit.target == target) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: hit.area.x, - row: hit.area.y, - modifiers: KeyModifiers::NONE, - }); - assert_eq!(session.handle_event(mouse, &geometry), Some(expected)); + assert_eq!( + prompt_click(&mut session, &geometry, hit.area), + Some(expected) + ); } let cancel = geometry @@ -1438,14 +1754,8 @@ mod tests { .iter() .find(|hit| hit.target == ChoicePickerHit::Cancel) .unwrap(); - let mouse = Event::Mouse(ratatui_crossterm::crossterm::event::MouseEvent { - kind: MouseEventKind::Down(ratatui_crossterm::crossterm::event::MouseButton::Left), - column: cancel.area.x, - row: cancel.area.y, - modifiers: KeyModifiers::NONE, - }); assert_eq!( - session.handle_event(mouse, &geometry), + prompt_click(&mut session, &geometry, cancel.area), Some(PromptCandidatePickerEvent::Cancelled) ); } @@ -1580,8 +1890,15 @@ mod tests { .iter() .find(|candidate| candidate.target == hit.target) .unwrap(); + assert_eq!( + mouse_session.handle_event( + mouse(mouse_hit.area, MouseEventKind::Down(MouseButton::Left)), + &mouse_geometry, + ), + Some(PromptCandidatePickerEvent::Changed) + ); let mouse_result = mouse_session.handle_event( - mouse(mouse_hit.area, MouseEventKind::Down(MouseButton::Left)), + mouse(mouse_hit.area, MouseEventKind::Up(MouseButton::Left)), &mouse_geometry, ); assert_eq!( @@ -1683,23 +2000,28 @@ mod tests { mouse(accept.area, MouseEventKind::Down(MouseButton::Left)), &file_geometry, ), + Some(FilePickerEvent::Changed) + ); + assert_eq!( + by_mouse.handle_event( + mouse(accept.area, MouseEventKind::Up(MouseButton::Left)), + &file_geometry, + ), expected ); } #[test] - fn prompt_picker_routes_every_real_key_paste_mouse_and_empty_result() { - let picker = ChoicePicker::new( - PickerMode::Single, - vec![ - PickerItem::new("alpha".to_owned(), "alpha"), - PickerItem::new("beta".to_owned(), "beta"), - PickerItem::new("gamma".to_owned(), "gamma"), - ], - Vec::new(), - ); + fn prompt_picker_wheel_owns_the_list_and_reaches_the_last_clickable_row() { + let items = (0..12) + .map(|index| { + let name = format!("item-{index:02}"); + PickerItem::new(name.clone(), name) + }) + .collect(); + let picker = ChoicePicker::new(PickerMode::Multiple, items, Vec::new()); let mut session = PromptCandidatePickerSession::new(picker); - let mut terminal = Terminal::new(TestBackend::new(52, 10)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(46, 8)).unwrap(); let mut geometry = ChoicePickerGeometry::default(); terminal .draw(|frame| { @@ -1708,60 +2030,194 @@ mod tests { }) .unwrap(); - for code in [ - KeyCode::Down, - KeyCode::Up, - KeyCode::End, - KeyCode::Home, - KeyCode::Tab, - KeyCode::BackTab, - ] { - assert_eq!( - session.handle_event(key(code), &geometry), - Some(PromptCandidatePickerEvent::Changed) - ); - } - assert_eq!(session.handle_event(control('x'), &geometry), None); - assert_eq!(session.handle_event(key(KeyCode::F(2)), &geometry), None); - assert_eq!( - session.handle_event(Event::Paste("be".to_owned()), &geometry), - Some(PromptCandidatePickerEvent::Changed) - ); - assert_eq!(session.visible_names(), ["beta"]); + assert_eq!(session.list.selected_index, 0); assert_eq!( - session.handle_event(key(KeyCode::Backspace), &geometry), - Some(PromptCandidatePickerEvent::Changed) + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollDown), &geometry,), + Some(PromptCandidatePickerEvent::Changed), ); + assert_eq!(session.list.selected_index, 1); assert_eq!( - session.handle_event(control('s'), &geometry), - Some(PromptCandidatePickerEvent::Accepted(vec![ - "beta".to_owned() - ])) + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollUp), &geometry,), + Some(PromptCandidatePickerEvent::Changed), ); - + assert_eq!(session.list.selected_index, 0); + for _ in 0..20 { + assert_eq!( + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollDown), &geometry,), + Some(PromptCandidatePickerEvent::Changed), + ); + } terminal .draw(|frame| { geometry = render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); }) .unwrap(); - for target in [ChoicePickerHit::Search, ChoicePickerHit::Row(0)] { - let hit = geometry + assert!( + geometry .hits .iter() - .find(|hit| hit.target == target) - .expect("the rendered choice target must be clickable"); - assert_eq!( - session.handle_event( - mouse(hit.area, MouseEventKind::Down(MouseButton::Left)), - &geometry, - ), - Some(PromptCandidatePickerEvent::Changed) - ); - } - let search = geometry.search; - assert_eq!( - session.handle_event(mouse(search, MouseEventKind::Moved), &geometry), + .any(|hit| hit.target == ChoicePickerHit::Row(11)), + "wheel scrolling must make the final semantic row visible and clickable", + ); + } + + #[test] + fn prompt_picker_search_click_places_the_caret_before_typing() { + let picker = ChoicePicker::new(PickerMode::Multiple, Vec::new(), Vec::new()); + let mut session = PromptCandidatePickerSession::new(picker); + session.query = LineInput::new("abcdef".to_owned()); + let mut terminal = Terminal::new(TestBackend::new(46, 8)).unwrap(); + let mut geometry = ChoicePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = + render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let _ = prompt_click( + &mut session, + &geometry, + Rect::new(geometry.search.x.saturating_add(3), geometry.search.y, 1, 1), + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('X')), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!(session.query.value(), "abXcdef"); + + assert_eq!( + session.handle_event( + mouse(geometry.search, MouseEventKind::Down(MouseButton::Left)), + &geometry, + ), + Some(PromptCandidatePickerEvent::Changed) + ); + session.cancel_click(); + assert_eq!( + session.handle_event( + mouse(geometry.search, MouseEventKind::Up(MouseButton::Left)), + &geometry, + ), + None + ); + } + + #[test] + fn file_picker_wheel_reaches_the_last_clickable_entry() { + let dir = tempdir().unwrap(); + for index in 0..20 { + fs::write(dir.path().join(format!("item-{index:02}")), b"").unwrap(); + } + let contract = PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::File, + skit_ui::PathOutputPolicy::Absolute, + false, + ); + let mut session = FilePickerSession::new(contract); + let mut terminal = Terminal::new(TestBackend::new(46, 8)).unwrap(); + let mut geometry = FilePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let initial = session.explorer.cursor_index; + assert_eq!( + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollDown), &geometry,), + Some(FilePickerEvent::Changed) + ); + assert_eq!(session.explorer.cursor_index, initial.saturating_add(1)); + assert_eq!( + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollUp), &geometry,), + Some(FilePickerEvent::Changed) + ); + assert_eq!(session.explorer.cursor_index, initial); + for _ in 0..40 { + assert_eq!( + session.handle_event(mouse(geometry.rows, MouseEventKind::ScrollDown), &geometry,), + Some(FilePickerEvent::Changed) + ); + } + let last = visible_entries(session.explorer()).len().saturating_sub(1); + assert_eq!(session.explorer.cursor_index, last); + assert!(session.explorer.scroll > 0); + } + + #[test] + fn prompt_picker_routes_every_real_key_paste_mouse_and_empty_result() { + let picker = ChoicePicker::new( + PickerMode::Single, + vec![ + PickerItem::new("alpha".to_owned(), "alpha"), + PickerItem::new("beta".to_owned(), "beta"), + PickerItem::new("gamma".to_owned(), "gamma"), + ], + Vec::new(), + ); + let mut session = PromptCandidatePickerSession::new(picker); + let mut terminal = Terminal::new(TestBackend::new(52, 10)).unwrap(); + let mut geometry = ChoicePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = + render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + + for code in [ + KeyCode::Down, + KeyCode::Up, + KeyCode::End, + KeyCode::Home, + KeyCode::Tab, + KeyCode::BackTab, + ] { + assert_eq!( + session.handle_event(key(code), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + } + assert_eq!(session.handle_event(control('x'), &geometry), None); + assert_eq!(session.handle_event(key(KeyCode::F(2)), &geometry), None); + assert_eq!( + session.handle_event(Event::Paste("be".to_owned()), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!(session.visible_names(), ["beta"]); + assert_eq!( + session.handle_event(key(KeyCode::Backspace), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!( + session.handle_event(control('s'), &geometry), + Some(PromptCandidatePickerEvent::Accepted(vec![ + "beta".to_owned() + ])) + ); + + terminal + .draw(|frame| { + geometry = + render_prompt_candidate_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + for target in [ChoicePickerHit::Search, ChoicePickerHit::Row(0)] { + let hit = geometry + .hits + .iter() + .find(|hit| hit.target == target) + .expect("the rendered choice target must be clickable"); + assert_eq!( + prompt_click(&mut session, &geometry, hit.area), + Some(PromptCandidatePickerEvent::Changed) + ); + } + let search = geometry.search; + assert_eq!( + session.handle_event(mouse(search, MouseEventKind::Moved), &geometry), None ); assert_eq!( @@ -1892,6 +2348,10 @@ mod tests { ), None ); + assert_eq!( + file_click(&mut session, &geometry, search), + Some(FilePickerEvent::Changed) + ); assert_eq!( session.handle_event( mouse(search, MouseEventKind::Down(MouseButton::Left)), @@ -1899,6 +2359,14 @@ mod tests { ), Some(FilePickerEvent::Changed) ); + session.cancel_click(); + assert_eq!( + session.handle_event( + mouse(search, MouseEventKind::Up(MouseButton::Left)), + &geometry + ), + None + ); assert_eq!(session.handle_hit(FilePickerHit::Entry(usize::MAX)), None); assert_eq!(session.handle_hit(FilePickerHit::CurrentDirectory), None); assert_eq!( @@ -1924,10 +2392,7 @@ mod tests { assert_eq!(row_indices.len(), 2); for (_, area) in row_indices { assert_eq!( - session.handle_event( - mouse(area, MouseEventKind::Down(MouseButton::Left)), - &geometry, - ), + file_click(&mut session, &geometry, area), Some(FilePickerEvent::Changed) ); } @@ -2132,4 +2597,468 @@ mod tests { assert!(!buffer_text(&failed_terminal).trim().is_empty()); } } + + #[test] + fn picker_releases_and_multiplicity_keep_distinct_keyboard_owners() { + let picker = ChoicePicker::new( + PickerMode::Multiple, + vec![PickerItem::new("name".to_owned(), "name")], + Vec::new(), + ); + let mut prompt = PromptCandidatePickerSession::new(picker); + let released_escape = Event::Key(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + assert_eq!( + prompt.handle_event(released_escape, &ChoicePickerGeometry::default()), + None, + ); + assert_eq!( + prompt.handle_event(key(KeyCode::Esc), &ChoicePickerGeometry::default()), + Some(PromptCandidatePickerEvent::Cancelled), + ); + + let dir = tempdir().unwrap(); + let file = dir.path().join("alpha.txt"); + fs::write(&file, b"").unwrap(); + let contract = |multiple| { + PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::File, + skit_ui::PathOutputPolicy::Absolute, + multiple, + ) + }; + let geometry = FilePickerGeometry::default(); + let mut single = FilePickerSession::new(contract(false)); + assert_eq!( + single.handle_event( + Event::Key(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &geometry, + ), + None, + ); + assert_eq!(single.handle_event(control('a'), &geometry), None); + assert_eq!(single.handle_event(control('n'), &geometry), None); + assert_eq!( + single.handle_event(key(KeyCode::Char(' ')), &geometry), + Some(FilePickerEvent::Changed), + ); + assert!(single.explorer().selected_files.is_empty()); + + let mut multiple = FilePickerSession::new(contract(true)); + assert_eq!( + multiple.handle_event(key(KeyCode::Char(' ')), &geometry), + Some(FilePickerEvent::Changed), + ); + assert!(multiple.explorer().selected_files.contains(&file)); + assert_eq!( + multiple.handle_event(control('n'), &geometry), + Some(FilePickerEvent::Changed), + ); + assert!(multiple.explorer().selected_files.is_empty()); + assert_eq!( + multiple.handle_event(control('a'), &geometry), + Some(FilePickerEvent::Changed), + ); + assert!(multiple.explorer().selected_files.contains(&file)); + + let mut current_directory = FilePickerSession::new(PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::Directory, + skit_ui::PathOutputPolicy::Absolute, + true, + )); + assert_eq!( + current_directory.handle_event(key(KeyCode::Up), &geometry), + Some(FilePickerEvent::Changed), + ); + assert_eq!( + current_directory.handle_event(key(KeyCode::Char(' ')), &geometry), + Some(FilePickerEvent::Changed), + ); + assert!(current_directory.explorer().selected_files.is_empty()); + } + + #[test] + fn file_picker_never_creates_an_unavailable_or_stale_current_directory_owner() { + let dir = tempdir().unwrap(); + let alpha = dir.path().join("alpha.txt"); + fs::write(&alpha, b"").unwrap(); + let mut files = FilePickerSession::new(PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::File, + skit_ui::PathOutputPolicy::Absolute, + false, + )); + let default_geometry = FilePickerGeometry::default(); + assert_eq!( + files.handle_event(key(KeyCode::Up), &default_geometry), + Some(FilePickerEvent::Changed), + ); + assert_eq!( + files.handle_event(key(KeyCode::Enter), &default_geometry), + Some(FilePickerEvent::Changed), + ); + assert_eq!(files.current_dir(), &dir.path().parent().unwrap()); + + let mut directories = FilePickerSession::new(PathPickerState::new( + PickerPurpose::WorkingDirectory, + dir.path().to_path_buf(), + PathSelectionMode::Directory, + skit_ui::PathOutputPolicy::Absolute, + false, + )); + let mut terminal = Terminal::new(TestBackend::new(70, 14)).unwrap(); + let mut stale = FilePickerGeometry::default(); + terminal + .draw(|frame| { + stale = render_file_picker(frame, frame.area(), &mut directories, Locale::En); + }) + .unwrap(); + let current = stale + .hits + .iter() + .find(|hit| hit.target == FilePickerHit::CurrentDirectory) + .expect("the empty filter exposes the current-directory row") + .area; + assert_eq!( + directories.handle_event(key(KeyCode::Char('z')), &stale), + Some(FilePickerEvent::Changed), + ); + assert_eq!(file_click(&mut directories, &stale, current), None); + } + + #[test] + fn picker_breakpoints_change_only_on_the_documented_height_and_width_edges() { + let picker = || { + ChoicePicker::new( + PickerMode::Multiple, + vec![PickerItem::new("name".to_owned(), "name")], + Vec::new(), + ) + }; + let dir = tempdir().unwrap(); + fs::write(dir.path().join("alpha.txt"), b"").unwrap(); + for (width, height, search_height, compact) in + [(52, 11, 1, true), (52, 12, 3, false), (51, 12, 1, true)] + { + let mut prompt = PromptCandidatePickerSession::new(picker()); + let mut prompt_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut prompt_geometry = ChoicePickerGeometry::default(); + prompt_terminal + .draw(|frame| { + prompt_geometry = render_prompt_candidate_picker( + frame, + frame.area(), + &mut prompt, + Locale::En, + ); + }) + .unwrap(); + assert_eq!(prompt_geometry.search.height, search_height); + assert!(prompt_geometry.hits.iter().any(|hit| { + hit.target == ChoicePickerHit::Search && hit.area == prompt_geometry.search + })); + + let mut file = FilePickerSession::new(PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::File, + skit_ui::PathOutputPolicy::Absolute, + false, + )); + let mut file_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut file_geometry = FilePickerGeometry::default(); + file_terminal + .draw(|frame| { + file_geometry = render_file_picker(frame, frame.area(), &mut file, Locale::En); + }) + .unwrap(); + assert_eq!(file_geometry.search.height, search_height); + assert_eq!( + buffer_text(&file_terminal).contains(&dir.path().display().to_string()), + !compact, + ); + } + } + + #[test] + fn file_rows_keep_cursor_and_selection_marks_with_their_exact_owners() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("alpha.txt"), b"").unwrap(); + fs::write(dir.path().join("beta.txt"), b"").unwrap(); + fs::create_dir(dir.path().join("folder")).unwrap(); + let contract = |multiple| { + PathPickerState::new( + PickerPurpose::Source, + dir.path().to_path_buf(), + PathSelectionMode::FileOrDirectory, + skit_ui::PathOutputPolicy::Absolute, + multiple, + ) + }; + let render = |session: &mut FilePickerSession| { + let mut terminal = Terminal::new(TestBackend::new(70, 14)).unwrap(); + let mut geometry = FilePickerGeometry::default(); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), session, Locale::En); + }) + .unwrap(); + (terminal, geometry) + }; + let row_for = |session: &FilePickerSession, geometry: &FilePickerGeometry, name: &str| { + geometry + .hits + .iter() + .find(|hit| { + let FilePickerHit::Entry(index) = hit.target else { + return false; + }; + visible_entries(session.explorer()) + .get(index) + .is_some_and(|entry| entry.name == name) + }) + .expect("the named entry has a visible row") + .area + }; + + let mut single = FilePickerSession::new(contract(false)); + let (single_terminal, single_geometry) = render(&mut single); + let cursor = single.explorer().cursor_index; + let focused = single_geometry + .hits + .iter() + .find(|hit| hit.target == FilePickerHit::Entry(cursor)) + .expect("the cursor entry has a visible hit") + .area; + let sibling = single_geometry + .hits + .iter() + .find(|hit| matches!(hit.target, FilePickerHit::Entry(index) if index != cursor)) + .expect("a sibling entry is visible") + .area; + assert_eq!( + single_terminal.backend().buffer()[(focused.x, focused.y)].bg, + SELECT_BG, + ); + assert_ne!( + single_terminal.backend().buffer()[(sibling.x, sibling.y)].bg, + SELECT_BG, + ); + let alpha = row_for(&single, &single_geometry, "alpha.txt"); + assert_eq!( + single_terminal.backend().buffer()[(alpha.x, alpha.y)].symbol(), + " " + ); + + let mut multiple = FilePickerSession::new(contract(true)); + let (multiple_terminal, multiple_geometry) = render(&mut multiple); + let alpha = row_for(&multiple, &multiple_geometry, "alpha.txt"); + let folder = row_for(&multiple, &multiple_geometry, "folder"); + assert_eq!( + multiple_terminal.backend().buffer()[(alpha.x, alpha.y)].symbol(), + "☐" + ); + assert_eq!( + multiple_terminal.backend().buffer()[(folder.x, folder.y)].symbol(), + " " + ); + + assert_eq!( + multiple.handle_event(key(KeyCode::Up), &multiple_geometry), + Some(FilePickerEvent::Changed), + ); + let (focused_terminal, focused_geometry) = render(&mut multiple); + let current = focused_geometry + .hits + .iter() + .find(|hit| hit.target == FilePickerHit::CurrentDirectory) + .expect("directory selection exposes its current-directory row") + .area; + assert_eq!( + focused_terminal.backend().buffer()[(current.x, current.y)].bg, + SELECT_BG, + ); + for hit in focused_geometry + .hits + .iter() + .filter(|hit| matches!(hit.target, FilePickerHit::Entry(_))) + { + assert_ne!( + focused_terminal.backend().buffer()[(hit.area.x, hit.area.y)].bg, + SELECT_BG, + ); + } + } + + #[test] + fn choice_footer_preserves_exact_fit_order_and_strict_overflow_order() { + let done = format!("[Ctrl+S] {}", text(Locale::En, "Done")); + let cancel = format!("[Esc] {}", text(Locale::En, "Cancel")); + let required = u16::try_from( + done.width() + .saturating_add(cancel.width()) + .saturating_add(2), + ) + .unwrap(); + for (width, first, same_row) in [ + (required - 1, ChoicePickerHit::Cancel, true), + (required, ChoicePickerHit::Done, true), + (required + 1, ChoicePickerHit::Done, true), + ] { + let picker = ChoicePicker::new( + PickerMode::Multiple, + vec![PickerItem::new("name".to_owned(), "name")], + Vec::new(), + ); + let mut session = PromptCandidatePickerSession::new(picker); + let mut terminal = Terminal::new(TestBackend::new(required + 2, 3)).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + hits = render_choice_footer( + frame, + Rect::new(1, 1, width, 1), + Locale::En, + &mut session, + ); + }) + .unwrap(); + assert_eq!(hits.first().map(|hit| &hit.target), Some(&first)); + assert_eq!( + hits.len() == 2 && hits[0].area.y == hits[1].area.y, + same_row + ); + } + } + + #[test] + fn picker_footer_uses_half_open_rows_and_exact_overflow_indicators() { + let positioned = |rows: usize| { + (0..rows) + .map(|row| PositionedPickerFooterItem { + label: char::from(b'A' + u8::try_from(row).unwrap()).to_string(), + target: row, + row, + x: 0, + width: 1, + }) + .collect::>() + }; + let area = Rect::new(1, 1, 6, 1); + let mut terminal = Terminal::new(TestBackend::new(10, 4)).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + frame.render_widget(Paragraph::new("SENTINEL"), Rect::new(1, 2, 8, 1)); + let scroll = ScrollableContentState::default(); + hits = render_picker_footer_items(frame, area, positioned(2), 2, &scroll); + }) + .unwrap(); + assert_eq!(hits, vec![(Rect::new(1, 1, 1, 1), 0)]); + assert_eq!(terminal.backend().buffer()[(1, 2)].symbol(), "S"); + assert_eq!( + terminal.backend().buffer()[(area.right() - 1, area.y)].symbol(), + "↓" + ); + + let mut exact = Terminal::new(TestBackend::new(10, 4)).unwrap(); + exact + .draw(|frame| { + let scroll = ScrollableContentState::default(); + let item = PositionedPickerFooterItem { + label: "Z".to_owned(), + target: 0, + row: 0, + x: area.width - 1, + width: 1, + }; + let exact_hits = render_picker_footer_items(frame, area, vec![item], 1, &scroll); + assert_eq!(exact_hits.len(), 1); + }) + .unwrap(); + assert_eq!( + exact.backend().buffer()[(area.right() - 1, area.y)].symbol(), + "Z" + ); + + for (offset, indicator, target) in [(1, "↕", 1), (2, "↑", 2)] { + let mut terminal = Terminal::new(TestBackend::new(10, 4)).unwrap(); + terminal + .draw(|frame| { + let mut scroll = ScrollableContentState::default(); + scroll.set_lines(vec![String::new(); 3]); + scroll.set_scroll_offset(offset); + let visible = + render_picker_footer_items(frame, area, positioned(3), 3, &scroll); + assert_eq!(visible[0].1, target); + }) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(area.right() - 1, area.y)].symbol(), + indicator, + ); + } + } + + #[test] + fn scrollable_picker_footer_reserves_only_a_real_indicator_column() { + for (width, expected_hit_width) in [(7, 3), (2, 1), (1, 1)] { + let mut terminal = Terminal::new(TestBackend::new(10, 3)).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| { + let area = Rect::new(1, 1, width, 1); + let labels = if width == 7 { + vec![("AAA".to_owned(), 0), ("BBB".to_owned(), 1)] + } else { + vec![("AA".to_owned(), 0), ("BB".to_owned(), 1)] + }; + let mut scroll = ScrollableContentState::default(); + let (positioned, rows, _) = scrollable_picker_footer(labels, area, &mut scroll); + hits = render_picker_footer_items(frame, area, positioned, rows, &scroll); + }) + .unwrap(); + assert_eq!(hits[0].0.width, expected_hit_width); + if width == 7 { + assert_eq!(hits.len(), 2, "an exact-fit footer wrapped at equality"); + assert_eq!(hits[0].0.y, hits[1].0.y); + } + } + } + + #[test] + fn file_picker_end_targets_the_last_real_directory_not_the_parent_or_current_row() { + let dir = tempdir().unwrap(); + let child = dir.path().join("child"); + fs::create_dir(&child).unwrap(); + let mut session = FilePickerSession::new(PathPickerState::new( + PickerPurpose::WorkingDirectory, + dir.path().to_path_buf(), + PathSelectionMode::Directory, + skit_ui::PathOutputPolicy::Absolute, + false, + )); + let geometry = FilePickerGeometry::default(); + assert_eq!( + session.handle_event(key(KeyCode::End), &geometry), + Some(FilePickerEvent::Changed), + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter), &geometry), + Some(FilePickerEvent::Changed), + ); + assert_eq!(session.current_dir(), &child); + } } diff --git a/crates/skit-tui/src/screens/preferences.rs b/crates/skit-tui/src/screens/preferences.rs index 1755e1d4..03042ec6 100644 --- a/crates/skit-tui/src/screens/preferences.rs +++ b/crates/skit-tui/src/screens/preferences.rs @@ -1,6 +1,6 @@ //! Application-preference widgets. -use std::{collections::HashMap, fmt::Display}; +use std::{cmp::Ordering, collections::HashMap, fmt::Display}; use ratatui_core::{ layout::{Constraint, Flex, Layout, Rect}, @@ -9,22 +9,22 @@ use ratatui_core::{ text::{Line, Span}, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind, }; use ratatui_interact::{ components::{ Button, ButtonState, ButtonVariant, ListPicker, ListPickerState, ListPickerStyle, ScrollableContentState, Select, SelectAction, SelectState, handle_scrollable_content_key, - handle_scrollable_content_mouse, handle_select_key, handle_select_mouse, + handle_scrollable_content_mouse, handle_select_key, }, state::FocusManager, traits::{ClickRegion, ClickRegionRegistry}, }; use ratatui_widgets::{clear::Clear, paragraph::Paragraph, paragraph::Wrap}; -use skit_application::AgentScope; use skit_application::preferences::{ AfterRunChoice, InteractiveFormChoice, JavascriptChoice, MirrorChoice, PreferencesField, }; +use skit_application::{AgentScope, AgentTarget}; use skit_i18n::{Locale, Localize, format_text, text}; use skit_ui::{ ChoicePresentation, PreferencesAction, PreferencesControl, PreferencesControlId, @@ -35,9 +35,11 @@ use tui_input::{Input as LineInput, backend::crossterm::EventHandler as _}; use unicode_width::UnicodeWidthStr as _; use crate::{ + pointer::{ClickOutcome, ClickTracker, EditableGeometry, is_primary_down}, rowclip::RowClip, session::{radio_style, render_line_input_band, select_style}, theme::{ACCENT, BOX_DIM, BOX_INDIGO, padded_panel}, + viewport::AlignmentSignature, }; /// Result of one Preferences widget event. @@ -51,6 +53,15 @@ pub(crate) enum PreferencesEventHandling { Ignored, } +/// Result from the blocking agent-install overlay, which accepts every event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AgentSkillOverlayEventHandling { + /// Dispatch a semantic action through the Preferences reducer. + Action(PreferencesAction), + /// Keep the event inside the modal overlay. + Consumed, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct PreferencesSignature(Vec<(PreferencesControlId, PreferencesControlShape)>); @@ -73,22 +84,26 @@ enum PreferencesWidget { labels: Vec, presentation: ChoicePresentation, buttons: Vec, - select_area: Rect, + select_area: Option, dropdown_regions: Vec>, }, Button(ButtonState), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] enum PreferencesHit { Control(PreferencesControlId), Radio { id: PreferencesControlId, option: usize, }, + Dropdown { + id: PreferencesControlId, + option: usize, + }, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] enum AgentSkillHit { Target(usize), Cancel, @@ -120,17 +135,32 @@ pub(crate) struct PreferencesWidgetSession { visible_height: usize, content_height: usize, control_areas: Vec<(PreferencesControlId, Rect)>, + editables: HashMap, clicks: ClickRegionRegistry, + click: ClickTracker, agent_picker: ListPickerState, agent_picker_height: usize, + agent_list_area: Rect, agent_cancel: ButtonState, agent_clicks: ClickRegionRegistry, + agent_click: ClickTracker, + agent_signature: Option>, agent_target_areas: Vec<(usize, Rect)>, agent_cancel_area: Option, pending_ensure_focus: bool, + alignment: Option>, } impl PreferencesWidgetSession { + pub(crate) fn cancel_underlay_click(&mut self) { + self.click.cancel(); + } + + pub(crate) fn cancel_click(&mut self) { + self.cancel_underlay_click(); + self.agent_click.cancel(); + } + /// Render the complete Preferences workflow. pub(crate) fn render( &mut self, @@ -141,7 +171,20 @@ impl PreferencesWidgetSession { ) { self.sync(view, locale); self.clicks.clear(); + self.editables.clear(); self.control_areas.clear(); + for widget in self.widgets.values_mut() { + if let PreferencesWidget::Choice { + presentation: ChoicePresentation::Picker, + select_area, + dropdown_regions, + .. + } = widget + { + *select_area = None; + dropdown_regions.clear(); + } + } let block = padded_panel(text(locale, "Preferences").into_owned(), BOX_INDIGO); let inner = block.inner(area); @@ -156,13 +199,18 @@ impl PreferencesWidgetSession { self.scroll .set_lines(vec![String::new(); self.content_height]); let maximum = self.maximum_scroll_offset(); - if self.scroll.scroll_offset() > maximum { - self.scroll.set_scroll_offset(maximum); - } - if self.pending_ensure_focus - && let Some(item) = items.iter().find(|item| { - matches!(&item.item, RenderItem::Control(control) if control.id == view.focused()) - }) + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum)); + let focused_item = items.iter().find(|item| { + matches!(&item.item, RenderItem::Control(control) if control.id == view.focused()) + }); + let reflow = focused_item.map_or((self.content_height, 0, 0), |item| { + (self.content_height, item.start, item.height) + }); + let alignment_changed = + AlignmentSignature::update(&mut self.alignment, view.focused(), inner, reflow); + if (self.pending_ensure_focus || alignment_changed) + && let Some(item) = focused_item { self.ensure_visible(item.start, item.height); self.pending_ensure_focus = false; @@ -194,6 +242,9 @@ impl PreferencesWidgetSession { if let Some(picker) = view.agent_skill_install() { self.render_agent_skill_picker(frame, area, picker, locale); } else { + if self.agent_signature.take().is_some() { + self.agent_click.cancel(); + } self.agent_clicks.clear(); self.agent_target_areas.clear(); self.agent_cancel_area = None; @@ -207,9 +258,17 @@ impl PreferencesWidgetSession { event: Event, view: &PreferencesView, ) -> PreferencesEventHandling { - self.sync(view, Locale::En); if let Some(picker) = view.agent_skill_install() { - return self.handle_agent_skill_event(event, picker); + return match self.handle_agent_skill_overlay_event(event, view, picker) { + AgentSkillOverlayEventHandling::Action(action) => { + PreferencesEventHandling::Action(action) + } + AgentSkillOverlayEventHandling::Consumed => PreferencesEventHandling::Consumed, + }; + } + self.sync(view, Locale::En); + if matches!(event, Event::FocusGained | Event::FocusLost) { + self.cancel_click(); } let focused = view.focused(); @@ -220,6 +279,44 @@ impl PreferencesWidgetSession { return handling; } if let Event::Mouse(mouse) = &event { + if is_primary_down(mouse) + && let Some(PreferencesHit::Control(id)) = + self.clicks.handle_click(mouse.column, mouse.row).cloned() + && let Some(editable) = self.editables.get(&id).copied() + && let Some(PreferencesWidget::Input(input)) = self.widgets.get_mut(&id) + { + let _ = editable.place_cursor(input, mouse.column, mouse.row); + } + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + self.click.cancel(); + for widget in self.widgets.values_mut() { + let PreferencesWidget::Choice { + state, + presentation: ChoicePresentation::Picker, + dropdown_regions, + .. + } = widget + else { + continue; + }; + if state.is_open + && dropdown_regions + .iter() + .any(|region| region.contains(mouse.column, mouse.row)) + { + if mouse.kind == MouseEventKind::ScrollUp { + state.highlight_prev(); + } else { + state.highlight_next(); + } + state.ensure_visible(dropdown_regions.len().max(1)); + return PreferencesEventHandling::Consumed; + } + } + } if matches!( mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown @@ -233,16 +330,48 @@ impl PreferencesWidgetSession { { return PreferencesEventHandling::Consumed; } - if !matches!(mouse.kind, MouseEventKind::Down(_)) { - return PreferencesEventHandling::Ignored; - } - if let Some(handling) = self.handle_select_mouse(mouse) { - return handling; - } - if let Some(hit) = self.clicks.handle_click(mouse.column, mouse.row).cloned() { - return self.activate_hit(hit, view); - } - return PreferencesEventHandling::Ignored; + let dropdown = self.widgets.iter().find_map(|(id, widget)| { + let PreferencesWidget::Choice { + state, + presentation: ChoicePresentation::Picker, + dropdown_regions, + .. + } = widget + else { + return None; + }; + state.is_open.then_some(())?; + dropdown_regions + .iter() + .rev() + .find(|region| region.contains(mouse.column, mouse.row)) + .and_then(|region| match region.data { + SelectAction::Select(option) => { + Some(PreferencesHit::Dropdown { id: *id, option }) + } + SelectAction::Focus | SelectAction::Open | SelectAction::Close => None, + }) + }); + let target = + dropdown.or_else(|| self.clicks.handle_click(mouse.column, mouse.row).cloned()); + return match self.click.update(mouse, target.as_ref()) { + ClickOutcome::Armed => PreferencesEventHandling::Consumed, + ClickOutcome::Activated(PreferencesHit::Dropdown { id, option }) => self + .widgets + .get_mut(&id) + .and_then(|widget| match widget { + PreferencesWidget::Choice { state, values, .. } => { + state.close(); + values.get(option) + } + PreferencesWidget::Input(_) | PreferencesWidget::Button(_) => None, + }) + .map_or(PreferencesEventHandling::Consumed, |value| { + choice_action(id, value) + }), + ClickOutcome::Activated(hit) => self.activate_hit(hit, view), + ClickOutcome::Ignored => PreferencesEventHandling::Ignored, + }; } if let Event::Paste(value) = event { return self.handle_paste(focused, &value); @@ -381,6 +510,10 @@ impl PreferencesWidgetSession { picker: &skit_ui::AgentSkillInstallView, locale: Locale, ) { + if self.agent_signature.as_deref() != Some(picker.targets()) { + self.agent_click.cancel(); + self.agent_signature = Some(picker.targets().to_vec()); + } self.agent_clicks.clear(); self.agent_target_areas.clear(); self.agent_cancel_area = None; @@ -401,13 +534,14 @@ impl PreferencesWidgetSession { frame.render_widget(block, panel); let preview_height = u16::from(!picker.targets().is_empty() && inner.height >= 3); - let cancel_height = u16::from(inner.height > 0); + let cancel_height = inner.height.min(1); let [list_area, preview_area, cancel_area] = Layout::vertical([ Constraint::Min(0), Constraint::Length(preview_height), Constraint::Length(cancel_height), ]) .areas(inner); + self.agent_list_area = list_area; self.agent_picker_height = usize::from(list_area.height); self.agent_picker .ensure_visible(self.agent_picker_height.max(1)); @@ -492,21 +626,34 @@ impl PreferencesWidgetSession { } } + pub(crate) fn handle_agent_skill_overlay_event( + &mut self, + event: Event, + view: &PreferencesView, + picker: &skit_ui::AgentSkillInstallView, + ) -> AgentSkillOverlayEventHandling { + self.sync(view, Locale::En); + if matches!(event, Event::FocusGained | Event::FocusLost) { + self.cancel_click(); + } + self.handle_agent_skill_event(event, picker) + } + fn handle_agent_skill_event( &mut self, event: Event, picker: &skit_ui::AgentSkillInstallView, - ) -> PreferencesEventHandling { + ) -> AgentSkillOverlayEventHandling { let selected = picker.selected().unwrap_or_default(); let selection = match event { Event::Key(key) if key.kind != KeyEventKind::Release => match key.code { KeyCode::Esc => { - return PreferencesEventHandling::Action( + return AgentSkillOverlayEventHandling::Action( PreferencesAction::CloseAgentSkillTargets, ); } KeyCode::Enter if picker.selected().is_some() => { - return PreferencesEventHandling::Action( + return AgentSkillOverlayEventHandling::Action( PreferencesAction::ConfirmAgentSkillTarget, ); } @@ -524,14 +671,17 @@ impl PreferencesWidgetSession { .saturating_add(self.agent_picker_height.max(1)) .min(picker.targets().len().saturating_sub(1)), ), - _ => return PreferencesEventHandling::Consumed, + _ => return AgentSkillOverlayEventHandling::Consumed, }, Event::Mouse(mouse) if matches!( mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => + ) && self + .agent_list_area + .contains((mouse.column, mouse.row).into()) => { + self.agent_click.cancel(); Some(if mouse.kind == MouseEventKind::ScrollUp { selected.saturating_sub(1) } else { @@ -540,32 +690,36 @@ impl PreferencesWidgetSession { .min(picker.targets().len().saturating_sub(1)) }) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - return match self - .agent_clicks - .handle_click(mouse.column, mouse.row) - .cloned() - { - Some(AgentSkillHit::Target(index)) => PreferencesEventHandling::Action( - PreferencesAction::ActivateAgentSkillTarget(index), - ), - Some(AgentSkillHit::Cancel) => { - PreferencesEventHandling::Action(PreferencesAction::CloseAgentSkillTargets) + Event::Mouse(mouse) => { + let target = self.agent_clicks.handle_click(mouse.column, mouse.row); + return match self.agent_click.update(&mouse, target) { + ClickOutcome::Activated(AgentSkillHit::Target(index)) => { + AgentSkillOverlayEventHandling::Action( + PreferencesAction::ActivateAgentSkillTarget(index), + ) + } + ClickOutcome::Activated(AgentSkillHit::Cancel) => { + AgentSkillOverlayEventHandling::Action( + PreferencesAction::CloseAgentSkillTargets, + ) + } + ClickOutcome::Armed | ClickOutcome::Ignored => { + AgentSkillOverlayEventHandling::Consumed } - None => PreferencesEventHandling::Consumed, }; } Event::FocusGained | Event::FocusLost - | Event::Mouse(_) | Event::Paste(_) | Event::Key(_) - | Event::Resize(_, _) => return PreferencesEventHandling::Consumed, + | Event::Resize(_, _) => return AgentSkillOverlayEventHandling::Consumed, }; selection.filter(|_| !picker.targets().is_empty()).map_or( - PreferencesEventHandling::Consumed, + AgentSkillOverlayEventHandling::Consumed, |index| { - PreferencesEventHandling::Action(PreferencesAction::SelectAgentSkillTarget(index)) + AgentSkillOverlayEventHandling::Action(PreferencesAction::SelectAgentSkillTarget( + index, + )) }, ) } @@ -579,6 +733,7 @@ impl PreferencesWidgetSession { .collect(), ); if self.signature.as_ref() != Some(&signature) { + self.click.cancel(); self.widgets = controls .iter() .map(|control| (control.id, widget(control, locale))) @@ -621,7 +776,11 @@ impl PreferencesWidgetSession { }; match widget { PreferencesWidget::Input(state) => { - render_line_input_band(frame, clip, state, false, focused, &label, None); + if let Some(editable) = + render_line_input_band(frame, clip, state, false, focused, &label, None) + { + self.editables.insert(control.id, editable); + } if state.value().is_empty() && let PreferencesControlKind::Text(model) = &control.kind && let Some(content) = clip.row(1) @@ -655,7 +814,7 @@ impl PreferencesWidgetSession { .placeholder(&placeholder) .style(select_style()) .render_stateful(frame, area); - *select_area = region.area; + *select_area = Some(region.area); } else { let style = select_style(); let display = state @@ -685,10 +844,12 @@ impl PreferencesWidgetSession { Style::default().fg(border), 0, ); - *select_area = area; + *select_area = Some(area); + } + if let Some(select_area) = *select_area { + self.clicks + .register(select_area, PreferencesHit::Control(control.id)); } - self.clicks - .register(*select_area, PreferencesHit::Control(control.id)); } PreferencesWidget::Choice { state, @@ -743,9 +904,13 @@ impl PreferencesWidgetSession { continue; }; if state.is_open { + let Some(anchor) = *select_area else { + dropdown_regions.clear(); + continue; + }; *dropdown_regions = Select::new(labels, state) .style(select_style()) - .render_dropdown(frame, *select_area, screen); + .render_dropdown(frame, anchor, screen); } else { dropdown_regions.clear(); } @@ -786,39 +951,6 @@ impl PreferencesWidgetSession { }) } - fn handle_select_mouse(&mut self, mouse: &MouseEvent) -> Option { - for (id, widget) in &mut self.widgets { - let PreferencesWidget::Choice { - state, - values, - presentation: ChoicePresentation::Picker, - select_area, - dropdown_regions, - .. - } = widget - else { - continue; - }; - if !state.is_open { - continue; - } - if let Some(action) = handle_select_mouse(mouse, state, *select_area, dropdown_regions) - { - return Some(match action { - SelectAction::Select(index) => values - .get(index) - .map_or(PreferencesEventHandling::Consumed, |value| { - choice_action(*id, value) - }), - SelectAction::Focus | SelectAction::Open | SelectAction::Close => { - PreferencesEventHandling::Consumed - } - }); - } - } - None - } - fn handle_paste( &mut self, focused: PreferencesControlId, @@ -870,6 +1002,7 @@ impl PreferencesWidgetSession { .map_or(PreferencesEventHandling::Ignored, |value| { choice_action(id, value) }), + PreferencesHit::Dropdown { .. } => PreferencesEventHandling::Consumed, } } @@ -893,12 +1026,13 @@ impl PreferencesWidgetSession { fn ensure_visible(&mut self, start: usize, height: usize) { let offset = self.scroll.scroll_offset(); let end = start.saturating_add(height); - if start < offset { - self.scroll.set_scroll_offset(start); - } else if end > offset.saturating_add(self.visible_height) { - self.scroll - .set_scroll_offset(end.saturating_sub(self.visible_height)); - } + let next = match start.cmp(&offset) { + Ordering::Less => start, + Ordering::Equal | Ordering::Greater => { + offset.max(end.saturating_sub(self.visible_height)) + } + }; + self.scroll.set_scroll_offset(next); } fn visible_band(&self, start: usize, height: usize) -> Option { @@ -912,8 +1046,7 @@ impl PreferencesWidgetSession { let clipped_end = end.min(viewport_end); Some(RowClip::new( height, - u16::try_from(clipped_start.saturating_sub(start)) - .expect("the Preferences band offset fits Ratatui's row offset"), + clipped_start.saturating_sub(start), Rect::new( self.viewport.x, self.viewport.y.saturating_add( @@ -959,7 +1092,7 @@ fn render_radio_band( option_row = option_row.saturating_add(1); } if option_row == target_row { - let option_area = Rect::new(x, row.y, wanted.min(row.right() - x), 1); + let option_area = Rect::new(x, row.y, wanted, 1); let region = Button::new(option_label, button) .variant(ButtonVariant::Toggle) .style(radio_style()) @@ -1124,7 +1257,7 @@ fn radio_rows(options: &[PreferencesOption], locale: Locale, width: u16) -> usiz let item = u16::try_from(label.as_ref().width().saturating_add(2)) .unwrap_or(u16::MAX) .min(width.max(1)); - if used > 0 && used.saturating_add(item) > width { + if used.saturating_add(item) > width { rows = rows.saturating_add(1); used = 0; } @@ -1201,7 +1334,7 @@ fn widget(control: &PreferencesControl, locale: Locale) -> PreferencesWidget { buttons: (0..choice.options.len()) .map(|index| ButtonState::toggled(selected == Some(index))) .collect(), - select_area: Rect::default(), + select_area: None, dropdown_regions: Vec::new(), } } @@ -1229,8 +1362,10 @@ fn sync_widget(widget: &mut PreferencesWidget, control: &PreferencesControl, loc .options .iter() .position(|option| option.value == choice.selected); - state.selected_index = selected; - state.highlighted_index = selected.unwrap_or_default(); + if state.selected_index != selected { + state.selected_index = selected; + state.highlighted_index = selected.unwrap_or_default(); + } for (index, button) in buttons.iter_mut().enumerate() { button.toggled = selected == Some(index); } @@ -1391,7 +1526,7 @@ mod tests { }; use super::*; - use crate::theme::{ACCENT, BOX_INDIGO}; + use crate::theme::{ACCENT, BOX_INDIGO, SELECT_BG}; fn view() -> PreferencesView { PreferencesView::new(PreferencesDraft::from_snapshot(PreferencesSnapshot { @@ -1449,6 +1584,14 @@ mod tests { }) } + const fn control_id(hit: &PreferencesHit) -> PreferencesControlId { + match hit { + PreferencesHit::Control(id) + | PreferencesHit::Radio { id, .. } + | PreferencesHit::Dropdown { id, .. } => *id, + } + } + fn draw( session: &mut PreferencesWidgetSession, view: &PreferencesView, @@ -1472,6 +1615,307 @@ mod tests { .join("\n") } + fn open_language_picker(session: &mut PreferencesWidgetSession) { + let widget = session + .widgets + .get_mut(&PreferencesControlId::Language) + .expect("the language control exists"); + assert!(matches!(widget, PreferencesWidget::Choice { .. })); + if let PreferencesWidget::Choice { state, .. } = widget { + state.open(); + } + } + + fn language_picker_snapshot( + session: &PreferencesWidgetSession, + ) -> (Vec>, usize, Vec) { + let widget = session + .widgets + .get(&PreferencesControlId::Language) + .expect("the language control exists"); + assert!(matches!(widget, PreferencesWidget::Choice { .. })); + let mut snapshot = None; + if let PreferencesWidget::Choice { + state, + dropdown_regions, + values, + .. + } = widget + { + snapshot = Some(( + dropdown_regions.clone(), + state.highlighted_index, + values.clone(), + )); + } + snapshot.expect("the language control is a picker") + } + + #[test] + fn an_open_preferences_select_drops_its_anchor_when_scrolled_out() { + let mut session = PreferencesWidgetSession::default(); + let view = view(); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + open_language_picker(&mut session); + session + .scroll + .set_scroll_offset(session.maximum_scroll_offset()); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + let (dropdown_regions, _, _) = language_picker_snapshot(&session); + assert!( + dropdown_regions.is_empty(), + "a clipped Preferences select reused its stale on-screen anchor" + ); + } + + #[test] + fn moving_focus_closes_the_previous_picker_and_removes_its_option_hits() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::Focus(PreferencesControlId::Language)); + let _ = draw(&mut session, &view, 80, 30, Locale::En); + open_language_picker(&mut session); + let _ = draw(&mut session, &view, 80, 30, Locale::En); + assert!(!language_picker_snapshot(&session).0.is_empty()); + + view.update(PreferencesAction::Focus(PreferencesControlId::Editor)); + let _ = draw(&mut session, &view, 80, 30, Locale::En); + assert!(language_picker_snapshot(&session).0.is_empty()); + } + + #[test] + fn preferences_picker_preserves_equal_identity_and_cancels_changed_option_shapes() { + let make_view = |languages: Vec| { + let mut view = + PreferencesView::new(PreferencesDraft::from_snapshot(PreferencesSnapshot { + language: String::new(), + available_languages: languages, + effective_language: "en".to_owned(), + editor: String::new(), + editor_fallback: Some("vim".to_owned()), + form: InteractiveFormChoice::Tui, + after_run: AfterRunChoice::Exit, + javascript: JavascriptChoice::Automatic, + bash_path: None, + runner_names: Vec::new(), + mirror: MirrorConfiguration::default(), + })); + view.update(PreferencesAction::Focus(PreferencesControlId::Language)); + view + }; + let old = make_view(vec!["en".to_owned(), "zh-CN".to_owned()]); + let replacement = make_view(vec!["en".to_owned(), "zh-TW".to_owned()]); + let mut session = PreferencesWidgetSession::default(); + let _ = draw(&mut session, &old, 80, 30, Locale::En); + open_language_picker(&mut session); + let _ = draw(&mut session, &old, 80, 30, Locale::En); + let routed_option_point = |regions: &[ClickRegion], option: usize| { + (0..30) + .flat_map(|row| (0..80).map(move |column| (column, row))) + .find(|(column, row)| { + regions + .iter() + .rev() + .find(|region| region.contains(*column, *row)) + .is_some_and(|region| region.data == SelectAction::Select(option)) + }) + .map(|(column, row)| Rect::new(column, row, 1, 1)) + .expect("the requested option owns one topmost cell") + }; + let (old_regions, _, old_values) = language_picker_snapshot(&session); + let old_option = old_values + .iter() + .position(|value| value == "zh-CN") + .expect("zh-CN value exists"); + let old_area = routed_option_point(&old_regions, old_option); + + assert_eq!( + session.handle_event( + mouse(old_area, MouseEventKind::Down(MouseButton::Left)), + &old, + ), + PreferencesEventHandling::Consumed + ); + let _ = draw(&mut session, &old, 80, 30, Locale::En); + assert_eq!( + session.handle_event(mouse(old_area, MouseEventKind::Up(MouseButton::Left)), &old,), + PreferencesEventHandling::Action(PreferencesAction::SetLanguage("zh-CN".to_owned())) + ); + + open_language_picker(&mut session); + let _ = draw(&mut session, &old, 80, 30, Locale::En); + assert_eq!( + session.handle_event( + mouse(old_area, MouseEventKind::Down(MouseButton::Left)), + &old, + ), + PreferencesEventHandling::Consumed + ); + let _ = draw(&mut session, &replacement, 80, 30, Locale::En); + open_language_picker(&mut session); + let _ = draw(&mut session, &replacement, 80, 30, Locale::En); + let (replacement_regions, _, replacement_values) = language_picker_snapshot(&session); + let replacement_option = replacement_values + .iter() + .position(|value| value == "zh-TW") + .expect("zh-TW value exists"); + assert_eq!(replacement_option, old_option); + let replacement_area = routed_option_point(&replacement_regions, replacement_option); + assert_eq!(replacement_area, old_area); + assert_eq!( + session.handle_event( + mouse(replacement_area, MouseEventKind::Up(MouseButton::Left),), + &replacement, + ), + PreferencesEventHandling::Ignored, + "a new option identity reused an armed index", + ); + } + + #[test] + fn open_preferences_select_owns_wheel_before_the_underlay() { + let mut session = PreferencesWidgetSession::default(); + let view = view(); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + open_language_picker(&mut session); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + let (dropdown_regions, before_highlight, _) = language_picker_snapshot(&session); + let option = dropdown_regions + .first() + .expect("a language option must be visible") + .area; + let before_underlay = session.scroll_offset(); + + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: option.x, + row: option.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!(session.scroll_offset(), before_underlay); + assert_eq!( + language_picker_snapshot(&session).1, + before_highlight.saturating_add(1) + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: option.x, + row: option.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!(session.scroll_offset(), before_underlay); + assert_eq!(language_picker_snapshot(&session).1, before_highlight); + } + + #[test] + fn an_open_preferences_select_does_not_steal_outside_wheel_or_unrelated_keys() { + let mut session = PreferencesWidgetSession::default(); + let view = view(); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + open_language_picker(&mut session); + let _ = draw(&mut session, &view, 40, 8, Locale::En); + let (regions, before_highlight, _) = language_picker_snapshot(&session); + let outside = (session.viewport.y..session.viewport.bottom()) + .flat_map(|row| { + (session.viewport.x..session.viewport.right()).map(move |column| (column, row)) + }) + .find(|(column, row)| !regions.iter().any(|region| region.contains(*column, *row))) + .expect("the short viewport has a cell outside the dropdown"); + let before_scroll = session.scroll_offset(); + + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: outside.0, + row: outside.1, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert!(session.scroll_offset() > before_scroll); + assert_eq!(language_picker_snapshot(&session).1, before_highlight); + assert_eq!( + session.handle_event(key(KeyCode::F(2), KeyModifiers::NONE), &view), + PreferencesEventHandling::Ignored + ); + } + + #[test] + fn nonoption_and_stale_dropdown_hits_are_inert() { + let mut session = PreferencesWidgetSession::default(); + let view = view(); + let _ = draw(&mut session, &view, 80, 30, Locale::En); + let area = Rect::new(0, 0, 1, 1); + + let language = session + .widgets + .get_mut(&PreferencesControlId::Language) + .expect("the language control exists"); + assert!(matches!(language, PreferencesWidget::Choice { .. })); + if let PreferencesWidget::Choice { + state, + dropdown_regions, + .. + } = language + { + state.open(); + dropdown_regions.clear(); + dropdown_regions.push(ClickRegion::new(area, SelectAction::Focus)); + } + session.clicks.clear(); + assert_eq!( + session.handle_event(mouse(area, MouseEventKind::Down(MouseButton::Left)), &view,), + PreferencesEventHandling::Ignored + ); + + if let Some(PreferencesWidget::Choice { + dropdown_regions, .. + }) = session.widgets.get_mut(&PreferencesControlId::Language) + { + dropdown_regions.clear(); + } + session.clicks.register( + area, + PreferencesHit::Dropdown { + id: PreferencesControlId::Editor, + option: 0, + }, + ); + assert_eq!( + session.handle_event(mouse(area, MouseEventKind::Down(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(area, MouseEventKind::Up(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.activate_hit( + PreferencesHit::Dropdown { + id: PreferencesControlId::Language, + option: 0, + }, + &view, + ), + PreferencesEventHandling::Consumed + ); + } + /// A wrapped Preferences sentence keeps its later rows when its top is above the viewport. #[test] fn a_top_clipped_preferences_copy_shows_its_surviving_later_rows() { @@ -1563,9 +2007,59 @@ mod tests { } #[test] - fn input_uses_a_real_cursor_and_emits_complete_unicode_values() { + fn rendered_preference_sections_keep_spacers_and_declared_help_order() { let mut session = PreferencesWidgetSession::default(); - let mut view = view(); + let view = complete_view(); + let terminal = draw(&mut session, &view, 120, 120, Locale::En); + let rows = terminal + .backend() + .buffer() + .content() + .chunks(120) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>(); + let find_row = |needle: &str| { + rows.iter() + .position(|row| row.contains(needle)) + .unwrap_or_else(|| panic!("missing rendered Preferences text {needle:?}")) + }; + + for heading in [ + "Editor", + "Interactive form", + "After a run (from this menu)", + "JavaScript runtime", + "Shell on Windows", + "Agents (prompt runners)", + "Download mirrors (mainland-China acceleration)", + ] { + let heading_row = find_row(heading); + assert!(heading_row > 0); + assert!( + rows[heading_row - 1] + .trim_matches(|character| matches!(character, ' ' | '│')) + .is_empty(), + "section {heading:?} has no blank spacer before it", + ); + } + + let interactive_control = find_row("Mini form — opens in place, fully clickable"); + let interactive_help = find_row( + "Used by terminal runs: `skit run` parameter prompts and the `skit add` review panel.", + ); + assert!(interactive_control < interactive_help); + + let mirror_heading = find_row("Download mirrors (mainland-China acceleration)"); + let mirror_help = + find_row("Each ecosystem is its own choice — mirror vendors differ per axis."); + let mirror_control = find_row("Master switch — \"off\" pauses mirrors"); + assert!(mirror_heading < mirror_help && mirror_help < mirror_control); + } + + #[test] + fn input_uses_a_real_cursor_and_emits_complete_unicode_values() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); view.update(PreferencesAction::Focus(PreferencesControlId::Editor)); let _ = draw(&mut session, &view, 80, 24, Locale::En); @@ -1590,6 +2084,35 @@ mod tests { ); } + #[test] + fn an_equal_model_render_preserves_the_mouse_selected_input_caret() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::SetEditor("abcdef".to_owned())); + view.update(PreferencesAction::Focus(PreferencesControlId::Editor)); + let _ = draw(&mut session, &view, 80, 24, Locale::En); + let area = session + .control_area(PreferencesControlId::Editor) + .expect("visible editor input"); + let point = Rect::new(area.x.saturating_add(3), area.y.saturating_add(1), 1, 1); + assert_eq!( + session.handle_event(mouse(point, MouseEventKind::Down(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed + ); + assert!(matches!( + session.handle_event(mouse(point, MouseEventKind::Up(MouseButton::Left)), &view,), + PreferencesEventHandling::Action(PreferencesAction::Focus( + PreferencesControlId::Editor + )) + )); + + let _ = draw(&mut session, &view, 80, 24, Locale::En); + assert_eq!( + session.handle_event(key(KeyCode::Char('X'), KeyModifiers::NONE), &view), + PreferencesEventHandling::Action(PreferencesAction::SetEditor("abXcdef".to_owned())) + ); + } + #[test] fn mouse_buttons_and_keyboard_navigation_share_typed_actions() { let mut session = PreferencesWidgetSession::default(); @@ -1607,8 +2130,17 @@ mod tests { }), &view, ); + assert_eq!(handling, PreferencesEventHandling::Consumed); assert_eq!( - handling, + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), PreferencesEventHandling::Action(PreferencesAction::ManageAgents) ); @@ -1666,10 +2198,170 @@ mod tests { }), &view, ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), PreferencesEventHandling::Action(PreferencesAction::ManageAgents) ); } + #[test] + fn every_non_dropdown_preference_control_requires_a_matching_primary_release() { + let mut session = PreferencesWidgetSession::default(); + let view = complete_view(); + let _ = draw(&mut session, &view, 140, 160, Locale::En); + + let mut targets = Vec::new(); + for row in 0..160 { + for column in 0..140 { + let Some(hit) = session.clicks.handle_click(column, row).cloned() else { + continue; + }; + let is_non_dropdown = !matches!(hit, PreferencesHit::Dropdown { .. }) + && !matches!( + session.widgets.get(&control_id(&hit)), + Some(PreferencesWidget::Choice { + presentation: ChoicePresentation::Picker, + .. + }) + ); + if is_non_dropdown && !targets.iter().any(|(registered, _, _)| registered == &hit) { + targets.push((hit, column, row)); + } + } + } + + for (id, widget) in &session.widgets { + if matches!( + widget, + PreferencesWidget::Choice { + presentation: ChoicePresentation::Picker, + .. + } + ) { + continue; + } + assert!( + targets.iter().any(|(hit, _, _)| control_id(hit) == *id), + "non-dropdown control {id:?} has no tested pointer target" + ); + } + assert!(!targets.is_empty()); + + for (target, column, row) in &targets { + session.click.cancel(); + assert_eq!( + session.handle_event( + mouse( + Rect::new(*column, *row, 1, 1), + MouseEventKind::Down(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Consumed, + "Down activated {target:?}" + ); + let released = session.handle_event( + mouse( + Rect::new(*column, *row, 1, 1), + MouseEventKind::Up(MouseButton::Left), + ), + &view, + ); + assert_eq!( + std::mem::discriminant(&released), + std::mem::discriminant(&PreferencesEventHandling::Action(PreferencesAction::Save,)), + "same-target Up did not activate {target:?}" + ); + } + + let (_, first_column, first_row) = &targets[0]; + let (_, second_column, second_row) = &targets[1]; + for (cancel, label) in [ + (MouseEventKind::Down(MouseButton::Right), "right click"), + (MouseEventKind::Down(MouseButton::Middle), "middle click"), + ] { + session.click.cancel(); + assert_eq!( + session.handle_event( + mouse( + Rect::new(*first_column, *first_row, 1, 1), + MouseEventKind::Down(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse(Rect::new(*first_column, *first_row, 1, 1), cancel), + &view, + ), + PreferencesEventHandling::Ignored, + "{label} was not rejected" + ); + assert_eq!( + session.handle_event( + mouse( + Rect::new(*first_column, *first_row, 1, 1), + MouseEventKind::Up(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Ignored, + "{label} did not cancel the armed control" + ); + } + + for (release_column, release_row, label) in [ + (*second_column, *second_row, "a different control"), + (0, 0, "outside the Preferences controls"), + ] { + session.click.cancel(); + assert_eq!( + session.handle_event( + mouse( + Rect::new(*first_column, *first_row, 1, 1), + MouseEventKind::Down(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse( + Rect::new(release_column, release_row, 1, 1), + MouseEventKind::Up(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Ignored, + "release over {label} activated the pressed control" + ); + assert_eq!( + session.handle_event( + mouse( + Rect::new(*first_column, *first_row, 1, 1), + MouseEventKind::Up(MouseButton::Left), + ), + &view, + ), + PreferencesEventHandling::Ignored, + "a cancelled control accepted a later release" + ); + } + } + #[test] fn short_terminals_keep_the_whole_form_wheel_reachable() { let mut session = PreferencesWidgetSession::default(); @@ -1763,6 +2455,18 @@ mod tests { }), &view, ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: target.x, + row: target.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), PreferencesEventHandling::Action(PreferencesAction::ActivateAgentSkillTarget(0)) ); @@ -1796,6 +2500,18 @@ mod tests { }), &view, ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: cancel.x, + row: cancel.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), PreferencesEventHandling::Action(PreferencesAction::CloseAgentSkillTargets) ); } @@ -1955,6 +2671,57 @@ mod tests { assert_eq!(session.scroll_offset(), 0); } + #[test] + fn preferences_shortcuts_buttons_and_inputs_keep_distinct_key_owners() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::Focus( + PreferencesControlId::InteractiveForm, + )); + let _ = draw(&mut session, &view, 100, 40, Locale::En); + assert_eq!( + session.handle_event(key(KeyCode::Char('s'), KeyModifiers::NONE), &view), + PreferencesEventHandling::Ignored + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('k'), KeyModifiers::NONE), &view), + PreferencesEventHandling::Ignored + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('s'), KeyModifiers::CONTROL), &view), + PreferencesEventHandling::Action(PreferencesAction::Save) + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('k'), KeyModifiers::CONTROL), &view), + PreferencesEventHandling::Action(PreferencesAction::InstallAgentSkill) + ); + + view.update(PreferencesAction::Focus(PreferencesControlId::Editor)); + let _ = draw(&mut session, &view, 100, 40, Locale::En); + assert_eq!( + session.handle_event(key(KeyCode::F(2), KeyModifiers::NONE), &view), + PreferencesEventHandling::Ignored + ); + assert_eq!( + session.handle_event(key(KeyCode::Char('k'), KeyModifiers::CONTROL), &view), + PreferencesEventHandling::Consumed, + "an input owns Ctrl+K instead of opening the agent installer", + ); + + view.update(PreferencesAction::Focus(PreferencesControlId::ManageAgents)); + let _ = draw(&mut session, &view, 100, 40, Locale::En); + assert_eq!( + session.handle_event(key(KeyCode::Char('z'), KeyModifiers::NONE), &view), + PreferencesEventHandling::Ignored + ); + for code in [KeyCode::Enter, KeyCode::Char(' ')] { + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &view), + PreferencesEventHandling::Action(PreferencesAction::ManageAgents) + ); + } + } + #[test] fn select_dropdown_and_mapping_matrix_keep_real_geometry_and_typed_values() { let mut session = PreferencesWidgetSession::default(); @@ -1973,24 +2740,18 @@ mod tests { mouse(language, MouseEventKind::Down(MouseButton::Left)), &view, ), - PreferencesEventHandling::Action(PreferencesAction::Focus( - PreferencesControlId::Language, - )) + PreferencesEventHandling::Consumed ); - view.update(PreferencesAction::Focus(PreferencesControlId::Language)); - let _ = draw(&mut session, &view, 90, 120, Locale::ZhCn); assert_eq!( session.handle_event( - mouse(language, MouseEventKind::Down(MouseButton::Left)), + mouse(language, MouseEventKind::Up(MouseButton::Left)), &view, ), - PreferencesEventHandling::Consumed + PreferencesEventHandling::Action(PreferencesAction::Focus( + PreferencesControlId::Language, + )) ); - if let Some(PreferencesWidget::Choice { state, .. }) = - session.widgets.get_mut(&PreferencesControlId::Language) - { - state.open(); - } + view.update(PreferencesAction::Focus(PreferencesControlId::Language)); let _ = draw(&mut session, &view, 90, 120, Locale::ZhCn); assert_eq!( session.handle_event( @@ -2001,7 +2762,7 @@ mod tests { ); assert_eq!( session.handle_event( - mouse(language, MouseEventKind::Down(MouseButton::Left)), + mouse(language, MouseEventKind::Up(MouseButton::Left)), &view, ), PreferencesEventHandling::Consumed @@ -2018,11 +2779,18 @@ mod tests { let dropdown = dropdown_area(PreferencesControlId::Language); assert!(!dropdown.is_empty()); assert!(dropdown_area(PreferencesControlId::Editor).is_empty()); - assert!(matches!( + assert_eq!( session.handle_event( mouse(dropdown, MouseEventKind::Down(MouseButton::Left)), &view, ), + PreferencesEventHandling::Consumed + ); + assert!(matches!( + session.handle_event( + mouse(dropdown, MouseEventKind::Up(MouseButton::Left)), + &view, + ), PreferencesEventHandling::Action(PreferencesAction::SetLanguage(_)) )); @@ -2251,13 +3019,14 @@ mod tests { session.handle_event(key(KeyCode::F(2), KeyModifiers::NONE), &view), PreferencesEventHandling::Consumed ); + let wheel_area = session.agent_target_area(0).expect("visible agent target"); for kind in [MouseEventKind::ScrollUp, MouseEventKind::ScrollDown] { assert!(matches!( session.handle_event( Event::Mouse(MouseEvent { kind, - column: 4, - row: 4, + column: wheel_area.x, + row: wheel_area.y, modifiers: KeyModifiers::NONE, }), &view, @@ -2305,6 +3074,446 @@ mod tests { assert!(invalid.error().is_some()); } + #[test] + fn agent_picker_wheel_is_contained_and_reaches_the_last_clickable_target() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::PresentAgentSkillTargets( + (0..20) + .map(|index| AgentTarget { + name: format!("agent-{index:02}"), + scope: AgentScope::User, + base: PathBuf::from(format!("/tmp/agent-{index:02}")), + }) + .collect(), + )); + let _ = draw(&mut session, &view, 46, 8, Locale::En); + let list = session.agent_list_area; + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Consumed, + "the modal owns outside wheel input without moving its list", + ); + for _ in 0..30 { + let handling = session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: list.x, + row: list.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ); + if let PreferencesEventHandling::Action(action) = handling { + view.update(action); + } + } + assert_eq!(view.agent_skill_install().unwrap().selected(), Some(19)); + let _ = draw(&mut session, &view, 46, 8, Locale::En); + let last = session + .agent_target_area(19) + .expect("last target is visible"); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: last.x, + row: last.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: last.x, + row: last.y, + modifiers: KeyModifiers::NONE, + }), + &view, + ), + PreferencesEventHandling::Action(PreferencesAction::ActivateAgentSkillTarget(19)) + ); + } + + #[test] + fn agent_picker_drag_and_nonprimary_events_cancel_a_pressed_target() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::PresentAgentSkillTargets(vec![ + AgentTarget { + name: "codex".to_owned(), + scope: AgentScope::User, + base: PathBuf::from("/tmp/codex"), + }, + ])); + let _ = draw(&mut session, &view, 60, 10, Locale::En); + let target = session.agent_target_area(0).expect("visible agent target"); + + for cancel_kind in [ + MouseEventKind::Drag(MouseButton::Left), + MouseEventKind::Up(MouseButton::Right), + MouseEventKind::Down(MouseButton::Middle), + ] { + session.cancel_click(); + assert_eq!( + session.handle_event( + mouse(target, MouseEventKind::Down(MouseButton::Left)), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(target, cancel_kind), &view), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(target, MouseEventKind::Up(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed, + "a cancelled press must not activate on a later primary release", + ); + } + assert_eq!( + session.handle_event( + mouse(target, MouseEventKind::Down(MouseButton::Left)), + &view, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(Event::FocusLost, &view), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(target, MouseEventKind::Up(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed, + "focus loss must cancel an armed agent target", + ); + } + + #[test] + fn agent_picker_height_and_key_kind_match_the_visible_target_state() { + let target = AgentTarget { + name: "codex".to_owned(), + scope: AgentScope::User, + base: PathBuf::from("/tmp/codex"), + }; + let path = target.skills_dir().display().to_string(); + let mut session = PreferencesWidgetSession::default(); + let mut populated = view(); + populated.update(PreferencesAction::PresentAgentSkillTargets(vec![target])); + + let short = draw(&mut session, &populated, 80, 4, Locale::En); + assert!(!text(short.backend().buffer()).contains(&path)); + let tall = draw(&mut session, &populated, 80, 5, Locale::En); + assert!(text(tall.backend().buffer()).contains(&path)); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &populated, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &populated), + PreferencesEventHandling::Action(PreferencesAction::ConfirmAgentSkillTarget) + ); + + let mut empty = view(); + empty.update(PreferencesAction::PresentAgentSkillTargets(Vec::new())); + let _ = draw(&mut session, &empty, 80, 5, Locale::En); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &empty), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &empty, + ), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session.handle_event(key(KeyCode::Esc, KeyModifiers::NONE), &empty), + PreferencesEventHandling::Action(PreferencesAction::CloseAgentSkillTargets) + ); + } + + #[test] + fn agent_picker_cancels_an_armed_index_when_the_target_identity_changes() { + let target = |name: &str| AgentTarget { + name: name.to_owned(), + scope: AgentScope::User, + base: PathBuf::from(format!("/tmp/{name}")), + }; + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::PresentAgentSkillTargets(vec![target( + "alpha", + )])); + let _ = draw(&mut session, &view, 60, 10, Locale::En); + let old = session.agent_target_area(0).expect("alpha target row"); + assert_eq!( + session.handle_event(mouse(old, MouseEventKind::Down(MouseButton::Left)), &view), + PreferencesEventHandling::Consumed + ); + + view.update(PreferencesAction::PresentAgentSkillTargets(vec![target( + "beta", + )])); + let _ = draw(&mut session, &view, 60, 10, Locale::En); + let replacement = session.agent_target_area(0).expect("beta target row"); + assert_eq!(replacement, old); + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Up(MouseButton::Left)), + &view, + ), + PreferencesEventHandling::Consumed + ); + + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Down(MouseButton::Left)), + &view, + ), + PreferencesEventHandling::Consumed + ); + let _ = draw(&mut session, &view, 60, 10, Locale::En); + assert_eq!( + session.handle_event( + mouse(replacement, MouseEventKind::Up(MouseButton::Left)), + &view, + ), + PreferencesEventHandling::Action(PreferencesAction::ActivateAgentSkillTarget(0)) + ); + } + + #[test] + fn preferences_alignment_distinguishes_above_equal_and_below_viewport_edges() { + let mut session = PreferencesWidgetSession { + visible_height: 3, + ..PreferencesWidgetSession::default() + }; + session.scroll.set_lines(vec![String::new(); 20]); + + for (offset, start, height, expected) in + [(5, 4, 1, 4), (5, 5, 3, 5), (5, 5, 5, 7), (5, 7, 2, 6)] + { + session.scroll.set_scroll_offset(offset); + session.ensure_visible(start, height); + assert_eq!(session.scroll_offset(), expected); + } + } + + #[test] + fn preferences_choice_actions_cover_after_run_and_every_javascript_runtime() { + assert_eq!( + choice_action(PreferencesControlId::AfterRun, "stay"), + PreferencesEventHandling::Action(PreferencesAction::SetAfterRun(AfterRunChoice::Stay,)) + ); + assert_eq!( + choice_action(PreferencesControlId::AfterRun, "exit"), + PreferencesEventHandling::Action(PreferencesAction::SetAfterRun(AfterRunChoice::Exit,)) + ); + for (value, expected) in [ + ("automatic", JavascriptChoice::Automatic), + ("deno", JavascriptChoice::Deno), + ("bun", JavascriptChoice::Bun), + ("node", JavascriptChoice::Node), + ] { + assert_eq!( + choice_action(PreferencesControlId::Javascript, value), + PreferencesEventHandling::Action(PreferencesAction::SetJavascript(expected)), + ); + } + } + + #[test] + fn rendered_after_run_and_javascript_options_dispatch_their_exact_typed_actions() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + let families = [ + ( + PreferencesControlId::AfterRun, + vec![ + ( + PreferencesAction::SetAfterRun(AfterRunChoice::Exit), + "Quit skit — leave the run's output in the terminal", + ), + ( + PreferencesAction::SetAfterRun(AfterRunChoice::Stay), + "Return to the Library immediately", + ), + ], + ), + ( + PreferencesControlId::Javascript, + vec![ + ( + PreferencesAction::SetJavascript(JavascriptChoice::Automatic), + "Automatic — the first of deno / bun / node found", + ), + ( + PreferencesAction::SetJavascript(JavascriptChoice::Deno), + "deno", + ), + ( + PreferencesAction::SetJavascript(JavascriptChoice::Bun), + "bun", + ), + ( + PreferencesAction::SetJavascript(JavascriptChoice::Node), + "node", + ), + ], + ), + ]; + for (id, expected) in families { + view.update(PreferencesAction::Focus(id)); + let terminal = draw(&mut session, &view, 140, 80, Locale::En); + for (option, (action, label)) in expected.into_iter().enumerate() { + let point = (0..80) + .flat_map(|row| (0..140).map(move |column| (column, row))) + .find(|(column, row)| { + session.clicks.handle_click(*column, *row) + == Some(&PreferencesHit::Radio { id, option }) + }) + .expect("rendered radio option has a hit"); + let row_text = (0..140) + .map(|column| terminal.backend().buffer()[(column, point.1)].symbol()) + .collect::(); + assert!( + row_text.contains(skit_i18n::text(Locale::En, label).as_ref()), + "option {option} is not bound to its visible label: {row_text}", + ); + let area = Rect::new(point.0, point.1, 1, 1); + assert_eq!( + session + .handle_event(mouse(area, MouseEventKind::Down(MouseButton::Left)), &view,), + PreferencesEventHandling::Consumed + ); + assert_eq!( + session + .handle_event(mouse(area, MouseEventKind::Up(MouseButton::Left)), &view,), + PreferencesEventHandling::Action(action) + ); + } + } + } + + #[test] + fn javascript_radio_pixels_follow_the_exact_selected_model_option() { + let mut session = PreferencesWidgetSession::default(); + let mut view = view(); + view.update(PreferencesAction::Focus(PreferencesControlId::Javascript)); + + let selected_pixels = |session: &mut PreferencesWidgetSession, + terminal: &Terminal| { + let mut selected = vec![false; 4]; + let area = terminal.backend().buffer().area; + for row in area.y..area.bottom() { + for column in area.x..area.right() { + if let Some(PreferencesHit::Radio { + id: PreferencesControlId::Javascript, + option, + }) = session.clicks.handle_click(column, row) + && terminal.backend().buffer()[(column, row)].bg == SELECT_BG + { + selected[*option] = true; + } + } + } + selected + }; + + let automatic = draw(&mut session, &view, 140, 80, Locale::En); + assert_eq!( + selected_pixels(&mut session, &automatic), + [true, false, false, false] + ); + view.update(PreferencesAction::SetJavascript(JavascriptChoice::Deno)); + let deno = draw(&mut session, &view, 140, 80, Locale::En); + assert_eq!( + selected_pixels(&mut session, &deno), + [false, true, false, false] + ); + } + + #[test] + fn preferences_radio_exact_fit_keeps_the_full_nonzero_origin_row_clickable() { + let labels = vec!["A".repeat(37), "B".repeat(38)]; + let options = labels + .iter() + .enumerate() + .map(|(index, label)| PreferencesOption { + value: index.to_string(), + label: label.clone(), + }) + .collect::>(); + let control = PreferencesControl { + id: PreferencesControlId::PypiChoice, + label: String::new(), + help: String::new(), + kind: PreferencesControlKind::Choice(PreferencesChoiceControl { + options: options.clone(), + selected: "0".to_owned(), + presentation: ChoicePresentation::Radio, + }), + }; + assert_eq!(radio_rows(&options, Locale::En, 80), 1); + + let mut terminal = Terminal::new(TestBackend::new(110, 4)).unwrap(); + let mut clicks = ClickRegionRegistry::new(); + let buttons = vec![ButtonState::default(), ButtonState::default()]; + terminal + .draw(|frame| { + render_radio_band( + frame, + RowClip::new(1, 0, Rect::new(20, 1, 80, 1)), + &control, + "", + &labels, + &buttons, + &mut clicks, + ); + }) + .unwrap(); + let row = (20..100) + .map(|column| terminal.backend().buffer()[(column, 1)].symbol()) + .collect::(); + assert!( + row.contains(&"B".repeat(38)), + "exact-fit option missing: {row}" + ); + assert_eq!( + clicks.handle_click(99, 1), + Some(&PreferencesHit::Radio { + id: PreferencesControlId::PypiChoice, + option: 1, + }) + ); + } + #[test] fn ephemeral_widget_shapes_resync_and_reject_stale_control_pairings() { let text_control = PreferencesControl { diff --git a/crates/skit-tui/src/screens/report.rs b/crates/skit-tui/src/screens/report.rs index cbe39670..3fbada55 100644 --- a/crates/skit-tui/src/screens/report.rs +++ b/crates/skit-tui/src/screens/report.rs @@ -1,47 +1,321 @@ //! Read-only health and diagnostic reports. use ratatui_core::{layout::Rect, terminal::Frame, text::Line}; +use ratatui_crossterm::crossterm::event::{Event, KeyEventKind}; +use ratatui_interact::components::{ + ScrollableContentState, handle_scrollable_content_key, handle_scrollable_content_mouse, +}; use ratatui_widgets::{ block::Block, borders::Borders, paragraph::{Paragraph, Wrap}, + scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use skit_i18n::{Locale, text}; use skit_ui::ReportView; -use crate::ViewGeometry; - -pub(crate) fn render( - frame: &mut Frame, - area: Rect, - report: &ReportView, - locale: Locale, -) -> ViewGeometry { - let lines = report - .items - .iter() - .map(|item| { - Line::from(format!( - "[{}] {}: {}", - text(locale, &item.status), - if item.translate_label { - text(locale, &item.label).into_owned() - } else { - item.label.clone() - }, - if item.translate_detail { - text(locale, &item.detail).into_owned() - } else { - item.detail.clone() - } - )) - }) - .collect::>(); - frame.render_widget( - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .block(Block::default().borders(Borders::ALL)), - area, - ); - ViewGeometry::default() +use crate::{ViewGeometry, viewport::Viewport}; + +/// Persistent reader position for a report screen. +#[derive(Debug, Default)] +pub(crate) struct ReportScreenSession { + scroll: ScrollableContentState, + viewport: Rect, + visible_height: usize, + report: Option, +} + +impl ReportScreenSession { + pub(crate) fn render( + &mut self, + frame: &mut Frame, + area: Rect, + report: &ReportView, + locale: Locale, + ) -> ViewGeometry { + if area.is_empty() { + self.viewport = area; + self.visible_height = 0; + return ViewGeometry { + rows: area, + first_visible: self.scroll.scroll_offset(), + ..ViewGeometry::default() + }; + } + + if self.report.as_ref() != Some(report) { + self.scroll.set_scroll_offset(0); + self.report = Some(report.clone()); + } + + let lines = report + .items + .iter() + .map(|item| { + Line::from(format!( + "[{}] {}: {}", + text(locale, &item.status), + if item.translate_label { + text(locale, &item.label).into_owned() + } else { + item.label.clone() + }, + if item.translate_detail { + text(locale, &item.detail).into_owned() + } else { + item.detail.clone() + } + )) + }) + .collect::>(); + let block = Block::default().borders(Borders::ALL); + let inner = block.inner(area); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let initial_height = paragraph.line_count(inner.width.max(1)); + self.viewport = if initial_height > usize::from(inner.height) { + Rect { + width: inner.width.saturating_sub(1), + ..inner + } + } else { + inner + }; + self.visible_height = usize::from(self.viewport.height); + let content_height = paragraph.line_count(self.viewport.width.max(1)); + self.scroll.set_lines(vec![String::new(); content_height]); + Viewport::new(self.viewport, content_height).clamp_scroll(&mut self.scroll); + + frame.render_widget(block, area); + frame.render_widget( + paragraph.scroll(( + u16::try_from(self.scroll.scroll_offset()).unwrap_or(u16::MAX), + 0, + )), + self.viewport, + ); + let mut scrollbar = ScrollbarState::new(content_height.saturating_sub(self.visible_height)) + .position(self.scroll.scroll_offset()) + .viewport_content_length(self.visible_height); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + &mut scrollbar, + ); + + ViewGeometry { + rows: self.viewport, + first_visible: self.scroll.scroll_offset(), + hits: Vec::new(), + detail_pane_visible: false, + } + } + + pub(crate) fn handle_event(&mut self, event: &Event) -> bool { + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => { + handle_scrollable_content_key(&mut self.scroll, key, self.visible_height).is_some() + } + Event::Mouse(mouse) => handle_scrollable_content_mouse( + &mut self.scroll, + mouse, + self.viewport, + self.visible_height, + ) + .is_some(), + Event::FocusGained + | Event::FocusLost + | Event::Key(_) + | Event::Paste(_) + | Event::Resize(_, _) => false, + } + } +} + +#[cfg(test)] +mod tests { + use ratatui_core::{backend::TestBackend, terminal::Terminal}; + use ratatui_crossterm::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + use skit_ui::ReportItem; + + use super::*; + + fn report() -> ReportView { + ReportView { + title: "Report".to_owned(), + items: vec![ReportItem { + status: "ok".to_owned(), + label: "Check".to_owned(), + translate_label: false, + detail: "wrapped detail ".repeat(20), + translate_detail: false, + }], + } + } + + #[test] + fn report_handles_an_empty_viewport_without_inventing_geometry() { + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = ReportScreenSession::default(); + let empty = Rect::new(3, 2, 0, 2); + terminal + .draw(|frame| { + let geometry = session.render(frame, empty, &report(), Locale::En); + assert_eq!(geometry.rows, empty); + assert_eq!(geometry.first_visible, 0); + assert!(geometry.hits.is_empty()); + assert!(!geometry.detail_pane_visible); + }) + .unwrap(); + } + + #[test] + fn report_owns_vertical_wheel_events_inside_its_viewport() { + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = ReportScreenSession::default(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &report(), Locale::En); + }) + .unwrap(); + assert!(session.handle_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: session.viewport.x, + row: session.viewport.y, + modifiers: KeyModifiers::NONE, + }))); + assert!(!session.handle_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: session.viewport.x, + row: session.viewport.y, + modifiers: KeyModifiers::NONE, + }))); + assert!(!session.handle_event(&Event::FocusGained)); + assert!(!session.handle_event(&Event::FocusLost)); + assert!(!session.handle_event(&Event::Key( + ratatui_crossterm::crossterm::event::KeyEvent::new_with_kind( + ratatui_crossterm::crossterm::event::KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Release, + ), + ))); + assert!(!session.handle_event(&Event::Paste("ignored".to_owned()))); + assert!(!session.handle_event(&Event::Resize(40, 10))); + } + + fn one_line_report(items: usize) -> ReportView { + ReportView { + title: "Report".to_owned(), + items: (0..items) + .map(|index| ReportItem { + status: "ok".to_owned(), + label: format!("C{index}"), + translate_label: false, + detail: "x".to_owned(), + translate_detail: false, + }) + .collect(), + } + } + + #[test] + fn report_reserves_a_scrollbar_column_only_for_strict_overflow() { + for (items, expected_width) in [(2, 18), (3, 18), (4, 17)] { + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = ReportScreenSession::default(); + let mut geometry = ViewGeometry::default(); + terminal + .draw(|frame| { + geometry = + session.render(frame, frame.area(), &one_line_report(items), Locale::En); + }) + .unwrap(); + + assert_eq!(geometry.rows, Rect::new(1, 1, expected_width, 3)); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + if items == 4 { + assert!( + rendered.contains('█') || rendered.contains('▲') || rendered.contains('▼'), + "strict overflow has no scrollbar: {rendered}", + ); + } + } + } + + #[test] + fn report_keeps_its_scroll_position_through_a_zero_height_frame() { + let report = report(); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = ReportScreenSession::default(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &report, Locale::En); + }) + .unwrap(); + assert!(session.handle_event(&Event::Key( + ratatui_crossterm::crossterm::event::KeyEvent::new( + ratatui_crossterm::crossterm::event::KeyCode::End, + KeyModifiers::NONE, + ), + ))); + let end = session.scroll.scroll_offset(); + assert!(end > 0); + + terminal + .draw(|frame| { + let geometry = session.render(frame, Rect::new(0, 0, 20, 0), &report, Locale::En); + assert_eq!(geometry.rows, Rect::new(0, 0, 20, 0)); + assert_eq!(geometry.first_visible, end); + }) + .unwrap(); + terminal + .draw(|frame| { + let geometry = session.render(frame, frame.area(), &report, Locale::En); + assert_eq!(geometry.first_visible, end); + }) + .unwrap(); + } + + #[test] + fn a_distinct_report_starts_at_its_first_row_instead_of_reusing_the_old_tail() { + let mut first = one_line_report(12); + first.title = "First".to_owned(); + let mut second = one_line_report(12); + second.title = "Second".to_owned(); + second.items[0].label = "SECOND-TOP".to_owned(); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + let mut session = ReportScreenSession::default(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &first, Locale::En); + }) + .unwrap(); + assert!(session.handle_event(&Event::Key( + ratatui_crossterm::crossterm::event::KeyEvent::new( + ratatui_crossterm::crossterm::event::KeyCode::End, + KeyModifiers::NONE, + ), + ))); + assert!(session.scroll.scroll_offset() > 0); + + terminal + .draw(|frame| { + let geometry = session.render(frame, frame.area(), &second, Locale::En); + assert_eq!(geometry.first_visible, 0); + }) + .unwrap(); + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("SECOND-TOP"), "{rendered}"); + } } diff --git a/crates/skit-tui/src/screens/run_modal.rs b/crates/skit-tui/src/screens/run_modal.rs index dc925d3c..f4c0f2df 100644 --- a/crates/skit-tui/src/screens/run_modal.rs +++ b/crates/skit-tui/src/screens/run_modal.rs @@ -8,7 +8,7 @@ use ratatui_core::{ terminal::Frame, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui_interact::{ components::{ListPicker, ListPickerState, ListPickerStyle}, @@ -30,6 +30,7 @@ use tui_input::{Input as LineInput, InputRequest, backend::crossterm::EventHandl use crate::{ EventHandling, ViewGeometry, + pointer::{ClickOutcome, ClickTracker, EditableGeometry}, screens::picker::{FilePickerEvent, FilePickerGeometry, FilePickerSession, render_file_picker}, session::render_line_input, }; @@ -66,7 +67,7 @@ enum ModalSignature { Other, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] enum ModalHit { PresetInput, TokenOption(usize), @@ -81,16 +82,29 @@ pub(crate) struct RunModalSession { preset: LineInput, token: ListPickerState, token_view_height: usize, + token_area: Rect, environment: LineInput, + preset_editable: Option, + environment_editable: Option, environment_list: ListPickerState, environment_view_height: usize, + environment_area: Rect, + environment_click_values: Vec, file: Option, file_geometry: FilePickerGeometry, file_missing_root: bool, clicks: ClickRegionRegistry, + click: ClickTracker, } impl RunModalSession { + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + if let Some(file) = self.file.as_mut() { + file.cancel_click(); + } + } + /// Keep widget state aligned with the serializable modal contract. pub(crate) fn sync(&mut self, modal: &ModalState) { let signature = match modal { @@ -118,6 +132,7 @@ impl RunModalSession { | ModalState::RunnerEditor { .. } => ModalSignature::Other, }; if self.signature.as_ref() != Some(&signature) { + self.click.cancel(); match &signature { ModalSignature::Preset => self.preset = LineInput::default(), ModalSignature::Token { options, .. } => { @@ -143,10 +158,16 @@ impl RunModalSession { self.preset = LineInput::new(value.clone()); } if let ModalState::RunEnvironmentPicker { query, visible, .. } = modal { + if self.environment_click_values != *visible { + self.click.cancel(); + self.environment_click_values.clone_from(visible); + } if self.environment.value() != query { self.environment = LineInput::new(query.clone()); } self.environment_list.set_total(visible.len()); + } else { + self.environment_click_values.clear(); } } @@ -259,7 +280,7 @@ impl RunModalSession { let inner = block.inner(panel); frame.render_widget(block, panel); let input = Rect::new(inner.x, inner.y, inner.width, inner.height.min(3)); - render_line_input( + self.preset_editable = render_line_input( frame, input, &self.preset, @@ -296,6 +317,7 @@ impl RunModalSession { let inner = block.inner(panel); frame.render_widget(block, panel); self.token_view_height = usize::from(inner.height); + self.token_area = inner; self.token.ensure_visible(self.token_view_height); let labels = options .iter() @@ -339,7 +361,7 @@ impl RunModalSession { let inner = block.inner(panel); frame.render_widget(block, panel); let input = Rect::new(inner.x, inner.y, inner.width, inner.height.min(3)); - render_line_input( + self.environment_editable = render_line_input( frame, input, &self.environment, @@ -355,6 +377,7 @@ impl RunModalSession { inner.height.saturating_sub(input.height), ); self.environment_view_height = usize::from(list.height); + self.environment_area = list; self.environment_list .ensure_visible(self.environment_view_height); frame.render_widget( @@ -408,22 +431,29 @@ impl RunModalSession { } action(Action::SetModalInput(self.preset.value().to_owned())) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - match self.clicks.handle_click(mouse.column, mouse.row) { - Some(ModalHit::PresetInput) => consumed(), - Some( + Event::Mouse(mouse) => { + let target = self.clicks.handle_click(mouse.column, mouse.row); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && matches!(target, Some(ModalHit::PresetInput)) + && let Some(editable) = self.preset_editable + { + let _ = editable.place_cursor(&mut self.preset, mouse.column, mouse.row); + } + match self.click.update(&mouse, target) { + ClickOutcome::Armed | ClickOutcome::Activated(ModalHit::PresetInput) => { + consumed() + } + ClickOutcome::Activated( ModalHit::TokenOption(_) | ModalHit::EnvironmentInput | ModalHit::EnvironmentOption(_), ) - | None => ignored(), + | ClickOutcome::Ignored => ignored(), } } - Event::FocusGained - | Event::FocusLost - | Event::Mouse(_) - | Event::Key(_) - | Event::Resize(_, _) => ignored(), + Event::FocusGained | Event::FocusLost | Event::Key(_) | Event::Resize(_, _) => { + ignored() + } } } @@ -478,20 +508,43 @@ impl RunModalSession { self.environment.value().to_owned(), )) } - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - match self.clicks.handle_click(mouse.column, mouse.row) { - Some(ModalHit::EnvironmentOption(index)) => visible - .get(*index) + Event::Mouse(mouse) => { + if self + .environment_area + .contains((mouse.column, mouse.row).into()) + && matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) + { + self.click.cancel(); + return move_list( + &mut self.environment_list, + self.environment_view_height, + u8::from(mouse.kind == MouseEventKind::ScrollDown), + ); + } + let target = self.clicks.handle_click(mouse.column, mouse.row); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && matches!(target, Some(ModalHit::EnvironmentInput)) + && let Some(editable) = self.environment_editable + { + let _ = editable.place_cursor(&mut self.environment, mouse.column, mouse.row); + } + match self.click.update(&mouse, target) { + ClickOutcome::Activated(ModalHit::EnvironmentOption(index)) => visible + .get(index) .map_or_else(consumed, |name| insert_environment(field, name)), - Some(ModalHit::EnvironmentInput) => consumed(), - Some(ModalHit::PresetInput | ModalHit::TokenOption(_)) | None => ignored(), + ClickOutcome::Armed | ClickOutcome::Activated(ModalHit::EnvironmentInput) => { + consumed() + } + ClickOutcome::Activated(ModalHit::PresetInput | ModalHit::TokenOption(_)) + | ClickOutcome::Ignored => ignored(), } } - Event::FocusGained - | Event::FocusLost - | Event::Mouse(_) - | Event::Key(_) - | Event::Resize(_, _) => ignored(), + Event::FocusGained | Event::FocusLost | Event::Key(_) | Event::Resize(_, _) => { + ignored() + } } } @@ -532,20 +585,34 @@ impl RunModalSession { KeyCode::Enter => Some(self.token.selected_index), _ => return ignored(), }, - Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(_)) => { - match self.clicks.handle_click(mouse.column, mouse.row) { - Some(ModalHit::TokenOption(index)) => Some(*index), - Some( + Event::Mouse(mouse) => { + if self.token_area.contains((mouse.column, mouse.row).into()) + && matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) + { + self.click.cancel(); + return move_list( + &mut self.token, + self.token_view_height, + u8::from(mouse.kind == MouseEventKind::ScrollDown), + ); + } + let target = self.clicks.handle_click(mouse.column, mouse.row); + match self.click.update(&mouse, target) { + ClickOutcome::Activated(ModalHit::TokenOption(index)) => Some(index), + ClickOutcome::Armed => return consumed(), + ClickOutcome::Activated( ModalHit::PresetInput | ModalHit::EnvironmentInput | ModalHit::EnvironmentOption(_), ) - | None => return ignored(), + | ClickOutcome::Ignored => return ignored(), } } Event::FocusGained | Event::FocusLost - | Event::Mouse(_) | Event::Paste(_) | Event::Key(_) | Event::Resize(_, _) => return ignored(), @@ -822,7 +889,7 @@ mod tests { } #[test] - fn environment_and_token_modals_route_every_key_and_left_down_target() { + fn environment_and_token_modals_route_every_key_and_primary_release_target() { let names = vec!["HOME".to_owned(), "PATH".to_owned(), "SHELL".to_owned()]; let modal = ModalState::RunEnvironmentPicker { field: 4, @@ -897,8 +964,12 @@ mod tests { session.handle_event(mouse(9, 7, MouseEventKind::Down(MouseButton::Left)), &home), RunModalEvent::Handling(EventHandling::Consumed) ); - assert!(matches!( + assert_eq!( session.handle_event(mouse(9, 10, MouseEventKind::Down(MouseButton::Left)), &home,), + RunModalEvent::Handling(EventHandling::Consumed) + ); + assert!(matches!( + session.handle_event(mouse(9, 10, MouseEventKind::Up(MouseButton::Left)), &home,), RunModalEvent::Insert { field: 4, .. } )); @@ -960,6 +1031,10 @@ mod tests { mouse(9, 5, MouseEventKind::Down(MouseButton::Left)), &tokens, ), + RunModalEvent::Handling(EventHandling::Consumed) + ); + assert_eq!( + session.handle_event(mouse(9, 5, MouseEventKind::Up(MouseButton::Left)), &tokens,), RunModalEvent::OpenFile { field: 2 } ); assert_eq!( @@ -1031,6 +1106,394 @@ mod tests { } } + #[test] + fn token_and_environment_wheels_reach_the_last_visible_owner_row() { + let names = (0..20) + .map(|index| format!("NAME_{index:02}")) + .collect::>(); + let environment = ModalState::RunEnvironmentPicker { + field: 0, + names: names.clone(), + query: String::new(), + visible: names, + }; + let mut session = RunModalSession::default(); + let mut terminal = Terminal::new(TestBackend::new(46, 8)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &environment, Locale::En); + }) + .unwrap(); + for _ in 0..30 { + assert_eq!( + session.handle_event( + mouse( + session.environment_area.x, + session.environment_area.y, + MouseEventKind::ScrollDown, + ), + &environment, + ), + consumed() + ); + } + assert_eq!(session.environment_list.selected_index, 19); + assert!(session.environment_list.scroll > 0); + + let options = vec![RunTokenOption::Today; 20]; + let token = ModalState::RunTokenMenu { field: 0, options }; + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &token, Locale::En); + }) + .unwrap(); + for _ in 0..30 { + assert_eq!( + session.handle_event( + mouse( + session.token_area.x, + session.token_area.y, + MouseEventKind::ScrollDown, + ), + &token, + ), + consumed() + ); + } + assert_eq!(session.token.selected_index, 19); + assert!(session.token.scroll > 0); + } + + #[test] + fn environment_sync_preserves_equal_cursor_and_accepts_external_values() { + let names = vec!["abcdef".to_owned(), "remote".to_owned()]; + let original = ModalState::RunEnvironmentPicker { + field: 2, + names: names.clone(), + query: "abcdef".to_owned(), + visible: names.clone(), + }; + let mut session = RunModalSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &original, Locale::En); + }) + .unwrap(); + for _ in 0..4 { + assert_eq!( + session.handle_event(key(KeyCode::Left), &original), + consumed() + ); + } + assert_eq!( + session.environment.cursor(), + 2, + "Left did not move the cursor" + ); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &original, Locale::En); + }) + .unwrap(); + assert_eq!( + session.handle_event(key(KeyCode::Char('X')), &original), + action(Action::SetRunEnvironmentQuery("abXcdef".to_owned())) + ); + + let mut external = RunModalSession::default(); + terminal + .draw(|frame| { + let _ = external.render(frame, frame.area(), &original, Locale::En); + }) + .unwrap(); + let changed = ModalState::RunEnvironmentPicker { + field: 2, + names, + query: "remote".to_owned(), + visible: vec!["remote".to_owned()], + }; + terminal + .draw(|frame| { + let _ = external.render(frame, frame.area(), &changed, Locale::En); + }) + .unwrap(); + let rendered = buffer_text(&terminal); + assert!( + rendered.contains("remote"), + "external value is missing: {rendered}" + ); + assert!( + !rendered.contains("abcdef"), + "stale value remains: {rendered}" + ); + assert_eq!( + external.handle_event(key(KeyCode::Char('X')), &changed), + action(Action::SetRunEnvironmentQuery("remoteX".to_owned())) + ); + } + + #[test] + fn file_notice_uses_only_spare_rows_for_a_missing_root() { + let dir = tempdir().unwrap(); + let existing = ModalState::RunFilePicker { + field: 0, + context: RunPathContext { + workdir: dir.path().display().to_string(), + invoke_cwd: dir.path().display().to_string(), + }, + mode: RunPathInsertMode::Replace, + }; + let mut session = RunModalSession::default(); + let mut tall = Terminal::new(TestBackend::new(80, 10)).unwrap(); + tall.draw(|frame| { + let _ = session.render(frame, frame.area(), &existing, Locale::En); + }) + .unwrap(); + assert!(!buffer_text(&tall).contains("working directory is missing")); + + let missing = ModalState::RunFilePicker { + field: 0, + context: RunPathContext { + workdir: dir.path().join("gone/deeper").display().to_string(), + invoke_cwd: dir.path().display().to_string(), + }, + mode: RunPathInsertMode::Replace, + }; + let mut one = Terminal::new(TestBackend::new(80, 1)).unwrap(); + one.draw(|frame| { + let _ = session.render(frame, frame.area(), &missing, Locale::En); + }) + .unwrap(); + let one_row = buffer_text(&one); + assert!( + one_row.contains("Arguments"), + "picker lost its only row: {one_row}" + ); + assert!(!one_row.contains("working directory is missing")); + + let mut two = Terminal::new(TestBackend::new(80, 2)).unwrap(); + two.draw(|frame| { + let _ = session.render(frame, frame.area(), &missing, Locale::En); + }) + .unwrap(); + let two_rows = buffer_text(&two); + assert!( + two_rows.contains("working directory is missing"), + "{two_rows}" + ); + assert!( + two_rows.contains("Arguments"), + "picker chrome is missing: {two_rows}" + ); + } + + #[test] + fn preset_modal_keeps_input_semantics_centering_and_compact_border() { + let fresh = ModalState::RunPresetName { + value: String::new(), + existing: BTreeSet::from(["saved".to_owned()]), + }; + let mut session = RunModalSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| { + let _ = session.render(frame, frame.area(), &fresh, Locale::En); + }) + .unwrap(); + let rendered = buffer_text(&terminal); + assert!(!rendered.contains("This overwrites the existing preset")); + assert_eq!(terminal.backend().buffer()[(10, 6)].symbol(), "╭"); + assert_eq!(terminal.backend().buffer()[(69, 6)].symbol(), "╮"); + assert_eq!( + session.handle_event(key(KeyCode::Char('c')), &fresh), + action(Action::SetModalInput("c".to_owned())) + ); + assert_eq!( + session.handle_event(control('c'), &fresh), + action(Action::Quit) + ); + + let saved = ModalState::RunPresetName { + value: "saved".to_owned(), + existing: BTreeSet::from(["saved".to_owned()]), + }; + let mut compact = RunModalSession::default(); + let mut five = Terminal::new(TestBackend::new(60, 5)).unwrap(); + five.draw(|frame| { + let _ = compact.render(frame, frame.area(), &saved, Locale::En); + }) + .unwrap(); + assert!(!buffer_text(&five).contains("This overwrites")); + assert_eq!(five.backend().buffer()[(0, 4)].symbol(), "╰"); + assert_eq!(five.backend().buffer()[(59, 4)].symbol(), "╯"); + + let mut six = Terminal::new(TestBackend::new(60, 6)).unwrap(); + six.draw(|frame| { + let _ = compact.render(frame, frame.area(), &saved, Locale::En); + }) + .unwrap(); + assert!(buffer_text(&six).contains("This overwrites the existing preset saved.")); + assert_eq!(six.backend().buffer()[(0, 5)].symbol(), "╰"); + assert_eq!(six.backend().buffer()[(59, 5)].symbol(), "╯"); + } + + #[test] + fn environment_and_token_ignore_key_releases_and_plain_c() { + let environment = ModalState::RunEnvironmentPicker { + field: 3, + names: vec!["HOME".to_owned()], + query: String::new(), + visible: vec!["HOME".to_owned()], + }; + let mut environment_session = RunModalSession::default(); + assert_eq!( + environment_session.handle_event( + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &environment, + ), + ignored() + ); + assert_eq!( + environment_session.handle_event(key(KeyCode::Char('c')), &environment), + action(Action::SetRunEnvironmentQuery("c".to_owned())) + ); + + let token = ModalState::RunTokenMenu { + field: 3, + options: vec![RunTokenOption::FileOrFolder], + }; + let mut token_session = RunModalSession::default(); + assert_eq!( + token_session.handle_event( + Event::Key(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &token, + ), + ignored() + ); + assert_eq!( + token_session.handle_event(key(KeyCode::Char('c')), &token), + ignored() + ); + assert_eq!( + token_session.handle_event(control('c'), &token), + action(Action::Quit) + ); + assert_eq!( + token_session.handle_event(key(KeyCode::Enter), &token), + RunModalEvent::OpenFile { field: 3 } + ); + } + + #[test] + fn recomposed_picker_rows_cancel_an_armed_semantic_item() { + let old_token = ModalState::RunTokenMenu { + field: 1, + options: vec![RunTokenOption::Today, RunTokenOption::Home], + }; + let mut token_session = RunModalSession::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| { + let _ = token_session.render(frame, frame.area(), &old_token, Locale::En); + }) + .unwrap(); + let token_cell = (token_session.token_area.x, token_session.token_area.y); + assert_eq!( + token_session.handle_event( + mouse( + token_cell.0, + token_cell.1, + MouseEventKind::Down(MouseButton::Left), + ), + &old_token, + ), + consumed() + ); + let reordered = ModalState::RunTokenMenu { + field: 1, + options: vec![RunTokenOption::Home, RunTokenOption::Today], + }; + terminal + .draw(|frame| { + let _ = token_session.render(frame, frame.area(), &reordered, Locale::En); + }) + .unwrap(); + assert_eq!( + token_session.handle_event( + mouse( + token_cell.0, + token_cell.1, + MouseEventKind::Up(MouseButton::Left), + ), + &reordered, + ), + ignored(), + "a release must not activate the new item at an old pressed index" + ); + + let names = vec!["HOME".to_owned(), "PATH".to_owned()]; + let old_environment = ModalState::RunEnvironmentPicker { + field: 2, + names: names.clone(), + query: String::new(), + visible: names.clone(), + }; + let mut environment_session = RunModalSession::default(); + terminal + .draw(|frame| { + let _ = + environment_session.render(frame, frame.area(), &old_environment, Locale::En); + }) + .unwrap(); + let environment_cell = ( + environment_session.environment_area.x, + environment_session.environment_area.y, + ); + assert_eq!( + environment_session.handle_event( + mouse( + environment_cell.0, + environment_cell.1, + MouseEventKind::Down(MouseButton::Left), + ), + &old_environment, + ), + consumed() + ); + let filtered = ModalState::RunEnvironmentPicker { + field: 2, + names, + query: "P".to_owned(), + visible: vec!["PATH".to_owned()], + }; + terminal + .draw(|frame| { + let _ = environment_session.render(frame, frame.area(), &filtered, Locale::En); + }) + .unwrap(); + assert_eq!( + environment_session.handle_event( + mouse( + environment_cell.0, + environment_cell.1, + MouseEventKind::Up(MouseButton::Left), + ), + &filtered, + ), + ignored(), + "filtering must not turn the pressed HOME row into PATH" + ); + } + #[test] fn file_modal_uses_real_picker_events_missing_root_and_dot_output() { let dir = tempdir().unwrap(); @@ -1055,6 +1518,11 @@ mod tests { let _ = session.render(frame, frame.area(), &modal, Locale::En); }) .unwrap(); + session.cancel_click(); + assert!( + session.file.is_some(), + "cancellation must keep the active picker" + ); assert_eq!( session.handle_event(key(KeyCode::F(2)), &modal), RunModalEvent::Handling(EventHandling::Ignored) diff --git a/crates/skit-tui/src/screens/settings.rs b/crates/skit-tui/src/screens/settings.rs index 646b0134..e18dc39f 100644 --- a/crates/skit-tui/src/screens/settings.rs +++ b/crates/skit-tui/src/screens/settings.rs @@ -15,7 +15,7 @@ //! stale mark behind. Only a text cursor lives here, because only a text cursor is the terminal's //! own. -use std::{collections::BTreeMap, fmt::Display}; +use std::{cmp::Ordering, collections::BTreeMap, fmt::Display, num::NonZeroUsize}; use ratatui_core::{ buffer::Buffer, @@ -26,12 +26,9 @@ use ratatui_core::{ widgets::Widget, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, -}; -use ratatui_interact::components::{ - CheckBox, CheckBoxState, ScrollableContentState, handle_scrollable_content_key, - handle_scrollable_content_mouse, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; +use ratatui_interact::components::{CheckBox, CheckBoxState}; use ratatui_textarea::TextArea as RichTextArea; use ratatui_widgets::{ paragraph::{Paragraph, Wrap}, @@ -43,12 +40,14 @@ use skit_ui::{SettingsAction, SettingsItem, SettingsNote, SettingsSectionId, Set use tui_input::{Input as LineInput, InputRequest, backend::crossterm::EventHandler as _}; use crate::{ + pointer::{ClickOutcome, ClickTracker, EditableGeometry, TextAreaGeometry, TextAreaViewport}, rowclip::{RowClip, editor_cursor_virtual_row}, session::{ TextAreaEventHandling, checkbox_style, edit_textarea, new_textarea, render_line_input_band, render_textarea_band, textarea_control_height, textarea_text, }, theme::{ACCENT, BOX_INDIGO, SELECT_BG, SELECT_FG, padded_panel}, + viewport::VirtualScrollState, }; /// Typed focus and mouse identity. User-visible English never selects behavior. @@ -186,8 +185,11 @@ pub struct SettingsScreenSession { signature: Option>, inputs: BTreeMap, bodies: BTreeMap>>, + body_viewports: BTreeMap, + input_editables: BTreeMap, + body_editables: BTreeMap, option_cursors: BTreeMap, - scroll: ScrollableContentState, + scroll: VirtualScrollState, viewport: Rect, visible_height: usize, spans: BTreeMap, @@ -195,6 +197,7 @@ pub struct SettingsScreenSession { undo_group: usize, redo_group: usize, aligned: Option, + click: ClickTracker, } /// What the viewport was last aligned for. @@ -213,6 +216,10 @@ struct Alignment { } impl SettingsScreenSession { + pub(crate) fn cancel_click(&mut self) { + self.click.cancel(); + } + /// Return where one field sat in the last render, at the full size it asked for. /// /// A field the viewport did not reach has no rectangle, because nothing was drawn for it. A @@ -230,9 +237,11 @@ impl SettingsScreenSession { .map(|field| (field.key.clone(), shape(field))) .collect::>(); if self.signature.as_ref() != Some(&signature) { + self.click.cancel(); self.signature = Some(signature); self.inputs.clear(); self.bodies.clear(); + self.body_viewports.clear(); self.option_cursors.clear(); for field in fields(view) { let value = field.value().as_text(); @@ -240,6 +249,8 @@ impl SettingsScreenSession { ControlShape::Body => { self.bodies .insert(field.key.clone(), Box::new(new_textarea(&value))); + self.body_viewports + .insert(field.key.clone(), TextAreaViewport::default()); } ControlShape::Line { .. } => { self.inputs.insert(field.key.clone(), LineInput::new(value)); @@ -288,9 +299,8 @@ impl SettingsScreenSession { /// back one frame later. fn follow_focus(&mut self, focused: &str, total: usize) { let maximum = total.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum { - self.scroll.set_scroll_offset(maximum); - } + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum)); let current = Alignment { focused: focused.to_owned(), visible_height: self.visible_height, @@ -307,26 +317,25 @@ impl SettingsScreenSession { }; let offset = self.scroll.scroll_offset(); let end = start.saturating_add(height); - if height > self.visible_height + if matches!(height.cmp(&self.visible_height), Ordering::Greater) && let Some(body) = self.bodies.get(focused) { let cursor = editor_cursor_virtual_row(start, body.cursor().0); - let next = if cursor - start < self.visible_height { - start - } else { - cursor + 1 - self.visible_height - }; + let next = cursor + .saturating_add(1) + .saturating_sub(self.visible_height) + .max(start); self.scroll.set_scroll_offset(next); return; } - if start < offset { - self.scroll.set_scroll_offset(start); - } else if end > offset.saturating_add(self.visible_height) { - // Show as much of the target as fits, and never past its first row. A target taller - // than the viewport would otherwise arrive showing only its tail, which for a section - // means the heading and the opening sentence are the parts that scroll away. - self.scroll - .set_scroll_offset(end.saturating_sub(self.visible_height).min(start)); + let viewport_end = offset.saturating_add(self.visible_height); + match (start.cmp(&offset), end.cmp(&viewport_end)) { + (Ordering::Less, _) => self.scroll.set_scroll_offset(start), + (Ordering::Equal | Ordering::Greater, Ordering::Greater) => { + self.scroll + .set_scroll_offset(end.saturating_sub(self.visible_height).min(start)); + } + (Ordering::Equal | Ordering::Greater, Ordering::Less | Ordering::Equal) => {} } } @@ -451,26 +460,37 @@ impl SettingsScreenSession { view: &SettingsView, geometry: &SettingsScreenGeometry, ) -> Option { - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) { - return handle_scrollable_content_mouse( - &mut self.scroll, - mouse, - self.viewport, - self.visible_height, - ) - .map(|_| SettingsScreenEvent::Changed); - } - if !matches!(mouse.kind, MouseEventKind::Down(_)) { - return None; + if self + .scroll + .handle_mouse(mouse, self.viewport, self.visible_height) + { + return Some(SettingsScreenEvent::Changed); } let target = geometry .hits .iter() + .rev() .find(|hit| hit.area.contains((mouse.column, mouse.row).into())) - .map(|hit| hit.target.clone())?; + .map(|hit| &hit.target); + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && let Some(SettingsControlId::Field(key)) = target + { + if let Some(editable) = self.input_editables.get(key).copied() + && let Some(input) = self.inputs.get_mut(key) + { + let _ = editable.place_cursor(input, mouse.column, mouse.row); + } + if let Some(editable) = self.body_editables.get(key).copied() + && let Some(body) = self.bodies.get_mut(key) + { + let _ = editable.place_cursor(body, mouse.column, mouse.row); + } + } + let target = match self.click.update(mouse, target) { + ClickOutcome::Armed => return Some(SettingsScreenEvent::Changed), + ClickOutcome::Activated(target) => target, + ClickOutcome::Ignored => return None, + }; match target { // A click on a toggle both moves the keyboard and flips it, which is the one thing a // person means by clicking a checkbox. @@ -503,11 +523,11 @@ impl SettingsScreenSession { fn handle_paste(&mut self, view: &SettingsView, value: &str) -> Option { let focused = view.focused().to_owned(); if let Some(body) = self.bodies.get_mut(&focused) { - let before_cursor = body.cursor(); - body.insert_str(value); - if body.cursor() != before_cursor { - self.aligned = None; + if value.is_empty() { + return None; } + body.insert_str(value); + self.aligned = None; self.undo_group = 1; self.redo_group = 0; return set_field(&focused, FieldValue::text(textarea_text(body))); @@ -560,8 +580,9 @@ impl SettingsScreenSession { } fn scroll_key(&mut self, key: KeyEvent) -> Option { - handle_scrollable_content_key(&mut self.scroll, &key, self.visible_height) - .map(|_| SettingsScreenEvent::Changed) + self.scroll + .handle_key(&key, self.visible_height) + .then_some(SettingsScreenEvent::Changed) } /// Return where one virtual span lands on screen at its full height, clipped by nothing. @@ -569,12 +590,11 @@ impl SettingsScreenSession { let offset = self.scroll.scroll_offset(); Rect::new( self.viewport.x, - self.viewport.y.saturating_add( - u16::try_from(start.saturating_sub(offset)) - .expect("the settings item starts inside terminal geometry"), - ), + self.viewport + .y + .saturating_add(u16::try_from(start.saturating_sub(offset)).unwrap_or(u16::MAX)), self.viewport.width, - u16::try_from(height).expect("the settings item height fits terminal geometry"), + u16::try_from(height).unwrap_or(u16::MAX), ) } @@ -590,8 +610,7 @@ impl SettingsScreenSession { let clipped_end = end.min(viewport_end); Some(RowClip::new( height, - u16::try_from(clipped_start.saturating_sub(start)) - .expect("the settings band offset fits Ratatui's row offset"), + clipped_start.saturating_sub(start), Rect::new( self.viewport.x, self.viewport.y.saturating_add( @@ -633,8 +652,13 @@ impl SettingsScreenSession { } = *draw; match &field.kind { FieldKind::Multiline => { - if let Some(body) = self.bodies.get_mut(&field.key) { - render_textarea_band(frame, clip, body, focused, label); + if let (Some(body), Some(viewport)) = ( + self.bodies.get_mut(&field.key), + self.body_viewports.get_mut(&field.key), + ) && let Some(editable) = + render_textarea_band(frame, clip, body, viewport, focused, label) + { + self.body_editables.insert(field.key.clone(), editable); } hits.push(SettingsHitRegion { area, @@ -670,9 +694,12 @@ impl SettingsScreenSession { | FieldKind::ArgumentList { .. } => { if let Some(input) = self.inputs.get(&field.key) { let secret = matches!(field.kind, FieldKind::Secret); - render_line_input_band(frame, clip, input, secret, focused, label, None); + if let Some(editable) = + render_line_input_band(frame, clip, input, secret, focused, label, None) + { + self.input_editables.insert(field.key.clone(), editable); + } if input.value().is_empty() - && !field.help.is_empty() && let Some(content) = clip.row(1) { Paragraph::new(text(locale, &field.help)) @@ -787,6 +814,20 @@ pub fn render_settings( locale: Locale, ) -> SettingsScreenGeometry { session.sync(view); + if area.is_empty() { + session.viewport = area; + session.visible_height = 0; + session.spans.clear(); + session.rendered.clear(); + session.input_editables.clear(); + session.body_editables.clear(); + session.aligned = None; + return SettingsScreenGeometry { + body: area, + first_visible: session.scroll.scroll_offset(), + hits: Vec::new(), + }; + } // Version 0.4 titles the panel with the entry name it opened with // (`src/skit/tui_settings.py:869-871`). let block = padded_panel( @@ -796,15 +837,29 @@ pub fn render_settings( let body = block.inner(area); frame.render_widget(block, area); session.viewport = body; - session.visible_height = usize::from(body.height).max(1); + session.visible_height = usize::from(body.height); + if body.is_empty() { + session.spans.clear(); + session.rendered.clear(); + session.input_editables.clear(); + session.body_editables.clear(); + session.aligned = None; + return SettingsScreenGeometry { + body, + first_visible: session.scroll.scroll_offset(), + hits: Vec::new(), + }; + } let items = layout_items(view, locale, body.width); let total = items .last() .map_or(0, |item| item.start.saturating_add(item.height)); - session.scroll.set_lines(vec![String::new(); total.max(1)]); + session.scroll.set_line_count(total.max(1)); session.spans.clear(); session.rendered.clear(); + session.input_editables.clear(); + session.body_editables.clear(); for (index, item) in items.iter().enumerate() { match &item.item { Item::Control { key, .. } => { @@ -902,8 +957,8 @@ pub fn render_settings( // Version 0.4 hosts the body in a `VerticalScroll`, whose scrollbar is the only thing that says // the screen continues (`src/skit/tui_settings.py:388`, and visible in its shipped frame). With // the sections below the fold and no mark beside them, a reader has nothing to act on. - if total > usize::from(body.height) { - let mut scrollbar = ScrollbarState::new(total.saturating_sub(usize::from(body.height))) + if let Some(overflow) = NonZeroUsize::new(total.saturating_sub(usize::from(body.height))) { + let mut scrollbar = ScrollbarState::new(overflow.get()) .position(session.scroll.scroll_offset()) .viewport_content_length(usize::from(body.height)); frame.render_stateful_widget( @@ -1539,17 +1594,7 @@ mod tests { }) .expect("the candidate is not a click target"); let area = hit.area; - dispatch( - &mut session, - &mut view, - &geometry, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: area.x, - row: area.y, - modifiers: KeyModifiers::NONE, - }), - ); + dispatch_click(&mut session, &mut view, &geometry, area); assert_eq!( view.submitted_values() .get(MANAGE_KEY) @@ -1968,6 +2013,69 @@ mod tests { handled } + fn dispatch_click( + session: &mut SettingsScreenSession, + view: &mut SettingsView, + geometry: &SettingsScreenGeometry, + area: Rect, + ) -> Option { + assert_eq!( + session.handle_event(click(area), view, geometry), + Some(SettingsScreenEvent::Changed), + ); + dispatch( + session, + view, + geometry, + mouse(area, MouseEventKind::Up(MouseButton::Left)), + ) + } + + #[test] + fn settings_click_rejects_mismatched_release_and_nonprimary_buttons() { + let mut session = SettingsScreenSession::default(); + let view = prompt_view(); + let (_, geometry) = draw(&mut session, &view, DEMO_WIDTH, 90); + let field_area = |key: &str| { + geometry + .hits + .iter() + .find(|hit| hit.target == SettingsControlId::Field(key.to_owned())) + .unwrap() + .area + }; + let name = field_area(NAME_KEY); + let description = field_area(DESCRIPTION_KEY); + assert_eq!( + session.handle_event(click(name), &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse(description, MouseEventKind::Up(MouseButton::Left)), + &view, + &geometry, + ), + None + ); + assert_eq!( + session.handle_event( + mouse(name, MouseEventKind::Down(MouseButton::Right)), + &view, + &geometry, + ), + None + ); + assert_eq!( + session.handle_event( + mouse(name, MouseEventKind::Up(MouseButton::Left)), + &view, + &geometry, + ), + None + ); + } + /// Typing reaches the focused box, and the model keeps every character. #[test] fn typing_reaches_the_focused_box_and_the_model_keeps_it() { @@ -2241,7 +2349,17 @@ mod tests { description_value ); } - dispatch(&mut session, &mut view, &geometry, click(description.area)); + assert_eq!( + session.handle_event(click(description.area), &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + assert_eq!(view.focused(), NAME_KEY); + dispatch( + &mut session, + &mut view, + &geometry, + mouse(description.area, MouseEventKind::Up(MouseButton::Left)), + ); assert_eq!(view.focused(), DESCRIPTION_KEY); // Clicking a text box moves the keyboard to it. @@ -2250,7 +2368,7 @@ mod tests { .iter() .find(|hit| hit.target == SettingsControlId::Field(NEEDS_KEY.to_owned())) .expect("the needs box is clickable"); - dispatch(&mut session, &mut view, &geometry, click(needs.area)); + dispatch_click(&mut session, &mut view, &geometry, needs.area); assert_eq!(view.focused(), NEEDS_KEY); // Clicking one option picks it, by value, and takes the keyboard with it. @@ -2265,7 +2383,7 @@ mod tests { } }) .expect("every option is clickable"); - dispatch(&mut session, &mut view, &geometry, click(store.area)); + dispatch_click(&mut session, &mut view, &geometry, store.area); assert_eq!(view.field(WORKDIR_KEY).unwrap().value().as_text(), "store"); assert_eq!(view.focused(), WORKDIR_KEY); @@ -2276,7 +2394,7 @@ mod tests { .find(|hit| hit.target == SettingsControlId::NewRunner) .expect("the new-agent chip is clickable"); assert_eq!( - session.handle_event(click(door.area), &view, &geometry), + dispatch_click(&mut session, &mut view, &geometry, door.area), Some(SettingsScreenEvent::Action(SettingsAction::NewRunner)) ); } @@ -2380,7 +2498,20 @@ mod tests { .iter() .find(|hit| hit.target == SettingsControlId::Field("kind:boolean".to_owned())) .expect("a toggle is clickable"); - dispatch(&mut session, &mut view, &geometry, click(toggle.area)); + assert_eq!( + session.handle_event(click(toggle.area), &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + assert_eq!( + view.field("kind:boolean").unwrap().value().as_text(), + "true" + ); + dispatch( + &mut session, + &mut view, + &geometry, + mouse(toggle.area, MouseEventKind::Up(MouseButton::Left)), + ); assert_eq!( view.field("kind:boolean").unwrap().value().as_text(), "false", @@ -2602,6 +2733,22 @@ mod tests { key: DESCRIPTION_KEY.to_owned(), }); let (_, geometry) = draw(&mut session, &view, DEMO_WIDTH, 90); + let before_empty_paste = view + .field(DESCRIPTION_KEY) + .expect("description exists") + .value() + .clone(); + assert_eq!( + session.handle_event(Event::Paste(String::new()), &view, &geometry), + None + ); + assert_eq!( + view.field(DESCRIPTION_KEY) + .expect("description exists") + .value(), + &before_empty_paste, + "an empty textarea paste must not change the model", + ); let pasted = dispatch( &mut session, &mut view, @@ -2794,6 +2941,599 @@ mod tests { assert!(!is_selected(&empty_choice, &option)); } + #[test] + fn same_signature_line_input_preserves_cursor_and_accepts_external_values() { + let view = prompt_view(); + let mut stable = SettingsScreenSession::default(); + let (_, geometry) = draw(&mut stable, &view, DEMO_WIDTH, 40); + for _ in 0..2 { + assert_eq!( + stable.handle_event(key(KeyCode::Left, KeyModifiers::NONE), &view, &geometry,), + Some(SettingsScreenEvent::Changed) + ); + } + let _ = draw(&mut stable, &view, DEMO_WIDTH, 40); + assert_eq!( + stable.handle_event( + key(KeyCode::Char('X'), KeyModifiers::NONE), + &view, + &geometry, + ), + Some(SettingsScreenEvent::Action(SettingsAction::SetField { + key: NAME_KEY.to_owned(), + value: FieldValue::text("BriXef"), + })) + ); + + let mut external = SettingsScreenSession::default(); + let mut replaced = prompt_view(); + let _ = draw(&mut external, &replaced, DEMO_WIDTH, 40); + replaced.set_value(NAME_KEY, FieldValue::text("Host replacement")); + let (terminal, geometry) = draw(&mut external, &replaced, DEMO_WIDTH, 40); + let area = external.field_area(NAME_KEY).expect("name field area"); + let mut name_control = String::new(); + for row in area.y..area.bottom() { + for column in area.x..area.right() { + name_control.push_str(terminal.backend().buffer()[(column, row)].symbol()); + } + } + assert!(name_control.contains("Host replacement"), "{name_control}"); + assert!( + !name_control.contains("Brief"), + "stale input remains: {name_control}" + ); + assert_eq!( + external.handle_event( + key(KeyCode::Char('X'), KeyModifiers::NONE), + &replaced, + &geometry, + ), + Some(SettingsScreenEvent::Action(SettingsAction::SetField { + key: NAME_KEY.to_owned(), + value: FieldValue::text("Host replacementX"), + })) + ); + } + + #[test] + fn matching_release_requires_the_same_control_shape_after_recompose() { + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new( + SettingsSectionId::Basics, + vec![SettingsItem::field(Field::new( + "same", + "Same", + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text("value"), + ))], + )]; + view.update(SettingsAction::Focus { + key: "same".to_owned(), + }); + + let mut stable = SettingsScreenSession::default(); + let (_, geometry) = draw(&mut stable, &view, 40, 10); + let stable_area = stable.field_area("same").expect("text field area"); + assert_eq!( + stable.handle_event(click(stable_area), &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + let (_, stable_geometry) = draw(&mut stable, &view, 40, 10); + assert_eq!( + stable.handle_event( + mouse(stable_area, MouseEventKind::Up(MouseButton::Left)), + &view, + &stable_geometry, + ), + Some(SettingsScreenEvent::Action(SettingsAction::Focus { + key: "same".to_owned(), + })) + ); + + let mut changed = SettingsScreenSession::default(); + let (_, geometry) = draw(&mut changed, &view, 40, 10); + let old_area = changed.field_area("same").expect("old field area"); + assert_eq!( + changed.handle_event(click(old_area), &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + let field = view.field_mut("same").expect("same field"); + field.kind = FieldKind::Boolean; + field.set_value(FieldValue::boolean(true)); + let (_, changed_geometry) = draw(&mut changed, &view, 40, 10); + assert_eq!( + changed.handle_event( + mouse(old_area, MouseEventKind::Up(MouseButton::Left)), + &view, + &changed_geometry, + ), + None, + "a release must not toggle a control that changed shape after press" + ); + } + + #[test] + fn available_prompt_picker_does_not_capture_plain_keys() { + let mut inputs = settings_inputs(); + inputs.interpolate = true; + inputs.candidates = (0..skit_ui::PROMPT_LIST_PREVIEW_LIMIT + 2) + .map(|index| format!("value_{index}")) + .collect(); + let view = SettingsView::from_inputs(&inputs); + assert!(view.prompt_picker_available()); + let mut session = SettingsScreenSession::default(); + let (_, geometry) = draw(&mut session, &view, DEMO_WIDTH, 60); + assert_eq!( + session.handle_event( + key(KeyCode::Char('x'), KeyModifiers::NONE), + &view, + &geometry, + ), + Some(SettingsScreenEvent::Action(SettingsAction::SetField { + key: NAME_KEY.to_owned(), + value: FieldValue::text("Briefx"), + })) + ); + assert_eq!( + session.handle_event(key(KeyCode::Tab, KeyModifiers::NONE), &view, &geometry), + Some(SettingsScreenEvent::Action(SettingsAction::FocusNext)) + ); + assert_eq!( + session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &view, + &geometry, + ), + Some(SettingsScreenEvent::OpenPromptCandidates) + ); + } + + #[test] + fn multi_choice_mouse_keeps_selection_order_cursor_and_focus_style() { + let options = vec![ + ChoiceOption::plain("one"), + ChoiceOption::plain("two"), + ChoiceOption::plain("three"), + ]; + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new( + SettingsSectionId::Basics, + vec![ + SettingsItem::field(Field::new( + "multiple", + "Multiple", + FieldKind::MultiChoice { options }, + FieldOwner::Declared, + FieldValue::Explicit(TypedValue::Choices(vec![ + "one".to_owned(), + "two".to_owned(), + "hidden".to_owned(), + ])), + )), + SettingsItem::field(Field::new( + "other", + "Other", + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text("value"), + )), + ], + )]; + view.update(SettingsAction::Focus { + key: "multiple".to_owned(), + }); + let mut session = SettingsScreenSession::default(); + let (terminal, geometry) = draw(&mut session, &view, 50, 18); + let option_area = |geometry: &SettingsScreenGeometry, value: &str| { + geometry + .hits + .iter() + .find(|hit| { + hit.target + == (SettingsControlId::Option { + field: "multiple".to_owned(), + value: value.to_owned(), + }) + }) + .expect("option hit") + .area + }; + let highlighted = |buffer: &Buffer, area: Rect| { + (area.x..area.right()).any(|column| buffer[(column, area.y)].bg == SELECT_BG) + }; + assert!(highlighted( + terminal.backend().buffer(), + option_area(&geometry, "one") + )); + assert!(!highlighted( + terminal.backend().buffer(), + option_area(&geometry, "two") + )); + assert!(!highlighted( + terminal.backend().buffer(), + option_area(&geometry, "three") + )); + + let three = option_area(&geometry, "three"); + assert!(matches!( + dispatch_click(&mut session, &mut view, &geometry, three), + Some(SettingsScreenEvent::Action(SettingsAction::SetField { .. })) + )); + assert_eq!( + view.field("multiple").unwrap().value().explicit(), + Some(&TypedValue::Choices(vec![ + "one".to_owned(), + "two".to_owned(), + "three".to_owned(), + "hidden".to_owned(), + ])) + ); + let (terminal, geometry) = draw(&mut session, &view, 50, 18); + assert!(highlighted( + terminal.backend().buffer(), + option_area(&geometry, "three") + )); + assert!(!highlighted( + terminal.backend().buffer(), + option_area(&geometry, "one") + )); + + let two = option_area(&geometry, "two"); + let _ = dispatch_click(&mut session, &mut view, &geometry, two); + assert_eq!( + view.field("multiple").unwrap().value().explicit(), + Some(&TypedValue::Choices(vec![ + "one".to_owned(), + "three".to_owned(), + "hidden".to_owned(), + ])) + ); + view.update(SettingsAction::Focus { + key: "other".to_owned(), + }); + let (terminal, geometry) = draw(&mut session, &view, 50, 18); + for value in ["one", "two", "three"] { + assert!(!highlighted( + terminal.backend().buffer(), + option_area(&geometry, value) + )); + } + } + + #[test] + fn page_keys_and_visible_geometry_exclude_both_offscreen_sides() { + let fields = (0..10) + .map(|index| { + SettingsItem::field(Field::new( + format!("field-{index}"), + format!("Field {index}"), + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text(format!("value-{index}")), + )) + }) + .collect(); + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new(SettingsSectionId::Basics, fields)]; + view.update(SettingsAction::Focus { + key: "field-0".to_owned(), + }); + let mut session = SettingsScreenSession::default(); + let (_, top) = draw(&mut session, &view, 40, 8); + assert!(session.field_area("field-9").is_none()); + assert!( + !top.hits + .iter() + .any(|hit| { hit.target == SettingsControlId::Field("field-9".to_owned()) }) + ); + assert_eq!( + session.handle_event(key(KeyCode::PageDown, KeyModifiers::NONE), &view, &top,), + Some(SettingsScreenEvent::Changed) + ); + let (_, bottom) = draw(&mut session, &view, 40, 8); + assert!(bottom.first_visible > top.first_visible); + assert!(session.field_area("field-0").is_none()); + assert!( + !bottom + .hits + .iter() + .any(|hit| { hit.target == SettingsControlId::Field("field-0".to_owned()) }) + ); + for hit in &bottom.hits { + assert!(hit.area.height > 0, "zero-height hit: {hit:?}"); + assert!(bottom.body.contains((hit.area.x, hit.area.y).into())); + } + assert_eq!( + session.handle_event(key(KeyCode::PageUp, KeyModifiers::NONE), &view, &bottom), + Some(SettingsScreenEvent::Changed) + ); + } + + #[test] + fn ordinary_focus_jumps_scroll_only_when_the_target_is_outside_the_viewport() { + let fields = (0..10) + .map(|index| { + SettingsItem::field(Field::new( + format!("field-{index}"), + format!("Field {index}"), + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text(format!("value-{index}")), + )) + }) + .collect(); + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new(SettingsSectionId::Basics, fields)]; + view.update(SettingsAction::Focus { + key: "field-0".to_owned(), + }); + let mut session = SettingsScreenSession::default(); + let (_, mut geometry) = draw(&mut session, &view, 40, 8); + for _ in 0..5 { + assert_eq!( + session.handle_event( + mouse(geometry.body, MouseEventKind::ScrollDown), + &view, + &geometry, + ), + Some(SettingsScreenEvent::Changed) + ); + (_, geometry) = draw(&mut session, &view, 40, 8); + } + let reader_offset = geometry.first_visible; + assert!( + reader_offset > 0, + "the wheel did not establish a scrolled viewport" + ); + + let already_visible = (1..9) + .map(|index| format!("field-{index}")) + .find(|key| { + let Some(area) = session.field_area(key) else { + return false; + }; + let fully_drawn = geometry.hits.iter().any(|hit| { + hit.target == SettingsControlId::Field(key.clone()) && hit.area == area + }); + fully_drawn && area.y >= geometry.body.y && area.bottom() < geometry.body.bottom() + }) + .expect("the scrolled viewport must contain a complete control above its bottom edge"); + view.update(SettingsAction::Focus { + key: already_visible, + }); + let (_, still) = draw(&mut session, &view, 40, 8); + assert_eq!( + still.first_visible, reader_offset, + "focusing a complete visible control moved the reader's viewport" + ); + + view.update(SettingsAction::Focus { + key: "field-9".to_owned(), + }); + let (_, jumped) = draw(&mut session, &view, 40, 8); + assert!( + jumped.first_visible > reader_offset, + "focus did not bring a control below the viewport into view" + ); + let area = session + .field_area("field-9") + .expect("the focused control below the fold was not rendered"); + assert!( + area.y >= jumped.body.y && area.bottom() <= jumped.body.bottom(), + "focused control {area:?} is outside {:?}", + jumped.body + ); + } + + #[test] + fn oversized_textarea_aligns_top_and_first_rows_below_the_viewport() { + let body = (0..8) + .map(|index| format!("line-{index}")) + .collect::>() + .join("\n"); + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new( + SettingsSectionId::Basics, + vec![ + SettingsItem::field(Field::new( + "prefix", + "Prefix", + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text("fixed"), + )), + SettingsItem::field(Field::new( + "body", + "Body", + FieldKind::Multiline, + FieldOwner::Declared, + FieldValue::text(body), + )), + ], + )]; + view.update(SettingsAction::Focus { + key: "body".to_owned(), + }); + let mut session = SettingsScreenSession::default(); + let (_, initial) = draw(&mut session, &view, 30, 8); + for _ in 0..7 { + assert_eq!( + session.handle_event(key(KeyCode::Up, KeyModifiers::NONE), &view, &initial), + Some(SettingsScreenEvent::Changed) + ); + } + let (top, top_geometry) = draw(&mut session, &view, 30, 8); + assert_eq!(top_geometry.first_visible, 4); + assert!(rendered(top.backend().buffer()).contains("line-0")); + + for _ in 0..5 { + assert_eq!( + session.handle_event(key(KeyCode::Down, KeyModifiers::NONE), &view, &top_geometry,), + Some(SettingsScreenEvent::Changed) + ); + } + let (boundary, boundary_geometry) = draw(&mut session, &view, 30, 8); + assert_eq!( + boundary_geometry.first_visible, 5, + "the first cursor row below the viewport did not advance by one" + ); + assert!(rendered(boundary.backend().buffer()).contains("line-5")); + + assert_eq!( + session.handle_event( + key(KeyCode::Down, KeyModifiers::NONE), + &view, + &boundary_geometry, + ), + Some(SettingsScreenEvent::Changed) + ); + let (below, below_geometry) = draw(&mut session, &view, 30, 8); + assert_eq!(below_geometry.first_visible, 6); + assert!(rendered(below.backend().buffer()).contains("line-6")); + } + + #[test] + fn multiline_content_beyond_terminal_coordinates_keeps_its_tail_caret_and_hit() { + let mut body = "row\n".repeat(65_536); + body.push_str("VISIBLE-TAIL"); + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new( + SettingsSectionId::Basics, + vec![SettingsItem::field(Field::new( + DESCRIPTION_KEY, + "Description", + FieldKind::Multiline, + FieldOwner::Declared, + FieldValue::text(body.clone()), + ))], + )]; + view.update(SettingsAction::Focus { + key: DESCRIPTION_KEY.to_owned(), + }); + let mut session = SettingsScreenSession::default(); + + let (terminal, geometry) = draw(&mut session, &view, 36, 8); + let hit = geometry + .hits + .iter() + .find(|hit| hit.target == SettingsControlId::Field(DESCRIPTION_KEY.to_owned())) + .expect("the visible textarea band must remain clickable"); + let buffer = terminal.backend().buffer(); + + assert!( + rendered(buffer).contains("VISIBLE-TAIL"), + "the tail at a virtual row above u16::MAX is not visible" + ); + assert!( + hit.area.height > 0 + && hit.area.y >= geometry.body.y + && hit.area.bottom() <= geometry.body.bottom(), + "the textarea published a ghost hit outside its viewport: {hit:?}" + ); + assert!( + (hit.area.y..hit.area.bottom()).any(|row| { + (hit.area.x..hit.area.right()).any(|column| { + let cell = &buffer[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the visible tail lost its focused caret style" + ); + assert_eq!( + view.field(DESCRIPTION_KEY).unwrap().value().as_text(), + body, + "rendering must not truncate the model value" + ); + } + + #[test] + fn boolean_help_read_only_and_scrollbar_use_their_own_rendered_rows() { + let mut view = prompt_view(); + view.sections = vec![SettingsSection::new( + SettingsSectionId::Basics, + vec![ + SettingsItem::field(Field::new( + "yes", + "Enabled", + FieldKind::Boolean, + FieldOwner::Declared, + FieldValue::boolean(true), + )), + SettingsItem::field(Field::new( + "no", + "Disabled", + FieldKind::Boolean, + FieldOwner::Declared, + FieldValue::boolean(false), + )), + SettingsItem::field( + Field::new( + "empty", + "Empty", + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text(""), + ) + .with_help("A name is required."), + ), + SettingsItem::field(Field::read_only( + "linked", + "Linked", + FieldOwner::Declared, + FieldValue::text("/tmp/source.md"), + ReadOnlyReason::ReferenceMode, + )), + ], + )]; + view.update(SettingsAction::Focus { + key: "yes".to_owned(), + }); + let mut session = SettingsScreenSession::default(); + let (terminal, _) = draw(&mut session, &view, 30, 24); + let rows = rendered(terminal.backend().buffer()) + .lines() + .map(str::to_owned) + .collect::>(); + let enabled = rows.iter().find(|row| row.contains("Enabled")).unwrap(); + let disabled = rows.iter().find(|row| row.contains("Disabled")).unwrap(); + assert!(enabled.contains('☑'), "{enabled}"); + assert!(disabled.contains('☐'), "{disabled}"); + assert!(rows.iter().any(|row| row.contains("A name is required."))); + assert_eq!( + rows.iter() + .filter(|row| row.contains("Linked: /tmp/source.md")) + .count(), + 1 + ); + assert!(rows.iter().any(|row| row.contains("source directly."))); + + let fields = (0..12) + .map(|index| { + SettingsItem::field(Field::new( + format!("long-{index}"), + format!("Long {index}"), + FieldKind::Text, + FieldOwner::Declared, + FieldValue::text(format!("value-{index}")), + )) + }) + .collect(); + view.sections = vec![SettingsSection::new(SettingsSectionId::Basics, fields)]; + view.update(SettingsAction::Focus { + key: "long-0".to_owned(), + }); + let mut scrolling = SettingsScreenSession::default(); + let (terminal, _) = draw(&mut scrolling, &view, 40, 8); + assert!( + (0..terminal.backend().buffer().area.height).any(|row| { + let cell = &terminal.backend().buffer()[(39, row)]; + cell.symbol() == "█" && cell.fg == super::BOX_INDIGO + }), + "the overflow scrollbar lost its indigo thumb" + ); + } + #[test] fn option_cursors_and_unavailable_picker_keep_keyboard_and_mouse_reverses() { let mut session = SettingsScreenSession::default(); @@ -2887,7 +3627,21 @@ mod tests { row: 0, modifiers: KeyModifiers::NONE, }; - assert_eq!(session.handle_mouse(&mouse, &view, &geometry), None); + assert_eq!( + session.handle_mouse(&mouse, &view, &geometry), + Some(SettingsScreenEvent::Changed) + ); + assert_eq!( + session.handle_mouse( + &MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..mouse + }, + &view, + &geometry, + ), + None + ); assert_eq!( choice_key( diff --git a/crates/skit-tui/src/session.rs b/crates/skit-tui/src/session.rs index 26784876..ae30632b 100644 --- a/crates/skit-tui/src/session.rs +++ b/crates/skit-tui/src/session.rs @@ -9,20 +9,18 @@ use ratatui_core::{ style::{Color, Modifier, Style}, terminal::Frame, text::{Line, Span}, - widgets::Widget, }; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui_interact::{ components::{ Button, ButtonState, ButtonStyle, ButtonVariant, CheckBox, CheckBoxState, CheckBoxStyle, - ScrollableContentState, Select, SelectAction, SelectState, SelectStyle, Toast, ToastState, - ToastStyle, handle_scrollable_content_key, handle_scrollable_content_mouse, - handle_select_key, handle_select_mouse, + Select, SelectAction, SelectState, SelectStyle, Toast, ToastState, ToastStyle, + handle_select_key, }, state::FocusManager, - traits::{ClickRegion, ClickRegionRegistry}, + traits::ClickRegion, }; use ratatui_textarea::{CursorMove, TextArea as RichTextArea}; use ratatui_widgets::{ @@ -39,19 +37,23 @@ use skit_i18n::{Locale, format_text, text}; use skit_ui::{ Action, AddAction, AddWorkflowState, ChoicePresentation, FormControl, FormField, FormView, InputMode, LibraryState, ModalState, RunDegradationNotice, RunField, RunFieldRole, RunFormView, - RunTokenError, RunValidationError, Screen, UiCommand, + RunTokenError, RunValidationError, RunnerEditorOwner, Screen, UiCommand, }; use tui_input::{Input as LineInput, InputRequest, backend::crossterm::EventHandler as _}; use unicode_width::UnicodeWidthStr as _; use crate::{ - HitRegion, HitTarget, ViewGeometry, command_action, + HitRegion, HitTarget, RunFieldCommand, ViewGeometry, command_action, footer::FooterSession, map_event, - rowclip::{RowClip, editor_cursor_virtual_row}, + pointer::{ + ClickDispatch, ClickOutcome, ClickTracker, EditableGeometry, HitMap, TextAreaGeometry, + TextAreaViewport, display_cursor, display_scroll, is_primary_down, secret_display, + }, + rowclip::{RowClip, bounded_textarea_lines, editor_cursor_virtual_row}, run_field_command_action, screens::add::{AddScreenEvent, AddScreenGeometry, AddScreenSession, render_add}, - screens::library::LibraryScreenSession, + screens::library::{LibraryClickTarget, LibraryPointerHandling, LibraryScreenSession}, screens::management::{ HealthEventHandling, HealthScreenSession, RunnerEditorEventHandling, RunnerEditorSession, RunnerManagerEventHandling, RunnerManagerSession, @@ -62,12 +64,16 @@ use crate::{ PromptCandidatePickerEvent, PromptCandidatePickerSession, render_file_picker, render_prompt_candidate_picker, }, - screens::preferences::{PreferencesEventHandling, PreferencesWidgetSession}, + screens::preferences::{ + AgentSkillOverlayEventHandling, PreferencesEventHandling, PreferencesWidgetSession, + }, + screens::report::ReportScreenSession, screens::run_modal::{RunModalEvent, RunModalSession}, screens::settings::{ SettingsScreenEvent, SettingsScreenGeometry, SettingsScreenSession, render_settings, }, theme::{ACCENT, BOX_DIM, BOX_MAROON, SELECT_BG, SELECT_FG, panel_block}, + viewport::{AlignmentSignature, VirtualScrollState}, }; /// Result of one stateful terminal event dispatch. @@ -83,22 +89,18 @@ pub enum EventHandling { /// One header shape that can own visible rows in the terminal layout. pub(crate) enum HeaderKind<'a> { - Help, - ConfirmRemove, - ConfirmDiscardChanges, - RunPresetName, - RunTokenMenu, - RunEnvironmentPicker, - RunFilePicker, - RunnerEditor(skit_ui::RunnerEditorMode), Library { query: &'a str, search: bool }, - Preferences, - Add, - Health, - Runners, Report(&'a str), } +#[derive(Clone, Debug, Eq, PartialEq)] +enum TopLevelClickTarget { + SearchInput, + Command(UiCommand), + RunFieldCommand(usize, RunFieldCommand), + Library(LibraryClickTarget), +} + /// Stateful terminal widget session. This state is not serialized into `skit-ui`. #[derive(Debug, Default)] pub struct TuiSession { @@ -112,6 +114,7 @@ pub struct TuiSession { path_suggestions: PathSuggestionSession, run_modal: RunModalSession, preferences: PreferencesWidgetSession, + report: ReportScreenSession, settings: SettingsScreenSession, settings_geometry: SettingsScreenGeometry, settings_prompt_overlay: Option<(PromptCandidatePickerSession, ChoicePickerGeometry)>, @@ -123,7 +126,8 @@ pub struct TuiSession { runner_editor: RunnerEditorSession, form: FormWidgetSession, footer: FooterSession, - clicks: ClickRegionRegistry, + clicks: HitMap, + top_level_click: ClickTracker, } #[derive(Debug)] @@ -149,12 +153,60 @@ struct VisiblePathSuggestion { suggestion: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PathSuggestionKey<'a> { + generation: u64, + field: usize, + value: &'a str, +} + +#[derive(Debug)] +struct ExpectedPathSuggestion { + generation: u64, + field: usize, + request: PathCompletionRequest, +} + +impl ExpectedPathSuggestion { + fn key(&self) -> PathSuggestionKey<'_> { + PathSuggestionKey { + generation: self.generation, + field: self.field, + value: &self.request.value, + } + } + + fn request_key(&self) -> (usize, &PathCompletionRequest) { + (self.field, &self.request) + } +} + +impl PathSuggestionResult { + fn key(&self) -> PathSuggestionKey<'_> { + PathSuggestionKey { + generation: self.generation, + field: self.field, + value: &self.value, + } + } +} + +impl VisiblePathSuggestion { + fn key(&self) -> PathSuggestionKey<'_> { + PathSuggestionKey { + generation: self.generation, + field: self.field, + value: &self.value, + } + } +} + #[derive(Debug, Default)] struct PathSuggestionSession { requests: Option>, results: Option>, generation: u64, - expected: Option<(u64, usize, PathCompletionRequest)>, + expected: Option, in_flight: bool, retry_pending: bool, visible: Option, @@ -188,7 +240,7 @@ impl PathSuggestionSession { let current_matches = self .expected .as_ref() - .is_some_and(|(_, target, expected)| *target == field && expected == &request); + .is_some_and(|expected| expected.request_key() == (field, &request)); if current_matches { return; } @@ -202,7 +254,11 @@ impl PathSuggestionSession { }; match self.requests.as_ref().map(|sender| sender.try_send(job)) { Some(Ok(())) => { - self.expected = Some((generation, field, request)); + self.expected = Some(ExpectedPathSuggestion { + generation, + field, + request, + }); self.in_flight = true; self.retry_pending = false; } @@ -221,43 +277,41 @@ impl PathSuggestionSession { return false; }; while let Ok(result) = results.try_recv() { - let is_current = self - .expected - .as_ref() - .is_some_and(|(generation, field, request)| { - *generation == result.generation - && *field == result.field - && request.value == result.value - }); - if !is_current { + let Some(expected) = self.expected.as_ref() else { + continue; + }; + if expected.key() != result.key() { continue; } self.visible = result.suggestion.and_then(|suggestion| { - (suggestion != result.value && suggestion.starts_with(&result.value)).then_some( - VisiblePathSuggestion { - generation: result.generation, - field: result.field, - value: result.value, - suggestion, - }, - ) + if suggestion == result.value || !suggestion.starts_with(&result.value) { + return None; + } + Some(VisiblePathSuggestion { + generation: result.generation, + field: result.field, + value: result.value, + suggestion, + }) }); self.in_flight = false; changed = true; } - changed + changed || self.retry_pending } fn visible(&self, field: usize, value: &str) -> Option<&str> { self.visible.as_ref().and_then(|visible| { - (visible.field == field - && visible.value == value - && self.expected.as_ref().is_some_and(|expected| { - expected.0 == visible.generation - && expected.1 == field - && expected.2.value == value - })) - .then_some(visible.suggestion.as_str()) + let expected = self.expected.as_ref()?; + let requested = PathSuggestionKey { + generation: visible.generation, + field, + value, + }; + if visible.key() != requested || expected.key() != requested { + return None; + } + Some(visible.suggestion.as_str()) }) } @@ -328,14 +382,15 @@ enum AddOverlayEvent { #[derive(Debug, Default)] struct SearchWidgetSession { input: LineInput, + editable: Option, } -#[derive(Clone, Debug)] -enum SessionHit { - SearchInput, - Target(HitTarget), +#[derive(Clone, Debug, Eq, PartialEq)] +enum RunClickTarget { + FocusField(usize), Checkbox(usize), Select(usize), + SelectOption { field: usize, value: String }, RadioOption { field: usize, value: String }, } @@ -369,21 +424,27 @@ struct RunWidgetSession { signature: Option, controls: Vec, focus: FocusManager, - scroll: ScrollableContentState, + scroll: VirtualScrollState, viewport: Rect, visible_height: usize, row_starts: Vec, row_heights: Vec, - select_areas: Vec, + editables: Vec>, + textarea_editables: Vec>, + textarea_viewports: Vec, + select_areas: Vec>, dropdown_regions: Vec>>, pending_ensure_focus: bool, + alignment: Option>, + hits: HitMap, + click: ClickTracker, } #[derive(Clone, Debug)] struct RunLayout { items: Vec, - field_starts: Vec, - field_heights: Vec, + control_starts: Vec, + control_heights: Vec, height: usize, } @@ -426,14 +487,19 @@ struct RunChip { struct FormWidgetSession { signature: Option>, controls: Vec, - clicks: ClickRegionRegistry, + clicks: HitMap, focus: FocusManager, - scroll: ScrollableContentState, + scroll: VirtualScrollState, viewport: Rect, visible_height: usize, row_starts: Vec, row_heights: Vec, + editables: Vec>, + textarea_editables: Vec>, + textarea_viewports: Vec, + click: ClickTracker, pending_ensure_focus: bool, + alignment: Option>, } #[derive(Debug)] @@ -503,23 +569,102 @@ impl TuiSession { state: &LibraryState, geometry: &ViewGeometry, ) -> EventHandling { + if matches!(event, Event::Resize(_, _)) { + self.top_level_click.cancel(); + self.cancel_owner_clicks(); + } + if let Event::Mouse(mouse) = &event + && matches!( + mouse.kind, + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight + ) + { + self.top_level_click.cancel(); + self.cancel_owner_clicks(); + } if is_ctrl_c(&event) { return self.handle_ctrl_c(); } + if let Some(handling) = self.handle_blocking_overlay_event(&event, state, geometry) { + return handling; + } if !crate::footer::is_suppressed(state) && let Event::Mouse(mouse) = &event && self.footer.handle_mouse(mouse) { return EventHandling::Consumed; } - if let Event::Mouse(mouse) = &event - && matches!(mouse.kind, MouseEventKind::Down(_)) + if state.modal().is_none() + && matches!(state.screen(), Screen::Library) + && let Event::Mouse(mouse) = &event && matches!( - self.clicks.handle_click(mouse.column, mouse.row), - Some(SessionHit::SearchInput) + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) { - return EventHandling::Action(Action::BeginSearch); + return match self.library.handle_wheel(mouse, geometry) { + LibraryPointerHandling::Action(action) => EventHandling::Action(action), + LibraryPointerHandling::Consumed => EventHandling::Consumed, + LibraryPointerHandling::Ignored => EventHandling::Ignored, + }; + } + if let Event::Mouse(mouse) = &event { + let mut target = self.clicks.topmost(mouse.column, mouse.row).cloned(); + if target.is_none() + && state.modal().is_none() + && matches!(state.screen(), Screen::Library) + { + target = self + .library + .click_target(mouse, geometry) + .map(TopLevelClickTarget::Library); + } + if is_primary_down(mouse) + && matches!(target, Some(TopLevelClickTarget::SearchInput)) + && let Some(editable) = self.search.editable + { + let _ = editable.place_cursor(&mut self.search.input, mouse.column, mouse.row); + } + if target.is_some() + && matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) + ) + { + self.cancel_owner_clicks(); + } + let dispatch = self.top_level_click.dispatch(mouse, target.as_ref()); + if let ClickDispatch::Captured(outcome) = dispatch { + return match outcome { + ClickOutcome::Armed => EventHandling::Consumed, + ClickOutcome::Activated(TopLevelClickTarget::SearchInput) => { + if state.input_mode() == InputMode::Browse { + EventHandling::Action(Action::BeginSearch) + } else { + EventHandling::Consumed + } + } + ClickOutcome::Activated(TopLevelClickTarget::Command(command)) => { + EventHandling::Action(command_action( + command, + state.command_context(), + geometry, + )) + } + ClickOutcome::Activated(TopLevelClickTarget::RunFieldCommand( + field, + command, + )) => EventHandling::Action(run_field_command_action(field, command)), + ClickOutcome::Activated(TopLevelClickTarget::Library(target)) => self + .library + .activate_click(target, state) + .map_or(EventHandling::Consumed, EventHandling::Action), + ClickOutcome::Ignored => EventHandling::Ignored, + }; + } } if state.modal().is_none() && matches!(state.screen(), Screen::Library) @@ -534,15 +679,6 @@ impl TuiSession { self.search.sync(state.query()); return self.handle_search_event(event, state, geometry); } - if let Some(ModalState::RunnerEditor { view, .. }) = state.modal() { - return match self.runner_editor.handle_event(event, view) { - RunnerEditorEventHandling::Action(action) => { - EventHandling::Action(Action::RunnerEditor(action)) - } - RunnerEditorEventHandling::Consumed => EventHandling::Consumed, - RunnerEditorEventHandling::Ignored => EventHandling::Ignored, - }; - } if matches!(state.modal(), Some(ModalState::Help)) && self.help.handle_event(&event) { return EventHandling::Consumed; } @@ -590,6 +726,9 @@ impl TuiSession { HealthEventHandling::Ignored => EventHandling::Ignored, }; } + if matches!(state.screen(), Screen::Report(_)) && self.report.handle_event(&event) { + return EventHandling::Consumed; + } if let Screen::Runners(view) = state.screen() { return match self.runners.handle_event(event, view) { RunnerManagerEventHandling::Action(action) => { @@ -603,22 +742,6 @@ impl TuiSession { return self.handle_add_event(event, state, view, geometry); } if let Screen::Settings(view) = state.screen() { - if let Some((session, geometry)) = self.settings_prompt_overlay.as_mut() { - return match session.handle_event(event, geometry) { - Some(PromptCandidatePickerEvent::Changed) => EventHandling::Consumed, - Some(PromptCandidatePickerEvent::Cancelled) => { - self.settings_prompt_overlay = None; - EventHandling::Consumed - } - Some(PromptCandidatePickerEvent::Accepted(names)) => { - self.settings_prompt_overlay = None; - EventHandling::Action(Action::Settings( - skit_ui::SettingsAction::SetPromptCandidates(names), - )) - } - None => EventHandling::Ignored, - }; - } return match self .settings .handle_event(event.clone(), view, &self.settings_geometry) @@ -673,7 +796,7 @@ impl TuiSession { self.handle_form_key(key, form) } Event::Mouse(mouse) => { - let handling = self.handle_form_mouse(mouse, geometry); + let handling = self.handle_form_mouse(mouse, form, geometry); if handling == EventHandling::Ignored { map_event(Event::Mouse(mouse), state, geometry) .map_or(EventHandling::Ignored, EventHandling::Action) @@ -690,6 +813,106 @@ impl TuiSession { map_event(event, state, geometry).map_or(EventHandling::Ignored, EventHandling::Action) } + fn handle_blocking_overlay_event( + &mut self, + event: &Event, + state: &LibraryState, + geometry: &ViewGeometry, + ) -> Option { + if let Some(ModalState::RunnerEditor { owner, view, .. }) = state.modal() { + self.top_level_click.cancel(); + match owner { + RunnerEditorOwner::Run { .. } => self.run.click.cancel(), + RunnerEditorOwner::Add => self.add.cancel_click(), + RunnerEditorOwner::Settings { .. } => self.settings.cancel_click(), + } + let pointer_lifecycle = matches!( + event, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_), + .. + }) + ); + return Some(match self.runner_editor.handle_event(event.clone(), view) { + RunnerEditorEventHandling::Action(action) => { + EventHandling::Action(Action::RunnerEditor(action)) + } + RunnerEditorEventHandling::Consumed => EventHandling::Consumed, + RunnerEditorEventHandling::Ignored if pointer_lifecycle => EventHandling::Consumed, + RunnerEditorEventHandling::Ignored => EventHandling::Ignored, + }); + } + if let Screen::Add(view) = state.screen() + && self.add_overlay.is_some() + { + self.top_level_click.cancel(); + self.add.cancel_click(); + return Some(self.handle_add_event(event.clone(), state, view, geometry)); + } + if matches!(state.screen(), Screen::Settings(_)) + && let Some((session, overlay_geometry)) = self.settings_prompt_overlay.as_mut() + { + self.top_level_click.cancel(); + self.settings.cancel_click(); + let blocked_input = matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_)); + return Some( + match session.handle_event(event.clone(), overlay_geometry) { + Some(PromptCandidatePickerEvent::Changed) => EventHandling::Consumed, + Some(PromptCandidatePickerEvent::Cancelled) => { + self.settings_prompt_overlay = None; + EventHandling::Consumed + } + Some(PromptCandidatePickerEvent::Accepted(names)) => { + self.settings_prompt_overlay = None; + EventHandling::Action(Action::Settings( + skit_ui::SettingsAction::SetPromptCandidates(names), + )) + } + None if blocked_input => EventHandling::Consumed, + None => EventHandling::Ignored, + }, + ); + } + if let Screen::Preferences(view) = state.screen() + && let Some(picker) = view.agent_skill_install() + { + self.top_level_click.cancel(); + self.preferences.cancel_underlay_click(); + return Some( + match self + .preferences + .handle_agent_skill_overlay_event(event.clone(), view, picker) + { + AgentSkillOverlayEventHandling::Action(action) => { + EventHandling::Action(Action::Preferences(action)) + } + AgentSkillOverlayEventHandling::Consumed => EventHandling::Consumed, + }, + ); + } + None + } + + fn cancel_owner_clicks(&mut self) { + self.runner_editor.cancel_click(); + self.run_modal.cancel_click(); + self.run.click.cancel(); + self.form.click.cancel(); + self.preferences.cancel_click(); + self.settings.cancel_click(); + if let Some((picker, _)) = self.settings_prompt_overlay.as_mut() { + picker.cancel_click(); + } + self.add.cancel_click(); + match self.add_overlay.as_mut() { + Some(AddOverlay::File { session, .. }) => session.cancel_click(), + Some(AddOverlay::Prompt { session, .. }) => session.cancel_click(), + None => {} + } + self.health.cancel_click(); + self.runners.cancel_click(); + } + fn open_settings_prompt_picker(&mut self, view: &skit_ui::SettingsView) { if view.prompt_picker_available() { self.settings_prompt_overlay = Some(( @@ -744,6 +967,9 @@ impl TuiSession { }; } if self.add_overlay.is_some() { + if matches!(event, Event::Mouse(_)) { + return EventHandling::Consumed; + } return map_event(event, state, geometry) .map_or(EventHandling::Ignored, EventHandling::Action); } @@ -807,8 +1033,17 @@ impl TuiSession { pub(crate) fn register_geometry(&mut self, geometry: &ViewGeometry) { for hit in &geometry.hits { - self.clicks - .register(hit.rect, SessionHit::Target(hit.action)); + match hit.action { + HitTarget::Command(command) => { + self.clicks + .register(hit.rect, TopLevelClickTarget::Command(command)); + } + HitTarget::RunFieldCommand { field, command } => self.clicks.register( + hit.rect, + TopLevelClickTarget::RunFieldCommand(field, command), + ), + HitTarget::FocusField(_) => {} + } } } @@ -818,8 +1053,10 @@ impl TuiSession { area: Rect, state: &LibraryState, locale: Locale, + decorated: bool, ) -> Vec { - self.footer.render(frame, area, state, locale) + self.footer + .render_with_decoration(frame, area, state, locale, decorated) } pub(crate) fn render_quit_toast(&mut self, frame: &mut Frame, locale: Locale) { @@ -858,53 +1095,18 @@ impl TuiSession { kind: HeaderKind<'_>, locale: Locale, ) { - let library_browse = matches!(&kind, HeaderKind::Library { search: false, .. }); let title = match kind { - HeaderKind::Help => text(locale, "Help").into_owned(), - HeaderKind::ConfirmRemove => text(locale, "Confirm removal").into_owned(), - HeaderKind::ConfirmDiscardChanges => { - text(locale, "Discard unsaved changes?").into_owned() - } - HeaderKind::RunPresetName => text(locale, "Save as preset").into_owned(), - HeaderKind::RunTokenMenu => text(locale, "Insert a run-time value").into_owned(), - HeaderKind::RunEnvironmentPicker => text(locale, "Environment variable").into_owned(), - HeaderKind::RunFilePicker => text(locale, "Insert a file or folder").into_owned(), - HeaderKind::RunnerEditor(mode) => match mode { - skit_ui::RunnerEditorMode::New => text(locale, "New agent (runner)").into_owned(), - skit_ui::RunnerEditorMode::Edit | skit_ui::RunnerEditorMode::Repair => { - text(locale, "Edit agent (runner)").into_owned() - } - }, - HeaderKind::Library { - query, - search: true, - } => { + HeaderKind::Library { query, search } => { self.search.sync(query); let label = text(locale, "Search"); - if area.height < 3 { - render_flat_search_input(frame, area, &self.search.input, &label); + self.search.editable = if area.height < 3 { + render_flat_search_input(frame, area, &self.search.input, search, &label) } else { - render_line_input(frame, area, &self.search.input, false, true, &label); - } - self.clicks.register(area, SessionHit::SearchInput); + render_line_input(frame, area, &self.search.input, false, search, &label) + }; + self.clicks.register(area, TopLevelClickTarget::SearchInput); return; } - HeaderKind::Library { - query, - search: false, - } => format!( - "{}: {}", - text(locale, "Library"), - if query.is_empty() { - text(locale, "all entries").into_owned() - } else { - query.to_owned() - } - ), - HeaderKind::Preferences => text(locale, "Preferences").into_owned(), - HeaderKind::Add => text(locale, "Add").into_owned(), - HeaderKind::Health => text(locale, "Health").into_owned(), - HeaderKind::Runners => text(locale, "Agents (prompt runners)").into_owned(), HeaderKind::Report(title) => text(locale, title).into_owned(), }; frame.render_widget( @@ -916,9 +1118,6 @@ impl TuiSession { ), area, ); - if library_browse { - self.clicks.register(area, SessionHit::SearchInput); - } } pub(crate) fn render_form( @@ -934,6 +1133,8 @@ impl TuiSession { let inner = block.inner(area); frame.render_widget(block, area); self.form.prepare_layout(form, inner); + self.form.editables = vec![None; self.form.controls.len()]; + self.form.textarea_editables = vec![None; self.form.controls.len()]; let mut hits = Vec::new(); for (index, field) in form.fields.iter().enumerate() { @@ -946,9 +1147,19 @@ impl TuiSession { state, secret, focused, - } => render_line_input_band(frame, clip, state, *secret, *focused, &label, None), + } => { + self.form.editables[index] = + render_line_input_band(frame, clip, state, *secret, *focused, &label, None); + } FormWidgetControl::TextArea { state, focused, .. } => { - render_textarea_band(frame, clip, state, *focused, &label); + self.form.textarea_editables[index] = render_textarea_band( + frame, + clip, + state, + &mut self.form.textarea_viewports[index], + *focused, + &label, + ); } } self.form.clicks.register(clip.area(), index); @@ -1003,6 +1214,7 @@ impl TuiSession { locale: Locale, ) -> ViewGeometry { self.run.sync(form); + self.run.hits.clear(); let block = panel_block( format!("{} {}", text(locale, "Run"), form.name()), BOX_MAROON, @@ -1023,7 +1235,9 @@ impl TuiSession { }; let layout = run_layout(form, locale, content.width); self.run.prepare_layout(&layout, form.focused(), content); - self.run.select_areas = vec![Rect::default(); self.run.controls.len()]; + self.run.editables = vec![None; self.run.controls.len()]; + self.run.textarea_editables = vec![None; self.run.controls.len()]; + self.run.select_areas = vec![None; self.run.controls.len()]; self.run.dropdown_regions = vec![Vec::new(); self.run.controls.len()]; let mut hits = Vec::new(); @@ -1050,23 +1264,36 @@ impl TuiSession { } self.render_run_chips(frame, visible, chips, &mut hits); } - RunRenderItem::Control(index) if usize::from(visible.height) == item.height => { - self.render_run_control(frame, visible, *index, locale, &mut hits); + RunRenderItem::Control(index) => { + let is_full = usize::from(visible.height) == item.height; + let is_textarea = matches!( + self.run.controls.get(*index), + Some(WidgetControl::TextArea { .. }) + ); + if is_full || is_textarea { + let clipped_top = + self.run.scroll.scroll_offset().saturating_sub(item.start); + self.render_run_control( + frame, + RowClip::new(item.height, clipped_top, visible), + *index, + locale, + &mut hits, + ); + } } - RunRenderItem::Control(_) | RunRenderItem::Spacer => {} + RunRenderItem::Spacer => {} } } - if layout.height > usize::from(content.height) { - let mut scrollbar = - ScrollbarState::new(layout.height.saturating_sub(usize::from(content.height))) - .position(self.run.scroll.scroll_offset()) - .viewport_content_length(usize::from(content.height)); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight).style(run_scrollbar_style()), - inner, - &mut scrollbar, - ); - } + let mut scrollbar = + ScrollbarState::new(layout.height.saturating_sub(usize::from(content.height))) + .position(self.run.scroll.scroll_offset()) + .viewport_content_length(usize::from(content.height)); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight).style(run_scrollbar_style()), + inner, + &mut scrollbar, + ); self.render_open_dropdowns(frame); ViewGeometry { @@ -1093,6 +1320,16 @@ impl TuiSession { } } + pub(crate) fn render_report( + &mut self, + frame: &mut Frame, + area: Rect, + report: &skit_ui::ReportView, + locale: Locale, + ) -> ViewGeometry { + self.report.render(frame, area, report, locale) + } + pub(crate) fn render_settings( &mut self, frame: &mut Frame, @@ -1252,11 +1489,18 @@ impl TuiSession { fn render_run_control( &mut self, frame: &mut Frame, - area: Rect, + clip: RowClip, index: usize, locale: Locale, hits: &mut Vec, ) { + let area = clip.area(); + let (Some(area_width), Some(_height)) = ( + std::num::NonZeroU16::new(area.width), + std::num::NonZeroU16::new(area.height), + ) else { + return; + }; let select_style = select_style(); match &mut self.run.controls[index] { WidgetControl::Input { @@ -1267,20 +1511,29 @@ impl TuiSession { let suggestion = (*focused) .then(|| self.path_suggestions.visible(index, state.value())) .flatten(); - render_line_input_with_suggestion( + self.run.editables[index] = render_line_input_with_suggestion( frame, area, state, *secret, *focused, "", suggestion, ); - self.clicks - .register(area, SessionHit::Target(HitTarget::FocusField(index))); + self.run + .hits + .register(area, RunClickTarget::FocusField(index)); hits.push(HitRegion { rect: area, action: HitTarget::FocusField(index), }); } WidgetControl::TextArea { state, focused, .. } => { - render_textarea(frame, area, state, *focused, ""); - self.clicks - .register(area, SessionHit::Target(HitTarget::FocusField(index))); + self.run.textarea_editables[index] = render_textarea_band( + frame, + clip, + state, + &mut self.run.textarea_viewports[index], + *focused, + "", + ); + self.run + .hits + .register(area, RunClickTarget::FocusField(index)); hits.push(HitRegion { rect: area, action: HitTarget::FocusField(index), @@ -1291,8 +1544,9 @@ impl TuiSession { let region = CheckBox::new(&shown, state) .style(checkbox_style()) .render_stateful(area, frame.buffer_mut()); - self.clicks - .register(region.area, SessionHit::Checkbox(index)); + self.run + .hits + .register(region.area, RunClickTarget::Checkbox(index)); hits.push(HitRegion { rect: region.area, action: HitTarget::FocusField(index), @@ -1310,8 +1564,10 @@ impl TuiSession { .placeholder(&placeholder) .style(select_style) .render_stateful(frame, area); - self.run.select_areas[index] = region.area; - self.clicks.register(region.area, SessionHit::Select(index)); + self.run.select_areas[index] = Some(region.area); + self.run + .hits + .register(region.area, RunClickTarget::Select(index)); hits.push(HitRegion { rect: region.area, action: HitTarget::FocusField(index), @@ -1323,35 +1579,34 @@ impl TuiSession { presentation: ChoicePresentation::Radio, buttons, } => { + let field_area = area; + self.run + .hits + .register(field_area, RunClickTarget::FocusField(index)); let mut x = area.x; let mut y = area.y; - if area.width > 0 { - for (option_label, button) in options.iter().zip(buttons.iter()) { - let width = u16::try_from(option_label.width().saturating_add(2)) - .unwrap_or(u16::MAX) - .min(area.width); - if x > area.x && x.saturating_add(width) > area.right() { - x = area.x; - y = y.saturating_add(1); - } - let option_area = Rect::new(x, y, width, 1); - let region = Button::new(option_label, button) - .variant(ButtonVariant::Toggle) - .style(radio_style()) - .render_stateful(option_area, frame.buffer_mut()); - self.clicks.register( - region.area, - SessionHit::RadioOption { - field: index, - value: option_label.clone(), - }, - ); - x = x.saturating_add(width).saturating_add(1); + for (option_label, button) in options.iter().zip(buttons.iter()) { + let width = u16::try_from(option_label.width().saturating_add(2)) + .unwrap_or(u16::MAX) + .min(area_width.get()); + if x.saturating_add(width) > area.right() { + x = area.x; + y = y.saturating_add(1); } + let option_area = Rect::new(x, y, width, 1); + let region = Button::new(option_label, button) + .variant(ButtonVariant::Toggle) + .style(radio_style()) + .render_stateful(option_area, frame.buffer_mut()); + self.run.hits.register( + region.area, + RunClickTarget::RadioOption { + field: index, + value: option_label.clone(), + }, + ); + x = x.saturating_add(width).saturating_add(1); } - let field_area = area; - self.clicks - .register(field_area, SessionHit::Target(HitTarget::FocusField(index))); hits.push(HitRegion { rect: field_area, action: HitTarget::FocusField(index), @@ -1370,20 +1625,19 @@ impl TuiSession { ) { for chip in chips { let width = chip.width.min(area.width.saturating_sub(chip.x)); - if width > 0 { - let chip_area = Rect::new(area.x.saturating_add(chip.x), area.y, width, 1); - let state = ButtonState::enabled(); - let region = Button::new(&chip.label, &state) - .variant(ButtonVariant::SingleLine) - .style(run_chip_style()) - .render_stateful(chip_area, frame.buffer_mut()); - self.clicks - .register(region.area, SessionHit::Target(chip.target)); - hits.push(HitRegion { - rect: region.area, - action: chip.target, - }); - } + let Some(width) = std::num::NonZeroU16::new(width) else { + continue; + }; + let chip_area = Rect::new(area.x.saturating_add(chip.x), area.y, width.get(), 1); + let state = ButtonState::enabled(); + let _region = Button::new(&chip.label, &state) + .variant(ButtonVariant::SingleLine) + .style(run_chip_style()) + .render_stateful(chip_area, frame.buffer_mut()); + hits.push(HitRegion { + rect: chip_area, + action: chip.target, + }); } } @@ -1400,9 +1654,13 @@ impl TuiSession { continue; }; if state.is_open { + let Some(anchor) = self.run.select_areas[index] else { + self.run.dropdown_regions[index].clear(); + continue; + }; let regions = Select::new(options, state) .style(select_style()) - .render_dropdown(frame, self.run.select_areas[index], screen); + .render_dropdown(frame, anchor, screen); self.run.dropdown_regions[index] = regions; } } @@ -1435,11 +1693,7 @@ impl TuiSession { (KeyCode::Tab, _) => return self.move_focus(true), (KeyCode::BackTab, _) => return self.move_focus(false), (KeyCode::PageUp | KeyCode::PageDown, _) => { - let _ = handle_scrollable_content_key( - &mut self.run.scroll, - &key, - self.run.visible_height, - ); + let _ = self.run.scroll.handle_key(&key, self.run.visible_height); return EventHandling::Consumed; } _ => {} @@ -1495,11 +1749,15 @@ impl TuiSession { .. } => { let before = textarea_text(state); + let before_cursor = state.cursor(); match edit_textarea(state, key, undo_group, redo_group) { TextAreaEventHandling::Ignored => return EventHandling::Ignored, TextAreaEventHandling::Consumed | TextAreaEventHandling::VerticalBoundary => {} } let after = textarea_text(state); + if state.cursor() != before_cursor { + self.run.pending_ensure_focus = true; + } if before == after { EventHandling::Consumed } else { @@ -1659,87 +1917,151 @@ impl TuiSession { &mut self, mouse: MouseEvent, form: &RunFormView, - geometry: &ViewGeometry, + _geometry: &ViewGeometry, ) -> EventHandling { if matches!( mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) && handle_scrollable_content_mouse( - &mut self.run.scroll, - &mouse, - self.run.viewport, - self.run.visible_height, - ) - .is_some() + ) { + for (index, control) in self.run.controls.iter_mut().enumerate() { + let WidgetControl::Choice { + state, + presentation: ChoicePresentation::Picker, + .. + } = control + else { + continue; + }; + if state.is_open + && self.run.dropdown_regions[index] + .iter() + .any(|region| region.contains(mouse.column, mouse.row)) + { + self.run.click.cancel(); + if mouse.kind == MouseEventKind::ScrollUp { + state.highlight_prev(); + } else { + state.highlight_next(); + } + state.ensure_visible(self.run.dropdown_regions[index].len().max(1)); + return EventHandling::Consumed; + } + } + } + if self + .run + .scroll + .handle_mouse(&mouse, self.run.viewport, self.run.visible_height) { return EventHandling::Consumed; } - if !matches!(mouse.kind, MouseEventKind::Down(_)) { + if !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) { return EventHandling::Ignored; } - for index in 0..self.run.controls.len() { - let WidgetControl::Choice { - state, - options, - presentation: ChoicePresentation::Picker, - .. - } = &mut self.run.controls[index] - else { - continue; - }; - if !state.is_open { - continue; + let dropdown_target = + self.run + .controls + .iter() + .enumerate() + .find_map(|(index, control)| { + let WidgetControl::Choice { + state, + options, + presentation: ChoicePresentation::Picker, + .. + } = control + else { + return None; + }; + if !state.is_open { + return None; + } + self.run.dropdown_regions[index] + .iter() + .rev() + .find(|region| region.contains(mouse.column, mouse.row)) + .and_then(|region| match region.data { + SelectAction::Select(option) => { + options.get(option).cloned().map(|value| { + RunClickTarget::SelectOption { + field: index, + value, + } + }) + } + SelectAction::Focus | SelectAction::Open | SelectAction::Close => None, + }) + }); + + let target = dropdown_target + .as_ref() + .or_else(|| self.run.hits.topmost(mouse.column, mouse.row)); + match self.run.click.update(&mouse, target) { + ClickOutcome::Armed => { + if let Some(RunClickTarget::FocusField(index)) = target { + self.place_run_cursor(*index, mouse.column, mouse.row); + } + EventHandling::Consumed } - if let Some(action) = handle_select_mouse( - &mouse, - state, - self.run.select_areas[index], - &self.run.dropdown_regions[index], - ) { - if let SelectAction::Select(option) = action { - return EventHandling::Action(Action::SelectFieldOption { - field: index, - value: options.get(option).cloned().unwrap_or_default(), - }); + ClickOutcome::Ignored => EventHandling::Ignored, + ClickOutcome::Activated(target) => match target { + RunClickTarget::FocusField(index) => { + self.place_run_cursor(index, mouse.column, mouse.row); + if form.focused() == index { + EventHandling::Consumed + } else { + EventHandling::Action(Action::FocusField(index)) + } } - if ClickRegion::new(self.run.select_areas[index], ()) - .contains(mouse.column, mouse.row) - { - return EventHandling::Consumed; + RunClickTarget::Checkbox(index) => { + EventHandling::Action(Action::ToggleField(index)) } - } - } - - match self.clicks.handle_click(mouse.column, mouse.row).cloned() { - None | Some(SessionHit::SearchInput) => { - let _ = geometry; - EventHandling::Ignored - } - Some(SessionHit::Target(HitTarget::Command(command))) => { - EventHandling::Action(command_action(command, geometry)) - } - Some(SessionHit::Target(HitTarget::RunFieldCommand { field, command })) => { - EventHandling::Action(run_field_command_action(field, command)) - } - Some(SessionHit::Target(HitTarget::FocusField(index))) => { - EventHandling::Action(Action::FocusField(index)) - } - Some(SessionHit::Checkbox(index)) => EventHandling::Action(Action::ToggleField(index)), - Some(SessionHit::Select(index)) => { - if let Some(WidgetControl::Choice { state, .. }) = self.run.controls.get_mut(index) - { - state.open(); + RunClickTarget::Select(index) => { + if let Some(WidgetControl::Choice { state, .. }) = + self.run.controls.get_mut(index) + { + state.open(); + } + if form.focused() == index { + EventHandling::Consumed + } else { + EventHandling::Action(Action::FocusField(index)) + } } - if form.focused() == index { - EventHandling::Consumed - } else { - EventHandling::Action(Action::FocusField(index)) + RunClickTarget::SelectOption { field, value } => { + EventHandling::Action(Action::SelectFieldOption { field, value }) } - } - Some(SessionHit::RadioOption { field, value }) => { - EventHandling::Action(Action::SelectFieldOption { field, value }) - } + RunClickTarget::RadioOption { field, value } => { + EventHandling::Action(Action::SelectFieldOption { field, value }) + } + }, + } + } + + fn place_run_cursor(&mut self, index: usize, column: u16, row: u16) { + if let Some((editable, WidgetControl::Input { state, .. })) = self + .run + .editables + .get(index) + .copied() + .flatten() + .zip(self.run.controls.get_mut(index)) + { + let _ = editable.place_cursor(state, column, row); + } + if let Some((editable, WidgetControl::TextArea { state, .. })) = self + .run + .textarea_editables + .get(index) + .copied() + .flatten() + .zip(self.run.controls.get_mut(index)) + { + let _ = editable.place_cursor(state, column, row); } } @@ -1764,11 +2086,7 @@ impl TuiSession { KeyCode::Enter | KeyCode::Down => self.move_form_focus(true), KeyCode::Up => self.move_form_focus(false), KeyCode::PageUp | KeyCode::PageDown => { - let _ = handle_scrollable_content_key( - &mut self.form.scroll, - &key, - self.form.visible_height, - ); + let _ = self.form.scroll.handle_key(&key, self.form.visible_height); EventHandling::Consumed } _ => EventHandling::Ignored, @@ -1830,11 +2148,7 @@ impl TuiSession { .. } => { let selected = state.is_selecting(); - let before_cursor = state.cursor(); let _ = state.insert_str(value); - if state.cursor() != before_cursor { - self.form.pending_ensure_focus = true; - } *undo_group = 1 + usize::from(selected && !value.is_empty()); *redo_group = 0; EventHandling::Action(Action::SetFieldValue { @@ -1845,33 +2159,61 @@ impl TuiSession { } } - fn handle_form_mouse(&mut self, mouse: MouseEvent, geometry: &ViewGeometry) -> EventHandling { - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) && handle_scrollable_content_mouse( - &mut self.form.scroll, - &mouse, - self.form.viewport, - self.form.visible_height, - ) - .is_some() + fn handle_form_mouse( + &mut self, + mouse: MouseEvent, + form: &FormView, + geometry: &ViewGeometry, + ) -> EventHandling { + if self + .form + .scroll + .handle_mouse(&mouse, self.form.viewport, self.form.visible_height) { return EventHandling::Consumed; } - if !matches!(mouse.kind, MouseEventKind::Down(_)) { - return EventHandling::Ignored; + let target = self.form.clicks.topmost(mouse.column, mouse.row).copied(); + match self.form.click.update(&mouse, target.as_ref()) { + ClickOutcome::Armed => { + let index = target.expect("an armed form click has one target"); + self.place_form_cursor(index, mouse.column, mouse.row); + EventHandling::Consumed + } + ClickOutcome::Activated(index) => { + if form.focused == index { + EventHandling::Consumed + } else { + EventHandling::Action(Action::FocusField(index)) + } + } + ClickOutcome::Ignored => { + let _ = geometry; + EventHandling::Ignored + } } - let Some(index) = self + } + + fn place_form_cursor(&mut self, index: usize, column: u16, row: u16) { + if let Some((editable, FormWidgetControl::Input { state, .. })) = self .form - .clicks - .handle_click(mouse.column, mouse.row) + .editables + .get(index) .copied() - else { - let _ = geometry; - return EventHandling::Ignored; - }; - EventHandling::Action(Action::FocusField(index)) + .flatten() + .zip(self.form.controls.get_mut(index)) + { + let _ = editable.place_cursor(state, column, row); + } + if let Some((editable, FormWidgetControl::TextArea { state, .. })) = self + .form + .textarea_editables + .get(index) + .copied() + .flatten() + .zip(self.form.controls.get_mut(index)) + { + let _ = editable.place_cursor(state, column, row); + } } fn move_focus(&mut self, forward: bool) -> EventHandling { @@ -1939,9 +2281,10 @@ impl RunWidgetSession { }; if self.signature.as_ref() != Some(&signature) { self.controls = form.fields().iter().map(widget_control).collect(); + self.textarea_viewports = vec![TextAreaViewport::default(); self.controls.len()]; self.focus.clear(); self.focus.register_all(0..self.controls.len()); - self.scroll = ScrollableContentState::empty(); + self.scroll = VirtualScrollState::default(); self.signature = Some(signature); self.pending_ensure_focus = true; } else { @@ -1961,24 +2304,42 @@ impl RunWidgetSession { fn prepare_layout(&mut self, layout: &RunLayout, focused: usize, viewport: Rect) { self.viewport = viewport; self.visible_height = usize::from(viewport.height); - self.row_starts.clone_from(&layout.field_starts); - self.row_heights.clone_from(&layout.field_heights); - self.scroll.set_lines(vec![String::new(); layout.height]); + self.row_starts.clone_from(&layout.control_starts); + self.row_heights.clone_from(&layout.control_heights); + self.scroll.set_line_count(layout.height); let maximum = layout.height.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum { - self.scroll.set_scroll_offset(maximum); - } - if self.pending_ensure_focus - && let (Some(start), Some(height)) = - (self.row_starts.get(focused), self.row_heights.get(focused)) + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum)); + let target = self + .row_starts + .get(focused) + .copied() + .zip(self.row_heights.get(focused).copied()); + let reflow = target.map_or((layout.height, 0, 0), |(start, height)| { + (layout.height, start, height) + }); + let alignment_changed = + AlignmentSignature::update(&mut self.alignment, focused, viewport, reflow); + if (self.pending_ensure_focus || alignment_changed) + && let Some((start, height)) = target { let offset = self.scroll.scroll_offset(); - let end = start.saturating_add(*height); - if *start < offset { - self.scroll.set_scroll_offset(*start); - } else if end > offset.saturating_add(self.visible_height) { - self.scroll - .set_scroll_offset(end.saturating_sub(self.visible_height)); + let end = start.saturating_add(height); + if height.cmp(&self.visible_height) == std::cmp::Ordering::Greater + && let Some(WidgetControl::TextArea { state, .. }) = self.controls.get(focused) + { + let last_content_row = height.saturating_sub(3); + let cursor = + editor_cursor_virtual_row(start, state.cursor().0.min(last_content_row)); + let next = start.max(cursor.saturating_add(1).saturating_sub(self.visible_height)); + self.scroll.set_scroll_offset(next); + } else { + match start.cmp(&offset) { + std::cmp::Ordering::Less => self.scroll.set_scroll_offset(start), + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => self + .scroll + .set_scroll_offset(offset.max(end.saturating_sub(self.visible_height))), + } } self.pending_ensure_focus = false; } @@ -2029,9 +2390,10 @@ impl FormWidgetSession { let signature = form.fields.iter().map(form_field_signature).collect(); if self.signature.as_ref() != Some(&signature) { self.controls = form.fields.iter().map(form_widget_control).collect(); + self.textarea_viewports = vec![TextAreaViewport::default(); self.controls.len()]; self.focus.clear(); self.focus.register_all(0..self.controls.len()); - self.scroll = ScrollableContentState::empty(); + self.scroll = VirtualScrollState::default(); self.signature = Some(signature); self.pending_ensure_focus = true; } else { @@ -2060,34 +2422,36 @@ impl FormWidgetSession { self.row_heights.push(height); next = next.saturating_add(height); } - self.scroll.set_lines(vec![String::new(); next]); + self.scroll.set_line_count(next); let maximum = next.saturating_sub(self.visible_height); - if self.scroll.scroll_offset() > maximum { - self.scroll.set_scroll_offset(maximum); - } - if self.pending_ensure_focus - && let (Some(start), Some(height)) = ( - self.row_starts.get(form.focused), - self.row_heights.get(form.focused), - ) + self.scroll + .set_scroll_offset(self.scroll.scroll_offset().min(maximum)); + let target = self + .row_starts + .get(form.focused) + .copied() + .zip(self.row_heights.get(form.focused).copied()); + let reflow = target.map_or((next, 0, 0), |(start, height)| (next, start, height)); + let alignment_changed = + AlignmentSignature::update(&mut self.alignment, form.focused, viewport, reflow); + if (self.pending_ensure_focus || alignment_changed) + && let Some((start, height)) = target { let offset = self.scroll.scroll_offset(); - let end = start.saturating_add(*height); - if *height > self.visible_height + let end = start.saturating_add(height); + if height.cmp(&self.visible_height) == std::cmp::Ordering::Greater && let FormWidgetControl::TextArea { state, .. } = &self.controls[form.focused] { - let cursor = editor_cursor_virtual_row(*start, state.cursor().0); - let next = if cursor - *start < self.visible_height { - *start - } else { - cursor + 1 - self.visible_height - }; + let cursor = editor_cursor_virtual_row(start, state.cursor().0); + let next = start.max(cursor.saturating_add(1).saturating_sub(self.visible_height)); self.scroll.set_scroll_offset(next); - } else if *start < offset { - self.scroll.set_scroll_offset(*start); - } else if end > offset.saturating_add(self.visible_height) { - self.scroll - .set_scroll_offset(end.saturating_sub(self.visible_height)); + } else { + match start.cmp(&offset) { + std::cmp::Ordering::Less => self.scroll.set_scroll_offset(start), + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => self + .scroll + .set_scroll_offset(offset.max(end.saturating_sub(self.visible_height))), + } } self.pending_ensure_focus = false; } @@ -2106,8 +2470,7 @@ impl FormWidgetSession { let clipped_end = end.min(viewport_end); Some(RowClip::new( height, - u16::try_from(clipped_start.saturating_sub(start)) - .expect("the form band offset fits Ratatui geometry"), + clipped_start.saturating_sub(start), Rect::new( self.viewport.x, self.viewport.y.saturating_add( @@ -2331,7 +2694,7 @@ pub(crate) fn edit_textarea( if (key.code == KeyCode::Char('z') && key .modifiers - .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)) + .contains(KeyModifiers::CONTROL.union(KeyModifiers::SHIFT))) || (key.code == KeyCode::Char('y') && key.modifiers == KeyModifiers::CONTROL) { let count = (*redo_group).max(1); @@ -2406,6 +2769,247 @@ fn select_radio( }) } +#[cfg(test)] +mod editor_contract_tests { + use super::*; + + fn edit( + state: &mut RichTextArea<'static>, + undo_group: &mut usize, + redo_group: &mut usize, + code: KeyCode, + modifiers: KeyModifiers, + ) -> TextAreaEventHandling { + edit_textarea( + state, + KeyEvent::new(code, modifiers), + undo_group, + redo_group, + ) + } + + fn undone_edit() -> (RichTextArea<'static>, usize, usize) { + let mut state = new_textarea("ab"); + let (mut undo_group, mut redo_group) = (0, 0); + assert_eq!( + edit( + &mut state, + &mut undo_group, + &mut redo_group, + KeyCode::Char('c'), + KeyModifiers::NONE, + ), + TextAreaEventHandling::Consumed + ); + assert_eq!(textarea_text(&state), "abc"); + assert_eq!( + edit( + &mut state, + &mut undo_group, + &mut redo_group, + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ), + TextAreaEventHandling::Consumed + ); + assert_eq!(textarea_text(&state), "ab"); + (state, undo_group, redo_group) + } + + #[test] + fn textarea_undo_and_redo_require_their_exact_key_chords() { + for (code, modifiers) in [ + ( + KeyCode::Char('z'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ( + KeyCode::Char('z'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT, + ), + (KeyCode::Char('y'), KeyModifiers::CONTROL), + ] { + let (mut state, mut undo_group, mut redo_group) = undone_edit(); + assert_eq!( + edit( + &mut state, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ), + TextAreaEventHandling::Consumed + ); + assert_eq!(textarea_text(&state), "abc", "redo chord {code:?}"); + } + + for (code, modifiers) in [ + (KeyCode::Char('q'), KeyModifiers::CONTROL), + ( + KeyCode::Char('q'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ( + KeyCode::Char('y'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ] { + let (mut state, mut undo_group, mut redo_group) = undone_edit(); + let _ = edit( + &mut state, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ); + assert_eq!(textarea_text(&state), "ab", "false redo chord {code:?}"); + } + + for (code, modifiers, expected) in [ + (KeyCode::Char('z'), KeyModifiers::NONE, "abz"), + (KeyCode::Char('z'), KeyModifiers::SHIFT, "abz"), + (KeyCode::Char('y'), KeyModifiers::NONE, "aby"), + ] { + let (mut state, mut undo_group, mut redo_group) = undone_edit(); + let _ = edit( + &mut state, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ); + assert_eq!(textarea_text(&state), expected, "plain character {code:?}"); + } + } + + #[test] + fn textarea_undo_groups_selection_replacement_and_deletion_exactly() { + let mut replaced = new_textarea("ab"); + let (mut undo_group, mut redo_group) = (0, 0); + for (code, modifiers) in [ + (KeyCode::End, KeyModifiers::NONE), + (KeyCode::Left, KeyModifiers::SHIFT), + (KeyCode::Char('界'), KeyModifiers::NONE), + (KeyCode::Left, KeyModifiers::NONE), + ] { + let _ = edit( + &mut replaced, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ); + } + assert_eq!(textarea_text(&replaced), "a界"); + let _ = edit( + &mut replaced, + &mut undo_group, + &mut redo_group, + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ); + assert_eq!(textarea_text(&replaced), "ab"); + + let mut deleted = new_textarea("ab"); + let (mut undo_group, mut redo_group) = (0, 0); + for (code, modifiers) in [ + (KeyCode::End, KeyModifiers::NONE), + (KeyCode::Char('Q'), KeyModifiers::NONE), + (KeyCode::Left, KeyModifiers::SHIFT), + (KeyCode::Backspace, KeyModifiers::NONE), + ] { + let _ = edit( + &mut deleted, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ); + } + assert_eq!(textarea_text(&deleted), "ab"); + let _ = edit( + &mut deleted, + &mut undo_group, + &mut redo_group, + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ); + assert_eq!(textarea_text(&deleted), "abQ"); + } + + #[test] + fn textarea_reports_only_unmodified_vertical_edges_as_boundaries() { + for (code, modifiers, expected) in [ + ( + KeyCode::F(2), + KeyModifiers::NONE, + TextAreaEventHandling::Ignored, + ), + ( + KeyCode::Up, + KeyModifiers::NONE, + TextAreaEventHandling::VerticalBoundary, + ), + ( + KeyCode::Up, + KeyModifiers::SHIFT, + TextAreaEventHandling::Consumed, + ), + ( + KeyCode::Char('x'), + KeyModifiers::NONE, + TextAreaEventHandling::Consumed, + ), + ] { + let mut state = new_textarea("ab"); + let (mut undo_group, mut redo_group) = (0, 0); + assert_eq!( + edit( + &mut state, + &mut undo_group, + &mut redo_group, + code, + modifiers, + ), + expected, + "event classification for {code:?}" + ); + } + + let mut selected = new_textarea("a\nb"); + selected.start_selection(); + let (mut undo_group, mut redo_group) = (0, 0); + assert_eq!( + edit( + &mut selected, + &mut undo_group, + &mut redo_group, + KeyCode::Up, + KeyModifiers::NONE, + ), + TextAreaEventHandling::Consumed + ); + } + + #[test] + fn radio_navigation_stops_at_both_ends_and_moves_one_option() { + let options = ["zero".to_owned(), "one".to_owned(), "two".to_owned()]; + for (selected, forward, expected) in + [(1, true, 2), (2, true, 2), (1, false, 0), (0, false, 0)] + { + let mut state = SelectState::with_selected(options.len(), selected); + assert_eq!( + select_radio(4, &mut state, &options, forward), + EventHandling::Action(Action::SelectFieldOption { + field: 4, + value: options[expected].clone(), + }) + ); + assert_eq!(state.selected_index, Some(expected)); + } + } +} + fn run_layout(form: &RunFormView, locale: Locale, width: u16) -> RunLayout { let mut items = Vec::new(); let mut start = 0_usize; @@ -2436,7 +3040,7 @@ fn run_layout(form: &RunFormView, locale: Locale, width: u16) -> RunLayout { width, ); } - if form.has_parameters() && form.preset_names().len() == 0 { + if form.has_parameters() && form.preset_names().next().is_none() { // Version 0.4 keeps the row labelled even when it is empty: `Preset:` then the hint, on // one line (`src/skit/tui_form.py:741-757`). Without the label the sentence floats free // and never says which control it is about. @@ -2463,10 +3067,9 @@ fn run_layout(form: &RunFormView, locale: Locale, width: u16) -> RunLayout { ); } - let mut field_starts = Vec::with_capacity(form.fields().len()); - let mut field_heights = Vec::with_capacity(form.fields().len()); + let mut control_starts = Vec::with_capacity(form.fields().len()); + let mut control_heights = Vec::with_capacity(form.fields().len()); for (index, field) in form.fields().iter().enumerate() { - let field_start = start; let label = run_field_label(field, locale); let label_width = u16::try_from(label.width()).unwrap_or(u16::MAX); // The chips continue the label's own row, two columns after it, exactly as version 0.4 @@ -2491,25 +3094,27 @@ fn run_layout(form: &RunFormView, locale: Locale, width: u16) -> RunLayout { 1, ); } + let control_start = start; + let control_height = run_control_height(field, width); push_run_item( &mut items, &mut start, RunRenderItem::Control(index), - run_control_height(field, width), + control_height, ); + control_starts.push(control_start); + control_heights.push(control_height); for note in run_field_notes(field, locale) { push_run_copy(&mut items, &mut start, note, width); } if index + 1 < form.fields().len() { push_run_item(&mut items, &mut start, RunRenderItem::Spacer, 1); } - field_starts.push(field_start); - field_heights.push(start.saturating_sub(field_start)); } RunLayout { items, - field_starts, - field_heights, + control_starts, + control_heights, height: start, } } @@ -2725,7 +3330,7 @@ fn run_field_chips( format!("📁 {}", text(locale, "browse")), HitTarget::RunFieldCommand { field: index, - command: UiCommand::BrowsePath, + command: RunFieldCommand::BrowsePath, }, )); } @@ -2734,7 +3339,7 @@ fn run_field_chips( format!("▾ {}", text(locale, "insert")), HitTarget::RunFieldCommand { field: index, - command: UiCommand::InsertValue, + command: RunFieldCommand::InsertValue, }, )); } @@ -2743,7 +3348,7 @@ fn run_field_chips( format!("↺ {}", text(locale, "default")), HitTarget::RunFieldCommand { field: index, - command: UiCommand::ResetDefault, + command: RunFieldCommand::ResetDefault, }, )); } @@ -2766,7 +3371,7 @@ fn run_chip_rows( let wanted = u16::try_from(label.width().saturating_add(2)) .unwrap_or(u16::MAX) .min(available); - if x > 0 && x.saturating_add(wanted) > available && !(row.is_empty() && rows.is_empty()) { + if x != 0 && x.saturating_add(wanted) > available { rows.push(row); row = Vec::new(); x = 0; @@ -2806,7 +3411,7 @@ fn packed_row_count(labels: &[String], width: u16) -> usize { let wanted = u16::try_from(label.width().saturating_add(2)) .unwrap_or(u16::MAX) .min(available); - if x > 0 && x.saturating_add(wanted) > available { + if x.saturating_add(wanted) > available { rows = rows.saturating_add(1); x = 0; } @@ -2848,7 +3453,7 @@ pub(crate) fn render_line_input( secret: bool, focused: bool, label: &str, -) { +) -> Option { render_line_input_band( frame, RowClip::new(3, 0, area), @@ -2857,7 +3462,20 @@ pub(crate) fn render_line_input( focused, label, None, - ); + ) +} + +pub(crate) fn render_search_line_input( + frame: &mut Frame, + area: Rect, + state: &LineInput, + label: &str, +) -> Option { + if area.height < 3 { + render_flat_search_input(frame, area, state, true, label) + } else { + render_line_input(frame, area, state, false, true, label) + } } fn render_line_input_with_suggestion( @@ -2868,7 +3486,7 @@ fn render_line_input_with_suggestion( focused: bool, label: &str, suggestion: Option<&str>, -) { +) -> Option { render_line_input_band( frame, RowClip::new(3, 0, area), @@ -2877,7 +3495,7 @@ fn render_line_input_with_suggestion( focused, label, suggestion, - ); + ) } /// Draw only the visible band of one bordered line input. @@ -2889,13 +3507,13 @@ pub(crate) fn render_line_input_band( focused: bool, label: &str, suggestion: Option<&str>, -) { +) -> Option { let border = if focused { ACCENT } else { BOX_DIM }; let width = usize::from(clip.area().width.saturating_sub(2).max(1)); - let scroll = state.visual_scroll(width); + let scroll = display_scroll(state.value(), state.cursor(), width, secret); let display = if secret { Line::from(Span::styled( - "•".repeat(state.value().chars().count()), + secret_display(state.value()), Style::default().fg(Color::White), )) } else { @@ -2919,11 +3537,7 @@ pub(crate) fn render_line_input_band( && let Some(row) = clip.row(1) && row.width > 2 { - let visual_cursor = if secret { - state.cursor() - } else { - state.visual_cursor() - }; + let visual_cursor = display_cursor(state.value(), state.cursor(), secret); let x = visual_cursor .saturating_sub(scroll) .min(width.saturating_sub(1)); @@ -2934,17 +3548,38 @@ pub(crate) fn render_line_input_band( row.y, )); } + clip.row(1).and_then(|row| { + (row.width > 2).then(|| { + EditableGeometry::new( + Rect::new( + row.x.saturating_add(1), + row.y, + row.width.saturating_sub(2), + 1, + ), + scroll, + secret, + ) + }) + }) } -fn render_flat_search_input(frame: &mut Frame, area: Rect, state: &LineInput, label: &str) { +fn render_flat_search_input( + frame: &mut Frame, + area: Rect, + state: &LineInput, + focused: bool, + label: &str, +) -> Option { let content = Rect::new( area.x.saturating_add(1), area.y, area.width.saturating_sub(2), area.height.min(1), ); - let width = usize::from(content.width.max(1)); - let scroll = state.visual_scroll(width); + let width = usize::from(std::num::NonZeroU16::new(content.width)?.get()); + let _height = std::num::NonZeroU16::new(content.height)?; + let scroll = display_scroll(state.value(), state.cursor(), width, false); let shown = if state.value().is_empty() { label.to_owned() } else { @@ -2956,9 +3591,8 @@ fn render_flat_search_input(frame: &mut Frame, area: Rect, state: &LineInput, la .scroll((0, u16::try_from(scroll).unwrap_or(u16::MAX))), content, ); - if content.width > 0 && content.height > 0 { - let x = state - .visual_cursor() + if focused { + let x = display_cursor(state.value(), state.cursor(), false) .saturating_sub(scroll) .min(width.saturating_sub(1)); frame.set_cursor_position(( @@ -2968,16 +3602,7 @@ fn render_flat_search_input(frame: &mut Frame, area: Rect, state: &LineInput, la content.y, )); } -} - -pub(crate) fn render_textarea( - frame: &mut Frame, - area: Rect, - state: &mut RichTextArea<'static>, - focused: bool, - label: &str, -) { - render_textarea_band(frame, RowClip::new(6, 0, area), state, focused, label); + Some(EditableGeometry::new(content, scroll, false)) } /// Draw only the visible band of one bordered text area. @@ -2985,9 +3610,10 @@ pub(crate) fn render_textarea_band( frame: &mut Frame, clip: RowClip, state: &mut RichTextArea<'static>, + viewport: &mut TextAreaViewport, focused: bool, label: &str, -) { +) -> Option { state.set_block( Block::default() .borders(Borders::ALL) @@ -3002,11 +3628,44 @@ pub(crate) fn render_textarea_band( Style::default().fg(Color::White) }); state.set_selection_style(Style::default().fg(SELECT_FG).bg(SELECT_BG)); - if clip.is_full() { - (&*state).render(clip.area(), frame.buffer_mut()); - } else { - clip.paint_bounded_stateful_editor(frame.buffer_mut(), &*state); - } + let content_width = usize::from(clip.area().width.saturating_sub(2)); + let content_height = clip.full_height().saturating_sub(2); + viewport.align(state, content_width, content_height); + let content_start = clip.top().max(1); + let content_end = clip + .top() + .saturating_add(usize::from(clip.area().height)) + .min(clip.full_height().saturating_sub(1)); + let first_row = viewport + .top_row() + .saturating_add(content_start.saturating_sub(1)); + let lines = bounded_textarea_lines( + state, + first_row, + content_end.saturating_sub(content_start), + viewport.left_cell(), + content_width, + Style::default().fg(SELECT_FG).bg(SELECT_BG), + ); + clip.paint_bordered_lines( + frame.buffer_mut(), + lines, + Line::from(label.to_owned()), + Style::default().fg(if focused { ACCENT } else { BOX_DIM }), + ); + let first = clip.row(content_start)?; + (content_start < content_end && first.width > 2).then(|| { + viewport.geometry( + Rect::new( + first.x.saturating_add(1), + first.y, + first.width.saturating_sub(2), + u16::try_from(content_end.saturating_sub(content_start)).unwrap_or(u16::MAX), + ), + content_start.saturating_sub(1), + usize::from(state.tab_length()), + ) + }) } pub(crate) fn checkbox_style() -> CheckBoxStyle { @@ -3035,6 +3694,8 @@ pub(crate) fn radio_style() -> ButtonStyle { #[cfg(test)] mod textarea_band_tests { + use std::collections::BTreeMap; + use ratatui_core::{backend::TestBackend, buffer::Buffer, style::Color, terminal::Terminal}; use super::*; @@ -3049,52 +3710,1364 @@ mod textarea_band_tests { } #[test] - fn a_top_clipped_form_textarea_keeps_later_rows_cursor_and_selection_styles() { - let form = FormView { - purpose: skit_ui::FormPurpose::Settings, - title: "Editor".to_owned(), - title_arguments: Vec::new(), - translate_title: false, - selector: None, - fields: vec![FormField::multiline("body", "Body", "first\nmiddle\nlast")], - focused: 0, - submit_label: "Save".to_owned(), - }; - let mut library = LibraryState::default(); - library.update(Action::Present(Screen::Form(form.clone()))); + fn an_open_run_select_without_a_visible_anchor_has_no_origin_dropdown() { + let mut select = SelectState::new(2); + select.open(); let mut session = TuiSession::default(); - let mut terminal = Terminal::new(TestBackend::new(24, 4)).unwrap(); - let mut geometry = ViewGeometry::default(); + session.run.controls.push(WidgetControl::Choice { + state: select, + options: vec!["first".to_owned(), "second".to_owned()], + presentation: ChoicePresentation::Picker, + buttons: Vec::new(), + }); + session.run.select_areas.push(None); + session.run.dropdown_regions.push(Vec::new()); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); terminal - .draw(|frame| { - geometry = session.render_form(frame, frame.area(), &form, Locale::En); - }) + .draw(|frame| session.render_open_dropdowns(frame)) .unwrap(); - for code in [KeyCode::Up, KeyCode::Up, KeyCode::Home] { + assert!( + session.run.dropdown_regions[0].is_empty(), + "a clipped select rendered a ghost dropdown at the default anchor" + ); + } + + #[test] + fn open_run_select_wheel_is_contained_and_reaches_the_last_option() { + let options = (0..20) + .map(|index| format!("option-{index:02}")) + .collect::>(); + let form = RunFormView::from_declarations( + "demo", + "Demo", + &[], + &BTreeMap::new(), + &options, + &options[0], + &BTreeMap::new(), + "", + ); + let mut session = TuiSession::default(); + session.run.sync(&form); + let index = 0; + assert_eq!( + session.handle_open_select_key( + index, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &form, + ), + Some(EventHandling::Consumed) + ); + assert_eq!( + session.handle_open_select_key( + index, + &KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE), + &form, + ), + None, + "an open picker must leave unrelated keys to the form" + ); + session.run.dropdown_regions = vec![Vec::new(); session.run.controls.len()]; + session.run.dropdown_regions[index].push(ClickRegion::new( + Rect::new(4, 4, 20, 1), + SelectAction::Focus, + )); + session.run.dropdown_regions[index].extend((0..3).map(|option| { + ClickRegion::new( + Rect::new(5, 5 + u16::try_from(option).unwrap(), 20, 1), + SelectAction::Select(option), + ) + })); + assert_eq!( + session.handle_run_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 4, + row: 4, + modifiers: KeyModifiers::NONE, + }, + &form, + &ViewGeometry::default(), + ), + EventHandling::Ignored, + "a non-option dropdown region must not arm an option" + ); + assert_eq!( + session.handle_run_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + &form, + &ViewGeometry::default(), + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_run_mouse( + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + &form, + &ViewGeometry::default(), + ), + EventHandling::Action(Action::SelectFieldOption { + field: index, + value: "option-00".to_owned(), + }) + ); + for _ in 0..30 { assert_eq!( - session.handle_event( - Event::Key(KeyEvent::new(code, KeyModifiers::NONE)), - &library, - &geometry, + session.handle_run_mouse( + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + &form, + &ViewGeometry::default(), ), EventHandling::Consumed ); } - terminal - .draw(|frame| { - geometry = session.render_form(frame, frame.area(), &form, Locale::En); - }) - .unwrap(); - assert!( - rendered(terminal.backend().buffer()).contains("first"), - "the real cursor move did not bring the first row into view" - ); - for code in [KeyCode::Down, KeyCode::Down, KeyCode::End] { + for kind in [MouseEventKind::ScrollUp, MouseEventKind::ScrollDown] { assert_eq!( - session.handle_event( - Event::Key(KeyEvent::new(code, KeyModifiers::SHIFT)), - &library, - &geometry, + session.handle_run_mouse( + MouseEvent { + kind, + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + &form, + &ViewGeometry::default(), + ), + EventHandling::Consumed + ); + } + assert_eq!( + session.handle_open_select_key( + index, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &form, + ), + Some(EventHandling::Action(Action::SelectFieldOption { + field: index, + value: "option-19".to_owned(), + })) + ); + } + + #[test] + fn search_mouse_fallback_ignores_pointer_motion() { + let mut state = LibraryState::default(); + state.update(Action::BeginSearch); + let mut session = TuiSession::default(); + + assert_eq!( + session.handle_search_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + &state, + &ViewGeometry::default(), + ), + EventHandling::Ignored + ); + } + + #[test] + fn search_and_bordered_inputs_publish_only_real_content_cells() { + let input = LineInput::new("界abc".to_owned()); + + let mut compact = Terminal::new(TestBackend::new(8, 2)).unwrap(); + let mut compact_geometry = None; + compact + .draw(|frame| { + compact_geometry = render_search_line_input(frame, frame.area(), &input, "Search"); + }) + .unwrap(); + let compact_text = rendered(compact.backend().buffer()); + assert!(compact_geometry.is_some()); + assert!( + !compact_text.contains('╭'), + "a two-row search drew a border" + ); + + let mut bordered = Terminal::new(TestBackend::new(8, 3)).unwrap(); + let mut bordered_geometry = None; + bordered + .draw(|frame| { + bordered_geometry = render_search_line_input(frame, frame.area(), &input, "Search"); + }) + .unwrap(); + assert!(bordered_geometry.is_some()); + assert!( + rendered(bordered.backend().buffer()).contains('┌'), + "an exact three-row search lost its border" + ); + assert_eq!( + bordered.backend().cursor_position().y, + 1, + "the exact-width Unicode input lost its content cursor" + ); + + let mut two_columns = Terminal::new(TestBackend::new(2, 3)).unwrap(); + let mut two_column_geometry = None; + two_columns + .draw(|frame| { + two_column_geometry = render_line_input( + frame, + frame.area(), + &LineInput::new(String::new()), + false, + true, + "Value", + ); + }) + .unwrap(); + assert!(two_column_geometry.is_none()); + assert_ne!( + two_columns.backend().cursor_position(), + ratatui_core::layout::Position::new(1, 1), + "a border-only row fabricated a content cursor" + ); + + let mut three_columns = Terminal::new(TestBackend::new(3, 3)).unwrap(); + let mut three_column_geometry = None; + three_columns + .draw(|frame| { + three_column_geometry = render_line_input( + frame, + frame.area(), + &LineInput::new(String::new()), + false, + true, + "Value", + ); + }) + .unwrap(); + assert!( + three_column_geometry.is_some(), + "one content cell lost geometry" + ); + assert_eq!( + three_columns.backend().cursor_position(), + ratatui_core::layout::Position::new(1, 1) + ); + + for (width, height, focused, expected_geometry) in [ + (2, 1, true, false), + (3, 0, true, false), + (3, 1, false, true), + (3, 1, true, true), + ] { + let backend_height = height.max(1); + let mut terminal = Terminal::new(TestBackend::new(width, backend_height)).unwrap(); + let mut geometry = None; + terminal + .draw(|frame| { + geometry = render_flat_search_input( + frame, + Rect::new(0, 0, width, height), + &input, + focused, + "Search", + ); + }) + .unwrap(); + assert_eq!( + geometry.is_some(), + expected_geometry, + "flat search geometry at {width}x{height}, focused={focused}" + ); + if focused && !expected_geometry { + assert_ne!( + terminal.backend().cursor_position(), + ratatui_core::layout::Position::new(1, 0), + "a flat input without content fabricated a cursor" + ); + } + } + } + + #[test] + fn flat_search_paints_and_places_its_caret_in_exact_nonzero_content() { + for (area, value, label, painted, expected_cursor) in [ + ( + Rect::new(2, 1, 3, 1), + "", + "Q", + vec![(3, "Q")], + ratatui_core::layout::Position::new(3, 1), + ), + ( + Rect::new(2, 1, 5, 1), + "ab", + "Search", + vec![(3, "a"), (4, "b")], + ratatui_core::layout::Position::new(5, 1), + ), + ] { + let mut terminal = Terminal::new(TestBackend::new(10, 3)).unwrap(); + let mut geometry = None; + terminal + .draw(|frame| { + geometry = render_flat_search_input( + frame, + area, + &LineInput::new(value.to_owned()), + true, + label, + ); + }) + .unwrap(); + + assert!(geometry.is_some(), "nonzero content lost edit geometry"); + for (column, symbol) in painted { + assert_eq!( + terminal.backend().buffer()[(column, area.y)].symbol(), + symbol, + "the flat input did not paint its final visible cells" + ); + } + assert_eq!( + terminal.backend().cursor_position(), + expected_cursor, + "the focused flat input did not place its terminal caret" + ); + } + } + + #[test] + fn clipped_textarea_geometry_excludes_borders_and_zero_width_content() { + fn draw_band(top: u16, width: u16, height: u16) -> (bool, String) { + let backend_height = height.max(1); + let mut terminal = Terminal::new(TestBackend::new(width, backend_height)).unwrap(); + let mut state = new_textarea("zero\none\ntwo\nthree"); + let mut viewport = TextAreaViewport::default(); + let mut has_geometry = false; + terminal + .draw(|frame| { + has_geometry = render_textarea_band( + frame, + RowClip::new(6, usize::from(top), Rect::new(0, 0, width, height)), + &mut state, + &mut viewport, + true, + "Body", + ) + .is_some(); + }) + .unwrap(); + (has_geometry, rendered(terminal.backend().buffer())) + } + + assert!(!draw_band(0, 8, 1).0, "a top border became editable"); + assert!(draw_band(1, 8, 1).0, "one content row lost geometry"); + assert!( + !draw_band(1, 2, 1).0, + "a border-only textarea width became editable" + ); + assert!(!draw_band(5, 8, 1).0, "a bottom border became editable"); + let (has_geometry, rendered) = draw_band(4, 8, 2); + assert!(has_geometry, "the last content row lost geometry"); + assert!( + rendered.contains("three"), + "the bottom-clipped editor did not paint its last content row: {rendered:?}" + ); + } + + #[test] + fn bounded_textarea_render_keeps_complete_unicode_graphemes_and_cursor_style() { + for (value, expected) in [("e\u{301}x", "e\u{301}"), ("👨‍👩‍👧x", "👨‍👩‍👧")] + { + for (width, horizontal_tail) in [(12, false), (5, true)] { + let value = if horizontal_tail { + format!("abcd{value}") + } else { + value.to_owned() + }; + let mut state = new_textarea(&value); + if !horizontal_tail { + state.move_cursor(CursorMove::Head); + } + let mut viewport = TextAreaViewport::default(); + let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap(); + terminal + .draw(|frame| { + let _ = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + let content_x = 1; + let cell = &terminal.backend().buffer()[(content_x, 1)]; + assert_eq!( + cell.symbol(), + expected, + "the final buffer split {expected:?} at width {width}" + ); + if !horizontal_tail { + assert_eq!(cell.fg, Color::Black); + assert_eq!(cell.bg, ACCENT, "the cursor did not style one grapheme"); + } + } + } + } + + #[test] + fn one_cell_textarea_keeps_a_caret_for_a_cursor_inside_one_grapheme() { + for value in ["e\u{301}x", "👨‍👩‍👧x"] { + let mut state = new_textarea(value); + state.move_cursor(CursorMove::Head); + state.move_cursor(CursorMove::Forward); + assert_eq!( + state.cursor().1, + 1, + "the fixture cursor must be inside {value:?}" + ); + let mut viewport = TextAreaViewport::default(); + let mut terminal = Terminal::new(TestBackend::new(3, 3)).unwrap(); + + terminal + .draw(|frame| { + let _ = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + + let caret = &terminal.backend().buffer()[(1, 1)]; + assert_eq!(caret.fg, Color::Black, "the caret vanished for {value:?}"); + assert_eq!(caret.bg, ACCENT, "the caret vanished for {value:?}"); + assert_eq!( + textarea_text(&state), + value, + "render changed the model value" + ); + } + + let mut state = new_textarea("a\tX"); + state.move_cursor(CursorMove::Head); + state.move_cursor(CursorMove::Forward); + state.move_cursor(CursorMove::Forward); + assert_eq!(state.cursor().1, 2, "the cursor must follow the tab"); + let mut viewport = TextAreaViewport::default(); + let mut terminal = Terminal::new(TestBackend::new(3, 3)).unwrap(); + terminal + .draw(|frame| { + let _ = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + let caret = &terminal.backend().buffer()[(1, 1)]; + assert_eq!(caret.symbol(), "X"); + assert_eq!((caret.fg, caret.bg), (Color::Black, ACCENT)); + } + + #[test] + fn rendered_textarea_clicks_use_tab_cells_and_rows_above_u16() { + for column in [0, 1, 2] { + let mut state = new_textarea("\tX"); + state.move_cursor(CursorMove::Head); + let mut viewport = TextAreaViewport::default(); + let mut geometry = None; + let mut terminal = Terminal::new(TestBackend::new(10, 3)).unwrap(); + terminal + .draw(|frame| { + geometry = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + let geometry = geometry.expect("the tab row is editable"); + assert!(geometry.place_cursor(&mut state, 1 + column, 1)); + state.insert_char('Z'); + assert_eq!(textarea_text(&state), "Z\tX", "tab cell {column}"); + } + + let mut lines = vec![String::new(); usize::from(u16::MAX) + 2]; + lines[usize::from(u16::MAX) + 1] = "abcdef".to_owned(); + let mut state = RichTextArea::new(lines); + state.move_cursor(CursorMove::Bottom); + state.move_cursor(CursorMove::Head); + let mut viewport = TextAreaViewport::default(); + let mut geometry = None; + let mut terminal = Terminal::new(TestBackend::new(10, 3)).unwrap(); + terminal + .draw(|frame| { + geometry = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + let geometry = geometry.expect("the virtual tail row is editable"); + assert!(geometry.place_cursor(&mut state, 4, 1)); + state.insert_char('X'); + assert_eq!(state.lines().last().unwrap(), "abcXdef"); + + let mut state = new_textarea("a"); + state.move_cursor(CursorMove::Head); + let mut viewport = TextAreaViewport::default(); + let mut geometry = None; + let mut terminal = Terminal::new(TestBackend::new(10, 3)).unwrap(); + terminal + .draw(|frame| { + geometry = render_textarea_band( + frame, + RowClip::new(3, 0, frame.area()), + &mut state, + &mut viewport, + true, + "Body", + ); + }) + .unwrap(); + let geometry = geometry.expect("the short row is editable"); + assert!(geometry.place_cursor(&mut state, 7, 1)); + state.insert_char('X'); + assert_eq!(textarea_text(&state), "aX"); + } + + #[test] + fn run_checkbox_style_is_visible_on_the_rendered_control() { + for (checked, focused, symbol, color) in [ + (false, false, "☐", Color::White), + (false, true, "☐", ACCENT), + (true, false, "☑", Color::Green), + (true, true, "☑", ACCENT), + ] { + let state = CheckBoxState { + checked, + focused, + ..CheckBoxState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(12, 1)).unwrap(); + terminal + .draw(|frame| { + let _ = CheckBox::new("value", &state) + .style(checkbox_style()) + .render_stateful(frame.area(), frame.buffer_mut()); + }) + .unwrap(); + let cell = terminal + .backend() + .buffer() + .content() + .iter() + .find(|cell| cell.symbol() == symbol) + .expect("checkbox symbol"); + assert_eq!(cell.fg, color, "checked={checked}, focused={focused}"); + } + } + + #[test] + fn an_oversized_focused_run_group_does_not_align_to_its_note_tail() { + let mut run = RunWidgetSession { + pending_ensure_focus: true, + ..RunWidgetSession::default() + }; + let layout = RunLayout { + items: Vec::new(), + control_starts: vec![1], + control_heights: vec![3], + height: 12, + }; + run.prepare_layout(&layout, 0, Rect::new(0, 0, 24, 4)); + assert!( + run.scroll.scroll_offset() < 8, + "the focused control was hidden to show the tail of its oversized note group" + ); + } + + fn positioned_run_layout(start: usize, height: usize, total: usize) -> RunLayout { + RunLayout { + items: Vec::new(), + control_starts: vec![start], + control_heights: vec![height], + height: total, + } + } + + fn run_session_at(offset: usize) -> RunWidgetSession { + let mut run = RunWidgetSession { + pending_ensure_focus: true, + ..RunWidgetSession::default() + }; + run.scroll.set_lines(vec![String::new(); 12]); + run.scroll.set_scroll_offset(offset); + run + } + + #[test] + fn run_focus_alignment_changes_only_for_a_control_outside_the_viewport() { + let viewport = Rect::new(5, 7, 20, 4); + + let mut exact = run_session_at(1); + exact.prepare_layout(&positioned_run_layout(1, 4, 12), 0, viewport); + assert_eq!( + exact.scroll.scroll_offset(), + 1, + "a control on both viewport boundaries changed the user offset" + ); + + let mut above = run_session_at(2); + above.prepare_layout(&positioned_run_layout(1, 2, 12), 0, viewport); + assert_eq!(above.scroll.scroll_offset(), 1); + + let mut below = run_session_at(1); + below.prepare_layout(&positioned_run_layout(2, 4, 12), 0, viewport); + assert_eq!( + below.scroll.scroll_offset(), + 2, + "one hidden row did not move the focused control into view" + ); + + below.scroll.set_scroll_offset(5); + below.prepare_layout(&positioned_run_layout(2, 4, 12), 0, viewport); + assert_eq!( + below.scroll.scroll_offset(), + 5, + "an unchanged layout reset a later user scroll" + ); + + let mut clamped = run_session_at(7); + clamped.prepare_layout(&positioned_run_layout(0, 1, 8), 1, viewport); + assert_eq!( + clamped.scroll.scroll_offset(), + 4, + "a shorter layout retained an offset after its last full viewport" + ); + } + + #[test] + fn long_run_textarea_cursor_stays_inside_its_fixed_outer_control() { + let value = (0..30) + .map(|index| format!("line-{index}")) + .collect::>() + .join("\n"); + let mut run = run_session_at(0); + run.controls.push(WidgetControl::TextArea { + state: Box::new(new_textarea(&value)), + focused: true, + undo_group: 0, + redo_group: 0, + }); + let layout = positioned_run_layout(2, 6, 12); + run.prepare_layout(&layout, 0, Rect::new(0, 0, 24, 5)); + assert_eq!( + run.scroll.scroll_offset(), + 2, + "the textarea's internal cursor escaped the outer layout extent" + ); + assert!(run.scroll.scroll_offset() <= layout.height.saturating_sub(run.visible_height)); + } + + #[test] + fn run_visible_rect_rejects_each_disjoint_side_and_clips_each_overlap() { + let mut run = run_session_at(3); + run.viewport = Rect::new(5, 7, 20, 4); + run.visible_height = 4; + + assert_eq!(run.visible_rect(0, 3), None, "an item above became a hit"); + assert_eq!(run.visible_rect(7, 2), None, "an item below became a hit"); + assert_eq!(run.visible_rect(2, 3), Some(Rect::new(5, 7, 20, 2))); + assert_eq!(run.visible_rect(5, 3), Some(Rect::new(5, 9, 20, 2))); + } + + fn generic_form(fields: Vec, focused: usize) -> FormView { + FormView { + purpose: skit_ui::FormPurpose::Settings, + title: "Form".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields, + focused, + submit_label: "Save".to_owned(), + } + } + + #[test] + fn generic_form_alignment_and_clipping_use_exact_viewport_boundaries() { + let fields = (0..4) + .map(|index| FormField::text(format!("field-{index}"), "Field", "value")) + .collect::>(); + let viewport = Rect::new(4, 6, 18, 3); + let form = generic_form(fields, 1); + + let mut exact = FormWidgetSession { + pending_ensure_focus: true, + ..FormWidgetSession::default() + }; + exact.sync(&form); + exact.scroll.set_lines(vec![String::new(); 12]); + exact.scroll.set_scroll_offset(3); + exact.prepare_layout(&form, viewport); + assert_eq!(exact.scroll.scroll_offset(), 3); + + let mut above = FormWidgetSession { + pending_ensure_focus: true, + ..FormWidgetSession::default() + }; + above.sync(&form); + above.scroll.set_lines(vec![String::new(); 12]); + above.scroll.set_scroll_offset(4); + above.prepare_layout(&form, viewport); + assert_eq!(above.scroll.scroll_offset(), 3); + + let mut below = FormWidgetSession { + pending_ensure_focus: true, + ..FormWidgetSession::default() + }; + below.sync(&form); + below.scroll.set_lines(vec![String::new(); 12]); + below.scroll.set_scroll_offset(2); + below.prepare_layout(&form, viewport); + assert_eq!(below.scroll.scroll_offset(), 3); + + exact.scroll.set_scroll_offset(7); + exact.prepare_layout(&form, viewport); + assert_eq!( + exact.scroll.scroll_offset(), + 7, + "an unchanged form reflow reset a user scroll" + ); + + let mut no_target = form.clone(); + no_target.focused = usize::MAX; + let mut clamped = FormWidgetSession::default(); + clamped.sync(&no_target); + clamped.scroll.set_lines(vec![String::new(); 12]); + clamped.scroll.set_scroll_offset(11); + clamped.prepare_layout(&no_target, viewport); + assert_eq!( + clamped.scroll.scroll_offset(), + 9, + "a shorter form retained an offset after its last full viewport" + ); + + exact.row_starts = vec![0, 2, 3, 5, 6]; + exact.row_heights = vec![3, 3, 3, 3, 2]; + exact.scroll.set_scroll_offset(3); + assert!(exact.visible_band(0).is_none(), "an above band survived"); + assert!(exact.visible_band(4).is_none(), "a below band survived"); + let clipped_top = exact.visible_band(1).expect("top-clipped band"); + assert_eq!(clipped_top.area(), Rect::new(4, 6, 18, 2)); + let top = exact.visible_band(2).expect("exact visible band"); + assert_eq!(top.area(), Rect::new(4, 6, 18, 3)); + let clipped_bottom = exact.visible_band(3).expect("bottom-clipped band"); + assert_eq!(clipped_bottom.area(), Rect::new(4, 8, 18, 1)); + } + + #[test] + fn oversized_form_textarea_aligns_its_cursor_row_without_hiding_its_start() { + let form = generic_form( + vec![ + FormField::text("prefix", "Prefix", "value"), + FormField::multiline("body", "Body", "zero\none\ntwo\nthree\nfour"), + ], + 1, + ); + let viewport = Rect::new(0, 0, 20, 4); + let mut session = TuiSession::default(); + session.form.sync(&form); + session.form.prepare_layout(&form, viewport); + assert_eq!( + session.form.scroll.scroll_offset(), + 5, + "the last textarea row was not aligned inside the viewport" + ); + + for _ in 0..10 { + assert_eq!( + session.handle_form_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &form,), + EventHandling::Consumed, + "the textarea did not accept an upward cursor command" + ); + } + session.form.prepare_layout(&form, viewport); + assert_eq!( + session.form.scroll.scroll_offset(), + 3, + "the first textarea row was hidden by absolute-row arithmetic" + ); + } + + #[test] + fn run_textarea_outer_alignment_distinguishes_cursor_viewport_boundaries() { + let value = (0..6) + .map(|index| format!("line-{index}")) + .collect::>() + .join("\n"); + let layout = positioned_run_layout(5, 6, 15); + let viewport = Rect::new(0, 0, 24, 3); + + for (cursor_row, expected_offset) in [(0, 5), (2, 6), (3, 7)] { + let mut state = new_textarea(&value); + state.move_cursor(CursorMove::Jump(cursor_row, 0)); + let mut run = run_session_at(0); + run.controls.push(WidgetControl::TextArea { + state: Box::new(state), + focused: true, + undo_group: 0, + redo_group: 0, + }); + + run.prepare_layout(&layout, 0, viewport); + + assert_eq!( + run.scroll.scroll_offset(), + expected_offset, + "cursor row {cursor_row} did not keep the outer textarea caret visible" + ); + } + } + + #[test] + fn form_textarea_alignment_distinguishes_cursor_viewport_boundaries() { + let value = (0..6) + .map(|index| format!("line-{index}")) + .collect::>() + .join("\n"); + let form = generic_form( + vec![ + FormField::text("prefix", "Prefix", "value"), + FormField::multiline("body", "Body", &value), + ], + 1, + ); + let viewport = Rect::new(0, 0, 24, 3); + + for (cursor_row, expected_offset) in [(0, 3), (2, 4), (3, 5)] { + let mut session = TuiSession::default(); + session.form.sync(&form); + for _ in 0..10 { + assert_eq!( + session.handle_form_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), &form,), + EventHandling::Consumed + ); + } + for _ in 0..cursor_row { + assert_eq!( + session + .handle_form_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &form,), + EventHandling::Consumed + ); + } + + session.form.prepare_layout(&form, viewport); + + assert_eq!( + session.form.scroll.scroll_offset(), + expected_offset, + "cursor row {cursor_row} did not keep the form textarea caret visible" + ); + } + + let mut unchanged = FormWidgetSession::default(); + unchanged.sync(&form); + unchanged.prepare_layout(&form, viewport); + unchanged.scroll.set_scroll_offset(7); + unchanged.prepare_layout(&form, viewport); + assert_eq!( + unchanged.scroll.scroll_offset(), + 7, + "an unchanged textarea layout reset a later user scroll" + ); + } + + #[test] + fn generic_form_control_shape_requires_multiline_and_nonsecret_text() { + let plain = FormField::text("plain", "Plain", "value"); + let multiline = FormField::multiline("body", "Body", "first\nsecond"); + let mut secret_multiline = multiline.clone(); + secret_multiline.secret = true; + + assert!(matches!( + form_widget_control(&plain), + FormWidgetControl::Input { secret: false, .. } + )); + assert!(matches!( + form_widget_control(&multiline), + FormWidgetControl::TextArea { .. } + )); + assert!(matches!( + form_widget_control(&secret_multiline), + FormWidgetControl::Input { secret: true, .. } + )); + } + + #[test] + fn generic_form_controls_sync_external_values_without_resetting_equal_values() { + let mut input_form = generic_form(vec![FormField::text("name", "Name", "abcdef")], 0); + let mut input_session = TuiSession::default(); + input_session.form.sync(&input_form); + assert_eq!( + input_session.handle_form_key( + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + &input_form, + ), + EventHandling::Consumed + ); + input_session.form.sync(&input_form); + assert_eq!( + input_session.handle_form_key( + KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), + &input_form, + ), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "abcdeXf".to_owned(), + }), + "an equal external value reset the line-input cursor" + ); + input_form.fields[0].value = "changed".to_owned(); + input_session.form.sync(&input_form); + assert_eq!( + input_session.handle_form_key( + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), + &input_form, + ), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "changed!".to_owned(), + }), + "an external line-input value did not replace the stale editor value" + ); + + let mut textarea_form = generic_form( + vec![FormField::multiline("body", "Body", "first\nsecond")], + 0, + ); + let mut textarea_session = TuiSession::default(); + textarea_session.form.sync(&textarea_form); + assert_eq!( + textarea_session.handle_form_key( + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + &textarea_form, + ), + EventHandling::Consumed + ); + textarea_session.form.sync(&textarea_form); + assert_eq!( + textarea_session.handle_form_key( + KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), + &textarea_form, + ), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "first\nseconXd".to_owned(), + }), + "an equal external value reset the textarea cursor" + ); + textarea_form.fields[0].value = "new\nvalue".to_owned(); + textarea_session.form.sync(&textarea_form); + assert_eq!( + textarea_session.handle_form_key( + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), + &textarea_form, + ), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "new\nvalue!".to_owned(), + }), + "an external textarea value did not replace the stale editor value" + ); + } + + fn text_control(value: &str, multiline: bool, secret: bool) -> FormControl { + FormControl::Text(skit_ui::TextControl { + value: value.to_owned(), + multiline, + secret, + ..skit_ui::TextControl::default() + }) + } + + fn choice_control(options: &[&str], selected: &str) -> FormControl { + FormControl::Choice(skit_ui::ChoiceControl { + options: options.iter().map(|value| (*value).to_owned()).collect(), + selected: selected.to_owned(), + presentation: ChoicePresentation::Radio, + }) + } + + fn run_field_with_control(key: &str, control: FormControl) -> RunField { + RunField { + key: key.to_owned(), + label: key.to_owned(), + help: String::new(), + role: RunFieldRole::Parameter { + name: key.to_owned(), + }, + parameter_type: ParameterType::Str, + multiple: false, + binding: skit_domain::parameters::ParameterBinding::None, + delivery: skit_domain::parameters::ParameterDelivery::Flag, + control, + required: false, + default: None, + degraded: false, + input_binding: false, + env_source: String::new(), + validation_error: None, + feedback: Default::default(), + } + } + + fn rendered_run_control(control: WidgetControl) -> Vec<(String, Color)> { + let mut session = TuiSession::default(); + session.run.controls.push(control); + session.run.editables.push(None); + session.run.textarea_editables.push(None); + session + .run + .textarea_viewports + .push(TextAreaViewport::default()); + session.run.select_areas.push(None); + session.run.dropdown_regions.push(Vec::new()); + let mut terminal = Terminal::new(TestBackend::new(24, 3)).unwrap(); + terminal + .draw(|frame| { + session.render_run_control( + frame, + RowClip::new(1, 0, Rect::new(0, 0, 24, 1)), + 0, + Locale::En, + &mut Vec::new(), + ); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| (cell.symbol().to_owned(), cell.fg)) + .collect() + } + + fn run_form_for_event_tests() -> RunFormView { + RunFormView::from_declarations( + "demo", + "Demo", + &[], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ) + } + + #[test] + fn run_control_shape_and_choice_selection_follow_the_typed_model() { + let multiline = run_field_with_control("body", text_control("a\nb", true, false)); + let secret_multiline = run_field_with_control("secret", text_control("a\nb", true, true)); + assert!(matches!( + widget_control(&multiline), + WidgetControl::TextArea { .. } + )); + assert!(matches!( + widget_control(&secret_multiline), + WidgetControl::Input { secret: true, .. } + )); + + let selected = rendered_run_control(widget_control(&run_field_with_control( + "choice", + choice_control(&["a", "b"], "b"), + ))); + assert_eq!( + selected + .iter() + .find(|(symbol, _)| symbol == "a") + .expect("first radio option") + .1, + Color::White + ); + assert_eq!( + selected + .iter() + .find(|(symbol, _)| symbol == "b") + .expect("selected radio option") + .1, + SELECT_FG + ); + + let missing = rendered_run_control(widget_control(&run_field_with_control( + "choice", + choice_control(&["a", "b"], "missing"), + ))); + for option in ["a", "b"] { + assert_eq!( + missing + .iter() + .find(|(symbol, _)| symbol == option) + .expect("unselected radio option") + .1, + Color::White, + "a missing model selection toggled {option}" + ); + } + } + + #[test] + fn run_controls_sync_every_external_value_and_preserve_equal_text_cursors() { + let form = run_form_for_event_tests(); + let mut input_session = TuiSession::default(); + input_session + .run + .controls + .push(widget_control(&run_field_with_control( + "input", + text_control("abcdef", false, false), + ))); + assert_eq!( + input_session.handle_run_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &form,), + EventHandling::Consumed + ); + input_session.run.controls[0].sync_value(&text_control("abcdef", false, false)); + assert_eq!( + input_session + .handle_run_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), &form,), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "abcdeXf".to_owned(), + }), + "an equal run value reset the line-input cursor" + ); + input_session.run.controls[0].sync_value(&text_control("changed", false, false)); + assert_eq!( + input_session + .handle_run_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), &form,), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "changed!".to_owned(), + }), + "an external run value did not replace stale line-input text" + ); + + let mut textarea_session = TuiSession::default(); + textarea_session + .run + .controls + .push(widget_control(&run_field_with_control( + "body", + text_control("first\nsecond", true, false), + ))); + assert_eq!( + textarea_session + .handle_run_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &form,), + EventHandling::Consumed + ); + textarea_session.run.controls[0].sync_value(&text_control("first\nsecond", true, false)); + assert_eq!( + textarea_session + .handle_run_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), &form,), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "first\nseconXd".to_owned(), + }), + "an equal run value reset the textarea cursor" + ); + textarea_session.run.controls[0].sync_value(&text_control("new\nvalue", true, false)); + assert_eq!( + textarea_session + .handle_run_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), &form,), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: "new\nvalue!".to_owned(), + }), + "an external run value did not replace stale textarea text" + ); + + let mut checkbox = WidgetControl::Checkbox(CheckBoxState::new(false)); + checkbox.sync_value(&FormControl::Checkbox { checked: true }); + let checked = rendered_run_control(checkbox); + assert!( + checked.iter().any(|(symbol, _)| symbol == "☑"), + "the external boolean value did not reach the rendered checkbox" + ); + + let mut choice = widget_control(&run_field_with_control( + "choice", + choice_control(&["a", "b"], "a"), + )); + choice.sync_value(&choice_control(&["a", "b"], "b")); + let selected = rendered_run_control(choice); + assert_eq!( + selected + .iter() + .find(|(symbol, _)| symbol == "b") + .expect("externally selected radio option") + .1, + SELECT_FG + ); + assert_eq!( + selected + .iter() + .find(|(symbol, _)| symbol == "a") + .expect("externally unselected radio option") + .1, + Color::White + ); + } + + #[test] + fn run_choice_focus_marks_only_the_active_option_and_closes_on_blur() { + let missing = run_field_with_control("choice", choice_control(&["a", "b"], "missing")); + let mut focused = widget_control(&missing); + focused.set_focused(true); + let focused = rendered_run_control(focused); + assert_eq!( + focused + .iter() + .find(|(symbol, _)| symbol == "a") + .expect("focused fallback radio option") + .1, + SELECT_FG + ); + assert_eq!( + focused + .iter() + .find(|(symbol, _)| symbol == "b") + .expect("unfocused radio option") + .1, + Color::White + ); + + let mut unfocused = widget_control(&missing); + unfocused.set_focused(false); + let unfocused = rendered_run_control(unfocused); + for option in ["a", "b"] { + assert_eq!( + unfocused + .iter() + .find(|(symbol, _)| symbol == option) + .expect("blurred radio option") + .1, + Color::White, + "blur left focus on {option}" + ); + } + + let picker = FormControl::Choice(skit_ui::ChoiceControl { + options: vec!["a".to_owned(), "b".to_owned()], + selected: "b".to_owned(), + presentation: ChoicePresentation::Picker, + }); + let mut session = TuiSession::default(); + session + .run + .controls + .push(widget_control(&run_field_with_control("choice", picker))); + session.run.controls[0].set_focused(true); + let form = run_form_for_event_tests(); + assert_eq!( + session.handle_open_select_key( + 0, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &form, + ), + Some(EventHandling::Consumed), + "the focused picker did not open" + ); + session.run.controls[0].set_focused(false); + assert_eq!( + session.handle_open_select_key( + 0, + &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + &form, + ), + None, + "a blurred picker stayed open" + ); + } + + #[test] + fn a_top_clipped_form_textarea_keeps_later_rows_cursor_and_selection_styles() { + let form = FormView { + purpose: skit_ui::FormPurpose::Settings, + title: "Editor".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![FormField::multiline("body", "Body", "first\nmiddle\nlast")], + focused: 0, + submit_label: "Save".to_owned(), + }; + let mut library = LibraryState::default(); + library.update(Action::Present(Screen::Form(form.clone()))); + let mut session = TuiSession::default(); + let mut terminal = Terminal::new(TestBackend::new(24, 4)).unwrap(); + let mut geometry = ViewGeometry::default(); + terminal + .draw(|frame| { + geometry = session.render_form(frame, frame.area(), &form, Locale::En); + }) + .unwrap(); + for code in [KeyCode::Up, KeyCode::Up, KeyCode::Home] { + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)), + &library, + &geometry, + ), + EventHandling::Consumed + ); + } + terminal + .draw(|frame| { + geometry = session.render_form(frame, frame.area(), &form, Locale::En); + }) + .unwrap(); + assert!( + rendered(terminal.backend().buffer()).contains("first"), + "the real cursor move did not bring the first row into view" + ); + for code in [KeyCode::Down, KeyCode::Down, KeyCode::End] { + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(code, KeyModifiers::SHIFT)), + &library, + &geometry, ), EventHandling::Consumed ); @@ -3302,6 +5275,172 @@ mod path_suggestion_tests { } } + fn request_with_value(value: &str) -> PathCompletionRequest { + PathCompletionRequest { + value: value.to_owned(), + ..request("/new") + } + } + + fn seeded_visible_session( + visible: (u64, usize, &str), + expected: (u64, usize, &str), + ) -> PathSuggestionSession { + PathSuggestionSession { + expected: Some(ExpectedPathSuggestion { + generation: expected.0, + field: expected.1, + request: request_with_value(expected.2), + }), + visible: Some(VisiblePathSuggestion { + generation: visible.0, + field: visible.1, + value: visible.2.to_owned(), + suggestion: format!("{}.txt", visible.2), + }), + ..PathSuggestionSession::default() + } + } + + #[test] + fn refresh_rejects_identical_and_unrelated_suggestions() { + for suggestion in ["n", "old.txt"] { + let (result_tx, result_rx) = mpsc::channel(); + let mut suggestions = PathSuggestionSession { + results: Some(result_rx), + expected: Some(ExpectedPathSuggestion { + generation: 7, + field: 2, + request: request_with_value("n"), + }), + in_flight: true, + ..PathSuggestionSession::default() + }; + result_tx + .send(PathSuggestionResult { + generation: 7, + field: 2, + value: "n".to_owned(), + suggestion: Some(suggestion.to_owned()), + }) + .unwrap(); + + assert!(suggestions.refresh()); + assert_eq!(suggestions.visible(2, "n"), None, "accepted {suggestion:?}"); + } + } + + #[test] + fn refresh_drains_results_that_arrive_after_cancellation() { + let (request_tx, request_rx) = mpsc::sync_channel(2); + let (result_tx, result_rx) = mpsc::channel(); + let mut suggestions = PathSuggestionSession { + requests: Some(request_tx), + results: Some(result_rx), + ..PathSuggestionSession::default() + }; + + suggestions.ensure(2, Some(request_with_value("old"))); + let old_job = request_rx.recv().unwrap(); + for suffix in ["txt", "toml"] { + result_tx + .send(PathSuggestionResult { + generation: old_job.generation, + field: old_job.field, + value: old_job.request.value.clone(), + suggestion: Some(format!("old.{suffix}")), + }) + .unwrap(); + } + + suggestions.clear(); + assert!(!suggestions.refresh()); + assert_eq!(suggestions.visible(2, "old"), None); + assert!(matches!( + suggestions.results.as_ref().unwrap().try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + + suggestions.ensure(2, Some(request_with_value("new"))); + let new_job = request_rx.recv().unwrap(); + result_tx + .send(PathSuggestionResult { + generation: new_job.generation, + field: new_job.field, + value: new_job.request.value.clone(), + suggestion: Some("new.txt".to_owned()), + }) + .unwrap(); + + assert!(suggestions.refresh()); + assert_eq!(suggestions.visible(2, "new"), Some("new.txt")); + } + + #[test] + fn visible_suggestion_requires_one_complete_identity() { + assert_eq!( + seeded_visible_session((7, 2, "n"), (7, 2, "n")).visible(2, "n"), + Some("n.txt") + ); + for (label, visible, expected, requested) in [ + ("visible field", (7, 1, "n"), (7, 2, "n"), (2, "n")), + ("visible value", (7, 2, "old"), (7, 2, "n"), (2, "n")), + ("expected generation", (7, 2, "n"), (8, 2, "n"), (2, "n")), + ("expected field", (7, 2, "n"), (7, 3, "n"), (2, "n")), + ("expected value", (7, 2, "n"), (7, 2, "old"), (2, "n")), + ] { + assert_eq!( + seeded_visible_session(visible, expected).visible(requested.0, requested.1), + None, + "accepted stale {label}" + ); + } + } + + #[test] + fn accepting_or_leaving_the_run_screen_clears_suggestion_state() { + let mut accepted = seeded_visible_session((7, 2, "n"), (7, 2, "n")); + assert_eq!(accepted.take(2, "n"), Some("n.txt".to_owned())); + assert_eq!(accepted.take(2, "n"), None); + + let (_result_tx, result_rx) = mpsc::channel(); + let mut session = TuiSession { + path_suggestions: PathSuggestionSession { + results: Some(result_rx), + retry_pending: true, + ..seeded_visible_session((7, 2, "n"), (7, 2, "n")) + }, + ..TuiSession::default() + }; + session.begin_render(&LibraryState::default()); + assert!(!session.refresh_background()); + assert_eq!(session.path_suggestions.visible(2, "n"), None); + } + + #[test] + fn terminal_poll_deadline_tracks_in_flight_and_retry_work() { + use crate::terminal::{TerminalEventWait, terminal_event_wait}; + + let mut session = TuiSession::default(); + assert_eq!( + terminal_event_wait(session.has_pending_path_completion()), + TerminalEventWait::Blocking + ); + + session.path_suggestions.in_flight = true; + assert_eq!( + terminal_event_wait(session.has_pending_path_completion()), + TerminalEventWait::Poll(Duration::from_millis(25)) + ); + + session.path_suggestions.in_flight = false; + session.path_suggestions.retry_pending = true; + assert_eq!( + terminal_event_wait(session.has_pending_path_completion()), + TerminalEventWait::Poll(Duration::from_millis(25)) + ); + } + #[test] fn same_value_in_a_new_context_replaces_the_old_pending_request() { let mut suggestions = PathSuggestionSession::new(Arc::new(ContextProvider)); @@ -3349,6 +5488,10 @@ mod path_suggestion_tests { assert!(suggestions.expected.is_none()); assert!(!suggestions.in_flight); assert!(suggestions.retry_pending); + assert!( + suggestions.refresh(), + "a pending retry must request the render pass that calls ensure again" + ); drop(held_requests); } @@ -3432,5 +5575,10 @@ mod path_suggestion_tests { EventHandling::Ignored ); assert!(session.settings_prompt_overlay.is_some()); + assert_eq!( + session.handle_event(Event::Resize(40, 10), &state, &ViewGeometry::default()), + EventHandling::Ignored + ); + assert!(session.settings_prompt_overlay.is_some()); } } diff --git a/crates/skit-tui/src/terminal.rs b/crates/skit-tui/src/terminal.rs index 9de4bf70..ecd4fa94 100644 --- a/crates/skit-tui/src/terminal.rs +++ b/crates/skit-tui/src/terminal.rs @@ -24,12 +24,12 @@ use thiserror::Error; use crate::{EventHandling, TuiSession, ViewGeometry, render_with_session}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TerminalEventWait { +pub(crate) enum TerminalEventWait { Blocking, Poll(Duration), } -const fn terminal_event_wait(path_completion_pending: bool) -> TerminalEventWait { +pub(crate) const fn terminal_event_wait(path_completion_pending: bool) -> TerminalEventWait { if path_completion_pending { TerminalEventWait::Poll(Duration::from_millis(25)) } else { @@ -50,7 +50,10 @@ fn terminal_claim_refusal(stdin_is_terminal: bool, stdout_is_terminal: bool) -> if stdin_is_terminal && stdout_is_terminal { None } else { - Some(io::Error::other("stdin and stdout are not a terminal")) + Some(io::Error::new( + io::ErrorKind::NotConnected, + "stdin and stdout are not a terminal", + )) } } @@ -68,8 +71,35 @@ fn claim_terminal() -> io::Result<()> { { return Err(refusal); } - enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) + let enter = || execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture); + claim_terminal_with(enable_raw_mode, enter, restore_terminal) +} + +/// Restore both terminal modes after a completed session or a partial claim. +/// +/// Raw mode and the alternate screen are independent terminal resources. Restoration always +/// attempts both and keeps the first restoration error. A failed claim uses this function for +/// best-effort rollback and keeps the original claim error. +fn restore_terminal() -> io::Result<()> { + restorative_terminal_transition(disable_raw_mode, || { + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture) + }) +} + +fn claim_terminal_with(enable: E, transition: T, rollback: R) -> io::Result<()> +where + E: FnOnce() -> io::Result<()>, + T: FnOnce() -> io::Result<()>, + R: FnOnce() -> io::Result<()>, +{ + enable()?; + match transition() { + Ok(()) => Ok(()), + Err(error) => { + let _ = rollback(); + Err(error) + } + } } /// Take the next event, or report that the poll window closed with nothing to take. @@ -98,15 +128,36 @@ where } } +#[derive(Clone, Debug, PartialEq)] +struct TerminalDispatch { + action: Option, + redraw: bool, +} + +const fn request_redraw(current: bool, changed: bool) -> bool { + current || changed +} + fn dispatch_event( session: &mut TuiSession, event: event::Event, state: &LibraryState, geometry: &ViewGeometry, -) -> Option { +) -> TerminalDispatch { + let resized = matches!(event, event::Event::Resize(_, _)); match session.handle_event(event, state, geometry) { - EventHandling::Action(action) => Some(action), - EventHandling::Consumed | EventHandling::Ignored => None, + EventHandling::Action(action) => TerminalDispatch { + action: Some(action), + redraw: true, + }, + EventHandling::Consumed => TerminalDispatch { + action: None, + redraw: true, + }, + EventHandling::Ignored => TerminalDispatch { + action: None, + redraw: resized, + }, } } @@ -272,7 +323,7 @@ where } } claim_terminal()?; - let _restore = RestoreTerminal; + let _restore = RestoreTerminal::new(restore_terminal); let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -280,46 +331,48 @@ where TuiSession::with_path_completion(provider) }); + let mut geometry = ViewGeometry::default(); + let mut redraw = true; let outcome = loop { - let _ = session.refresh_background(); - let mut geometry = ViewGeometry::default(); - terminal.draw(|frame| { - geometry = render_with_session(frame, &state, locale, &mut session); - })?; + redraw = request_redraw(redraw, session.refresh_background()); + if redraw { + terminal.draw(|frame| { + geometry = render_with_session(frame, &state, locale, &mut session); + })?; + redraw = false; + } let Some(event) = read_terminal_event(terminal_event_wait(session.has_pending_path_completion()))? else { continue; }; - if let Some(action) = dispatch_event(&mut session, event, &state, &geometry) { - let mut outcome = observe(&action); - let effect = state.update(action); - match effect { - Effect::None if outcome.is_some() => break outcome, - Effect::None => {} - Effect::Quit => break outcome, - effect => { - if !accept_host_effect(&mut state, &effect, &mut preflight, locale) { - continue; - } - terminal.show_cursor()?; - suspend_terminal()?; - let (quit, host_outcome) = drain_host_effects_observed( - &mut state, - &mut host, - effect, - &mut locale, - &mut observe, - )?; - if outcome.is_none() { - outcome = host_outcome; + let dispatched = dispatch_event(&mut session, event, &state, &geometry); + redraw = request_redraw(redraw, dispatched.redraw); + if let Some(action) = dispatched.action { + let step = advance_hosted_action( + &mut state, + action, + &mut preflight, + &mut host, + &mut locale, + &mut observe, + &mut |transition| match transition { + HostedTerminalTransition::Suspend => { + terminal.show_cursor()?; + sequential_terminal_transition(disable_raw_mode, || { + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture) + }) } - if quit || outcome.is_some() { - break outcome; + HostedTerminalTransition::Resume => { + sequential_terminal_transition(enable_raw_mode, || { + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) + })?; + terminal.clear() } - resume_terminal()?; - terminal.clear()?; - } + }, + )?; + if let HostedActionStep::Finish(outcome) = step { + break outcome; } } }; @@ -327,6 +380,69 @@ where Ok(outcome) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HostedTerminalTransition { + Suspend, + Resume, +} + +#[derive(Debug, Eq, PartialEq)] +enum HostedActionStep { + Continue, + Finish(Option), +} + +/// Apply one dispatched action and report whether the hosted loop continues. +/// +/// The transition callback is the terminal boundary. Production leaves and re-enters the +/// alternate screen there. Tests use the same path with a recorded fake lifecycle. +fn advance_hosted_action( + state: &mut LibraryState, + action: Action, + preflight: &mut P, + host: &mut F, + locale: &mut Locale, + observe: &mut impl FnMut(&Action) -> Option, + transition: &mut impl FnMut(HostedTerminalTransition) -> io::Result<()>, +) -> Result, TuiError> +where + F: FnMut(Effect) -> Result, + P: FnMut(&Effect) -> Result<(), E>, + E: Localize, +{ + let mut outcome = observe(&action); + let effect = state.update(action); + match effect { + Effect::None => { + if outcome.is_some() { + Ok(HostedActionStep::Finish(outcome)) + } else { + Ok(HostedActionStep::Continue) + } + } + Effect::Quit => Ok(HostedActionStep::Finish(outcome)), + effect => { + if !accept_host_effect(state, &effect, preflight, *locale) { + return Ok(HostedActionStep::Continue); + } + transition(HostedTerminalTransition::Suspend)?; + let (quit, host_outcome) = + drain_host_effects_observed(state, host, effect, locale, observe)?; + if outcome.is_none() { + outcome = host_outcome; + } + if quit { + return Ok(HostedActionStep::Finish(outcome)); + } + if outcome.is_some() { + return Ok(HostedActionStep::Finish(outcome)); + } + transition(HostedTerminalTransition::Resume)?; + Ok(HostedActionStep::Continue) + } + } +} + fn accept_host_effect( state: &mut LibraryState, effect: &Effect, @@ -485,7 +601,7 @@ where let mut state = LibraryState::default(); state.update(Action::Present(screen)); claim_terminal()?; - let _restore = RestoreTerminal; + let _restore = RestoreTerminal::new(restore_terminal); let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -493,31 +609,33 @@ where TuiSession::with_path_completion(provider) }); + let mut geometry = ViewGeometry::default(); + let mut redraw = true; let values = loop { - let _ = session.refresh_background(); - let mut geometry = ViewGeometry::default(); - terminal.draw(|frame| { - geometry = render_with_session(frame, &state, locale, &mut session); - })?; + redraw = request_redraw(redraw, session.refresh_background()); + if redraw { + terminal.draw(|frame| { + geometry = render_with_session(frame, &state, locale, &mut session); + })?; + redraw = false; + } let Some(event) = read_terminal_event(terminal_event_wait(session.has_pending_path_completion()))? else { continue; }; - if let Some(action) = dispatch_event(&mut session, event, &state, &geometry) { - if action == Action::Quit { - break None; - } - // Escape inside a modal closes only that modal and keeps the form - // (`src/skit/tui_form.py:376-377` `action_cancel` dismisses the preset modal). The form - // itself is the last screen here, so Escape outside a modal cancels the collection. - if action == Action::Back && state.modal().is_none() { - break None; - } - let effect = state.update(action); - if let Some(values) = drain_collect_effects(&mut state, &mut host, effect, &mut locale)? - { - break values; + let dispatched = dispatch_event(&mut session, event, &state, &geometry); + redraw = request_redraw(redraw, dispatched.redraw); + if let Some(action) = dispatched.action { + match collect_action_step(&mut state, action) { + CollectActionStep::Cancel => break None, + CollectActionStep::Effect(effect) => { + if let Some(values) = + drain_collect_effects(&mut state, &mut host, effect, &mut locale)? + { + break values; + } + } } } }; @@ -525,6 +643,25 @@ where Ok(values) } +#[derive(Debug, PartialEq)] +enum CollectActionStep { + Cancel, + Effect(Effect), +} + +fn collect_action_step(state: &mut LibraryState, action: Action) -> CollectActionStep { + if matches!(action, Action::Quit) { + return CollectActionStep::Cancel; + } + // Escape inside a modal closes only that modal and keeps the form + // (`src/skit/tui_form.py:376-377` `action_cancel` dismisses the preset modal). The form itself + // is the last screen here, so Escape outside a modal cancels the collection. + if matches!(action, Action::Back) && state.modal().is_none() { + return CollectActionStep::Cancel; + } + CollectActionStep::Effect(state.update(action)) +} + /// Serve one effect chain for a collected form. /// /// `Ok(None)` continues the event loop. `Ok(Some(values))` finishes the collection, and @@ -563,23 +700,51 @@ where Err(TuiError::EffectCycle) } -#[derive(Debug)] -struct RestoreTerminal; +fn sequential_terminal_transition(first: F, second: S) -> io::Result<()> +where + F: FnOnce() -> io::Result<()>, + S: FnOnce() -> io::Result<()>, +{ + first()?; + second() +} -fn suspend_terminal() -> io::Result<()> { - disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture) +fn restorative_terminal_transition(first: F, second: S) -> io::Result<()> +where + F: FnOnce() -> io::Result<()>, + S: FnOnce() -> io::Result<()>, +{ + let first = first(); + let second = second(); + first.and(second) } -fn resume_terminal() -> io::Result<()> { - enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) +struct RestoreTerminal +where + F: FnOnce() -> io::Result<()>, +{ + restore: Option, } -impl Drop for RestoreTerminal { +impl RestoreTerminal +where + F: FnOnce() -> io::Result<()>, +{ + fn new(restore: F) -> Self { + Self { + restore: Some(restore), + } + } +} + +impl Drop for RestoreTerminal +where + F: FnOnce() -> io::Result<()>, +{ fn drop(&mut self) { - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + if let Some(restore) = self.restore.take() { + let _ = restore(); + } } } @@ -587,6 +752,21 @@ impl Drop for RestoreTerminal { mod tests { use super::*; + fn empty_run_form() -> RunFormView { + use std::collections::BTreeMap; + + RunFormView::from_declarations( + "demo", + "Demo", + &[], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ) + } + /// Every stream combination answers the same way on every host: only two real terminals /// proceed, and each refusal names both streams so the caller's report is exact. #[test] @@ -597,10 +777,49 @@ mod tests { { let refusal = terminal_claim_refusal(stdin_is_terminal, stdout_is_terminal) .expect("a non-terminal stream must refuse"); + assert_eq!(refusal.kind(), io::ErrorKind::NotConnected); assert_eq!(refusal.to_string(), "stdin and stdout are not a terminal"); } } + #[test] + fn terminal_claim_rolls_back_when_the_screen_transition_fails() { + use std::cell::Cell; + + let step_calls = Cell::new(0_usize); + let step = || { + step_calls.set(step_calls.get().saturating_add(1)); + Ok(()) + }; + let error = claim_terminal_with( + || Ok(()), + || Err(io::Error::other("screen transition failed")), + step, + ) + .unwrap_err(); + + assert_eq!(error.to_string(), "screen transition failed"); + assert_eq!(step_calls.get(), 1, "raw mode was not rolled back"); + + step_calls.set(0); + let error = claim_terminal_with(|| Err(io::Error::other("raw mode failed")), step, step) + .unwrap_err(); + assert_eq!(error.to_string(), "raw mode failed"); + assert_eq!( + step_calls.get(), + 0, + "a failed raw-mode claim ran a later terminal step" + ); + } + + #[test] + fn redraw_requests_are_monotonic_until_the_loop_draws() { + assert!(!request_redraw(false, false)); + assert!(request_redraw(false, true)); + assert!(request_redraw(true, false)); + assert!(request_redraw(true, true)); + } + #[derive(Debug)] struct HostError; @@ -877,6 +1096,293 @@ mod tests { assert_eq!(hosted, Some(slug)); } + #[test] + fn hosted_action_step_distinguishes_local_continue_and_typed_finish() { + use std::cell::RefCell; + + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut locale = Locale::En; + let mut host = harmless_host; + let mut preflight = |_effect: &Effect| Ok::<(), HostError>(()); + let mut observe = add_workflow_outcome; + let transitions = RefCell::new(Vec::new()); + let mut record_transition = |transition| { + transitions.borrow_mut().push(transition); + Ok(()) + }; + + assert_eq!( + advance_hosted_action( + &mut state, + Action::ClearStatus, + &mut preflight, + &mut host, + &mut locale, + &mut observe, + &mut record_transition, + ) + .unwrap(), + HostedActionStep::Continue + ); + assert!(transitions.borrow().is_empty()); + assert_eq!( + advance_hosted_action( + &mut state, + Action::AddCancelled, + &mut preflight, + &mut host, + &mut locale, + &mut observe, + &mut record_transition, + ) + .unwrap(), + HostedActionStep::Finish(Some(AddWorkflowOutcome::Cancelled)) + ); + assert!(transitions.borrow().is_empty()); + + // The same recorder is live when an actual host effect crosses the terminal boundary. + // This distinguishes the two local actions above from an accidentally suppressed + // transition callback. + let mut continuing_state = LibraryState::default(); + let mut continuing_host = harmless_host; + let mut no_outcome = |_action: &Action| None::<()>; + assert_eq!( + advance_hosted_action( + &mut continuing_state, + Action::Reload, + &mut preflight, + &mut continuing_host, + &mut locale, + &mut no_outcome, + &mut record_transition, + ) + .unwrap(), + HostedActionStep::Continue + ); + assert_eq!( + &*transitions.borrow(), + &[ + HostedTerminalTransition::Suspend, + HostedTerminalTransition::Resume, + ] + ); + } + + #[test] + fn hosted_action_step_finishes_for_quit_or_outcome_independently() { + let mut locale = Locale::En; + let mut preflight = |_effect: &Effect| Ok::<(), HostError>(()); + + let mut quit_state = LibraryState::default(); + let mut quit_host = |_effect| -> Result { Ok(Action::Quit) }; + let mut no_outcome = |_action: &Action| None::<()>; + let mut transitions = Vec::new(); + assert_eq!( + advance_hosted_action( + &mut quit_state, + Action::Reload, + &mut preflight, + &mut quit_host, + &mut locale, + &mut no_outcome, + &mut |transition| { + transitions.push(transition); + Ok(()) + }, + ) + .unwrap(), + HostedActionStep::Finish(None) + ); + assert_eq!(transitions, [HostedTerminalTransition::Suspend]); + + let slug = Slug::parse("settled").unwrap(); + let completed = slug.clone(); + let mut outcome_state = LibraryState::default(); + outcome_state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut outcome_host = move |_effect| -> Result { + Ok(Action::AddCompleted { + surface: skit_application::library_detail::LibrarySurface::default(), + rerunnable: Vec::new(), + slug: completed.clone(), + message: "Added".to_owned(), + }) + }; + let mut observe = add_workflow_outcome; + transitions.clear(); + assert_eq!( + advance_hosted_action( + &mut outcome_state, + Action::Reload, + &mut preflight, + &mut outcome_host, + &mut locale, + &mut observe, + &mut |transition| { + transitions.push(transition); + Ok(()) + }, + ) + .unwrap(), + HostedActionStep::Finish(Some(AddWorkflowOutcome::Completed(slug))) + ); + assert_eq!(transitions, [HostedTerminalTransition::Suspend]); + + let mut continuing_state = LibraryState::default(); + let mut continuing_host = harmless_host; + let mut no_outcome = |_action: &Action| None::<()>; + transitions.clear(); + assert_eq!( + advance_hosted_action( + &mut continuing_state, + Action::Reload, + &mut preflight, + &mut continuing_host, + &mut locale, + &mut no_outcome, + &mut |transition| { + transitions.push(transition); + Ok(()) + }, + ) + .unwrap(), + HostedActionStep::Continue + ); + assert_eq!( + transitions, + [ + HostedTerminalTransition::Suspend, + HostedTerminalTransition::Resume, + ] + ); + } + + #[test] + fn collected_form_actions_cancel_only_the_form_owner() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Run(Box::new(empty_run_form())))); + assert_eq!( + collect_action_step(&mut state, Action::Quit), + CollectActionStep::Cancel + ); + + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Run(Box::new(empty_run_form())))); + assert_eq!( + collect_action_step(&mut state, Action::Back), + CollectActionStep::Cancel + ); + + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Run(Box::new(empty_run_form())))); + state.update(Action::OpenHelp); + assert!(state.modal().is_some()); + assert_eq!( + collect_action_step(&mut state, Action::Back), + CollectActionStep::Effect(Effect::None) + ); + assert!(state.modal().is_none()); + } + + #[test] + fn public_run_collection_still_claims_a_real_terminal() { + use std::process::{Command, Stdio}; + + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "terminal::tests::public_run_collection_rejects_piped_child", + "--ignored", + "--nocapture", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success()); + } + + #[test] + #[ignore = "runs only as the piped child of the public wrapper contract"] + fn public_run_collection_rejects_piped_child() { + let error = collect_run_form(empty_run_form(), harmless_host, Locale::En) + .expect_err("a collected form must reject non-terminal test streams"); + assert!(matches!( + &error, + TuiError::Io(error) + if error.kind() == io::ErrorKind::NotConnected + && error.to_string() == "stdin and stdout are not a terminal" + )); + } + + #[test] + fn terminal_transition_primitives_keep_order_errors_and_drop_restoration() { + use std::cell::RefCell; + use std::rc::Rc; + + let calls = Rc::new(RefCell::new(Vec::new())); + let first_calls = Rc::clone(&calls); + let second_calls = Rc::clone(&calls); + let mut second = move || { + second_calls.borrow_mut().push("second"); + Ok(()) + }; + sequential_terminal_transition( + move || { + first_calls.borrow_mut().push("first"); + Ok(()) + }, + &mut second, + ) + .unwrap(); + assert_eq!(&*calls.borrow(), &["first", "second"]); + + calls.borrow_mut().clear(); + let first_calls = Rc::clone(&calls); + let error = sequential_terminal_transition( + move || { + first_calls.borrow_mut().push("first-error"); + Err(io::Error::other("first failed")) + }, + &mut second, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "first failed"); + assert_eq!(&*calls.borrow(), &["first-error"]); + + calls.borrow_mut().clear(); + let first_calls = Rc::clone(&calls); + let second_calls = Rc::clone(&calls); + let error = restorative_terminal_transition( + move || { + first_calls.borrow_mut().push("restore-raw-error"); + Err(io::Error::other("raw restore failed")) + }, + move || { + second_calls.borrow_mut().push("restore-screen"); + Ok(()) + }, + ) + .unwrap_err(); + assert_eq!(error.to_string(), "raw restore failed"); + assert_eq!(&*calls.borrow(), &["restore-raw-error", "restore-screen"]); + + calls.borrow_mut().clear(); + let restore_calls = Rc::clone(&calls); + { + let _restore = RestoreTerminal::new(move || { + restore_calls.borrow_mut().push("drop-restore"); + Ok(()) + }); + } + assert_eq!(&*calls.borrow(), &["drop-restore"]); + } + #[test] fn host_and_collection_drains_cover_quit_submit_locale_error_and_cycles() { use std::collections::BTreeMap; @@ -962,18 +1468,65 @@ mod tests { geometry = render_with_session(frame, &state, Locale::En, &mut session); }) .unwrap(); - assert_eq!( - dispatch_event(&mut session, Event::FocusGained, &state, &geometry), - None - ); + let ignored = dispatch_event(&mut session, Event::FocusGained, &state, &geometry); + assert_eq!(ignored.action, None); + assert!(!ignored.redraw); let ctrl_c = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert_eq!( - dispatch_event(&mut session, ctrl_c.clone(), &state, &geometry), - None + let consumed = dispatch_event(&mut session, ctrl_c.clone(), &state, &geometry); + assert_eq!(consumed.action, None); + assert!(consumed.redraw); + let action = dispatch_event(&mut session, ctrl_c, &state, &geometry); + assert_eq!(action.action, Some(Action::Quit)); + assert!(action.redraw); + } + + #[test] + fn dispatch_keeps_ignored_consumed_action_and_resize_redraw_decisions_distinct() { + use ratatui_core::{backend::TestBackend, terminal::Terminal}; + use ratatui_crossterm::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, + }; + + let state = LibraryState::default(); + let mut session = TuiSession::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let mut geometry = ViewGeometry::default(); + terminal + .draw(|frame| { + geometry = render_with_session(frame, &state, Locale::En, &mut session); + }) + .unwrap(); + + let ignored = dispatch_event( + &mut session, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, ); - assert_eq!( - dispatch_event(&mut session, ctrl_c, &state, &geometry), - Some(Action::Quit) + let consumed = dispatch_event( + &mut session, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + &state, + &geometry, + ); + assert!(!ignored.redraw, "an ignored pointer move must not redraw"); + assert!(consumed.redraw, "a consumed event must request a redraw"); + + let resized = dispatch_event(&mut session, Event::Resize(59, 13), &state, &geometry); + assert!(resized.redraw, "a terminal resize must request a redraw"); + + let action = dispatch_event( + &mut session, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + &state, + &geometry, ); + assert!(action.redraw, "an action must request a redraw"); + assert_eq!(action.action, Some(Action::Quit)); } } diff --git a/crates/skit-tui/src/viewport.rs b/crates/skit-tui/src/viewport.rs new file mode 100644 index 00000000..31b1b0da --- /dev/null +++ b/crates/skit-tui/src/viewport.rs @@ -0,0 +1,264 @@ +//! Shared viewport invariants for clipped and scrollable surfaces. + +use ratatui_core::layout::Rect; +use ratatui_crossterm::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}; +use ratatui_interact::components::ScrollableContentState; + +/// Inputs that decide whether a scroll owner must realign its focused item. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AlignmentSignature { + focus: Focus, + viewport_width: u16, + viewport_height: u16, + reflow: Reflow, +} + +impl AlignmentSignature { + /// Store the current inputs and report whether focus or layout changed. + pub(crate) fn update( + previous: &mut Option, + focus: Focus, + viewport: Rect, + reflow: Reflow, + ) -> bool { + let current = Self { + focus, + viewport_width: viewport.width, + viewport_height: viewport.height, + reflow, + }; + let changed = previous.as_ref() != Some(¤t); + *previous = Some(current); + changed + } +} + +/// One measured viewport and the virtual rows that it displays. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Viewport { + area: Rect, + content_height: usize, +} + +/// Scroll state for virtual rows that do not have one stored string per row. +/// +/// Forms and settings already own their text in typed controls. A second `Vec` of empty +/// rows makes render memory grow with a multiline value even though only a terminal-sized band is +/// visible. This state stores only the row count and the reader's position. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct VirtualScrollState { + line_count: usize, + scroll_offset: usize, +} + +impl VirtualScrollState { + #[cfg(test)] + pub(crate) const fn line_count(&self) -> usize { + self.line_count + } + + pub(crate) const fn scroll_offset(&self) -> usize { + self.scroll_offset + } + + pub(crate) fn set_line_count(&mut self, line_count: usize) { + self.line_count = line_count; + self.scroll_offset = if line_count == 0 { + 0 + } else { + self.scroll_offset.min(line_count - 1) + }; + } + + #[cfg(test)] + pub(crate) fn set_lines(&mut self, lines: Vec) { + self.set_line_count(lines.len()); + } + + pub(crate) fn set_scroll_offset(&mut self, offset: usize) { + self.scroll_offset = if self.line_count == 0 { + 0 + } else { + offset.min(self.line_count - 1) + }; + } + + fn scroll_up(&mut self, rows: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(rows); + } + + fn scroll_down(&mut self, rows: usize, visible_height: usize) { + let maximum = self.line_count.saturating_sub(visible_height); + self.scroll_offset = self.scroll_offset.saturating_add(rows).min(maximum); + } + + /// Apply the same reader keys as `ratatui-interact` without materializing virtual rows. + pub(crate) fn handle_key(&mut self, key: &KeyEvent, visible_height: usize) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1), + KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1, visible_height), + KeyCode::PageUp => self.scroll_up(visible_height.saturating_sub(1)), + KeyCode::PageDown => { + self.scroll_down(visible_height.saturating_sub(1), visible_height); + } + KeyCode::Home => self.scroll_offset = 0, + KeyCode::End => { + self.scroll_offset = self.line_count.saturating_sub(visible_height); + } + _ => return false, + } + true + } + + /// Apply a wheel event only when this virtual viewport owns its terminal cell. + pub(crate) fn handle_mouse( + &mut self, + mouse: &MouseEvent, + area: Rect, + visible_height: usize, + ) -> bool { + if !area.contains((mouse.column, mouse.row).into()) { + return false; + } + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_up(3), + MouseEventKind::ScrollDown => self.scroll_down(3, visible_height), + _ => return false, + } + true + } +} + +impl Viewport { + pub(crate) const fn new(area: Rect, content_height: usize) -> Self { + Self { + area, + content_height, + } + } + + pub(crate) const fn visible_height(self) -> usize { + self.area.height as usize + } + + pub(crate) const fn maximum_scroll_offset(self) -> usize { + self.content_height.saturating_sub(self.visible_height()) + } + + /// Clamp persistent scroll state after content or viewport geometry changes. + pub(crate) fn clamp_scroll(self, scroll: &mut ScrollableContentState) { + scroll.set_scroll_offset(scroll.scroll_offset().min(self.maximum_scroll_offset())); + } + + /// Split a local surface without fabricating a row in an empty viewport. + pub(crate) const fn split_footer(area: Rect, preferred_footer_height: u16) -> [Rect; 2] { + let footer_height = match area.height.checked_sub(preferred_footer_height) { + Some(_) => preferred_footer_height, + None => area.height, + }; + let body_height = area.height.saturating_sub(footer_height); + [ + Rect::new(area.x, area.y, area.width, body_height), + Rect::new( + area.x, + area.y.saturating_add(body_height), + area.width, + footer_height, + ), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_clamp_preserves_every_offset_through_the_exact_maximum() { + let viewport = Viewport::new(Rect::new(2, 3, 20, 4), 9); + assert_eq!(viewport.maximum_scroll_offset(), 5); + for offset in [0, 4, 5] { + let mut scroll = ScrollableContentState::default(); + scroll.set_lines(vec![String::new(); 9]); + scroll.set_scroll_offset(offset); + viewport.clamp_scroll(&mut scroll); + assert_eq!(scroll.scroll_offset(), offset); + } + + let mut scroll = ScrollableContentState::default(); + scroll.set_lines(vec![String::new(); 9]); + scroll.set_scroll_offset(8); + viewport.clamp_scroll(&mut scroll); + assert_eq!(scroll.scroll_offset(), 5); + Viewport::new(Rect::new(2, 3, 20, 10), 9).clamp_scroll(&mut scroll); + assert_eq!(scroll.scroll_offset(), 0); + } + + #[test] + fn footer_split_tiles_the_viewport_at_zero_exact_and_overflow_boundaries() { + for height in 0..=4 { + let area = Rect::new(7, 11, 20, height); + for preferred in 0..=5 { + let [body, footer] = Viewport::split_footer(area, preferred); + assert_eq!(body, Rect::new(7, 11, 20, height.saturating_sub(preferred))); + assert_eq!(footer.x, area.x); + assert_eq!(footer.y, body.bottom()); + assert_eq!(footer.width, area.width); + assert_eq!(footer.height, preferred.min(height)); + assert_eq!(footer.bottom(), area.bottom()); + } + } + } + + #[test] + fn virtual_scroll_keeps_large_content_as_a_count_and_reaches_its_tail() { + let mut scroll = VirtualScrollState::default(); + scroll.set_line_count(usize::from(u16::MAX) + 10_000); + assert_eq!(scroll.line_count(), usize::from(u16::MAX) + 10_000); + assert!(scroll.handle_key(&KeyEvent::from(KeyCode::End), 5)); + assert_eq!(scroll.scroll_offset(), scroll.line_count() - 5); + + let down = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 5, + row: 5, + modifiers: ratatui_crossterm::crossterm::event::KeyModifiers::NONE, + }; + assert!(!scroll.handle_mouse(&down, Rect::new(6, 6, 4, 4), 5)); + assert!(scroll.handle_mouse(&down, Rect::new(4, 4, 4, 4), 5)); + assert_eq!(scroll.scroll_offset(), scroll.line_count() - 5); + } + + #[test] + fn virtual_scroll_owns_the_documented_keys_and_only_vertical_wheel_events() { + let mut scroll = VirtualScrollState::default(); + scroll.set_line_count(20); + scroll.set_scroll_offset(5); + + for (code, expected) in [ + (KeyCode::Up, 4), + (KeyCode::Char('k'), 3), + (KeyCode::Down, 4), + (KeyCode::Char('j'), 5), + (KeyCode::Home, 0), + ] { + assert!(scroll.handle_key(&KeyEvent::from(code), 4)); + assert_eq!(scroll.scroll_offset(), expected, "key {code:?}"); + } + + scroll.set_scroll_offset(5); + for code in [KeyCode::F(10), KeyCode::Enter] { + assert!(!scroll.handle_key(&KeyEvent::from(code), 4)); + assert_eq!(scroll.scroll_offset(), 5, "key {code:?}"); + } + + let moved = MouseEvent { + kind: MouseEventKind::Moved, + column: 5, + row: 5, + modifiers: ratatui_crossterm::crossterm::event::KeyModifiers::NONE, + }; + assert!(!scroll.handle_mouse(&moved, Rect::new(4, 4, 4, 4), 4)); + assert_eq!(scroll.scroll_offset(), 5); + } +} diff --git a/crates/skit-tui/tests/add_workflow.rs b/crates/skit-tui/tests/add_workflow.rs index 101e3635..86034771 100644 --- a/crates/skit-tui/tests/add_workflow.rs +++ b/crates/skit-tui/tests/add_workflow.rs @@ -3,12 +3,13 @@ use ratatui_crossterm::crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use skit_application::SourcePermissions; +use skit_domain::StorageMode; use skit_i18n::Locale; use skit_tui::{AddControlId, AddScreenEvent, AddScreenSession, render_add}; use skit_tui::{EventHandling, TuiSession, ViewGeometry, render_with_session}; use skit_ui::{ Action, AddAction, AddEffect, AddWorkflowState, DraftSummary, KnownEntryKind, LibraryState, - ReviewDefaults, ReviewState, Screen, SourceSnapshot, + PROMPT_LIST_PREVIEW_LIMIT, ReviewDefaults, ReviewState, Screen, SourceSnapshot, }; fn draw(session: &mut TuiSession, state: &LibraryState) -> (Terminal, ViewGeometry) { @@ -59,14 +60,1218 @@ fn position_of(buffer: &Buffer, needle: &str) -> (u16, u16) { } fn mouse(column: u16, row: u16) -> Event { + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), column, row) +} + +fn mouse_with_kind(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind, column, row, modifiers: KeyModifiers::NONE, }) } +fn add_click( + session: &mut AddScreenSession, + state: &AddWorkflowState, + geometry: &skit_tui::AddScreenGeometry, + column: u16, + row: u16, +) -> Option { + assert_eq!( + session.handle_event(mouse(column, row), state, geometry), + Some(AddScreenEvent::Changed) + ); + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ) +} + +fn session_click( + session: &mut TuiSession, + state: &LibraryState, + geometry: &ViewGeometry, + column: u16, + row: u16, +) -> EventHandling { + assert_eq!( + session.handle_event(mouse(column, row), state, geometry), + EventHandling::Consumed + ); + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ) +} + +fn snapshot(path: &str, bytes: &[u8], is_draft: bool) -> SourceSnapshot { + SourceSnapshot { + path: path.into(), + source_record: path.to_owned(), + bytes: bytes.to_vec(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft, + identity: None, + } +} + +fn review( + path: &str, + bytes: &[u8], + kind: KnownEntryKind, + is_draft: bool, + defaults: ReviewDefaults, +) -> AddWorkflowState { + AddWorkflowState::from_review(ReviewState::from_source( + snapshot(path, bytes, is_draft), + kind, + defaults, + )) +} + +fn draw_add( + session: &mut AddScreenSession, + state: &AddWorkflowState, +) -> (Terminal, skit_tui::AddScreenGeometry) { + draw_add_sized(session, state, 100, 60) +} + +fn draw_add_sized( + session: &mut AddScreenSession, + state: &AddWorkflowState, + width: u16, + height: u16, +) -> (Terminal, skit_tui::AddScreenGeometry) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut geometry = skit_tui::AddScreenGeometry::default(); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), state, session, Locale::En); + }) + .unwrap(); + (terminal, geometry) +} + +fn assert_focus_cycle_has_only_visible_controls( + session: &mut AddScreenSession, + state: &AddWorkflowState, +) -> Vec { + let (_, mut geometry) = draw_add(session, state); + let first = session + .focused() + .cloned() + .expect("the rendered Add stage must own a focus target"); + let mut visited = Vec::new(); + let attempts = geometry.hits.len().saturating_add(1); + for _ in 0..attempts { + let focused = session + .focused() + .cloned() + .expect("Tab left the rendered Add stage without focus"); + assert!( + geometry.hits.iter().any(|hit| hit.target == focused), + "focus reached a control with no rendered pointer twin: {focused:?}; stage={:?}, review={:?}", + state.stage(), + state + .review() + .map(|review| (review.kind(), review.is_fresh(), review.storage())) + ); + if !visited.contains(&focused) { + visited.push(focused); + } + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + state, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + (_, geometry) = draw_add(session, state); + if session.focused() == Some(&first) { + return visited; + } + } + panic!("Tab did not complete one bounded Add focus traversal"); +} + +fn prompt_review( + candidate_count: usize, + interpolate: bool, + defaults: ReviewDefaults, +) -> AddWorkflowState { + let body = (0..candidate_count) + .map(|index| format!("{{{{field{index:02}}}}}")) + .collect::>() + .join(" "); + review( + "task.prompt.md", + body.as_bytes(), + KnownEntryKind::Prompt, + false, + ReviewDefaults { + interpolate: Some(interpolate), + ..defaults + }, + ) +} + +fn tab_until_add_focus( + session: &mut AddScreenSession, + state: &AddWorkflowState, + geometry: &skit_tui::AddScreenGeometry, + target: &AddControlId, +) { + for _ in 0..32 { + if session.focused() == Some(target) { + return; + } + let _ = session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + state, + geometry, + ); + } + assert_eq!( + session.focused(), + Some(target), + "focus did not reach {target:?} in one bounded traversal" + ); +} + +fn ambiguous_kind(path: &str) -> AddWorkflowState { + let mut state = AddWorkflowState::new(Vec::new()); + let _ = state.reduce(AddAction::SetSourcePath(path.to_owned())); + let request = state + .reduce(AddAction::Continue) + .into_iter() + .find_map(|effect| match effect { + AddEffect::InspectSource { request, .. } => Some(request), + _ => None, + }) + .expect("source inspection request"); + let _ = state.reduce(AddAction::SourceInspected { + request, + result: Ok(snapshot(path, b"plain body\n", false)), + }); + assert!( + state.kind_picker().is_some(), + "the source must be ambiguous" + ); + state +} + +#[test] +fn add_session_syncs_external_source_and_review_values_without_resetting_equal_input() { + let mut source = AddWorkflowState::new(Vec::new()); + let mut session = AddScreenSession::default(); + let (_, initial_geometry) = draw_add(&mut session, &source); + assert_eq!( + session.focused(), + Some(&AddControlId::Text(skit_tui::AddTextField::SourcePath)) + ); + assert!( + initial_geometry + .hits + .iter() + .any(|hit| { hit.target == AddControlId::Text(skit_tui::AddTextField::SourcePath) }) + ); + + let _ = source.reduce(AddAction::SetSourcePath("picked/external.js".to_owned())); + let _ = source.reduce(AddAction::SetCommandTemplate("node {file}".to_owned())); + let _ = source.reduce(AddAction::SetCommandName("external-name".to_owned())); + let _ = source.reduce(AddAction::SetCommandDescription( + "external-description".to_owned(), + )); + let (terminal, geometry) = draw_add(&mut session, &source); + let rendered = text(terminal.backend().buffer()); + for value in [ + "picked/external.js", + "node {file}", + "external-name", + "external-description", + ] { + assert!( + rendered.contains(value), + "stale source input {value:?}:\n{rendered}" + ); + } + assert!(geometry.hits.iter().any(|hit| { + hit.target == AddControlId::Text(skit_tui::AddTextField::CommandDescription) + })); + + let mut javascript_review = review( + "tool.js", + b"import chalk from 'chalk';\nconsole.log(chalk);\n", + KnownEntryKind::JavaScript, + false, + ReviewDefaults::default(), + ); + let (_, geometry) = draw_add(&mut session, &javascript_review); + assert!( + geometry + .hits + .iter() + .any(|hit| { session.focused() == Some(&hit.target) }), + "a stage change left focus outside the visible control geometry" + ); + + let _ = javascript_review.reduce(AddAction::SetReviewName( + "renamed-outside-the-widget".to_owned(), + )); + let _ = javascript_review.reduce(AddAction::SetReviewDescription( + "description-from-the-reducer".to_owned(), + )); + let _ = javascript_review.reduce(AddAction::SetReviewDependencies("chalk@5".to_owned())); + let (terminal, _) = draw_add(&mut session, &javascript_review); + let rendered = text(terminal.backend().buffer()); + for value in [ + "renamed-outside-the-widget", + "description-from-the-reducer", + "chalk@5", + ] { + assert!( + rendered.contains(value), + "stale review input {value:?}:\n{rendered}" + ); + } + + let mut python = review( + "tool.py", + b"print('ok')\n", + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ); + let _ = draw_add(&mut session, &python); + let _ = python.reduce(AddAction::SetReviewPython(">=3.12".to_owned())); + let (terminal, geometry) = draw_add(&mut session, &python); + assert!( + text(terminal.backend().buffer()).contains(">=3.12"), + "the external Python constraint did not replace the widget value" + ); + assert!( + geometry.hits.iter().any(|hit| { + hit.target == AddControlId::Text(skit_tui::AddTextField::PythonConstraint) + }) + ); +} + +#[test] +fn add_equal_state_rerender_preserves_the_clicked_input_cursor() { + let mut state = AddWorkflowState::new(Vec::new()); + let _ = state.reduce(AddAction::SetSourcePath("abcdef".to_owned())); + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + let source = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Text(skit_tui::AddTextField::SourcePath)) + .expect("source input hit") + .area; + assert_eq!( + add_click( + &mut session, + &state, + &geometry, + source.x.saturating_add(3), + source.y.saturating_add(1), + ), + Some(AddScreenEvent::Changed) + ); + + let (_, rerendered_geometry) = draw_add(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)), + &state, + &rerendered_geometry, + ), + Some(AddScreenEvent::Action(AddAction::SetSourcePath( + "abXcdef".to_owned() + ))) + ); +} + +#[test] +fn add_sync_rebuilds_exact_source_kind_and_storage_control_shapes() { + let mut session = AddScreenSession::default(); + + let empty_source = AddWorkflowState::new(Vec::new()); + let (_, empty_geometry) = draw_add(&mut session, &empty_source); + assert!( + empty_geometry + .hits + .iter() + .all(|hit| hit.target != AddControlId::DeleteDraft), + "an empty draft inventory exposed Delete" + ); + assert_focus_cycle_has_only_visible_controls(&mut session, &empty_source); + + let draft_source = AddWorkflowState::new(vec![DraftSummary { + path: "kept.py".into(), + modified: 1, + identity: None, + permissions: SourcePermissions::default(), + content_hash: None, + }]); + let (_, draft_geometry) = draw_add(&mut session, &draft_source); + for target in [AddControlId::Draft(0), AddControlId::DeleteDraft] { + assert!( + draft_geometry.hits.iter().any(|hit| hit.target == target), + "a nonempty draft inventory lost {target:?}" + ); + } + + let kind = ambiguous_kind("likely.md"); + let suggested = kind + .kind_picker() + .and_then(|picker| picker.suggested()) + .expect("likely.md must have a suggested kind"); + let (_, kind_geometry) = draw_add(&mut session, &kind); + assert!( + kind_geometry + .hits + .iter() + .any(|hit| { session.focused() == Some(&hit.target) }), + "kind focus is not a visible typed hit" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &kind, + &kind_geometry, + ), + Some(AddScreenEvent::Action(AddAction::PickKind(Some(suggested)))) + ); + + for (label, state, storage_visible, edit_visible) in [ + ( + "fresh script", + review( + "draft.js", + b"console.log(1);\n", + KnownEntryKind::JavaScript, + true, + ReviewDefaults::default(), + ), + false, + true, + ), + ( + "external executable", + review( + "tool", + b"binary bytes", + KnownEntryKind::Executable, + false, + ReviewDefaults::default(), + ), + false, + false, + ), + ( + "external script", + review( + "tool.js", + b"console.log(1);\n", + KnownEntryKind::JavaScript, + false, + ReviewDefaults::default(), + ), + true, + true, + ), + ] { + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + geometry + .hits + .iter() + .any(|hit| hit.target == AddControlId::Storage), + storage_visible, + "wrong Storage control shape for {label}" + ); + assert_eq!( + geometry + .hits + .iter() + .any(|hit| hit.target == AddControlId::EditSource), + edit_visible, + "wrong Edit control shape for {label}" + ); + assert!( + geometry + .hits + .iter() + .any(|hit| { session.focused() == Some(&hit.target) }), + "{label} left focus outside visible geometry" + ); + assert_focus_cycle_has_only_visible_controls(&mut session, &state); + } +} + +#[test] +fn add_storage_sync_keeps_copy_and_reference_controls_disjoint() { + let mut state = review( + "tool.js", + b"import chalk from 'chalk';\nconst OUTPUT = 'out.txt';\nconsole.log(chalk, OUTPUT);\n", + KnownEntryKind::JavaScript, + false, + ReviewDefaults::default(), + ); + let mut session = AddScreenSession::default(); + let (copy_terminal, copy_geometry) = draw_add(&mut session, &state); + let copy = text(copy_terminal.backend().buffer()); + assert!( + copy.contains("Keep a copy"), + "copy selection is not rendered:\n{copy}" + ); + assert!( + copy.contains("Package dependencies"), + "npm copy lost its dependency editor" + ); + assert!( + copy_geometry + .hits + .iter() + .any(|hit| { hit.target == AddControlId::Text(skit_tui::AddTextField::Dependencies) }) + ); + assert!( + copy_geometry + .hits + .iter() + .any(|hit| { matches!(hit.target, AddControlId::Candidate(_)) }) + ); + + tab_until_add_focus( + &mut session, + &state, + ©_geometry, + &AddControlId::Text(skit_tui::AddTextField::Dependencies), + ); + let _ = state.reduce(AddAction::SetReviewStorage(StorageMode::Reference)); + let (reference_terminal, reference_geometry) = draw_add(&mut session, &state); + let reference = text(reference_terminal.backend().buffer()); + assert!( + reference.contains("Link the original"), + "reference selection is not rendered:\n{reference}" + ); + assert!(!reference.contains("Package dependencies")); + assert!(reference_geometry.hits.iter().all(|hit| { + hit.target != AddControlId::Text(skit_tui::AddTextField::Dependencies) + && !matches!(hit.target, AddControlId::Candidate(_)) + })); + assert!( + reference_geometry + .hits + .iter() + .any(|hit| { session.focused() == Some(&hit.target) }), + "focus stayed on a control removed by reference mode" + ); + + let _ = state.reduce(AddAction::SetReviewStorage(StorageMode::Copy)); + let (copy_again_terminal, copy_again_geometry) = draw_add(&mut session, &state); + assert!(text(copy_again_terminal.backend().buffer()).contains("Keep a copy")); + assert!( + copy_again_geometry + .hits + .iter() + .any(|hit| { hit.target == AddControlId::Text(skit_tui::AddTextField::Dependencies) }) + ); + + let reference_first = review( + "linked.js", + b"import chalk from 'chalk';\n", + KnownEntryKind::JavaScript, + false, + ReviewDefaults { + reference: true, + ..ReviewDefaults::default() + }, + ); + let (terminal, geometry) = draw_add(&mut AddScreenSession::default(), &reference_first); + let rendered = text(terminal.backend().buffer()); + assert!(rendered.contains("Link the original")); + assert!( + geometry + .hits + .iter() + .all(|hit| { hit.target != AddControlId::Text(skit_tui::AddTextField::Dependencies) }) + ); + assert_focus_cycle_has_only_visible_controls( + &mut AddScreenSession::default(), + &reference_first, + ); +} + +#[test] +fn add_prompt_candidate_boundaries_keep_focus_hits_and_ctrl_o_in_lockstep() { + for (count, interpolate, has_picker) in [ + (PROMPT_LIST_PREVIEW_LIMIT - 1, true, false), + (PROMPT_LIST_PREVIEW_LIMIT, true, false), + (PROMPT_LIST_PREVIEW_LIMIT + 1, true, true), + (PROMPT_LIST_PREVIEW_LIMIT + 1, false, false), + ] { + let state = prompt_review(count, interpolate, ReviewDefaults::default()); + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + geometry + .hits + .iter() + .any(|hit| hit.target == AddControlId::Continue), + has_picker, + "wrong Choose variables control at count={count}, interpolate={interpolate}" + ); + let visited = assert_focus_cycle_has_only_visible_controls(&mut session, &state); + assert_eq!( + visited.contains(&AddControlId::Continue), + has_picker, + "focus shape disagrees with the rendered picker control" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL,)), + &state, + &geometry, + ), + has_picker.then_some(AddScreenEvent::OpenPromptCandidates), + "Ctrl+O disagrees with the visible picker control" + ); + } +} + +#[test] +fn add_runner_selection_and_close_report_the_exact_visible_state_change() { + let state = prompt_review( + 1, + true, + ReviewDefaults { + runner: Some("beta".to_owned()), + runner_names: vec!["alpha".to_owned(), "beta".to_owned()], + ..ReviewDefaults::default() + }, + ); + let mut session = AddScreenSession::default(); + let (terminal, geometry) = draw_add(&mut session, &state); + let runner = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Runner) + .expect("Prompt runner control") + .area; + let buffer = terminal.backend().buffer(); + let runner_text = (runner.y..runner.bottom()) + .flat_map(|row| { + (runner.x..runner.right()).map(move |column| buffer[(column, row)].symbol().to_owned()) + }) + .collect::(); + assert!( + runner_text.contains("beta"), + "wrong selected runner: {runner_text}" + ); + assert_eq!( + add_click( + &mut session, + &state, + &geometry, + runner.x.saturating_add(1), + runner.y.saturating_add(1), + ), + Some(AddScreenEvent::Changed) + ); + let (_, open) = draw_add(&mut session, &state); + assert!( + open.hits + .iter() + .any(|hit| matches!(hit.target, AddControlId::RunnerOption(_))) + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + &state, + &open, + ), + Some(AddScreenEvent::Changed), + "closing an open runner select must request a redraw" + ); + let (_, closed) = draw_add(&mut session, &state); + assert!( + closed + .hits + .iter() + .all(|hit| !matches!(hit.target, AddControlId::RunnerOption(_))) + ); +} + +#[test] +fn add_kind_home_and_end_choose_the_first_and_last_exact_kind() { + for (code, take_choice) in [(KeyCode::Home, 0_usize), (KeyCode::End, usize::MAX)] { + let state = ambiguous_kind("likely.md"); + let choices = state.kind_picker().expect("kind picker").choices(); + let expected = if take_choice == usize::MAX { + *choices.last().expect("last kind") + } else { + choices[take_choice] + }; + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)), + &state, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::PickKind(Some(expected)))) + ); + } +} + +#[test] +fn add_source_ctrl_d_is_distinct_from_bare_d_and_other_control_keys() { + let mut state = AddWorkflowState::new(Vec::new()); + let _ = state.reduce(AddAction::SetSourcePath("abc".to_owned())); + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::SetSourcePath( + "abcd".to_owned() + ))) + ); + + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + &state, + &geometry, + ), + None, + "an unrelated Ctrl key must not become Delete-next-character" + ); + + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::SetSourcePath( + "abc".to_owned() + ))) + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL,)), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::SetSourcePath( + "ab".to_owned() + ))) + ); +} + +#[test] +fn add_storage_dropdown_options_keep_typed_indices_and_actions() { + let state = review( + "tool.py", + b"print('ok')\n", + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ); + for (index, expected) in [(0, StorageMode::Copy), (1, StorageMode::Reference)] { + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add(&mut session, &state); + let storage = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Storage) + .expect("Storage control") + .area; + assert_eq!( + add_click( + &mut session, + &state, + &geometry, + storage.x.saturating_add(1), + storage.y.saturating_add(1), + ), + Some(AddScreenEvent::Changed) + ); + let (_, open) = draw_add(&mut session, &state); + let option = open + .hits + .iter() + .find(|hit| hit.target == AddControlId::StorageOption(index)) + .unwrap_or_else(|| panic!("Storage option {index}")) + .area; + assert_eq!( + add_click( + &mut session, + &state, + &open, + option.x.saturating_add(1), + option.y, + ), + Some(AddScreenEvent::Action(AddAction::SetReviewStorage( + expected + ))) + ); + } +} + +#[test] +fn add_review_notes_distinguish_empty_owned_metadata_and_modeled_boundaries() { + for (body, absent) in [ + ( + b"# /// script\n# requires-python = \">=3.12\"\n# dependencies = []\n# ///\nprint(1)\n" + .as_slice(), + "needs Python >=3.12", + ), + ( + b"# /// script\n# dependencies = [\"requests\"]\n# ///\nprint(1)\n".as_slice(), + "installs requests", + ), + ] { + let state = review( + "owned.py", + body, + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ); + let (terminal, _) = draw_add(&mut AddScreenSession::default(), &state); + let rendered = text(terminal.backend().buffer()); + assert!( + rendered.contains(absent), + "missing owned-metadata fact: {rendered}" + ); + assert!( + !rendered.contains("(none declared)"), + "a partially populated metadata block was labeled empty: {rendered}" + ); + } + + let zero = review( + "zero.py", + b"P = 'x'\np.add_argument('--help', action='help')\n", + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ); + let (terminal, _) = draw_add(&mut AddScreenSession::default(), &zero); + let zero_rendered = text(terminal.backend().buffer()); + assert!( + !zero_rendered.contains("skit read this script's own arguments"), + "a zero-field parser must not claim a run form was modeled" + ); + assert!( + !zero_rendered.contains("couldn't model them statically"), + "a modeled zero-field parser must not get the unmodeled-framework notice" + ); + + let dynamic = review( + "dynamic.sh", + b"#!/usr/bin/env bash\nOUTDIR=/tmp\nOPTS=\"n:v\"\nwhile getopts \"$OPTS\" o; do :; done\necho $OUTDIR\n", + KnownEntryKind::Shell, + false, + ReviewDefaults::default(), + ); + let review = dynamic.review().expect("dynamic review"); + assert!(review.onboarding().uses_cli_framework()); + assert!(review.modeled_cli_field_count().is_none()); + let (terminal, _) = draw_add_sized(&mut AddScreenSession::default(), &dynamic, 200, 100); + assert!( + text(terminal.backend().buffer()) + .contains("so the run form offers an extra-arguments field"), + "the unmodeled CLI-framework notice is missing" + ); +} + +#[test] +fn add_review_candidate_and_reference_copy_are_semantically_exact() { + let input = review( + "input.py", + b"NAME = input('Your name?')\nprint(NAME)\n", + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ); + let (terminal, _) = draw_add(&mut AddScreenSession::default(), &input); + assert!( + text(terminal.backend().buffer()).contains("input() #1: Your name?"), + "an input-bound parameter lost its prompt label" + ); + + for (body, expected, rejected) in [ + ( + b"import argparse\np = argparse.ArgumentParser()\np.add_argument('--name')\n" + .as_slice(), + "Link the original: skit never writes to the file.", + "parameter setup is skipped", + ), + ( + b"P = 'x'\np.add_argument('--help', action='help')\n".as_slice(), + "parameter setup is skipped", + "Link the original: skit never writes to the file.", + ), + ] { + let state = review( + "linked.py", + body, + KnownEntryKind::Python, + false, + ReviewDefaults { + reference: true, + ..ReviewDefaults::default() + }, + ); + let (terminal, _) = draw_add(&mut AddScreenSession::default(), &state); + let rendered = text(terminal.backend().buffer()); + assert!( + rendered.contains(expected), + "missing reference note: {rendered}" + ); + assert!( + !rendered.contains(rejected), + "wrong reference note: {rendered}" + ); + } +} + +#[test] +fn add_source_draft_overflow_note_has_an_exact_zero_to_one_boundary() { + let drafts = (0..=PROMPT_LIST_PREVIEW_LIMIT) + .map(|index| DraftSummary { + path: format!("draft-{index:02}.py").into(), + modified: u64::try_from(index).unwrap(), + identity: None, + permissions: SourcePermissions::default(), + content_hash: None, + }) + .collect::>(); + for (count, note) in [ + (PROMPT_LIST_PREVIEW_LIMIT, None), + (PROMPT_LIST_PREVIEW_LIMIT + 1, Some("…and 1 more")), + ] { + let state = AddWorkflowState::new(drafts[..count].to_vec()); + let (terminal, _) = draw_add(&mut AddScreenSession::default(), &state); + let rendered = text(terminal.backend().buffer()); + assert_eq!( + rendered.contains("…and"), + note.is_some(), + "wrong overflow boundary for {count} drafts: {rendered}" + ); + if let Some(note) = note { + assert!(rendered.contains(note), "wrong overflow count: {rendered}"); + } + } +} + +#[test] +fn add_footer_labels_and_select_options_follow_the_review_lane() { + for (state, edit_label, reference_label) in [ + ( + prompt_review(1, true, ReviewDefaults::default()), + "Edit prompt", + "Link the original — edits take effect immediately; skit never writes", + ), + ( + review( + "tool.py", + b"print(1)\n", + KnownEntryKind::Python, + false, + ReviewDefaults::default(), + ), + "Edit script", + "Link the original — edits take effect immediately, but skit won't write", + ), + ] { + let mut session = AddScreenSession::default(); + let (terminal, geometry) = draw_add(&mut session, &state); + assert!( + text(terminal.backend().buffer()).contains(edit_label), + "wrong localized edit footer label" + ); + let storage = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Storage) + .expect("external review Storage control") + .area; + assert_eq!( + add_click( + &mut session, + &state, + &geometry, + storage.x.saturating_add(1), + storage.y.saturating_add(1), + ), + Some(AddScreenEvent::Changed) + ); + let (terminal, open) = draw_add(&mut session, &state); + let option = open + .hits + .iter() + .find(|hit| hit.target == AddControlId::StorageOption(1)) + .expect("visible reference option after opening Storage") + .area; + let option_text = (option.x..option.right()) + .map(|column| terminal.backend().buffer()[(column, option.y)].symbol()) + .collect::(); + assert!( + option_text.contains(reference_label), + "the typed reference hit does not own its visible lane-specific label: {option_text}" + ); + assert_eq!( + add_click( + &mut session, + &state, + &open, + option.x.saturating_add(1), + option.y, + ), + Some(AddScreenEvent::Action(AddAction::SetReviewStorage( + StorageMode::Reference + ))) + ); + } +} + +#[test] +fn add_footer_wraps_only_past_its_exact_measured_width() { + let state = AddWorkflowState::new(Vec::new()); + let expected = [ + AddControlId::Continue, + AddControlId::Cancel, + AddControlId::NewScript, + AddControlId::NewPrompt, + AddControlId::NextField, + AddControlId::PreviousField, + ]; + let (wide, wide_geometry) = draw_add_sized(&mut AddScreenSession::default(), &state, 200, 3); + let footer_y = 2; + let exact_width = wide_geometry + .hits + .iter() + .filter(|hit| hit.area.y == footer_y && expected.contains(&hit.target)) + .map(|hit| hit.area.right()) + .max() + .expect("wide footer command hits"); + assert_ne!(wide.backend().buffer()[(199, footer_y)].symbol(), "↓"); + + for (width, all_visible) in [ + (exact_width, true), + (exact_width.saturating_sub(1), false), + (2, false), + (1, false), + ] { + let (terminal, geometry) = + draw_add_sized(&mut AddScreenSession::default(), &state, width, 3); + let footer_hits = geometry + .hits + .iter() + .filter(|hit| hit.area.y == footer_y && expected.contains(&hit.target)) + .collect::>(); + assert_eq!( + footer_hits.len() == expected.len(), + all_visible, + "wrong footer wrap at exact measured width {width}" + ); + assert!( + footer_hits.iter().all(|hit| hit.area.right() <= width), + "a footer hit overlaps the reserved indicator column or exits the viewport" + ); + if width == 1 { + assert!(!footer_hits.is_empty(), "width one lost its first action"); + assert_eq!( + terminal.backend().buffer()[(0, footer_y)].symbol(), + "[", + "a scroll indicator replaced the only visible action cell" + ); + let mut session = AddScreenSession::default(); + let (_, geometry) = draw_add_sized(&mut session, &state, width, 3); + assert_eq!( + add_click(&mut session, &state, &geometry, 0, footer_y), + Some(AddScreenEvent::Action(AddAction::Continue)), + "the visible width-one action cell must activate Continue" + ); + } else if !all_visible { + assert_eq!( + terminal.backend().buffer()[(width - 1, footer_y)].symbol(), + "↓", + "an overflowing footer needs a separate scroll indicator cell" + ); + assert!( + footer_hits.iter().all(|hit| hit.area.right() < width), + "a footer hit overlaps its scroll indicator" + ); + } + } +} + +#[test] +fn add_local_footer_activates_continue_on_primary_release() { + let state = AddWorkflowState::new(Vec::new()); + let mut session = AddScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + let area = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Continue) + .expect("Continue is visible in the Add footer") + .area; + + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), area.x, area.y), + &state, + &geometry, + ), + Some(AddScreenEvent::Changed), + "primary Down must arm Continue without continuing" + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), area.x, area.y), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::Continue)) + ); +} + +#[test] +fn add_release_outside_cancels_the_footer_arm_before_a_late_release() { + let state = AddWorkflowState::new(Vec::new()); + let mut session = AddScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + let continue_area = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Continue) + .expect("Continue is visible in the Add footer") + .area; + let outside = (0..20) + .flat_map(|row| (0..80).map(move |column| (column, row))) + .find(|(column, row)| { + geometry + .hits + .iter() + .all(|hit| !hit.area.contains((*column, *row).into())) + }) + .expect("the Add frame must contain one non-widget cell"); + + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Down(MouseButton::Left), + continue_area.x, + continue_area.y, + ), + &state, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), outside.0, outside.1), + &state, + &geometry, + ), + None, + "release outside must cancel the armed footer target" + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + continue_area.x, + continue_area.y, + ), + &state, + &geometry, + ), + None, + "a late release must not resurrect the cancelled footer target" + ); +} + +#[test] +fn add_remaining_controls_activate_only_on_matching_primary_release() { + let state = AddWorkflowState::new(Vec::new()); + let mut session = AddScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + let cancel = geometry + .hits + .iter() + .find(|hit| hit.target == AddControlId::Cancel) + .expect("Cancel is visible in the Add footer") + .area; + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), cancel.x, cancel.y,), + &state, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), cancel.x, cancel.y), + &state, + &geometry, + ), + Some(AddScreenEvent::Action(AddAction::Cancel)) + ); +} + fn scroll_down(column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, @@ -76,6 +1281,56 @@ fn scroll_down(column: u16, row: u16) -> Event { }) } +#[test] +fn add_render_clamps_stale_scroll_and_never_registers_zero_height_rows() { + let long = AddWorkflowState::new( + (0..8) + .map(|index| DraftSummary { + path: format!("draft-{index}.py").into(), + modified: u64::try_from(index).unwrap(), + identity: None, + permissions: SourcePermissions::default(), + content_hash: None, + }) + .collect(), + ); + let mut session = AddScreenSession::default(); + let (_, mut geometry) = draw_add_sized(&mut session, &long, 48, 5); + assert_eq!( + geometry.first_visible, 2, + "fresh render must not jump to the end" + ); + for _ in 0..8 { + if geometry.first_visible >= 4 { + break; + } + assert_eq!( + session.handle_event( + scroll_down(geometry.body.x, geometry.body.y), + &long, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + (_, geometry) = draw_add_sized(&mut session, &long, 48, 5); + } + assert_eq!( + geometry.first_visible, 5, + "the test needs an exact row edge" + ); + assert!( + geometry.hits.iter().all(|hit| hit.area.height > 0), + "a row ending at the viewport start registered a zero-height hit" + ); + + let short = prompt_review(0, false, ReviewDefaults::default()); + let (_, clamped) = draw_add(&mut session, &short); + assert_eq!( + clamped.first_visible, 0, + "a shorter Add shape retained an unreachable stale offset" + ); +} + fn advertised_key(buffer: &Buffer, area: ratatui_core::layout::Rect) -> Event { let rendered = (area.x..area.right()) .map(|column| buffer[(column, area.y)].symbol()) @@ -279,11 +1534,8 @@ fn every_advertised_add_action_is_scrollable_and_clickable_at_every_size_tier() &geometry, ); assert_typed_add_event(&hit.target, key_handling, &workflow); - let mouse_handling = session.handle_event( - mouse(hit.area.x, hit.area.y), - &workflow, - &geometry, - ); + let mouse_handling = + add_click(&mut session, &workflow, &geometry, hit.area.x, hit.area.y); assert_typed_add_event(&hit.target, mouse_handling, &workflow); } } @@ -387,7 +1639,7 @@ fn typed_add_screen_uses_mature_input_and_mouse_opened_file_explorer() { ); let select_row = row_containing(terminal.backend().buffer(), "Select"); assert_eq!( - session.handle_event(mouse(3, select_row), &state, &geometry), + session_click(&mut session, &state, &geometry, 3, select_row), EventHandling::Consumed ); @@ -420,7 +1672,7 @@ fn typed_add_screen_uses_mature_input_and_mouse_opened_file_explorer() { let (terminal, geometry) = draw(&mut session, &state); let (cancel_x, cancel_y) = position_of(terminal.backend().buffer(), "[Esc] Cancel"); assert_eq!( - session.handle_event(mouse(cancel_x, cancel_y), &state, &geometry), + session_click(&mut session, &state, &geometry, cancel_x, cancel_y), EventHandling::Consumed ); let (terminal, _) = draw(&mut session, &state); @@ -444,6 +1696,15 @@ fn add_file_overlay_accepts_a_real_path_and_ignores_non_widget_events() { EventHandling::Consumed ); let (_, geometry) = draw(&mut session, &state); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), 79, 1), + &state, + &geometry, + ), + EventHandling::Consumed, + "an active picker must own blank pointer cells instead of falling through to Add", + ); assert_eq!( session.handle_event(Event::FocusGained, &state, &geometry), EventHandling::Ignored @@ -540,7 +1801,7 @@ fn add_prompt_review_routes_the_complete_picker_and_runner_editor_seam() { let (terminal, geometry) = draw_sized(&mut session, &state, 80, 60); let (runner_x, runner_y) = position_of(terminal.backend().buffer(), "Add Runner"); assert_eq!( - session.handle_event(mouse(runner_x, runner_y), &state, &geometry), + session_click(&mut session, &state, &geometry, runner_x, runner_y), EventHandling::Action(Action::OpenAddRunnerEditor) ); } diff --git a/crates/skit-tui/tests/interactive_run_form.rs b/crates/skit-tui/tests/interactive_run_form.rs index 8d5256da..9d42e344 100644 --- a/crates/skit-tui/tests/interactive_run_form.rs +++ b/crates/skit-tui/tests/interactive_run_form.rs @@ -1,9 +1,7 @@ use std::collections::BTreeMap; use std::fs; -use ratatui_core::{ - backend::TestBackend, buffer::Buffer, layout::Rect, style::Color, terminal::Terminal, -}; +use ratatui_core::{backend::TestBackend, buffer::Buffer, style::Color, terminal::Terminal}; use ratatui_crossterm::crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -11,17 +9,21 @@ use skit_application::tokens::TokenContext; use skit_domain::parameters::{ParamDecl, ParameterType, ParameterValue}; use skit_i18n::Locale; use skit_tui::{ - EventHandling, FilePickerEvent, FilePickerHit, FilePickerSession, HitTarget, TuiSession, - ViewGeometry, render_file_picker, render_with_session, + EventHandling, FilePickerEvent, FilePickerHit, FilePickerSession, HitTarget, RunFieldCommand, + TuiSession, ViewGeometry, render_file_picker, render_with_session, }; use skit_ui::{ Action, Effect, FormControl, FormField, FormPurpose, FormView, LibraryState, ModalState, PathOutputPolicy, PathPickerState, PathSelectionMode, PickerPurpose, RunFormContext, - RunFormView, RunPathContext, Screen, UiCommand, + RunFormView, RunPathContext, Screen, SettingsInputs, SettingsView, UiCommand, }; +use unicode_width::UnicodeWidthStr as _; const ACCENT: Color = Color::Rgb(0xD9, 0x77, 0x57); const BOX_MAROON: Color = Color::Rgb(0x92, 0x35, 0x35); +const BOX_DIM: Color = Color::Rgb(0x3A, 0x3A, 0x3A); +const SELECT_BG: Color = Color::Rgb(0x5A, 0x2D, 0x1E); +const SELECT_FG: Color = Color::Rgb(0xEE, 0xEE, 0xEE); fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { Event::Key(KeyEvent::new(code, modifiers)) @@ -85,6 +87,59 @@ fn drive( handling } +fn session_click( + session: &mut TuiSession, + state: &LibraryState, + geometry: &ViewGeometry, + column: u16, + row: u16, +) -> EventHandling { + assert_eq!( + session.handle_event(mouse(column, row), state, geometry), + EventHandling::Consumed + ); + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ) +} + +fn drive_click( + session: &mut TuiSession, + state: &mut LibraryState, + geometry: &ViewGeometry, + column: u16, + row: u16, +) -> EventHandling { + assert_eq!( + drive(session, state, geometry, mouse(column, row)), + EventHandling::Consumed + ); + drive( + session, + state, + geometry, + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + ) +} + +fn file_click( + session: &mut FilePickerSession, + geometry: &skit_tui::FilePickerGeometry, + column: u16, + row: u16, +) -> Option { + assert_eq!( + session.handle_event(mouse(column, row), geometry), + Some(FilePickerEvent::Changed) + ); + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + geometry, + ) +} + fn form() -> RunFormView { let mut enabled = ParamDecl::new("enabled"); enabled.parameter_type = ParameterType::Bool; @@ -185,21 +240,51 @@ fn complete_surface_form() -> RunFormView { fn row_containing(buffer: &Buffer, needle: &str) -> u16 { (0..buffer.area.height) .find(|row| { - (0..buffer.area.width) - .map(|column| buffer[(column, *row)].symbol()) - .collect::() - .contains(needle) + let mut visible = String::new(); + let mut continuation_cells = 0; + for column in 0..buffer.area.width { + if continuation_cells > 0 { + continuation_cells -= 1; + continue; + } + let symbol = buffer[(column, *row)].symbol(); + visible.push_str(symbol); + continuation_cells = symbol.width().saturating_sub(1); + } + visible.contains(needle) }) .expect("expected rendered row") } +fn visible_buffer_text(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|row| { + let mut visible = String::new(); + let mut continuation_cells = 0; + for column in 0..buffer.area.width { + if continuation_cells > 0 { + continuation_cells -= 1; + continue; + } + let symbol = buffer[(column, row)].symbol(); + visible.push_str(symbol); + continuation_cells = symbol.width().saturating_sub(1); + } + visible + }) + .collect::>() + .join("\n") +} + fn buffer_position(buffer: &Buffer, needle: &str) -> (u16, u16) { for row in 0..buffer.area.height { - let line = (0..buffer.area.width) + let cells = (0..buffer.area.width) .map(|column| buffer[(column, row)].symbol()) - .collect::(); - if let Some(column) = line.find(needle) { - return (u16::try_from(column).unwrap(), row); + .collect::>(); + for column in 0..cells.len() { + if cells[column..].concat().starts_with(needle) { + return (u16::try_from(column).unwrap(), row); + } } } panic!("expected rendered text: {needle}"); @@ -270,6 +355,129 @@ fn mature_input_edits_unicode_at_the_cursor_and_renders_focus_style() { ); } +#[test] +fn clicking_single_line_run_and_form_inputs_places_the_cursor_before_typing() { + let mut run = state_with_form(text_run_form("abcdef")); + let mut run_session = TuiSession::default(); + let (terminal, geometry) = draw(&mut run_session, &run, 80, 18); + let (start, row) = buffer_position(terminal.backend().buffer(), "abcdef"); + assert_eq!( + run_session.handle_event(mouse(start + 2, row), &run, &geometry), + EventHandling::Consumed + ); + let _ = drive( + &mut run_session, + &mut run, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!( + run.run_form().unwrap().fields()[0].control.value(), + "abXcdef" + ); + + let mut form = LibraryState::default(); + form.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![FormField::text("value", "Value", "abcdef")], + focused: 0, + submit_label: "Save".to_owned(), + }))); + let mut form_session = TuiSession::default(); + let (terminal, geometry) = draw(&mut form_session, &form, 80, 18); + let (start, row) = buffer_position(terminal.backend().buffer(), "abcdef"); + assert_eq!( + form_session.handle_event(mouse(start + 2, row), &form, &geometry), + EventHandling::Consumed + ); + let _ = drive( + &mut form_session, + &mut form, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!(form.form().unwrap().fields[0].value, "abXcdef"); +} + +#[test] +fn clicking_a_run_textarea_places_its_logical_cursor() { + let mut state = state_with_form(with_multiline_run_field( + text_run_form("first\nabcdef\nlast"), + 0, + )); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 80, 18); + let (start, row) = buffer_position(terminal.backend().buffer(), "abcdef"); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), start + 2, row), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), start + 2, row), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let _ = drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "first\nabXcdef\nlast" + ); +} + +#[test] +fn textarea_click_uses_the_rendered_vertical_and_horizontal_viewport() { + let value = "row0\nrow1\nrow2\nrow3\nrow4\nrow5\n0123456789abcdefghijklmnopqrstuvwxyz"; + let mut state = state_with_form(with_multiline_run_field(text_run_form(value), 0)); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 30, 12); + assert_eq!( + session.handle_event(key(KeyCode::End, KeyModifiers::NONE), &state, &geometry), + EventHandling::Consumed + ); + + let (terminal, geometry) = draw(&mut session, &state, 30, 12); + let (start, row) = buffer_position(terminal.backend().buffer(), "wxyz"); + assert_eq!( + session.handle_event(mouse(start + 1, row), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), start + 1, row), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let _ = drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "row0\nrow1\nrow2\nrow3\nrow4\nrow5\n0123456789abcdefghijklmnopqrstuvwXxyz" + ); +} + #[test] fn run_surface_renders_main_banners_field_affordances_feedback_and_errors() { let mut state = state_with_form(complete_surface_form()); @@ -281,7 +489,7 @@ fn run_surface_renders_main_banners_field_affordances_feedback_and_errors() { assert_eq!(state.update(Action::Submit), Effect::None); let mut session = TuiSession::default(); let (terminal, _) = draw(&mut session, &state, 120, 44); - let rendered = buffer_text(terminal.backend().buffer()); + let rendered = visible_buffer_text(terminal.backend().buffer()); for expected in [ "The stored field moved in the source.", @@ -324,9 +532,9 @@ fn every_visible_run_field_affordance_has_a_typed_mouse_action() { let (_, geometry) = draw(&mut session, &state, 120, 44); for (command, expected) in [ - (UiCommand::BrowsePath, Action::OpenRunFilePicker(2)), - (UiCommand::InsertValue, Action::OpenRunTokenMenuFor(2)), - (UiCommand::ResetDefault, Action::ResetRunField(2)), + (RunFieldCommand::BrowsePath, Action::OpenRunFilePicker(2)), + (RunFieldCommand::InsertValue, Action::OpenRunTokenMenuFor(2)), + (RunFieldCommand::ResetDefault, Action::ResetRunField(2)), ] { let area = geometry .hits @@ -336,43 +544,17 @@ fn every_visible_run_field_affordance_has_a_typed_mouse_action() { }) .expect("the visible field chip must expose its typed click region"); assert_eq!( - session.handle_event(mouse(area.x, area.y), &state, &geometry), + session_click(&mut session, &state, &geometry, area.x, area.y), EventHandling::Action(expected.clone()) ); - assert_eq!( - skit_tui::map_event(mouse(area.x, area.y), &state, &geometry), - Some(expected) - ); for kind in [MouseEventKind::Moved, MouseEventKind::Up(MouseButton::Left)] { assert_eq!( - skit_tui::map_event( - Event::Mouse(MouseEvent { - kind, - column: area.x, - row: area.y, - modifiers: KeyModifiers::NONE, - }), - &state, - &geometry, - ), - None + session.handle_event(mouse_with_kind(kind, area.x, area.y), &state, &geometry,), + EventHandling::Ignored ); } } - let mut stale = geometry.clone(); - stale.hits.push(skit_tui::HitRegion { - rect: Rect::new(0, 0, 1, 1), - action: HitTarget::RunFieldCommand { - field: 7, - command: UiCommand::Back, - }, - }); - assert_eq!( - skit_tui::map_event(mouse(0, 0), &state, &stale), - Some(Action::Back) - ); - let area = geometry .hits .iter() @@ -381,7 +563,7 @@ fn every_visible_run_field_affordance_has_a_typed_mouse_action() { }) .expect("the runner picker must show its New agent mouse door"); assert_eq!( - session.handle_event(mouse(area.x, area.y), &state, &geometry), + session_click(&mut session, &state, &geometry, area.x, area.y), EventHandling::Action(Action::OpenRunRunnerEditor) ); } @@ -397,11 +579,12 @@ fn checkbox_radio_and_picker_have_keyboard_and_mouse_paths() { .iter() .find(|hit| hit.action == HitTarget::FocusField(2)) .unwrap(); - drive( + drive_click( &mut session, &mut state, &geometry, - mouse(checkbox.rect.x, checkbox.rect.y), + checkbox.rect.x, + checkbox.rect.y, ); assert!(matches!( state.run_form().unwrap().fields()[2].control, @@ -453,6 +636,130 @@ fn checkbox_radio_and_picker_have_keyboard_and_mouse_paths() { assert!(buffer_text(terminal.backend().buffer()).contains("Enable upload?")); } +#[test] +fn run_checkbox_toggles_only_after_a_primary_press_and_release() { + let mut state = state_with_form(form()); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 100, 28); + let checkbox = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(2)) + .expect("the checkbox has a visible hit") + .rect; + + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Down(MouseButton::Left), + checkbox.x, + checkbox.y, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + "primary Down must arm the checkbox without toggling it" + ); + assert!(matches!( + state.run_form().unwrap().fields()[2].control, + FormControl::Checkbox { checked: false } + )); + let release = session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + checkbox.x, + checkbox.y, + ), + &state, + &geometry, + ); + assert_eq!(release, EventHandling::Action(Action::ToggleField(2))); + let EventHandling::Action(action) = release else { + unreachable!(); + }; + state.update(action); + assert!(matches!( + state.run_form().unwrap().fields()[2].control, + FormControl::Checkbox { checked: true } + )); +} + +#[test] +fn a_global_footer_click_cancels_an_armed_run_control() { + let state = state_with_form(form()); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 100, 28); + let checkbox = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(2)) + .expect("the checkbox has a visible hit") + .rect; + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the global footer has a visible command") + .rect; + + assert_eq!( + session.handle_event(mouse(checkbox.x, checkbox.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed, + "a new top-level press must replace the armed content target" + ); + assert!(matches!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Action(_) + )); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + checkbox.x, + checkbox.y, + ), + &state, + &geometry, + ), + EventHandling::Ignored, + "the earlier Run press stayed armed after another owner completed a click" + ); + + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 100, 28); + assert_eq!( + session.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event(mouse(checkbox.x, checkbox.y), &state, &geometry), + EventHandling::Consumed, + "a new content press must replace the armed top-level target" + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + checkbox.x, + checkbox.y, + ), + &state, + &geometry, + ), + EventHandling::Action(Action::ToggleField(2)), + "the replacement content press was swallowed by the old owner" + ); +} + #[test] fn run_and_generic_form_hits_require_a_mouse_button_press() { let run = state_with_form(form()); @@ -481,6 +788,18 @@ fn run_and_generic_form_hits_require_a_mouse_button_press() { } assert_eq!( session.handle_event(mouse(run_hit.rect.x, run_hit.rect.y), &run, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + run_hit.rect.x, + run_hit.rect.y, + ), + &run, + &geometry, + ), EventHandling::Action(Action::ToggleField(2)) ); @@ -522,606 +841,1533 @@ fn run_and_generic_form_hits_require_a_mouse_button_press() { } assert_eq!( session.handle_event(mouse(form_hit.rect.x, form_hit.rect.y), &form, &geometry), - EventHandling::Action(Action::FocusField(1)) - ); -} - -#[test] -fn focus_auto_scrolls_a_long_form_and_wheel_scroll_uses_the_shared_viewport() { - let declarations = (0..16) - .map(|index| ParamDecl::new(format!("field-{index}"))) - .collect::>(); - let form = RunFormView::from_declarations( - "long", - "Long form", - &declarations, - &BTreeMap::new(), - &[], - "", - &BTreeMap::new(), - "", + EventHandling::Consumed, + "generic Form Down must only arm the field" ); - let mut state = state_with_form(form); - state.update(Action::FocusField(15)); - let mut session = TuiSession::default(); - let (terminal, geometry) = draw(&mut session, &state, 70, 18); - - let text = buffer_text(terminal.backend().buffer()); - assert!( - text.contains("field-15"), - "focused controls must auto-scroll into view" + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + form_hit.rect.x, + form_hit.rect.y, + ), + &form, + &geometry, + ), + EventHandling::Action(Action::FocusField(1)) ); - assert!(geometry.first_visible > 0); - let handling = session.handle_event( - Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: geometry.rows.x, - row: geometry.rows.y, - modifiers: KeyModifiers::NONE, - }), - &state, - &geometry, - ); - assert_eq!(handling, EventHandling::Consumed); - let (_, after) = draw(&mut session, &state, 70, 18); - assert!(after.first_visible < geometry.first_visible); + let first_hit = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("the first form field must expose its mouse hit area"); + for (cancel, label) in [ + (MouseEventKind::Down(MouseButton::Right), "right click"), + (MouseEventKind::Down(MouseButton::Middle), "middle click"), + ] { + assert_eq!( + session.handle_event(mouse(form_hit.rect.x, form_hit.rect.y), &form, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(cancel, form_hit.rect.x, form_hit.rect.y), + &form, + &geometry, + ), + EventHandling::Ignored, + "generic Form accepted {label}" + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + form_hit.rect.x, + form_hit.rect.y, + ), + &form, + &geometry, + ), + EventHandling::Ignored, + "{label} did not cancel the armed field" + ); + } + + for (column, row, label) in [ + (first_hit.rect.x, first_hit.rect.y, "a different field"), + (0, 0, "outside the form"), + ] { + assert_eq!( + session.handle_event(mouse(form_hit.rect.x, form_hit.rect.y), &form, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + &form, + &geometry, + ), + EventHandling::Ignored, + "generic Form release over {label} activated the pressed field" + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + form_hit.rect.x, + form_hit.rect.y, + ), + &form, + &geometry, + ), + EventHandling::Ignored, + "a cancelled field accepted a later release" + ); + } } #[test] -fn single_line_input_moves_and_deletes_complete_graphemes() { - let mut state = state_with_form(text_run_form("e\u{301}👨‍👩‍👧‍👦x")); - let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 18); +fn settings_and_global_footer_clicks_cancel_the_previous_owner() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Settings(Box::new( + SettingsView::from_inputs(&SettingsInputs { + selector: "settings-owner".to_owned(), + kind: "python".to_owned(), + name: "Settings owner".to_owned(), + description: "SETTINGS_CONTROL_SENTINEL".to_owned(), + source: "/tmp/settings-owner.py".to_owned(), + supports_modes: true, + has_stored_name: true, + ..SettingsInputs::default() + }), + )))); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Home, KeyModifiers::NONE), + let targets = |session: &mut TuiSession| { + let (terminal, geometry) = draw(session, &state, 100, 40); + let control = buffer_position(terminal.backend().buffer(), "SETTINGS_CONTROL_SENTINEL"); + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("Settings must keep one global footer action visible") + .rect; + (geometry, control, footer) + }; + + let mut control_first = TuiSession::default(); + let (geometry, control, footer) = targets(&mut control_first); + assert_eq!( + control_first.handle_event(mouse(control.0, control.1), &state, &geometry), + EventHandling::Consumed ); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Right, KeyModifiers::NONE), + assert_eq!( + control_first.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed ); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Delete, KeyModifiers::NONE), + assert!(matches!( + control_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Action(_) + )); + assert_eq!( + control_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), control.0, control.1,), + &state, + &geometry, + ), + EventHandling::Ignored, + "a late Settings Up survived a completed global-footer click" ); + let mut footer_first = TuiSession::default(); + let (geometry, control, footer) = targets(&mut footer_first); assert_eq!( - state.run_form().unwrap().fields()[0].control.value(), - "e\u{301}x", - "one Delete must remove the complete ZWJ family after one grapheme move" + footer_first.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed ); -} - -#[test] -fn run_shortcuts_keep_submit_and_preset_save_distinct() { - let state = state_with_form(text_run_form("value")); - let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 18); - assert_eq!( - session.handle_event( - key(KeyCode::Char('r'), KeyModifiers::CONTROL), + footer_first.handle_event(mouse(control.0, control.1), &state, &geometry), + EventHandling::Consumed + ); + assert!(matches!( + footer_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), control.0, control.1,), &state, &geometry, ), - EventHandling::Action(Action::Submit) - ); + EventHandling::Action(Action::Settings(_)) + )); assert_eq!( - session.handle_event( - key(KeyCode::Char('s'), KeyModifiers::CONTROL), + footer_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), &state, &geometry, ), - EventHandling::Action(Action::OpenRunPresetSave) + EventHandling::Ignored, + "a late global-footer Up survived the Settings control's click" ); + + let mut resized = TuiSession::default(); + let (geometry, control, _) = targets(&mut resized); assert_eq!( - session.handle_event( - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + resized.handle_event(mouse(control.0, control.1), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + resized.handle_event(Event::Resize(90, 32), &state, &geometry), + EventHandling::Ignored + ); + let (terminal, geometry) = draw(&mut resized, &state, 90, 32); + let control = buffer_position(terminal.backend().buffer(), "SETTINGS_CONTROL_SENTINEL"); + assert_eq!( + resized.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), control.0, control.1), &state, &geometry, ), - EventHandling::Action(Action::OpenRunTokenMenu) + EventHandling::Ignored, + "a resize must cancel the Settings control arm" ); } #[test] -fn ctrl_t_token_menu_localizes_every_row_in_simplified_chinese() { - let mut path = ParamDecl::new("path"); - path.parameter_type = ParameterType::Path; - let form = RunFormView::from_declarations( - "paths", - "Paths", - &[path], - &BTreeMap::new(), - &[], - "", - &BTreeMap::new(), - "", - ) - .with_context(RunFormContext { - entry_kind: "python".to_owned(), - path: Some(RunPathContext { - workdir: "/work".to_owned(), - invoke_cwd: "/invoke".to_owned(), +fn moving_over_a_global_hit_does_not_cancel_an_armed_settings_control() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Settings(Box::new( + SettingsView::from_inputs(&SettingsInputs { + selector: "settings-move".to_owned(), + kind: "python".to_owned(), + name: "Settings move".to_owned(), + description: "SETTINGS_MOVE_SENTINEL".to_owned(), + source: "/tmp/settings-move.py".to_owned(), + supports_modes: true, + has_stored_name: true, + ..SettingsInputs::default() }), - tokens: TokenContext { - cwd: "/invoke".to_owned(), - home: Some("/home/demo".to_owned()), - env: BTreeMap::new(), - today: "2026-08-24".to_owned(), - now: "12-00-00".to_owned(), - }, - }); - let mut state = state_with_form(form); + )))); let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 100, 24); + let (terminal, geometry) = draw(&mut session, &state, 100, 40); + let control = buffer_position(terminal.backend().buffer(), "SETTINGS_MOVE_SENTINEL"); + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("Settings keeps a global footer target") + .rect; + assert_eq!( - drive( - &mut session, - &mut state, + session.handle_event(mouse(control.0, control.1), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Moved, footer.x, footer.y), + &state, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), ), - EventHandling::Action(Action::OpenRunTokenMenu) + EventHandling::Ignored ); + assert!(matches!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), control.0, control.1), + &state, + &geometry, + ), + EventHandling::Action(Action::Settings(_)) + )); +} - let (terminal, _) = draw_in_locale(&mut session, &state, 100, 24, Locale::ZhCn); - let rendered = buffer_text(terminal.backend().buffer()); - // Ratatui's TestBackend exposes the continuation cell of each wide glyph as a space. - let compact = rendered.replace(' ', ""); - for translated in [ - "文件或文件夹…", - "运行时所在目录(跟着你在哪运行而变)", - "此刻目录(固定路径)", - "今天日期", - "当前时间", - "主目录", - "环境变量…", +#[test] +fn run_and_generic_form_ignore_key_release_events() { + let run = state_with_form(text_run_form("abc")); + let mut run_session = TuiSession::default(); + let (_, run_geometry) = draw(&mut run_session, &run, 80, 18); + let released = |character| { + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char(character), + KeyModifiers::CONTROL, + ratatui_crossterm::crossterm::event::KeyEventKind::Release, + )) + }; + assert_eq!( + run_session.handle_event(released('r'), &run, &run_geometry), + EventHandling::Ignored + ); + + let mut form = LibraryState::default(); + form.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![FormField::text("name", "Name", "abc")], + focused: 0, + submit_label: "Save".to_owned(), + }))); + let mut form_session = TuiSession::default(); + let (_, form_geometry) = draw(&mut form_session, &form, 80, 18); + assert_eq!( + form_session.handle_event(released('s'), &form, &form_geometry), + EventHandling::Ignored + ); +} + +#[test] +fn run_control_shortcuts_require_control_and_bare_letters_edit_the_input() { + for (character, expected) in [ + ('r', Action::Submit), + ('s', Action::OpenRunPresetSave), + ('t', Action::OpenRunTokenMenu), + ('o', Action::ResetFocusedRunField), + ('n', Action::OpenRunRunnerEditor), ] { - assert!( - compact.contains(translated), - "missing {translated}: {rendered}" + let state = state_with_form(text_run_form("")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event( + key(KeyCode::Char(character), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(expected), + "Ctrl+{character} did not dispatch its Run command" + ); + + let state = state_with_form(text_run_form("")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event( + key(KeyCode::Char(character), KeyModifiers::NONE), + &state, + &geometry, + ), + EventHandling::Action(Action::SetFieldValue { + field: 0, + value: character.to_string(), + }), + "bare {character} triggered a Run command" ); } - for source in [ - "File or folder…", - "Directory at run time (changes with where you run)", - "This directory, as a fixed path", - "Today's date", - "Current time", - "Home directory", - "Environment variable…", +} + +#[test] +fn run_checkbox_owns_space_enter_down_and_up() { + for (code, expected) in [ + (KeyCode::Char(' '), Action::ToggleField(2)), + (KeyCode::Enter, Action::Submit), + (KeyCode::Down, Action::FocusField(3)), + (KeyCode::Up, Action::FocusField(1)), ] { - assert!( - !rendered.contains(source), - "untranslated {source}: {rendered}" + let mut state = state_with_form(form()); + state.update(Action::FocusField(2)); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry), + EventHandling::Action(expected), + "the focused checkbox ignored {code:?}" ); } } #[test] -fn preset_name_modal_is_unicode_editable_and_submits_the_exact_snapshot() { - let form = RunFormView::from_declarations( - "demo", - "Demo", - &[ParamDecl::new("name")], - &BTreeMap::from([("name".to_owned(), "Ada".to_owned())]), - &[], - "", - &BTreeMap::from([("夜間".to_owned(), BTreeMap::new())]), - "", - ); - let mut state = state_with_form(form); - let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 20); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char('s'), KeyModifiers::CONTROL), - ); - assert!(matches!( - state.modal(), - Some(ModalState::RunPresetName { .. }) - )); +fn run_radio_and_closed_picker_own_their_navigation_keys() { + for (code, expected) in [ + (KeyCode::Enter, Action::Submit), + (KeyCode::Down, Action::FocusField(4)), + (KeyCode::Up, Action::FocusField(2)), + ] { + let mut state = state_with_form(form()); + state.update(Action::FocusField(3)); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry), + EventHandling::Action(expected), + "the focused radio control ignored {code:?}" + ); + } - let (_, geometry) = draw(&mut session, &state, 80, 20); - for character in "夜間".chars() { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char(character), KeyModifiers::NONE), + for code in [KeyCode::Enter, KeyCode::Char(' '), KeyCode::Down] { + let mut state = state_with_form(form()); + state.update(Action::FocusField(0)); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry), + EventHandling::Consumed, + "the closed runner picker ignored {code:?}" ); } - assert!(matches!( - state.modal(), - Some(ModalState::RunPresetName { value, .. }) if value == "夜間" - )); - let (terminal, geometry) = draw(&mut session, &state, 80, 20); - let rendered = buffer_text(terminal.backend().buffer()); - assert!(rendered.contains("Save as preset")); - assert!( - rendered.contains("This overwrites the existing preset") - && rendered.contains('夜') - && rendered.contains('間'), - "{rendered}" +} + +#[test] +fn open_run_picker_does_not_own_wheel_outside_its_dropdown() { + let mut state = state_with_form(form()); + state.update(Action::FocusField(0)); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry), + EventHandling::Consumed ); + let (_, geometry) = draw(&mut session, &state, 80, 18); - let EventHandling::Action(action) = - session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry) - else { - panic!("Enter did not submit the preset name"); - }; assert_eq!( - state.update(action), - Effect::SaveRunPreset { - selector: "demo".to_owned(), - name: "夜間".to_owned(), - values: BTreeMap::from([("name".to_owned(), "Ada".to_owned())]), - secret_names: Default::default(), - } + session.handle_event( + mouse_with_kind(MouseEventKind::ScrollDown, 0, 0), + &state, + &geometry, + ), + EventHandling::Ignored ); } #[test] -fn token_list_picker_has_keyboard_and_mouse_insertion_paths() { - let form = text_run_form("prefix ").with_context(RunFormContext { - entry_kind: "python".to_owned(), - path: Some(RunPathContext { - workdir: "/work".to_owned(), - invoke_cwd: "/invoke".to_owned(), - }), - tokens: TokenContext { - cwd: "/invoke".to_owned(), - home: Some("/home/alice".to_owned()), - env: BTreeMap::new(), - today: "2026-08-08".to_owned(), - now: "12-00-00".to_owned(), - }, - }); - let mut state = state_with_form(form); +fn run_textarea_paste_groups_selection_replacement_but_not_empty_deletion() { + let make_state = || state_with_form(with_multiline_run_field(text_run_form("a界b"), 0)); + + let mut replaced = make_state(); let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 24); - drive( + let (_, geometry) = draw(&mut session, &replaced, 80, 18); + for event in [ + key(KeyCode::End, KeyModifiers::NONE), + key(KeyCode::Left, KeyModifiers::SHIFT), + ] { + let _ = session.handle_event(event, &replaced, &geometry); + } + assert!(matches!( + drive( + &mut session, + &mut replaced, + &geometry, + Event::Paste("🙂".to_owned()), + ), + EventHandling::Action(Action::SetFieldValue { .. }) + )); + assert_eq!( + replaced.run_form().unwrap().fields()[0].control.value(), + "a界🙂" + ); + let _ = drive( &mut session, - &mut state, + &mut replaced, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + key(KeyCode::Char('z'), KeyModifiers::CONTROL), + ); + assert_eq!( + replaced.run_form().unwrap().fields()[0].control.value(), + "a界b" ); - let (terminal, geometry) = draw(&mut session, &state, 80, 24); - let row = row_containing( - terminal.backend().buffer(), - "Directory at run time (changes with where you run)", + let mut deleted = make_state(); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &deleted, 80, 18); + let _ = drive( + &mut session, + &mut deleted, + &geometry, + key(KeyCode::End, KeyModifiers::NONE), ); - assert_eq!( - drive(&mut session, &mut state, &geometry, mouse(10, row)), - EventHandling::Action(Action::SetRunFieldValueAndCloseModal { - field: 0, - value: "prefix {cwd}".to_owned(), - }) + let _ = drive( + &mut session, + &mut deleted, + &geometry, + key(KeyCode::Char('Q'), KeyModifiers::NONE), + ); + let _ = session.handle_event(key(KeyCode::Left, KeyModifiers::SHIFT), &deleted, &geometry); + let _ = drive( + &mut session, + &mut deleted, + &geometry, + Event::Paste(String::new()), ); - assert!(state.modal().is_none()); assert_eq!( - state.run_form().unwrap().fields()[0].control.value(), - "prefix {cwd}" + deleted.run_form().unwrap().fields()[0].control.value(), + "a界b" ); - - let (_, geometry) = draw(&mut session, &state, 80, 24); - drive( + let _ = drive( &mut session, - &mut state, + &mut deleted, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + key(KeyCode::Char('z'), KeyModifiers::CONTROL), ); - let (_, geometry) = draw(&mut session, &state, 80, 24); assert_eq!( - session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry), - EventHandling::Action(Action::SetRunFieldValueAndCloseModal { + deleted.run_form().unwrap().fields()[0].control.value(), + "a界bQ" + ); +} + +#[test] +fn generic_form_shortcut_focus_and_page_keys_keep_distinct_meanings() { + let make_state = |focused| { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![ + FormField::text("first", "First", ""), + FormField::text("second", "Second", ""), + ], + focused, + submit_label: "Save".to_owned(), + }))); + state + }; + + for (code, focused, expected) in [ + ( + KeyCode::Enter, + 0, + EventHandling::Action(Action::FocusField(1)), + ), + ( + KeyCode::Down, + 0, + EventHandling::Action(Action::FocusField(1)), + ), + (KeyCode::Up, 1, EventHandling::Action(Action::FocusField(0))), + (KeyCode::PageUp, 0, EventHandling::Consumed), + (KeyCode::PageDown, 0, EventHandling::Consumed), + ] { + let state = make_state(focused); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 24, 8); + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry), + expected, + "generic Form ignored {code:?}" + ); + } + + let state = make_state(0); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event( + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(Action::Submit) + ); + let state = make_state(0); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + assert_eq!( + session.handle_event( + key(KeyCode::Char('s'), KeyModifiers::NONE), + &state, + &geometry, + ), + EventHandling::Action(Action::SetFieldValue { field: 0, - value: "prefix {cwd}{cwd}".to_owned(), + value: "s".to_owned(), }) ); } #[test] -fn environment_picker_fuzzy_filters_and_accepts_keyboard_or_mouse_values() { - let form = text_run_form("").with_context(RunFormContext { - entry_kind: "python".to_owned(), - path: Some(RunPathContext { - workdir: "/work".to_owned(), - invoke_cwd: "/invoke".to_owned(), - }), - tokens: TokenContext { - cwd: "/invoke".to_owned(), - home: Some("/home/alice".to_owned()), - env: BTreeMap::from([ - ("HOME".to_owned(), "/home/alice".to_owned()), - ("HTTP_PROXY".to_owned(), "http://proxy".to_owned()), - ("SKIT_PROFILE".to_owned(), "test".to_owned()), - ]), - today: "2026-08-08".to_owned(), - now: "12-00-00".to_owned(), - }, - }); - let mut state = state_with_form(form); +fn generic_form_paste_keeps_cursor_selection_and_scroll_state_exact() { + let make_form = |field| { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![field], + focused: 0, + submit_label: "Save".to_owned(), + }))); + state + }; + + let mut line = make_form(FormField::text("name", "Name", "a界b")); let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 24); - drive( + let (_, geometry) = draw(&mut session, &line, 80, 18); + let _ = session.handle_event(key(KeyCode::Left, KeyModifiers::NONE), &line, &geometry); + let _ = drive( &mut session, - &mut state, + &mut line, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + Event::Paste("🙂".to_owned()), ); - let (_, geometry) = draw(&mut session, &state, 80, 24); - for _ in 0..5 { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Down, KeyModifiers::NONE), - ); + assert_eq!(line.form().unwrap().fields[0].value, "a界🙂b"); + + let mut textarea = make_form(FormField::multiline("body", "Body", "a界b")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &textarea, 80, 18); + for event in [ + key(KeyCode::End, KeyModifiers::NONE), + key(KeyCode::Left, KeyModifiers::SHIFT), + ] { + let _ = session.handle_event(event, &textarea, &geometry); } - drive( + let _ = drive( &mut session, - &mut state, + &mut textarea, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), + Event::Paste("🙂".to_owned()), ); - assert!(matches!( - state.modal(), - Some(ModalState::RunEnvironmentPicker { .. }) - )); - - let (_, geometry) = draw(&mut session, &state, 80, 24); - for character in "hme".chars() { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char(character), KeyModifiers::NONE), - ); - } - assert!(matches!( - state.modal(), - Some(ModalState::RunEnvironmentPicker { visible, .. }) if visible == &["HOME".to_owned()] - )); - let (terminal, geometry) = draw(&mut session, &state, 80, 24); - let home_row = row_containing(terminal.backend().buffer(), "HOME"); - drive(&mut session, &mut state, &geometry, mouse(12, home_row)); - assert_eq!( - state.run_form().unwrap().fields()[0].control.value(), - "{env:HOME}" + assert_eq!(textarea.form().unwrap().fields[0].value, "a界🙂"); + let _ = drive( + &mut session, + &mut textarea, + &geometry, + key(KeyCode::Char('z'), KeyModifiers::CONTROL), ); + assert_eq!(textarea.form().unwrap().fields[0].value, "a界b"); - let (_, geometry) = draw(&mut session, &state, 80, 24); - drive( + let mut appended = make_form(FormField::multiline("body", "Body", "ab")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &appended, 80, 18); + let _ = drive( &mut session, - &mut state, + &mut appended, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + key(KeyCode::End, KeyModifiers::NONE), ); - let (_, geometry) = draw(&mut session, &state, 80, 24); - for _ in 0..5 { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Down, KeyModifiers::NONE), - ); - } - drive( + let _ = drive( &mut session, - &mut state, + &mut appended, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), + key(KeyCode::Char('Q'), KeyModifiers::NONE), ); - let (_, geometry) = draw(&mut session, &state, 80, 24); - for character in "UNSET_NAME".chars() { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char(character), KeyModifiers::NONE), - ); - } - assert!(matches!( - state.modal(), - Some(ModalState::RunEnvironmentPicker { visible, .. }) if visible.is_empty() - )); - drive( + let _ = drive( &mut session, - &mut state, + &mut appended, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), + Event::Paste("X".to_owned()), + ); + assert_eq!(appended.form().unwrap().fields[0].value, "abQX"); + let _ = drive( + &mut session, + &mut appended, + &geometry, + key(KeyCode::Char('z'), KeyModifiers::CONTROL), ); assert_eq!( - state.run_form().unwrap().fields()[0].control.value(), - "{env:HOME}{env:UNSET_NAME}" + appended.form().unwrap().fields[0].value, + "abQ", + "undo after an unselected paste also removed the preceding edit" + ); + + let mut scrolled = make_form(FormField::multiline("body", "Body", "top")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &scrolled, 24, 5); + let _ = drive( + &mut session, + &mut scrolled, + &geometry, + Event::Paste("\none\ntwo\nthree\nfour\nPASTE_SENTINEL".to_owned()), + ); + let (terminal, _) = draw(&mut session, &scrolled, 24, 5); + assert!( + buffer_text(terminal.backend().buffer()).contains("PASTE_SENTINEL"), + "the Form did not follow the cursor after a multiline paste" ); } #[test] -fn run_file_picker_uses_the_shared_explorer_for_keyboard_mouse_and_missing_roots() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("work"); - fs::create_dir(&workdir).unwrap(); - fs::write(workdir.join("alpha.txt"), "alpha").unwrap(); - // The picked name carries a space and glob metacharacters to prove literal placement. The - // brackets are the metacharacters Python's glob.escape centers on, and Windows permits them - // where it forbids a star, so one spelling serves every host. - fs::write(workdir.join("beta file[1].txt"), "beta").unwrap(); - let mut path = ParamDecl::new("path"); - path.parameter_type = ParameterType::Path; +fn generic_form_arm_cannot_survive_a_release_owned_by_the_global_footer() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit entry".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![ + FormField::text("name", "Name", "demo"), + FormField::text("description", "Description", ""), + ], + focused: 0, + submit_label: "Save".to_owned(), + }))); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .expect("the second Form field must have a hit") + .rect; + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the global footer must have a hit") + .rect; + + assert_eq!( + session.handle_event(mouse(field.x, field.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), field.x, field.y), + &state, + &geometry, + ), + EventHandling::Ignored, + "a release in another owner must cancel the Form arm" + ); +} + +#[test] +fn resize_cancels_a_generic_form_arm_before_the_release() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit entry".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![ + FormField::text("name", "Name", "demo"), + FormField::text("description", "Description", ""), + ], + focused: 0, + submit_label: "Save".to_owned(), + }))); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .unwrap() + .rect; + assert_eq!( + session.handle_event(mouse(field.x, field.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event(Event::Resize(70, 16), &state, &geometry), + EventHandling::Ignored + ); + + let (_, resized) = draw(&mut session, &state, 70, 16); + let field = resized + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .unwrap() + .rect; + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), field.x, field.y), + &state, + &resized, + ), + EventHandling::Ignored, + "a resize must cancel a pointer arm from the old geometry" + ); +} + +#[test] +fn focus_auto_scrolls_a_long_form_and_wheel_scroll_uses_the_shared_viewport() { + let declarations = (0..16) + .map(|index| ParamDecl::new(format!("field-{index}"))) + .collect::>(); let form = RunFormView::from_declarations( - "files", - "Files", - &[path], - &BTreeMap::from([("path".to_owned(), "old.txt".to_owned())]), + "long", + "Long form", + &declarations, + &BTreeMap::new(), &[], "", &BTreeMap::new(), "", - ) - .with_context(RunFormContext { - entry_kind: "python".to_owned(), - path: Some(RunPathContext { - workdir: workdir.display().to_string(), - invoke_cwd: temp.path().display().to_string(), - }), - tokens: TokenContext { - cwd: temp.path().display().to_string(), - home: None, - env: BTreeMap::new(), - today: "2026-08-08".to_owned(), - now: "12-00-00".to_owned(), - }, - }); + ); let mut state = state_with_form(form); + state.update(Action::FocusField(15)); let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 84, 26); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + let (terminal, geometry) = draw(&mut session, &state, 70, 18); + + let text = buffer_text(terminal.backend().buffer()); + assert!( + text.contains("field-15"), + "focused controls must auto-scroll into view" ); - let (_, geometry) = draw(&mut session, &state, 84, 26); - drive( - &mut session, - &mut state, + assert!(geometry.first_visible > 0); + + let handling = session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: geometry.rows.x, + row: geometry.rows.y, + modifiers: KeyModifiers::NONE, + }), + &state, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), ); - assert!(matches!( - state.modal(), - Some(ModalState::RunFilePicker { .. }) - )); + assert_eq!(handling, EventHandling::Consumed); + let (_, after) = draw(&mut session, &state, 70, 18); + assert!(after.first_visible < geometry.first_visible); +} + +#[test] +fn resize_only_keeps_the_focused_run_control_visible() { + let declarations = (0..16) + .map(|index| ParamDecl::new(format!("run-field-{index}"))) + .collect::>(); + let form = RunFormView::from_declarations( + "resize-run", + "Resize run", + &declarations, + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ); + let mut state = state_with_form(form); + state.update(Action::FocusField(15)); + let mut session = TuiSession::default(); + let (large, large_geometry) = draw(&mut session, &state, 70, 100); + assert_eq!(large_geometry.first_visible, 0); + assert!(buffer_text(large.backend().buffer()).contains("run-field-15")); + + let (small, small_geometry) = draw(&mut session, &state, 70, 18); + let rendered = buffer_text(small.backend().buffer()); + assert!( + small_geometry.first_visible > 0 && rendered.contains("run-field-15"), + "resize-only reflow hid the focused Run control: offset={} {rendered}", + small_geometry.first_visible + ); +} + +#[test] +fn resize_only_keeps_the_focused_generic_form_control_visible() { + let fields = (0..16) + .map(|index| { + FormField::text( + format!("generic-field-{index}"), + format!("Generic field {index}"), + "", + ) + }) + .collect(); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Resize form".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields, + focused: 15, + submit_label: "Save".to_owned(), + }))); + let mut session = TuiSession::default(); + let (large, large_geometry) = draw(&mut session, &state, 70, 100); + assert_eq!(large_geometry.first_visible, 0); + assert!(buffer_text(large.backend().buffer()).contains("Generic field 15")); + + let (small, small_geometry) = draw(&mut session, &state, 70, 18); + let rendered = buffer_text(small.backend().buffer()); + assert!( + small_geometry.first_visible > 0 && rendered.contains("Generic field 15"), + "resize-only reflow hid the focused generic Form control: offset={} {rendered}", + small_geometry.first_visible + ); +} + +#[test] +fn single_line_input_moves_and_deletes_complete_graphemes() { + let mut state = state_with_form(text_run_form("e\u{301}👨‍👩‍👧‍👦x")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); - let (terminal, geometry) = draw(&mut session, &state, 84, 26); - let rendered = buffer_text(terminal.backend().buffer()); - assert!(rendered.contains("alpha.txt"), "{rendered}"); - for character in "alpha".chars() { - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char(character), KeyModifiers::NONE), - ); - } - let (_, geometry) = draw(&mut session, &state, 84, 26); drive( &mut session, &mut state, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), - ); - assert_eq!( - state.run_form().unwrap().fields()[0].control.value(), - "alpha.txt" + key(KeyCode::Home, KeyModifiers::NONE), ); - - let (_, geometry) = draw(&mut session, &state, 84, 26); drive( &mut session, &mut state, &geometry, - key(KeyCode::Char('t'), KeyModifiers::CONTROL), + key(KeyCode::Right, KeyModifiers::NONE), ); - let (_, geometry) = draw(&mut session, &state, 84, 26); drive( &mut session, &mut state, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), + key(KeyCode::Delete, KeyModifiers::NONE), ); - let (terminal, geometry) = draw(&mut session, &state, 84, 26); - let beta_row = row_containing(terminal.backend().buffer(), "beta file[1].txt"); - drive(&mut session, &mut state, &geometry, mouse(12, beta_row)); + assert_eq!( state.run_form().unwrap().fields()[0].control.value(), - "beta file[1].txt" + "e\u{301}x", + "one Delete must remove the complete ZWJ family after one grapheme move" ); +} - let missing = temp.path().join("gone").join("child"); - let mut path = ParamDecl::new("path"); - path.parameter_type = ParameterType::Path; - let missing_form = RunFormView::from_declarations( - "missing", - "Missing", - &[path], - &BTreeMap::new(), - &[], - "", - &BTreeMap::new(), +#[test] +fn run_shortcuts_keep_submit_and_preset_save_distinct() { + let state = state_with_form(text_run_form("value")); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + + assert_eq!( + session.handle_event( + key(KeyCode::Char('r'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(Action::Submit) + ); + assert_eq!( + session.handle_event( + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(Action::OpenRunPresetSave) + ); + assert_eq!( + session.handle_event( + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(Action::OpenRunTokenMenu) + ); +} + +#[test] +fn ctrl_t_token_menu_localizes_every_row_in_simplified_chinese() { + let mut path = ParamDecl::new("path"); + path.parameter_type = ParameterType::Path; + let form = RunFormView::from_declarations( + "paths", + "Paths", + &[path], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), "", ) .with_context(RunFormContext { entry_kind: "python".to_owned(), path: Some(RunPathContext { - workdir: missing.display().to_string(), - invoke_cwd: temp.path().display().to_string(), + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), }), tokens: TokenContext { - cwd: temp.path().display().to_string(), - home: None, + cwd: "/invoke".to_owned(), + home: Some("/home/demo".to_owned()), + env: BTreeMap::new(), + today: "2026-08-24".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = state_with_form(form); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 100, 24); + assert_eq!( + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ), + EventHandling::Action(Action::OpenRunTokenMenu) + ); + + let (terminal, _) = draw_in_locale(&mut session, &state, 100, 24, Locale::ZhCn); + let rendered = visible_buffer_text(terminal.backend().buffer()); + // Ratatui's TestBackend exposes the continuation cell of each wide glyph as a space. + let compact = rendered.replace(' ', ""); + for translated in [ + "文件或文件夹…", + "运行时所在目录(跟着你在哪运行而变)", + "此刻目录(固定路径)", + "今天日期", + "当前时间", + "主目录", + "环境变量…", + ] { + assert!( + compact.contains(translated), + "missing {translated}: {rendered}" + ); + } + for source in [ + "File or folder…", + "Directory at run time (changes with where you run)", + "This directory, as a fixed path", + "Today's date", + "Current time", + "Home directory", + "Environment variable…", + ] { + assert!( + !rendered.contains(source), + "untranslated {source}: {rendered}" + ); + } +} + +#[test] +fn preset_name_modal_is_unicode_editable_and_submits_the_exact_snapshot() { + let form = RunFormView::from_declarations( + "demo", + "Demo", + &[ParamDecl::new("name")], + &BTreeMap::from([("name".to_owned(), "Ada".to_owned())]), + &[], + "", + &BTreeMap::from([("夜間".to_owned(), BTreeMap::new())]), + "", + ); + let mut state = state_with_form(form); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 20); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + ); + assert!(matches!( + state.modal(), + Some(ModalState::RunPresetName { .. }) + )); + + let (_, geometry) = draw(&mut session, &state, 80, 20); + for character in "夜間".chars() { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char(character), KeyModifiers::NONE), + ); + } + assert!(matches!( + state.modal(), + Some(ModalState::RunPresetName { value, .. }) if value == "夜間" + )); + let (terminal, geometry) = draw(&mut session, &state, 80, 20); + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("Save as preset")); + assert!( + rendered.contains("This overwrites the existing preset") + && rendered.contains('夜') + && rendered.contains('間'), + "{rendered}" + ); + + let EventHandling::Action(action) = + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry) + else { + panic!("Enter did not submit the preset name"); + }; + assert_eq!( + state.update(action), + Effect::SaveRunPreset { + selector: "demo".to_owned(), + name: "夜間".to_owned(), + values: BTreeMap::from([("name".to_owned(), "Ada".to_owned())]), + secret_names: Default::default(), + } + ); +} + +#[test] +fn token_list_picker_has_keyboard_and_mouse_insertion_paths() { + let form = text_run_form("prefix ").with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: Some("/home/alice".to_owned()), env: BTreeMap::new(), today: "2026-08-08".to_owned(), now: "12-00-00".to_owned(), }, }); - let mut state = state_with_form(missing_form); - let (_, geometry) = draw(&mut session, &state, 84, 26); + let mut state = state_with_form(form); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 24); drive( &mut session, &mut state, &geometry, key(KeyCode::Char('t'), KeyModifiers::CONTROL), ); - let (_, geometry) = draw(&mut session, &state, 84, 26); + + let (terminal, geometry) = draw(&mut session, &state, 80, 24); + let row = row_containing( + terminal.backend().buffer(), + "Directory at run time (changes with where you run)", + ); + assert_eq!( + drive_click(&mut session, &mut state, &geometry, 10, row), + EventHandling::Action(Action::SetRunFieldValueAndCloseModal { + field: 0, + value: "prefix {cwd}".to_owned(), + }) + ); + assert!(state.modal().is_none()); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "prefix {cwd}" + ); + + let (_, geometry) = draw(&mut session, &state, 80, 24); drive( &mut session, &mut state, &geometry, - key(KeyCode::Enter, KeyModifiers::NONE), + key(KeyCode::Char('t'), KeyModifiers::CONTROL), ); - let (terminal, _) = draw(&mut session, &state, 84, 26); - assert!( - buffer_text(terminal.backend().buffer()) - .contains("The entry's working directory is missing — starting here instead.") + let (_, geometry) = draw(&mut session, &state, 80, 24); + assert_eq!( + session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry), + EventHandling::Action(Action::SetRunFieldValueAndCloseModal { + field: 0, + value: "prefix {cwd}{cwd}".to_owned(), + }) ); } #[test] -fn every_visible_file_picker_footer_action_has_a_key_and_mouse_twin_at_every_size_tier() { - let temp = tempfile::tempdir().unwrap(); - fs::write(temp.path().join("alpha.txt"), "alpha").unwrap(); - let contract = PathPickerState::new( - PickerPurpose::Argument, - temp.path().to_path_buf(), - PathSelectionMode::FileOrDirectory, - PathOutputPolicy::RelativeTo(temp.path().to_path_buf()), - false, - ); - let is_footer = |target: &FilePickerHit| { - matches!( - target, - FilePickerHit::Accept - | FilePickerHit::Cancel - | FilePickerHit::Up - | FilePickerHit::Hidden - ) +fn run_modal_and_global_footer_clicks_cancel_the_previous_owner() { + let form = text_run_form("prefix ").with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: Some("/home/alice".to_owned()), + env: BTreeMap::new(), + today: "2026-08-08".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = state_with_form(form); + state.update(Action::OpenRunTokenMenuFor(0)); + + let targets = |session: &mut TuiSession| { + let (terminal, geometry) = draw(session, &state, 100, 28); + let modal = buffer_position( + terminal.backend().buffer(), + "Directory at run time (changes with where you run)", + ); + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the token modal must keep one global footer action visible") + .rect; + (geometry, modal, footer) }; - let key_for = |target: &FilePickerHit| match target { + + let mut modal_first = TuiSession::default(); + let (geometry, modal, footer) = targets(&mut modal_first); + assert_eq!( + modal_first.handle_event(mouse(modal.0, modal.1), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + modal_first.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed + ); + assert!(matches!( + modal_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Action(_) + )); + assert_eq!( + modal_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), modal.0, modal.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "a late token-modal Up survived a completed global-footer click" + ); + + let mut footer_first = TuiSession::default(); + let (geometry, modal, footer) = targets(&mut footer_first); + assert_eq!( + footer_first.handle_event(mouse(footer.x, footer.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + footer_first.handle_event(mouse(modal.0, modal.1), &state, &geometry), + EventHandling::Consumed + ); + assert!(matches!( + footer_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), modal.0, modal.1), + &state, + &geometry, + ), + EventHandling::Action(Action::SetRunFieldValueAndCloseModal { field: 0, .. }) + )); + assert_eq!( + footer_first.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Ignored, + "a late global-footer Up survived the token modal's click" + ); + + let mut wheeled = TuiSession::default(); + let (geometry, modal, _) = targets(&mut wheeled); + assert_eq!( + wheeled.handle_event(mouse(modal.0, modal.1), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + wheeled.handle_event( + mouse_with_kind(MouseEventKind::ScrollDown, modal.0, modal.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let (terminal, geometry) = draw(&mut wheeled, &state, 100, 28); + let modal = buffer_position( + terminal.backend().buffer(), + "Directory at run time (changes with where you run)", + ); + assert_eq!( + wheeled.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), modal.0, modal.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "wheel input must cancel the token-modal arm" + ); +} + +#[test] +fn environment_picker_fuzzy_filters_and_accepts_keyboard_or_mouse_values() { + let form = text_run_form("").with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: Some("/home/alice".to_owned()), + env: BTreeMap::from([ + ("HOME".to_owned(), "/home/alice".to_owned()), + ("HTTP_PROXY".to_owned(), "http://proxy".to_owned()), + ("SKIT_PROFILE".to_owned(), "test".to_owned()), + ]), + today: "2026-08-08".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = state_with_form(form); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 24); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + let (_, geometry) = draw(&mut session, &state, 80, 24); + for _ in 0..5 { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Down, KeyModifiers::NONE), + ); + } + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + state.modal(), + Some(ModalState::RunEnvironmentPicker { .. }) + )); + + let (_, geometry) = draw(&mut session, &state, 80, 24); + for character in "hme".chars() { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char(character), KeyModifiers::NONE), + ); + } + assert!(matches!( + state.modal(), + Some(ModalState::RunEnvironmentPicker { visible, .. }) if visible == &["HOME".to_owned()] + )); + let (terminal, geometry) = draw(&mut session, &state, 80, 24); + let home_row = row_containing(terminal.backend().buffer(), "HOME"); + drive_click(&mut session, &mut state, &geometry, 12, home_row); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "{env:HOME}" + ); + + let (_, geometry) = draw(&mut session, &state, 80, 24); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + let (_, geometry) = draw(&mut session, &state, 80, 24); + for _ in 0..5 { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Down, KeyModifiers::NONE), + ); + } + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + let (_, geometry) = draw(&mut session, &state, 80, 24); + for character in "UNSET_NAME".chars() { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char(character), KeyModifiers::NONE), + ); + } + assert!(matches!( + state.modal(), + Some(ModalState::RunEnvironmentPicker { visible, .. }) if visible.is_empty() + )); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "{env:HOME}{env:UNSET_NAME}" + ); +} + +#[test] +fn run_file_picker_uses_the_shared_explorer_for_keyboard_mouse_and_missing_roots() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("work"); + fs::create_dir(&workdir).unwrap(); + fs::write(workdir.join("alpha.txt"), "alpha").unwrap(); + // The picked name carries a space and glob metacharacters to prove literal placement. The + // brackets are the metacharacters Python's glob.escape centers on, and Windows permits them + // where it forbids a star, so one spelling serves every host. + fs::write(workdir.join("beta file[1].txt"), "beta").unwrap(); + let mut path = ParamDecl::new("path"); + path.parameter_type = ParameterType::Path; + let form = RunFormView::from_declarations( + "files", + "Files", + &[path], + &BTreeMap::from([("path".to_owned(), "old.txt".to_owned())]), + &[], + "", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: workdir.display().to_string(), + invoke_cwd: temp.path().display().to_string(), + }), + tokens: TokenContext { + cwd: temp.path().display().to_string(), + home: None, + env: BTreeMap::new(), + today: "2026-08-08".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = state_with_form(form); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + state.modal(), + Some(ModalState::RunFilePicker { .. }) + )); + + let (terminal, geometry) = draw(&mut session, &state, 84, 26); + let rendered = buffer_text(terminal.backend().buffer()); + assert!(rendered.contains("alpha.txt"), "{rendered}"); + for character in "alpha".chars() { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char(character), KeyModifiers::NONE), + ); + } + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "alpha.txt" + ); + + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + let (terminal, geometry) = draw(&mut session, &state, 84, 26); + let beta_row = row_containing(terminal.backend().buffer(), "beta file[1].txt"); + drive_click(&mut session, &mut state, &geometry, 12, beta_row); + assert_eq!( + state.run_form().unwrap().fields()[0].control.value(), + "beta file[1].txt" + ); + + let missing = temp.path().join("gone").join("child"); + let mut path = ParamDecl::new("path"); + path.parameter_type = ParameterType::Path; + let missing_form = RunFormView::from_declarations( + "missing", + "Missing", + &[path], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: missing.display().to_string(), + invoke_cwd: temp.path().display().to_string(), + }), + tokens: TokenContext { + cwd: temp.path().display().to_string(), + home: None, + env: BTreeMap::new(), + today: "2026-08-08".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = state_with_form(missing_form); + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + let (_, geometry) = draw(&mut session, &state, 84, 26); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Enter, KeyModifiers::NONE), + ); + let (terminal, _) = draw(&mut session, &state, 84, 26); + assert!( + buffer_text(terminal.backend().buffer()) + .contains("The entry's working directory is missing — starting here instead.") + ); +} + +#[test] +fn every_visible_file_picker_footer_action_has_a_key_and_mouse_twin_at_every_size_tier() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("alpha.txt"), "alpha").unwrap(); + let contract = PathPickerState::new( + PickerPurpose::Argument, + temp.path().to_path_buf(), + PathSelectionMode::FileOrDirectory, + PathOutputPolicy::RelativeTo(temp.path().to_path_buf()), + false, + ); + let is_footer = |target: &FilePickerHit| { + matches!( + target, + FilePickerHit::Accept + | FilePickerHit::Cancel + | FilePickerHit::Up + | FilePickerHit::Hidden + ) + }; + let key_for = |target: &FilePickerHit| match target { FilePickerHit::Accept => key(KeyCode::Enter, KeyModifiers::NONE), FilePickerHit::Cancel => key(KeyCode::Esc, KeyModifiers::NONE), FilePickerHit::Up => key(KeyCode::Backspace, KeyModifiers::NONE), @@ -1129,312 +2375,1029 @@ fn every_visible_file_picker_footer_action_has_a_key_and_mouse_twin_at_every_siz _ => panic!("non-footer file-picker target: {target:?}"), }; - let mut inventory_session = FilePickerSession::new(contract.clone()); - let mut inventory_terminal = Terminal::new(TestBackend::new(200, 30)).unwrap(); - let mut inventory_geometry = Default::default(); - inventory_terminal - .draw(|frame| { - inventory_geometry = - render_file_picker(frame, frame.area(), &mut inventory_session, Locale::En); + let mut inventory_session = FilePickerSession::new(contract.clone()); + let mut inventory_terminal = Terminal::new(TestBackend::new(200, 30)).unwrap(); + let mut inventory_geometry = Default::default(); + inventory_terminal + .draw(|frame| { + inventory_geometry = + render_file_picker(frame, frame.area(), &mut inventory_session, Locale::En); + }) + .unwrap(); + let expected = inventory_geometry + .hits + .iter() + .filter(|hit| is_footer(&hit.target)) + .map(|hit| hit.target.clone()) + .collect::>(); + assert_eq!(expected.len(), 4, "the production footer inventory changed"); + + // These are the exact modal-body areas produced by 120x30, 46x12, and 24x6 terminals. + for (width, height) in [(120, 24), (46, 6), (24, 5)] { + let mut seen = Vec::new(); + for page in 0..16 { + let mut page_session = FilePickerSession::new(contract.clone()); + let mut page_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut page_geometry = Default::default(); + for step in 0..=page { + page_terminal + .draw(|frame| { + page_geometry = + render_file_picker(frame, frame.area(), &mut page_session, Locale::En); + }) + .unwrap(); + if step < page { + let footer = page_geometry + .hits + .iter() + .find(|hit| is_footer(&hit.target)) + .expect("each footer page has a visible mouse target"); + assert_eq!( + page_session.handle_event( + mouse_with_kind( + MouseEventKind::ScrollDown, + footer.area.x, + footer.area.y, + ), + &page_geometry, + ), + Some(FilePickerEvent::Changed) + ); + } + } + + for hit in page_geometry + .hits + .iter() + .filter(|hit| is_footer(&hit.target)) + { + if seen.contains(&hit.target) { + continue; + } + seen.push(hit.target.clone()); + + let mut key_session = FilePickerSession::new(contract.clone()); + let mut key_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut key_geometry = Default::default(); + key_terminal + .draw(|frame| { + key_geometry = + render_file_picker(frame, frame.area(), &mut key_session, Locale::En); + }) + .unwrap(); + let key_result = key_session.handle_event(key_for(&hit.target), &key_geometry); + + let mut mouse_session = FilePickerSession::new(contract.clone()); + let mut mouse_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut mouse_geometry = Default::default(); + for step in 0..=page { + mouse_terminal + .draw(|frame| { + mouse_geometry = render_file_picker( + frame, + frame.area(), + &mut mouse_session, + Locale::En, + ); + }) + .unwrap(); + if step < page { + let footer = mouse_geometry + .hits + .iter() + .find(|candidate| is_footer(&candidate.target)) + .unwrap(); + let _ = mouse_session.handle_event( + mouse_with_kind( + MouseEventKind::ScrollDown, + footer.area.x, + footer.area.y, + ), + &mouse_geometry, + ); + } + } + let mouse_hit = mouse_geometry + .hits + .iter() + .find(|candidate| candidate.target == hit.target) + .expect("the same typed target is visible on the same footer page"); + let mouse_result = file_click( + &mut mouse_session, + &mouse_geometry, + mouse_hit.area.x, + mouse_hit.area.y, + ); + assert_eq!( + mouse_result, key_result, + "file-picker {:?} key and mouse diverged at {width}x{height}", + hit.target + ); + } + if seen.len() == expected.len() { + break; + } + } + assert!( + seen.len() == expected.len() && expected.iter().all(|item| seen.contains(item)), + "file-picker footer dropped actions at {width}x{height}: expected={expected:?} seen={seen:?}" + ); + } +} + +#[test] +fn multiline_input_supports_selection_replacement_and_undo() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Form(FormView { + purpose: FormPurpose::Settings, + title: "Edit description".to_owned(), + title_arguments: Vec::new(), + translate_title: false, + selector: None, + fields: vec![FormField::multiline("description", "Description", "ab")], + focused: 0, + submit_label: "Save".to_owned(), + }))); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 18); + + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::End, KeyModifiers::NONE), + ); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Left, KeyModifiers::SHIFT), + ); + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('界'), KeyModifiers::NONE), + ); + assert_eq!(state.form().unwrap().fields[0].value, "a界"); + + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('z'), KeyModifiers::CONTROL), + ); + assert_eq!(state.form().unwrap().fields[0].value, "ab"); +} + +/// The run form the demo tape records: the `greet` argparse surface. +/// +/// `docs/assets/demo/scripts/en/greet.py` declares name, count, shout and names, and +/// `docs/assets/demo/shots.tape` records it at 1280x780 with JetBrains Mono 20 and padding 20. +/// Measuring the shipped frames gives a 12.19 px column and a 26.33 px row, so the terminal is +/// 101x28 cells. +fn demo_greet_form() -> RunFormView { + let mut name = ParamDecl::new("name"); + name.help = "Who to greet".to_owned(); + name.default = Some(ParameterValue::String("World".to_owned())); + + let mut count = ParamDecl::new("count"); + count.parameter_type = ParameterType::Int; + count.help = "How many times to greet".to_owned(); + count.default = Some(ParameterValue::Integer(1)); + + let mut shout = ParamDecl::new("shout"); + shout.parameter_type = ParameterType::Bool; + shout.help = "Greet in UPPERCASE".to_owned(); + shout.default = Some(ParameterValue::Bool(false)); + + let mut names = ParamDecl::new("names"); + names.parameter_type = ParameterType::Path; + names.help = "Also greet everyone in this file, one per line".to_owned(); + + RunFormView::from_declarations( + "greet", + "greet", + &[name, count, shout, names], + &BTreeMap::from([ + ("name".to_owned(), "Ada".to_owned()), + ("count".to_owned(), "3".to_owned()), + ("shout".to_owned(), "true".to_owned()), + ("names".to_owned(), "names.txt".to_owned()), + ]), + &[], + "", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/demo".to_owned(), + invoke_cwd: "/demo".to_owned(), + }), + tokens: TokenContext { + cwd: "/demo".to_owned(), + home: Some("/root".to_owned()), + env: BTreeMap::new(), + today: "2026-08-09".to_owned(), + now: "12-00-00".to_owned(), + }, + }) +} + +/// The demo terminal must show every field the demo is about, and say so when it cannot. +/// +/// Version 0.4 fits this exact form in this exact window: the label carries its own chips +/// (`src/skit/tui_form.py:190-215` builds one `Static` from label plus browse/insert/default), the +/// preset row is one line reading `Preset: …` (`:741-757`), and the body is a `VerticalScroll` +/// whose scrollbar tells the user the content continues (`:380-384`). The Rust form spent an extra +/// row per field on a second chip line plus three rows on a duplicated title, so the path field — +/// the scene the README builds around — was off screen with nothing to say so. +#[test] +fn the_demo_run_form_fits_its_recorded_terminal_and_shows_a_scroll_affordance() { + let state = state_with_form(demo_greet_form()); + let mut session = TuiSession::default(); + let (terminal, _) = draw(&mut session, &state, 101, 28); + let rendered = buffer_text(terminal.backend().buffer()); + + // The preset row keeps its label, exactly as version 0.4 writes it. + assert!( + rendered.contains("Preset: none yet"), + "no labelled preset row:\n{rendered}" + ); + // Every field the demo tape shows must be on screen. + for fact in [ + "name", + "Who to greet", + "count", + "How many times to greet", + "shout", + "Greet in UPPERCASE", + "names", + "names.txt", + "Also greet everyone in this file", + ] { + assert!(rendered.contains(fact), "missing {fact}:\n{rendered}"); + } + // The title belongs to the form panel alone, never twice. + assert_eq!( + rendered.matches("Run greet").count(), + 1, + "the title is duplicated:\n{rendered}" + ); + // Content still taller than the viewport must advertise itself. + assert!( + rendered.contains('█') || rendered.contains('▐') || rendered.contains('║'), + "no scroll affordance:\n{rendered}" + ); + + // The tail of the form is reachable: focus moves into it and the viewport follows. + let mut state = state; + let (_, geometry) = draw(&mut session, &state, 101, 28); + for _ in 1..form_field_count(&state) { + drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Tab, KeyModifiers::NONE), + ); + } + let (terminal, _) = draw(&mut session, &state, 101, 28); + let rendered = buffer_text(terminal.backend().buffer()); + assert!( + rendered.contains("Extra arguments"), + "the argument tail never comes into view:\n{rendered}" + ); +} + +fn form_field_count(state: &LibraryState) -> usize { + state.run_form().map_or(0, |form| form.fields().len()) +} + +/// A window too short for the form must still say the content continues. +#[test] +fn a_short_run_form_window_renders_a_scroll_affordance() { + let state = state_with_form(demo_greet_form()); + let mut session = TuiSession::default(); + let (terminal, _) = draw(&mut session, &state, 101, 14); + let rendered = buffer_text(terminal.backend().buffer()); + assert!( + rendered.contains('█') || rendered.contains('▐') || rendered.contains('║'), + "no scroll affordance in a short window:\n{rendered}" + ); +} + +#[test] +fn run_exact_fit_and_one_row_overflow_own_only_a_real_scrollbar_column() { + let state = state_with_form(text_run_form("abcdef")); + + // This two-field form takes ten content rows at this width. At 80x13, the Run panel has + // exactly ten inner rows. It must keep the full 78-column inner width. + let mut exact_session = TuiSession::default(); + let (exact, exact_geometry) = draw(&mut exact_session, &state, 80, 13); + assert_eq!( + ( + exact_geometry.rows.x, + exact_geometry.rows.y, + exact_geometry.rows.width, + exact_geometry.rows.height, + ), + (1, 1, 78, 10), + ); + let exact_fields = exact_geometry + .hits + .iter() + .filter(|hit| matches!(hit.action, HitTarget::FocusField(_))) + .collect::>(); + assert_eq!(exact_fields.len(), 2, "an exact fit clipped a field"); + assert!( + exact_fields + .iter() + .all(|hit| hit.rect.width == 78 && hit.rect.height == 3), + "an exact fit reserved a ghost scrollbar column: {exact_fields:?}", + ); + assert!( + exact_fields[0] + .rect + .contains(exact.backend().cursor_position()), + "the exact-fit input lost its terminal cursor", + ); + let exact_right_edge = (exact_geometry.rows.y..exact_geometry.rows.bottom()) + .map(|row| { + exact.backend().buffer()[(exact_geometry.rows.right() - 1, row)] + .symbol() + .to_owned() }) - .unwrap(); - let expected = inventory_geometry + .collect::(); + assert!( + !exact_right_edge.contains('█') + && !exact_right_edge.contains('▲') + && !exact_right_edge.contains('▼'), + "an exact fit painted a ghost scrollbar: {exact_right_edge:?}", + ); + let focused_right = exact_fields[0].rect.right() - 1; + let focused_edge = (exact_fields[0].rect.y..exact_fields[0].rect.bottom()) + .map(|row| &exact.backend().buffer()[(focused_right, row)]) + .collect::>(); + assert_eq!( + focused_edge + .iter() + .map(|cell| cell.symbol()) + .collect::>(), + vec!["┐", "│", "┘"], + "the exact-fit scrollbar call changed the focused input edge", + ); + assert!( + focused_edge.iter().all(|cell| cell.fg == ACCENT), + "the exact-fit scrollbar call patched the focused input style: {focused_edge:?}", + ); + + // One fewer terminal row leaves nine inner rows. The final three-row control is clipped by + // one row, so it must have no painted control or pointer hit. A real scrollbar owns column 78. + let mut overflow_session = TuiSession::default(); + let (overflow, overflow_geometry) = draw(&mut overflow_session, &state, 80, 12); + assert_eq!( + ( + overflow_geometry.rows.x, + overflow_geometry.rows.y, + overflow_geometry.rows.width, + overflow_geometry.rows.height, + ), + (1, 1, 77, 9), + ); + let overflow_fields = overflow_geometry .hits .iter() - .filter(|hit| is_footer(&hit.target)) - .map(|hit| hit.target.clone()) + .filter(|hit| matches!(hit.action, HitTarget::FocusField(_))) .collect::>(); - assert_eq!(expected.len(), 4, "the production footer inventory changed"); + assert_eq!( + overflow_fields.len(), + 1, + "a clipped control published a ghost pointer hit: {overflow_fields:?}", + ); + assert_eq!(overflow_fields[0].rect.width, 77); + assert!( + overflow_fields[0] + .rect + .contains(overflow.backend().cursor_position()), + "the overflowing form moved its cursor into the scrollbar column", + ); + let scrollbar = (overflow_geometry.rows.y..overflow_geometry.rows.bottom()) + .map(|row| { + overflow.backend().buffer()[(overflow_geometry.rows.right(), row)] + .symbol() + .to_owned() + }) + .collect::(); + assert!( + scrollbar.contains('█') || scrollbar.contains('▲') || scrollbar.contains('▼'), + "the reserved column has no scroll affordance: {scrollbar:?}", + ); + assert!( + (overflow_geometry.rows.y..overflow_geometry.rows.bottom()).any(|row| { + let cell = &overflow.backend().buffer()[(overflow_geometry.rows.right(), row)]; + matches!(cell.symbol(), "█" | "▲" | "▼") && cell.fg == Color::DarkGray + }), + "the scrollbar lost its muted visual role", + ); +} - // These are the exact modal-body areas produced by 120x30, 46x12, and 24x6 terminals. - for (width, height) in [(120, 24), (46, 6), (24, 5)] { - let mut seen = Vec::new(); - for page in 0..16 { - let mut page_session = FilePickerSession::new(contract.clone()); - let mut page_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - let mut page_geometry = Default::default(); - for step in 0..=page { - page_terminal - .draw(|frame| { - page_geometry = - render_file_picker(frame, frame.area(), &mut page_session, Locale::En); - }) - .unwrap(); - if step < page { - let footer = page_geometry - .hits - .iter() - .find(|hit| is_footer(&hit.target)) - .expect("each footer page has a visible mouse target"); - assert_eq!( - page_session.handle_event( - mouse_with_kind( - MouseEventKind::ScrollDown, - footer.area.x, - footer.area.y, - ), - &page_geometry, - ), - Some(FilePickerEvent::Changed) - ); +#[test] +fn run_field_chips_that_exactly_fit_stay_on_the_label_row_and_keep_hits() { + let mut source = ParamDecl::new("src"); + source.parameter_type = ParameterType::Path; + source.default = Some(ParameterValue::String("default.txt".to_owned())); + let form = RunFormView::from_declarations( + "paths", + "Paths", + &[source], + &BTreeMap::from([("src".to_owned(), "chosen.txt".to_owned())]), + &[], + "", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: None, + env: BTreeMap::new(), + today: "2026-08-28".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let state = state_with_form(form); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 47, 40); + assert_eq!(geometry.rows.width, 45); + let label_row = row_containing(terminal.backend().buffer(), "src"); + let chip_hits = geometry + .hits + .iter() + .filter(|hit| { + matches!( + hit.action, + HitTarget::RunFieldCommand { + field: 0, + command: RunFieldCommand::BrowsePath + | RunFieldCommand::InsertValue + | RunFieldCommand::ResetDefault, } - } - - for hit in page_geometry - .hits - .iter() - .filter(|hit| is_footer(&hit.target)) - { - if seen.contains(&hit.target) { - continue; + ) + }) + .collect::>(); + assert_eq!(chip_hits.len(), 3, "a field chip lost its pointer hit"); + assert!( + chip_hits + .iter() + .all(|hit| hit.rect.height == 1 && hit.rect.y == label_row), + "an exactly fitting chip wrapped off the label row: {chip_hits:?}", + ); + assert!( + chip_hits.iter().all(|hit| { + (hit.rect.x..hit.rect.right()).any(|column| { + let cell = &terminal.backend().buffer()[(column, hit.rect.y)]; + cell.fg == ACCENT && cell.bg == SELECT_BG + }) + }), + "a Run chip lost its interactive color: {chip_hits:?}" + ); + let reset = chip_hits + .iter() + .find(|hit| { + matches!( + hit.action, + HitTarget::RunFieldCommand { + command: RunFieldCommand::ResetDefault, + .. } - seen.push(hit.target.clone()); + ) + }) + .expect("reset chip hit"); + assert_eq!( + reset.rect.right(), + geometry.rows.right(), + "the exact-fit contract must exercise the right boundary", + ); - let mut key_session = FilePickerSession::new(contract.clone()); - let mut key_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - let mut key_geometry = Default::default(); - key_terminal - .draw(|frame| { - key_geometry = - render_file_picker(frame, frame.area(), &mut key_session, Locale::En); - }) - .unwrap(); - let key_result = key_session.handle_event(key_for(&hit.target), &key_geometry); + let mut overflow_session = TuiSession::default(); + let (overflow, overflow_geometry) = draw(&mut overflow_session, &state, 46, 40); + let label_row = row_containing(overflow.backend().buffer(), "src"); + let overflow_hits = overflow_geometry + .hits + .iter() + .filter(|hit| matches!(hit.action, HitTarget::RunFieldCommand { field: 0, .. })) + .collect::>(); + assert_eq!(overflow_hits.len(), 3); + assert!( + overflow_hits.iter().any(|hit| hit.rect.y == label_row) + && overflow_hits.iter().any(|hit| hit.rect.y == label_row + 1), + "one overflowing chip did not wrap after the first row: {overflow_hits:?}", + ); - let mut mouse_session = FilePickerSession::new(contract.clone()); - let mut mouse_terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - let mut mouse_geometry = Default::default(); - for step in 0..=page { - mouse_terminal - .draw(|frame| { - mouse_geometry = render_file_picker( - frame, - frame.area(), - &mut mouse_session, - Locale::En, - ); - }) - .unwrap(); - if step < page { - let footer = mouse_geometry - .hits - .iter() - .find(|candidate| is_footer(&candidate.target)) - .unwrap(); - let _ = mouse_session.handle_event( - mouse_with_kind( - MouseEventKind::ScrollDown, - footer.area.x, - footer.area.y, - ), - &mouse_geometry, - ); - } + let mut zero_session = TuiSession::default(); + let (_, zero_geometry) = draw(&mut zero_session, &state, 2, 40); + assert_eq!(zero_geometry.rows.width, 0); + assert!( + zero_geometry.hits.iter().all(|hit| !matches!( + hit.action, + HitTarget::FocusField(_) | HitTarget::RunFieldCommand { .. } + )), + "a zero-width Run row fabricated a control hit", + ); +} + +#[test] +fn a_first_run_chip_wraps_intact_after_a_long_cjk_label() { + let mut source = ParamDecl::new("source"); + source.prompt = "非常非常長的來源檔案路徑".to_owned(); + source.parameter_type = ParameterType::Path; + let form = RunFormView::from_declarations( + "cjk-path", + "CJK path", + &[source], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "python".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: None, + env: BTreeMap::new(), + today: "2026-08-28".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let state = state_with_form(form); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw_in_locale(&mut session, &state, 24, 30, Locale::ZhTw); + let rendered = visible_buffer_text(terminal.backend().buffer()); + assert!( + rendered.contains("非常非常長"), + "the CJK field label was not visible: {rendered:?}" + ); + assert!( + rendered.contains("瀏覽"), + "the CJK browse chip was not visible: {rendered:?}" + ); + let label_row = row_containing(terminal.backend().buffer(), "非常非常長"); + let browse_row = row_containing(terminal.backend().buffer(), "瀏覽"); + assert_eq!( + browse_row, + label_row + 1, + "the first chip was clipped on the long label row" + ); + let browse = geometry + .hits + .iter() + .find(|hit| { + matches!( + hit.action, + HitTarget::RunFieldCommand { + field: 0, + command: RunFieldCommand::BrowsePath, } - let mouse_hit = mouse_geometry - .hits - .iter() - .find(|candidate| candidate.target == hit.target) - .expect("the same typed target is visible on the same footer page"); - let mouse_result = mouse_session - .handle_event(mouse(mouse_hit.area.x, mouse_hit.area.y), &mouse_geometry); - assert_eq!( - mouse_result, key_result, - "file-picker {:?} key and mouse diverged at {width}x{height}", - hit.target - ); - } - if seen.len() == expected.len() { - break; - } - } - assert!( - seen.len() == expected.len() && expected.iter().all(|item| seen.contains(item)), - "file-picker footer dropped actions at {width}x{height}: expected={expected:?} seen={seen:?}" + ) + }) + .expect("browse chip hit"); + assert_eq!(browse.rect.y, browse_row); + assert!( + browse.rect.width >= 9, + "the browse hit was clipped: {browse:?}" + ); +} + +#[test] +fn preset_empty_state_appears_only_for_parameter_forms_without_saved_presets() { + let hint = "none yet — fill the form and press Ctrl+S to save one"; + + let no_parameters = RunFormView::from_declarations( + "empty", + "Empty", + &[], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ); + let mut empty_session = TuiSession::default(); + let empty = state_with_form(no_parameters); + let (terminal, _) = draw(&mut empty_session, &empty, 100, 30); + assert!( + !buffer_text(terminal.backend().buffer()).contains(hint), + "a form without parameters advertised parameter presets", + ); + + let declaration = ParamDecl::new("value"); + let presets = BTreeMap::from([( + "saved".to_owned(), + BTreeMap::from([("value".to_owned(), "preset value".to_owned())]), + )]); + let with_saved = RunFormView::from_declarations( + "saved", + "Saved", + core::slice::from_ref(&declaration), + &BTreeMap::new(), + &[], + "", + &presets, + "", + ); + let mut saved_session = TuiSession::default(); + let saved = state_with_form(with_saved); + let (terminal, _) = draw(&mut saved_session, &saved, 100, 30); + let rendered = buffer_text(terminal.backend().buffer()); + assert!( + rendered.contains("Preset"), + "the form with saved presets lost its picker label: {rendered:?}", + ); + assert!( + !rendered.contains(hint), + "a real preset picker also painted the empty-preset notice", + ); + + let without_saved = RunFormView::from_declarations( + "new", + "New", + &[declaration], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ); + let mut new_session = TuiSession::default(); + let new = state_with_form(without_saved); + let (terminal, _) = draw(&mut new_session, &new, 100, 30); + assert!(buffer_text(terminal.backend().buffer()).contains(hint)); +} + +#[test] +fn run_control_height_tracks_typed_multiline_and_packed_radio_content() { + let multiline = with_multiline_run_field(text_run_form("first\nsecond"), 0); + let mut multiline_session = TuiSession::default(); + let multiline = state_with_form(multiline); + let (_, geometry) = draw(&mut multiline_session, &multiline, 40, 40); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("multiline field hit"); + assert_eq!(field.rect.height, 6); + + let mut secret_value = serde_json::to_value(text_run_form("first\nsecond")).unwrap(); + secret_value["fields"][0]["control"]["text"]["multiline"] = true.into(); + secret_value["fields"][0]["control"]["text"]["secret"] = true.into(); + let secret_form: RunFormView = serde_json::from_value(secret_value).unwrap(); + let mut secret_session = TuiSession::default(); + let secret = state_with_form(secret_form); + let (_, geometry) = draw(&mut secret_session, &secret, 40, 40); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("secret field hit"); + assert_eq!(field.rect.height, 3, "a secret field became a textarea"); + + let state = state_with_form(form()); + let mut exact_session = TuiSession::default(); + let (_, exact) = draw(&mut exact_session, &state, 15, 40); + let radio = exact + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(3)) + .expect("radio field hit"); + assert_eq!(radio.rect.height, 1); + let mut wrapped_session = TuiSession::default(); + let (_, wrapped) = draw(&mut wrapped_session, &state, 14, 40); + let radio = wrapped + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(3)) + .expect("wrapped radio field hit"); + assert_eq!(radio.rect.height, 2); +} + +#[test] +fn a_focused_run_textarea_keeps_its_cursor_and_last_row_in_a_short_viewport() { + let value = (0..24) + .map(|index| format!("line-{index}")) + .chain(core::iter::once("TAIL".to_owned())) + .collect::>() + .join("\n"); + let form = RunFormView::from_declarations( + "textarea", + "Textarea", + &[ParamDecl::new("prefix"), ParamDecl::new("body")], + &BTreeMap::from([ + ("prefix".to_owned(), "fixed".to_owned()), + ("body".to_owned(), value), + ]), + &[], + "", + &BTreeMap::new(), + "", + ); + let form = with_multiline_run_field(form, 1); + let mut state = state_with_form(form); + state.update(Action::FocusField(1)); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 30, 6); + assert!( + geometry.rows.height < 6, + "the fixture must clip the six-row textarea: {:?}", + geometry.rows + ); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .expect("the clipped focused textarea must keep a visible hit"); + assert!( + field.rect.height > 0 && field.rect.height < 6, + "the textarea did not publish its real visible band: {field:?}" + ); + assert!( + buffer_text(terminal.backend().buffer()).contains("TAIL"), + "cursor alignment hid the textarea's last row" + ); + assert!( + (field.rect.y..field.rect.bottom()).any(|row| { + (field.rect.x..field.rect.right()).any(|column| { + let cell = &terminal.backend().buffer()[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the clipped textarea lost its visible caret cell" + ); + + for _ in 0..25 { + assert_eq!( + session.handle_event(key(KeyCode::Up, KeyModifiers::NONE), &state, &geometry,), + EventHandling::Consumed, + ); + } + let (top, top_geometry) = draw(&mut session, &state, 30, 6); + let top_field = top_geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .expect("the top-aligned textarea must keep a visible hit"); + assert!( + buffer_text(top.backend().buffer()).contains("line-0"), + "cursor movement did not realign the outer textarea band" + ); + assert!( + (top_field.rect.y..top_field.rect.bottom()).any(|row| { + (top_field.rect.x..top_field.rect.right()).any(|column| { + let cell = &top.backend().buffer()[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the top-aligned textarea lost its visible caret cell" + ); + + for _ in 0..25 { + assert_eq!( + session.handle_event( + key(KeyCode::Down, KeyModifiers::NONE), + &state, + &top_geometry, + ), + EventHandling::Consumed, ); } + let (bottom, bottom_geometry) = draw(&mut session, &state, 30, 6); + let bottom_field = bottom_geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(1)) + .expect("the bottom-aligned textarea must keep a visible hit"); + assert!( + buffer_text(bottom.backend().buffer()).contains("TAIL"), + "reverse cursor movement did not realign the outer textarea band" + ); + assert!( + (bottom_field.rect.y..bottom_field.rect.bottom()).any(|row| { + (bottom_field.rect.x..bottom_field.rect.right()).any(|column| { + let cell = &bottom.backend().buffer()[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the bottom-realigned textarea lost its visible caret cell" + ); } #[test] -fn multiline_input_supports_selection_replacement_and_undo() { +fn a_form_textarea_beyond_terminal_coordinates_renders_only_its_visible_tail() { + let mut value = "row\n".repeat(65_536); + value.push_str("VISIBLE-TAIL"); let mut state = LibraryState::default(); state.update(Action::Present(Screen::Form(FormView { purpose: FormPurpose::Settings, - title: "Edit description".to_owned(), + title: "Large form".to_owned(), title_arguments: Vec::new(), translate_title: false, selector: None, - fields: vec![FormField::multiline("description", "Description", "ab")], + fields: vec![FormField::multiline("body", "Body", value.clone())], focused: 0, submit_label: "Save".to_owned(), }))); let mut session = TuiSession::default(); - let (_, geometry) = draw(&mut session, &state, 80, 18); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::End, KeyModifiers::NONE), + let (terminal, geometry) = draw(&mut session, &state, 36, 10); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("the visible textarea band must remain clickable"); + let buffer = terminal.backend().buffer(); + + assert!( + buffer_text(buffer).contains("VISIBLE-TAIL"), + "the tail at a virtual row above u16::MAX is not visible" ); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Left, KeyModifiers::SHIFT), + assert!( + field.rect.height > 0 + && field.rect.y >= geometry.rows.y + && field.rect.bottom() <= geometry.rows.bottom(), + "the textarea published a ghost hit outside its viewport: {field:?}" ); - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char('界'), KeyModifiers::NONE), + assert!( + (field.rect.y..field.rect.bottom()).any(|row| { + (field.rect.x..field.rect.right()).any(|column| { + let cell = &buffer[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the visible tail lost its focused caret style" ); - assert_eq!(state.form().unwrap().fields[0].value, "a界"); - - drive( - &mut session, - &mut state, - &geometry, - key(KeyCode::Char('z'), KeyModifiers::CONTROL), + assert_eq!( + state.form().unwrap().fields[0].value, + value, + "rendering must not truncate the model value" ); - assert_eq!(state.form().unwrap().fields[0].value, "ab"); } -/// The run form the demo tape records: the `greet` argparse surface. -/// -/// `docs/assets/demo/scripts/en/greet.py` declares name, count, shout and names, and -/// `docs/assets/demo/shots.tape` records it at 1280x780 with JetBrains Mono 20 and padding 20. -/// Measuring the shipped frames gives a 12.19 px column and a 26.33 px row, so the terminal is -/// 101x28 cells. -fn demo_greet_form() -> RunFormView { - let mut name = ParamDecl::new("name"); - name.help = "Who to greet".to_owned(); - name.default = Some(ParameterValue::String("World".to_owned())); +#[test] +fn a_fixed_height_run_textarea_can_show_a_tail_beyond_terminal_coordinates() { + let mut value = "row\n".repeat(65_536); + value.push_str("RUN-VISIBLE-TAIL"); + let form = with_multiline_run_field(text_run_form(&value), 0); + let state = state_with_form(form); + let mut session = TuiSession::default(); - let mut count = ParamDecl::new("count"); - count.parameter_type = ParameterType::Int; - count.help = "How many times to greet".to_owned(); - count.default = Some(ParameterValue::Integer(1)); + let (terminal, geometry) = draw(&mut session, &state, 36, 10); + let field = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("the fixed-height textarea must remain clickable"); + let buffer = terminal.backend().buffer(); - let mut shout = ParamDecl::new("shout"); - shout.parameter_type = ParameterType::Bool; - shout.help = "Greet in UPPERCASE".to_owned(); - shout.default = Some(ParameterValue::Bool(false)); + assert!( + buffer_text(buffer).contains("RUN-VISIBLE-TAIL"), + "the fixed-height editor did not expose its large virtual tail" + ); + assert!( + field.rect.height > 0 + && field.rect.y >= geometry.rows.y + && field.rect.bottom() <= geometry.rows.bottom(), + "the fixed-height editor published a ghost hit: {field:?}" + ); + assert!( + (field.rect.y..field.rect.bottom()).any(|row| { + (field.rect.x..field.rect.right()).any(|column| { + let cell = &buffer[(column, row)]; + cell.fg == Color::Black && cell.bg == ACCENT + }) + }), + "the large fixed-height editor lost its focused caret style" + ); +} - let mut names = ParamDecl::new("names"); - names.parameter_type = ParameterType::Path; - names.help = "Also greet everyone in this file, one per line".to_owned(); +#[test] +fn run_radio_options_wrap_only_after_the_exact_right_boundary() { + let state = state_with_form(form()); + let mut exact_session = TuiSession::default(); + let (exact, exact_geometry) = draw(&mut exact_session, &state, 15, 40); + assert_eq!(exact_geometry.rows.width, 13); + let (_json_column, json_row) = buffer_position(exact.backend().buffer(), "json"); + let (yaml_column, yaml_row) = buffer_position(exact.backend().buffer(), "yaml"); + assert_eq!(json_row, yaml_row, "an exactly fitting option wrapped"); + let json = &exact.backend().buffer()[(_json_column, json_row)]; + assert_eq!((json.fg, json.bg), (SELECT_FG, SELECT_BG)); + assert_eq!( + yaml_column.saturating_sub(1) + 6, + exact_geometry.rows.right(), + "the exact-fit contract must exercise the right boundary", + ); + assert_eq!( + session_click( + &mut exact_session, + &state, + &exact_geometry, + yaml_column, + yaml_row, + ), + EventHandling::Action(Action::SelectFieldOption { + field: 3, + value: "yaml".to_owned(), + }), + ); - RunFormView::from_declarations( - "greet", - "greet", - &[name, count, shout, names], - &BTreeMap::from([ - ("name".to_owned(), "Ada".to_owned()), - ("count".to_owned(), "3".to_owned()), - ("shout".to_owned(), "true".to_owned()), - ("names".to_owned(), "names.txt".to_owned()), - ]), - &[], - "", - &BTreeMap::new(), - "", - ) - .with_context(RunFormContext { - entry_kind: "python".to_owned(), - path: Some(RunPathContext { - workdir: "/demo".to_owned(), - invoke_cwd: "/demo".to_owned(), + let mut wrapped_session = TuiSession::default(); + let (wrapped, wrapped_geometry) = draw(&mut wrapped_session, &state, 14, 40); + assert_eq!(wrapped_geometry.rows.width, 12); + let (_, json_row) = buffer_position(wrapped.backend().buffer(), "json"); + let (yaml_column, yaml_row) = buffer_position(wrapped.backend().buffer(), "yaml"); + assert_eq!(yaml_row, json_row + 1, "an overflowing option did not wrap"); + assert_eq!( + session_click( + &mut wrapped_session, + &state, + &wrapped_geometry, + yaml_column, + yaml_row, + ), + EventHandling::Action(Action::SelectFieldOption { + field: 3, + value: "yaml".to_owned(), }), - tokens: TokenContext { - cwd: "/demo".to_owned(), - home: Some("/root".to_owned()), - env: BTreeMap::new(), - today: "2026-08-09".to_owned(), - now: "12-00-00".to_owned(), - }, - }) + ); } -/// The demo terminal must show every field the demo is about, and say so when it cannot. -/// -/// Version 0.4 fits this exact form in this exact window: the label carries its own chips -/// (`src/skit/tui_form.py:190-215` builds one `Static` from label plus browse/insert/default), the -/// preset row is one line reading `Preset: …` (`:741-757`), and the body is a `VerticalScroll` -/// whose scrollbar tells the user the content continues (`:380-384`). The Rust form spent an extra -/// row per field on a second chip line plus three rows on a duplicated title, so the path field — -/// the scene the README builds around — was off screen with nothing to say so. #[test] -fn the_demo_run_form_fits_its_recorded_terminal_and_shows_a_scroll_affordance() { - let state = state_with_form(demo_greet_form()); +fn run_select_uses_distinct_unfocused_focused_and_open_styles() { + let mut state = state_with_form(form()); let mut session = TuiSession::default(); - let (terminal, _) = draw(&mut session, &state, 101, 28); - let rendered = buffer_text(terminal.backend().buffer()); - - // The preset row keeps its label, exactly as version 0.4 writes it. + let (unfocused, geometry) = draw(&mut session, &state, 60, 30); + let runner = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("runner picker hit") + .rect; assert!( - rendered.contains("Preset: none yet"), - "no labelled preset row:\n{rendered}" - ); - // Every field the demo tape shows must be on screen. - for fact in [ - "name", - "Who to greet", - "count", - "How many times to greet", - "shout", - "Greet in UPPERCASE", - "names", - "names.txt", - "Also greet everyone in this file", - ] { - assert!(rendered.contains(fact), "missing {fact}:\n{rendered}"); - } - // The title belongs to the form panel alone, never twice. - assert_eq!( - rendered.matches("Run greet").count(), - 1, - "the title is duplicated:\n{rendered}" + (runner.x..runner.right()).any(|column| { + let cell = &unfocused.backend().buffer()[(column, runner.y)]; + matches!(cell.symbol(), "╭" | "┌" | "─" | "╮" | "┐") && cell.fg == BOX_DIM + }), + "the unfocused picker lost its dim border" ); - // Content still taller than the viewport must advertise itself. + + state.update(Action::FocusField(0)); + let (focused, geometry) = draw(&mut session, &state, 60, 30); + let runner = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::FocusField(0)) + .expect("focused runner picker hit") + .rect; assert!( - rendered.contains('█') || rendered.contains('▐') || rendered.contains('║'), - "no scroll affordance:\n{rendered}" + (runner.x..runner.right()).any(|column| { + let cell = &focused.backend().buffer()[(column, runner.y)]; + matches!(cell.symbol(), "╭" | "┌" | "─" | "╮" | "┐") && cell.fg == ACCENT + }), + "the focused picker lost its accent border" ); - - // The tail of the form is reachable: focus moves into it and the viewport follows. - let mut state = state; - let (_, geometry) = draw(&mut session, &state, 101, 28); - for _ in 1..form_field_count(&state) { + assert_eq!( drive( &mut session, &mut state, &geometry, - key(KeyCode::Tab, KeyModifiers::NONE), - ); - } - let (terminal, _) = draw(&mut session, &state, 101, 28); - let rendered = buffer_text(terminal.backend().buffer()); - assert!( - rendered.contains("Extra arguments"), - "the argument tail never comes into view:\n{rendered}" + key(KeyCode::Enter, KeyModifiers::NONE), + ), + EventHandling::Consumed ); -} - -fn form_field_count(state: &LibraryState) -> usize { - state.run_form().map_or(0, |form| form.fields().len()) -} - -/// A window too short for the form must still say the content continues. -#[test] -fn a_short_run_form_window_renders_a_scroll_affordance() { - let state = state_with_form(demo_greet_form()); - let mut session = TuiSession::default(); - let (terminal, _) = draw(&mut session, &state, 101, 14); - let rendered = buffer_text(terminal.backend().buffer()); + let (open, _) = draw(&mut session, &state, 60, 30); + let dropdown_corner = &open.backend().buffer()[(runner.x, runner.bottom())]; + assert_eq!(dropdown_corner.symbol(), "┌"); + assert_eq!(dropdown_corner.fg, ACCENT); + let highlighted = &open.backend().buffer()[(runner.x + 1, runner.bottom() + 1)]; + assert_eq!((highlighted.fg, highlighted.bg), (SELECT_FG, SELECT_BG)); assert!( - rendered.contains('█') || rendered.contains('▐') || rendered.contains('║'), - "no scroll affordance in a short window:\n{rendered}" + open.backend() + .buffer() + .content() + .iter() + .any(|cell| cell.fg == SELECT_FG && cell.bg == SELECT_BG), + "the open picker lost its highlighted option" ); } @@ -1615,7 +3578,7 @@ fn central_session_run_control_event_matrix_keeps_widget_priority() { &geometry, ); for hit in &geometry.hits { - let _ = session.handle_event(mouse(hit.rect.x, hit.rect.y), &state, &geometry); + let _ = session_click(&mut session, &state, &geometry, hit.rect.x, hit.rect.y); } } @@ -1660,7 +3623,7 @@ fn central_session_picker_textarea_and_generic_form_matrix_uses_public_screens() } let (_, open) = draw(&mut session, &state, 54, 16); for hit in &open.hits { - let _ = session.handle_event(mouse(hit.rect.x, hit.rect.y), &state, &open); + let _ = session_click(&mut session, &state, &open, hit.rect.x, hit.rect.y); } state.update(Action::FocusField(0)); let (_, picker_geometry) = draw(&mut session, &state, 54, 16); @@ -1691,6 +3654,15 @@ fn central_session_picker_textarea_and_generic_form_matrix_uses_public_screens() ), EventHandling::Consumed ); + let _ = session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + picker_hit.rect.x, + picker_hit.rect.y, + ), + &state, + &picker_geometry, + ); assert!(buffer_text(terminal.backend().buffer()).contains('a')); state.update(Action::FocusField(1)); let (_, textarea_geometry) = draw(&mut session, &state, 54, 16); @@ -1770,6 +3742,15 @@ fn central_session_picker_textarea_and_generic_form_matrix_uses_public_screens() } for hit in &generic_geometry.hits { let _ = session.handle_event(mouse(hit.rect.x, hit.rect.y), &generic, &generic_geometry); + let _ = session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + hit.rect.x, + hit.rect.y, + ), + &generic, + &generic_geometry, + ); } generic.update(Action::FocusField(1)); let (_, generic_textarea_geometry) = draw(&mut session, &generic, 24, 8); @@ -1933,7 +3914,7 @@ fn central_session_serialized_run_contract_covers_notes_validation_and_tiny_layo } let (radio_x, radio_y) = buffer_position(terminal.backend().buffer(), "first-long-label"); assert!(matches!( - session.handle_event(mouse(radio_x, radio_y), &state, &geometry), + session_click(&mut session, &state, &geometry, radio_x, radio_y), EventHandling::Action(Action::SelectFieldOption { .. }) )); assert_eq!( diff --git a/crates/skit-tui/tests/interactive_search.rs b/crates/skit-tui/tests/interactive_search.rs index b8d0cff0..668b5983 100644 --- a/crates/skit-tui/tests/interactive_search.rs +++ b/crates/skit-tui/tests/interactive_search.rs @@ -5,19 +5,26 @@ use ratatui_crossterm::crossterm::event::{ use skit_application::LibraryScan; use skit_domain::{EntryKind, EntrySummary, Slug, StorageMode}; use skit_i18n::Locale; -use skit_tui::{EventHandling, TuiSession, ViewGeometry, render_with_session}; -use skit_ui::{Action, LibraryState}; +use skit_tui::{EventHandling, HitTarget, TuiSession, ViewGeometry, render_with_session}; +use skit_ui::{Action, Effect, HostRequest, InputMode, LibraryState, UiCommand}; fn state() -> LibraryState { + state_with_names(&["Unicode"]) +} + +fn state_with_names(names: &[&str]) -> LibraryState { LibraryState::from_scan(LibraryScan { - entries: vec![EntrySummary { - slug: Slug::parse("unicode").unwrap(), - name: "Unicode".to_owned(), - kind: EntryKind::parse("python").unwrap(), - mode: StorageMode::Copy, - description: String::new(), - target: None, - }], + entries: names + .iter() + .map(|name| EntrySummary { + slug: Slug::parse(name.to_lowercase()).unwrap(), + name: (*name).to_owned(), + kind: EntryKind::parse("python").unwrap(), + mode: StorageMode::Copy, + description: String::new(), + target: None, + }) + .collect(), diagnostics: Vec::new(), }) } @@ -50,6 +57,124 @@ fn drive( handling } +fn mouse(button: MouseButton, column: u16, row: u16) -> Event { + mouse_kind(MouseEventKind::Down(button), column, row) +} + +fn mouse_kind(kind: MouseEventKind, column: u16, row: u16) -> Event { + Event::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) +} + +#[test] +fn global_footer_activates_only_after_a_primary_press_and_release() { + let state = state(); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let search = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::Command(UiCommand::Search)) + .expect("Search is visible in the global footer") + .rect; + + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), search.x, search.y,), + &state, + &geometry, + ), + EventHandling::Consumed, + "primary Down must arm the footer without activating it" + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), search.x, search.y,), + &state, + &geometry, + ), + EventHandling::Action(Action::BeginSearch) + ); + + let mut cancelled = TuiSession::default(); + let (_, cancel_geometry) = draw(&mut cancelled, &state); + let search = cancel_geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::Command(UiCommand::Search)) + .unwrap() + .rect; + let outside = ( + cancel_geometry.rows.x.saturating_sub(1), + cancel_geometry.rows.y, + ); + assert!(!cancel_geometry.rows.contains(outside.into())); + assert!( + cancel_geometry + .hits + .iter() + .all(|hit| !hit.rect.contains(outside.into())), + "the cancellation probe must not name another top-level target" + ); + assert_eq!( + cancelled.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), search.x, search.y,), + &state, + &cancel_geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + cancelled.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), outside.0, outside.1), + &state, + &cancel_geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + cancelled.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), search.x, search.y,), + &state, + &cancel_geometry, + ), + EventHandling::Ignored, + "release outside must cancel the armed footer target" + ); + for button in [MouseButton::Right, MouseButton::Middle] { + for kind in [MouseEventKind::Down(button), MouseEventKind::Up(button)] { + assert_eq!( + cancelled.handle_event( + mouse_kind(kind, search.x, search.y), + &state, + &cancel_geometry, + ), + EventHandling::Ignored, + "{button:?} must never arm or activate the footer" + ); + } + } +} + +fn buffer_position(terminal: &Terminal, needle: &str) -> (u16, u16) { + let buffer = terminal.backend().buffer(); + for row in 0..buffer.area.height { + for column in 0..buffer.area.width { + let tail = (column..buffer.area.width) + .map(|x| buffer[(x, row)].symbol()) + .collect::(); + if tail.starts_with(needle) { + return (column, row); + } + } + } + panic!("missing {needle:?}"); +} + #[test] fn search_uses_grapheme_editing_paste_navigation_and_a_real_cursor() { let mut state = state(); @@ -95,6 +220,30 @@ fn search_uses_grapheme_editing_paste_navigation_and_a_real_cursor() { ); } +#[test] +fn search_ignores_key_release_events() { + let mut state = state(); + state.update(Action::BeginSearch); + state.update(Action::SetSearchQuery("abc".to_owned())); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let released = Event::Key(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + ratatui_crossterm::crossterm::event::KeyEventKind::Release, + )); + + assert_eq!( + session.handle_event(released, &state, &geometry), + EventHandling::Ignored + ); + assert_eq!(state.query(), "abc"); + assert_eq!( + session.handle_event(key(KeyCode::Esc, KeyModifiers::NONE), &state, &geometry), + EventHandling::Action(Action::FinishSearch) + ); +} + #[test] fn clicking_the_search_header_enters_search_mode() { let state = state(); @@ -111,10 +260,538 @@ fn clicking_the_search_header_enters_search_mode() { &state, &geometry, ), + EventHandling::Consumed, + "browse-search Down must only arm the semantic input target" + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 1, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), EventHandling::Action(Action::BeginSearch) ); } +#[test] +fn a_new_frame_drops_the_previous_search_header_hit() { + let browse = state(); + let mut session = TuiSession::default(); + let _ = draw(&mut session, &browse); + + let mut help = browse.clone(); + help.update(Action::OpenHelp); + let (_, geometry) = draw(&mut session, &help); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + assert_eq!( + session.handle_event(mouse_kind(kind, 2, 1), &help, &geometry), + EventHandling::Ignored, + "the Library Search hit survived into the Help frame" + ); + } +} + +#[test] +fn global_hits_exclude_their_right_and_bottom_edges() { + let state = state(); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let contains = |rect: ratatui_core::layout::Rect, column, row| { + column >= rect.x && column < rect.right() && row >= rect.y && row < rect.bottom() + }; + let edge_without_another_hit = |right: bool| { + geometry.hits.iter().find_map(|hit| { + let point = if right { + (hit.rect.right(), hit.rect.y) + } else { + (hit.rect.x, hit.rect.bottom()) + }; + (point.0 < 80 + && point.1 < 20 + && !geometry + .hits + .iter() + .any(|candidate| contains(candidate.rect, point.0, point.1))) + .then_some(point) + }) + }; + + for (label, point) in [ + ( + "right", + edge_without_another_hit(true).expect("a footer hit has a free right edge"), + ), + ( + "bottom", + edge_without_another_hit(false).expect("a footer hit has a free bottom edge"), + ), + ] { + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), point.0, point.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "the exclusive {label} edge armed a global hit" + ); + } +} + +#[test] +fn clicking_after_b_moves_the_search_cursor_before_typing() { + let mut state = state(); + state.update(Action::BeginSearch); + state.update(Action::SetSearchQuery("abcdef".to_owned())); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state); + let (start, row) = buffer_position(&terminal, "abcdef"); + + assert_eq!( + session.handle_event(mouse(MouseButton::Left, start + 2, row), &state, &geometry), + EventHandling::Consumed, + "a click inside the active search input must move its cursor" + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), start + 2, row), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let _ = drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!(state.query(), "abXcdef"); +} + +#[test] +fn nonprimary_search_presses_do_not_move_the_caret() { + for button in [MouseButton::Right, MouseButton::Middle] { + let mut state = state(); + state.update(Action::BeginSearch); + state.update(Action::SetSearchQuery("abcdef".to_owned())); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state); + let (start, row) = buffer_position(&terminal, "abcdef"); + + assert_eq!( + session.handle_event(mouse(button, start + 2, row), &state, &geometry), + EventHandling::Ignored + ); + let _ = drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!(state.query(), "abcdefX", "{button:?} moved the caret"); + } +} + +#[test] +fn resize_cancels_active_search_arm_but_keeps_the_placed_caret() { + let mut state = state(); + state.update(Action::BeginSearch); + state.update(Action::SetSearchQuery("abcdef".to_owned())); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state); + let (start, row) = buffer_position(&terminal, "abcdef"); + + assert_eq!( + session.handle_event(mouse(MouseButton::Left, start + 2, row), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event(Event::Resize(79, 19), &state, &geometry), + EventHandling::Ignored + ); + let (terminal, resized) = draw(&mut session, &state); + let (start, row) = buffer_position(&terminal, "abcdef"); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), start + 2, row), + &state, + &resized, + ), + EventHandling::Ignored, + "a resize must cancel the Search arm from the old geometry" + ); + let handling = drive( + &mut session, + &mut state, + &resized, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!( + handling, + EventHandling::Action(Action::SetSearchQuery("abXcdef".to_owned())) + ); + assert_eq!(state.query(), "abXcdef"); +} + +#[test] +fn clicking_browse_search_places_the_cursor_before_entering_search() { + let mut state = state(); + state.update(Action::BeginSearch); + state.update(Action::SetSearchQuery("abcdef".to_owned())); + state.update(Action::FinishSearch); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state); + let (start, row) = buffer_position(&terminal, "abcdef"); + + assert_eq!( + session.handle_event(mouse(MouseButton::Left, start + 2, row), &state, &geometry), + EventHandling::Consumed + ); + let handling = session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), start + 2, row), + &state, + &geometry, + ); + assert_eq!(handling, EventHandling::Action(Action::BeginSearch)); + let EventHandling::Action(action) = handling else { + unreachable!(); + }; + state.update(action); + let _ = drive( + &mut session, + &mut state, + &geometry, + key(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert_eq!(state.query(), "abXcdef"); +} + +#[test] +fn invalid_row_selection_does_not_leave_search() { + let mut state = state_with_names(&["Alpha", "Beta"]); + state.update(Action::BeginSearch); + state.update(Action::SelectVisible(usize::MAX)); + assert_eq!(state.input_mode(), InputMode::Search); +} + +#[test] +fn library_rows_select_then_activate_and_leave_search_mode() { + let mut state = state_with_names(&["Alpha", "Beta", "Gamma"]); + state.update(Action::BeginSearch); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let second_row = geometry.rows.y.saturating_add(1); + assert_eq!( + session.handle_event( + mouse_kind( + MouseEventKind::Down(MouseButton::Left), + geometry.rows.x, + second_row, + ), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let select = session.handle_event( + mouse_kind( + MouseEventKind::Up(MouseButton::Left), + geometry.rows.x, + second_row, + ), + &state, + &geometry, + ); + assert_eq!(select, EventHandling::Action(Action::SelectVisible(1))); + let EventHandling::Action(select) = select else { + unreachable!(); + }; + assert_eq!(state.update(select), Effect::None); + assert_eq!( + state.selected().map(|entry| entry.name.as_str()), + Some("Beta") + ); + assert_eq!( + state.input_mode(), + InputMode::Browse, + "a row click must leave Search so the next click can activate it" + ); + + let (_, geometry) = draw(&mut session, &state); + assert_eq!( + session.handle_event( + mouse_kind( + MouseEventKind::Down(MouseButton::Left), + geometry.rows.x, + second_row, + ), + &state, + &geometry, + ), + EventHandling::Consumed + ); + let open = session.handle_event( + mouse_kind( + MouseEventKind::Up(MouseButton::Left), + geometry.rows.x, + second_row, + ), + &state, + &geometry, + ); + assert_eq!(open, EventHandling::Action(Action::OpenRun)); + let EventHandling::Action(open) = open else { + unreachable!(); + }; + assert_eq!( + state.update(open), + Effect::Open { + request: HostRequest::Run, + selector: Some("beta".to_owned()), + } + ); + + let boot = state_with_names(&["Alpha", "Beta"]); + let mut boot_session = TuiSession::default(); + let (_, geometry) = draw(&mut boot_session, &boot); + assert_eq!( + boot_session.handle_event( + mouse_kind( + MouseEventKind::Down(MouseButton::Left), + geometry.rows.x, + geometry.rows.y, + ), + &boot, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + boot_session.handle_event( + mouse_kind( + MouseEventKind::Up(MouseButton::Left), + geometry.rows.x, + geometry.rows.y, + ), + &boot, + &geometry, + ), + EventHandling::Action(Action::OpenRun), + "the boot-selected row must activate on its first click" + ); +} + +#[test] +fn right_and_middle_clicks_do_not_activate_search_rows_or_global_footer() { + let state = state_with_names(&["Alpha", "Beta"]); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the global footer has a visible command") + .rect; + for button in [MouseButton::Right, MouseButton::Middle] { + for (column, row, owner) in [ + (2, 1, "search"), + (geometry.rows.x, geometry.rows.y, "Library row"), + (footer.x, footer.y, "global footer"), + ] { + assert_eq!( + session.handle_event(mouse(button, column, row), &state, &geometry), + EventHandling::Ignored, + "{owner} accepted {button:?}" + ); + } + } +} + +#[test] +fn library_rows_cancel_mismatched_outside_and_nonprimary_click_sequences() { + let state = state_with_names(&["Alpha", "Beta"]); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let first = (geometry.rows.x, geometry.rows.y); + let second = (geometry.rows.x, geometry.rows.y.saturating_add(1)); + + for (release, label) in [(second, "a different row"), ((0, 0), "outside the rows")] { + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), release.0, release.1,), + &state, + &geometry, + ), + EventHandling::Ignored, + "release over {label} activated the pressed row" + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "a cancelled Library row accepted a later release" + ); + } + + for button in [MouseButton::Right, MouseButton::Middle] { + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(button), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "{button:?} did not cancel the armed Library row" + ); + } + + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Action(Action::OpenRun) + ); +} + +#[test] +fn library_wheel_is_contained_and_rows_require_matching_up() { + let state = state_with_names(&["Alpha", "Beta"]); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), + EventHandling::Ignored, + "a wheel outside the Library rows escaped its owner" + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: geometry.rows.x, + row: geometry.rows.y, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), + EventHandling::Action(Action::Next) + ); + + let second_row = geometry.rows.y.saturating_add(1); + assert_eq!( + session.handle_event( + mouse(MouseButton::Left, geometry.rows.x, second_row), + &state, + &geometry, + ), + EventHandling::Consumed, + "Down must arm the Library row without selecting it" + ); + assert_eq!( + session.handle_event( + mouse_kind( + MouseEventKind::Up(MouseButton::Left), + geometry.rows.x, + second_row, + ), + &state, + &geometry, + ), + EventHandling::Action(Action::SelectVisible(1)), + "matching Up must select the armed Library row" + ); +} + +#[test] +fn library_wheel_cancels_an_armed_row_before_it_moves_selection() { + let mut state = state_with_names(&["Alpha", "Beta"]); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state); + let first = (geometry.rows.x, geometry.rows.y); + + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Down(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + drive( + &mut session, + &mut state, + &geometry, + mouse_kind(MouseEventKind::ScrollDown, first.0, first.1), + ), + EventHandling::Action(Action::Next) + ); + assert_eq!( + session.handle_event( + mouse_kind(MouseEventKind::Up(MouseButton::Left), first.0, first.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "wheel input must cancel the earlier row press" + ); +} + #[test] fn ctrl_c_requires_a_second_press_and_uses_a_transient_mature_toast() { for searching in [false, true] { diff --git a/crates/skit-tui/tests/main_library_surface.rs b/crates/skit-tui/tests/main_library_surface.rs index 11a78423..e5774dbf 100644 --- a/crates/skit-tui/tests/main_library_surface.rs +++ b/crates/skit-tui/tests/main_library_surface.rs @@ -114,6 +114,21 @@ fn library_uses_main_kind_badges_localized_columns_and_reference_marker() { "Shell entry", Some("/tmp/linked.sh"), ), + entry("fish", "Fish entry", "fish", StorageMode::Copy, "", None), + entry("js", "JS entry", "js", StorageMode::Copy, "", None), + entry("ts", "TS entry", "ts", StorageMode::Copy, "", None), + entry( + "powershell", + "PowerShell entry", + "powershell", + StorageMode::Copy, + "", + None, + ), + entry("ruby", "Ruby entry", "ruby", StorageMode::Copy, "", None), + entry("perl", "Perl entry", "perl", StorageMode::Copy, "", None), + entry("lua", "Lua entry", "lua", StorageMode::Copy, "", None), + entry("r", "R entry", "r", StorageMode::Copy, "", None), entry( "command", "Command entry", @@ -130,6 +145,14 @@ fn library_uses_main_kind_badges_localized_columns_and_reference_marker() { "Program entry", None, ), + entry( + "prompt", + "Prompt entry", + "prompt", + StorageMode::Copy, + "Prompt entry", + None, + ), entry( "future", "Future entry", @@ -146,9 +169,18 @@ fn library_uses_main_kind_badges_localized_columns_and_reference_marker() { assert!(text.contains("Kind"), "{text}"); assert!(text.contains("⬡ Python"), "{text}"); assert!(text.contains("# Shell ↗"), "{text}"); + assert!(text.contains("∿ fish"), "{text}"); + assert!(text.contains("✦ JavaScript"), "{text}"); + assert!(text.contains("✧ TypeScript"), "{text}"); + assert!(text.contains("» PowerShell"), "{text}"); + assert!(text.contains("◆ Ruby"), "{text}"); + assert!(text.contains("◈ Perl"), "{text}"); + assert!(text.contains("○ Lua"), "{text}"); + assert!(text.contains("◇ R"), "{text}"); assert!(text.contains("$ Command"), "{text}"); assert!(!text.contains("$ Command ↗"), "{text}"); assert!(text.contains("▶ Program"), "{text}"); + assert!(text.contains("✎ Prompt"), "{text}"); assert!(text.contains("? future-kind"), "{text}"); assert!(!text.contains("? future-kind ↗"), "{text}"); @@ -529,6 +561,20 @@ fn library_detail_uses_mature_keyboard_and_mouse_scrolling_after_pointer_focus() session.handle_event(detail_click, &view, &geometry), EventHandling::Consumed ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 75, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed, + "the detail pane must take focus only after a matching release" + ); assert_eq!( session.handle_event( Event::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)), @@ -537,10 +583,19 @@ fn library_detail_uses_mature_keyboard_and_mouse_scrolling_after_pointer_focus() ), EventHandling::Consumed ); - let (at_bottom, geometry) = draw_with_session(&view, &mut session, 100, 18); + let (at_bottom, _geometry) = draw_with_session(&view, &mut session, 100, 18); let at_bottom_text = lines(at_bottom.backend().buffer()).join("\n"); assert!(at_bottom_text.contains("Not run yet"), "{at_bottom_text}"); + let (_, hidden) = draw_with_session(&view, &mut session, 1, 12); + assert!(!hidden.detail_pane_visible); + let (restored, geometry) = draw_with_session(&view, &mut session, 100, 18); + let restored_text = lines(restored.backend().buffer()).join("\n"); + assert!( + restored_text.contains("Not run yet"), + "a zero-width detail pane reset its preserved scroll position: {restored_text}" + ); + assert_eq!( session.handle_event( Event::Mouse(MouseEvent { @@ -586,7 +641,7 @@ fn library_detail_uses_mature_keyboard_and_mouse_scrolling_after_pointer_focus() EventHandling::Ignored ); } - assert!(matches!( + assert_eq!( session.handle_event( Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -597,6 +652,19 @@ fn library_detail_uses_mature_keyboard_and_mouse_scrolling_after_pointer_focus() &view, &geometry, ), + EventHandling::Consumed + ); + assert!(matches!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), EventHandling::Action(Action::SelectVisible(_)) )); assert!(matches!( @@ -612,6 +680,334 @@ fn library_detail_uses_mature_keyboard_and_mouse_scrolling_after_pointer_focus() ), EventHandling::Action(Action::Next) )); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 2, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Action(Action::Previous) + ); +} + +#[test] +fn selecting_another_entry_resets_the_detail_viewport_to_its_top() { + let alpha = entry( + "alpha", + "Alpha", + "python", + StorageMode::Copy, + &format!("ALPHA_TOP {}", "alpha words ".repeat(80)), + None, + ); + let beta = entry( + "beta", + "Beta", + "python", + StorageMode::Copy, + &format!("BETA_TOP {}", "beta words ".repeat(80)), + None, + ); + let mut view = LibraryState::from_surface( + LibraryScan { + entries: vec![alpha.clone(), beta.clone()], + diagnostics: Vec::new(), + }, + BTreeMap::from([ + (alpha.slug, LibraryEntryDetail::default()), + (beta.slug, LibraryEntryDetail::default()), + ]), + ); + let mut session = TuiSession::default(); + let (_, geometry) = draw_with_session(&view, &mut session, 100, 18); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind, + column: 75, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + } + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Consumed + ); + let (bottom, _) = draw_with_session(&view, &mut session, 100, 18); + assert!( + lines(bottom.backend().buffer()) + .join("\n") + .contains("Not run yet") + ); + + view.update(Action::Next); + assert_eq!( + view.selected().map(|entry| entry.name.as_str()), + Some("Beta") + ); + let (selected, _) = draw_with_session(&view, &mut session, 100, 18); + let selected_text = lines(selected.backend().buffer()).join("\n"); + assert!( + selected_text.contains("BETA_TOP"), + "a new detail kept the previous entry's bottom offset: {selected_text}" + ); + assert!( + !selected_text.contains("Not run yet"), + "a new detail did not return to its first viewport: {selected_text}" + ); +} + +#[test] +fn library_detail_focus_requires_a_matching_primary_release() { + let view = state(vec![ + entry( + "alpha", + "Alpha", + "python", + StorageMode::Copy, + "Alpha detail", + None, + ), + entry( + "beta", + "Beta", + "python", + StorageMode::Copy, + "Beta detail", + None, + ), + ]); + let detail = (75, 7); + + for (release, label) in [ + ((2, 7), "the Library list"), + ((0, 0), "outside the Library body"), + ] { + let mut session = TuiSession::default(); + let (_, geometry) = draw_with_session(&view, &mut session, 100, 18); + assert!(geometry.detail_pane_visible); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: release.0, + row: release.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Ignored, + "release over {label} activated detail focus" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Action(Action::Next), + "a cancelled detail click stole Library-list keyboard focus" + ); + } + + for button in [MouseButton::Right, MouseButton::Middle] { + let mut session = TuiSession::default(); + let (_, geometry) = draw_with_session(&view, &mut session, 100, 18); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(button), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Ignored, + "{button:?} did not cancel the armed detail focus" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Action(Action::Next) + ); + } + + let mut session = TuiSession::default(); + let (_, geometry) = draw_with_session(&view, &mut session, 100, 18); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: detail.0, + row: detail.1, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Action(Action::OpenRun), + "the focused detail viewport must leave Enter for Library activation" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Ignored, + "an unrelated key must pass through the focused detail viewport" + ); + assert_eq!( + session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + &view, + &geometry, + ), + EventHandling::Consumed, + "a same-target detail click did not transfer keyboard focus" + ); +} + +#[test] +fn hidden_library_detail_releases_keyboard_focus_after_resize() { + let mut view = state(vec![ + entry("alpha", "Alpha", "python", StorageMode::Copy, "Alpha", None), + entry("beta", "Beta", "python", StorageMode::Copy, "Beta", None), + ]); + let mut session = TuiSession::default(); + let (_, geometry) = draw_with_session(&view, &mut session, 100, 18); + assert!(geometry.detail_pane_visible); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 75, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 75, + row: 7, + modifiers: KeyModifiers::NONE, + }), + &view, + &geometry, + ), + EventHandling::Consumed, + "the fixture must focus the visible detail before it hides" + ); + + let (_, hidden) = draw_with_session(&view, &mut session, 46, 12); + assert!(!hidden.detail_pane_visible); + let handling = session.handle_event( + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + &view, + &hidden, + ); + assert_eq!( + handling, + EventHandling::Action(Action::Next), + "a hidden detail pane must not consume Library navigation" + ); + if let EventHandling::Action(action) = handling { + view.update(action); + } + assert_eq!( + view.selected().map(|entry| entry.name.as_str()), + Some("Beta") + ); } #[test] @@ -661,6 +1057,34 @@ fn library_detail_renders_overflow_parameters_and_every_last_run_age_and_exit_sh "{rendered}" ); + let exact_parameters = (0..6) + .map(|index| LibraryParameterDetail { + key: format!("p{index}"), + value: index.to_string(), + secret: false, + }) + .collect(); + let exact = LibraryState::from_surface( + LibraryScan { + entries: vec![item.clone()], + diagnostics: Vec::new(), + }, + BTreeMap::from([( + item.slug.clone(), + LibraryEntryDetail { + parameters: exact_parameters, + ..LibraryEntryDetail::default() + }, + )]), + ); + let exact_lines = lines(draw(&exact, 220, 40, Locale::En).backend().buffer()); + let (_, parameter_line) = line_with(&exact_lines, "Parameters"); + assert!(parameter_line.contains("p5=5"), "{parameter_line}"); + assert!( + !parameter_line.contains('…'), + "exactly six parameters advertised hidden content: {parameter_line}" + ); + for (age, expected) in [ (LibraryRunAge::Raw("earlier".to_owned()), "earlier"), (LibraryRunAge::JustNow, "just now"), diff --git a/crates/skit-tui/tests/management_workflow.rs b/crates/skit-tui/tests/management_workflow.rs index 1a998cec..e8dc5061 100644 --- a/crates/skit-tui/tests/management_workflow.rs +++ b/crates/skit-tui/tests/management_workflow.rs @@ -1,18 +1,28 @@ use ratatui_core::{backend::TestBackend, buffer::Buffer, terminal::Terminal}; use ratatui_crossterm::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use skit_i18n::Locale; use skit_tui::{EventHandling, TuiSession, ViewGeometry, render_with_session}; use skit_ui::{ - Action, HealthAction, HealthIssue, HealthIssueKind, HealthSnapshot, HealthView, LibraryState, - MirrorHealth, ModalState, RunFormView, RunnerEditorAction, RunnerEditorOwner, RunnerEditorView, + Action, HealthAction, HealthIssue, HealthIssueKind, HealthRebuildOutcome, HealthSnapshot, + HealthView, LibraryState, MirrorHealth, ModalState, ReportItem, ReportView, RunFormView, + RunnerEditorAction, RunnerEditorField, RunnerEditorOwner, RunnerEditorView, RunnerManagerAction, RunnerManagerView, RunnerRow, RunnerRowIdentity, Screen, UvHealth, }; use std::collections::BTreeMap; fn draw(session: &mut TuiSession, state: &LibraryState) -> (Terminal, ViewGeometry) { - let mut terminal = Terminal::new(TestBackend::new(88, 30)).unwrap(); + draw_sized(session, state, 88, 30) +} + +fn draw_sized( + session: &mut TuiSession, + state: &LibraryState, + width: u16, + height: u16, +) -> (Terminal, ViewGeometry) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); let mut geometry = ViewGeometry::default(); terminal .draw(|frame| { @@ -22,6 +32,105 @@ fn draw(session: &mut TuiSession, state: &LibraryState) -> (Terminal String { buffer.content().iter().map(|cell| cell.symbol()).collect() } @@ -44,9 +153,13 @@ fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { Event::Key(KeyEvent::new(code, modifiers)) } -fn mouse(column: u16, row: u16) -> Event { +fn key_with_kind(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Event { + Event::Key(KeyEvent::new_with_kind(code, modifiers, kind)) +} + +fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind, column, row, modifiers: KeyModifiers::NONE, @@ -86,6 +199,32 @@ fn runner(name: &str) -> RunnerRow { } } +#[test] +fn health_uses_singular_copy_only_for_exactly_one_entry() { + for (count, registered, rebuilt) in [ + (1, "1 entry registered", "Index rebuilt: 1 entry"), + (2, "2 entries registered", "Index rebuilt: 2 entries"), + ] { + let mut snapshot = health().snapshot().clone(); + snapshot.entry_count = count; + let mut view = HealthView::new(snapshot.clone()); + view.reduce(HealthAction::Rebuilt { + snapshot: Box::new(snapshot), + outcome: HealthRebuildOutcome { + entry_count: count, + problems: Vec::new(), + }, + }); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Health(Box::new(view)))); + let mut session = TuiSession::default(); + let (terminal, _) = draw(&mut session, &state); + let screen = rendered_text(terminal.backend().buffer()); + assert!(screen.contains(registered), "{screen}"); + assert!(screen.contains(rebuilt), "{screen}"); + } +} + #[test] fn health_screen_routes_every_advertised_keyboard_and_mouse_action() { let mut state = LibraryState::default(); @@ -106,19 +245,56 @@ fn health_screen_routes_every_advertised_keyboard_and_mouse_action() { ), EventHandling::Action(Action::Health(HealthAction::Rebuild)) ); + for event in [ + key(KeyCode::Char('r'), KeyModifiers::NONE), + key(KeyCode::Char('x'), KeyModifiers::CONTROL), + key_with_kind( + KeyCode::Char('r'), + KeyModifiers::CONTROL, + KeyEventKind::Release, + ), + ] { + assert_eq!( + session.handle_event(event, &state, &geometry), + EventHandling::Ignored, + "only a pressed Ctrl+R may rebuild Health" + ); + } assert_eq!( session.handle_event(key(KeyCode::Enter, KeyModifiers::NONE), &state, &geometry), EventHandling::Action(Action::Health(HealthAction::Jump)) ); let (x, y) = cell_position(buffer, "Ctrl+R Rebuild index"); assert_eq!( - session.handle_event(mouse(x, y), &state, &geometry), + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), x, y), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), x, y), + &state, + &geometry, + ), EventHandling::Action(Action::Health(HealthAction::Rebuild)) ); assert_eq!( session.handle_event(key(KeyCode::Null, KeyModifiers::NONE), &state, &geometry), EventHandling::Ignored ); + let (issue_x, issue_y) = cell_position(buffer, "Missing"); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Moved, issue_x, issue_y), + &state, + &geometry, + ), + EventHandling::Ignored, + "pointer motion over an issue must not move the Health selection" + ); let mut empty = health(); empty = HealthView::new(HealthSnapshot { @@ -127,13 +303,36 @@ fn health_screen_routes_every_advertised_keyboard_and_mouse_action() { }); state.update(Action::Present(Screen::Health(Box::new(empty)))); let (_, geometry) = draw(&mut session, &state); - assert_eq!( - session.handle_event( - key(KeyCode::PageDown, KeyModifiers::NONE), - &state, - &geometry - ), - EventHandling::Consumed + for code in [ + KeyCode::Up, + KeyCode::Down, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::Home, + KeyCode::End, + ] { + assert_eq!( + session.handle_event(key(code, KeyModifiers::NONE), &state, &geometry), + EventHandling::Consumed, + "Health without issues must keep {code:?} in the summary viewport" + ); + } +} + +#[test] +fn unpinned_runner_removal_does_not_fabricate_a_pin_warning() { + let mut manager = RunnerManagerView::new(vec![runner("codex")]); + manager.reduce(RunnerManagerAction::RemoveSelected); + assert!(manager.removal().is_some()); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Runners(Box::new(manager)))); + let mut session = TuiSession::default(); + let (terminal, _) = draw(&mut session, &state); + let screen = rendered_text(terminal.backend().buffer()); + assert!(screen.contains("Confirm removal"), "{screen}"); + assert!( + !screen.contains("0 prompt") && !screen.contains("prompts pin"), + "an unpinned runner fabricated a dependency warning: {screen}" ); } @@ -156,6 +355,39 @@ fn runner_manager_and_shared_modal_use_typed_mature_widget_sessions() { ), EventHandling::Action(Action::Runners(RunnerManagerAction::New)) ); + for event in [ + key(KeyCode::Char('n'), KeyModifiers::NONE), + key(KeyCode::Char('x'), KeyModifiers::CONTROL), + key_with_kind( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + KeyEventKind::Release, + ), + ] { + assert_eq!( + session.handle_event(event, &state, &geometry), + EventHandling::Ignored, + "only a pressed Ctrl+N may open a new runner" + ); + } + let (row_x, row_y) = cell_position(terminal.backend().buffer(), "codex"); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Moved, row_x, row_y), + &state, + &geometry, + ), + EventHandling::Ignored, + "pointer motion over a runner must not move the selection" + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::ScrollDown, row_x, row_y), + &state, + &geometry, + ), + EventHandling::Action(Action::Runners(RunnerManagerAction::Next)) + ); assert_eq!( session.handle_event(key(KeyCode::Null, KeyModifiers::NONE), &state, &geometry), EventHandling::Ignored @@ -196,6 +428,73 @@ fn runner_manager_and_shared_modal_use_typed_mature_widget_sessions() { assert!(rendered.contains("New agent (runner)")); assert!(rendered.contains("Tab/↓ Next field")); assert!(rendered.contains("Enter Save")); + let mut narrow_session = TuiSession::default(); + let (narrow_terminal, narrow_geometry) = draw_sized(&mut narrow_session, &state, 24, 18); + let (footer_x, footer_y) = cell_position(narrow_terminal.backend().buffer(), "Tab/↓"); + assert_eq!( + narrow_session.handle_event( + mouse(MouseEventKind::ScrollDown, footer_x, footer_y), + &state, + &narrow_geometry, + ), + EventHandling::Consumed, + "the narrow RunnerEditor footer must own its wheel input" + ); + let (_, name_label_row) = cell_position(buffer, "Name, e.g. aider"); + let (command_x, command_label_row) = cell_position(buffer, "Command, e.g. aider"); + assert_eq!( + terminal.backend().cursor_position().y, + name_label_row.saturating_add(1), + "the Name-focused editor placed its caret in the wrong input" + ); + assert_ne!( + terminal.backend().cursor_position().y, + command_label_row.saturating_add(1), + "the unfocused Command input also placed a caret" + ); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + command_x, + command_label_row + ), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + command_x, + command_label_row + ), + &state, + &geometry, + ), + EventHandling::Action(Action::RunnerEditor(RunnerEditorAction::Focus( + RunnerEditorField::Command, + ))) + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Moved, command_x, command_label_row), + &state, + &geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + session.handle_event( + key_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Release), + &state, + &geometry, + ), + EventHandling::Ignored, + "a released RunnerEditor key must not activate its action" + ); assert_eq!( session.handle_event( key(KeyCode::Char('界'), KeyModifiers::NONE), @@ -219,7 +518,19 @@ fn runner_manager_and_shared_modal_use_typed_mature_widget_sessions() { ); let (x, y) = cell_position(buffer, "Esc Cancel"); assert_eq!( - session.handle_event(mouse(x, y), &state, &geometry), + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), x, y), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), x, y), + &state, + &geometry, + ), EventHandling::Action(Action::RunnerEditor(RunnerEditorAction::Cancel)) ); diff --git a/crates/skit-tui/tests/overlay_pointer_priority.rs b/crates/skit-tui/tests/overlay_pointer_priority.rs new file mode 100644 index 00000000..4d0615c2 --- /dev/null +++ b/crates/skit-tui/tests/overlay_pointer_priority.rs @@ -0,0 +1,853 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use ratatui_core::{backend::TestBackend, buffer::Buffer, terminal::Terminal}; +use ratatui_crossterm::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use skit_application::{ + AgentScope, AgentTarget, SourcePermissions, + preferences::{ + AfterRunChoice, InteractiveFormChoice, JavascriptChoice, MirrorConfiguration, + PreferencesDraft, PreferencesSnapshot, + }, + tokens::TokenContext, +}; +use skit_domain::parameters::{ParamDecl, ParameterType, ParameterValue}; +use skit_i18n::Locale; +use skit_tui::{EventHandling, HitTarget, TuiSession, ViewGeometry, render_with_session}; +use skit_ui::{ + Action, AddAction, AddWorkflowState, KnownEntryKind, LibraryState, PreferencesAction, + PreferencesView, ReviewDefaults, ReviewState, RunFormContext, RunFormView, RunPathContext, + RunnerEditorAction, Screen, SettingsAction, SettingsInputs, SettingsView, SourceSnapshot, +}; + +fn draw( + session: &mut TuiSession, + state: &LibraryState, + width: u16, + height: u16, +) -> (Terminal, ViewGeometry) { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut geometry = ViewGeometry::default(); + terminal + .draw(|frame| { + geometry = render_with_session(frame, state, Locale::En, session); + }) + .unwrap(); + (terminal, geometry) +} + +fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { + Event::Key(KeyEvent::new(code, modifiers)) +} + +fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { + Event::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) +} + +fn footer_target(geometry: &ViewGeometry) -> (u16, u16) { + let area = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the rendered global footer has a command hit") + .rect; + (area.x, area.y) +} + +fn assert_footer_is_blocked( + session: &mut TuiSession, + state: &LibraryState, + geometry: &ViewGeometry, +) { + let (column, row) = footer_target(geometry); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), column, row), + state, + geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ), + EventHandling::Consumed, + "a blocking overlay leaked its rendered global-footer target", + ); +} + +fn buffer_position(buffer: &Buffer, needle: &str) -> (u16, u16) { + for row in 0..buffer.area.height { + let line = (0..buffer.area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::(); + if let Some(column) = line.find(needle) { + return (u16::try_from(column).unwrap(), row); + } + } + panic!("missing rendered text {needle:?}"); +} + +fn last_buffer_position(buffer: &Buffer, needle: &str) -> (u16, u16) { + let mut found = None; + for row in 0..buffer.area.height { + let line = (0..buffer.area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::(); + if let Some(column) = line.find(needle) { + found = Some((u16::try_from(column).unwrap(), row)); + } + } + found.unwrap_or_else(|| panic!("missing rendered text {needle:?}")) +} + +fn prompt_add_state() -> LibraryState { + let source = (0..21) + .map(|index| format!("{{{{h{index:02}}}}}")) + .collect::>() + .join(" "); + let review = ReviewState::from_source( + SourceSnapshot { + path: "task.prompt.md".into(), + source_record: "task.prompt.md".to_owned(), + bytes: source.into_bytes(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft: false, + identity: None, + }, + KnownEntryKind::Prompt, + ReviewDefaults::default(), + ); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::from_review(review), + )))); + state +} + +fn preferences() -> PreferencesView { + PreferencesView::new(PreferencesDraft::from_snapshot(PreferencesSnapshot { + language: String::new(), + available_languages: vec!["en".to_owned(), "zh-CN".to_owned(), "zh-TW".to_owned()], + effective_language: "en".to_owned(), + editor: String::new(), + editor_fallback: Some("vim".to_owned()), + form: InteractiveFormChoice::Tui, + after_run: AfterRunChoice::Exit, + javascript: JavascriptChoice::Automatic, + bash_path: None, + runner_names: Vec::new(), + mirror: MirrorConfiguration::default(), + })) +} + +#[test] +fn add_file_and_prompt_overlays_block_global_footer_but_keep_their_owner() { + let mut file_state = LibraryState::default(); + file_state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut file_session = TuiSession::default(); + let (_, geometry) = draw(&mut file_session, &file_state, 80, 34); + assert_eq!( + file_session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &file_state, + &geometry, + ), + EventHandling::Consumed, + ); + let (_, geometry) = draw(&mut file_session, &file_state, 80, 34); + assert!( + !geometry + .hits + .iter() + .any(|hit| matches!(hit.action, HitTarget::Command(_))), + "the Add workflow suppresses the global footer while its picker owns the screen", + ); + assert_eq!( + file_session.handle_event( + key(KeyCode::Char('x'), KeyModifiers::NONE), + &file_state, + &geometry, + ), + EventHandling::Consumed, + "the file overlay lost its own search input", + ); + + let source = (0..21) + .map(|index| format!("{{{{field{index:02}}}}}")) + .collect::>() + .join(" "); + let review = ReviewState::from_source( + SourceSnapshot { + path: "task.prompt.md".into(), + source_record: "task.prompt.md".to_owned(), + bytes: source.into_bytes(), + permissions: SourcePermissions::default(), + executable: None, + is_regular: true, + is_directory: false, + is_draft: false, + identity: None, + }, + KnownEntryKind::Prompt, + ReviewDefaults::default(), + ); + let mut prompt_state = LibraryState::default(); + prompt_state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::from_review(review), + )))); + let mut prompt_session = TuiSession::default(); + let (_, geometry) = draw(&mut prompt_session, &prompt_state, 80, 60); + assert_eq!( + prompt_session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &prompt_state, + &geometry, + ), + EventHandling::Consumed, + ); + let (_, geometry) = draw(&mut prompt_session, &prompt_state, 80, 60); + assert!( + !geometry + .hits + .iter() + .any(|hit| matches!(hit.action, HitTarget::Command(_))), + "the Add workflow suppresses the global footer while its picker owns the screen", + ); + assert_eq!( + prompt_session.handle_event( + key(KeyCode::Char(' '), KeyModifiers::NONE), + &prompt_state, + &geometry, + ), + EventHandling::Consumed, + "the prompt overlay lost its focused choice", + ); +} + +#[test] +fn settings_prompt_picker_blocks_global_footer_but_keeps_its_owner() { + let candidates = (0..=skit_ui::PROMPT_LIST_PREVIEW_LIMIT) + .map(|index| format!("VALUE_{index}")) + .collect::>(); + let settings = SettingsView::from_inputs(&SettingsInputs { + kind: "prompt".to_owned(), + name: "Prompt".to_owned(), + supports_modes: true, + interpolate: true, + candidates, + ..SettingsInputs::default() + }); + let mut settings_state = LibraryState::default(); + settings_state.update(Action::Present(Screen::Settings(Box::new(settings)))); + let mut settings_session = TuiSession::default(); + let (_, geometry) = draw(&mut settings_session, &settings_state, 90, 32); + assert_eq!( + settings_session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &settings_state, + &geometry, + ), + EventHandling::Consumed, + ); + let (terminal, geometry) = draw(&mut settings_session, &settings_state, 90, 32); + assert_footer_is_blocked(&mut settings_session, &settings_state, &geometry); + let (choice_column, choice_row) = buffer_position(terminal.backend().buffer(), "VALUE_1"); + assert_eq!( + settings_session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + choice_column, + choice_row, + ), + &settings_state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + settings_session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + choice_column, + choice_row, + ), + &settings_state, + &geometry, + ), + EventHandling::Consumed, + "the Settings picker lost its selected row", + ); + assert_eq!( + settings_session.handle_event( + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + &settings_state, + &geometry, + ), + EventHandling::Action(Action::Settings( + skit_ui::SettingsAction::SetPromptCandidates(vec!["VALUE_1".to_owned()]), + )), + "the overlay row did not change the picker's isolated selection", + ); +} + +#[test] +fn agent_picker_blocks_global_footer_but_keeps_its_owner() { + let mut preferences = preferences(); + preferences.update(PreferencesAction::PresentAgentSkillTargets(vec![ + AgentTarget { + name: "codex".to_owned(), + scope: AgentScope::User, + base: PathBuf::from("/home/demo/.codex"), + }, + ])); + let mut preferences_state = LibraryState::default(); + preferences_state.update(Action::Present(Screen::Preferences(Box::new(preferences)))); + let mut preferences_session = TuiSession::default(); + let (terminal, geometry) = draw(&mut preferences_session, &preferences_state, 100, 30); + assert_footer_is_blocked(&mut preferences_session, &preferences_state, &geometry); + let (column, row) = buffer_position(terminal.backend().buffer(), "codex (user)"); + assert_eq!( + preferences_session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), column, row), + &preferences_state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + preferences_session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), column, row), + &preferences_state, + &geometry, + ), + EventHandling::Action(Action::Preferences( + PreferencesAction::ActivateAgentSkillTarget(0), + )), + ); +} + +#[test] +fn runner_editor_blocks_the_underlying_run_field_command_hit() { + let mut path = ParamDecl::new("path"); + path.parameter_type = ParameterType::Path; + path.default = Some(ParameterValue::String("default.txt".to_owned())); + let form = RunFormView::from_declarations( + "prompt", + "Prompt", + &[path], + &BTreeMap::new(), + &["codex".to_owned()], + "codex", + &BTreeMap::new(), + "", + ) + .with_context(RunFormContext { + entry_kind: "prompt".to_owned(), + path: Some(RunPathContext { + workdir: "/work".to_owned(), + invoke_cwd: "/invoke".to_owned(), + }), + tokens: TokenContext { + cwd: "/invoke".to_owned(), + home: None, + env: BTreeMap::new(), + today: "2026-08-28".to_owned(), + now: "12-00-00".to_owned(), + }, + }); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Run(Box::new(form)))); + state.update(Action::OpenRunRunnerEditor); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 100, 32); + let underlay = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::RunFieldCommand { .. })) + .expect("the RunnerEditor render exposed one underlying Run command") + .rect; + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + underlay.x, + underlay.y, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + let release = session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + underlay.x, + underlay.y, + ), + &state, + &geometry, + ); + assert!( + !matches!( + release, + EventHandling::Action( + Action::OpenRunFilePicker(_) + | Action::OpenRunTokenMenuFor(_) + | Action::ResetRunField(_) + ) + ), + "the RunnerEditor leaked an underlying Run field command: {release:?}", + ); + assert!(matches!( + session.handle_event( + key(KeyCode::Char('x'), KeyModifiers::NONE), + &state, + &geometry + ), + EventHandling::Action(Action::RunnerEditor(_)) + )); +} + +#[test] +fn a_global_click_cancels_an_armed_add_control() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 90, 34); + let local = buffer_position(terminal.backend().buffer(), "[Enter] Continue"); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), local.0, local.1), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + let mut preferences_state = LibraryState::default(); + preferences_state.update(Action::Present(Screen::Preferences( + Box::new(preferences()), + ))); + let (_, preferences_geometry) = draw(&mut session, &preferences_state, 90, 34); + let (footer_column, footer_row) = footer_target(&preferences_geometry); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + footer_column, + footer_row, + ), + &preferences_state, + &preferences_geometry, + ), + EventHandling::Consumed, + ); + assert!(matches!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + footer_column, + footer_row, + ), + &preferences_state, + &preferences_geometry, + ), + EventHandling::Action(_), + )); + let (_, geometry) = draw(&mut session, &state, 90, 34); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), local.0, local.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "a late Add release survived a complete global-footer click", + ); +} + +#[test] +fn resize_cancels_an_armed_add_file_overlay_row_without_disabling_it() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 34); + assert_eq!( + session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + for character in "Cargo.toml".chars() { + assert_eq!( + session.handle_event( + key(KeyCode::Char(character), KeyModifiers::NONE), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + } + let (terminal, geometry) = draw(&mut session, &state, 80, 34); + let target = last_buffer_position(terminal.backend().buffer(), "Cargo.toml"); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event(Event::Resize(80, 34), &state, &geometry), + EventHandling::Ignored, + ); + let (_, geometry) = draw(&mut session, &state, 80, 34); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Consumed, + "a file row survived resize as an armed semantic target", + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert!(matches!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Action(Action::Add(AddAction::SetSourcePath(path))) + if path.ends_with("Cargo.toml") + )); +} + +#[test] +fn resize_cancels_an_armed_add_prompt_row_without_changing_its_selection() { + let state = prompt_add_state(); + let mut session = TuiSession::default(); + let (_, geometry) = draw(&mut session, &state, 80, 60); + assert_eq!( + session.handle_event( + key(KeyCode::Char('o'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + let (terminal, geometry) = draw(&mut session, &state, 80, 60); + let target = buffer_position(terminal.backend().buffer(), "h01"); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event(Event::Resize(80, 60), &state, &geometry), + EventHandling::Ignored, + ); + let (_, geometry) = draw(&mut session, &state, 80, 60); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), target.0, target.1), + &state, + &geometry, + ), + EventHandling::Consumed, + "a prompt row survived resize as an armed semantic target", + ); + let expected = (0..21) + .map(|index| format!("h{index:02}")) + .collect::>(); + assert_eq!( + session.handle_event( + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + &state, + &geometry, + ), + EventHandling::Action(Action::Add(AddAction::SetPromptCandidates(expected))), + "the cancelled release changed the isolated prompt selection", + ); +} + +#[test] +fn focus_loss_cancels_agent_and_preferences_arms_without_disabling_the_overlay() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Preferences( + Box::new(preferences()), + ))); + state.update(Action::Preferences(PreferencesAction::SetEditor( + "editor-probe".to_owned(), + ))); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 100, 30); + let underlay = buffer_position(terminal.backend().buffer(), "editor-probe"); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + + state.update(Action::Preferences( + PreferencesAction::PresentAgentSkillTargets(vec![AgentTarget { + name: "codex".to_owned(), + scope: AgentScope::User, + base: PathBuf::from("/home/demo/.codex"), + }]), + )); + let (terminal, geometry) = draw(&mut session, &state, 100, 30); + let overlay = buffer_position(terminal.backend().buffer(), "codex (user)"); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + overlay.0, + overlay.1, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event(Event::FocusLost, &state, &geometry), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), overlay.0, overlay.1,), + &state, + &geometry, + ), + EventHandling::Consumed, + "the agent overlay arm survived terminal focus loss", + ); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + overlay.0, + overlay.1, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), overlay.0, overlay.1,), + &state, + &geometry, + ), + EventHandling::Action(Action::Preferences( + PreferencesAction::ActivateAgentSkillTarget(0), + )), + "focus loss disabled the agent overlay's next complete click", + ); + state.update(Action::Preferences( + PreferencesAction::CloseAgentSkillTargets, + )); + let (_, geometry) = draw(&mut session, &state, 100, 30); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Ignored, + "the Preferences underlay arm survived its blocking overlay", + ); +} + +#[test] +fn add_runner_editor_cancels_its_armed_underlay_and_remains_operable() { + let mut state = prompt_add_state(); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 80, 60); + let underlay = buffer_position(terminal.backend().buffer(), "Add Runner"); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + state.update(Action::OpenAddRunnerEditor); + let (_, geometry) = draw(&mut session, &state, 80, 60); + let edit = session.handle_event( + key(KeyCode::Char('x'), KeyModifiers::NONE), + &state, + &geometry, + ); + assert_eq!( + edit, + EventHandling::Action(Action::RunnerEditor(RunnerEditorAction::SetName( + "x".to_owned(), + ))), + "the Add-owned RunnerEditor lost its own input", + ); + if let EventHandling::Action(action) = edit { + state.update(action); + } + state.update(Action::RunnerEditor(RunnerEditorAction::Cancel)); + let (_, geometry) = draw(&mut session, &state, 80, 60); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Ignored, + "the Add underlay activated after its RunnerEditor closed", + ); +} + +#[test] +fn settings_runner_editor_cancels_its_armed_underlay_and_remains_operable() { + let settings = SettingsView::from_inputs(&SettingsInputs { + selector: "prompt-entry".to_owned(), + kind: "prompt".to_owned(), + name: "Prompt".to_owned(), + runner: "codex".to_owned(), + interpolate: true, + configured_runners: vec!["codex".to_owned()], + ..SettingsInputs::default() + }); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Settings(Box::new(settings)))); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw(&mut session, &state, 100, 50); + let underlay = buffer_position(terminal.backend().buffer(), "New agent"); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + let open = session.handle_event( + key(KeyCode::Char('n'), KeyModifiers::CONTROL), + &state, + &geometry, + ); + assert_eq!( + open, + EventHandling::Action(Action::Settings(SettingsAction::NewRunner)), + "the advertised Settings Ctrl+N did not route to its Settings-owned editor", + ); + if let EventHandling::Action(action) = open { + state.update(action); + } + let (_, geometry) = draw(&mut session, &state, 100, 50); + let edit = session.handle_event( + key(KeyCode::Char('x'), KeyModifiers::NONE), + &state, + &geometry, + ); + assert_eq!( + edit, + EventHandling::Action(Action::RunnerEditor(RunnerEditorAction::SetName( + "x".to_owned(), + ))), + "the Settings-owned RunnerEditor lost its own input", + ); + if let EventHandling::Action(action) = edit { + state.update(action); + } + state.update(Action::RunnerEditor(RunnerEditorAction::Cancel)); + let (_, geometry) = draw(&mut session, &state, 100, 50); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + underlay.0, + underlay.1, + ), + &state, + &geometry, + ), + EventHandling::Ignored, + "the Settings underlay activated after its RunnerEditor closed", + ); + let footer = geometry + .hits + .iter() + .find(|hit| hit.action == HitTarget::Command(skit_ui::UiCommand::NewRunner)) + .expect("the advertised Settings Ctrl+N command also has a footer hit") + .rect; + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), footer.x, footer.y,), + &state, + &geometry, + ), + EventHandling::Consumed, + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y,), + &state, + &geometry, + ), + EventHandling::Action(Action::Settings(SettingsAction::NewRunner)), + "the Settings New agent footer hit used the Run-only action", + ); +} diff --git a/crates/skit-tui/tests/port_test_draft_and_reader_tui.rs b/crates/skit-tui/tests/port_test_draft_and_reader_tui.rs index e682bad5..c4d98079 100644 --- a/crates/skit-tui/tests/port_test_draft_and_reader_tui.rs +++ b/crates/skit-tui/tests/port_test_draft_and_reader_tui.rs @@ -134,10 +134,10 @@ fn rendered(buffer: &Buffer) -> String { buffer.content().iter().map(|cell| cell.symbol()).collect() } -/// Click one rendered add control through the same mouse path as the host session. -fn left_click(column: u16, row: u16) -> Event { +/// Send one primary mouse phase through the same path as the host session. +fn left_mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind, column, row, modifiers: KeyModifiers::NONE, @@ -452,7 +452,27 @@ fn test_ctrl_d_deletes_the_highlighted_draft_after_confirm() { .find(|hit| hit.target == AddControlId::Draft(doomed_index)) .expect("the doomed draft is a mouse target") .area; - let select = session.handle_event(left_click(draft_area.x, draft_area.y), &workflow, &geometry); + assert_eq!( + session.handle_event( + left_mouse( + MouseEventKind::Down(MouseButton::Left), + draft_area.x, + draft_area.y, + ), + &workflow, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + let select = session.handle_event( + left_mouse( + MouseEventKind::Up(MouseButton::Left), + draft_area.x, + draft_area.y, + ), + &workflow, + &geometry, + ); assert_eq!( select, Some(AddScreenEvent::Action(AddAction::SelectDraft(doomed_index))) @@ -650,7 +670,19 @@ fn test_delete_draft_chip_only_renders_when_drafts_exist() { .find(|hit| hit.target == AddControlId::DeleteDraft) .expect("the advertised delete chip is clickable") .area; - let event = session.handle_event(left_click(delete.x, delete.y), &present, &geometry); + assert_eq!( + session.handle_event( + left_mouse(MouseEventKind::Down(MouseButton::Left), delete.x, delete.y,), + &present, + &geometry, + ), + Some(AddScreenEvent::Changed) + ); + let event = session.handle_event( + left_mouse(MouseEventKind::Up(MouseButton::Left), delete.x, delete.y), + &present, + &geometry, + ); assert_eq!( event, Some(AddScreenEvent::Action(AddAction::DeleteSelectedDraft)) diff --git a/crates/skit-tui/tests/port_test_path_tui.rs b/crates/skit-tui/tests/port_test_path_tui.rs index 6c469c99..5097e266 100644 --- a/crates/skit-tui/tests/port_test_path_tui.rs +++ b/crates/skit-tui/tests/port_test_path_tui.rs @@ -332,13 +332,25 @@ fn completion_worker_test() -> MutexGuard<'static, ()> { /// suite, and the polls below read the real checkpoint, so a healthy run never waits it out. const WORKER_ANSWER_BUDGET: Duration = Duration::from_secs(30); -fn wait_for_completion(session: &mut TuiSession) { - // Poll the actual result checkpoint; do not add a fixed delay to the successful path. The - // short sleep between polls keeps the waiter from competing for the very time slices the - // workers need on an oversubscribed host. +fn wait_for_completion( + session: &mut TuiSession, + state: &LibraryState, + locale: Locale, + expected: &str, +) { + // A refresh can request a render that retries a full worker queue. It does not prove that a + // result is visible. Follow the production refresh-render loop and wait for the real ghost. let deadline = Instant::now() + WORKER_ANSWER_BUDGET; while Instant::now() < deadline { - if session.refresh_background() { + let _ = session.refresh_background(); + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| { + let _ = render_with_session(frame, state, locale, session); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + if region_text(buffer, buffer.area).contains(expected) { return; } std::thread::sleep(Duration::from_millis(1)); @@ -954,9 +966,19 @@ fn test_picker_row_click_is_the_mouse_path() { .iter() .find(|hit| hit.target == FilePickerHit::Entry(0)) .expect("a clickable row for data.csv"); - let event = click(hit.area.x + 2, hit.area.y); assert_eq!( - session.handle_event(event, &geometry), + session.handle_event(click(hit.area.x + 2, hit.area.y), &geometry), + Some(FilePickerEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + hit.area.x + 2, + hit.area.y, + ), + &geometry, + ), Some(FilePickerEvent::Accepted(vec![PathBuf::from("data.csv")])) ); } @@ -1033,7 +1055,21 @@ fn test_picker_esc_cancels_and_up_chip_is_clickable() { .iter() .find(|hit| hit.target == FilePickerHit::Up) .expect("an Up chip"); - let _ = session.handle_event(click(up.area.x + 1, up.area.y), &geometry); + assert_eq!( + session.handle_event(click(up.area.x + 1, up.area.y), &geometry), + Some(FilePickerEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + up.area.x + 1, + up.area.y, + ), + &geometry, + ), + Some(FilePickerEvent::Changed) + ); assert_eq!(session.current_dir(), &root); assert_eq!( feed(&mut session, key(KeyCode::Esc)), @@ -1072,7 +1108,7 @@ fn test_path_fields_render_hint_and_suggester() { let mut session = completion_session(); let geometry = render_root(&mut session, &state); type_run_value(&mut session, &mut state, &geometry, "da"); - wait_for_completion(&mut session); + wait_for_completion(&mut session, &state, Locale::En, "data.csv"); let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); terminal @@ -1104,7 +1140,7 @@ fn right_accepts_only_the_current_ghost_at_the_end_of_the_input() { let mut session = completion_session(); let geometry = render_root(&mut session, &state); type_run_value(&mut session, &mut state, &geometry, "da"); - wait_for_completion(&mut session); + wait_for_completion(&mut session, &state, Locale::En, "data.csv"); assert_eq!(field_value(&state, 0), "da", "a ghost is not a value"); assert_eq!( @@ -1233,7 +1269,7 @@ fn path_hint_and_existing_browse_door_stay_complete_in_three_locales() { let mut session = completion_session(); let geometry = render_root(&mut session, &state); type_run_value(&mut session, &mut state, &geometry, "da"); - wait_for_completion(&mut session); + wait_for_completion(&mut session, &state, locale, "data.csv"); let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); terminal .draw(|frame| { @@ -1876,6 +1912,13 @@ fn test_picker_pinned_row_shows_its_label() { } assert_eq!( session.handle_event(click(area.x, area.y), &geometry), + Some(FilePickerEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), area.x, area.y), + &geometry, + ), Some(FilePickerEvent::Accepted(vec![PathBuf::new()])) ); } diff --git a/crates/skit-tui/tests/port_test_prompt_tui.rs b/crates/skit-tui/tests/port_test_prompt_tui.rs index f2277699..c52913fc 100644 --- a/crates/skit-tui/tests/port_test_prompt_tui.rs +++ b/crates/skit-tui/tests/port_test_prompt_tui.rs @@ -522,7 +522,15 @@ fn test_form_picker_mouse_click_picks_a_runner() { assert_eq!(runner_value(&state), original); } - let handling = session.handle_event(mouse(runner.x, runner.y), &state, &geometry); + assert_eq!( + session.handle_event(mouse(runner.x, runner.y), &state, &geometry), + EventHandling::Consumed + ); + let handling = session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), runner.x, runner.y), + &state, + &geometry, + ); let EventHandling::Action(action) = handling else { panic!("clicking the closed runner picker must focus and open it: {handling:?}"); }; @@ -537,7 +545,16 @@ fn test_form_picker_mouse_click_picks_a_runner() { }) .unwrap(); let (column, row) = buffer_position(terminal.backend().buffer(), RUNNERS[1]); - let handling = session.handle_event(mouse(column, row), &state, &geometry); + assert_eq!( + session.handle_event(mouse(column, row), &state, &geometry), + EventHandling::Consumed, + "runner option Down must arm without selecting" + ); + let handling = session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + &state, + &geometry, + ); let EventHandling::Action(action) = handling else { panic!("clicking a rendered runner option must select it: {handling:?}"); }; @@ -896,9 +913,15 @@ fn settings_short_candidate_cursor_and_mouse_can_pick_a_non_first_name() { }) .unwrap(); let (column, row) = buffer_position(terminal.backend().buffer(), "☐ c"); - let EventHandling::Action(action) = - mouse_session.handle_event(mouse(column, row), &mouse_state, &mouse_geometry) - else { + assert_eq!( + mouse_session.handle_event(mouse(column, row), &mouse_state, &mouse_geometry), + EventHandling::Consumed + ); + let EventHandling::Action(action) = mouse_session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), column, row), + &mouse_state, + &mouse_geometry, + ) else { panic!("the non-first option must keep its mouse twin"); }; mouse_state.update(action); @@ -1144,6 +1167,14 @@ fn test_settings_candidate_picker_reaches_a_hidden_name_and_waits_for_outer_save session.handle_event(mouse(point.0, point.1), &state, &geometry), EventHandling::Consumed ); + assert_eq!( + session.handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), point.0, point.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); assert!( draw_with_session(&mut session, &state, 110, 40) .0 @@ -1390,7 +1421,21 @@ fn test_review_candidate_picker_select_all_and_done_are_mouse_operable() { .find(|hit| hit.target == ChoicePickerHit::SelectAll) .expect("the select-all control is a mouse target") .area; - let _ = session.handle_event(mouse(select_all.x, select_all.y), &geometry); + assert_eq!( + session.handle_event(mouse(select_all.x, select_all.y), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + select_all.x, + select_all.y, + ), + &geometry, + ), + Some(PromptCandidatePickerEvent::Changed) + ); let (_, geometry) = draw_picker(&mut session, 100, 40); let done = geometry @@ -1399,8 +1444,15 @@ fn test_review_candidate_picker_select_all_and_done_are_mouse_operable() { .find(|hit| hit.target == ChoicePickerHit::Done) .expect("Done is a mouse target") .area; + assert_eq!( + session.handle_event(mouse(done.x, done.y), &geometry), + Some(PromptCandidatePickerEvent::Changed) + ); let PromptCandidatePickerEvent::Accepted(picked) = session - .handle_event(mouse(done.x, done.y), &geometry) + .handle_event( + mouse_with_kind(MouseEventKind::Up(MouseButton::Left), done.x, done.y), + &geometry, + ) .unwrap() else { panic!("clicking Done must accept the picker"); diff --git a/crates/skit-tui/tests/port_test_reset_default_ui.rs b/crates/skit-tui/tests/port_test_reset_default_ui.rs index 7412753b..9fde48af 100644 --- a/crates/skit-tui/tests/port_test_reset_default_ui.rs +++ b/crates/skit-tui/tests/port_test_reset_default_ui.rs @@ -51,7 +51,9 @@ use ratatui_crossterm::crossterm::event::{ }; use skit_domain::parameters::{ParamDecl, ParameterBinding, ParameterType, ParameterValue}; use skit_i18n::Locale; -use skit_tui::{EventHandling, HitTarget, TuiSession, ViewGeometry, render_with_session}; +use skit_tui::{ + EventHandling, HitTarget, RunFieldCommand, TuiSession, ViewGeometry, render_with_session, +}; use skit_ui::{Action, LibraryState, RunFormView, Screen, UiCommand}; /// The exact English hint the intercepted-input field owes (i18n key kept verbatim). @@ -164,8 +166,12 @@ fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { } fn mouse(column: u16, row: u16) -> Event { + mouse_with_kind(MouseEventKind::Down(MouseButton::Left), column, row) +} + +fn mouse_with_kind(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind, column, row, modifiers: KeyModifiers::NONE, @@ -184,7 +190,7 @@ fn has_reset_chip(geometry: &ViewGeometry, field: usize) -> bool { hit.action == HitTarget::RunFieldCommand { field, - command: UiCommand::ResetDefault, + command: RunFieldCommand::ResetDefault, } }) } @@ -276,7 +282,7 @@ fn test_reset_chip_mouse_click_restores_the_default() { hit.action == HitTarget::RunFieldCommand { field: 0, - command: UiCommand::ResetDefault, + command: RunFieldCommand::ResetDefault, } }) .expect("the ↺ default chip is a click target"); @@ -284,6 +290,18 @@ fn test_reset_chip_mouse_click_restores_the_default() { // would stay "world" and this would fail — so the click pins the field-keyed routing end to end. assert_eq!( session.handle_event(mouse(hit.rect.x, hit.rect.y), &state, &geometry), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse_with_kind( + MouseEventKind::Up(MouseButton::Left), + hit.rect.x, + hit.rect.y, + ), + &state, + &geometry, + ), EventHandling::Action(Action::ResetRunField(0)) ); state.update(Action::ResetRunField(0)); diff --git a/crates/skit-tui/tests/port_test_tui_nav.rs b/crates/skit-tui/tests/port_test_tui_nav.rs index 0b73d970..4ae2d3dc 100644 --- a/crates/skit-tui/tests/port_test_tui_nav.rs +++ b/crates/skit-tui/tests/port_test_tui_nav.rs @@ -81,6 +81,10 @@ fn left_click(column: u16, row: u16) -> Event { mouse(MouseEventKind::Down(MouseButton::Left), column, row) } +fn left_release(column: u16, row: u16) -> Event { + mouse(MouseEventKind::Up(MouseButton::Left), column, row) +} + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { Event::Mouse(MouseEvent { kind, @@ -126,6 +130,34 @@ fn drive( handling } +fn drive_click( + session: &mut TuiSession, + state: &mut LibraryState, + geometry: &ViewGeometry, + column: u16, + row: u16, +) -> EventHandling { + assert_eq!( + drive(session, state, geometry, left_click(column, row)), + EventHandling::Consumed + ); + drive(session, state, geometry, left_release(column, row)) +} + +fn add_click( + session: &mut AddScreenSession, + state: &AddWorkflowState, + geometry: &AddScreenGeometry, + column: u16, + row: u16, +) -> Option { + assert_eq!( + session.handle_event(left_click(column, row), state, geometry), + Some(AddScreenEvent::Changed) + ); + session.handle_event(left_release(column, row), state, geometry) +} + /// Press one key through the root and apply its action. fn press(session: &mut TuiSession, state: &mut LibraryState, code: KeyCode) { let (_, geometry) = draw(session, state); @@ -142,7 +174,7 @@ fn click_chip(session: &mut TuiSession, state: &mut LibraryState, command: UiCom .find(|hit| hit.action == HitTarget::Command(command)) .unwrap_or_else(|| panic!("no footer chip fires {command:?}")) .rect; - drive(session, state, &geometry, left_click(rect.x, rect.y)); + let _ = drive_click(session, state, &geometry, rect.x, rect.y); } // --- run-form fixture: the oracle's two-const-str entry --- @@ -298,7 +330,13 @@ fn test_add_source_arrows_walk_path_template_name() { assert_eq!(session.focused(), Some(&ADD_PATH)); } assert!(matches!( - session.handle_event(left_click(browse.area.x, browse.area.y), &state, &geometry), + add_click( + &mut session, + &state, + &geometry, + browse.area.x, + browse.area.y, + ), Some(AddScreenEvent::OpenPathPicker(_)) )); assert_eq!(session.focused(), Some(&ADD_PATH)); @@ -317,7 +355,7 @@ fn test_add_source_arrows_walk_path_template_name() { let (buffer, geometry) = draw_add(&mut session, &state); assert_eq!(session.focused(), Some(&ADD_PATH)); - // The forward footer hit ignores pointer motion/release and fires only on a real left down. + // The forward footer hit ignores pointer motion and a bare release. A primary click moves on. let next = geometry .hits .iter() @@ -332,7 +370,7 @@ fn test_add_source_arrows_walk_path_template_name() { assert_eq!(session.focused(), Some(&ADD_PATH)); } assert_eq!( - session.handle_event(left_click(next.area.x, next.area.y), &state, &geometry), + add_click(&mut session, &state, &geometry, next.area.x, next.area.y,), Some(AddScreenEvent::Changed) ); let (buffer, geometry) = draw_add(&mut session, &state); @@ -357,10 +395,12 @@ fn test_add_source_arrows_walk_path_template_name() { assert_eq!(session.focused(), Some(&ADD_TEMPLATE)); } assert_eq!( - session.handle_event( - left_click(previous.area.x, previous.area.y), + add_click( + &mut session, &state, &geometry, + previous.area.x, + previous.area.y, ), Some(AddScreenEvent::Changed) ); @@ -445,7 +485,7 @@ fn test_add_review_boots_on_name_and_arrows_move() { let (buffer, geometry) = draw_add(&mut session, &state); assert_eq!(session.focused(), Some(&RV_NAME)); - // The rendered forward hit ignores pointer motion/release and fires only on a real left down. + // The rendered forward hit ignores pointer motion and a bare release. A click moves on. let next = geometry .hits .iter() @@ -460,7 +500,7 @@ fn test_add_review_boots_on_name_and_arrows_move() { assert_eq!(session.focused(), Some(&RV_NAME)); } assert_eq!( - session.handle_event(left_click(next.area.x, next.area.y), &state, &geometry), + add_click(&mut session, &state, &geometry, next.area.x, next.area.y,), Some(AddScreenEvent::Changed) ); let (buffer, geometry) = draw_add(&mut session, &state); @@ -485,10 +525,12 @@ fn test_add_review_boots_on_name_and_arrows_move() { assert_eq!(session.focused(), Some(&RV_DESC)); } assert_eq!( - session.handle_event( - left_click(previous.area.x, previous.area.y), + add_click( + &mut session, &state, &geometry, + previous.area.x, + previous.area.y, ), Some(AddScreenEvent::Changed) ); @@ -559,10 +601,12 @@ fn add_kind_and_open_select_keep_their_arrow_ownership() { .find(|hit| hit.target == AddControlId::Storage) .expect("the non-fresh review renders its storage select"); assert_eq!( - select_session.handle_event( - left_click(storage.area.x, storage.area.y), + add_click( + &mut select_session, &review, &geometry, + storage.area.x, + storage.area.y, ), Some(AddScreenEvent::Changed) ); @@ -675,7 +719,7 @@ fn test_prefs_boots_on_language_and_arrows_move() { ); assert_eq!(prefs_focus(&state), radio); // still the same widget… - // …and only a real left down on the typed footer hit moves to the next section. + // …and only a primary press and matching release moves to the next section. let (_, geometry) = draw(&mut session, &state); let next = geometry .hits @@ -696,12 +740,7 @@ fn test_prefs_boots_on_language_and_arrows_move() { assert_eq!(prefs_focus(&state), radio); } assert_eq!( - drive( - &mut session, - &mut state, - &geometry, - left_click(next.x, next.y), - ), + drive_click(&mut session, &mut state, &geometry, next.x, next.y,), EventHandling::Action(Action::FocusNext) ); assert_eq!(prefs_focus(&state), PreferencesControlId::AfterRun); @@ -727,12 +766,7 @@ fn test_prefs_boots_on_language_and_arrows_move() { assert_eq!(prefs_focus(&state), PreferencesControlId::AfterRun); } assert_eq!( - drive( - &mut session, - &mut state, - &geometry, - left_click(previous.x, previous.y), - ), + drive_click(&mut session, &mut state, &geometry, previous.x, previous.y,), EventHandling::Action(Action::FocusPrevious) ); assert_eq!(prefs_focus(&state), radio); @@ -863,7 +897,7 @@ fn test_settings_boots_on_name_and_arrows_move() { _ => unreachable!(), } - // The chips are the same actions. Hover and release are inert; Left Down activates each one. + // The chips are the same actions. Hover and a bare release are inert; a primary click acts. let (footer, geometry) = draw(&mut session, &state); assert!(footer.contains("Tab/↓"), "{footer}"); assert!(footer.contains("Shift+Tab/↑"), "{footer}"); @@ -886,12 +920,7 @@ fn test_settings_boots_on_name_and_arrows_move() { assert_eq!(settings_focus(&state), "name"); } assert_eq!( - drive( - &mut session, - &mut state, - &geometry, - left_click(next.x, next.y), - ), + drive_click(&mut session, &mut state, &geometry, next.x, next.y,), EventHandling::Action(Action::FocusNext) ); assert_eq!(settings_focus(&state), second); @@ -916,12 +945,7 @@ fn test_settings_boots_on_name_and_arrows_move() { assert_eq!(settings_focus(&state), second); } assert_eq!( - drive( - &mut session, - &mut state, - &geometry, - left_click(previous.x, previous.y), - ), + drive_click(&mut session, &mut state, &geometry, previous.x, previous.y,), EventHandling::Action(Action::FocusPrevious) ); assert_eq!(settings_focus(&state), "name"); diff --git a/crates/skit-tui/tests/port_test_tui_responsive.rs b/crates/skit-tui/tests/port_test_tui_responsive.rs index a6b300b6..5137cc66 100644 --- a/crates/skit-tui/tests/port_test_tui_responsive.rs +++ b/crates/skit-tui/tests/port_test_tui_responsive.rs @@ -19,14 +19,12 @@ use skit_application::{ }; use skit_domain::{EntryKind, EntrySummary, Slug, StorageMode, parameters::ParamDecl}; use skit_i18n::Locale; -use skit_tui::{ - AddControlId, AddScreenEvent, AddScreenSession, AddTextField, EventHandling, HitTarget, - TuiSession, ViewGeometry, render_add, render_with_session, -}; +use skit_tui::{EventHandling, HitTarget, TuiSession, ViewGeometry, render_with_session}; use skit_ui::{ Action, AddWorkflowState, FormField, FormPurpose, FormView, LibraryState, PreferencesAction, PreferencesControlId, PreferencesView, RunFormContext, RunFormView, Screen, UiCommand, }; +use unicode_width::UnicodeWidthStr as _; fn key(code: KeyCode) -> Event { Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) @@ -41,6 +39,15 @@ fn click(area: Rect) -> Event { }) } +fn release(area: Rect) -> Event { + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }) +} + fn scroll_down(area: Rect) -> Event { Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, @@ -73,11 +80,21 @@ fn draw( state: &LibraryState, width: u16, height: u16, +) -> (Terminal, ViewGeometry) { + draw_in_locale(session, state, width, height, Locale::En) +} + +fn draw_in_locale( + session: &mut TuiSession, + state: &LibraryState, + width: u16, + height: u16, + locale: Locale, ) -> (Terminal, ViewGeometry) { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); let mut geometry = ViewGeometry::default(); terminal - .draw(|frame| geometry = render_with_session(frame, state, Locale::En, session)) + .draw(|frame| geometry = render_with_session(frame, state, locale, session)) .unwrap(); (terminal, geometry) } @@ -95,6 +112,19 @@ fn drive( handling } +fn drive_click( + session: &mut TuiSession, + state: &mut LibraryState, + geometry: &ViewGeometry, + area: Rect, +) -> EventHandling { + assert_eq!( + drive(session, state, geometry, click(area)), + EventHandling::Consumed + ); + drive(session, state, geometry, release(area)) +} + fn lines(buffer: &Buffer) -> Vec { (0..buffer.area.height) .map(|y| { @@ -109,6 +139,31 @@ fn text(buffer: &Buffer) -> String { lines(buffer).join("\n") } +fn visible_lines(buffer: &Buffer) -> Vec { + buffer + .content() + .chunks(usize::from(buffer.area.width)) + .map(|cells| { + let mut line = String::new(); + let mut continuation_cells = 0; + for cell in cells { + if continuation_cells > 0 { + continuation_cells -= 1; + continue; + } + let symbol = cell.symbol(); + line.push_str(symbol); + continuation_cells = symbol.width().saturating_sub(1); + } + line + }) + .collect() +} + +fn visible_text(buffer: &Buffer) -> String { + visible_lines(buffer).join("\n") +} + fn position(buffer: &Buffer, needle: &str) -> Option<(usize, usize)> { let exact = match needle { "Library" => "╭ Library", @@ -138,6 +193,42 @@ fn rows_with(buffer: &Buffer, needle: &str) -> Vec { .collect() } +fn assert_no_border_collision(buffer: &Buffer, surface: &str) { + let collisions = [('╰', '└'), ('┘', '╯'), ('╭', '┌'), ('┐', '╮')]; + for (row, cells) in buffer + .content() + .chunks(usize::from(buffer.area.width)) + .enumerate() + { + for pair in cells.windows(2) { + let left = pair[0].symbol().chars().next(); + let right = pair[1].symbol().chars().next(); + assert!( + !collisions.contains(&(left.unwrap_or(' '), right.unwrap_or(' '))), + "{surface} has touching borders on row {row}:\n{}", + text(buffer) + ); + } + } +} + +fn assert_library_rows_are_visible(locale: Locale, width: u16, height: u16) { + let state = library(vec![ + summary("alpha", "Alpha"), + summary("beta", "Beta"), + summary("gamma", "Gamma"), + ]); + let mut session = TuiSession::default(); + let (terminal, _) = draw_in_locale(&mut session, &state, width, height, locale); + let rendered = text(terminal.backend().buffer()); + for name in ["Alpha", "Beta", "Gamma"] { + assert!( + rendered.contains(name), + "Library hid {name} at {width}x{height} in {locale:?}:\n{rendered}" + ); + } +} + fn command_hit(geometry: &ViewGeometry, command: UiCommand) -> Option { geometry .hits @@ -160,7 +251,7 @@ fn hit_text(buffer: &Buffer, area: Rect) -> String { } fn bordered_control_height(buffer: &Buffer, title: &str) -> usize { - let rows = lines(buffer); + let rows = visible_lines(buffer); let top = rows .iter() .position(|row| row.contains(title)) @@ -231,9 +322,15 @@ fn test_breakpoint_tiers_are_the_documented_contract() { let (_, geometry) = draw(&mut session, &state, 26, height); command_rows(&geometry).len() }; - assert!(footer_rows(28) > 6); + assert!( + footer_rows(28) >= footer_rows(27), + "the tall-tier budget must not make an allocated footer smaller" + ); assert_eq!(footer_rows(27), 6); - assert_eq!(footer_rows(16), 6); + assert!( + (1..=6).contains(&footer_rows(16)), + "the normal-tier budget must use only rows left after the primary viewport" + ); assert_eq!(footer_rows(15), 2); assert_eq!(footer_rows(10), 2); assert_eq!(footer_rows(9), 1); @@ -310,6 +407,10 @@ fn test_width_tier_boundary_flips_side_by_side_to_stacked() { #[test] fn test_narrow_short_hides_detail_and_tab_pin_survives_resizes() { + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + assert_library_rows_are_visible(locale, 46, 12); + } + let mut state = library(vec![summary("alpha", "Alpha")]); let mut session = TuiSession::default(); let (terminal, geometry) = draw(&mut session, &state, 70, 12); @@ -335,6 +436,10 @@ fn test_narrow_short_hides_detail_and_tab_pin_survives_resizes() { #[test] fn test_tiny_narrow_tab_still_brings_the_pane_back() { + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + assert_library_rows_are_visible(locale, 46, 9); + } + let mut state = library(vec![summary("alpha", "Alpha")]); let mut session = TuiSession::default(); let (terminal, geometry) = draw(&mut session, &state, 46, 9); @@ -363,19 +468,41 @@ fn test_height_tier_boundaries_flatten_search_then_drop_the_global_row() { let mut search = library(vec![summary("alpha", "Alpha")]); search.update(Action::BeginSearch); let mut session = TuiSession::default(); - let (normal, _) = draw(&mut session, &search, 100, 16); + let (normal, _) = draw(&mut session, &search, 100, 17); assert_eq!( bordered_control_height(normal.backend().buffer(), "Search"), 3 ); let mut session = TuiSession::default(); - let (short, _) = draw(&mut session, &search, 100, 15); + let (transition, _) = draw(&mut session, &search, 100, 16); assert_eq!( - bordered_control_height(short.backend().buffer(), "Search"), + bordered_control_height(transition.backend().buffer(), "Search"), 1, - "short-tier search must flatten" + "the first normal-tier row must keep the fallback flat Search" ); + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for searching in [false, true] { + let mut state = library(vec![summary("alpha", "Alpha")]); + if searching { + state.update(Action::BeginSearch); + } + let mut session = TuiSession::default(); + let (terminal, _) = draw_in_locale(&mut session, &state, 100, 15, locale); + let rendered = visible_text(terminal.backend().buffer()); + let first_row = &visible_lines(terminal.backend().buffer())[0]; + let search = skit_i18n::text(locale, "Search"); + assert!( + first_row.contains(search.as_ref()) + && !first_row + .chars() + .any(|character| ['╭', '┌', '╮', '┐'].contains(&character)), + "short browse={searching} did not use the flat search row in {locale:?}:\n{}", + rendered + ); + } + } + let browse = library(vec![summary("alpha", "Alpha")]); let mut session = TuiSession::default(); let (ten, geometry) = draw(&mut session, &browse, 100, 10); @@ -387,6 +514,146 @@ fn test_height_tier_boundaries_flatten_search_then_drop_the_global_row() { assert!(text(nine.backend().buffer()).contains("1/1 entry")); } +#[test] +fn test_growing_across_height_tiers_never_shrinks_the_primary_viewport() { + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for (shorter, taller) in [(6, 7), (9, 10), (15, 16), (27, 28)] { + let state = library(vec![ + summary("alpha", "Alpha"), + summary("beta", "Beta"), + summary("gamma", "Gamma"), + ]); + let mut session = TuiSession::default(); + let (_, before) = draw_in_locale(&mut session, &state, 100, shorter, locale); + let (_, after) = draw_in_locale(&mut session, &state, 100, taller, locale); + assert!( + after.rows.height >= before.rows.height, + "growing from {shorter} to {taller} rows shrank the primary viewport from {} to {} rows in {locale:?}", + before.rows.height, + after.rows.height, + ); + } + } +} + +#[test] +fn test_footer_minimum_structure_is_monotonic_and_keeps_status_out_of_hits() { + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for width in [38, 100] { + let mut state = library(vec![ + summary("alpha", "Alpha"), + summary("beta", "Beta"), + summary("gamma", "Gamma"), + ]); + state.update(Action::SetStatus("STATUS_SENTINEL".to_owned())); + state.update(Action::ToggleDetail { + currently_visible: true, + }); + let mut previous_rows = None; + + for height in 14..=19 { + let mut session = TuiSession::default(); + let (terminal, geometry) = + draw_in_locale(&mut session, &state, width, height, locale); + if let Some(previous) = previous_rows { + assert!( + geometry.rows.height >= previous, + "the primary body shrank from {previous} to {} rows at {width}x{height} in {locale:?}", + geometry.rows.height, + ); + } + previous_rows = Some(geometry.rows.height); + + let search = skit_i18n::text(locale, "Search"); + let expected_header_height = if height <= 16 { 1 } else { 3 }; + assert_eq!( + bordered_control_height(terminal.backend().buffer(), search.as_ref()), + expected_header_height, + "the Search header used an intermediate or wrong shape at {width}x{height} in {locale:?}:\n{}", + text(terminal.backend().buffer()), + ); + if height <= 16 { + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), + EventHandling::Consumed, + "the flat Search row lost its pointer hit at {width}x{height} in {locale:?}", + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 1, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), + EventHandling::Ignored, + "the flat Search row registered a ghost second-row hit at {width}x{height} in {locale:?}", + ); + } + + let command_hits = geometry + .hits + .iter() + .filter(|hit| matches!(hit.action, HitTarget::Command(_))) + .collect::>(); + assert!( + !command_hits.is_empty(), + "the footer lost every command at {width}x{height} in {locale:?}:\n{}", + text(terminal.backend().buffer()), + ); + assert!( + rows_with(terminal.backend().buffer(), "STATUS_SENTINEL").len() == 1, + "the footer lost or duplicated its status at {width}x{height} in {locale:?}:\n{}", + text(terminal.backend().buffer()), + ); + let status_row = + u16::try_from(rows_with(terminal.backend().buffer(), "STATUS_SENTINEL")[0]) + .unwrap(); + for hit in command_hits { + assert!( + hit.rect.bottom() <= status_row || hit.rect.y > status_row, + "footer hit {:?} overlaps status row {status_row} at {width}x{height} in {locale:?}", + hit.rect, + ); + } + } + } + } +} + +#[test] +fn test_root_hit_rectangles_stay_inside_every_boundary_viewport() { + let state = library(vec![summary("alpha", "Alpha")]); + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + for width in [1, 2, 10, 16, 24, 79, 80] { + for height in [1, 2, 6, 7, 9, 10, 15, 16, 27, 28] { + let mut session = TuiSession::default(); + let (_, geometry) = draw_in_locale(&mut session, &state, width, height, locale); + for hit in geometry.hits { + assert!(hit.rect.width > 0 && hit.rect.height > 0); + assert!( + hit.rect.right() <= width && hit.rect.bottom() <= height, + "hit {:?} escaped {width}x{height} in {locale:?}", + hit.rect, + ); + } + } + } + } +} + #[test] fn test_flattened_search_still_filters() { let mut state = library(vec![summary("alpha", "alpha"), summary("beta", "beta")]); @@ -421,7 +688,7 @@ fn test_footer_wraps_between_pills_and_wrapped_chips_stay_clickable() { let detail = command_hit(&geometry, UiCommand::ToggleDetail).unwrap(); assert!(detail.y > search.y, "Tab pill did not wrap after Search"); assert_eq!( - drive(&mut session, &mut state, &geometry, click(detail)), + drive_click(&mut session, &mut state, &geometry, detail), EventHandling::Action(Action::ToggleDetail { currently_visible: true, }) @@ -429,7 +696,7 @@ fn test_footer_wraps_between_pills_and_wrapped_chips_stay_clickable() { let (terminal, geometry) = draw(&mut session, &state, 44, 24); assert!(position(terminal.backend().buffer(), "Detail pane").is_none()); let detail = command_hit(&geometry, UiCommand::ToggleDetail).unwrap(); - let _ = drive(&mut session, &mut state, &geometry, click(detail)); + let _ = drive_click(&mut session, &mut state, &geometry, detail); let (terminal, _) = draw(&mut session, &state, 44, 24); assert!(position(terminal.backend().buffer(), "Detail pane").is_some()); } @@ -481,6 +748,29 @@ fn test_short_tier_caps_visible_lines_but_keeps_chips_scroll_reachable() { #[test] fn test_prefs_mirror_rows_are_horizontal_until_narrow_and_sentences_always_stack() { + for (locale, first_option, save) in [ + (Locale::En, "Automatic", "Save"), + (Locale::ZhCn, "自动", "保存"), + (Locale::ZhTw, "自動", "儲存"), + ] { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Preferences( + Box::new(preferences()), + ))); + let mut session = TuiSession::default(); + let (terminal, _) = draw_in_locale(&mut session, &state, 24, 6, locale); + let rendered = visible_text(terminal.backend().buffer()); + assert!( + rendered.contains(first_option), + "Preferences hid its first option at 24x6 in {locale:?}:\n{rendered}" + ); + assert!( + rendered.contains("Ctrl+S") && rendered.contains(save), + "Preferences hid its primary action at 24x6 in {locale:?}:\n{rendered}" + ); + assert_no_border_collision(terminal.backend().buffer(), "Preferences"); + } + let rows = [ ( PreferencesControlId::PypiChoice, @@ -542,19 +832,37 @@ fn test_prefs_mirror_rows_are_horizontal_until_narrow_and_sentences_always_stack #[test] fn test_help_overlay_caps_to_a_tiny_screen_and_scrolls_by_key() { - let mut state = library(vec![summary("alpha", "Alpha")]); - state.update(Action::OpenHelp); - let mut session = TuiSession::default(); - let (terminal, geometry) = draw(&mut session, &state, 40, 8); - assert_eq!(geometry.first_visible, 0); - assert!(geometry.rows.right() <= 40 && geometry.rows.bottom() <= 8); - assert!(text(terminal.backend().buffer()).contains("Help")); - assert_eq!( - session.handle_event(key(KeyCode::Down), &state, &geometry), - EventHandling::Consumed - ); - let (_, geometry) = draw(&mut session, &state, 40, 8); - assert!(geometry.first_visible > 0); + for locale in [Locale::En, Locale::ZhCn, Locale::ZhTw] { + let mut state = library(vec![summary("alpha", "Alpha")]); + state.update(Action::OpenHelp); + let mut session = TuiSession::default(); + let (terminal, geometry) = draw_in_locale(&mut session, &state, 40, 8, locale); + assert_eq!(geometry.first_visible, 0); + assert!(geometry.rows.right() <= 40 && geometry.rows.bottom() <= 8); + let help = skit_i18n::text(locale, "Help"); + let quit = skit_i18n::text(locale, "Quit"); + let before = visible_text(terminal.backend().buffer()); + let title_count = before.matches(help.as_ref()).count(); + assert!( + !before.contains(quit.as_ref()), + "fixture already shows Quit" + ); + assert_eq!( + session.handle_event(key(KeyCode::End), &state, &geometry), + EventHandling::Consumed + ); + let (terminal, geometry) = draw_in_locale(&mut session, &state, 40, 8, locale); + let after = visible_text(terminal.backend().buffer()); + assert!(geometry.first_visible > 0); + assert!( + after.contains(quit.as_ref()), + "Help scrolling did not make Quit visible in {locale:?}:\n{after}" + ); + assert_eq!( + title_count, 1, + "Help printed its title more than once in {locale:?}:\n{before}" + ); + } } #[test] @@ -568,11 +876,12 @@ fn test_confirm_remove_shrinks_for_a_long_name_on_a_narrow_screen() { let (terminal, _) = draw(&mut session, &state, 40, 20); let rows = lines(terminal.backend().buffer()); let titles = rows_with(terminal.backend().buffer(), "Confirm removal"); - assert!( - titles.len() >= 2, - "header rendered but popup title is missing: {rows:#?}" + assert_eq!( + titles.len(), + 1, + "the confirmation title must have one owner: {rows:#?}" ); - let top = *titles.last().unwrap(); + let top = titles[0]; assert!(rows[top].contains('┐') || rows[top].contains('╮')); let bottom = rows .iter() @@ -582,6 +891,12 @@ fn test_confirm_remove_shrinks_for_a_long_name_on_a_narrow_screen() { .map(|(y, _)| y) .expect("confirmation bottom border is visible"); assert!(bottom < 20); + + let (_, geometry) = draw(&mut session, &state, 40, 6); + assert!( + geometry.hits.is_empty(), + "the modal-owned buttons must not compete with a global footer" + ); } #[test] @@ -617,9 +932,17 @@ fn test_env_picker_fits_input_and_esc_chip_across_the_tiers() { let mut session = TuiSession::default(); let (terminal, geometry) = draw(&mut session, &state, 70, 20); + let title_rows = rows_with(terminal.backend().buffer(), "Environment variable"); + assert_eq!( + title_rows.len(), + 1, + "the environment picker title must have one owner:\n{}", + text(terminal.backend().buffer()) + ); + let title_row = &lines(terminal.backend().buffer())[title_rows[0]]; assert!( - rows_with(terminal.backend().buffer(), "Environment variable").len() >= 2, - "environment header rendered but picker popup is missing" + title_row.contains('┐') || title_row.contains('╮'), + "the environment picker top border is visible: {title_row:?}" ); assert!(command_hit(&geometry, UiCommand::CloseModal).is_some()); @@ -638,51 +961,45 @@ fn test_env_picker_fits_input_and_esc_chip_across_the_tiers() { #[test] fn test_add_source_fields_stay_reachable_on_short_terminals() { - let mut workflow = AddWorkflowState::new(Vec::new()); - let mut session = AddScreenSession::default(); - let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); - let mut geometry = Default::default(); - terminal - .draw(|frame| { - geometry = render_add(frame, frame.area(), &workflow, &mut session, Locale::En) - }) - .unwrap(); - assert_eq!( - session.focused(), - Some(&AddControlId::Text(AddTextField::SourcePath)) - ); - let rendered = text(terminal.backend().buffer()); - assert!( - rendered.contains("[Ctrl+O] Select"), - "the short layout must keep Browse's independent keyboard path visible: {rendered}" - ); - let browse = geometry - .hits - .iter() - .find(|hit| hit.target == AddControlId::BrowseSource) - .expect("the short layout keeps the Browse mouse target visible"); - assert!(browse.area.bottom() <= geometry.body.bottom()); + for (locale, primary, continue_label) in [ + (Locale::En, "Source path", "Continue"), + (Locale::ZhCn, "源文件路径", "继续"), + (Locale::ZhTw, "來源路徑", "繼續"), + ] { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut session = TuiSession::default(); + let (terminal, _) = draw_in_locale(&mut session, &state, 24, 6, locale); + let rendered = visible_text(terminal.backend().buffer()); + assert!( + rendered.contains(primary), + "Add hid its primary content at 24x6 in {locale:?}:\n{rendered}" + ); + assert!( + rendered.contains("Enter") && rendered.contains(continue_label), + "Add hid Continue at 24x6 in {locale:?}:\n{rendered}" + ); + assert_no_border_collision(terminal.backend().buffer(), "Add"); + } - // Browse has its own key, so two field-navigation steps reach Name: path -> template -> name. + // Browse has its own key. Two navigation steps reach Name: path -> template -> name. + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Add(Box::new( + AddWorkflowState::new(Vec::new()), + )))); + let mut session = TuiSession::default(); + let (_, mut geometry) = draw(&mut session, &state, 80, 12); for _ in 0..2 { - if let Some(AddScreenEvent::Action(action)) = - session.handle_event(key(KeyCode::Tab), &workflow, &geometry) - { - let _ = workflow.reduce(action); - } - terminal - .draw(|frame| { - geometry = render_add(frame, frame.area(), &workflow, &mut session, Locale::En) - }) - .unwrap(); + let _ = drive(&mut session, &mut state, &geometry, key(KeyCode::Tab)); + let (_, next_geometry) = draw(&mut session, &state, 80, 12); + geometry = next_geometry; } - let name = AddControlId::Text(AddTextField::CommandName); - assert_eq!(session.focused(), Some(&name)); - let area = geometry - .hits - .iter() - .find_map(|hit| (hit.target == name).then_some(hit.area)) - .expect("focused name input is visible after focus scrolling"); - assert_eq!(area.height, 3); - assert!(area.bottom() <= geometry.body.bottom()); + let (terminal, _) = draw(&mut session, &state, 80, 12); + assert!( + text(terminal.backend().buffer()).contains("Name"), + "focus did not bring the command name into view:\n{}", + text(terminal.backend().buffer()) + ); } diff --git a/crates/skit-tui/tests/preferences_workflow.rs b/crates/skit-tui/tests/preferences_workflow.rs index 05fb29e8..f419f066 100644 --- a/crates/skit-tui/tests/preferences_workflow.rs +++ b/crates/skit-tui/tests/preferences_workflow.rs @@ -1,4 +1,4 @@ -use ratatui_core::{backend::TestBackend, terminal::Terminal}; +use ratatui_core::{backend::TestBackend, buffer::Buffer, terminal::Terminal}; use ratatui_crossterm::crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -9,9 +9,43 @@ use skit_application::preferences::{ use skit_i18n::Locale; use skit_tui::{EventHandling, HitTarget, TuiSession, render_with_session}; use skit_ui::{ - Action, LibraryState, ModalState, PreferencesAction, PreferencesView, Screen, UiCommand, + Action, LibraryState, ModalState, PreferencesAction, PreferencesControlId, PreferencesView, + Screen, UiCommand, }; +fn draw( + session: &mut TuiSession, + state: &LibraryState, + width: u16, + height: u16, +) -> Terminal { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + let _ = render_with_session(frame, state, Locale::En, session); + }) + .unwrap(); + terminal +} + +fn rendered_text(buffer: &Buffer) -> String { + buffer.content().iter().map(|cell| cell.symbol()).collect() +} + +fn buffer_position(buffer: &Buffer, needle: &str) -> (u16, u16) { + for row in 0..buffer.area.height { + let cells = (0..buffer.area.width) + .map(|column| buffer[(column, row)].symbol()) + .collect::>(); + for column in 0..cells.len() { + if cells[column..].concat().starts_with(needle) { + return (u16::try_from(column).unwrap(), row); + } + } + } + panic!("missing {needle:?}"); +} + fn preferences() -> PreferencesView { PreferencesView::new(PreferencesDraft::from_snapshot(PreferencesSnapshot { language: String::new(), @@ -55,9 +89,8 @@ fn dirty_preferences_discard_guard_has_exact_keys_and_clickable_actions() { .iter() .map(|cell| cell.symbol()) .collect::(); - // The recorded screen-header deviation keeps two copies. See - // `docs/design/rust-contract-matrix.md#the-discard-confirmation-also-names-its-surface`. - assert_eq!(rendered.matches("Discard unsaved changes?").count(), 2); + // Version 0.4 shows the question once inside the confirmation panel. + assert_eq!(rendered.matches("Discard unsaved changes?").count(), 1); assert!(rendered.contains("Discard")); assert!(rendered.contains("Keep editing")); @@ -118,7 +151,99 @@ fn dirty_preferences_discard_guard_has_exact_keys_and_clickable_actions() { &state, &geometry, ), + EventHandling::Consumed, + "primary Down must arm {command:?} without activating it" + ); + assert_eq!( + session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ), EventHandling::Action(expected) ); } } + +#[test] +fn resize_only_keeps_the_focused_preferences_control_visible() { + let mut view = preferences(); + let _ = view.update(PreferencesAction::Focus(PreferencesControlId::NpmChoice)); + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Preferences(Box::new(view)))); + let mut session = TuiSession::default(); + + let large = draw(&mut session, &state, 70, 100); + assert!(rendered_text(large.backend().buffer()).contains("npm registry")); + let small = draw(&mut session, &state, 70, 18); + let rendered = rendered_text(small.backend().buffer()); + assert!( + rendered.contains("npm registry"), + "resize-only reflow hid the focused Preferences control: {rendered}" + ); +} + +#[test] +fn preferences_arm_cannot_survive_a_release_owned_by_the_global_footer() { + let mut state = LibraryState::default(); + state.update(Action::Present(Screen::Preferences( + Box::new(preferences()), + ))); + state.update(Action::Preferences(PreferencesAction::SetEditor( + "editor-probe".to_owned(), + ))); + let mut session = TuiSession::default(); + let mut terminal = Terminal::new(TestBackend::new(100, 28)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_with_session(frame, &state, Locale::En, &mut session); + }) + .unwrap(); + let editor = buffer_position(terminal.backend().buffer(), "editor-probe"); + let footer = geometry + .hits + .iter() + .find(|hit| matches!(hit.action, HitTarget::Command(_))) + .expect("the global footer must have a hit") + .rect; + let mouse = |kind, column, row| { + Event::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) + }; + + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), editor.0, editor.1), + &state, + &geometry, + ), + EventHandling::Consumed + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), footer.x, footer.y), + &state, + &geometry, + ), + EventHandling::Ignored + ); + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), editor.0, editor.1), + &state, + &geometry, + ), + EventHandling::Ignored, + "a release in another owner must cancel the Preferences arm" + ); +} diff --git a/crates/skit-tui/tests/render.rs b/crates/skit-tui/tests/render.rs index c95d4979..326c8253 100644 --- a/crates/skit-tui/tests/render.rs +++ b/crates/skit-tui/tests/render.rs @@ -201,6 +201,28 @@ fn mouse(kind: MouseEventKind, column: u16, row: u16) -> Event { }) } +fn session_click( + session: &mut TuiSession, + state: &LibraryState, + geometry: &ViewGeometry, + column: u16, + row: u16, +) -> EventHandling { + assert_eq!( + session.handle_event( + mouse(MouseEventKind::Down(MouseButton::Left), column, row), + state, + geometry, + ), + EventHandling::Consumed + ); + session.handle_event( + mouse(MouseEventKind::Up(MouseButton::Left), column, row), + state, + geometry, + ) +} + fn binding_event(binding: UiBinding) -> Event { let code = match binding.key { UiKey::Character(character) => KeyCode::Char(character), @@ -686,44 +708,39 @@ fn mouse_wheel_rows_and_footer_hits_map_to_frontend_neutral_actions() { assert_eq!( map_event(mouse(MouseEventKind::ScrollUp, 40, 20), &state, &geometry), - Some(Action::Previous) + None ); assert_eq!( map_event(mouse(MouseEventKind::ScrollDown, 40, 20), &state, &geometry), - Some(Action::Next) - ); - assert_eq!( - map_event( - mouse(MouseEventKind::Down(MouseButton::Left), 4, 4), - &state, - &geometry - ), - Some(Action::SelectVisible(6)) - ); - assert_eq!( - map_event( - mouse(MouseEventKind::Down(MouseButton::Left), 1, 10), - &state, - &geometry - ), - Some(Action::Quit) + None ); assert_eq!( - map_event( - mouse(MouseEventKind::Down(MouseButton::Left), 8, 10), - &state, - &geometry - ), - Some(Action::Reload) + map_event(mouse(MouseEventKind::ScrollUp, 4, 4), &state, &geometry), + Some(Action::Previous) ); assert_eq!( - map_event( - mouse(MouseEventKind::Down(MouseButton::Left), 16, 10), - &state, - &geometry - ), - Some(Action::BeginSearch) + map_event(mouse(MouseEventKind::ScrollDown, 4, 4), &state, &geometry), + Some(Action::Next) ); + for (column, row, owner) in [ + (4, 4, "Library row"), + (1, 10, "Quit footer chip"), + (8, 10, "Reload footer chip"), + (16, 10, "Search footer chip"), + ] { + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + MouseEventKind::Down(MouseButton::Right), + MouseEventKind::Down(MouseButton::Middle), + ] { + assert_eq!( + map_event(mouse(kind, column, row), &state, &geometry), + None, + "stateless mapping activated {owner} on {kind:?}; TuiSession must own click state" + ); + } + } assert_eq!( map_event( mouse(MouseEventKind::Down(MouseButton::Left), 99, 99), @@ -921,11 +938,7 @@ fn report_and_confirmation_keys_match_their_footer_actions() { }) .unwrap(); assert_eq!( - session.handle_event( - mouse(MouseEventKind::Down(MouseButton::Left), 51, 17), - &confirm, - &geometry, - ), + session_click(&mut session, &confirm, &geometry, 51, 17), EventHandling::Action(Action::Submit), "the mature dialog's visible Remove button must be a positive mouse path" ); @@ -945,18 +958,36 @@ fn every_rendered_chip_and_form_row_is_clickable() { let mut help = state(); help.update(Action::OpenHelp); - for view in [state(), searching, form_state(), report, confirmation, help] { + for (label, view) in [ + ("library", state()), + ("search", searching), + ("form", form_state()), + ("report", report), + ("confirmation", confirmation), + ("help", help), + ] { let backend = TestBackend::new(120, 30); let mut terminal = Terminal::new(backend).unwrap(); - let mut geometry = None; + let mut session = TuiSession::default(); + let mut geometry = ViewGeometry::default(); terminal - .draw(|frame| geometry = Some(render(frame, &view))) + .draw(|frame| { + geometry = render_with_session(frame, &view, Locale::En, &mut session); + }) .unwrap(); - let geometry = geometry.unwrap(); - assert!(!geometry.hits.is_empty()); - for hit in &geometry.hits { + if label == "confirmation" { assert!( - map_event( + geometry.hits.is_empty(), + "the mature dialog must keep its hit registry private" + ); + // `report_and_confirmation_keys_match_their_footer_actions` drives the dialog's + // private hit registry through its visible buttons. + continue; + } + assert!(!geometry.hits.is_empty(), "{label} exposed no root hits"); + for hit in &geometry.hits { + assert_eq!( + session.handle_event( mouse( MouseEventKind::Down(MouseButton::Left), hit.rect.x, @@ -964,9 +995,22 @@ fn every_rendered_chip_and_form_row_is_clickable() { ), &view, &geometry, - ) - .is_some(), - "unmapped hit {hit:?}" + ), + EventHandling::Consumed, + "{label} did not arm {hit:?}", + ); + assert_ne!( + session.handle_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + hit.rect.x, + hit.rect.y, + ), + &view, + &geometry, + ), + EventHandling::Ignored, + "unmapped hit {hit:?}", ); } } @@ -1024,9 +1068,12 @@ fn help_and_detail_are_real_serializable_ui_surfaces() { let mut view = state(); view.update(Action::OpenHelp); let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - let mut geometry = None; + let mut session = TuiSession::default(); + let mut geometry = ViewGeometry::default(); terminal - .draw(|frame| geometry = Some(render(frame, &view))) + .draw(|frame| { + geometry = render_with_session(frame, &view, Locale::En, &mut session); + }) .unwrap(); let text = terminal .backend() @@ -1057,25 +1104,13 @@ fn help_and_detail_are_real_serializable_ui_surfaces() { ); } let close = geometry - .unwrap() .hits - .into_iter() + .iter() .find(|hit| hit.action == HitTarget::Command(UiCommand::CloseModal)) .unwrap(); assert_eq!( - map_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - close.rect.x, - close.rect.y, - ), - &view, - &ViewGeometry { - hits: vec![close], - ..ViewGeometry::default() - }, - ), - Some(Action::Back) + session_click(&mut session, &view, &geometry, close.rect.x, close.rect.y,), + EventHandling::Action(Action::Back) ); view.update(Action::Back); @@ -1230,7 +1265,7 @@ fn narrow_footer_scroll_reaches_all_actions_in_each_supported_locale() { } #[test] -fn every_advertised_registry_command_is_keyboard_and_mouse_reachable_at_every_size_tier() { +fn every_enabled_registry_key_and_rendered_chip_work_at_every_size_tier() { for (surface, view) in registry_states() { for (width, height) in [(120, 30), (46, 12), (24, 6)] { let context = view.command_context(); @@ -1280,18 +1315,16 @@ fn every_advertised_registry_command_is_keyboard_and_mouse_reachable_at_every_si let HitTarget::Command(command) = hit.action else { continue; }; - if expected.contains(&command) && !seen.contains(&command) { + if !seen.contains(&command) { seen.push(command); assert!( matches!( - session.handle_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.rect.x, - hit.rect.y, - ), + session_click( + &mut session, &view, &geometry, + hit.rect.x, + hit.rect.y, ), EventHandling::Action(_) ), @@ -1300,30 +1333,28 @@ fn every_advertised_registry_command_is_keyboard_and_mouse_reachable_at_every_si ); } } - if seen.len() == expected.len() && expected.iter().all(|item| seen.contains(item)) { - break; - } - let anchor = geometry + let anchors = geometry .hits .iter() - .find(|hit| matches!(hit.action, HitTarget::Command(_))) - .map_or((1, height.saturating_sub(2)), |hit| { - (hit.rect.x, hit.rect.y) - }); - assert_eq!( - session.handle_event( + .filter(|hit| matches!(hit.action, HitTarget::Command(_))) + .map(|hit| (hit.rect.x, hit.rect.y)) + .collect::>(); + let mut advanced = false; + for anchor in anchors { + if session.handle_event( mouse(MouseEventKind::ScrollDown, anchor.0, anchor.1), &view, &geometry, - ), - EventHandling::Consumed, - "{surface} footer stopped before every command was reachable at {width}x{height}: {seen:?}" - ); + ) == EventHandling::Consumed + { + advanced = true; + break; + } + } + if !advanced { + break; + } } - assert!( - seen.len() == expected.len() && expected.iter().all(|item| seen.contains(item)), - "not every {surface} ({context:?}) command became a real mouse target at {width}x{height}: expected={expected:?} seen={seen:?}" - ); } } } @@ -1333,11 +1364,13 @@ fn every_library_footer_action_has_the_expected_mouse_mapping() { let view = state(); let backend = TestBackend::new(120, 30); let mut terminal = Terminal::new(backend).unwrap(); - let mut geometry = None; + let mut session = TuiSession::default(); + let mut geometry = ViewGeometry::default(); terminal - .draw(|frame| geometry = Some(render(frame, &view))) + .draw(|frame| { + geometry = render_with_session(frame, &view, Locale::En, &mut session); + }) .unwrap(); - let geometry = geometry.unwrap(); let expected = [ (UiCommand::Run, Action::OpenRun), (UiCommand::Rerun, Action::Rerun), @@ -1368,16 +1401,8 @@ fn every_library_footer_action_has_the_expected_mouse_mapping() { .find(|hit| hit.action == HitTarget::Command(command)) .unwrap(); assert_eq!( - map_event( - mouse( - MouseEventKind::Down(MouseButton::Left), - hit.rect.x, - hit.rect.y, - ), - &view, - &geometry, - ), - Some(expected_action), + session_click(&mut session, &view, &geometry, hit.rect.x, hit.rect.y,), + EventHandling::Action(expected_action), "incorrect mouse mapping for {command:?}" ); } @@ -1633,11 +1658,7 @@ fn central_session_ctrl_c_search_help_and_confirmation_priority_use_real_events( ); let (keep_x, keep_y) = buffer_position(terminal.backend().buffer(), "Keep"); assert_eq!( - session.handle_event( - mouse(MouseEventKind::Down(MouseButton::Left), keep_x, keep_y), - &confirm, - &geometry, - ), + session_click(&mut session, &confirm, &geometry, keep_x, keep_y), EventHandling::Action(Action::Back) ); for event in [ diff --git a/crates/skit-tui/tests/terminal_pty.rs b/crates/skit-tui/tests/terminal_pty.rs index 04890329..e8c1b201 100644 --- a/crates/skit-tui/tests/terminal_pty.rs +++ b/crates/skit-tui/tests/terminal_pty.rs @@ -9,7 +9,10 @@ use std::{ use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use skit_application::path_completion::{PathCompletionProvider, PathCompletionRequest}; use skit_i18n::{Locale, Localize, Message}; -use skit_tui::{collect_form, collect_run_form, run, run_preflighted, run_with_path_completion}; +use skit_tui::{ + collect_form, collect_run_form, collect_run_form_with_path_completion, run, run_preflighted, + run_with_path_completion, +}; use skit_ui::{Action, Effect, FormField, FormPurpose, FormView, LibraryState, RunFormView}; #[derive(Debug)] @@ -98,6 +101,28 @@ fn public_terminal_wrapper_child() { None ); } + "collect-run-with-path" => { + let form = RunFormView::from_declarations( + "demo", + "Wrapper run with path completion", + &[], + &BTreeMap::new(), + &[], + "", + &BTreeMap::new(), + "", + ); + assert_eq!( + collect_run_form_with_path_completion( + form, + harmless_host, + Locale::En, + Arc::new(EmptyPathProvider), + ) + .unwrap(), + None + ); + } other => panic!("unknown wrapper mode {other}"), } } @@ -117,6 +142,7 @@ fn every_public_terminal_wrapper_owns_a_real_terminal_lifecycle() { ("run", "Library"), ("run-with-path", "Library"), ("collect-run", "Extra arguments"), + ("collect-run-with-path", "Extra arguments"), ] { run_child_in_pty("public_terminal_wrapper_child", Some(mode), marker, None); } diff --git a/crates/skit-tui/tests/viewport_session_invariants.rs b/crates/skit-tui/tests/viewport_session_invariants.rs new file mode 100644 index 00000000..a83b2092 --- /dev/null +++ b/crates/skit-tui/tests/viewport_session_invariants.rs @@ -0,0 +1,370 @@ +use std::collections::BTreeSet; + +use ratatui_core::{backend::TestBackend, layout::Rect, terminal::Terminal}; +use ratatui_crossterm::crossterm::event::{Event, KeyModifiers, MouseEvent, MouseEventKind}; +use skit_i18n::Locale; +use skit_tui::{ + AddControlId, AddScreenSession, FilePickerHit, FilePickerSession, PromptCandidatePickerSession, + SettingsScreenSession, render_add, render_file_picker, render_prompt_candidate_picker, + render_settings, +}; +use skit_ui::{ + AddAction, AddEffect, AddWorkflowState, ChoicePicker, PathOutputPolicy, PathPickerState, + PathSelectionMode, PickerItem, PickerMode, PickerPurpose, SettingsInputs, SettingsView, +}; + +fn assert_empty_rect(rect: Rect, viewport: Rect, owner: &str) { + assert_eq!( + rect.height, 0, + "{owner} produced a positive-height child: {rect:?}" + ); + assert!( + rect.x >= viewport.x + && rect.right() <= viewport.right() + && rect.y >= viewport.y + && rect.bottom() <= viewport.bottom(), + "{owner} produced a child outside its empty viewport: child={rect:?} viewport={viewport:?}" + ); +} + +#[test] +fn zero_height_local_viewports_do_not_emit_positive_children_or_hits() { + let viewport = Rect::new(4, 5, 24, 0); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); + + let add = AddWorkflowState::new(Vec::new()); + let mut add_session = AddScreenSession::default(); + terminal + .draw(|frame| { + let geometry = render_add(frame, viewport, &add, &mut add_session, Locale::En); + assert_empty_rect(geometry.body, viewport, "Add body"); + for hit in geometry.hits { + assert_empty_rect(hit.area, viewport, "Add hit"); + } + }) + .unwrap(); + + let settings = SettingsView::from_inputs(&SettingsInputs::default()); + let mut settings_session = SettingsScreenSession::default(); + terminal + .draw(|frame| { + let geometry = render_settings( + frame, + viewport, + &settings, + &mut settings_session, + Locale::En, + ); + assert_empty_rect(geometry.body, viewport, "Settings body"); + for hit in geometry.hits { + assert_empty_rect(hit.area, viewport, "Settings hit"); + } + }) + .unwrap(); + + let picker = ChoicePicker::new( + PickerMode::Multiple, + vec![PickerItem::new("name".to_owned(), "name")], + Vec::new(), + ); + let mut prompt_session = PromptCandidatePickerSession::new(picker); + terminal + .draw(|frame| { + let geometry = + render_prompt_candidate_picker(frame, viewport, &mut prompt_session, Locale::En); + assert_empty_rect(geometry.search, viewport, "Prompt picker search"); + assert_empty_rect(geometry.rows, viewport, "Prompt picker rows"); + for hit in geometry.hits { + assert_empty_rect(hit.area, viewport, "Prompt picker hit"); + } + }) + .unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let contract = PathPickerState::new( + PickerPurpose::Argument, + directory.path().to_path_buf(), + PathSelectionMode::FileOrDirectory, + PathOutputPolicy::RelativeTo(directory.path().to_path_buf()), + false, + ); + let mut file_session = FilePickerSession::new(contract); + terminal + .draw(|frame| { + let geometry = render_file_picker(frame, viewport, &mut file_session, Locale::En); + assert_empty_rect(geometry.search, viewport, "File picker search"); + assert_empty_rect(geometry.rows, viewport, "File picker rows"); + for hit in geometry.hits { + assert_empty_rect(hit.area, viewport, "File picker hit"); + } + }) + .unwrap(); +} + +#[test] +fn one_row_surfaces_do_not_fabricate_body_hits_below_a_zero_height_body() { + let viewport = Rect::new(4, 5, 24, 1); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); + + let settings = SettingsView::from_inputs(&SettingsInputs::default()); + let mut settings_session = SettingsScreenSession::default(); + terminal + .draw(|frame| { + let geometry = render_settings( + frame, + viewport, + &settings, + &mut settings_session, + Locale::En, + ); + assert_eq!(geometry.body.height, 0); + assert!( + geometry.hits.is_empty(), + "Settings fabricated hits for a zero-height body: {:?}", + geometry.hits + ); + }) + .unwrap(); + + let picker = ChoicePicker::new( + PickerMode::Multiple, + vec![PickerItem::new("name".to_owned(), "name")], + Vec::new(), + ); + let mut prompt_session = PromptCandidatePickerSession::new(picker); + terminal + .draw(|frame| { + let geometry = + render_prompt_candidate_picker(frame, viewport, &mut prompt_session, Locale::En); + assert_eq!(geometry.search.height, 0); + assert_eq!(geometry.rows.height, 0); + assert!( + geometry.hits.is_empty(), + "Prompt picker fabricated hits below its zero-height rows: {:?}", + geometry.hits + ); + }) + .unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let contract = PathPickerState::new( + PickerPurpose::Argument, + directory.path().to_path_buf(), + PathSelectionMode::FileOrDirectory, + PathOutputPolicy::RelativeTo(directory.path().to_path_buf()), + false, + ); + let mut file_session = FilePickerSession::new(contract); + terminal + .draw(|frame| { + let geometry = render_file_picker(frame, viewport, &mut file_session, Locale::En); + assert_eq!(geometry.search.height, 0); + assert_eq!(geometry.rows.height, 0); + assert!( + geometry.hits.is_empty(), + "File picker fabricated hits below its zero-height rows: {:?}", + geometry.hits + ); + }) + .unwrap(); +} + +#[test] +fn add_footer_clamps_its_offset_when_only_height_grows() { + let state = AddWorkflowState::new(Vec::new()); + let mut session = AddScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(24, 13)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + + for _ in 0..12 { + let footer = geometry + .hits + .iter() + .find(|hit| hit.area.y >= geometry.body.bottom()) + .expect("the short Add footer has a visible hit") + .area; + let _ = session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: footer.x, + row: footer.y, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + } + + let mut grown = Terminal::new(TestBackend::new(24, 14)).unwrap(); + grown + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + let footer_rows = geometry + .hits + .iter() + .filter(|hit| { + matches!( + hit.target, + AddControlId::NextField | AddControlId::PreviousField + ) + }) + .map(|hit| hit.area.y) + .collect::>(); + assert_eq!( + footer_rows.len(), + 2, + "height growth must expose both local footer rows after clamping" + ); +} + +#[test] +fn file_picker_footer_clamps_its_offset_when_only_height_grows() { + let directory = tempfile::tempdir().unwrap(); + let contract = PathPickerState::new( + PickerPurpose::Argument, + directory.path().to_path_buf(), + PathSelectionMode::FileOrDirectory, + PathOutputPolicy::RelativeTo(directory.path().to_path_buf()), + false, + ); + let mut session = FilePickerSession::new(contract); + let mut terminal = Terminal::new(TestBackend::new(52, 11)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + + for _ in 0..12 { + let footer = geometry + .hits + .iter() + .find(|hit| { + matches!( + hit.target, + FilePickerHit::Accept + | FilePickerHit::Cancel + | FilePickerHit::Up + | FilePickerHit::Hidden + ) + }) + .expect("the compact file footer has a visible hit") + .area; + let _ = session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: footer.x, + row: footer.y, + modifiers: KeyModifiers::NONE, + }), + &geometry, + ); + terminal + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + } + + let mut grown = Terminal::new(TestBackend::new(52, 12)).unwrap(); + grown + .draw(|frame| { + geometry = render_file_picker(frame, frame.area(), &mut session, Locale::En); + }) + .unwrap(); + let footer_rows = geometry + .hits + .iter() + .filter(|hit| { + matches!( + hit.target, + FilePickerHit::Accept + | FilePickerHit::Cancel + | FilePickerHit::Up + | FilePickerHit::Hidden + ) + }) + .map(|hit| hit.area.y) + .collect::>(); + assert_eq!( + footer_rows.len(), + 2, + "height growth must expose both file-picker footer rows after clamping" + ); +} + +#[test] +fn add_long_problem_note_reaches_its_wrapped_sentinel() { + let mut state = AddWorkflowState::new(Vec::new()); + let _ = state.reduce(AddAction::SetSourcePath("missing.py".to_owned())); + let request = state + .reduce(AddAction::Continue) + .into_iter() + .find_map(|effect| match effect { + AddEffect::InspectSource { request, .. } => Some(request), + _ => None, + }) + .expect("Continue requests source inspection"); + let _ = state.reduce(AddAction::SourceInspected { + request, + result: Err(format!( + "{}ADD-END", + "a long source inspection problem ".repeat(16) + )), + }); + let mut session = AddScreenSession::default(); + let mut terminal = Terminal::new(TestBackend::new(24, 8)).unwrap(); + let mut geometry = Default::default(); + terminal + .draw(|frame| geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En)) + .unwrap(); + + let mut last = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + for _ in 0..40 { + if last.contains("ADD-END") { + break; + } + let _ = session.handle_event( + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: geometry.body.x, + row: geometry.body.y, + modifiers: KeyModifiers::NONE, + }), + &state, + &geometry, + ); + terminal + .draw(|frame| { + geometry = render_add(frame, frame.area(), &state, &mut session, Locale::En); + }) + .unwrap(); + last = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + } + assert!(last.contains("ADD-END"), "{last}"); +} diff --git a/crates/skit-ui/src/lib.rs b/crates/skit-ui/src/lib.rs index 4787df56..5fc17ce4 100644 --- a/crates/skit-ui/src/lib.rs +++ b/crates/skit-ui/src/lib.rs @@ -1807,6 +1807,7 @@ impl LibraryState { Action::SelectVisible(index) => { if index < self.visible.len() { self.selected = Some(index); + self.input_mode = InputMode::Browse; } } Action::BeginSearch => self.input_mode = InputMode::Search, diff --git a/docs/design/python-test-port-ledger.md b/docs/design/python-test-port-ledger.md index 1b3a60bf..0d8c48aa 100644 --- a/docs/design/python-test-port-ledger.md +++ b/docs/design/python-test-port-ledger.md @@ -224,10 +224,10 @@ The five `test_store.py` closures retain their ignored bodies and exact oracle n | Python module | # | Rust target | Status | | --- | --- | --- | --- | -| test_prompt_tui.py | 83 | crates/skit-tui/tests/port_test_prompt_tui.rs + crates/skit-cli/tests/terminal_pty.rs | implementation parity · selected-runner ProgramNotFound returns to Library without child/state writes `0f76635`; zero-runner prompts open the shared runner editor and cancel/save/failure paths match the oracle `949a51a` · real MouseDown runner selection and pinned/unpinned rerun run at the live TUI host `f8091da` · eight Settings candidate owners are active; option-level keyboard navigation, v0.4 row order/overflow, and the single inline chooser route are complete `c4b8659`/`6b29372` · final localized prompt receipts are owned by `210a73a` | +| test_prompt_tui.py | 83 | crates/skit-tui/tests/port_test_prompt_tui.rs + crates/skit-cli/tests/terminal_pty.rs | implementation parity · selected-runner ProgramNotFound returns to Library without child/state writes `0f76635`; zero-runner prompts open the shared runner editor and cancel/save/failure paths match the oracle `949a51a` · real primary press and matching release select a runner, and pinned/unpinned rerun runs at the live TUI host `f8091da` · eight Settings candidate owners are active; option-level keyboard navigation, v0.4 row order/overflow, and the single inline chooser route are complete `c4b8659`/`6b29372` · final localized prompt receipts are owned by `210a73a` | | test_path_tui.py | 61 | crates/skit-tui/tests/port_test_path_tui.rs + application/store completion ports + CLI PTY | implementation parity (57 REAL / 4 cross-crate closures; target 62 pass / 4 ignored with 5 Rust race/reverse owners) · typed bounded scanner, async latest-wins ghost, activation/token/shlex/brace/hidden/error/no-cache behavior, secret exclusion, Right acceptance, vanished-origin form projection, and real PTY launch are complete `ef76e01`..`0f7703a` · current-directory picker, paging, and picked-path escaping remain covered (`9718a1a`, `98b6bc8`, `0fd6d85`) | | test_phase1.py | 27 | crates/skit-cli/tests/port_test_phase1.rs | done (27) · no gap | -| test_tui_responsive.py | 19 | crates/skit-tui/tests/port_test_tui_responsive.rs + manifest | done (17 executable) · full replacement `459765a` · detail pin `462aa9e` · compact modal controls `ef7c1ef` · short Search `061d29c` · narrow Preferences `9238165` · 2 Textual-only architecture-closed | +| test_tui_responsive.py | 19 | crates/skit-tui/tests/port_test_tui_responsive.rs + manifest | done (17 executable) · full replacement `459765a` · detail pin `462aa9e` · compact modal controls `ef7c1ef` · short Search `061d29c` · narrow Preferences `9238165` · 2 Textual-only architecture-closed · additive Rust contracts pin the 80-column boundary, the 10/16/28-row boundaries, zero-size geometry, body-first root allocation, and clickable wrapped or scrolled footer chips | | test_settings_and_draft_review_atomicity.py | 16 | crates/skit-ui/tests/port_test_settings_and_draft_review_atomicity.rs + private CLI Settings host | implementation parity (9/7) · the real FileStore owner now proves cleanup failure leaves every Settings axis, metadata, source, and node_modules unchanged; CLI explicit repeated clear and Settings share the same identity/name preflight and cleanup-before-update seam `fd50836` | | test_draft_and_reader_tui.py | 16 | crates/skit-tui/tests/port_test_draft_and_reader_tui.rs + crates/skit-cli/tests/terminal_pty.rs | implementation parity (16 REAL / 0 ignored + 1 reducer additive) · real-host bash draft resume/consume and identity-carrying effects `6b5c6f0` · focused Ctrl+D, draft shortcut, footer click `f6516fa` | | test_reset_default_ui.py | 14 | crates/skit-cli/tests/terminal_pty.rs + crates/skit-tui/tests/port_test_reset_default_ui.rs | implementation parity (12 executable / 2 cross-crate closures) · the real plain PTY now prints field-owned help, degraded-default, and intercepted-input hints in all three locales before the matching prompt, with dry-run no-write proof · live source defaults remain reconciled only into display clones and use source-only Python repr | @@ -235,6 +235,13 @@ The five `test_store.py` closures retain their ignored bodies and exact oracle n | test_tui_nav.py | 5 | crates/skit-tui/tests/port_test_tui_nav.rs | implementation parity (5/0) · Add Source field navigation and stage-scoped Ctrl+O Browse `617f3ee`; Settings multiline boundary yield `8802cb7`; Add Review `8eb9b03`; Preferences shared fallback `afeae66` | | test_ime_input.py | 3 | crates/skit-tui/tests/port_test_ime_input.rs | done (0) · 3 cross-crate (kitty-key/PTY terminal input) | +The following evidence is additive. It does not change the Python port counts in this table. +Shared pointer tests cover semantic primary press and matching release, cancellation, topmost hit +order, and cell-to-caret mapping for wide, combining, emoji-ZWJ, secret, and horizontally scrolled +text. Screen owners cover exact wheel containment and the last visual row. The live Unix PTY sends +SGR mouse bytes, checks selective dirty redraws, and replays output through `vt100` before it asserts +the final visible grid. + ### Tier 6 — i18n and packaging (`skit-i18n`, tooling) | Python module | # | Rust target | Status | diff --git a/docs/design/rust-contract-matrix.md b/docs/design/rust-contract-matrix.md index e7c7720b..33b39e0c 100644 --- a/docs/design/rust-contract-matrix.md +++ b/docs/design/rust-contract-matrix.md @@ -25,7 +25,7 @@ progress` is a release blocker; it is not a permitted behavior change. | Add lanes, drafts, kind picker, analysis review, edit/reanalyse, and atomic commit | Complete | application/UI/TUI workflow, transaction, editor, and pinned-main add-lane/review owners | | Run, preset, token, environment, file, runner, and default form commands | Complete | application/form/UI/TUI, real PTY, picker, and pinned-main form/prompt owners | | Settings, preferences, health, runner manager, Agent Skill, and dirty guards | Complete | application/UI/TUI transaction and responsive-management owners plus pinned-main manifests | -| Every advertised TUI command has positive keyboard and mouse owners at every documented responsive size tier | Complete | shared command-registry and local-action inventories at `120x30`, `46x12`, and `24x6`, plus reducer, `TestBackend`, and real PTY owners | +| Every advertised TUI command has positive keyboard and mouse owners at every documented responsive size tier | Complete | The shared layout contracts pin the width boundary at 80 columns and the height boundaries at 10, 16, and 28 rows. Boundary, zero-size, three-locale, command-registry, and local-action owners use the reducer and `TestBackend`. Pointer owners require primary Down and a matching semantic Up, keep Unicode caret placement on grapheme boundaries, contain wheel input in the topmost visible owner, and cancel stale presses. A real 46x12 PTY sends SGR mouse input, checks selective redraws, and uses a `vt100` final-grid oracle to reject stale history text | | Complete en/zh-CN/zh-TW catalog and stable machine English | Complete | `skit-i18n/tests/catalog.rs`, crate localization tests, English/tooling gates, and three-locale PTY/TestBackend owners | | PyPI/Maturin wheel, `uv tool`, archives, security, coverage, docs, benchmarks | Complete | Local wheel/sdist installs, embedded-Skill and real-run smoke, corpus/manifest checks, deny/audit/Zizmor, 100% LCOV, docs build/link check, 112 metrics with 8/8 evaluated enforced budgets, and 23 Criterion estimates pass; three-platform native CI, coverage, and supply-chain workflows are green at the release head. The owner moved full mutation adjudication to issue #47: the pipeline fix is in this tree, and the follow-up runs the 64-shard matrix to zero survivors | | Future Tauri uses the same application/form/UI state and command registry | Complete | serializable `skit-ui` round-trip, frontend-neutral effects, typed application/form ports, and frontend-parity tests | @@ -36,16 +36,6 @@ Version 0.5 keeps the behavior of the pinned oracle. Where it says something dif purpose, the change is recorded here with both spellings, so a reader never has to guess whether a difference is a decision or a defect. -### The discard confirmation also names its surface - -Version 0.4 shows `Discard unsaved changes?` once inside an untitled border -(`src/skit/tui_settings.py:42-65`). Version 0.5 shows the question in that body and in the shared -screen header, so it appears twice. - -The port names every surface in the header. Examples include `Help`, `Confirm removal`, and `Save -as preset`. The second question keeps this surface-naming convention and uses the existing -localized copy. - ### The library item is an "entry", not a "script" Version 0.4 calls every library item a script, because it started as a script launcher. Version 0.5 diff --git a/docs/design/rust-rewrite.md b/docs/design/rust-rewrite.md index ceca0d22..897a7b7e 100644 --- a/docs/design/rust-rewrite.md +++ b/docs/design/rust-rewrite.md @@ -80,6 +80,29 @@ was incorrect. They are required product capabilities and are release blockers u The executable status and evidence for these areas is in `rust-contract-matrix.md`. This document does not claim that the cutover is complete while any matrix row is incomplete. +## Ratatui layout and input contracts + +The Ratatui adapter classifies each frame once. A width below 80 columns is narrow. Heights 0-9 +are tiny, 10-15 are short, 16-27 are normal, and 28 or more are tall. The root planner gives the +primary body its minimum rows before it assigns header and footer rows. Short and tiny layouts use +a flat Library search row and an undecorated footer. Self-titled screens and modals own their title; +they do not also render a global title. Boundary tests cover zero-size viewports, each adjacent tier, +all three locales, hit containment, and body growth that never shrinks the primary viewport. + +Mouse actions use one semantic press-and-release contract. Primary Down arms the target. A primary +Up activates it only when the semantic target still matches. A drag, a nonprimary button, a resize, +or a different owner cancels the press. An editable field can place its caret on primary Down, but +that placement does not activate an action. Shared geometry maps terminal cells to complete ASCII, +wide, combining, emoji-ZWJ, secret, and horizontally scrolled graphemes. Wheel input belongs to the +topmost visible list, dropdown, modal, body, or footer under the pointer. It cancels an armed click +and clamps the owner's scroll offset to its final visual rows. + +The live Unix PTY test sends real SGR mouse Down and Up bytes at 46x12. It verifies the 1003 and 1006 +mode transitions and their shutdown restoration. The test replays all output through `vt100` and checks +the final terminal grid, so erased history text cannot pass as visible text. Ignored pointer motion +emits no frame. Consumed input, reducer actions, resize events, and completed background work request +a redraw. The dirty flag uses a monotonic OR until the loop draws the next frame. + ## Localized messages `skit-i18n` owns one static catalog and two presentation types: