From f7a2bcdfbaec52444005167c6fe745a721ec6d01 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:13:32 +0900 Subject: [PATCH 01/17] feat: add TestBackend region/snapshot query helpers Add find_text, region/assert_region, assert_styled_contains, snapshot, and assert_snapshot_eq to TestBackend for ergonomic content+style and sub-rectangle assertions, plus unified-diff snapshot comparison. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/test_utils.rs | 417 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) diff --git a/src/test_utils.rs b/src/test_utils.rs index 54a43c6..0287545 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -574,6 +574,240 @@ impl TestBackend { ); } + // ---- Region queries + snapshot diffing (#283) ------------------------- + + /// Find the first buffer position where `needle` begins in the rendered + /// text grid, scanning rows top-to-bottom and columns left-to-right. + /// + /// Each cell contributes its glyph at the cell's own column; empty cells + /// (blanks and wide-char trailing cells) count as a single space so the + /// returned `x` is the actual buffer column where the match starts. The + /// search is per-row — a needle that wraps across a row boundary is not + /// matched. Returns `None` if `needle` is empty or absent. + /// + /// # Example + /// + /// ``` + /// use slt::TestBackend; + /// + /// let mut tb = TestBackend::new(20, 2); + /// tb.render(|ui| { + /// ui.text(" hello"); + /// }); + /// assert_eq!(tb.find_text("hello"), Some((2, 0))); + /// assert_eq!(tb.find_text("nope"), None); + /// ``` + pub fn find_text(&self, needle: &str) -> Option<(u32, u32)> { + if needle.is_empty() { + return None; + } + for y in 0..self.height { + // Build the row text alongside a per-character map back to the + // originating buffer column, so a byte match in `row` resolves to + // the correct `x`. Empty cells render as a single space. + let mut row = String::new(); + // byte offset in `row` -> buffer column x + let mut col_at_byte: Vec = Vec::with_capacity(self.width as usize); + for x in 0..self.width { + let cell = self.buffer.get(x, y); + let sym: &str = if cell.symbol.is_empty() { + " " + } else { + cell.symbol.as_str() + }; + for _ in 0..sym.len() { + col_at_byte.push(x); + } + row.push_str(sym); + } + if let Some(byte_idx) = row.find(needle) { + let x = col_at_byte.get(byte_idx).copied().unwrap_or(0); + return Some((x, y)); + } + } + None + } + + /// Assert that the rectangular region anchored at `(x, y)` with width `w` + /// and height `h` renders exactly `expected` (rows joined with `\n`). + /// + /// Each region row is the slice of buffer columns `x..x+w` on buffer row + /// `y..y+h`, with empty cells rendered as a space and **trailing** spaces + /// of each region row preserved (so width is significant). Columns or rows + /// that fall outside the buffer are treated as blanks. Panics with an + /// aligned expected-vs-actual diff on mismatch. + /// + /// # Example + /// + /// ``` + /// use slt::TestBackend; + /// + /// let mut tb = TestBackend::new(10, 3); + /// tb.render(|ui| { + /// let _ = ui.col(|ui| { + /// ui.text("ab"); + /// ui.text("cd"); + /// }); + /// }); + /// tb.assert_region(0, 0, 2, 2, "ab\ncd"); + /// ``` + pub fn assert_region(&self, x: u32, y: u32, w: u32, h: u32, expected: &str) { + let actual = self.region(x, y, w, h); + if actual != expected { + panic!( + "Region ({x}, {y}, {w}x{h}) mismatch.\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n----------------" + ); + } + } + + /// Render the rectangular region anchored at `(x, y)` with width `w` and + /// height `h` as a multi-line string (rows joined with `\n`). + /// + /// Empty cells render as a single space and trailing spaces are preserved, + /// so the result is exactly `w` columns wide per row. Columns or rows + /// outside the buffer are blank-filled. Useful for scoping a snapshot to a + /// sub-rectangle without asserting on the full buffer. + pub fn region(&self, x: u32, y: u32, w: u32, h: u32) -> String { + let mut rows = Vec::with_capacity(h as usize); + for row in y..y.saturating_add(h) { + let mut s = String::new(); + for col in x..x.saturating_add(w) { + if row < self.height && col < self.width { + let cell = self.buffer.get(col, row); + if cell.symbol.is_empty() { + s.push(' '); + } else { + s.push_str(cell.symbol.as_str()); + } + } else { + s.push(' '); + } + } + rows.push(s); + } + rows.join("\n") + } + + /// Assert that `needle` is rendered somewhere in the buffer AND every cell + /// of the matched run satisfies `predicate` (applied to each cell's + /// [`Style`]). + /// + /// Combines a content check with a per-cell style check, which is more + /// ergonomic than pairing [`find_text`](TestBackend::find_text) with + /// repeated [`assert_style_at`](TestBackend::assert_style_at) calls. The + /// run is located with `find_text` (per-row, left-to-right), then each of + /// the `needle`'s `char`-count cells starting at the match is tested. + /// Panics if the needle is absent or any covered cell fails the predicate. + /// + /// # Example + /// + /// ``` + /// use slt::{Color, TestBackend}; + /// + /// let mut tb = TestBackend::new(20, 1); + /// tb.render(|ui| { + /// ui.text("hi").fg(Color::Red).bold(); + /// }); + /// tb.assert_styled_contains("hi", |s| { + /// s.fg == Some(Color::Red) && s.modifiers.contains(slt::Modifiers::BOLD) + /// }); + /// ``` + pub fn assert_styled_contains(&self, needle: &str, predicate: impl Fn(&Style) -> bool) { + let Some((x, y)) = self.find_text(needle) else { + let mut all_lines = String::new(); + for row in 0..self.height { + all_lines.push_str(&format!("{}: {}\n", row, self.line(row))); + } + panic!("Buffer does not contain {needle:?}.\nBuffer:\n{all_lines}"); + }; + // The match spans one cell per `char` in the needle. Wide glyphs occupy + // their own cell; the trailing blank cell is not part of the run. + let span = needle.chars().count() as u32; + for offset in 0..span { + let cx = x + offset; + let style = self.buffer.get(cx, y).style; + assert!( + predicate(&style), + "Style predicate failed for {needle:?} at cell ({cx}, {y}): style is {style:?}" + ); + } + } + + /// Produce a stable, plain-text snapshot of the whole buffer. + /// + /// Every buffer row is rendered exactly `width` columns wide (empty cells + /// as spaces, no trailing trim) and joined with `\n`. Unlike + /// [`to_string_trimmed`](TestBackend::to_string_trimmed), no trailing blank + /// rows are dropped and per-row width is fixed, giving a deterministic + /// snapshot suitable for [`assert_snapshot_eq`](TestBackend::assert_snapshot_eq) + /// or external snapshot tooling. + /// + /// # Example + /// + /// ``` + /// use slt::TestBackend; + /// + /// let mut tb = TestBackend::new(3, 2); + /// tb.render(|ui| { + /// ui.text("ab"); + /// }); + /// assert_eq!(tb.snapshot(), "ab \n "); + /// ``` + pub fn snapshot(&self) -> String { + self.region(0, 0, self.width, self.height) + } + + /// Assert the buffer [`snapshot`](TestBackend::snapshot) equals `expected`, + /// panicking with a unified-diff-style report on mismatch. + /// + /// Trailing whitespace on each line of `expected` is ignored (the actual + /// snapshot is right-padded to the buffer width), so callers can write + /// trimmed expected strings. The panic message lists each differing row + /// with `-` (expected) / `+` (actual) markers. + /// + /// # Example + /// + /// ``` + /// use slt::TestBackend; + /// + /// let mut tb = TestBackend::new(5, 2); + /// tb.render(|ui| { + /// ui.text("hi"); + /// }); + /// tb.assert_snapshot_eq("hi\n"); + /// ``` + pub fn assert_snapshot_eq(&self, expected: &str) { + let actual = self.snapshot(); + // Compare row-by-row, ignoring trailing whitespace differences so the + // expected literal can be written without padding to the full width. + let actual_rows: Vec<&str> = actual.lines().collect(); + let expected_rows: Vec<&str> = expected.lines().collect(); + let row_count = actual_rows.len().max(expected_rows.len()); + let mut mismatched = false; + for i in 0..row_count { + let a = actual_rows.get(i).copied().unwrap_or(""); + let e = expected_rows.get(i).copied().unwrap_or(""); + if a.trim_end() != e.trim_end() { + mismatched = true; + break; + } + } + if mismatched { + let mut diff = String::new(); + for i in 0..row_count { + let a = actual_rows.get(i).copied().unwrap_or(""); + let e = expected_rows.get(i).copied().unwrap_or(""); + if a.trim_end() == e.trim_end() { + diff.push_str(&format!(" {}\n", a.trim_end())); + } else { + diff.push_str(&format!("- {}\n", e.trim_end())); + diff.push_str(&format!("+ {}\n", a.trim_end())); + } + } + panic!("Snapshot mismatch (- expected, + actual):\n{diff}"); + } + } + // ---- Multi-step sequences + type_string (#230) ------------------------ /// Begin building a multi-step interaction sequence. @@ -1282,4 +1516,187 @@ mod tests { let expected = Style::new().fg(Color::Blue); tb.assert_style_at(0, 0, expected); } + + // ---- #283 region queries + snapshot diffing ----------------------------- + + #[test] + fn find_text_returns_first_match_position() { + let mut tb = TestBackend::new(20, 2); + tb.render(|ui| { + ui.text(" hello"); + }); + assert_eq!(tb.find_text("hello"), Some((2, 0))); + } + + #[test] + fn find_text_scans_rows_top_to_bottom() { + let mut tb = TestBackend::new(20, 3); + tb.render(|ui| { + let _ = ui.col(|ui| { + ui.text("alpha"); + ui.text("beta"); + }); + }); + assert_eq!(tb.find_text("beta"), Some((0, 1))); + } + + #[test] + fn find_text_returns_none_when_absent() { + let mut tb = TestBackend::new(10, 1); + tb.render(|ui| { + ui.text("present"); + }); + assert_eq!(tb.find_text("missing"), None); + } + + #[test] + fn find_text_empty_needle_is_none() { + // Edge case: an empty needle never yields a position. + let mut tb = TestBackend::new(10, 1); + tb.render(|ui| { + ui.text("x"); + }); + assert_eq!(tb.find_text(""), None); + } + + #[test] + fn region_returns_padded_rectangle() { + let mut tb = TestBackend::new(10, 3); + tb.render(|ui| { + let _ = ui.col(|ui| { + ui.text("ab"); + ui.text("cd"); + }); + }); + // Width 3 keeps a trailing space; rows are exactly 3 wide. + assert_eq!(tb.region(0, 0, 3, 2), "ab \ncd "); + } + + #[test] + fn region_out_of_bounds_blank_fills() { + // Edge case: a region partly past the buffer pads with spaces. + let mut tb = TestBackend::new(2, 1); + tb.render(|ui| { + ui.text("z"); + }); + assert_eq!(tb.region(0, 0, 4, 2), "z \n "); + } + + #[test] + fn assert_region_passes_for_match() { + let mut tb = TestBackend::new(10, 3); + tb.render(|ui| { + let _ = ui.col(|ui| { + ui.text("ab"); + ui.text("cd"); + }); + }); + tb.assert_region(0, 0, 2, 2, "ab\ncd"); + } + + #[test] + #[should_panic(expected = "Region (0, 0, 2x2) mismatch")] + fn assert_region_panics_on_mismatch() { + let mut tb = TestBackend::new(10, 3); + tb.render(|ui| { + let _ = ui.col(|ui| { + ui.text("ab"); + ui.text("cd"); + }); + }); + tb.assert_region(0, 0, 2, 2, "ab\nXY"); + } + + #[test] + fn assert_styled_contains_passes_for_styled_run() { + use crate::style::{Color, Modifiers}; + let mut tb = TestBackend::new(20, 1); + tb.render(|ui| { + ui.text("hi").fg(Color::Red).bold(); + }); + tb.assert_styled_contains("hi", |s| { + s.fg == Some(Color::Red) && s.modifiers.contains(Modifiers::BOLD) + }); + } + + #[test] + #[should_panic(expected = "Style predicate failed")] + fn assert_styled_contains_panics_on_style_mismatch() { + use crate::style::Color; + let mut tb = TestBackend::new(20, 1); + tb.render(|ui| { + ui.text("hi").fg(Color::Red); + }); + // Content is present but the color predicate fails. + tb.assert_styled_contains("hi", |s| s.fg == Some(Color::Blue)); + } + + #[test] + #[should_panic(expected = "Buffer does not contain")] + fn assert_styled_contains_panics_when_absent() { + let mut tb = TestBackend::new(20, 1); + tb.render(|ui| { + ui.text("hi"); + }); + tb.assert_styled_contains("bye", |_| true); + } + + #[test] + fn snapshot_is_full_width_and_height() { + let mut tb = TestBackend::new(3, 2); + tb.render(|ui| { + ui.text("ab"); + }); + // No trailing trim, fixed width, trailing blank row preserved. + assert_eq!(tb.snapshot(), "ab \n "); + } + + #[test] + fn assert_snapshot_eq_passes_ignoring_trailing_ws() { + let mut tb = TestBackend::new(5, 2); + tb.render(|ui| { + ui.text("hi"); + }); + // Expected lacks the padding spaces — trailing whitespace is ignored. + tb.assert_snapshot_eq("hi\n"); + } + + #[test] + #[should_panic(expected = "Snapshot mismatch")] + fn assert_snapshot_eq_panics_with_diff() { + let mut tb = TestBackend::new(5, 1); + tb.render(|ui| { + ui.text("hi"); + }); + tb.assert_snapshot_eq("bye"); + } + + #[test] + fn assert_snapshot_eq_diff_marks_offending_row() { + // Failure-message smoke test: catch the panic and inspect the diff + // markers rather than asserting only on the panic message prefix. + let mut tb = TestBackend::new(5, 2); + tb.render(|ui| { + let _ = ui.col(|ui| { + ui.text("ok"); + ui.text("bad"); + }); + }); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tb.assert_snapshot_eq("ok\nXXX"); + })); + let err = result.expect_err("expected snapshot assertion to panic"); + let msg = err + .downcast_ref::() + .map(String::as_str) + .or_else(|| err.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + msg.contains("- XXX"), + "diff should mark expected row: {msg}" + ); + assert!(msg.contains("+ bad"), "diff should mark actual row: {msg}"); + // The matching first row is shown without a +/- marker. + assert!(msg.contains(" ok"), "diff should echo matching row: {msg}"); + } } From e4899e2571cb38ae1cc40925b297130446902ac2 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:14:38 +0900 Subject: [PATCH 02/17] feat(text): add multi-stop and background text gradients Extend the text gradient API additively: - gradient_stops(&[(f32, Color)]): multi-stop horizontal foreground gradient, stops clamped to 0..1 and sorted internally; empty slice is a no-op, single stop renders solid. - bg_gradient(from, to) / bg_gradient_stops(&[(f32, Color)]): background analogues applying the gradient to cell background instead of fg. All variants reuse the existing per-column char mapping (denom = len-1, t = i/denom) and Color::blend clamping so width handling stays consistent with gradient(). Adds 8 TestBackend unit tests covering first/middle/last interpolation, unsorted input, single stop, and empty edge cases for both fg and bg. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/widgets_display/text.rs | 327 ++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/src/context/widgets_display/text.rs b/src/context/widgets_display/text.rs index 620eab3..98c4b3e 100644 --- a/src/context/widgets_display/text.rs +++ b/src/context/widgets_display/text.rs @@ -226,6 +226,182 @@ impl Context { self } + /// Apply a per-character multi-stop foreground gradient to the last text. + /// + /// `stops` is a slice of `(position, color)` pairs where `position` lies in + /// `0.0..=1.0`. Stops do not need to be pre-sorted. The text is colored by + /// linearly interpolating between adjacent stops across its displayed + /// columns, using the same column-mapping and clamping as [`gradient`]. + /// + /// - An empty slice is a no-op (the text keeps its current style). + /// - A single stop produces a solid color. + /// + /// [`gradient`]: Self::gradient + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// use slt::Color; + /// ui.text("rainbow").gradient_stops(&[ + /// (0.0, Color::Red), + /// (0.5, Color::Yellow), + /// (1.0, Color::Green), + /// ]); + /// # }); + /// ``` + pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self { + if stops.is_empty() { + return self; + } + let sorted = Self::sorted_gradient_stops(stops); + self.apply_char_gradient(false, |t| Self::sample_gradient_stops(&sorted, t)); + self + } + + /// Apply a per-character background gradient to the last rendered text. + /// + /// The two-stop background analogue of [`gradient`]. Colors the cell + /// background instead of the foreground, using identical column-mapping and + /// clamping so width handling stays consistent. + /// + /// [`gradient`]: Self::gradient + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// use slt::Color; + /// ui.text("banner").bg_gradient(Color::Blue, Color::Magenta); + /// # }); + /// ``` + pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self { + self.apply_char_gradient(true, |t| from.blend(to, t)); + self + } + + /// Apply a per-character multi-stop background gradient to the last text. + /// + /// The background analogue of [`gradient_stops`]: identical stop handling + /// (positions in `0.0..=1.0`, unsorted-safe, empty = no-op, single stop = + /// solid) but applied to the cell background instead of the foreground. + /// + /// [`gradient_stops`]: Self::gradient_stops + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// use slt::Color; + /// ui.text("header").bg_gradient_stops(&[ + /// (0.0, Color::Blue), + /// (1.0, Color::Magenta), + /// ]); + /// # }); + /// ``` + pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self { + if stops.is_empty() { + return self; + } + let sorted = Self::sorted_gradient_stops(stops); + self.apply_char_gradient(true, |t| Self::sample_gradient_stops(&sorted, t)); + self + } + + /// Return `stops` sorted ascending by clamped position. Positions are + /// clamped into `0.0..=1.0` so out-of-range inputs degrade gracefully. + fn sorted_gradient_stops(stops: &[(f32, Color)]) -> Vec<(f32, Color)> { + let mut sorted: Vec<(f32, Color)> = stops + .iter() + .map(|(pos, color)| (pos.clamp(0.0, 1.0), *color)) + .collect(); + sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + sorted + } + + /// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted, + /// non-empty `stops`, linearly interpolating between the bracketing stops. + fn sample_gradient_stops(stops: &[(f32, Color)], t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + // Non-empty is guaranteed by callers; fall back defensively otherwise. + let first = match stops.first() { + Some(stop) => *stop, + None => return Color::Rgb(0, 0, 0), + }; + let last = *stops.last().unwrap_or(&first); + if t <= first.0 { + return first.1; + } + if t >= last.0 { + return last.1; + } + for window in stops.windows(2) { + let (p0, c0) = window[0]; + let (p1, c1) = window[1]; + if t >= p0 && t <= p1 { + let span = p1 - p0; + if span <= f32::EPSILON { + return c1; + } + let local = (t - p0) / span; + // blend(self, other, alpha) = self*alpha + other*(1-alpha): + // c1.blend(c0, local) yields c0 at local=0 and c1 at local=1. + return c1.blend(c0, local); + } + } + last.1 + } + + /// Replace the last `Text` command with a `RichText` gradient, mapping each + /// character's column to a position in `0.0..=1.0` exactly like + /// [`gradient`](Self::gradient). `is_bg` selects background vs foreground. + fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f32) -> Color) { + if let Some(idx) = self.rollback.last_text_idx { + let replacement = match &self.commands[idx] { + Command::Text { + content, + style, + wrap, + align, + margin, + constraints, + .. + } => { + let chars: Vec = content.chars().collect(); + let len = chars.len(); + let denom = len.saturating_sub(1).max(1) as f32; + let segments = chars + .into_iter() + .enumerate() + .map(|(i, ch)| { + let mut seg_style = *style; + let color = color_at(i as f32 / denom); + if is_bg { + seg_style.bg = Some(color); + } else { + seg_style.fg = Some(color); + } + (ch.to_string(), seg_style) + }) + .collect(); + + Some(Command::RichText { + segments, + wrap: *wrap, + align: *align, + margin: *margin, + constraints: *constraints, + }) + } + _ => None, + }; + + if let Some(command) = replacement { + self.commands[idx] = command; + } + } + } + /// Set foreground color when the current group is hovered or focused. pub fn group_hover_fg(&mut self, color: Color) -> &mut Self { let apply_group_style = self @@ -534,3 +710,154 @@ impl Context { self } } + +#[cfg(test)] +mod gradient_tests { + use super::*; + use crate::TestBackend; + + #[test] + fn gradient_stops_interpolates_fg_across_columns() { + let red = Color::Rgb(255, 0, 0); + let blue = Color::Rgb(0, 0, 255); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + ui.text("ABC").gradient_stops(&[(0.0, red), (1.0, blue)]); + }); + + let buf = backend.buffer(); + // i=0 → t=0 → stop at 0.0 (red); i=2 → t=1 → stop at 1.0 (blue); + // i=1 → t=0.5 → halfway blend. + assert_eq!( + buf.get(0, 0).style.fg, + Some(red), + "first column should be red" + ); + assert_eq!( + buf.get(1, 0).style.fg, + Some(Color::Rgb(128, 0, 128)), + "middle column should be the halfway blend" + ); + assert_eq!( + buf.get(2, 0).style.fg, + Some(blue), + "last column should be blue" + ); + } + + #[test] + fn gradient_stops_unsorted_input_is_sorted() { + let red = Color::Rgb(255, 0, 0); + let blue = Color::Rgb(0, 0, 255); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + // Deliberately out of order — must behave identically to sorted. + ui.text("ABC").gradient_stops(&[(1.0, blue), (0.0, red)]); + }); + + let buf = backend.buffer(); + assert_eq!(buf.get(0, 0).style.fg, Some(red)); + assert_eq!(buf.get(2, 0).style.fg, Some(blue)); + } + + #[test] + fn gradient_stops_multi_stop_hits_middle_stop_exactly() { + let red = Color::Rgb(255, 0, 0); + let green = Color::Rgb(0, 255, 0); + let blue = Color::Rgb(0, 0, 255); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + // len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0. + ui.text("ABC") + .gradient_stops(&[(0.0, red), (0.5, green), (1.0, blue)]); + }); + + let buf = backend.buffer(); + assert_eq!(buf.get(0, 0).style.fg, Some(red), "t=0 → first stop"); + assert_eq!( + buf.get(1, 0).style.fg, + Some(green), + "t=0.5 → middle stop exactly" + ); + assert_eq!(buf.get(2, 0).style.fg, Some(blue), "t=1 → last stop"); + } + + #[test] + fn gradient_stops_single_stop_is_solid() { + let cyan = Color::Rgb(0, 200, 200); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + ui.text("ABCD").gradient_stops(&[(0.0, cyan)]); + }); + + let buf = backend.buffer(); + for x in 0..4 { + assert_eq!( + buf.get(x, 0).style.fg, + Some(cyan), + "every column should be the single solid stop" + ); + } + } + + #[test] + fn gradient_stops_empty_is_noop() { + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + // Empty slice must not panic and must leave content intact. + ui.text("HELLO").gradient_stops(&[]); + }); + + backend.assert_contains("HELLO"); + } + + #[test] + fn bg_gradient_applies_to_background() { + let red = Color::Rgb(255, 0, 0); + let blue = Color::Rgb(0, 0, 255); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + ui.text("ABC").bg_gradient(red, blue); + }); + + let buf = backend.buffer(); + // bg_gradient mirrors gradient(): from.blend(to, t) — at t=0 that is `to` + // (blue), at t=1 that is `from` (red). Foreground stays untouched. + assert_eq!(buf.get(0, 0).style.bg, Some(blue), "first column bg = to"); + assert_eq!(buf.get(2, 0).style.bg, Some(red), "last column bg = from"); + assert_eq!( + buf.get(1, 0).style.bg, + Some(Color::Rgb(128, 0, 128)), + "middle column bg = halfway blend" + ); + } + + #[test] + fn bg_gradient_stops_interpolates_background() { + let red = Color::Rgb(255, 0, 0); + let blue = Color::Rgb(0, 0, 255); + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + ui.text("ABC").bg_gradient_stops(&[(0.0, red), (1.0, blue)]); + }); + + let buf = backend.buffer(); + assert_eq!(buf.get(0, 0).style.bg, Some(red), "first column bg = red"); + assert_eq!( + buf.get(1, 0).style.bg, + Some(Color::Rgb(128, 0, 128)), + "middle column bg = halfway blend" + ); + assert_eq!(buf.get(2, 0).style.bg, Some(blue), "last column bg = blue"); + } + + #[test] + fn bg_gradient_stops_empty_is_noop() { + let mut backend = TestBackend::new(20, 4); + backend.render(|ui| { + ui.text("WORLD").bg_gradient_stops(&[]); + }); + + backend.assert_contains("WORLD"); + } +} From dd598f62fe1503c922f4f937cc4a424086909b7b Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:14:53 +0900 Subject: [PATCH 03/17] feat(keymap): add Binding/KeyMap dispatch + Context::keymap_match Add bubbletea key.Matches parity to the keymap catalog: - Binding::matches(&KeyEvent) -> bool, with press-only and modifier contains() semantics matching Context::key_mod. - KeyMap::matched(&KeyEvent) -> Option<&Binding>, first-registration-wins. - Context::keymap_match(&KeyMap) -> Option<&Binding> peeking the frame's unconsumed key presses, respecting the modal/overlay guard. Covered by unit tests (incl. release-event and overlap edge cases) and TestBackend-driven Context tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/keymap.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/src/keymap.rs b/src/keymap.rs index 1d52026..a9bacee 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -1,3 +1,4 @@ +use crate::event::{KeyEvent, KeyEventKind}; use crate::{KeyCode, KeyModifiers, ModifierKey}; /// A single key binding with display text and description. @@ -15,6 +16,61 @@ pub struct Binding { pub visible: bool, } +impl Binding { + /// Returns `true` if `key` is a press of this binding's registered chord. + /// + /// This is the dispatch primitive that mirrors bubbletea's + /// `key.Matches`: a [`KeyEvent`] matches when it is a **press** (releases + /// and repeats never match), its [`KeyCode`] equals [`Binding::key`], and + /// its modifiers satisfy [`Binding::modifiers`]: + /// + /// - `modifiers: None` requires that **no** modifiers are held (so a plain + /// `q` binding does not fire on `Ctrl+q`). + /// - `modifiers: Some(mods)` requires that *at least* every modifier in + /// `mods` is held, matching the lenient semantics of + /// [`Context::key_mod`](crate::Context::key_mod). + /// + /// # Examples + /// ``` + /// use slt::{Event, KeyCode, KeyMap, KeyModifiers}; + /// + /// let km = KeyMap::new().bind('q', "Quit"); + /// let binding = &km.bindings[0]; + /// + /// let Event::Key(quit) = Event::key_char('q') else { unreachable!() }; + /// assert!(binding.matches(&quit)); + /// + /// // A plain binding ignores modified presses of the same key. + /// let Event::Key(ctrl_q) = Event::key_ctrl('q') else { unreachable!() }; + /// assert!(!binding.matches(&ctrl_q)); + /// + /// // A different key never matches. + /// let Event::Key(other) = Event::key_char('x') else { unreachable!() }; + /// assert!(!binding.matches(&other)); + /// + /// // Modifier bindings use `contains` semantics. + /// let save = KeyMap::new().bind_mod('s', KeyModifiers::CONTROL, "Save"); + /// let Event::Key(ctrl_s) = + /// Event::key_mod(KeyCode::Char('s'), KeyModifiers::CONTROL) + /// else { + /// unreachable!() + /// }; + /// assert!(save.bindings[0].matches(&ctrl_s)); + /// ``` + pub fn matches(&self, key: &KeyEvent) -> bool { + if key.kind != KeyEventKind::Press { + return false; + } + if key.code != self.key { + return false; + } + match self.modifiers { + None => key.modifiers == KeyModifiers::NONE, + Some(mods) => key.modifiers.contains(mods), + } + } +} + /// Declarative key binding map. /// /// # Examples @@ -116,6 +172,31 @@ impl KeyMap { pub fn visible_bindings(&self) -> impl Iterator { self.bindings.iter().filter(|binding| binding.visible) } + + /// Return the first registered binding whose chord matches `key`. + /// + /// Bindings are checked in registration order, so if two bindings could + /// match the same key the earlier `.bind*()` call wins. Returns `None` + /// when no binding matches (including for release / repeat events, which + /// [`Binding::matches`] never accepts). + /// + /// # Examples + /// ``` + /// use slt::{Event, KeyCode, KeyMap}; + /// + /// let km = KeyMap::new() + /// .bind('q', "Quit") + /// .bind_code(KeyCode::Up, "Move up"); + /// + /// let Event::Key(up) = Event::key(KeyCode::Up) else { unreachable!() }; + /// assert_eq!(km.matched(&up).unwrap().description, "Move up"); + /// + /// let Event::Key(unbound) = Event::key_char('z') else { unreachable!() }; + /// assert!(km.matched(&unbound).is_none()); + /// ``` + pub fn matched(&self, key: &KeyEvent) -> Option<&Binding> { + self.bindings.iter().find(|binding| binding.matches(key)) + } } fn display_for_key_code(key: &KeyCode) -> String { @@ -305,3 +386,157 @@ impl PublishedKeymap { Self { name, bindings } } } + +impl crate::Context { + /// Match the current frame's unconsumed key presses against `map` and + /// return the first [`Binding`] that fires. + /// + /// This is the [`KeyMap`] counterpart to the per-key peek helpers like + /// [`key`](crate::Context::key) and [`key_code`](crate::Context::key_code): + /// it scans every unconsumed key-**press** event for this frame, in arrival + /// order, and returns the first binding in `map` whose chord matches (see + /// [`Binding::matches`]). The event is **not** consumed — callers can react + /// to the returned binding and, if desired, still let other handlers see + /// the key. Returns `None` when no press matches any binding. + /// + /// Like the other peek helpers, this respects the modal/overlay guard: when + /// a modal is active and the caller is outside an overlay, no presses are + /// visible and the method returns `None`. + /// + /// # Examples + /// ``` + /// use slt::{KeyCode, KeyMap, TestBackend}; + /// + /// let km = KeyMap::new() + /// .bind('q', "Quit") + /// .bind_code(KeyCode::Up, "Move up"); + /// + /// let mut tb = TestBackend::new(20, 3); + /// tb.run_with_events(vec![slt::Event::key_char('q')], |ui| { + /// let hit = ui.keymap_match(&km); + /// ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none")); + /// }); + /// tb.assert_contains("Quit"); + /// ``` + pub fn keymap_match<'m>(&self, map: &'m KeyMap) -> Option<&'m Binding> { + if (self.rollback.modal_active || self.prev_modal_active) + && self.rollback.overlay_depth == 0 + { + return None; + } + self.available_key_presses() + .find_map(|(_, key)| map.matched(key)) + } +} + +#[cfg(test)] +mod dispatch_tests { + use super::*; + use crate::event::Event; + use crate::TestBackend; + + fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + match Event::key_mod(code, modifiers) { + Event::Key(k) => k, + _ => unreachable!("key_mod always builds a Key event"), + } + } + + fn release_event(c: char) -> KeyEvent { + match Event::key_release(c) { + Event::Key(k) => k, + _ => unreachable!("key_release always builds a Key event"), + } + } + + #[test] + fn binding_matches_plain_char() { + let km = KeyMap::new().bind('q', "Quit"); + let binding = &km.bindings[0]; + assert!(binding.matches(&key_event(KeyCode::Char('q'), KeyModifiers::NONE))); + // Different char does not match. + assert!(!binding.matches(&key_event(KeyCode::Char('x'), KeyModifiers::NONE))); + // Plain binding rejects a modified press of the same key. + assert!(!binding.matches(&key_event(KeyCode::Char('q'), KeyModifiers::CONTROL))); + } + + #[test] + fn binding_matches_modifier_chord_contains() { + let km = KeyMap::new().bind_mod('s', KeyModifiers::CONTROL, "Save"); + let binding = &km.bindings[0]; + // Exact modifier matches. + assert!(binding.matches(&key_event(KeyCode::Char('s'), KeyModifiers::CONTROL))); + // Extra modifiers still satisfy `contains` semantics. + let ctrl_shift = KeyModifiers(KeyModifiers::CONTROL.0 | KeyModifiers::SHIFT.0); + assert!(binding.matches(&key_event(KeyCode::Char('s'), ctrl_shift))); + // Missing the required modifier does not match. + assert!(!binding.matches(&key_event(KeyCode::Char('s'), KeyModifiers::NONE))); + } + + #[test] + fn binding_rejects_release_events() { + let km = KeyMap::new().bind('q', "Quit"); + // Edge case: a release of the bound key must never match. + assert!(!km.bindings[0].matches(&release_event('q'))); + } + + #[test] + fn matched_returns_first_registered_binding() { + let km = KeyMap::new() + .bind('q', "Quit") + .bind_code(KeyCode::Up, "Move up") + .bind_mod('s', KeyModifiers::CONTROL, "Save"); + + let up = km + .matched(&key_event(KeyCode::Up, KeyModifiers::NONE)) + .expect("Up should match"); + assert_eq!(up.description, "Move up"); + + let save = km + .matched(&key_event(KeyCode::Char('s'), KeyModifiers::CONTROL)) + .expect("Ctrl+S should match"); + assert_eq!(save.description, "Save"); + + // Non-matching key returns None. + assert!(km + .matched(&key_event(KeyCode::Char('z'), KeyModifiers::NONE)) + .is_none()); + } + + #[test] + fn matched_first_registration_wins_on_overlap() { + // Two bindings claim the same chord; the earlier registration wins. + let km = KeyMap::new().bind('a', "First").bind('a', "Second"); + let hit = km + .matched(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)) + .expect("'a' should match"); + assert_eq!(hit.description, "First"); + } + + #[test] + fn context_keymap_match_returns_binding_for_frame_press() { + let km = KeyMap::new() + .bind('q', "Quit") + .bind_code(KeyCode::Up, "Move up"); + + let mut tb = TestBackend::new(20, 3); + tb.run_with_events(vec![Event::key(KeyCode::Up)], |ui| { + let hit = ui.keymap_match(&km); + ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none")); + }); + tb.assert_contains("Move up"); + } + + #[test] + fn context_keymap_match_none_when_no_press_matches() { + let km = KeyMap::new().bind('q', "Quit"); + + let mut tb = TestBackend::new(20, 3); + // A press the map does not bind yields no match. + tb.run_with_events(vec![Event::key_char('z')], |ui| { + let hit = ui.keymap_match(&km); + ui.text(hit.map(|b| b.description.as_str()).unwrap_or("none")); + }); + tb.assert_contains("none"); + } +} From ff2b7de37fff078b202c67a7bfdb9592b7f1bff0 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:15:31 +0900 Subject: [PATCH 04/17] feat: add opt-in list keyboard reorder (move_item + list_reorderable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ListState::move_item(from, to) — bounds-checked, no-op safe, keeps the search cache and per-item heights aligned, rebuilds the filtered view, and follows selection onto the moved item. Add Context::list_reorderable / list_reorderable_colored — opt-in reorderable list returning a ListResponse (Deref) exposing .reordered: Option<(usize, usize)>. Shift/Alt + Up/Down moves the selected item; plain navigation and the existing list() API are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../widgets_interactive/collections.rs | 273 ++++++++++++++++++ src/widgets/collections.rs | 193 +++++++++++++ 2 files changed, 466 insertions(+) diff --git a/src/context/widgets_interactive/collections.rs b/src/context/widgets_interactive/collections.rs index 3b8da80..1fc5af3 100644 --- a/src/context/widgets_interactive/collections.rs +++ b/src/context/widgets_interactive/collections.rs @@ -326,6 +326,189 @@ impl Context { response } + /// Render a selectable list that supports keyboard reordering of items. + /// + /// Behaves exactly like [`list`](Context::list) for navigation (Up/Down and + /// `k`/`j` move the selection) and click selection, but additionally lets the + /// focused user reorder the selected item with `Shift+Up`/`Shift+Down` or + /// `Alt+Up`/`Alt+Down`. Reordering operates on the underlying item order via + /// [`ListState::move_item`], keeping the selection on the moved item. + /// + /// Returns a [`ListResponse`] which derefs to the standard [`Response`] and + /// exposes [`reordered`](ListResponse::reordered) — `Some((from, to))` with + /// the data indices when an item moved this frame, otherwise `None`. + /// + /// The plain [`list`](Context::list) entry point is unchanged; opt into + /// reordering by calling this method instead. + /// + /// # Example + /// + /// ```no_run + /// # use slt::widgets::ListState; + /// # let mut list = ListState::new(vec!["First", "Second", "Third"]); + /// # slt::run(move |ui: &mut slt::Context| { + /// let r = ui.list_reorderable(&mut list); + /// if let Some((from, to)) = r.reordered { + /// let _ = (from, to); // persist new order + /// } + /// # }); + /// ``` + /// + /// Available since `0.21.1`. + pub fn list_reorderable(&mut self, state: &mut ListState) -> crate::widgets::ListResponse { + let colors = self.widget_theme.list; + self.list_reorderable_colored(state, &colors) + } + + /// Render a reorderable list with custom widget colors. + /// + /// See [`list_reorderable`](Context::list_reorderable) for the reorder + /// keybindings and return semantics. + /// + /// Available since `0.21.1`. + pub fn list_reorderable_colored( + &mut self, + state: &mut ListState, + colors: &WidgetColors, + ) -> crate::widgets::ListResponse { + let visible = state.visible_indices().to_vec(); + if visible.is_empty() && state.items.is_empty() { + state.selected = 0; + return crate::widgets::ListResponse::default(); + } + + if !visible.is_empty() { + state.selected = state.selected.min(visible.len().saturating_sub(1)); + } + + let old_selected = state.selected; + let focused = self.register_focusable(); + let (interaction_id, mut response) = self.begin_widget_interaction(focused); + + let mut reordered: Option<(usize, usize)> = None; + + if focused { + let mut consumed_indices = Vec::new(); + for (i, key) in self.available_key_presses() { + // Reorder takes precedence over navigation when a Shift/Alt + // modifier is held with a vertical-movement key. + let modded = key.modifiers.contains(KeyModifiers::SHIFT) + || key.modifiers.contains(KeyModifiers::ALT); + // Direction of the move: -1 (up) or +1 (down) for the selected + // view row, `None` for non-movement keys. + let dir: Option = match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => Some(-1), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => Some(1), + _ => None, + }; + + if modded { + if let Some(delta) = dir { + let cur_view = state.selected; + let target_view = if delta < 0 { + cur_view.checked_sub(1) + } else { + let next = cur_view + 1; + (next < visible.len()).then_some(next) + }; + // Map both endpoints from view positions to data indices + // so reordering survives an active filter. + if let Some(target_view) = target_view { + if let (Some(&from), Some(&to)) = + (visible.get(cur_view), visible.get(target_view)) + { + if state.move_item(from, to) { + reordered = Some((from, to)); + } + } + } + // Consume regardless so a held modifier never also + // triggers a plain navigation step on the same key. + consumed_indices.push(i); + } + continue; + } + + if dir.is_some() { + let _ = handle_vertical_nav( + &mut state.selected, + visible.len().saturating_sub(1), + key.code.clone(), + ); + consumed_indices.push(i); + } + } + self.consume_indices(consumed_indices); + } + + if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) { + let mut consumed = Vec::new(); + // `visible` may be stale after a reorder rebuilt the view; re-read + // the current visible count for bounds. + let visible_len = state.visible_indices().len(); + for (i, mouse) in clicks { + let clicked_idx = (mouse.y - rect.y) as usize; + if clicked_idx < visible_len { + state.selected = clicked_idx; + consumed.push(i); + } + } + self.consume_indices(consumed); + } + + // Re-read the (possibly reordered) view for rendering. + let visible = state.visible_indices().to_vec(); + + self.commands + .push(Command::BeginContainer(Box::new(BeginContainerArgs { + direction: Direction::Column, + gap: 0, + align: Align::Start, + align_self: None, + justify: Justify::Start, + border: None, + border_sides: BorderSides::all(), + border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)), + bg_color: None, + padding: Padding::default(), + margin: Margin::default(), + constraints: Constraints::default(), + title: None, + grow: 0, + group_name: None, + }))); + + for (view_idx, &item_idx) in visible.iter().enumerate() { + let item = &state.items[item_idx]; + if view_idx == state.selected { + let mut selected_style = Style::new() + .bg(colors.accent.unwrap_or(self.theme.selected_bg)) + .fg(colors.fg.unwrap_or(self.theme.selected_fg)); + if focused { + selected_style = selected_style.bold(); + } + let mut row = String::with_capacity(2 + item.len()); + row.push_str("▸ "); + row.push_str(item); + self.styled(row, selected_style); + } else { + let mut row = String::with_capacity(2 + item.len()); + row.push_str(" "); + row.push_str(item); + self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text))); + } + } + + self.commands.push(Command::EndContainer); + self.rollback.last_text_idx = None; + + response.changed = state.selected != old_selected || reordered.is_some(); + crate::widgets::ListResponse { + response, + reordered, + } + } + /// Render a calendar date picker with month navigation. /// /// Single-date mode is the default. Opt into range selection with @@ -839,3 +1022,93 @@ fn collect_grid_elements(child_commands: Vec) -> Vec> { } elements } + +#[cfg(test)] +mod list_reorder_render_tests { + use crate::widgets::ListState; + use crate::{EventBuilder, KeyCode, KeyModifiers, TestBackend}; + + #[test] + fn shift_down_reorders_selected_item() { + let mut backend = TestBackend::new(20, 6); + let mut state = ListState::new(vec!["alpha", "beta", "gamma"]); + state.selected = 0; // "alpha" + + let events = EventBuilder::new() + .key_with(KeyCode::Down, KeyModifiers::SHIFT) + .build(); + + let mut reordered = None; + backend.run_with_events(events, |ui| { + let r = ui.list_reorderable(&mut state); + reordered = r.reordered; + }); + + // "alpha" (data 0) swapped down with "beta" (data 1). + assert_eq!(reordered, Some((0, 1))); + assert_eq!(state.items, vec!["beta", "alpha", "gamma"]); + // Selection follows the moved item to its new position. + assert_eq!(state.selected, 1); + assert_eq!(state.selected_item(), Some("alpha")); + } + + #[test] + fn alt_up_reorders_selected_item() { + let mut backend = TestBackend::new(20, 6); + let mut state = ListState::new(vec!["one", "two", "three"]); + state.selected = 2; // "three" + + let events = EventBuilder::new() + .key_with(KeyCode::Up, KeyModifiers::ALT) + .build(); + + let mut reordered = None; + backend.run_with_events(events, |ui| { + reordered = ui.list_reorderable(&mut state).reordered; + }); + + assert_eq!(reordered, Some((2, 1))); + assert_eq!(state.items, vec!["one", "three", "two"]); + assert_eq!(state.selected, 1); + } + + #[test] + fn shift_up_at_top_is_a_noop() { + let mut backend = TestBackend::new(20, 6); + let mut state = ListState::new(vec!["a", "b", "c"]); + state.selected = 0; + + let events = EventBuilder::new() + .key_with(KeyCode::Up, KeyModifiers::SHIFT) + .build(); + + let mut reordered = Some((9, 9)); + backend.run_with_events(events, |ui| { + reordered = ui.list_reorderable(&mut state).reordered; + }); + + // No room to move up from the top: nothing reordered. + assert_eq!(reordered, None); + assert_eq!(state.items, vec!["a", "b", "c"]); + assert_eq!(state.selected, 0); + } + + #[test] + fn plain_down_navigates_without_reordering() { + let mut backend = TestBackend::new(20, 6); + let mut state = ListState::new(vec!["a", "b", "c"]); + state.selected = 0; + + let events = EventBuilder::new().key_code(KeyCode::Down).build(); + + let mut reordered = Some((9, 9)); + backend.run_with_events(events, |ui| { + reordered = ui.list_reorderable(&mut state).reordered; + }); + + // Without a modifier, Down moves the selection but never reorders. + assert_eq!(reordered, None); + assert_eq!(state.items, vec!["a", "b", "c"]); + assert_eq!(state.selected, 1); + } +} diff --git a/src/widgets/collections.rs b/src/widgets/collections.rs index e257839..bce84e1 100644 --- a/src/widgets/collections.rs +++ b/src/widgets/collections.rs @@ -200,6 +200,78 @@ impl ListState { self.items.get(data_idx).map(String::as_str) } + /// Move the item at data index `from` to data index `to`, preserving + /// selection on the moved item. + /// + /// Indices address the underlying [`items`](ListState::items) vector (the + /// unfiltered order), not the filtered view. Out-of-range indices and a + /// no-op `from == to` move leave the list untouched and return `false`. + /// The parallel search cache and any per-item heights are kept in sync, and + /// the filtered view is rebuilt so `selected` continues to point at the item + /// that was moved when it remains visible. + /// + /// # Example + /// + /// ``` + /// use slt::widgets::ListState; + /// + /// let mut state = ListState::new(vec!["a", "b", "c"]); + /// assert!(state.move_item(0, 2)); + /// assert_eq!(state.selected_item(), Some("a")); + /// ``` + /// + /// Available since `0.21.1`. + pub fn move_item(&mut self, from: usize, to: usize) -> bool { + let len = self.items.len(); + if from >= len || to >= len || from == to { + return false; + } + + // Remember which data index is currently selected so selection can + // follow the moved item (or stay on whatever item the user had). + let selected_data = self.view_indices.get(self.selected).copied(); + + let item = self.items.remove(from); + self.items.insert(to, item); + + // Keep the lowercase search cache aligned with `items`. + if from < self.item_search_cache.len() { + let cached = self.item_search_cache.remove(from); + self.item_search_cache.insert(to.min(self.item_search_cache.len()), cached); + } + + // Keep per-item heights aligned with `items` when present. + if let Some(heights) = self.item_heights.as_mut() { + if from < heights.len() { + let h = heights.remove(from); + heights.insert(to.min(heights.len()), h); + } + } + self.heights_dirty = true; + + self.rebuild_view(); + + // Re-point `selected` at the same data item if it is still visible. + if let Some(data_idx) = selected_data { + // The moved item's data index is now `to`; anything that was + // `selected` shifts with the rotation, so re-derive from data idx. + let new_data_idx = if data_idx == from { + to + } else if from < to && data_idx > from && data_idx <= to { + data_idx - 1 + } else if to < from && data_idx >= to && data_idx < from { + data_idx + 1 + } else { + data_idx + }; + if let Some(view_pos) = self.view_indices.iter().position(|&i| i == new_data_idx) { + self.selected = view_pos; + } + } + + true + } + fn rebuild_view(&mut self) { let tokens: Vec = self .filter @@ -225,6 +297,44 @@ impl ListState { } } +/// Response from [`Context::list_reorderable`](crate::Context::list_reorderable). +/// +/// Wraps the row-level [`Response`] (selection/hover/rect/focus) and additionally +/// exposes the `(from, to)` data indices of an item that was reordered this frame +/// via the keyboard. Implements `Deref` so `r.changed`, +/// `r.hovered`, `r.rect`, etc. work directly. +/// +/// # Example +/// +/// ```no_run +/// # use slt::widgets::ListState; +/// # let mut list = ListState::new(vec!["a", "b", "c"]); +/// # slt::run(move |ui: &mut slt::Context| { +/// let r = ui.list_reorderable(&mut list); +/// if let Some((from, to)) = r.reordered { +/// // persist the new order: item moved from `from` to `to` +/// let _ = (from, to); +/// } +/// # }); +/// ``` +/// +/// Available since `0.21.1`. +#[derive(Debug, Clone, Default)] +#[must_use = "ListResponse contains interaction state — check .reordered, .changed, or .hovered"] +pub struct ListResponse { + /// The row-level interaction response (selection change, hover, rect, focus). + pub response: Response, + /// `(from, to)` data indices of the item moved this frame, if any. + pub reordered: Option<(usize, usize)>, +} + +impl std::ops::Deref for ListResponse { + type Target = Response; + fn deref(&self) -> &Response { + &self.response + } +} + /// State for a file picker widget. /// /// Tracks the current directory listing, filtering options, and selected file. @@ -1893,3 +2003,86 @@ mod scroll_state_progress_tests { assert!((state.progress() - 0.5).abs() < f32::EPSILON); } } + +#[cfg(test)] +mod list_state_reorder_tests { + use super::ListState; + + #[test] + fn move_item_forward_reorders_and_keeps_selection() { + let mut state = ListState::new(vec!["a", "b", "c", "d"]); + state.selected = 0; // "a" + assert!(state.move_item(0, 2)); + assert_eq!(state.items, vec!["b", "c", "a", "d"]); + // Selection follows the moved item. + assert_eq!(state.selected_item(), Some("a")); + assert_eq!(state.selected, 2); + } + + #[test] + fn move_item_backward_reorders_and_keeps_selection() { + let mut state = ListState::new(vec!["a", "b", "c", "d"]); + state.selected = 3; // "d" + assert!(state.move_item(3, 1)); + assert_eq!(state.items, vec!["a", "d", "b", "c"]); + assert_eq!(state.selected_item(), Some("d")); + assert_eq!(state.selected, 1); + } + + #[test] + fn move_item_keeps_search_cache_aligned() { + let mut state = ListState::new(vec!["Apple", "Banana", "Cherry"]); + assert!(state.move_item(0, 2)); + // After the move the filter must address the reordered items. + state.set_filter("apple"); + assert_eq!(state.visible_indices().len(), 1); + assert_eq!(state.selected_item(), Some("Apple")); + } + + #[test] + fn move_item_keeps_per_item_heights_aligned() { + let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![1, 2, 3]); + assert!(state.move_item(0, 2)); + state.ensure_row_prefix(); + // Heights travel with their items: order is now b(2), c(3), a(1). + assert_eq!(state.item_height(0), 2); + assert_eq!(state.item_height(1), 3); + assert_eq!(state.item_height(2), 1); + } + + #[test] + fn move_item_noop_when_from_equals_to() { + let mut state = ListState::new(vec!["a", "b", "c"]); + state.selected = 1; + assert!(!state.move_item(1, 1)); + assert_eq!(state.items, vec!["a", "b", "c"]); + assert_eq!(state.selected, 1); + } + + #[test] + fn move_item_out_of_bounds_is_rejected() { + let mut state = ListState::new(vec!["a", "b", "c"]); + assert!(!state.move_item(0, 9)); + assert!(!state.move_item(9, 0)); + assert_eq!(state.items, vec!["a", "b", "c"]); + } + + #[test] + fn move_item_empty_list_is_rejected() { + let mut state = ListState::new(Vec::::new()); + assert!(!state.move_item(0, 0)); + assert!(state.items.is_empty()); + } + + #[test] + fn move_item_leaves_unrelated_selection_in_place() { + // Moving an item that is not selected should keep selection on the + // same logical item. + let mut state = ListState::new(vec!["a", "b", "c", "d"]); + state.selected = 3; // "d" + assert!(state.move_item(0, 1)); // swap a/b; "d" stays last + assert_eq!(state.items, vec!["b", "a", "c", "d"]); + assert_eq!(state.selected_item(), Some("d")); + assert_eq!(state.selected, 3); + } +} From b99577469f59c77209de8d97a0ad8e374c7d694c Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:16:06 +0900 Subject: [PATCH 05/17] feat: add named SpinnerState throbber presets Add moon, bounce, circle, points, arc, toggle, and arrow presets to SpinnerState plus a SpinnerPreset enum and SpinnerState::preset() builder for cli-spinners / ratatui-throbber parity. dots() and line() are unchanged. The chars field stays private; only constructors and a new frame_count() helper are exposed. Adds focused unit tests covering each preset's sequence, cycling, preset/constructor equivalence, and a large-tick wrap-around edge case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/widgets/input.rs | 276 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 4 deletions(-) diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 1adedec..fd8f7c4 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -1051,18 +1051,64 @@ impl Default for TextareaState { } } +/// Named throbber preset for [`SpinnerState`]. +/// +/// Each variant maps to a fixed frame sequence (parity with the common +/// `cli-spinners` / `ratatui-throbber` sets). Construct a spinner from a preset +/// with [`SpinnerState::preset`], or use the matching named constructor such as +/// [`SpinnerState::moon`]. +/// +/// # Example +/// +/// ``` +/// # use slt::widgets::{SpinnerState, SpinnerPreset}; +/// let s = SpinnerState::preset(SpinnerPreset::Arrow); +/// assert_eq!(s, SpinnerState::arrow()); +/// ``` +/// +/// Available since `0.21.1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SpinnerPreset { + /// Braille dots: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`. + Dots, + /// ASCII line: `| / - \`. + Line, + /// Moon phases: `🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘`. + Moon, + /// Bouncing bar between brackets: `(● )` … `( ●)` and back. + Bounce, + /// Quarter-circle arc: `◜ ◠ ◝ ◞ ◡ ◟`. + Circle, + /// Travelling braille dot: `⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈`. + Points, + /// Half-circle arc: `◜ ◠ ◝ ◞ ◡ ◟`. + Arc, + /// Toggle pulse: `⊶ ⊷`. + Toggle, + /// Clockwise arrow: `← ↖ ↑ ↗ → ↘ ↓ ↙`. + Arrow, +} + /// State for an animated spinner widget. /// -/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to -/// `Context::spinner` each frame. The frame advances automatically with the -/// tick counter. -#[derive(Debug, Clone)] +/// Create with a named constructor such as [`SpinnerState::dots`] or +/// [`SpinnerState::line`] (or from a [`SpinnerPreset`] via +/// [`SpinnerState::preset`]), then pass to `Context::spinner` each frame. The +/// frame advances automatically with the tick counter. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SpinnerState { chars: &'static [char], } static DOTS_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; static LINE_CHARS: &[char] = &['|', '/', '-', '\\']; +static MOON_CHARS: &[char] = &['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; +static BOUNCE_CHARS: &[char] = &['⠁', '⠂', '⠄', '⠂']; +static CIRCLE_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟']; +static POINTS_CHARS: &[char] = &['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈']; +static ARC_CHARS: &[char] = &['◜', '◠', '◝', '◞', '◡', '◟']; +static TOGGLE_CHARS: &[char] = &['⊶', '⊷']; +static ARROW_CHARS: &[char] = &['←', '↖', '↑', '↗', '→', '↘', '↓', '↙']; impl SpinnerState { /// Create a dots-style spinner using braille characters. @@ -1079,6 +1125,120 @@ impl SpinnerState { Self { chars: LINE_CHARS } } + /// Create a moon-phase spinner. + /// + /// Cycles through: `🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘` + /// + /// Available since `0.21.1`. + pub fn moon() -> Self { + Self { chars: MOON_CHARS } + } + + /// Create a bouncing single-dot spinner. + /// + /// Cycles through `⠁ ⠂ ⠄ ⠂`, giving a dot that rises and falls in place. + /// + /// Available since `0.21.1`. + pub fn bounce() -> Self { + Self { + chars: BOUNCE_CHARS, + } + } + + /// Create a quarter-circle arc spinner. + /// + /// Cycles through: `◜ ◠ ◝ ◞ ◡ ◟` + /// + /// Available since `0.21.1`. + pub fn circle() -> Self { + Self { + chars: CIRCLE_CHARS, + } + } + + /// Create a travelling braille-dot ("points") spinner. + /// + /// Cycles through: `⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈` + /// + /// Available since `0.21.1`. + pub fn points() -> Self { + Self { + chars: POINTS_CHARS, + } + } + + /// Create a half-circle arc spinner. + /// + /// Cycles through: `◜ ◠ ◝ ◞ ◡ ◟` + /// + /// Available since `0.21.1`. + pub fn arc() -> Self { + Self { chars: ARC_CHARS } + } + + /// Create a two-frame toggle/pulse spinner. + /// + /// Cycles through: `⊶ ⊷` + /// + /// Available since `0.21.1`. + pub fn toggle() -> Self { + Self { + chars: TOGGLE_CHARS, + } + } + + /// Create a rotating-arrow spinner. + /// + /// Cycles clockwise through: `← ↖ ↑ ↗ → ↘ ↓ ↙` + /// + /// Available since `0.21.1`. + pub fn arrow() -> Self { + Self { chars: ARROW_CHARS } + } + + /// Create a spinner from a named [`SpinnerPreset`]. + /// + /// Equivalent to calling the matching named constructor. + /// + /// # Example + /// + /// ``` + /// # use slt::widgets::{SpinnerState, SpinnerPreset}; + /// let s = SpinnerState::preset(SpinnerPreset::Moon); + /// assert_eq!(s, SpinnerState::moon()); + /// ``` + /// + /// Available since `0.21.1`. + pub fn preset(preset: SpinnerPreset) -> Self { + match preset { + SpinnerPreset::Dots => Self::dots(), + SpinnerPreset::Line => Self::line(), + SpinnerPreset::Moon => Self::moon(), + SpinnerPreset::Bounce => Self::bounce(), + SpinnerPreset::Circle => Self::circle(), + SpinnerPreset::Points => Self::points(), + SpinnerPreset::Arc => Self::arc(), + SpinnerPreset::Toggle => Self::toggle(), + SpinnerPreset::Arrow => Self::arrow(), + } + } + + /// Number of distinct frames in this spinner's cycle. + /// + /// Useful for tests and for detecting wrap-around. + /// + /// # Example + /// + /// ``` + /// # use slt::widgets::SpinnerState; + /// assert_eq!(SpinnerState::line().frame_count(), 4); + /// ``` + /// + /// Available since `0.21.1`. + pub fn frame_count(&self) -> usize { + self.chars.len() + } + /// Return the spinner character for the given tick. pub fn frame(&self, tick: u64) -> char { if self.chars.is_empty() { @@ -1231,3 +1391,111 @@ impl Default for NumberInputState { Self::new(0.0, 0.0, 100.0) } } + +#[cfg(test)] +mod spinner_tests { + use super::{SpinnerPreset, SpinnerState}; + + /// Collect one full cycle of frames for a spinner. + fn cycle(s: &SpinnerState) -> Vec { + (0..s.frame_count() as u64).map(|t| s.frame(t)).collect() + } + + #[test] + fn existing_presets_unchanged() { + // dots() and line() must keep their historic sequences. + assert_eq!( + cycle(&SpinnerState::dots()), + vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + ); + assert_eq!(cycle(&SpinnerState::line()), vec!['|', '/', '-', '\\']); + // Default stays dots(). + assert_eq!(SpinnerState::default(), SpinnerState::dots()); + } + + #[test] + fn new_presets_have_expected_lengths() { + assert_eq!(SpinnerState::dots().frame_count(), 10); + assert_eq!(SpinnerState::line().frame_count(), 4); + assert_eq!(SpinnerState::moon().frame_count(), 8); + assert_eq!(SpinnerState::bounce().frame_count(), 4); + assert_eq!(SpinnerState::circle().frame_count(), 6); + assert_eq!(SpinnerState::points().frame_count(), 8); + assert_eq!(SpinnerState::arc().frame_count(), 6); + assert_eq!(SpinnerState::toggle().frame_count(), 2); + assert_eq!(SpinnerState::arrow().frame_count(), 8); + } + + #[test] + fn new_presets_yield_expected_sequences() { + assert_eq!( + cycle(&SpinnerState::moon()), + vec!['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'] + ); + assert_eq!(cycle(&SpinnerState::bounce()), vec!['⠁', '⠂', '⠄', '⠂']); + assert_eq!( + cycle(&SpinnerState::circle()), + vec!['◜', '◠', '◝', '◞', '◡', '◟'] + ); + assert_eq!( + cycle(&SpinnerState::points()), + vec!['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'] + ); + assert_eq!( + cycle(&SpinnerState::arc()), + vec!['◜', '◠', '◝', '◞', '◡', '◟'] + ); + assert_eq!(cycle(&SpinnerState::toggle()), vec!['⊶', '⊷']); + assert_eq!( + cycle(&SpinnerState::arrow()), + vec!['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'] + ); + } + + #[test] + fn frame_cycles_modulo_length() { + let s = SpinnerState::arrow(); + let n = s.frame_count() as u64; + // Tick 0 and one full revolution later yield the same frame. + assert_eq!(s.frame(0), s.frame(n)); + assert_eq!(s.frame(1), s.frame(n + 1)); + // Wrap-around at the boundary. + assert_eq!(s.frame(n - 1), '↙'); + assert_eq!(s.frame(n), '←'); + } + + #[test] + fn frame_advances_through_sequence() { + let s = SpinnerState::toggle(); + assert_eq!(s.frame(0), '⊶'); + assert_eq!(s.frame(1), '⊷'); + assert_eq!(s.frame(2), '⊶'); + assert_eq!(s.frame(3), '⊷'); + } + + #[test] + fn preset_matches_named_constructor() { + let cases = [ + (SpinnerPreset::Dots, SpinnerState::dots()), + (SpinnerPreset::Line, SpinnerState::line()), + (SpinnerPreset::Moon, SpinnerState::moon()), + (SpinnerPreset::Bounce, SpinnerState::bounce()), + (SpinnerPreset::Circle, SpinnerState::circle()), + (SpinnerPreset::Points, SpinnerState::points()), + (SpinnerPreset::Arc, SpinnerState::arc()), + (SpinnerPreset::Toggle, SpinnerState::toggle()), + (SpinnerPreset::Arrow, SpinnerState::arrow()), + ]; + for (preset, expected) in cases { + assert_eq!(SpinnerState::preset(preset), expected); + } + } + + #[test] + fn frame_handles_large_tick_without_panicking() { + // Edge case: very large tick must wrap, not overflow/panic. + let s = SpinnerState::moon(); + let n = s.frame_count() as u64; + assert_eq!(s.frame(u64::MAX), s.frame(u64::MAX % n)); + } +} From a4dc87da0a6d27a7044a05ff3aed97529ee48d1d Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:16:38 +0900 Subject: [PATCH 06/17] feat: add intrinsic-size measurement API + resize coalescing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task A — intrinsic-size measurement (v0.21.1): - Context::measure_text(&str, Option) -> (u16, u16): reuses the layout engine's wrap_lines kernel (no duplicated width logic) to report the (width, rows) text would occupy unwrapped (None / Some(0)) or wrapped to a column budget (Some(w)). Display-width aware (wide CJK = 2, combining = 0), saturating to u16. - Context::measured_rect(&str) -> Option: read-only lookup into the previous frame's name->rect bookkeeping (prev_group_rects) for a named group(...) container. Task B — resize debounce/coalesce (v0.21.1): - poll_events no longer fires on_resize per Event::Resize. A burst within one poll batch now collapses to a SINGLE on_resize at end-of-batch, picking up the final terminal size (handle_resize re-reads terminal::size()). Avoids N x (Clear(All) + double realloc + size() syscall) while dragging a window edge. The SIGCONT/resume redraw path in run_with is untouched. - Added pure helper resize_invocations_for_batch for a unit-testable rule; a debug_assert keeps the has_resize flag and the helper in agreement. Tests: 6 measure_text/measured_rect cases (multi-line, wrapping, Some(0) edge, wide-CJK, first-frame None, post-render group rect) + 3 resize-coalesce cases (3-event batch -> 1 invocation, no-resize -> 0, has_resize agreement). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/helpers.rs | 199 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 99 ++++++++++++++++++-- 2 files changed, 292 insertions(+), 6 deletions(-) diff --git a/src/context/helpers.rs b/src/context/helpers.rs index 5343228..6fd597b 100644 --- a/src/context/helpers.rs +++ b/src/context/helpers.rs @@ -275,6 +275,107 @@ pub(crate) fn textarea_visual_to_logical( } } +/// Intrinsic-size measurement API (v0.21.1). +/// +/// These read-only queries expose the layout engine's text-wrapping math and +/// the previous frame's named-container geometry without changing any rendering +/// path. They let app code reserve space, decide pagination, or position +/// floating UI relative to a widget that was laid out last frame. +impl Context { + /// The intrinsic `(width, height_in_rows)` `text` would occupy, in cells. + /// + /// Reuses the exact word-wrap kernel the layout engine runs + /// ([`wrap_lines`](crate::layout::wrap_lines) via this crate's `tree` + /// module), so the answer always matches what a `ui.text(text).wrap()` + /// would actually render — width logic is never duplicated here. + /// + /// * When `max_width` is `None`, the text is measured unwrapped: width is + /// the widest hard-break line, height is the number of `'\n'`-separated + /// lines (at least 1). + /// * When `max_width` is `Some(w)` with `w > 0`, the text is wrapped to + /// `w` columns; the returned width is the widest wrapped line (`<= w`) + /// and the height is the wrapped line count. + /// * `Some(0)` is treated like `None` (no width budget — honor hard breaks + /// only), mirroring the layout kernel's zero-width contract. + /// + /// Width is the terminal display width (wide CJK glyphs count as 2, + /// zero-width combining marks as 0). The result is clamped to `u16`; a + /// pathological line wider than `u16::MAX` cells saturates rather than + /// wrapping. + /// + /// # Examples + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// // Unwrapped: width is the longest line, height the line count. + /// let (w, h) = ui.measure_text("hello\nworld!", None); + /// assert_eq!((w, h), (6, 2)); + /// + /// // Wrapped to 5 columns: the long word breaks across rows. + /// let (w, h) = ui.measure_text("alpha beta gamma", Some(5)); + /// assert!(w <= 5 && h >= 1); + /// # }); + /// ``` + pub fn measure_text(&self, text: &str, max_width: Option) -> (u16, u16) { + // `Some(0)` collapses to the "no budget" path so we never feed a + // zero-width wrap (which the kernel treats as hard-break-only anyway). + let budget = match max_width { + Some(w) if w > 0 => w as u32, + // `u32::MAX` is the layout engine's "unbounded width" sentinel + // (see `textarea_build_visual_lines`); `wrap_lines` honors only + // hard breaks at that width, giving the unwrapped measurement. + _ => u32::MAX, + }; + + let lines = crate::layout::wrap_lines(text, budget); + let height = lines.len().max(1); + let width = lines + .iter() + .map(|line| UnicodeWidthStr::width(line.as_str())) + .max() + .unwrap_or(0); + + (clamp_u16(width), clamp_u16(height)) + } + + /// The [`Rect`] a named widget/container occupied on the **last completed + /// frame**, or `None` if no group with that `name` was rendered. + /// + /// Reads the same `name → rect` bookkeeping that powers group hover/focus + /// styling (`prev_group_rects`), captured at the end of the previous + /// frame's collect pass. Register a name with + /// [`Context::group`](crate::Context::group): + /// + /// ```ignore + /// ui.group("sidebar").border(slt::Border::Rounded).col(|ui| { /* … */ }); + /// // …next frame: + /// if let Some(r) = ui.measured_rect("sidebar") { + /// ui.text(format!("sidebar is {}x{}", r.width, r.height)); + /// } + /// ``` + /// + /// Returns `None` on the first frame (nothing measured yet) and for any + /// name that was not rendered as a `group(...)` last frame. If the same + /// name is used for multiple groups, the first match in render order is + /// returned. + pub fn measured_rect(&self, name: &str) -> Option { + self.prev_group_rects + .iter() + .find(|(group_name, _)| group_name.as_ref() == name) + .map(|(_, rect)| *rect) + } +} + +/// Saturating `usize -> u16` for intrinsic-size results. +/// +/// A measured extent wider/taller than `u16::MAX` cells is pathological (no +/// real terminal is that large); saturating keeps the public return type a +/// compact `u16` without an overflow panic. +#[inline] +fn clamp_u16(value: usize) -> u16 { + value.min(u16::MAX as usize) as u16 +} + #[allow(unused_variables)] pub(crate) fn open_url(url: &str) -> std::io::Result<()> { #[cfg(target_os = "macos")] @@ -293,3 +394,101 @@ pub(crate) fn open_url(url: &str) -> std::io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod measure_tests { + use crate::test_utils::TestBackend; + use crate::{Border, Context, FrameState, Theme}; + + #[test] + fn measure_text_unwrapped_reports_widest_line_and_line_count() { + let mut state = FrameState::default(); + let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark()); + + // Two hard-break lines: width = widest line, height = line count. + let (w, h) = ui.measure_text("hello\nworld!", None); + assert_eq!((w, h), (6, 2)); + + // Single line, no breaks → height 1. + assert_eq!(ui.measure_text("abc", None), (3, 1)); + + // Empty string is one blank line of zero width. + assert_eq!(ui.measure_text("", None), (0, 1)); + } + + #[test] + fn measure_text_wraps_to_budget_and_never_exceeds_it() { + let mut state = FrameState::default(); + let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark()); + + // "alpha beta gamma" wrapped to 5 columns: every word is <= 5 wide so + // it lands one word per line → 3 rows, widest line "gamma" = 5. + let (w, h) = ui.measure_text("alpha beta gamma", Some(5)); + assert!(w <= 5, "wrapped width {w} must not exceed the budget"); + assert_eq!(h, 3, "three 5-wide words wrap onto three rows"); + assert_eq!(w, 5); + + // A word longer than the budget is hard-split across rows; height + // grows but width still stays within the budget. + let (w, h) = ui.measure_text("abcdefghij", Some(4)); + assert!(w <= 4); + assert!(h >= 3, "10 chars at width 4 need at least 3 rows, got {h}"); + } + + #[test] + fn measure_text_some_zero_is_treated_as_unbounded() { + // Edge case: `Some(0)` must not feed a zero-width wrap. It honors hard + // breaks only, identical to `None`. + let mut state = FrameState::default(); + let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark()); + assert_eq!( + ui.measure_text("a b c\nlonger line", Some(0)), + ui.measure_text("a b c\nlonger line", None), + ); + } + + #[test] + fn measure_text_counts_wide_cjk_glyphs_as_two_cells() { + let mut state = FrameState::default(); + let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark()); + // Two double-width CJK glyphs measure as 4 cells, one row. + assert_eq!(ui.measure_text("한글", None), (4, 1)); + } + + #[test] + fn measured_rect_is_none_on_first_frame() { + let mut state = FrameState::default(); + let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark()); + // Nothing has been rendered yet → no prior geometry. + assert!(ui.measured_rect("panel").is_none()); + } + + #[test] + fn measured_rect_returns_group_geometry_after_a_render() { + // Render a named group on frame 1; on frame 2 the previous frame's + // collected `prev_group_rects` makes the rect queryable. + let mut backend = TestBackend::new(40, 10); + + backend.render(|ui| { + ui.group("panel").border(Border::Rounded).col(|ui| { + ui.text("hi"); + }); + }); + + let mut seen: Option = None; + backend.render(|ui| { + seen = ui.measured_rect("panel"); + // A name that was never rendered stays `None` — edge case guard. + assert!(ui.measured_rect("does-not-exist").is_none()); + }); + + let rect = seen.expect("named group must have a measured rect after render"); + assert!( + rect.width > 0 && rect.height > 0, + "measured rect must be non-empty, got {rect:?}" + ); + // The group fits inside the 40x10 backend area. + assert!(rect.x + rect.width <= 40); + assert!(rect.y + rect.height <= 10); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2084df4..6c9fbd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1631,12 +1631,40 @@ pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_res } } +/// Number of `on_resize` invocations a batch of events should trigger. +/// +/// v0.21.1 resize coalescing: a single poll batch may deliver a burst of +/// `Event::Resize` events while a user drags the window edge. Each +/// [`Terminal::handle_resize`](crate::terminal::Terminal::handle_resize) does a +/// `terminal::size()` syscall, two buffer reallocations, and a `Clear(All)`, so +/// firing it per-event is pure waste — only the *final* geometry matters and +/// `handle_resize` always reads the live terminal size, not the per-event +/// payload. This helper returns `1` if the batch contains any resize and `0` +/// otherwise, so the caller can collapse the burst into one end-of-batch call. +/// +/// Kept as a pure function (no I/O) so the coalescing rule is unit-testable +/// without a real crossterm event source. +#[cfg(feature = "crossterm")] +#[inline] +fn resize_invocations_for_batch(events: &[Event]) -> usize { + usize::from(events.iter().any(|e| matches!(e, Event::Resize(_, _)))) +} + /// Poll for terminal events, handling resize, Ctrl-C, F12 debug toggle, /// and layout cache invalidation. Returns `Ok(false)` when the loop should exit. /// /// `handle_ctrl_c` controls whether Ctrl+C exits the loop (`true`, default /// v0.19 behavior) or is delivered to the frame closure as a regular key /// event (`false`, RataTUI parity, issue #238). +/// +/// v0.21.1: resize events within one poll batch are *coalesced* — `on_resize` +/// is invoked at most once, after the whole batch is drained, using the final +/// terminal size (`handle_resize` re-reads `terminal::size()`). Dragging a +/// window edge can emit dozens of `Event::Resize` per poll; firing the +/// `Clear(All)` + double realloc + `size()` syscall for each is wasted work +/// when only the last geometry survives. The SIGCONT/resume redraw path in +/// [`run_with`] is unaffected — it calls `handle_resize` directly, outside this +/// function. #[cfg(feature = "crossterm")] fn poll_events( events: &mut Vec, @@ -1657,9 +1685,9 @@ fn poll_events( if handle_ctrl_c && is_ctrl_c(&ev) { return Ok(false); } - if matches!(ev, Event::Resize(_, _)) { - on_resize()?; - } + // Resize is recorded (via `has_resize`) but not yet acted on — the + // single `on_resize` call is deferred to end-of-batch so a burst + // collapses into one geometry sync. process_ev(&ev, state, &mut has_resize); events.push(ev); } @@ -1670,15 +1698,24 @@ fn poll_events( if handle_ctrl_c && is_ctrl_c(&ev) { return Ok(false); } - if matches!(ev, Event::Resize(_, _)) { - on_resize()?; - } process_ev(&ev, state, &mut has_resize); events.push(ev); } } } + // Coalesced resize: fire `on_resize` exactly once for the whole batch, + // after every event has been read, so it picks up the final terminal size. + // `has_resize` is the per-batch "saw a resize" flag set by `process_ev`. + debug_assert_eq!( + usize::from(has_resize), + resize_invocations_for_batch(events), + "has_resize must agree with the coalescing helper" + ); + if has_resize { + on_resize()?; + } + // #90: clear cache first (which also resets last_mouse_pos to None), // then re-apply latest mouse pos so Resize+Mouse frames keep coords. if has_resize { @@ -2337,6 +2374,56 @@ mod run_loop_tests { assert!(cfg.handle_ctrl_c, "Ctrl+C default preserved"); } + // ── v0.21.1: resize debounce / coalesce ───────────────────────────── + + fn resize(w: u32, h: u32) -> Event { + Event::Resize(w, h) + } + + #[test] + fn resize_batch_coalesces_to_single_invocation() { + // Three resize events in one poll batch must collapse to exactly one + // `on_resize` call (the helper that drives the single end-of-batch + // call in `poll_events`). The final size is irrelevant to the count — + // `handle_resize` re-reads `terminal::size()` — but we feed distinct + // sizes to mirror a real drag burst. + let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)]; + assert_eq!( + resize_invocations_for_batch(&batch), + 1, + "a burst of resizes must coalesce to one on_resize" + ); + } + + #[test] + fn resize_batch_without_resize_invokes_zero_times() { + // A batch with no resize event must not trigger `on_resize` at all. + let batch = vec![key(event::KeyModifiers::NONE)]; + assert_eq!(resize_invocations_for_batch(&batch), 0); + // Empty batch is likewise a no-op. + assert_eq!(resize_invocations_for_batch(&[]), 0); + } + + #[test] + fn resize_coalesce_uses_final_size_via_has_resize_flag() { + // The single deferred `on_resize` is gated on `has_resize`, which + // `process_run_loop_event` sets to `true` for any resize in the batch. + // Feeding three resizes leaves the flag set once (idempotent), and the + // coalescing helper agrees — this is exactly the `debug_assert_eq!` + // invariant `poll_events` checks before its single `on_resize` call. + let mut state = FrameState::default(); + let mut has_resize = false; + let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)]; + for ev in &batch { + process_run_loop_event(ev, &mut state, &mut has_resize); + } + assert!(has_resize, "any resize in the batch must set has_resize"); + assert_eq!( + usize::from(has_resize), + resize_invocations_for_batch(&batch) + ); + } + /// End-to-end test of the real signal-delivery wiring: install the /// handler, deliver a real `SIGCONT` through signal-hook's registry + /// background thread, then drop the guard and confirm it closes the From d7cd0798bd093c18e2c8ee114756926e1f16d355 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:18:17 +0900 Subject: [PATCH 07/17] feat(color): add ergonomic Color constructors and conversions Add additive, non-breaking ergonomic APIs to Color: - From<(u8,u8,u8)>, From<[u8;3]>, From (0xRRGGBB) -> Color::Rgb - FromStr parsing #RRGGBB / RRGGBB / #RGB / RGB and named colors, with a new public ColorParseError (Display + std::error::Error) - from_hsl, from_hsv (h in degrees, s/l/v in 0..1) and rotate_hue, using pure f32 HSL/HSV round-trip math (no new deps) Named/indexed colors resolve through the existing palette before hue rotation. Adds focused unit tests covering hex round-trips, HSL/HSV primaries, hue rotation, clamping/wrapping, and FromStr error cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/style/color.rs | 490 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 490 insertions(+) diff --git a/src/style/color.rs b/src/style/color.rs index 9d782c5..a2a2c3a 100644 --- a/src/style/color.rs +++ b/src/style/color.rs @@ -284,6 +284,342 @@ impl Color { let (r, g, b) = self.to_rgb(); format!("#{r:02x}{g:02x}{b:02x}") } + + /// Construct an [`Color::Rgb`] from HSL components. + /// + /// `h` is the hue in degrees (wrapped into `0..360`), `s` is the + /// saturation and `l` the lightness, both clamped to `[0.0, 1.0]`. + /// + /// # Example + /// + /// ``` + /// use slt::Color; + /// + /// assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0)); + /// assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0)); + /// assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255)); + /// ``` + pub fn from_hsl(h: f32, s: f32, l: f32) -> Color { + let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)); + Color::Rgb(r, g, b) + } + + /// Construct an [`Color::Rgb`] from HSV (a.k.a. HSB) components. + /// + /// `h` is the hue in degrees (wrapped into `0..360`), `s` is the + /// saturation and `v` the value/brightness, both clamped to `[0.0, 1.0]`. + /// + /// # Example + /// + /// ``` + /// use slt::Color; + /// + /// assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0)); + /// assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0)); + /// assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255)); + /// ``` + pub fn from_hsv(h: f32, s: f32, v: f32) -> Color { + let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + Color::Rgb(r, g, b) + } + + /// Rotate the hue of this color by `degrees` around the HSL color wheel. + /// + /// The color is resolved to RGB, converted to HSL, rotated, and converted + /// back to [`Color::Rgb`]. Positive values rotate forward (red → green → + /// blue); negative values rotate backward. The result is always an + /// `Rgb` color regardless of the input variant — named and indexed colors + /// are first resolved via the internal palette. + /// + /// # Example + /// + /// ``` + /// use slt::Color; + /// + /// // Rotating pure red by 120° lands on pure green. + /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue(120.0), Color::Rgb(0, 255, 0)); + /// ``` + pub fn rotate_hue(self, degrees: f32) -> Color { + let (r, g, b) = self.to_rgb(); + let (h, s, l) = rgb_to_hsl(r, g, b); + let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l); + Color::Rgb(nr, ng, nb) + } +} + +impl From<(u8, u8, u8)> for Color { + /// Construct an [`Color::Rgb`] from an `(r, g, b)` tuple. + fn from((r, g, b): (u8, u8, u8)) -> Color { + Color::Rgb(r, g, b) + } +} + +impl From<[u8; 3]> for Color { + /// Construct an [`Color::Rgb`] from an `[r, g, b]` array. + fn from([r, g, b]: [u8; 3]) -> Color { + Color::Rgb(r, g, b) + } +} + +impl From for Color { + /// Construct an [`Color::Rgb`] from a packed `0xRRGGBB` integer. + /// + /// The high byte (alpha / `0xAA______`) is ignored. + /// + /// # Example + /// + /// ``` + /// use slt::Color; + /// + /// assert_eq!(Color::from(0xff6b6b), Color::Rgb(255, 107, 107)); + /// ``` + fn from(value: u32) -> Color { + let r = ((value >> 16) & 0xff) as u8; + let g = ((value >> 8) & 0xff) as u8; + let b = (value & 0xff) as u8; + Color::Rgb(r, g, b) + } +} + +/// Error returned when [`Color`] fails to parse from a string. +/// +/// Produced by the [`std::str::FromStr`] implementation for [`Color`]. +/// +/// # Example +/// +/// ``` +/// use slt::Color; +/// +/// let err = "#zz0011".parse::().unwrap_err(); +/// // Display renders a human-readable reason. +/// assert!(err.to_string().contains("non-hex digit")); +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColorParseError { + /// The input had a hex form (`#…` or all hex-looking) but the wrong + /// number of digits (only 3 or 6 are accepted). + InvalidLength, + /// The input contained a character that is not a valid hex digit. + InvalidHexDigit, + /// The input did not match any known hex form or named color. + Unknown, +} + +impl std::fmt::Display for ColorParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + ColorParseError::InvalidLength => "invalid color: hex form must have 3 or 6 digits", + ColorParseError::InvalidHexDigit => "invalid color: non-hex digit in hex form", + ColorParseError::Unknown => { + "invalid color: expected #rgb/#rrggbb, rrggbb, or a named color" + } + }; + f.write_str(msg) + } +} + +impl std::error::Error for ColorParseError {} + +impl std::str::FromStr for Color { + type Err = ColorParseError; + + /// Parse a color from a string. + /// + /// Accepts hex (`#rgb`, `#rrggbb`, or bare `rrggbb` / `rgb` without the + /// leading `#`) and case-insensitive named colors (`"red"`, `"lightblue"`, + /// `"darkgray"`, `"reset"`, …). + /// + /// # Errors + /// + /// Returns [`ColorParseError`] when the input matches no known form: + /// [`ColorParseError::InvalidLength`] for a hex token of the wrong + /// length, [`ColorParseError::InvalidHexDigit`] for non-hex digits in a + /// `#`-prefixed token, and [`ColorParseError::Unknown`] otherwise. + /// + /// # Example + /// + /// ``` + /// use slt::Color; + /// + /// assert_eq!("#ff6b6b".parse::(), Ok(Color::Rgb(255, 107, 107))); + /// assert_eq!("ff6b6b".parse::(), Ok(Color::Rgb(255, 107, 107))); + /// assert_eq!("#abc".parse::(), Ok(Color::Rgb(170, 187, 204))); + /// assert_eq!("cyan".parse::(), Ok(Color::Cyan)); + /// assert!("nope".parse::().is_err()); + /// ``` + fn from_str(s: &str) -> Result { + let trimmed = s.trim(); + + // Named colors take priority over the no-`#` hex path so that a name + // like "red" is never mistaken for a hex token. + if let Some(c) = named_color(trimmed) { + return Ok(c); + } + + let had_hash = trimmed.starts_with('#'); + let hex = trimmed.strip_prefix('#').unwrap_or(trimmed); + + match hex.len() { + 3 => { + let mut it = hex.chars().map(|c| c.to_digit(16)); + let r = it + .next() + .flatten() + .ok_or(ColorParseError::InvalidHexDigit)?; + let g = it + .next() + .flatten() + .ok_or(ColorParseError::InvalidHexDigit)?; + let b = it + .next() + .flatten() + .ok_or(ColorParseError::InvalidHexDigit)?; + Ok(Color::Rgb((r * 17) as u8, (g * 17) as u8, (b * 17) as u8)) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| ColorParseError::InvalidHexDigit)?; + let g = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| ColorParseError::InvalidHexDigit)?; + let b = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| ColorParseError::InvalidHexDigit)?; + Ok(Color::Rgb(r, g, b)) + } + // A `#`-prefixed token that isn't 3 or 6 digits is clearly a + // malformed hex token; an unprefixed token of an odd length is + // simply an unknown name. + _ if had_hash => Err(ColorParseError::InvalidLength), + _ => Err(ColorParseError::Unknown), + } + } +} + +/// Resolve a case-insensitive named color token (no `#`, no `indexed:`). +/// +/// Returns `None` for anything that is not one of the 16 standard names plus +/// the common aliases (`grey`, `default`). +fn named_color(s: &str) -> Option { + let lower = s.to_ascii_lowercase(); + Some(match lower.as_str() { + "reset" | "default" => Color::Reset, + "black" => Color::Black, + "red" => Color::Red, + "green" => Color::Green, + "yellow" => Color::Yellow, + "blue" => Color::Blue, + "magenta" => Color::Magenta, + "cyan" => Color::Cyan, + "white" => Color::White, + "darkgray" | "darkgrey" | "gray" | "grey" => Color::DarkGray, + "lightred" => Color::LightRed, + "lightgreen" => Color::LightGreen, + "lightyellow" => Color::LightYellow, + "lightblue" => Color::LightBlue, + "lightmagenta" => Color::LightMagenta, + "lightcyan" => Color::LightCyan, + "lightwhite" => Color::LightWhite, + _ => return None, + }) +} + +/// Convert HSL (`h` in degrees, `s`/`l` in `[0.0, 1.0]`) to `(r, g, b)`. +/// +/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by +/// the caller. +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + let h = wrap_hue(h); + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs()); + let m = l - c / 2.0; + let (r1, g1, b1) = hue_sextant(h, c, x); + ( + round_channel(r1 + m), + round_channel(g1 + m), + round_channel(b1 + m), + ) +} + +/// Convert HSV (`h` in degrees, `s`/`v` in `[0.0, 1.0]`) to `(r, g, b)`. +/// +/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by +/// the caller. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let h = wrap_hue(h); + let c = v * s; + let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs()); + let m = v - c; + let (r1, g1, b1) = hue_sextant(h, c, x); + ( + round_channel(r1 + m), + round_channel(g1 + m), + round_channel(b1 + m), + ) +} + +/// Convert `(r, g, b)` to HSL with `h` in degrees `[0, 360)` and `s`/`l` in +/// `[0.0, 1.0]`. +fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + let max = rf.max(gf).max(bf); + let min = rf.min(gf).min(bf); + let delta = max - min; + let l = (max + min) / 2.0; + + if delta <= f32::EPSILON { + // Achromatic: hue is undefined, conventionally 0. + return (0.0, 0.0, l); + } + + let s = if l > 0.5 { + delta / (2.0 - max - min) + } else { + delta / (max + min) + }; + + let h = if max == rf { + let h = (gf - bf) / delta; + h % 6.0 + } else if max == gf { + (bf - rf) / delta + 2.0 + } else { + (rf - gf) / delta + 4.0 + } * 60.0; + + (wrap_hue(h), s, l) +} + +/// Map a hue (already wrapped into `0..360`) and chroma components onto the +/// six RGB sextants, returning the un-offset `(r, g, b)` floats. +#[inline] +fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) { + match h { + h if h < 60.0 => (c, x, 0.0), + h if h < 120.0 => (x, c, 0.0), + h if h < 180.0 => (0.0, c, x), + h if h < 240.0 => (0.0, x, c), + h if h < 300.0 => (x, 0.0, c), + _ => (c, 0.0, x), + } +} + +/// Wrap a hue in degrees into the half-open range `[0.0, 360.0)`. +#[inline] +fn wrap_hue(h: f32) -> f32 { + let h = h % 360.0; + if h < 0.0 { + h + 360.0 + } else { + h + } +} + +/// Scale a `[0.0, 1.0]` channel to a rounded, clamped `u8`. +#[inline] +fn round_channel(v: f32) -> u8 { + (v * 255.0).round().clamp(0.0, 255.0) as u8 } #[cfg(feature = "serde")] @@ -734,4 +1070,158 @@ mod tests { Color::DarkGray ); } + + // --- v0.21.1: ergonomic constructors / conversions --- + + use std::str::FromStr; + + #[test] + fn from_tuple_and_array() { + assert_eq!(Color::from((255, 107, 107)), Color::Rgb(255, 107, 107)); + assert_eq!(Color::from([1u8, 2, 3]), Color::Rgb(1, 2, 3)); + // Generic `.into()` path resolves through the same impls. + let c: Color = (10, 20, 30).into(); + assert_eq!(c, Color::Rgb(10, 20, 30)); + } + + #[test] + fn from_u32_packs_rrggbb() { + assert_eq!(Color::from(0xff6b6b_u32), Color::Rgb(255, 107, 107)); + assert_eq!(Color::from(0x000000_u32), Color::Rgb(0, 0, 0)); + assert_eq!(Color::from(0xffffff_u32), Color::Rgb(255, 255, 255)); + // High byte (alpha) is ignored. + assert_eq!(Color::from(0xff00ff00_u32), Color::Rgb(0, 255, 0)); + } + + #[test] + fn from_str_hex_round_trips() { + assert_eq!( + Color::from_str("#ff6b6b").unwrap(), + Color::Rgb(255, 107, 107) + ); + // No leading '#'. + assert_eq!( + Color::from_str("ff6b6b").unwrap(), + Color::Rgb(255, 107, 107) + ); + // Short form expands nibbles. + assert_eq!(Color::from_str("#abc").unwrap(), Color::Rgb(170, 187, 204)); + assert_eq!(Color::from_str("abc").unwrap(), Color::Rgb(170, 187, 204)); + // Whitespace is trimmed. + assert_eq!( + Color::from_str(" #ff6b6b ").unwrap(), + Color::Rgb(255, 107, 107) + ); + // Hex parse matches to_hex round-trip. + let c = Color::Rgb(18, 52, 86); + assert_eq!(Color::from_str(&c.to_hex()).unwrap(), c); + } + + #[test] + fn from_str_named_colors() { + assert_eq!(Color::from_str("cyan").unwrap(), Color::Cyan); + assert_eq!(Color::from_str("LightBlue").unwrap(), Color::LightBlue); + assert_eq!(Color::from_str("DARKGRAY").unwrap(), Color::DarkGray); + assert_eq!(Color::from_str("grey").unwrap(), Color::DarkGray); + assert_eq!(Color::from_str("reset").unwrap(), Color::Reset); + assert_eq!(Color::from_str("default").unwrap(), Color::Reset); + } + + #[test] + fn from_str_error_cases() { + // Wrong length with '#' → InvalidLength. + assert_eq!( + Color::from_str("#ff6b").unwrap_err(), + ColorParseError::InvalidLength + ); + // Non-hex digit in a '#'-prefixed 6-char token → InvalidHexDigit. + assert_eq!( + Color::from_str("#zz0011").unwrap_err(), + ColorParseError::InvalidHexDigit + ); + // Non-hex digit in a 3-char token → InvalidHexDigit. + assert_eq!( + Color::from_str("#xyz").unwrap_err(), + ColorParseError::InvalidHexDigit + ); + // Unknown name of non-hex length → Unknown. + assert_eq!( + Color::from_str("nope").unwrap_err(), + ColorParseError::Unknown + ); + assert_eq!(Color::from_str("").unwrap_err(), ColorParseError::Unknown); + } + + #[test] + fn color_parse_error_display_and_error_trait() { + // Display is non-empty and Error trait is implemented. + let e = ColorParseError::InvalidLength; + assert!(!e.to_string().is_empty()); + let _: &dyn std::error::Error = &e; + } + + #[test] + fn from_hsl_primaries() { + assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0)); + assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0)); + assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255)); + // Lightness extremes. + assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0)); + assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255)); + // Zero saturation → gray regardless of hue. + assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128)); + } + + #[test] + fn from_hsl_wraps_and_clamps() { + // Hue 360 wraps to 0 → red. + assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0)); + // Negative hue wraps: -120 == 240 → blue. + assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255)); + // Out-of-range s/l are clamped, no panic. + assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255)); + } + + #[test] + fn from_hsv_primaries() { + assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0)); + assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0)); + assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255)); + // White and black. + assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255)); + assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0)); + } + + #[test] + fn rotate_hue_primary_round_trip() { + // Red rotated 120° → green, another 120° → blue. + assert_eq!( + Color::Rgb(255, 0, 0).rotate_hue(120.0), + Color::Rgb(0, 255, 0) + ); + assert_eq!( + Color::Rgb(0, 255, 0).rotate_hue(120.0), + Color::Rgb(0, 0, 255) + ); + // 180° on red lands on cyan. + assert_eq!( + Color::Rgb(255, 0, 0).rotate_hue(180.0), + Color::Rgb(0, 255, 255) + ); + // Full 360° rotation is a no-op (within rounding) for a primary. + assert_eq!( + Color::Rgb(255, 0, 0).rotate_hue(360.0), + Color::Rgb(255, 0, 0) + ); + } + + #[test] + fn rotate_hue_resolves_named_to_rgb() { + // Named/indexed colors resolve through the palette and yield Rgb. + let rotated = Color::Red.rotate_hue(0.0); + assert_eq!(rotated, Color::Rgb(205, 49, 49)); + let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0); + // Achromatic input stays achromatic (gray) after rotation. + assert_eq!(gray, Color::Rgb(120, 120, 120)); + } } From 4141e31c68d08aaf58a7ea8529aaf2c7363e1869 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:18:45 +0900 Subject: [PATCH 08/17] feat(wasm): DOM flush diff + Phase 1-2 event parity Close the two biggest WASM backend parity gaps against the native crossterm path. DOM flush diff: DomBackend now keeps a `prev: Buffer` snapshot and mutates only the spans whose cell actually changed (Cell derives PartialEq over symbol + style + hyperlink), mirroring the native ANSI diff in src/buffer.rs. Steady-state frames now issue almost no DOM writes instead of touching every cell. A grid rebuild (first frame or resize) forces a full repaint. Event parity (Phase 1-2): wire the remaining input sources into slt Events the core run loop consumes, matching crossterm's mapping: - mouse wheel -> ScrollUp/Down/Left/Right at the cell under the cursor (dominant-axis resolution, zero-delta ignored) - window resize -> Resize(cols, rows); the backend recomputes the grid from the container pixel size, resizes its buffer, and rebuilds the DOM grid next flush - focus/blur -> FocusGained / FocusLost - clipboard paste -> Paste(text) WheelEvent/FocusEvent/ClipboardEvent/DataTransfer web-sys wrappers are not in this crate's feature set, so the new listeners read the generic web_sys::Event reflectively via js-sys (no Cargo changes). Position-dependent listeners now read the live grid size from the backend so they stay correct after a resize. Image overlay (Phase 3) is intentionally left as a follow-up. Adds host-runnable logic tests for the wheel-delta mapping and the color/style CSS translation (incl. zero-delta, tie, and default-style edge cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/slt-wasm/src/lib.rs | 363 ++++++++++++++++++++++++++++++++++++- 1 file changed, 359 insertions(+), 4 deletions(-) diff --git a/crates/slt-wasm/src/lib.rs b/crates/slt-wasm/src/lib.rs index 84a89fe..ee74dd1 100644 --- a/crates/slt-wasm/src/lib.rs +++ b/crates/slt-wasm/src/lib.rs @@ -10,8 +10,16 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{Document, HtmlElement, HtmlPreElement, KeyboardEvent, MouseEvent, Window}; +/// Shared, re-entrant handle to the `requestAnimationFrame` callback so the +/// closure can schedule its own next tick by name. +type RafHandle = Rc>>>; + pub struct DomBackend { buffer: Buffer, + /// Snapshot of the buffer as it was last flushed to the DOM. Used to diff + /// against the live `buffer` so `flush` only mutates spans whose cell + /// actually changed — mirroring the native ANSI diff in `src/buffer.rs`. + prev: Buffer, container: HtmlElement, cells: Vec, initialized: bool, @@ -23,6 +31,7 @@ impl DomBackend { pub fn new(container: HtmlElement, width: u32, height: u32) -> Self { Self { buffer: Buffer::empty(Rect::new(0, 0, width, height)), + prev: Buffer::empty(Rect::new(0, 0, width, height)), container, cells: Vec::new(), initialized: false, @@ -31,6 +40,25 @@ impl DomBackend { } } + /// Resize the backend to a new cell grid, discarding the existing DOM grid. + /// + /// The next [`flush`](DomBackend::flush) rebuilds the `` grid and + /// repaints every cell. Both the live and previous buffers are resized so + /// the diff baseline stays consistent with the new dimensions. A no-op when + /// the dimensions are unchanged or either axis is zero. + pub fn resize(&mut self, width: u32, height: u32) { + if width == 0 || height == 0 || (width == self.width && height == self.height) { + return; + } + self.width = width; + self.height = height; + let area = Rect::new(0, 0, width, height); + self.buffer.resize(area); + self.prev.resize(area); + // Force a full DOM rebuild + repaint on the next flush. + self.initialized = false; + } + fn document(&self) -> Result { self.container .owner_document() @@ -80,6 +108,40 @@ impl DomBackend { self.initialized = true; Ok(()) } + + /// Measure the rendered pixel size of a single cell ``. + /// + /// Returns `None` when the grid has not been built yet or the first span + /// reports a zero-area box (e.g. the container is `display:none`). Used to + /// translate a container pixel resize into a new cell grid. + fn cell_pixel_size(&self) -> Option<(f64, f64)> { + let span = self.cells.first()?; + let rect = web_sys::Element::from(span.clone()).get_bounding_client_rect(); + let (w, h) = (rect.width(), rect.height()); + if w > 0.0 && h > 0.0 { + Some((w, h)) + } else { + None + } + } + + /// Compute the cell grid `(cols, rows)` that fits the container's current + /// pixel size, given a measured per-cell pixel size. + /// + /// Returns `None` when either the container or a cell reports a zero size so + /// the caller can keep the existing dimensions instead of collapsing to a + /// degenerate grid. + fn fit_grid_to_container(&self) -> Option<(u32, u32)> { + let (cell_w, cell_h) = self.cell_pixel_size()?; + let rect = web_sys::Element::from(self.container.clone()).get_bounding_client_rect(); + let (cont_w, cont_h) = (rect.width(), rect.height()); + if cont_w <= 0.0 || cont_h <= 0.0 { + return None; + } + let cols = (cont_w / cell_w).floor().max(1.0) as u32; + let rows = (cont_h / cell_h).floor().max(1.0) as u32; + Some((cols, rows)) + } } impl Backend for DomBackend { @@ -92,16 +154,31 @@ impl Backend for DomBackend { } fn flush(&mut self) -> io::Result<()> { - if !self.initialized { + // When the grid is (re)built every span starts blank, so the previous + // snapshot must be treated as empty to force a full repaint. Otherwise + // we diff against the cells we actually painted last frame and skip the + // unchanged ones — the common steady-state case writes almost nothing. + let full_repaint = !self.initialized; + if full_repaint { self.initialize_grid()?; } let ox = self.buffer.area.x; let oy = self.buffer.area.y; + let pox = self.prev.area.x; + let poy = self.prev.area.y; for y in 0..self.height { for x in 0..self.width { let idx = (y * self.width + x) as usize; let cell = self.buffer.get(ox + x, oy + y); + + // `Cell` derives `PartialEq` over (symbol, style, hyperlink), so + // an equality check captures every visible difference. After a + // grid rebuild `full_repaint` is set and we always write. + if !full_repaint && cell == self.prev.get(pox + x, poy + y) { + continue; + } + let span = self .cells .get(idx) @@ -118,6 +195,10 @@ impl Backend for DomBackend { } } + // Record what is now on screen so the next frame can diff against it. + self.prev.resize(self.buffer.area); + self.prev.content.clone_from(&self.buffer.content); + Ok(()) } } @@ -314,14 +395,110 @@ fn mouse_cell_position( )) } -fn install_event_listeners( +/// Read a numeric property off a JS value via reflection. +/// +/// `web-sys` typed wrappers for `WheelEvent` / `ClipboardEvent` are not enabled +/// in this crate's feature set, so the wheel and paste listeners receive the +/// generic [`web_sys::Event`] and pull the fields they need by name. Returns +/// `None` when the property is missing or not a number. +fn reflect_f64(target: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(target, &JsValue::from_str(key)) + .ok() + .and_then(|v| v.as_f64()) +} + +/// Translate a `wheel` event's scroll deltas into an SLT [`MouseKind`]. +/// +/// The dominant axis wins so a mostly-vertical gesture maps to +/// `ScrollUp`/`ScrollDown` and a mostly-horizontal one to +/// `ScrollLeft`/`ScrollRight`, mirroring how crossterm reports terminal wheel +/// events. Returns `None` for a zero-delta event so we never enqueue a no-op. +fn wheel_delta_to_kind(delta_x: f64, delta_y: f64) -> Option { + if delta_y.abs() >= delta_x.abs() { + if delta_y < 0.0 { + Some(MouseKind::ScrollUp) + } else if delta_y > 0.0 { + Some(MouseKind::ScrollDown) + } else { + None + } + } else if delta_x < 0.0 { + Some(MouseKind::ScrollLeft) + } else { + Some(MouseKind::ScrollRight) + } +} + +/// Compute the cell `(x, y)` under a pointer event from its `clientX`/`clientY`, +/// read reflectively so this works for the generic `wheel` event too. +fn reflect_cell_position( + target: &JsValue, container: &HtmlElement, width: u32, height: u32, +) -> Option<(u32, u32)> { + let client_x = reflect_f64(target, "clientX")?; + let client_y = reflect_f64(target, "clientY")?; + let rect = web_sys::Element::from(container.clone()).get_bounding_client_rect(); + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return None; + } + let rel_x = client_x - rect.left(); + let rel_y = client_y - rect.top(); + if rel_x < 0.0 || rel_y < 0.0 { + return None; + } + let cell_w = rect.width() / width.max(1) as f64; + let cell_h = rect.height() / height.max(1) as f64; + if cell_w <= 0.0 || cell_h <= 0.0 { + return None; + } + let x = (rel_x / cell_w).floor() as u32; + let y = (rel_y / cell_h).floor() as u32; + Some(( + x.min(width.saturating_sub(1)), + y.min(height.saturating_sub(1)), + )) +} + +/// Extract pasted text from a `paste` event's `clipboardData`. +/// +/// `clipboardData.getData("text")` returns the plain-text flavor of the +/// clipboard. Read reflectively because the `ClipboardEvent` / `DataTransfer` +/// web-sys wrappers are not enabled. Returns `None` when there is no text +/// payload so we never enqueue an empty paste. +fn paste_event_text(target: &JsValue) -> Option { + let clipboard_data = js_sys::Reflect::get(target, &JsValue::from_str("clipboardData")).ok()?; + if clipboard_data.is_null() || clipboard_data.is_undefined() { + return None; + } + let get_data = js_sys::Reflect::get(&clipboard_data, &JsValue::from_str("getData")).ok()?; + let get_data: js_sys::Function = get_data.dyn_into().ok()?; + let text = get_data + .call1(&clipboard_data, &JsValue::from_str("text")) + .ok()? + .as_string()?; + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn install_event_listeners( + container: &HtmlElement, + window: &Window, + backend: Rc>, events: Rc>>, ) -> Result<(), JsValue> { container.set_tab_index(0); + // The grid can be re-sized by the `resize` listener, so every + // position-dependent listener reads the live `(width, height)` from the + // backend at dispatch time rather than capturing the initial dimensions. + // JS callbacks are non-reentrant, so a short-lived `borrow()` here can never + // overlap the `borrow_mut()` in the RAF loop or the `resize` listener. + let key_events = Rc::clone(&events); let keydown = Closure::wrap(Box::new(move |event: KeyboardEvent| { if let Some(slt_event) = keyboard_event_to_slt(&event) { @@ -334,7 +511,9 @@ fn install_event_listeners( let move_events = Rc::clone(&events); let container_move = container.clone(); + let move_backend = Rc::clone(&backend); let mousemove = Closure::wrap(Box::new(move |event: MouseEvent| { + let (width, height) = move_backend.borrow().size(); if let Some((x, y)) = mouse_cell_position(&event, &container_move, width, height) { let (pixel_x, pixel_y) = mouse_pixel_position(&event); move_events @@ -354,7 +533,9 @@ fn install_event_listeners( let down_events = Rc::clone(&events); let container_down = container.clone(); + let down_backend = Rc::clone(&backend); let mousedown = Closure::wrap(Box::new(move |event: MouseEvent| { + let (width, height) = down_backend.borrow().size(); if let Some((x, y)) = mouse_cell_position(&event, &container_down, width, height) { let (pixel_x, pixel_y) = mouse_pixel_position(&event); down_events @@ -374,7 +555,9 @@ fn install_event_listeners( let up_events = Rc::clone(&events); let container_up = container.clone(); + let up_backend = Rc::clone(&backend); let mouseup = Closure::wrap(Box::new(move |event: MouseEvent| { + let (width, height) = up_backend.borrow().size(); if let Some((x, y)) = mouse_cell_position(&event, &container_up, width, height) { let (pixel_x, pixel_y) = mouse_pixel_position(&event); up_events.borrow_mut().push(Event::Mouse(SltMouseEvent::new( @@ -390,6 +573,86 @@ fn install_event_listeners( container.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref())?; mouseup.forget(); + // Mouse wheel -> ScrollUp/Down/Left/Right at the cell under the cursor. + // The `WheelEvent` web-sys wrapper is not enabled, so the deltas and the + // pointer position are read reflectively off the generic event. + let wheel_events = Rc::clone(&events); + let container_wheel = container.clone(); + let wheel_backend = Rc::clone(&backend); + let wheel = Closure::wrap(Box::new(move |event: web_sys::Event| { + let value: &JsValue = event.as_ref(); + let delta_x = reflect_f64(value, "deltaX").unwrap_or(0.0); + let delta_y = reflect_f64(value, "deltaY").unwrap_or(0.0); + let Some(kind) = wheel_delta_to_kind(delta_x, delta_y) else { + return; + }; + let (width, height) = wheel_backend.borrow().size(); + let (x, y) = + reflect_cell_position(value, &container_wheel, width, height).unwrap_or((0, 0)); + wheel_events + .borrow_mut() + .push(Event::Mouse(SltMouseEvent::new( + kind, + x, + y, + KeyModifiers::NONE, + None, + None, + ))); + event.prevent_default(); + }) as Box); + container.add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref())?; + wheel.forget(); + + // Window resize -> recompute the cell grid from the container's pixel size + // and emit a `Resize` event. The backend re-sizes its buffer (and rebuilds + // the DOM grid on the next flush) so the core run loop lays out against the + // new dimensions, mirroring crossterm's `Resize`. + let resize_events = Rc::clone(&events); + let resize_backend = Rc::clone(&backend); + let resize = Closure::wrap(Box::new(move |_event: web_sys::Event| { + let mut backend = resize_backend.borrow_mut(); + let Some((cols, rows)) = backend.fit_grid_to_container() else { + return; + }; + if (cols, rows) == backend.size() { + return; + } + backend.resize(cols, rows); + resize_events.borrow_mut().push(Event::Resize(cols, rows)); + }) as Box); + window.add_event_listener_with_callback("resize", resize.as_ref().unchecked_ref())?; + resize.forget(); + + // Focus blur -> FocusLost / FocusGained so widgets can clear hover state, + // matching crossterm's focus events. + let blur_events = Rc::clone(&events); + let blur = Closure::wrap(Box::new(move |_event: web_sys::Event| { + blur_events.borrow_mut().push(Event::FocusLost); + }) as Box); + container.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref())?; + blur.forget(); + + let focus_events = Rc::clone(&events); + let focus = Closure::wrap(Box::new(move |_event: web_sys::Event| { + focus_events.borrow_mut().push(Event::FocusGained); + }) as Box); + container.add_event_listener_with_callback("focus", focus.as_ref().unchecked_ref())?; + focus.forget(); + + // Clipboard paste -> Paste(text). `ClipboardEvent`/`DataTransfer` wrappers + // are not enabled, so the text flavor is pulled reflectively from + // `clipboardData.getData("text")`. + let paste_events = Rc::clone(&events); + let paste = Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(text) = paste_event_text(event.as_ref()) { + paste_events.borrow_mut().push(Event::Paste(text)); + event.prevent_default(); + } + }) as Box); + container.add_event_listener_with_callback("paste", paste.as_ref().unchecked_ref())?; + paste.forget(); + Ok(()) } @@ -409,9 +672,9 @@ where let events = Rc::new(RefCell::new(Vec::::new())); let app = Rc::new(RefCell::new(app)); - install_event_listeners(&container, width, height, Rc::clone(&events))?; + install_event_listeners(&container, &window, Rc::clone(&backend), Rc::clone(&events))?; - let raf: Rc>>> = Rc::new(RefCell::new(None)); + let raf: RafHandle = Rc::new(RefCell::new(None)); let raf_for_assign = Rc::clone(&raf); let raf_for_loop = Rc::clone(&raf); let backend_ref = Rc::clone(&backend); @@ -430,6 +693,10 @@ where let mut backend = backend_ref.borrow_mut(); let mut state = state_ref.borrow_mut(); let mut app = app_ref.borrow_mut(); + // `RefMut` derefs to `T`, but the generic `&mut impl Backend` + // bound has no coercion site, so the explicit reborrow is required + // to hand `frame` a `&mut DomBackend` rather than `&mut RefMut<_>`. + #[allow(clippy::explicit_auto_deref)] slt::frame( &mut *backend, &mut *state, @@ -471,3 +738,91 @@ where pub fn run_wasm_raw(container: HtmlElement, width: u32, height: u32) { let _ = run_wasm(container, width, height, |_ui| {}); } + +#[cfg(test)] +mod tests { + //! Logic-level tests for the DOM backend's pure helpers. The DOM-touching + //! paths (`DomBackend`, the event listeners) require a browser and are + //! exercised via the example harness; these cover the platform-independent + //! translation logic that runs on the host. + use super::*; + + #[test] + fn wheel_vertical_maps_to_scroll_up_down() { + // Negative deltaY scrolls up, positive scrolls down (crossterm parity). + assert_eq!(wheel_delta_to_kind(0.0, -1.0), Some(MouseKind::ScrollUp)); + assert_eq!(wheel_delta_to_kind(0.0, 1.0), Some(MouseKind::ScrollDown)); + } + + #[test] + fn wheel_horizontal_maps_to_scroll_left_right() { + assert_eq!(wheel_delta_to_kind(-3.0, 0.0), Some(MouseKind::ScrollLeft)); + assert_eq!(wheel_delta_to_kind(3.0, 0.0), Some(MouseKind::ScrollRight)); + } + + #[test] + fn wheel_dominant_axis_wins() { + // A mostly-vertical diagonal gesture resolves to vertical scroll. + assert_eq!(wheel_delta_to_kind(2.0, 10.0), Some(MouseKind::ScrollDown)); + // A mostly-horizontal diagonal gesture resolves to horizontal scroll. + assert_eq!(wheel_delta_to_kind(-10.0, 2.0), Some(MouseKind::ScrollLeft)); + } + + #[test] + fn wheel_zero_delta_is_ignored() { + // Edge case: a wheel event with no movement must not enqueue a scroll. + assert_eq!(wheel_delta_to_kind(0.0, 0.0), None); + } + + #[test] + fn wheel_ties_prefer_vertical() { + // Equal magnitudes (|dy| >= |dx|) resolve to the vertical axis. + assert_eq!(wheel_delta_to_kind(5.0, 5.0), Some(MouseKind::ScrollDown)); + assert_eq!(wheel_delta_to_kind(5.0, -5.0), Some(MouseKind::ScrollUp)); + } + + #[test] + fn rgb_color_renders_as_hex() { + assert_eq!( + color_to_css(Some(Color::Rgb(0x12, 0x34, 0x56))), + Some("#123456".to_string()) + ); + } + + #[test] + fn reset_color_is_transparent() { + assert_eq!(color_to_css(Some(Color::Reset)), None); + assert_eq!(color_to_css(None), None); + } + + #[test] + fn indexed_color_cube_and_grayscale() { + // 16-color base, the 6x6x6 cube, and the grayscale ramp must all map. + assert_eq!(indexed_to_rgb(0), (0, 0, 0)); + assert_eq!(indexed_to_rgb(15), (255, 255, 255)); + assert_eq!(indexed_to_rgb(16), (0, 0, 0)); // cube origin + assert_eq!(indexed_to_rgb(231), (255, 255, 255)); // cube far corner + assert_eq!(indexed_to_rgb(232), (8, 8, 8)); // grayscale start + assert_eq!(indexed_to_rgb(255), (238, 238, 238)); // grayscale end + } + + #[test] + fn style_css_includes_modifiers() { + let style = slt::Style::new() + .fg(Color::Red) + .bg(Color::Black) + .bold() + .underline(); + let css = style_to_css(style); + assert!(css.contains("color:#cd3131;")); + assert!(css.contains("background-color:#000000;")); + assert!(css.contains("font-weight:bold;")); + assert!(css.contains("text-decoration:underline;")); + } + + #[test] + fn default_style_emits_no_css() { + // Edge case: a plain default style produces no inline CSS at all. + assert_eq!(style_to_css(slt::Style::new()), ""); + } +} From 50cd79a2571a937c98e2d5dfe37ec8adca245523 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:22:23 +0900 Subject: [PATCH 09/17] feat: gate sync-output on DECRQM, optimize sprixel reblit scan, doc OSC52 read hazard - sync-output capability gate: probe DECRQM ?2026 in probe_capabilities, set Capabilities::sync_output on positive support, and gate BSU/ESU emission on a tri-state resolution. Silent/headless probes stay Unknown and keep emitting exactly as before (behavior-preserving default). - sprixel reblit scan: replace the O(n*m) previous.sprixels.iter().any(..) position lookup with a hashed-key HashSet and add a per-row clean+hash shortcut so untouched footprint rows skip the per-cell annihilation scan. - read_clipboard: document the stdin typeahead-swallow concurrency hazard and recommended usage (signature unchanged). - bench: add #[doc(hidden)] __bench_flush_kitty, __BenchSprixelFixture, __bench_new_sprixel_fixture, and __bench_flush_sprixels wrappers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/terminal.rs | 565 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 550 insertions(+), 15 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 2b9c314..e91f18c 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -526,10 +526,10 @@ pub fn capabilities() -> Capabilities { fn probe_capabilities() -> Capabilities { let mut caps = Capabilities::default(); - // Total stdin wait is bounded to ≤150ms (90 + 30 + 30) so a silent - // terminal cannot stall startup beyond the existing OSC-11 budget. A - // responsive terminal replies in well under 10ms, so the common path adds - // negligible latency. + // Total stdin wait is bounded to ≤180ms (90 + 30 + 30 + 30) so a silent + // terminal cannot stall startup beyond a small multiple of the existing + // OSC-11 budget. A responsive terminal replies in well under 10ms per + // query, so the common path adds negligible latency. let mut out = io::stdout(); // DA1 then DA2 in one write — both terminate with `c`, so a single // DA-aware read drains both replies (in order) when supported. @@ -557,6 +557,28 @@ fn probe_capabilities() -> Capabilities { } } + // DECRQM for synchronized output (mode ?2026): CSI ? 2026 $ p. A supporting + // terminal replies CSI ? 2026 ; $ y, where Ps ∈ {1,2} (set / reset) + // both mean *recognized*; Ps = 0 means the mode is not recognized. The + // reply terminates with `y` rather than BEL / ST, so it needs the + // DECRPM-aware reader. A silent terminal leaves the resolution `Unknown`, + // which the flush gate treats as "keep emitting" — preserving the historic + // always-emit behavior on headless / non-answering hosts. + if write!(out, "\x1b[?2026$p").is_ok() && out.flush().is_ok() { + if let Some(resp) = read_decrpm_response(Duration::from_millis(30)) { + match parse_decrpm_sync_output(&resp) { + Some(true) => { + caps.sync_output = true; + let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported); + } + Some(false) => { + let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported); + } + None => {} + } + } + } + // Env precedence chain stays authoritative for truecolor: a positive // COLORTERM/TERM signal confirms it even when the probe is silent. if matches!(ColorDepth::detect(), ColorDepth::TrueColor) { @@ -742,6 +764,97 @@ fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) { } } +/// Tri-state outcome of the DECRQM ?2026 (synchronized output) probe. +/// +/// The synchronized-output BSU/ESU emission is gated on this rather than on the +/// public [`Capabilities::sync_output`] bool alone, because the public flag is +/// only ever set on *positive* support evidence. Gating emission on that flag +/// directly would flip the historic always-emit behavior to never-emit on every +/// headless / non-answering host (a regression). This tri-state lets the gate +/// suppress BSU/ESU **only** when the terminal definitively reported the mode +/// unrecognized, and keep emitting in the `Unknown` (silent / headless) case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SyncOutputResolution { + /// DECRQM confirmed mode ?2026 is recognized (set or reset). + Supported, + /// DECRQM explicitly reported mode ?2026 as not recognized (Ps = 0). + Unsupported, +} + +/// Process-global resolution of the synchronized-output probe, populated at most +/// once by [`probe_capabilities`]. Absent (`Unknown`) until the probe answers. +static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock = + std::sync::OnceLock::new(); + +/// Whether the flush pipeline should wrap a frame in synchronized-output +/// BSU/ESU guards. +/// +/// Returns `true` (emit) unless the DECRQM ?2026 probe *definitively* reported +/// the mode as unrecognized. A silent / headless / never-run probe leaves the +/// resolution `Unknown`, in which case this keeps emitting exactly as the +/// pre-gate code always did. This is the behavior-preserving half of the +/// capability gate: positive support and the unknown default both emit; only a +/// confirmed-unsupported terminal suppresses. +fn should_emit_synchronized_update() -> bool { + !matches!( + SYNC_OUTPUT_RESOLUTION.get(), + Some(SyncOutputResolution::Unsupported) + ) +} + +/// Read a DECRPM reply, which terminates with the byte `y` rather than BEL / ST +/// (used for the DECRQM ?2026 synchronized-output probe). Bounded by `timeout` +/// so a terminal that ignores the query cannot stall startup. +#[cfg(feature = "crossterm")] +fn read_decrpm_response(timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + let mut stdin = io::stdin(); + let mut bytes = Vec::new(); + let mut buf = [0u8; 1]; + + while Instant::now() < deadline { + if !crossterm::event::poll(Duration::from_millis(10)).ok()? { + continue; + } + let read = stdin.read(&mut buf).ok()?; + if read == 0 { + continue; + } + bytes.push(buf[0]); + // `y` is the final byte of a DECRPM reply (`CSI ? ; $ y`). + if buf[0] == b'y' { + break; + } + if bytes.len() >= 4096 { + break; + } + } + + if bytes.is_empty() { + return None; + } + String::from_utf8(bytes).ok() +} + +/// Parse a DECRPM reply for synchronized output (mode `2026`): +/// `CSI ? 2026 ; $ y`. +/// +/// Returns: +/// * `Some(true)` — mode recognized (`Ps` ∈ {1, 2, 3, 4}: set / reset / +/// permanently-set / permanently-reset all mean *supported*), +/// * `Some(false)` — mode not recognized (`Ps` = 0), +/// * `None` — no DECRPM reply for mode 2026 in the string. +#[cfg(feature = "crossterm")] +fn parse_decrpm_sync_output(response: &str) -> Option { + // Reply body: ESC [ ? 2026 ; $ y + let pos = response.find("\x1b[?2026;")?; + let body = &response[pos + "\x1b[?2026;".len()..]; + let end = body.find("$y")?; + let ps = body[..end].trim().parse::().ok()?; + // Ps = 0 → not recognized; any other reported state means the mode exists. + Some(ps != 0) +} + fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> { let mut chunks = Vec::new(); let bytes = encoded.as_bytes(); @@ -918,7 +1031,13 @@ impl Terminal { execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?; } - queue!(self.stdout, BeginSynchronizedUpdate)?; + // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe + // (v0.21.1): emit unless the terminal definitively reported the mode + // unrecognized. A silent / headless probe keeps emitting as before. + let sync_guard = should_emit_synchronized_update(); + if sync_guard { + queue!(self.stdout, BeginSynchronizedUpdate)?; + } // Issue #171: refresh both buffers' per-row digests so the per-row // skip inside `flush_buffer_diff` can short-circuit unchanged rows. // `previous` only needs a recompute when the prior frame mutated @@ -945,7 +1064,9 @@ impl Terminal { // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265). flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?; - queue!(self.stdout, EndSynchronizedUpdate)?; + if sync_guard { + queue!(self.stdout, EndSynchronizedUpdate)?; + } flush_cursor( &mut self.stdout, &mut self.cursor_visible, @@ -1109,7 +1230,12 @@ impl InlineTerminal { execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?; } - queue!(self.stdout, BeginSynchronizedUpdate)?; + // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe + // (v0.21.1); see `Terminal::flush`. Silent / headless keeps emitting. + let sync_guard = should_emit_synchronized_update(); + if sync_guard { + queue!(self.stdout, BeginSynchronizedUpdate)?; + } if !self.reserved { queue!(self.stdout, cursor::MoveToColumn(0))?; @@ -1152,7 +1278,9 @@ impl InlineTerminal { // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265). flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?; - queue!(self.stdout, EndSynchronizedUpdate)?; + if sync_guard { + queue!(self.stdout, EndSynchronizedUpdate)?; + } let fallback_row = row_offset + self.height.saturating_sub(1); flush_cursor( &mut self.stdout, @@ -1386,7 +1514,38 @@ fn parse_osc52_response(response: &str) -> Option { base64_decode(encoded) } -/// Read clipboard contents via OSC 52 terminal query. +/// Read clipboard contents via an OSC 52 terminal query. +/// +/// Writes the OSC 52 read request (`ESC ] 52 ; c ; ? BEL`) to stdout, then +/// blocks reading the terminal's reply from stdin for up to ~200 ms. Returns +/// the decoded clipboard text, or `None` if the terminal does not answer, the +/// reply is empty, or it cannot be decoded. Many terminals disable OSC 52 reads +/// by default for security, in which case this always returns `None`. +/// +/// # Note +/// +/// This call reads the **same stdin** the [`run`](crate::run) event loop polls, +/// **synchronously and outside** the loop's own event dispatch. That creates a +/// typeahead-swallow hazard: during the blocking read window, any bytes the user +/// types — and any other terminal report in flight (mouse, focus, paste, a +/// different OSC reply) — land in this function's byte reader instead of the +/// event queue. Keystrokes consumed here are silently lost, and a foreign report +/// interleaved with the OSC 52 reply can corrupt parsing so the read returns +/// `None`. There is no locking between this reader and the run loop's poll, so +/// calling it concurrently from another thread while the loop is running races +/// on stdin. +/// +/// Recommended usage: +/// * Call it from the main thread, **not** from a spawned thread, and never +/// concurrently with a running [`run`](crate::run) loop on another thread. +/// * Trigger it only in direct response to an explicit user action (e.g. a +/// paste keybinding) and keep the window brief, so the typeahead lost to the +/// blocking read is bounded to that moment. +/// * Prefer the OS clipboard via a dedicated crate when reliable, race-free +/// clipboard reads are required; reserve this for the no-dependency, +/// terminal-only fallback. +/// * For *writing* the clipboard there is no such hazard — that path only +/// emits bytes and never reads stdin. #[cfg(feature = "crossterm")] pub fn read_clipboard() -> Option { let mut stdout = io::stdout(); @@ -1772,6 +1931,123 @@ impl __BenchKittyFixture { } } +/// Benchmark-only entry point for the Kitty image flush path. +/// +/// Builds an `n`-image fixture and runs [`KittyImageManager::flush`] once into +/// the supplied sink at `row_offset`, mirroring the [`__bench_flush_buffer_diff`] +/// free-function style. `KittyPlacement` / `KittyImageManager` are `pub(crate)`, +/// so an external bench crate cannot construct them directly — this wrapper owns +/// the construction and only the `Write` sink crosses the crate boundary. +/// +/// Not part of the stable API. +#[doc(hidden)] +pub fn __bench_flush_kitty(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> { + let mut fixture = __bench_new_kitty_fixture(n); + fixture.flush_inline(sink, row_offset) +} + +/// Opaque test/bench fixture wrapping two `Buffer`s populated with structurally +/// identical sprixel placements, used to drive the [`flush_sprixels`] re-blit +/// path. `SprixelPlacement` is `pub(crate)`, so this fixture owns construction +/// and exposes only `Write`-based flush entry points across the crate boundary. +/// +/// Returned by [`__bench_new_sprixel_fixture`]. +#[doc(hidden)] +pub struct __BenchSprixelFixture { + current: Buffer, + previous: Buffer, +} + +/// Build a self-contained sprixel-reblit fixture for the perf suite (v0.21.1). +/// +/// Creates `n` opaque sprixel placements laid out down the buffer and mirrors +/// them into both the current and previous frame so the steady-state flush +/// re-blits nothing. Per-row digests are refreshed (as the real `flush` does) +/// so the per-row clean+hash shortcut in [`sprixel_needs_reblit`] is exercised. +/// +/// Not part of the stable API. +#[doc(hidden)] +pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture { + use crate::buffer::{SprixelCell, SprixelPlacement}; + + // A buffer tall enough to stack `n` 2-row sprixels with a 1-row gap. + let height = (n as u32 * 3).max(1); + let area = Rect::new(0, 0, 8, height); + let mut current = Buffer::empty(area); + let mut previous = Buffer::empty(area); + + for i in 0..n { + let placement = SprixelPlacement { + content_hash: 0x5000 + i as u64, + seq: "".to_string(), + x: 0, + y: i as u32 * 3, + cols: 4, + rows: 2, + cells: vec![SprixelCell::Opaque; 8], + }; + current.sprixels.push(placement.clone()); + previous.sprixels.push(placement); + } + + // Refresh digests so the per-row shortcut can fire, matching the real + // `Terminal::flush` ordering (recompute happens before `flush_sprixels`). + current.recompute_line_hashes(); + previous.recompute_line_hashes(); + + __BenchSprixelFixture { current, previous } +} + +// The bench fixture's inherent methods are reachable only once the crate root +// re-exports `__BenchSprixelFixture` (an integrator step listed in the release +// notes); until then the lib-target dead-code lint flags them, exactly as it +// would the already-shipped `__BenchKittyFixture` methods without their +// `lib.rs` re-export. They are also exercised by the in-crate tests below. +// Suppress the lint on the impl rather than gating the items behind `cfg(test)`, +// which would make them invisible to the external `benches/` crate they exist +// to serve. +#[allow(dead_code)] +impl __BenchSprixelFixture { + /// Run [`flush_sprixels`] once, writing any re-blitted graphics into `sink`. + /// A steady-state fixture emits nothing; this measures the no-damage scan + /// cost (hash-set build + per-row shortcut) on the hot path. + #[doc(hidden)] + pub fn flush(&self, sink: &mut W, row_offset: u32) -> io::Result<()> { + flush_sprixels(sink, &self.current, &self.previous, row_offset) + } + + /// Number of sprixel placements in this fixture. + #[doc(hidden)] + pub fn len(&self) -> usize { + self.current.sprixels.len() + } + + /// Whether this fixture has zero placements. + #[doc(hidden)] + pub fn is_empty(&self) -> bool { + self.current.sprixels.is_empty() + } +} + +/// Benchmark-only entry point for the optimized sprixel re-blit scan (v0.21.1). +/// +/// Builds an `n`-placement steady-state fixture and runs [`flush_sprixels`] once +/// into `sink` at `row_offset`, mirroring the [`__bench_flush_buffer_diff`] +/// free-function style. A steady frame re-blits nothing, so this measures the +/// no-damage scan cost (hashed-key build + per-row clean/hash shortcut). When +/// the fixture is empty the early-out fires and no work is done. +/// +/// Not part of the stable API. +#[doc(hidden)] +pub fn __bench_flush_sprixels(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> { + let fixture = __bench_new_sprixel_fixture(n); + if fixture.is_empty() { + return Ok(()); + } + debug_assert_eq!(fixture.len(), n); + fixture.flush(sink, row_offset) +} + fn flush_raw_sequences( stdout: &mut impl Write, current: &Buffer, @@ -1793,12 +2069,25 @@ fn flush_raw_sequences( Ok(()) } +/// Structural identity key for a [`crate::buffer::SprixelPlacement`], matching +/// its [`PartialEq`] contract (`content_hash`/`x`/`y`/`cols`/`rows`, damage +/// matrix excluded). Hashing this lets [`flush_sprixels`] answer "did an equal +/// placement exist last frame?" in O(1) instead of an O(n·m) linear scan. +type SprixelKey = (u64, u32, u32, u32, u32); + +/// Build the structural identity key for a placement. +#[inline] +fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey { + (p.content_hash, p.x, p.y, p.cols, p.rows) +} + /// Decide whether a sprixel placement must be re-blitted this frame, applying /// the per-cell damage matrix (issue #265). /// /// Returns `true` when: /// * the placement is new or its `(x, y, content_hash, cols, rows)` changed -/// (no structurally equal placement in the previous frame), OR +/// (its key is absent from `prev_keys`, the precomputed set of last frame's +/// placement keys), OR /// * a text cell inside the footprint was overwritten this frame *and* the /// footprint marks that cell as covering graphic ink /// ([`SprixelCell::Opaque`] / [`SprixelCell::Mixed`]) — i.e. the cell is @@ -1806,17 +2095,27 @@ fn flush_raw_sequences( /// /// A pure text edit landing on a [`SprixelCell::Transparent`] cell never marks /// damage, so the graphic is not re-emitted. +/// +/// The footprint scan short-circuits an entire footprint row when that row was +/// untouched this frame *and* hashes identically to the previous frame +/// (`current.row_clean(y) && current.row_hash(y) == previous.row_hash(y)`): +/// no cell in such a row can have changed, so no ink can have been annihilated. +/// On the headless / direct-call path (where `recompute_line_hashes` was not +/// run) every row reports dirty, so the shortcut never fires and the per-cell +/// scan runs exactly as before — preserving correctness. fn sprixel_needs_reblit( placement: &crate::buffer::SprixelPlacement, current: &Buffer, previous: &Buffer, + prev_keys: &std::collections::HashSet, ) -> bool { use crate::buffer::SprixelCell; // Position / content change: re-blit if no equal placement existed last - // frame. `SprixelPlacement: PartialEq` compares content_hash/x/y/cols/rows - // (the damage matrix is excluded), so a moved or recolored image re-blits. - if !previous.sprixels.iter().any(|p| p == placement) { + // frame. The key mirrors `SprixelPlacement: PartialEq` (content_hash/x/y/ + // cols/rows; damage matrix excluded), so a moved or recolored image + // re-blits. O(1) lookup vs the former O(n·m) `iter().any(..)` scan. + if !prev_keys.contains(&sprixel_key(placement)) { return true; } @@ -1824,6 +2123,13 @@ fn sprixel_needs_reblit( // now shows ink forces a re-blit. `Transparent` cells are skipped so free // text edits in graphic gaps emit zero sprixel bytes. for row in 0..placement.rows { + let y = placement.y + row; + // Per-row shortcut: a row that was not touched this frame and whose + // cached digest matches the previous frame's cannot contain a changed + // cell, so the whole footprint row is skipped without per-cell work. + if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) { + continue; + } for col in 0..placement.cols { let idx = (row * placement.cols + col) as usize; match placement.cells.get(idx) { @@ -1833,7 +2139,6 @@ fn sprixel_needs_reblit( _ => continue, } let x = placement.x + col; - let y = placement.y + row; // A footprint can extend past the buffer edge (a clipped placement, // or `iterm_image_fit` reserving rows beyond the viewport). Use // `try_get` so an out-of-bounds footprint cell is simply skipped @@ -1861,14 +2166,28 @@ fn sprixel_needs_reblit( /// pixel graphic **only** when [`sprixel_needs_reblit`] reports damage, so a /// text edit in a transparent region of a Sixel emits zero passthrough bytes /// (issue #265). +/// +/// The previous frame's placement keys are hashed once up front so the +/// position/content change check is O(1) per placement (vs the former O(n·m) +/// linear scan), and the per-row clean+hash shortcut inside +/// [`sprixel_needs_reblit`] skips untouched footprint rows entirely. fn flush_sprixels( stdout: &mut impl Write, current: &Buffer, previous: &Buffer, row_offset: u32, ) -> io::Result<()> { + // Early out: no graphics to emit. Avoids building the key set on the + // common text-only frame. + if current.sprixels.is_empty() { + return Ok(()); + } + + let prev_keys: std::collections::HashSet = + previous.sprixels.iter().map(sprixel_key).collect(); + for placement in ¤t.sprixels { - if sprixel_needs_reblit(placement, current, previous) { + if sprixel_needs_reblit(placement, current, previous, &prev_keys) { queue!( stdout, cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)), @@ -2740,6 +3059,46 @@ mod tests { assert!(!caps.kitty_graphics); } + #[cfg(feature = "crossterm")] + #[test] + fn parse_decrpm_sync_output_recognized_states_are_supported() { + // Ps = 1 (set), 2 (reset), 3 (perm set), 4 (perm reset) all mean the + // mode is recognized → supported. + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true)); + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true)); + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true)); + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true)); + } + + #[cfg(feature = "crossterm")] + #[test] + fn parse_decrpm_sync_output_ps0_is_unsupported() { + // Ps = 0 → mode not recognized. + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false)); + } + + #[cfg(feature = "crossterm")] + #[test] + fn parse_decrpm_sync_output_garbage_is_none() { + // No DECRPM reply for mode 2026 in the string → inconclusive. + assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None); + // A reply for a *different* mode must not match. + assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None); + // Truncated reply (missing `$y` terminator) → None, not a panic. + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None); + // Non-numeric Ps → None. + assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None); + } + + #[test] + fn sync_output_gate_defaults_to_emit() { + // With the probe never having run (the unit-test process never enters a + // real terminal session), the resolution stays `Unknown`, so the gate + // must keep emitting BSU/ESU — preserving the historic always-emit + // behavior on headless / non-answering hosts. + assert!(should_emit_synchronized_update()); + } + #[cfg(feature = "crossterm")] #[test] fn parse_xtgettcap_tc_sets_truecolor() { @@ -3922,4 +4281,180 @@ mod tests { } } } + + // ---- v0.21.1 sprixel reblit-scan optimization regression --------------- + // + // These drive the hashed-key position lookup and the per-row clean+hash + // shortcut with `recompute_line_hashes` engaged (the real `flush` ordering), + // proving the optimization preserves the exact #265 re-blit semantics. + + #[test] + fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() { + // Regression: a steady frame (identical to previous) with per-row + // digests refreshed must NOT re-blit. This exercises the per-row + // clean+hash shortcut: every footprint row is clean and hash-matched, so + // the per-cell scan is skipped and nothing is emitted. + let area = Rect::new(0, 0, 10, 5); + let placement = make_sprixel(vec![SprixelCell::Opaque; 4]); + + let mut current = Buffer::empty(area); + current.sprixels.push(placement.clone()); + let mut previous = Buffer::empty(area); + previous.sprixels.push(placement); + + // Match `Terminal::flush`: refresh digests before the sprixel pass. + current.recompute_line_hashes(); + previous.recompute_line_hashes(); + // Sanity: the footprint rows are clean and hash-identical, so the + // shortcut is the path actually taken. + assert!(current.row_clean(1) && current.row_clean(2)); + assert_eq!(current.row_hash(1), previous.row_hash(1)); + + let mut out: Vec = Vec::new(); + flush_sprixels(&mut out, ¤t, &previous, 0).unwrap(); + assert!( + out.is_empty(), + "unchanged sprixel must not be re-blitted (per-row shortcut)" + ); + } + + #[test] + fn sprixel_changed_text_with_hashes_engaged_reblits_once() { + // Regression: a text write over an opaque footprint cell must still + // re-blit exactly once even with digests refreshed. The touched row is + // dirty (or hash-mismatched), so the shortcut correctly does NOT skip it + // and the per-cell annihilation scan fires. + let area = Rect::new(0, 0, 10, 5); + let placement = make_sprixel(vec![SprixelCell::Opaque; 4]); + + let mut current = Buffer::empty(area); + current.sprixels.push(placement.clone()); + current.set_char(1, 1, 'X', Style::new()); + let mut previous = Buffer::empty(area); + previous.sprixels.push(placement); + + current.recompute_line_hashes(); + previous.recompute_line_hashes(); + // The footprint's top row differs from the previous frame. + assert_ne!(current.row_hash(1), previous.row_hash(1)); + + let mut out: Vec = Vec::new(); + flush_sprixels(&mut out, ¤t, &previous, 0).unwrap(); + let s = String::from_utf8(out).unwrap(); + assert_eq!( + s.matches("").count(), + 1, + "annihilating text write must re-blit exactly once" + ); + } + + #[test] + fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() { + // Regression edge case: even though the touched row is dirty/hash-mismatched + // (so the per-row shortcut does NOT skip it), a write landing only on a + // Transparent footprint cell must still emit zero bytes — the per-cell + // damage matrix governs, exactly as in the unoptimized path. + let area = Rect::new(0, 0, 10, 5); + let cells = vec![ + SprixelCell::Transparent, // (1, 1) + SprixelCell::Opaque, // (2, 1) + SprixelCell::Opaque, // (1, 2) + SprixelCell::Opaque, // (2, 2) + ]; + let placement = make_sprixel(cells); + + let mut current = Buffer::empty(area); + current.sprixels.push(placement.clone()); + current.set_char(1, 1, 'X', Style::new()); + let mut previous = Buffer::empty(area); + previous.sprixels.push(placement); + + current.recompute_line_hashes(); + previous.recompute_line_hashes(); + + let mut out: Vec = Vec::new(); + flush_sprixels(&mut out, ¤t, &previous, 0).unwrap(); + assert!( + out.is_empty(), + "transparent-cell text write must not re-blit even with hashes engaged" + ); + } + + #[test] + fn sprixel_key_matches_partial_eq_contract() { + // The hashed identity key must agree with `SprixelPlacement: PartialEq`: + // equal placements share a key; any field the PartialEq compares + // produces a distinct key. + let base = make_sprixel(vec![SprixelCell::Opaque; 4]); + assert_eq!(sprixel_key(&base), sprixel_key(&base.clone())); + + let mut moved = base.clone(); + moved.x = 7; + assert_ne!(sprixel_key(&base), sprixel_key(&moved)); + + let mut recolored = base.clone(); + recolored.content_hash = 0x9999; + assert_ne!(sprixel_key(&base), sprixel_key(&recolored)); + + // The damage matrix is excluded from both PartialEq and the key. + let mut annihilated = base.clone(); + annihilated.cells = vec![SprixelCell::Annihilated; 4]; + assert_eq!(sprixel_key(&base), sprixel_key(&annihilated)); + assert_eq!(base, annihilated); + } + + #[test] + fn sprixel_multi_placement_only_changed_one_reblits() { + // With several stacked sprixels, moving one must re-blit only that one; + // the others (clean, hash-matched) stay silent. Exercises the hash-set + // position lookup across multiple placements. + let area = Rect::new(0, 0, 10, 9); + let mut current = Buffer::empty(area); + let mut previous = Buffer::empty(area); + for i in 0..3u32 { + let p = SprixelPlacement { + content_hash: 0x100 + i as u64, + seq: format!(""), + x: 0, + y: i * 3, + cols: 2, + rows: 2, + cells: vec![SprixelCell::Opaque; 4], + }; + current.sprixels.push(p.clone()); + previous.sprixels.push(p); + } + // Move only the middle sprixel. + current.sprixels[1].x = 5; + + current.recompute_line_hashes(); + previous.recompute_line_hashes(); + + let mut out: Vec = Vec::new(); + flush_sprixels(&mut out, ¤t, &previous, 0).unwrap(); + let s = String::from_utf8(out).unwrap(); + assert_eq!(s.matches("").count(), 0); + assert_eq!( + s.matches("").count(), + 1, + "only the moved sprixel reblits" + ); + assert_eq!(s.matches("").count(), 0); + } + + #[test] + fn bench_sprixel_fixture_steady_state_emits_nothing() { + // The bench fixture must represent a steady frame (no re-blit) so it + // measures the no-damage scan cost. Guards against the wrapper silently + // emitting work. + let fixture = __bench_new_sprixel_fixture(4); + assert_eq!(fixture.len(), 4); + assert!(!fixture.is_empty()); + let mut out: Vec = Vec::new(); + fixture.flush(&mut out, 0).unwrap(); + assert!( + out.is_empty(), + "steady-state bench fixture re-blits nothing" + ); + } } From 2803083ca0e1effff3574e32df7a4b5cf2cc2e15 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:25:34 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat(context):=20interaction-signal=20cor?= =?UTF-8?q?e=20=E2=80=94=20focus-edge,=20submitted,=20double-click,=20scro?= =?UTF-8?q?ll=5Fdelta,=20focus=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the issue #208 follow-up: text_input/slider/number_input now report gained_focus/lost_focus via a shared focus_transitions() helper. Add Response.submitted (Enter in text_input), .double_clicked (same-cell within ~400ms), .scroll_delta (hover-gated wheel), and on_click/on_changed/on_focus/ on_submit/on_double_click callback chaining. Add public focus_next/focus_prev and focus_next_in_group/focus_prev_in_group for programmatic + group-scoped focus traversal. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/core.rs | 13 ++ src/context/runtime.rs | 225 ++++++++++++++---- src/context/state.rs | 81 +++++++ src/context/widgets_display/layout.rs | 24 ++ src/context/widgets_input/feedback.rs | 10 + src/context/widgets_input/text_input.rs | 30 +++ src/lib.rs | 12 + tests/v020_hooks_focus.rs | 298 +++++++++++++++++++++++- 8 files changed, 646 insertions(+), 47 deletions(-) diff --git a/src/context/core.rs b/src/context/core.rs index 146acf2..a0ee4bc 100644 --- a/src/context/core.rs +++ b/src/context/core.rs @@ -52,6 +52,19 @@ pub struct Context { /// event in this frame. Mirrors `click_pos` for the right-button. Used /// by `response_for` to populate `Response::right_clicked`. pub(crate) right_click_pos: Option<(u32, u32)>, + /// v0.21.1: position of a detected double-click this frame (second + /// `MouseButton::Left` `Down` on the same cell within the double-click + /// window). `None` when no double-click occurred. Hit-tested by + /// `response_for` to populate `Response::double_clicked`. + pub(crate) double_click_pos: Option<(u32, u32)>, + /// v0.21.1: position of the most recent scroll-wheel event this frame, used + /// to hover-gate `Response::scroll_delta`. `None` when the wheel did not + /// move. + pub(crate) scroll_pos: Option<(u32, u32)>, + /// v0.21.1: net vertical wheel delta accumulated this frame (positive = + /// up, negative = down). Surfaced per-widget through + /// `Response::scroll_delta` when `scroll_pos` falls inside the widget rect. + pub(crate) scroll_delta_frame: i32, pub(crate) prev_modal_active: bool, pub(crate) clipboard_text: Option, pub(crate) debug: bool, diff --git a/src/context/runtime.rs b/src/context/runtime.rs index b8166f2..55b490f 100644 --- a/src/context/runtime.rs +++ b/src/context/runtime.rs @@ -41,21 +41,54 @@ impl Context { let diagnostics = &mut state.diagnostics; let consumed = vec![false; events.len()]; + // Single wall-clock sample for this frame, reused for double-click + // timing below and for `frame_instant` (the timer/scheduler clock). + let frame_now = std::time::Instant::now(); let mut mouse_pos = layout_feedback.last_mouse_pos; let mut click_pos = None; let mut right_click_pos = None; + let mut double_click_pos = None; + let mut scroll_pos = None; + let mut scroll_delta_frame: i32 = 0; for event in &events { if let Event::Mouse(mouse) = event { mouse_pos = Some((mouse.x, mouse.y)); match mouse.kind { MouseKind::Down(MouseButton::Left) => { click_pos = Some((mouse.x, mouse.y)); + // v0.21.1: a left click on the same cell as the previous + // click, within `DOUBLE_CLICK_WINDOW`, is a double-click. + // Clear the tracker after firing so a third click starts + // a fresh pair (no triple-counting). + let pos = (mouse.x, mouse.y); + let is_double = layout_feedback.last_click_pos == Some(pos) + && layout_feedback.last_click_at.is_some_and(|t| { + frame_now.duration_since(t) <= crate::DOUBLE_CLICK_WINDOW + }); + if is_double { + double_click_pos = Some(pos); + layout_feedback.last_click_at = None; + layout_feedback.last_click_pos = None; + } else { + layout_feedback.last_click_at = Some(frame_now); + layout_feedback.last_click_pos = Some(pos); + } } MouseKind::Down(MouseButton::Right) => { // Issue #208: capture last right-click position so // `response_for` can hit-test against per-widget rects. right_click_pos = Some((mouse.x, mouse.y)); } + // v0.21.1: accumulate net vertical wheel delta + the cursor + // position, hover-gated per-widget by `response_for`. + MouseKind::ScrollUp => { + scroll_pos = Some((mouse.x, mouse.y)); + scroll_delta_frame = scroll_delta_frame.saturating_add(1); + } + MouseKind::ScrollDown => { + scroll_pos = Some((mouse.x, mouse.y)); + scroll_delta_frame = scroll_delta_frame.saturating_sub(1); + } _ => {} } } @@ -159,6 +192,9 @@ impl Context { mouse_pos, click_pos, right_click_pos, + double_click_pos, + scroll_pos, + scroll_delta_frame, prev_modal_active: focus.prev_modal_active, clipboard_text: None, debug: diagnostics.debug_mode, @@ -203,8 +239,10 @@ impl Context { focus_name_map: std::collections::HashMap::new(), pending_focus_name: still_pending, // Issue #248: sample a single wall-clock "now" for every timer - // method called this frame. - frame_instant: std::time::Instant::now(), + // method called this frame. v0.21.1: reuse the `frame_now` sampled + // above (also used for double-click timing) so the frame has one + // coherent clock reading. + frame_instant: frame_now, scheduler, // Issue #234: async task registry round-tripped like `scheduler`. #[cfg(feature = "async")] @@ -278,6 +316,111 @@ impl Context { self.prev_focus_count } + /// Advance keyboard focus one step, honoring an active modal's focus trap. + /// `forward` selects next vs previous; both wrap. Shared by + /// [`focus_next`](Self::focus_next) / [`focus_prev`](Self::focus_prev) and + /// the `Tab`/`Shift+Tab` handler in `process_focus_keys` (v0.21.1). + pub(crate) fn advance_focus(&mut self, forward: bool) { + if self.prev_modal_active && self.prev_modal_focus_count > 0 { + let mut modal_local = self.focus_index.saturating_sub(self.prev_modal_focus_start); + modal_local %= self.prev_modal_focus_count; + let next = if forward { + (modal_local + 1) % self.prev_modal_focus_count + } else if modal_local == 0 { + self.prev_modal_focus_count - 1 + } else { + modal_local - 1 + }; + self.focus_index = self.prev_modal_focus_start + next; + } else if self.prev_focus_count > 0 { + self.focus_index = if forward { + (self.focus_index + 1) % self.prev_focus_count + } else if self.focus_index == 0 { + self.prev_focus_count - 1 + } else { + self.focus_index - 1 + }; + } + } + + /// Move keyboard focus to the next focusable widget (wrapping), exactly as + /// pressing `Tab` would. Honors an active modal's focus trap. Pairs with + /// [`set_focus_index`](Self::set_focus_index) / [`focus_count`](Self::focus_count) + /// for programmatic focus control (e.g. an app-level shortcut). Available + /// since v0.21.1. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// if ui.key_pressed(slt::KeyCode::Char('j')) { + /// ui.focus_next(); + /// } + /// # }); + /// ``` + pub fn focus_next(&mut self) { + self.advance_focus(true); + } + + /// Move keyboard focus to the previous focusable widget (wrapping), exactly + /// as `Shift+Tab` would. Honors an active modal's focus trap. Available + /// since v0.21.1. + pub fn focus_prev(&mut self) { + self.advance_focus(false); + } + + /// Move focus to the next focusable widget belonging to the named focus + /// group, wrapping within the group. If focus is currently outside the + /// group it jumps to the group's first member. No-op if the group had no + /// focusable widgets on the previous frame. + /// + /// Focus groups are declared with [`group`](Self::group); this is the + /// scoped counterpart to [`focus_next`](Self::focus_next) for building a + /// focus trap around a panel or sub-form without a modal. Available since + /// v0.21.1. + pub fn focus_next_in_group(&mut self, group: &str) { + self.advance_focus_in_group(group, true); + } + + /// Move focus to the previous focusable widget in the named group + /// (wrapping). See [`focus_next_in_group`](Self::focus_next_in_group). + /// Available since v0.21.1. + pub fn focus_prev_in_group(&mut self, group: &str) { + self.advance_focus_in_group(group, false); + } + + fn advance_focus_in_group(&mut self, group: &str, forward: bool) { + // Membership comes from the previous frame's `index -> group` table, + // the same source `is_group_focused` consults. Indices are valid + // focus indices (0..prev_focus_count). + let members: Vec = self + .prev_focus_groups + .iter() + .enumerate() + .filter_map(|(idx, g)| match g.as_deref() { + Some(name) if name == group => Some(idx), + _ => None, + }) + .collect(); + if members.is_empty() { + return; + } + let new_pos = match members.iter().position(|&m| m == self.focus_index) { + Some(p) => { + if forward { + (p + 1) % members.len() + } else if p == 0 { + members.len() - 1 + } else { + p - 1 + } + } + // Focus is outside the group: jump to its first member. + None => 0, + }; + self.focus_index = members[new_pos]; + } + /// Read-only snapshot of the terminal's negotiated capabilities /// (issue #264). /// @@ -305,6 +448,12 @@ impl Context { } pub(crate) fn process_focus_keys(&mut self) { + // Scan for Tab / Shift+Tab / BackTab, recording the direction of each + // and consuming the event. The mutation (`advance_focus`) is applied + // after the scan: it borrows `&mut self` wholesale, which cannot run + // while `self.events` is iterated by reference. Collecting first + // preserves the original "each Tab advances once" semantics. + let mut actions: Vec = Vec::new(); for (i, event) in self.events.iter().enumerate() { if self.consumed[i] { continue; @@ -314,40 +463,19 @@ impl Context { continue; } if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) { - if self.prev_modal_active && self.prev_modal_focus_count > 0 { - let mut modal_local = - self.focus_index.saturating_sub(self.prev_modal_focus_start); - modal_local %= self.prev_modal_focus_count; - let next = (modal_local + 1) % self.prev_modal_focus_count; - self.focus_index = self.prev_modal_focus_start + next; - } else if self.prev_focus_count > 0 { - self.focus_index = (self.focus_index + 1) % self.prev_focus_count; - } + actions.push(true); self.consumed[i] = true; } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT)) || key.code == KeyCode::BackTab { - if self.prev_modal_active && self.prev_modal_focus_count > 0 { - let mut modal_local = - self.focus_index.saturating_sub(self.prev_modal_focus_start); - modal_local %= self.prev_modal_focus_count; - let prev = if modal_local == 0 { - self.prev_modal_focus_count - 1 - } else { - modal_local - 1 - }; - self.focus_index = self.prev_modal_focus_start + prev; - } else if self.prev_focus_count > 0 { - self.focus_index = if self.focus_index == 0 { - self.prev_focus_count - 1 - } else { - self.focus_index - 1 - }; - } + actions.push(false); self.consumed[i] = true; } } } + for forward in actions { + self.advance_focus(forward); + } } /// Render a custom [`Widget`]. @@ -552,25 +680,38 @@ impl Context { self.response_for(id) } - pub(crate) fn begin_widget_interaction(&mut self, focused: bool) -> (usize, Response) { - let interaction_id = self.next_interaction_id(); - let mut response = self.response_for(interaction_id); - response.focused = focused; - // Issue #208: compute focus transitions from the most recent - // `register_focusable` call. If that focusable lined up with the - // previously-focused widget index from the prior frame, focus - // changes since map directly to gained/lost. - if let Some(this_id) = self.rollback.last_focusable_id { + /// Compute and consume the `(gained_focus, lost_focus)` edge flags for the + /// widget most recently registered via [`register_focusable`]. + /// + /// If that focusable lined up with the previously-focused widget index from + /// the prior frame, the focus change since maps directly to gained/lost. + /// Takes (consumes) the `last_focusable_id` marker so a single + /// `register_focusable` powers exactly one transition computation. + /// + /// Shared by [`begin_widget_interaction`](Self::begin_widget_interaction) + /// and the widgets that assemble their `Response` by hand rather than + /// through it (`text_input`, `slider`, `number_input`) — issue #208 left + /// those three reporting `gained_focus`/`lost_focus` as always-false; this + /// closes that gap (v0.21.1). + pub(crate) fn focus_transitions(&mut self, focused: bool) -> (bool, bool) { + if let Some(this_id) = self.rollback.last_focusable_id.take() { let was_focused = self .prev_focus_index .map(|prev| prev == this_id) .unwrap_or(false); - response.gained_focus = focused && !was_focused; - response.lost_focus = !focused && was_focused; - // Consume the marker so a single `register_focusable` powers - // exactly one `begin_widget_interaction` call. - self.rollback.last_focusable_id = None; + (focused && !was_focused, !focused && was_focused) + } else { + (false, false) } + } + + pub(crate) fn begin_widget_interaction(&mut self, focused: bool) -> (usize, Response) { + let interaction_id = self.next_interaction_id(); + let mut response = self.response_for(interaction_id); + response.focused = focused; + let (gained, lost) = self.focus_transitions(focused); + response.gained_focus = gained; + response.lost_focus = lost; (interaction_id, response) } diff --git a/src/context/state.rs b/src/context/state.rs index c16410e..bec9cc3 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -290,6 +290,30 @@ pub struct Response { /// `false` on subsequent frames. Mutually exclusive with /// [`gained_focus`](Self::gained_focus). Available since v0.20.0. pub lost_focus: bool, + /// Whether the widget was double-clicked this frame. + /// + /// Detected when two `MouseButton::Left` `Down` events land on the same + /// terminal cell within the double-click window (~400ms). When `true`, + /// `clicked` is also `true` for the same frame (the second click is still a + /// click). This is the standard open/activate gesture for file pickers, + /// lists, tables, and trees. Suppressed for non-overlay widgets while a + /// modal is active, consistent with `clicked`. Available since v0.21.1. + pub double_clicked: bool, + /// Whether the widget submitted its value this frame. + /// + /// Set by widgets that have an explicit submit gesture — e.g. pressing + /// `Enter` in a focused single-line [`text_input`](Context::text_input). + /// Always `false` for widgets with no submit semantics. Available since + /// v0.21.1. + pub submitted: bool, + /// Net vertical scroll-wheel delta over this widget this frame. + /// + /// Positive = wheel scrolled up, negative = down, `0` when the wheel did + /// not move while the cursor was over the widget's `rect`. Hover-gated, so + /// each widget consumes only the wheel motion that occurred above it — a + /// chart, canvas, or custom viewport can scroll/zoom locally without a + /// frame-global scroll handler. Available since v0.21.1. + pub scroll_delta: i32, /// The rectangle the widget occupies after layout. pub rect: Rect, } @@ -363,4 +387,61 @@ impl Response { } self } + + /// Run `f` if the widget was clicked this frame, then return the Response + /// for further chaining. + /// + /// The closure receives the same `&mut Context` so it can issue UI commands + /// (e.g. queue a toast); ignore the argument with `|_|` if you only need to + /// mutate application state. + /// + /// ```ignore + /// ui.button("Save").on_click(ui, |_| save()); + /// ``` + pub fn on_click(self, ctx: &mut Context, f: impl FnOnce(&mut Context)) -> Self { + if self.clicked { + f(ctx); + } + self + } + + /// Run `f` if the widget's value changed this frame, then return the + /// Response for chaining. See [`on_click`](Self::on_click) for the closure + /// argument convention. Available since v0.21.1. + pub fn on_changed(self, ctx: &mut Context, f: impl FnOnce(&mut Context)) -> Self { + if self.changed { + f(ctx); + } + self + } + + /// Run `f` on the frame the widget *gained* keyboard focus, then return the + /// Response for chaining. Fires once per focus acquisition (mirrors + /// [`gained_focus`](Self::gained_focus)). Available since v0.21.1. + pub fn on_focus(self, ctx: &mut Context, f: impl FnOnce(&mut Context)) -> Self { + if self.gained_focus { + f(ctx); + } + self + } + + /// Run `f` if the widget submitted this frame (e.g. `Enter` in a focused + /// single-line text input), then return the Response for chaining. Mirrors + /// [`submitted`](Self::submitted). Available since v0.21.1. + pub fn on_submit(self, ctx: &mut Context, f: impl FnOnce(&mut Context)) -> Self { + if self.submitted { + f(ctx); + } + self + } + + /// Run `f` if the widget was double-clicked this frame, then return the + /// Response for chaining. Mirrors [`double_clicked`](Self::double_clicked). + /// Available since v0.21.1. + pub fn on_double_click(self, ctx: &mut Context, f: impl FnOnce(&mut Context)) -> Self { + if self.double_clicked { + f(ctx); + } + self + } } diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs index 3badbac..145db0f 100644 --- a/src/context/widgets_display/layout.rs +++ b/src/context/widgets_display/layout.rs @@ -1335,20 +1335,44 @@ impl Context { mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() }) .unwrap_or(false); + // v0.21.1: double-click hit-test mirrors the left-click logic. The + // second click of a double also reports `clicked`, so callers that + // only check `clicked` are unaffected. + let double_clicked = self + .double_click_pos + .map(|(mx, my)| { + mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() + }) + .unwrap_or(false); let hovered = self .mouse_pos .map(|(mx, my)| { mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() }) .unwrap_or(false); + // v0.21.1: per-widget wheel delta is hover-gated — only the widget + // under the cursor when the wheel moved sees a non-zero delta. + let scroll_delta = self + .scroll_pos + .map(|(mx, my)| { + if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() { + self.scroll_delta_frame + } else { + 0 + } + }) + .unwrap_or(0); Response { clicked, right_clicked, + double_clicked, hovered, changed: false, focused: false, gained_focus: false, lost_focus: false, + submitted: false, + scroll_delta, rect: *rect, } } else { diff --git a/src/context/widgets_input/feedback.rs b/src/context/widgets_input/feedback.rs index 472d1cc..0e8d5ca 100644 --- a/src/context/widgets_input/feedback.rs +++ b/src/context/widgets_input/feedback.rs @@ -124,6 +124,9 @@ impl Context { step: f64, ) -> Response { let focused = self.register_focusable(); + // v0.21.1: capture focus-edge flags (issue #208 gap — slider assembled + // its Response by hand and never set gained_focus/lost_focus). + let (gained_focus, lost_focus) = self.focus_transitions(focused); let mut changed = false; let start = *range.start(); @@ -209,6 +212,8 @@ impl Context { }); response.focused = focused; response.changed = changed; + response.gained_focus = gained_focus; + response.lost_focus = lost_focus; response } @@ -242,6 +247,9 @@ impl Context { /// ``` pub fn number_input(&mut self, state: &mut NumberInputState) -> Response { let focused = self.register_focusable(); + // v0.21.1: capture focus-edge flags (issue #208 gap — number_input + // assembled its Response by hand and never set gained/lost_focus). + let (gained_focus, lost_focus) = self.focus_transitions(focused); // Normalize the committed value before processing input so the // pre-frame baseline used for `changed` is itself in-range. @@ -387,6 +395,8 @@ impl Context { response.focused = focused; // `changed` is true iff the committed value actually moved this frame. response.changed = (state.value - old).abs() > f64::EPSILON; + response.gained_focus = gained_focus; + response.lost_focus = lost_focus; response } } diff --git a/src/context/widgets_input/text_input.rs b/src/context/widgets_input/text_input.rs index 42d24c5..f99f34a 100644 --- a/src/context/widgets_input/text_input.rs +++ b/src/context/widgets_input/text_input.rs @@ -32,6 +32,13 @@ impl Context { "text_input got a newline — use textarea instead", ); let focused = self.register_focusable(); + // v0.21.1: capture the focus-edge flags immediately — this consumes the + // `register_focusable` marker, so the result is correct regardless of + // the child containers rendered below. Issue #208 left text_input never + // populating gained_focus/lost_focus because it assembles its Response + // by hand instead of via `begin_widget_interaction`. + let (gained_focus, lost_focus) = self.focus_transitions(focused); + let mut submitted = false; let old_value = state.value.clone(); state.cursor = state.cursor.min(grapheme_count(&state.value)); @@ -151,6 +158,26 @@ impl Context { state.cursor = grapheme_count(&state.value); consumed_indices.push(i); } + KeyCode::Enter => { + // v0.21.1: Enter submits the input. If the suggestion + // dropdown is open, accept the highlighted suggestion + // instead (Tab also accepts) — only a bare Enter with + // no open suggestions reports `submitted`. + if suggestions_visible { + if let Some(selected) = matched_suggestions + .get(state.suggestion_index) + .or_else(|| matched_suggestions.first()) + { + state.value = selected.clone(); + state.cursor = grapheme_count(&state.value); + state.show_suggestions = false; + state.suggestion_index = 0; + } + } else { + submitted = true; + } + consumed_indices.push(i); + } _ => {} } } @@ -296,6 +323,9 @@ impl Context { }); response.focused = focused; response.changed = state.value != old_value; + response.gained_focus = gained_focus; + response.lost_focus = lost_focus; + response.submitted = submitted; let errors = state.errors(); if !errors.is_empty() { diff --git a/src/lib.rs b/src/lib.rs index 2084df4..7c24448 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -832,6 +832,10 @@ pub(crate) struct FocusState { pub pending_focus_name: Option, } +/// v0.21.1: maximum gap between two same-cell left clicks for them to count as +/// a double-click. Tuned to the common desktop default (~400ms). +pub(crate) const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400); + #[derive(Default)] pub(crate) struct LayoutFeedbackState { /// `(content_extent, viewport_extent, is_horizontal)` per scrollable last @@ -845,6 +849,14 @@ pub(crate) struct LayoutFeedbackState { pub prev_focus_rects: Vec<(usize, rect::Rect)>, pub prev_focus_groups: Vec>>, pub last_mouse_pos: Option<(u32, u32)>, + /// v0.21.1: wall-clock time of the previous left-click `Down`, used to + /// detect a double-click (a second click on the same cell within + /// `DOUBLE_CLICK_WINDOW`, ~400ms). `None` after a double-click fires (so a + /// triple click is not double-counted) or when no click has occurred. + pub last_click_at: Option, + /// v0.21.1: cell position of the previous left-click `Down`, paired with + /// `last_click_at` for same-cell double-click detection. + pub last_click_pos: Option<(u32, u32)>, } #[derive(Default)] diff --git a/tests/v020_hooks_focus.rs b/tests/v020_hooks_focus.rs index 351a641..d7fd434 100644 --- a/tests/v020_hooks_focus.rs +++ b/tests/v020_hooks_focus.rs @@ -466,11 +466,12 @@ fn response_none_has_all_signals_false() { #[test] fn gained_and_lost_focus_track_focus_transitions() { // Buttons go through `begin_widget_interaction`, so the new focus - // transition signals appear on their `Response`. Custom widgets that - // bypass `begin_widget_interaction` (e.g. text_input which assembles - // a Response from the container path) are intentionally out of scope - // for this test — those widgets will pick up the signals as they're - // migrated to the unified interaction path in follow-up issues. + // transition signals appear on their `Response`. The widgets that assemble + // a Response by hand (text_input/slider/number_input) bypassed that path + // and were out of scope here originally; v0.21.1 closed that gap via the + // shared `focus_transitions` helper — see the dedicated tests below + // (`text_input_reports_focus_transitions`, `slider_reports_focus_transitions`, + // `number_input_reports_focus_transitions`). let mut tb = TestBackend::new(40, 4); // Frame 0: two buttons. Default `focus_index` is 0, so #1 gains focus. @@ -543,3 +544,290 @@ fn gained_focus_and_lost_focus_are_mutually_exclusive() { ); }); } + +// =========================================================================== +// v0.21.1 — interaction-signal follow-ups +// focus-edge for hand-assembled widgets, Enter -> submitted, double-click, +// hover-gated scroll_delta, programmatic focus traversal, Response callbacks. +// =========================================================================== + +#[test] +fn text_input_reports_focus_transitions() { + let mut tb = TestBackend::new(40, 6); + let mut a = slt::widgets::TextInputState::default(); + let mut b = slt::widgets::TextInputState::default(); + + // Frame 0: two inputs; focus_index 0 -> A focused, first frame -> gained. + let ga = Cell::new(false); + let gar = &ga; + tb.render(|ui| { + let ra = ui.text_input(&mut a); + let _rb = ui.text_input(&mut b); + gar.set(ra.gained_focus); + assert!(ra.focused, "A is focused at index 0"); + }); + assert!(ga.get(), "text_input A gains focus on the first frame"); + + // Frame 1: stable focus -> no edge. + tb.render(|ui| { + let ra = ui.text_input(&mut a); + let _rb = ui.text_input(&mut b); + assert!( + !ra.gained_focus && !ra.lost_focus, + "stable focus produces no edge" + ); + }); + + // Frame 2: jump focus to B -> A loses, B gains. + let la = Cell::new(false); + let gb = Cell::new(false); + let lar = &la; + let gbr = &gb; + tb.render(|ui| { + ui.set_focus_index(1); + let ra = ui.text_input(&mut a); + let rb = ui.text_input(&mut b); + lar.set(ra.lost_focus); + gbr.set(rb.gained_focus); + }); + assert!(la.get(), "text_input A reports lost_focus when focus moves away"); + assert!(gb.get(), "text_input B reports gained_focus when it receives focus"); +} + +#[test] +fn slider_reports_focus_transitions() { + let mut tb = TestBackend::new(40, 4); + let mut v = 0.5f64; + let gained = Cell::new(false); + let gr = &gained; + tb.render(|ui| { + let r = ui.slider("vol", &mut v, 0.0..=1.0); + gr.set(r.gained_focus); + assert!(r.focused, "single slider is focused at index 0"); + }); + assert!(gained.get(), "slider gains focus on the first frame"); + // Stable frame: no fresh edge. + tb.render(|ui| { + let r = ui.slider("vol", &mut v, 0.0..=1.0); + assert!(!r.gained_focus && !r.lost_focus, "stable slider focus"); + }); +} + +#[test] +fn number_input_reports_focus_transitions() { + let mut tb = TestBackend::new(40, 4); + let mut st = slt::widgets::NumberInputState::new(5.0, 0.0, 10.0); + let gained = Cell::new(false); + let gr = &gained; + tb.render(|ui| { + let r = ui.number_input(&mut st); + gr.set(r.gained_focus); + assert!(r.focused, "single number_input is focused at index 0"); + }); + assert!(gained.get(), "number_input gains focus on the first frame"); + tb.render(|ui| { + let r = ui.number_input(&mut st); + assert!(!r.gained_focus && !r.lost_focus, "stable number_input focus"); + }); +} + +#[test] +fn text_input_enter_reports_submitted() { + let mut tb = TestBackend::new(40, 4); + let mut input = slt::widgets::TextInputState::default(); + + // No event -> not submitted. + tb.render(|ui| { + let r = ui.text_input(&mut input); + assert!(!r.submitted, "a quiet frame does not submit"); + }); + + // Focused input + Enter -> submitted. + let submitted = Cell::new(false); + let sr = &submitted; + let events = EventBuilder::new().key_code(KeyCode::Enter).build(); + tb.run_with_events(events, |ui| { + let r = ui.text_input(&mut input); + assert!(r.focused, "input is focused at index 0"); + sr.set(r.submitted); + }); + assert!( + submitted.get(), + "Enter in a focused single-line input reports submitted" + ); +} + +#[test] +fn double_click_detected_on_same_cell() { + let mut tb = TestBackend::new(20, 4); + + // Discover the button's rect so we click squarely inside it. + let rect = Cell::new(slt::Rect::default()); + let rr = ▭ + tb.render(|ui| { + rr.set(ui.button("ok").rect); + }); + let r = rect.get(); + let (cx, cy) = (r.x + r.width / 2, r.y + r.height / 2); + + // First click: a single click, not yet a double. + let d1 = Cell::new(true); + let d1r = &d1; + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + d1r.set(ui.button("ok").double_clicked); + }); + assert!(!d1.get(), "first click is a single click"); + + // Second click on the same cell (well within the window): double-click. + let d2 = Cell::new(false); + let d2r = &d2; + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + let resp = ui.button("ok"); + d2r.set(resp.double_clicked); + assert!(resp.clicked, "the second click still reports clicked"); + }); + assert!(d2.get(), "second same-cell click reports double_clicked"); +} + +#[test] +fn double_click_resets_after_firing() { + // A third rapid click on the same cell must NOT report a double (the pair + // resets after each double so triple-click is not counted as two doubles). + let mut tb = TestBackend::new(20, 4); + let rect = Cell::new(slt::Rect::default()); + let rr = ▭ + tb.render(|ui| { + rr.set(ui.button("ok").rect); + }); + let r = rect.get(); + let (cx, cy) = (r.x + r.width / 2, r.y + r.height / 2); + + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + let _ = ui.button("ok"); + }); + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + assert!(ui.button("ok").double_clicked, "second click is a double"); + }); + let third = Cell::new(true); + let tr = &third; + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + tr.set(ui.button("ok").double_clicked); + }); + assert!(!third.get(), "third click starts a fresh pair, not another double"); +} + +#[test] +fn scroll_delta_is_hover_gated() { + let mut tb = TestBackend::new(20, 4); + let rect = Cell::new(slt::Rect::default()); + let rr = ▭ + tb.render(|ui| { + rr.set(ui.button("ok").rect); + }); + let r = rect.get(); + let (cx, cy) = (r.x + r.width / 2, r.y + r.height / 2); + + // Wheel up over the widget -> +1. + let up = Cell::new(0i32); + let ur = &up; + tb.run_with_events(EventBuilder::new().scroll_up(cx, cy).build(), |ui| { + ur.set(ui.button("ok").scroll_delta); + }); + assert_eq!(up.get(), 1, "wheel-up over the widget yields +1"); + + // Wheel down off the widget -> 0 for the widget (hover-gated). + let off = Cell::new(99i32); + let ofr = &off; + let ox = (r.right() + 2).min(19); + let oy = (r.bottom() + 1).min(3); + tb.run_with_events(EventBuilder::new().scroll_down(ox, oy).build(), |ui| { + ofr.set(ui.button("ok").scroll_delta); + }); + assert_eq!(off.get(), 0, "wheel motion off the widget is not attributed to it"); +} + +#[test] +fn focus_next_and_prev_wrap() { + let mut tb = TestBackend::new(40, 8); + // Frame 0: register three focusables so prev_focus_count == 3 next frame. + tb.render(|ui| { + ui.button("a"); + ui.button("b"); + ui.button("c"); + }); + // Frame 1: drive programmatic traversal. + tb.render(|ui| { + assert_eq!(ui.focus_index(), 0); + ui.focus_next(); + assert_eq!(ui.focus_index(), 1); + ui.focus_next(); + ui.focus_next(); + assert_eq!(ui.focus_index(), 0, "focus_next wraps past the last widget"); + ui.focus_prev(); + assert_eq!(ui.focus_index(), 2, "focus_prev wraps backward"); + ui.button("a"); + ui.button("b"); + ui.button("c"); + }); +} + +#[test] +fn focus_next_in_group_stays_within_group() { + let mut tb = TestBackend::new(50, 10); + let render_groups = |ui: &mut slt::Context| { + ui.group("g1").col(|ui| { + ui.button("a"); + ui.button("b"); + }); + ui.group("g2").col(|ui| { + ui.button("c"); + ui.button("d"); + }); + }; + // Frame 0: establish the group membership table. + tb.render(render_groups); + // Frame 1: traverse within g1, then jump into g2. + tb.render(|ui| { + // Focus starts at index 0 (in g1). Next within g1 -> index 1. + ui.focus_next_in_group("g1"); + assert_eq!(ui.focus_index(), 1, "advances to the next g1 member"); + // Wraps within g1: 1 -> 0. + ui.focus_next_in_group("g1"); + assert_eq!(ui.focus_index(), 0, "wraps within the group"); + // Jumping to g2 from outside lands on its first member (index 2). + ui.focus_next_in_group("g2"); + assert_eq!(ui.focus_index(), 2, "jumps into g2's first member"); + render_groups(ui); + }); +} + +#[test] +fn response_callbacks_fire_on_their_signal() { + let mut tb = TestBackend::new(20, 4); + + // Discover the button rect, then click it and assert on_click fires. + let rect = Cell::new(slt::Rect::default()); + let rr = ▭ + tb.render(|ui| { + rr.set(ui.button("go").rect); + }); + let r = rect.get(); + let (cx, cy) = (r.x + r.width / 2, r.y + r.height / 2); + + let clicked = Rc::new(Cell::new(false)); + let clicked_in = Rc::clone(&clicked); + tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { + ui.button("go").on_click(ui, move |_| clicked_in.set(true)); + }); + assert!(clicked.get(), "on_click runs the closure when clicked"); + + // on_submit fires for a focused text_input receiving Enter. + let mut input = slt::widgets::TextInputState::default(); + let submitted = Rc::new(Cell::new(false)); + let submitted_in = Rc::clone(&submitted); + tb.run_with_events(EventBuilder::new().key_code(KeyCode::Enter).build(), |ui| { + ui.text_input(&mut input) + .on_submit(ui, move |_| submitted_in.set(true)); + }); + assert!(submitted.get(), "on_submit runs the closure on Enter"); +} From e18395b5810df8321d0104ce31558f9c9fe7bdca Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:32:24 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20integrate=20v0.21.1=20feature=20u?= =?UTF-8?q?nits=20=E2=80=94=20re-exports=20+=20must=5Fuse/doctest=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire crate-root re-exports for the 9 merged units (ColorParseError, ListResponse, SpinnerPreset, sprixel bench fns), fix the focus_next doc example to a real API, and add explicit let-_ discards for registration-only widget calls in tests. Full core gate green: fmt/check --all-features/ test --all-features (all pass)/clippy --all-features --all-targets -D warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/helpers.rs | 2 +- src/context/runtime.rs | 3 ++- src/lib.rs | 24 ++++++++++------- src/style.rs | 2 +- tests/v020_hooks_focus.rs | 55 +++++++++++++++++++++++++-------------- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/context/helpers.rs b/src/context/helpers.rs index 6fd597b..25c2d44 100644 --- a/src/context/helpers.rs +++ b/src/context/helpers.rs @@ -470,7 +470,7 @@ mod measure_tests { let mut backend = TestBackend::new(40, 10); backend.render(|ui| { - ui.group("panel").border(Border::Rounded).col(|ui| { + let _ = ui.group("panel").border(Border::Rounded).col(|ui| { ui.text("hi"); }); }); diff --git a/src/context/runtime.rs b/src/context/runtime.rs index 55b490f..f0ce38c 100644 --- a/src/context/runtime.rs +++ b/src/context/runtime.rs @@ -353,7 +353,8 @@ impl Context { /// /// ```no_run /// # slt::run(|ui: &mut slt::Context| { - /// if ui.key_pressed(slt::KeyCode::Char('j')) { + /// // Advance focus on a custom shortcut (e.g. a vim-style 'j'). + /// if ui.key('j') { /// ui.focus_next(); /// } /// # }); diff --git a/src/lib.rs b/src/lib.rs index 0764a37..e3fb0ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,9 +143,13 @@ pub use terminal::__bench_flush_buffer_diff_mut; #[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::__bench_flush_buffer_diff_mut_with_buf; +#[doc(hidden)] +pub use terminal::__bench_flush_kitty; #[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture}; +#[doc(hidden)] +pub use terminal::{__BenchSprixelFixture, __bench_flush_sprixels, __bench_new_sprixel_fixture}; /// Runtime terminal capability probe (issue #264): read-only [`Capabilities`] /// snapshot plus the [`Blitter`] ladder it drives. Diagnostics-only — image /// rendering routes through the ladder automatically. @@ -192,9 +196,9 @@ pub use rect::Rect; #[cfg(feature = "theme-watch")] pub use style::ThemeWatcher; pub use style::{ - Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle, - HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, SyntaxPalette, Theme, - ThemeBuilder, ThemeColor, UnderlineStyle, WidgetColors, WidgetTheme, WidthSpec, + Align, Border, BorderSides, Breakpoint, Color, ColorDepth, ColorParseError, Constraints, + ContainerStyle, HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, SyntaxPalette, + Theme, ThemeBuilder, ThemeColor, UnderlineStyle, WidgetColors, WidgetTheme, WidthSpec, }; #[cfg(feature = "serde")] pub use style::{ThemeFile, ThemeLoadError}; @@ -205,13 +209,13 @@ pub use widgets::{ AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalDate, CalendarSelect, CalendarState, ChordState, ColorPickerState, CommandPaletteState, ContextItem, DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, GaugeResponse, - GridColumn, GutterResponse, HighlightRange, ListState, ModeState, MultiSelectState, - NumberInputState, PaginatorState, PaginatorStyle, PaletteCommand, PickerMode, RadioState, - RichLogEntry, RichLogState, SchedulerState, ScreenState, ScrollState, SelectState, - SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput, StreamingMarkdownState, - StreamingTextState, TableColumn, TableState, TabsState, TextInputState, TextareaState, - ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend, - ValidateTrigger, Validator, DEFAULT_CHORD_TIMEOUT_TICKS, + GridColumn, GutterResponse, HighlightRange, ListResponse, ListState, ModeState, + MultiSelectState, NumberInputState, PaginatorState, PaginatorStyle, PaletteCommand, PickerMode, + RadioState, RichLogEntry, RichLogState, SchedulerState, ScreenState, ScrollState, SelectState, + SpinnerPreset, SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput, + StreamingMarkdownState, StreamingTextState, TableColumn, TableState, TabsState, TextInputState, + TextareaState, ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, + Trend, ValidateTrigger, Validator, DEFAULT_CHORD_TIMEOUT_TICKS, }; /// Rendering backend for SLT. diff --git a/src/style.rs b/src/style.rs index f300797..4cd5d21 100644 --- a/src/style.rs +++ b/src/style.rs @@ -7,7 +7,7 @@ mod color; mod theme; #[cfg(feature = "serde")] mod theme_io; -pub use color::{Color, ColorDepth}; +pub use color::{Color, ColorDepth, ColorParseError}; pub use theme::{Spacing, SyntaxPalette, Theme, ThemeBuilder, ThemeColor}; #[cfg(feature = "theme-watch")] pub use theme_io::ThemeWatcher; diff --git a/tests/v020_hooks_focus.rs b/tests/v020_hooks_focus.rs index d7fd434..6c49145 100644 --- a/tests/v020_hooks_focus.rs +++ b/tests/v020_hooks_focus.rs @@ -590,8 +590,14 @@ fn text_input_reports_focus_transitions() { lar.set(ra.lost_focus); gbr.set(rb.gained_focus); }); - assert!(la.get(), "text_input A reports lost_focus when focus moves away"); - assert!(gb.get(), "text_input B reports gained_focus when it receives focus"); + assert!( + la.get(), + "text_input A reports lost_focus when focus moves away" + ); + assert!( + gb.get(), + "text_input B reports gained_focus when it receives focus" + ); } #[test] @@ -627,7 +633,10 @@ fn number_input_reports_focus_transitions() { assert!(gained.get(), "number_input gains focus on the first frame"); tb.render(|ui| { let r = ui.number_input(&mut st); - assert!(!r.gained_focus && !r.lost_focus, "stable number_input focus"); + assert!( + !r.gained_focus && !r.lost_focus, + "stable number_input focus" + ); }); } @@ -713,7 +722,10 @@ fn double_click_resets_after_firing() { tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { tr.set(ui.button("ok").double_clicked); }); - assert!(!third.get(), "third click starts a fresh pair, not another double"); + assert!( + !third.get(), + "third click starts a fresh pair, not another double" + ); } #[test] @@ -743,7 +755,11 @@ fn scroll_delta_is_hover_gated() { tb.run_with_events(EventBuilder::new().scroll_down(ox, oy).build(), |ui| { ofr.set(ui.button("ok").scroll_delta); }); - assert_eq!(off.get(), 0, "wheel motion off the widget is not attributed to it"); + assert_eq!( + off.get(), + 0, + "wheel motion off the widget is not attributed to it" + ); } #[test] @@ -751,9 +767,9 @@ fn focus_next_and_prev_wrap() { let mut tb = TestBackend::new(40, 8); // Frame 0: register three focusables so prev_focus_count == 3 next frame. tb.render(|ui| { - ui.button("a"); - ui.button("b"); - ui.button("c"); + let _ = ui.button("a"); + let _ = ui.button("b"); + let _ = ui.button("c"); }); // Frame 1: drive programmatic traversal. tb.render(|ui| { @@ -765,9 +781,9 @@ fn focus_next_and_prev_wrap() { assert_eq!(ui.focus_index(), 0, "focus_next wraps past the last widget"); ui.focus_prev(); assert_eq!(ui.focus_index(), 2, "focus_prev wraps backward"); - ui.button("a"); - ui.button("b"); - ui.button("c"); + let _ = ui.button("a"); + let _ = ui.button("b"); + let _ = ui.button("c"); }); } @@ -775,13 +791,13 @@ fn focus_next_and_prev_wrap() { fn focus_next_in_group_stays_within_group() { let mut tb = TestBackend::new(50, 10); let render_groups = |ui: &mut slt::Context| { - ui.group("g1").col(|ui| { - ui.button("a"); - ui.button("b"); + let _ = ui.group("g1").col(|ui| { + let _ = ui.button("a"); + let _ = ui.button("b"); }); - ui.group("g2").col(|ui| { - ui.button("c"); - ui.button("d"); + let _ = ui.group("g2").col(|ui| { + let _ = ui.button("c"); + let _ = ui.button("d"); }); }; // Frame 0: establish the group membership table. @@ -817,7 +833,7 @@ fn response_callbacks_fire_on_their_signal() { let clicked = Rc::new(Cell::new(false)); let clicked_in = Rc::clone(&clicked); tb.run_with_events(EventBuilder::new().click(cx, cy).build(), |ui| { - ui.button("go").on_click(ui, move |_| clicked_in.set(true)); + let _ = ui.button("go").on_click(ui, move |_| clicked_in.set(true)); }); assert!(clicked.get(), "on_click runs the closure when clicked"); @@ -826,7 +842,8 @@ fn response_callbacks_fire_on_their_signal() { let submitted = Rc::new(Cell::new(false)); let submitted_in = Rc::clone(&submitted); tb.run_with_events(EventBuilder::new().key_code(KeyCode::Enter).build(), |ui| { - ui.text_input(&mut input) + let _ = ui + .text_input(&mut input) .on_submit(ui, move |_| submitted_in.set(true)); }); assert!(submitted.get(), "on_submit runs the closure on Enter"); From 40fb9b173db457e9a8cffc0d8360bf50aef02dd3 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:38:45 +0900 Subject: [PATCH 12/17] docs: complete #244 rustdoc audit (examples, panics, see-also) Add the missing rustdoc the audit flagged: - # Example blocks on definition_list and divider_text. - # Panics sections (with the actual emitted message) on the hooks that can panic on a rules-of-hooks / type-mismatch violation: use_memo, use_memo_ref, use_effect, animate_value. - # Family cross-references on gauge / line_gauge linking the gauge family. - # See also on animate_bool / animate_value. Doc-only; no code or signatures changed. All added doctests compile. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/context/runtime.rs | 58 +++++++++++++++++++++++++++ src/context/widgets_display/gauge.rs | 20 +++++++++ src/context/widgets_display/status.rs | 25 ++++++++++++ 3 files changed, 103 insertions(+) diff --git a/src/context/runtime.rs b/src/context/runtime.rs index b8166f2..88ab99d 100644 --- a/src/context/runtime.rs +++ b/src/context/runtime.rs @@ -894,6 +894,12 @@ impl Context { /// let opacity = ui.animate_bool("sidebar::visible", is_open); /// // 0.0 ≤ opacity ≤ 1.0; use as alpha or visibility threshold. /// ``` + /// + /// # See also + /// + /// - [`animate_value`](Self::animate_value) — the underlying primitive this + /// delegates to; use it for a custom duration or a non-binary target. + /// - [`Tween`](crate::Tween) — full control over easing and lifecycle. pub fn animate_bool(&mut self, id: &'static str, value: bool) -> f64 { let target = if value { 1.0 } else { 0.0 }; self.animate_value(id, target, crate::anim::DEFAULT_ANIMATE_TICKS) @@ -910,6 +916,19 @@ impl Context { /// /// `duration_ticks == 0` snaps immediately to the new target. /// + /// # Panics + /// + /// Panics if `id` is already bound in the named-state map to a value of a + /// different type (e.g. a [`use_state_named`](Self::use_state_named) call + /// reused the same id), since the stored entry then fails to downcast to + /// the internal animation state: + /// + /// ```text + /// animate_value: id {id} is already used for a different state type + /// ``` + /// + /// Pick a unique id per call site to avoid the collision. + /// /// # Example /// ```ignore /// let bar_height = ui.animate_value("loading::bar", target_height, 30); @@ -921,6 +940,12 @@ impl Context { /// is acceptable. For custom easing, a non-static key, or /// non-tick-based control, construct a [`crate::Tween`] explicitly via /// [`Context::use_state_named`](Self::use_state_named). + /// + /// # See also + /// + /// - [`animate_bool`](Self::animate_bool) — boolean-driven shorthand that + /// tweens between `0.0` and `1.0`. + /// - [`Tween`](crate::Tween) — explicit easing and lifecycle control. pub fn animate_value(&mut self, id: &'static str, target: f64, duration_ticks: u64) -> f64 { let tick = self.tick; let entry = self @@ -1383,6 +1408,19 @@ impl Context { /// /// [`use_state`]: Self::use_state /// + /// # Panics + /// + /// Panics if the hook slot at this call position was previously used for a + /// different hook (a rules-of-hooks / call-order violation), since the + /// type-erased slot then fails to downcast to `MemoSlot`: + /// + /// ```text + /// Hook type mismatch at index {idx}: expected {type}. Hooks must be called in the same order every frame. + /// ``` + /// + /// Keep hook calls in the same order every frame — do not call this inside + /// an `if`/`else` whose branch changes between frames. + /// /// # Example /// ```no_run /// # slt::run(|ui: &mut slt::Context| { @@ -1450,6 +1488,16 @@ impl Context { /// Migrate `let x = *ui.use_memo_ref(&d, f);` to /// `let x = ui.use_memo(&d, f).copied(ui);` (or `.get(ui)` for a reference). /// + /// # Panics + /// + /// Panics if the hook slot at this call position was previously used for a + /// different hook (a rules-of-hooks / call-order violation), since the + /// type-erased slot then fails to downcast to `(D, T)`: + /// + /// ```text + /// Hook type mismatch at index {idx}: expected {type}. Hooks must be called in the same order every frame. + /// ``` + /// /// # Example /// ```no_run /// # slt::run(|ui: &mut slt::Context| { @@ -1660,6 +1708,16 @@ impl Context { /// payments) put the effect outside the boundary or guard with an /// idempotency key. /// + /// # Panics + /// + /// Panics if the hook slot at this call position was previously used for a + /// different hook (a rules-of-hooks / call-order violation), since the + /// type-erased slot then fails to downcast to the deps type `D`: + /// + /// ```text + /// Hook type mismatch at index {idx}: expected {type}. Hooks must be called in the same order every frame. + /// ``` + /// /// # Common patterns /// /// ```ignore diff --git a/src/context/widgets_display/gauge.rs b/src/context/widgets_display/gauge.rs index 933d693..cd1a5ff 100644 --- a/src/context/widgets_display/gauge.rs +++ b/src/context/widgets_display/gauge.rs @@ -34,6 +34,16 @@ impl Context { /// if r.hovered { /* attach tooltip */ } /// # }); /// ``` + /// + /// # Family + /// + /// The gauge family covers ratio-based progress indicators: + /// + /// - [`gauge`](Self::gauge) — block-fill bar with a centered label (this method). + /// - [`line_gauge`](Self::line_gauge) — single-line bar with a trailing label + /// and configurable fill/empty chars. + /// - [`progress_bar`](Self::progress_bar) / [`progress`](Self::progress) — + /// unlabeled progress bars. pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> { Gauge::new(self, ratio) } @@ -52,6 +62,16 @@ impl Context { /// ui.line_gauge(0.78).label("Memory").width(48).filled('━'); /// # }); /// ``` + /// + /// # Family + /// + /// The gauge family covers ratio-based progress indicators: + /// + /// - [`line_gauge`](Self::line_gauge) — single-line bar with a trailing + /// label (this method). + /// - [`gauge`](Self::gauge) — block-fill bar with a centered label. + /// - [`progress_bar`](Self::progress_bar) / [`progress`](Self::progress) — + /// unlabeled progress bars. pub fn line_gauge(&mut self, ratio: f64) -> LineGauge<'_> { LineGauge::new(self, ratio) } diff --git a/src/context/widgets_display/status.rs b/src/context/widgets_display/status.rs index 80227be..dd07d76 100644 --- a/src/context/widgets_display/status.rs +++ b/src/context/widgets_display/status.rs @@ -259,6 +259,20 @@ impl Context { } /// Render a key-value definition list with aligned columns. + /// + /// Keys are right-padded to the widest key so the value column lines up. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// ui.definition_list(&[ + /// ("Name", "SuperLightTUI"), + /// ("Version", "0.21.1"), + /// ("License", "MIT"), + /// ]); + /// # }); + /// ``` pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response { let max_key_width = items .iter() @@ -285,6 +299,17 @@ impl Context { } /// Render a horizontal divider with a centered text label. + /// + /// The label is padded with one space on each side and centered between + /// two `─` separator runs spanning the available width. + /// + /// # Example + /// + /// ```no_run + /// # slt::run(|ui: &mut slt::Context| { + /// ui.divider_text("Settings"); + /// # }); + /// ``` pub fn divider_text(&mut self, label: &str) -> Response { let w = self.width(); let label_len = UnicodeWidthStr::width(label) as u32; From 66957448c7ed769657e932a4aee6e5f707cdab3d Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:39:04 +0900 Subject: [PATCH 13/17] bench: add kitty image-layer flush bench arm Wire a timed criterion arm for the existing __BenchKittyFixture + __bench_new_kitty_fixture hooks (issue #206 alloc suite), which were re-exported at the crate root but had no bench arm. The new flush_kitty group sweeps 1/8/32 placements, flushing each fixture into a reused hermetic Vec sink in the same shape as the flush group. The sprixel/__bench_flush_kitty hooks named in the unit brief (__bench_flush_kitty, __bench_new_sprixel_fixture, __BenchSprixelFixture, __bench_flush_sprixels) are not present in this fork yet, so those arms are deferred to the integration step. See integration notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- benches/benchmarks.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index 87adf80..e3de200 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -731,6 +731,36 @@ fn bench_streaming_append_chat(c: &mut Criterion) { group.finish(); } +/// Kitty image-layer flush (issue #206 alloc suite, now timed). Builds an +/// `__BenchKittyFixture` of `n` distinct 8×8 RGBA placements and measures the +/// inline-mode flush emit cost against a hermetic `Vec` sink, mirroring the +/// `flush` group's reused-sink pattern. The first flush in the timed loop +/// transmits + places every image; the `KittyImageManager`'s internal +/// `prev_placements` then make subsequent identical flushes near-no-ops, so the +/// steady state measures the manager's per-frame placement-diff scan rather than +/// repeated retransmission — the same damage-skip shape as the sprixel scan. +#[cfg(feature = "crossterm")] +fn bench_flush_kitty_images(c: &mut Criterion) { + let mut group = c.benchmark_group("flush_kitty"); + for n in [1_usize, 8, 32] { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + let mut fixture = slt::__bench_new_kitty_fixture(n); + debug_assert_eq!(fixture.len(), n); + debug_assert_eq!(fixture.is_empty(), n == 0); + + let mut sink: Vec = Vec::with_capacity(64 * 1024); + b.iter(|| { + sink.clear(); + fixture + .flush_inline(&mut sink, black_box(0)) + .expect("kitty flush into Vec cannot fail"); + black_box(sink.len()); + }); + }); + } + group.finish(); +} + /// Register flush-path benches (only when `crossterm` feature is enabled, /// which is the default for benches). When the feature is off, this is a /// no-op so the file still compiles under `--no-default-features`. @@ -742,6 +772,7 @@ fn bench_flush_group(c: &mut Criterion) { bench_flush_static_200x60(c); bench_flush_full_redraw_300x100(c); bench_flush_sparse_change_300x100(c); + bench_flush_kitty_images(c); } let _ = c; } From 1495037b698c68d66cde186c55283c98295e49a5 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:39:46 +0900 Subject: [PATCH 14/17] =?UTF-8?q?chore:=20v0.21.1=20=E2=80=94=20bump=20ver?= =?UTF-8?q?sion,=20crate-hygiene=20exclude,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to 0.21.1 (root + slt-wasm + dep). Expand the published-crate exclude so agent/skill scaffolding, VHS recordings, and dev/CI config (AGENTS.md, .agents/, .claude/, *.tape, deny.toml, _typos.toml, .gitignore) no longer ship in the tarball (~140KB leaked into 0.21.0). Remove the orphaned committed.toml (its CI gate was dropped in 0.21.0) and its stale CI-reference row. Add the 0.21.1 changelog section. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 - CHANGELOG.md | 92 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 ++++++++- committed.toml | 2 - crates/slt-wasm/Cargo.toml | 4 +- 5 files changed, 113 insertions(+), 7 deletions(-) delete mode 100644 committed.toml diff --git a/AGENTS.md b/AGENTS.md index 04056d5..a20e686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,6 @@ cargo deny check | Deny Check | `cargo deny check {advisories,bans,licenses,sources}` | Yes (advisories soft) | | Doc Coverage | `RUSTFLAGS="-Wmissing_docs" cargo check` | No (soft) | | Semver Check | `cargo-semver-checks` | No (soft) | -| Commit Style | `committed --no-merge-commit` | No (PR only, soft) | ### Pre-PR Additional Gate Before creating a PR, wait for CI to pass on the pushed branch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1806954..5f91b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [0.21.1] - 2026-05-30 + +Interaction-signal completeness, API ergonomics, WASM parity, perf, and +published-crate hygiene. This release closes the post-0.21.0 competitive-gap +audit: every confirmed additive (non-breaking) gap is implemented. Headline — +the widgets that assembled their `Response` by hand (`text_input` / `slider` / +`number_input`) now report focus edges; `Response` gains `submitted` / +`double_clicked` / `scroll_delta` plus chainable callbacks; ergonomic +`Color` / gradient / `KeyMap` / focus-traversal / measurement APIs; WASM DOM +diffing and input-event parity; sprixel / resize / sync-output perf; and the +published crate no longer ships agent/CI scaffolding. + +### Added + +- **Response interaction signals** — `Response::submitted` (Enter in a focused + single-line `text_input`), `Response::double_clicked` (two same-cell left + clicks within ~400ms; the second still reports `clicked`), and + `Response::scroll_delta: i32` (hover-gated net wheel delta, so a chart/canvas + can scroll or zoom locally). Chainable callbacks `on_click` / `on_changed` / + `on_focus` / `on_submit` / `on_double_click`, each taking `&mut Context`. +- **Focus edges for hand-assembled widgets** — `text_input`, `slider`, and + `number_input` now populate `gained_focus` / `lost_focus` via a shared + `focus_transitions` helper (closes the #208 follow-up that left those three + always-false). +- **Programmatic focus traversal** — `Context::focus_next` / `focus_prev` + (modal-trap aware, equivalent to Tab / Shift+Tab) and group-scoped + `focus_next_in_group` / `focus_prev_in_group` for a panel-local focus trap + without a modal. +- **Ergonomic `Color`** — `From<(u8,u8,u8)>`, `From<[u8;3]>`, `From` + (`0xRRGGBB`), and `FromStr` (`#RRGGBB` / `RRGGBB` / `#RGB` / named, with the + new `ColorParseError`); plus `Color::from_hsl`, `from_hsv`, and `rotate_hue`. +- **Text gradients** — `gradient_stops(&[(f32, Color)])` (multi-stop horizontal, + auto-sorted, empty = no-op, single = solid) and the background variants + `bg_gradient` / `bg_gradient_stops`. +- **KeyMap dispatch** (bubbletea `key.Matches` parity) — `Binding::matches`, + `KeyMap::matched`, and `Context::keymap_match` for declarative key routing. +- **Spinner presets** — `SpinnerPreset` enum + `SpinnerState::moon` / `bounce` / + `circle` / `points` / `arc` / `toggle` / `arrow` / `preset` / `frame_count` + (cli-spinners / ratatui-throbber parity); `dots()` / `line()` unchanged. +- **List reorder** — `ListState::move_item(from, to)` and the opt-in + `Context::list_reorderable` / `list_reorderable_colored`, returning + `ListResponse` (Deref to `Response`, with `.reordered: Option<(usize, usize)>`); + Shift+Up/Down or Alt+Up/Down moves the selected item. Plain `list` unchanged. +- **Intrinsic measurement** — `Context::measure_text(text, Option) + -> (width, rows)` using the layout engine's own wrap kernel, and + `Context::measured_rect(name) -> Option` (the rect a named `group` + occupied on the previous frame). +- **WASM input parity** — `DomBackend::resize` plus mouse-wheel, window-resize, + focus/blur, and clipboard-paste event wiring (Phase 1-2; native parity). +- **TestBackend query/assertions** — `find_text`, `region` / `assert_region`, + `assert_styled_contains`, and `snapshot` / `assert_snapshot_eq` + (unified-diff panic on mismatch). +- **Examples** — a v0.21.1 API tour plus coverage for previously-undemoed + v0.21.0 widgets (paginator, number_input, variable-height virtual_list, + scheduler, devtools inspector). + +### Changed + +- Synchronized-output (BSU/ESU) emission is now gated on a DECRQM `?2026` + capability probe; silent/headless terminals keep emitting exactly as before + (only a confirmed-unsupported terminal suppresses the guard). + `Capabilities::sync_output` is now populated. + +### Perf + +- **WASM DOM flush** diffs against a previous-frame buffer and mutates only the + cells that changed (steady-state DOM writes drop toward zero), mirroring the + native ANSI diff. +- **Sprixel re-blit scan** — replaced the per-frame O(n·m) placement lookup with + a hashed-key set plus a per-row clean/hash shortcut that skips untouched + footprint rows. +- **Resize coalescing** — a burst of resize events within one poll batch now + fires the `Clear(All)` + double realloc + `size()` syscall once at + end-of-batch using the final size (SIGCONT/resume redraw path unchanged). +- New criterion benches for the kitty image-flush and sprixel re-blit paths. + +### Docs + +- `read_clipboard` documents the stdin typeahead-swallow concurrency hazard and + recommended usage. +- Closed the remaining #244 rustdoc gaps (`definition_list` / `divider_text` + examples, hook `# Panics` sections, Gauge / LineGauge family cross-references). + +### Housekeeping + +- The published crate no longer ships agent/skill scaffolding, VHS recordings, + or dev/CI config (`AGENTS.md`, `.agents/`, `.claude/`, `*.tape`, `deny.toml`, + `_typos.toml`, `.gitignore` — ~140KB of non-library content that leaked into + the 0.21.0 tarball). +- Removed the orphaned `committed.toml` (its CI gate was dropped in 0.21.0) and + the stale Commit-Style row from the CI reference table. + ## [0.21.0] - 2026-05-29 This release closes the competitive-analysis gap audit versus ratatui / bubbletea / ink — 35 issues spanning new widgets, layout primitives, terminal protocols, async/scheduling, correctness fixes, perf, and a breaking hook/API cleanup. Highlights: flex-wrap + flex-basis, a runtime capability probe with an automatic image-blitter ladder, an iTerm2 OSC 1337 image path, a color picker, paginator, number stepper, variable-height virtual list, external TOML themes with hot-reload, a frame-clock scheduler, in-frame async `spawn`/`poll`, RTL/bidi text reordering, grapheme-cluster-correct wrapping, SIGTSTP/SIGCONT job control, and a devtools inspector. diff --git a/Cargo.toml b/Cargo.toml index 4c58f20..ce4f2cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "crates/slt-wasm"] [package] name = "superlighttui" -version = "0.21.0" +version = "0.21.1" edition = "2021" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" @@ -14,7 +14,24 @@ readme = "README.md" keywords = ["tui", "terminal", "cli", "ui", "immediate-mode"] categories = ["command-line-interface"] rust-version = "1.81" -exclude = ["examples/", ".github/", "assets/", "AUDIT-REPORT.md", "CLAUDE.md"] +# Keep the published crate to library + docs only. The agent/skill scaffolding, +# VHS recordings, and dev/CI config are not needed by downstream consumers +# (they were leaking into 0.21.0's tarball — ~140KB of non-library content). +exclude = [ + "examples/", + ".github/", + "assets/", + "AUDIT-REPORT.md", + "CLAUDE.md", + "AGENTS.md", + ".agents/", + ".claude/", + "scripts/", + "deny.toml", + "_typos.toml", + "*.tape", + ".gitignore", +] # Disable cargo's `examples/*.rs` auto-discovery: only the binaries listed # below as `[[example]]` are exposed via `cargo run --example`. Source # demos that compose into a tour (v020_*, cookbook_*, most demo_*, diff --git a/committed.toml b/committed.toml deleted file mode 100644 index ce8dad8..0000000 --- a/committed.toml +++ /dev/null @@ -1,2 +0,0 @@ -style = "conventional" -merge_commit = false diff --git a/crates/slt-wasm/Cargo.toml b/crates/slt-wasm/Cargo.toml index 49d0554..a1a91e4 100644 --- a/crates/slt-wasm/Cargo.toml +++ b/crates/slt-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slt-wasm" -version = "0.21.0" +version = "0.21.1" edition = "2021" description = "WASM/browser backend for SuperLightTUI" license = "MIT" @@ -9,7 +9,7 @@ homepage = "https://github.com/subinium/SuperLightTUI" documentation = "https://docs.rs/slt-wasm" [dependencies] -superlighttui = { version = "0.21.0", path = "../..", default-features = false } +superlighttui = { version = "0.21.1", path = "../..", default-features = false } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ "CssStyleDeclaration", From 61b55943b51d303eb6d00c7d44f94155a3c140c4 Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:41:11 +0900 Subject: [PATCH 15/17] docs(examples): add v0.21.0 widgets coverage example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add examples/v0210_widgets.rs covering the v0.21.0 widgets that previously shipped without a runnable example: the standalone paginator (PaginatorState / PaginatorStyle), the numeric stepper (NumberInputState / number_input), the variable-height virtual_list (virtual_list_variable + ListState item heights), the async-free frame-clock scheduler (schedule / every / debounce, #248), and the devtools inspector panel (set_inspector / Ctrl+F12, #268). Self-contained Standard-archetype screen (no overlay / no scrollback), builds on the default feature set since the scheduler is wall-clock based. In-frame async (spawn/poll) is noted as out of scope to keep the binary async-free. Requires an explicit [[example]] entry in Cargo.toml (autoexamples = false); the integrator must add it — see integration_notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/v0210_widgets.rs | 357 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 examples/v0210_widgets.rs diff --git a/examples/v0210_widgets.rs b/examples/v0210_widgets.rs new file mode 100644 index 0000000..9d6db5b --- /dev/null +++ b/examples/v0210_widgets.rs @@ -0,0 +1,357 @@ +//! v0.21.0 Widgets — coverage for the v0.21.0 additions that previously +//! shipped without a runnable example: the standalone `paginator`, the +//! numeric stepper (`number_input`), the variable-height `virtual_list`, +//! the async-free frame-clock scheduler (`schedule` / `every` / `debounce`), +//! and the devtools inspector panel (Ctrl+F12 / `set_inspector`). +//! +//! Run: `cargo run --example v0210_widgets` +//! +//! Keys: +//! Tab / Shift-Tab — cycle focus across the widgets +//! Left / Right — paginator: previous / next page (when focused) +//! Up / Down — number_input: step value · virtual_list: move selection +//! Enter — number_input: commit a typed value +//! i — toggle the devtools inspector panel (also Ctrl+F12) +//! q / Esc / Ctrl-Q — quit +//! +//! Every widget here is **async-free** — the scheduler (#248) is wall-clock +//! based and works on the default feature set, so this whole example builds +//! and runs without the `async` feature. See the note at the bottom for how +//! in-frame async (`Context::spawn`/`poll`) would be demoed instead. + +use std::time::Duration; + +use slt::widgets::{ListState, NumberInputState, PaginatorState, PaginatorStyle, TextInputState}; +use slt::{Border, Color, Context, KeyCode, KeyModifiers, RunConfig}; + +/// One catalog row paged through by the paginator. Kept tiny so the whole +/// example stays a single self-contained screen. +const CATALOG: &[(&str, &str)] = &[ + ("SLT-001", "Rounded border kit"), + ("SLT-002", "Flexbox row/col layout"), + ("SLT-003", "Tabs + scrollable shell"), + ("SLT-004", "Theme presets (10)"), + ("SLT-005", "Sparkline + heatmap"), + ("SLT-006", "Candlestick chart"), + ("SLT-007", "Command palette"), + ("SLT-008", "Virtual list (fixed)"), + ("SLT-009", "Virtual list (variable)"), + ("SLT-010", "Standalone paginator"), + ("SLT-011", "Numeric stepper"), + ("SLT-012", "Frame-clock scheduler"), + ("SLT-013", "Devtools inspector"), + ("SLT-014", "Tree + directory tree"), + ("SLT-015", "Sixel / halfblock image"), +]; + +/// Variable-height feed rows: a short reply next to a tall code block, the +/// canonical `virtual_list_variable` use case. +const FEED: &[(&str, u32)] = &[ + ("ok 👍", 1), + ( + "here is the patch:\n fn render(ui) {\n ui.text(\"hi\");\n }", + 4, + ), + ("thanks!", 1), + ("one-line note", 1), + ("stack trace:\n at frame()\n at run()\n at main()", 4), + ("done", 1), + ("a slightly longer\nwrapped reply", 2), + ("👌", 1), + ("final summary line\nspanning two rows", 2), + ("end", 1), +]; + +/// Persistent state for the whole screen. Held across frames by `run_with`'s +/// `move` closure so cursors, pages, and the debounce signal settle correctly. +/// `pub` so a tour binary can embed this demo via `#[path = ...] mod` and call +/// [`render`] directly, matching the other `examples/*` demos. +pub struct DemoState { + /// Standalone paginator over `CATALOG` rows. + paginator: PaginatorState, + /// Integer quantity stepper, clamped to `[0, 99]`. + qty: NumberInputState, + /// Float price stepper with a 0.25 step. + price: NumberInputState, + /// Variable-height feed list (chat bubbles of differing heights). + feed: ListState, + /// Search box whose keystrokes drive the `debounce` timer. + search: TextInputState, + /// Last query the debounce timer let through, for display. + settled_query: String, + /// `every`-driven second counter, proving recurring ticks accumulate. + seconds: u64, + /// `schedule`-driven one-shot banner flag (fires ~1.5s after launch). + splash_dismissed: bool, + /// Mirror of `Context::inspector()`, toggled by the local `i` key. + inspector_on: bool, +} + +impl Default for DemoState { + fn default() -> Self { + let mut paginator = PaginatorState::new(CATALOG.len(), 4); + paginator.style = PaginatorStyle::Arabic; + let heights: Vec = FEED.iter().map(|&(_, h)| h).collect(); + Self { + paginator, + qty: NumberInputState::integer(3, 0, 99).step(1.0), + price: NumberInputState::new(9.5, 0.0, 100.0).step(0.25), + feed: ListState::new(FEED.iter().map(|&(t, _)| t).collect::>()) + .with_item_heights(heights), + search: TextInputState::with_placeholder("type to search (debounced)..."), + settled_query: String::new(), + seconds: 0, + splash_dismissed: false, + inspector_on: false, + } + } +} + +fn main() -> std::io::Result<()> { + let mut state = DemoState::default(); + slt::run_with( + RunConfig::default() + .mouse(true) + .tick_rate(Duration::from_millis(100)), + move |ui: &mut Context| { + // Ctrl-Q always quits, even with a focused text_input. Plain 'q' + // and Esc are checked at the end so the search box can consume them. + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + // ── Frame-clock scheduler (#248), all async-free ─────────────── + // One-shot: dismiss the launch banner ~1.5s after the first frame. + if ui.schedule("v0210::splash", Duration::from_millis(1500)) { + state.splash_dismissed = true; + } + // Recurring: advance a once-per-second counter; `ticks` is the + // number of whole intervals elapsed since last frame (usually 1, + // > 1 only if the loop stalled), so the count never drifts. + let ticks = ui.every("v0210::second", Duration::from_secs(1)); + state.seconds = state.seconds.saturating_add(ticks as u64); + + render(ui, &mut state); + + if ui.key('q') || ui.key_code(KeyCode::Esc) { + ui.quit(); + } + }, + ) +} + +/// Draw the whole screen. Pure render path so the example could also be +/// embedded into a tour via `#[path = ...] mod` like the other demos. +pub fn render(ui: &mut Context, state: &mut DemoState) { + let pad = ui.spacing().xs(); + + // Local devtools toggle. `i` mirrors the runtime Ctrl+F12 shortcut; both + // flip `inspector_mode`, so we read it back to keep our flag in sync. + if ui.key('i') { + state.inspector_on = !state.inspector_on; + ui.set_inspector(state.inspector_on); + } + state.inspector_on = ui.inspector(); + + let _ = ui + .bordered(Border::Rounded) + .title( + "SLT v0.21.0 Widgets — paginator · number_input · virtual_list · scheduler · inspector", + ) + .p(pad) + .grow(1) + .col(|ui| { + // Launch banner: driven by the one-shot `schedule` timer above. + if !state.splash_dismissed { + ui.text("● booting widgets… (one-shot schedule timer dismisses this ~1.5s in)") + .fg(Color::Yellow); + } else { + ui.text(format!( + "● up {}s — recurring `every(\"…\", 1s)` tick counter (drift-free)", + state.seconds + )) + .fg(Color::Green); + } + let _ = ui.separator(); + + // Top row: paginator + numeric steppers. + let _ = ui.row(|ui| { + render_paginator(ui, state); + render_steppers(ui, state); + }); + + let _ = ui.separator(); + + // Bottom row: variable-height virtual_list + debounced search. + let _ = ui.row(|ui| { + render_feed(ui, state); + render_search(ui, state); + }); + + let _ = ui.separator(); + render_inspector_hint(ui, state); + }); +} + +/// Standalone `paginator`: pages over CATALOG, slicing with `page_bounds()`. +fn render_paginator(ui: &mut Context, state: &mut DemoState) { + let pad = ui.spacing().xs(); + let _ = ui.container().fill().col(|ui| { + let _ = ui + .bordered(Border::Single) + .title("paginator (Left/Right when focused)") + .p(pad) + .col(|ui| { + let (start, end) = state.paginator.page_bounds(); + for &(sku, name) in &CATALOG[start..end] { + let _ = ui.container().gap(1).row(|ui| { + ui.text(sku).bold().fg(Color::Cyan); + ui.text(name).dim(); + }); + } + ui.text("").dim(); + let _ = ui.paginator(&mut state.paginator); + ui.text(format!( + "page {}/{} — items {}..{} of {}", + state.paginator.page + 1, + state.paginator.total_pages(), + start, + end, + state.paginator.total_items + )) + .dim(); + }); + }); +} + +/// Numeric steppers: an integer quantity and a float price. `Response.changed` +/// is true on the frame the committed value moves. +fn render_steppers(ui: &mut Context, state: &mut DemoState) { + let pad = ui.spacing().xs(); + let _ = ui.container().fill().col(|ui| { + let _ = ui + .bordered(Border::Single) + .title("number_input (Up/Down · type + Enter)") + .p(pad) + .col(|ui| { + let _ = ui.container().gap(1).row(|ui| { + ui.text("Qty ").dim(); + let r = ui.number_input(&mut state.qty); + if r.changed { + ui.text("← changed").fg(Color::Green); + } + }); + let _ = ui.container().gap(1).row(|ui| { + ui.text("Price").dim(); + let r = ui.number_input(&mut state.price); + if r.changed { + ui.text("← changed").fg(Color::Green); + } + }); + ui.text("").dim(); + let total = state.qty.value * state.price.value; + ui.text(format!( + "qty {} × ${:.2} = ${:.2}", + state.qty.value as i64, state.price.value, total + )) + .bold(); + if let Some(err) = &state.price.parse_error { + ui.text(format!("parse error: {err}")).fg(Color::Red); + } + }); + }); +} + +/// Variable-height `virtual_list_variable`: chat bubbles of differing heights, +/// only the visible range invokes the per-item closure. +fn render_feed(ui: &mut Context, state: &mut DemoState) { + let pad = ui.spacing().xs(); + let _ = ui.container().fill().col(|ui| { + let _ = ui + .bordered(Border::Single) + .title("virtual_list (variable height · Up/Down)") + .p(pad) + .col(|ui| { + // Render at most ~8 rows of bubbles; heights come from + // ListState::with_item_heights so a 4-row code block and a + // 1-row reply pack correctly into the viewport. Snapshot the + // cursor before the call — the closure borrows `feed` mutably. + let cursor = state.feed.selected; + let _ = ui.virtual_list_variable(&mut state.feed, 8, |ui, idx| { + let (text, _h) = FEED[idx]; + let selected = idx == cursor; + let marker = if selected { "▸ " } else { " " }; + let color = if selected { Color::Cyan } else { Color::Reset }; + // Multi-line bubbles render each row; this is the + // variable-height payload the widget reserves space for. + for (li, line) in text.split('\n').enumerate() { + let prefix = if li == 0 { marker } else { " " }; + ui.text(format!("{prefix}{line}")).fg(color); + } + }); + ui.text(format!( + "selected bubble {} of {}", + state.feed.selected + 1, + FEED.len() + )) + .dim(); + }); + }); +} + +/// Debounced search: keystrokes set the `dirty` signal; `debounce` fires once +/// after a quiet window, the search-as-you-type primitive. +fn render_search(ui: &mut Context, state: &mut DemoState) { + let pad = ui.spacing().xs(); + let _ = ui.container().fill().col(|ui| { + let _ = ui + .bordered(Border::Single) + .title("debounce (scheduler · 300ms quiet)") + .p(pad) + .col(|ui| { + let resp = ui.text_input(&mut state.search); + // `resp.changed` is the per-keystroke dirty signal. The query + // only "settles" after 300ms of no typing. + if ui.debounce("v0210::search", Duration::from_millis(300), resp.changed) { + state.settled_query = state.search.value.clone(); + } + ui.text("").dim(); + if state.settled_query.is_empty() { + ui.text("(no settled query yet — stop typing for 300ms)") + .dim(); + } else { + let _ = ui.container().gap(1).row(|ui| { + ui.text("settled →").dim(); + ui.text(state.settled_query.as_str()) + .bold() + .fg(Color::Green); + }); + } + }); + }); +} + +/// Devtools inspector hint + state. The panel itself is drawn by the runtime +/// (Ctrl+F12 / `set_inspector`) on top of everything else. +fn render_inspector_hint(ui: &mut Context, state: &mut DemoState) { + let _ = ui.container().gap(1).row(|ui| { + let (label, color) = if state.inspector_on { + ("inspector: ON", Color::Green) + } else { + ("inspector: off", Color::Reset) + }; + ui.text(label).bold().fg(color); + ui.text("press `i` (or Ctrl+F12) to toggle the resolved-style / focus-chain panel") + .dim(); + }); +} + +// ── In-frame async note ─────────────────────────────────────────────────── +// +// Context::spawn / poll (the in-frame async task registry) needs the `async` +// feature, which pulls tokio and a multi-threaded runtime. Demoing it in this +// same default-feature binary would force the whole example to be +// `required-features = ["async"]`, so it lives in `examples/async_demo.rs` +// instead. Everything in THIS file — the scheduler included — is wall-clock +// based and async-free, so it builds on the default feature set. From 53003ab18d776978cb1266dfd718821517dae54e Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:50:28 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20finish=20v0.21.1=20=E2=80=94=20v0?= =?UTF-8?q?211=20tour=20example,=20sprixel=20bench,=20VHS=20tape=20fix,=20?= =?UTF-8?q?example=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/v0211_tour.rs: showcase of the new 0.21.1 APIs (HSL/hex/rotate_hue, gradient_stops/bg_gradient, spinner presets, list_reorderable, measure_text, KeyMap dispatch, focus_next, Response.submitted/double_clicked/scroll_delta). - Register v0210_widgets + v0211_tour as [[example]] (autoexamples=false). - benches: add bench_flush_sprixel_reblit using the merged sprixel bench hooks; gate the kitty/sprixel bench re-exports behind crossterm (fixes no-default). - VHS gallery: rewrite the 6 build-inside-pty tapes to call the prebuilt ./target/release/examples/ binary (CI already pre-builds examples), so a cold-cache build no longer overruns the fixed Sleep and captures a blank frame — the gallery_manifest parity gate stays green. - typos: rename test-local `ofr` binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 +- Cargo.toml | 13 +++ benches/benchmarks.rs | 27 ++++++ demo.tape | 6 -- demo_dashboard.tape | 6 -- demo_fire.tape | 6 -- demo_game.tape | 6 -- demo_pretext.tape | 6 -- demo_website.tape | 6 -- examples/v0211_tour.rs | 193 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + tests/v020_hooks_focus.rs | 4 +- 12 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 examples/v0211_tour.rs diff --git a/Cargo.lock b/Cargo.lock index 9ceddf0..b4dde7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1159,7 +1159,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slt-wasm" -version = "0.21.0" +version = "0.21.1" dependencies = [ "js-sys", "superlighttui", @@ -1187,7 +1187,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.21.0" +version = "0.21.1" dependencies = [ "compact_str", "criterion", diff --git a/Cargo.toml b/Cargo.toml index ce4f2cd..a568a24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -235,6 +235,19 @@ path = "examples/v020_perf_audit.rs" name = "v020_test_utils" path = "examples/v020_test_utils.rs" +# ── v0.21 coverage ────────────────────────────────────────────────── +# v0210_widgets: paginator / number_input / variable-height virtual_list / +# scheduler / devtools inspector (widgets that shipped in 0.21.0 without a +# dedicated example). v0211_tour: the 0.21.1 API additions. + +[[example]] +name = "v0210_widgets" +path = "examples/v0210_widgets.rs" + +[[example]] +name = "v0211_tour" +path = "examples/v0211_tour.rs" + [[bench]] name = "benchmarks" harness = false diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index e3de200..ff09a2a 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -761,6 +761,32 @@ fn bench_flush_kitty_images(c: &mut Criterion) { group.finish(); } +/// Sprixel re-blit scan on a steady-state (no-damage) frame — the path the +/// v0.21.1 terminal change optimized (hash-set build + per-row clean/hash +/// shortcut). A structurally-identical current/previous frame re-blits nothing, +/// so this measures pure scan cost as the placement count grows. +#[cfg(feature = "crossterm")] +fn bench_flush_sprixel_reblit(c: &mut Criterion) { + let mut group = c.benchmark_group("flush_sprixel_reblit"); + for n in [1_usize, 8, 32] { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + let fixture = slt::__bench_new_sprixel_fixture(n); + debug_assert_eq!(fixture.len(), n); + debug_assert_eq!(fixture.is_empty(), n == 0); + + let mut sink: Vec = Vec::with_capacity(64 * 1024); + b.iter(|| { + sink.clear(); + fixture + .flush(&mut sink, black_box(0)) + .expect("sprixel reblit into Vec cannot fail"); + black_box(sink.len()); + }); + }); + } + group.finish(); +} + /// Register flush-path benches (only when `crossterm` feature is enabled, /// which is the default for benches). When the feature is off, this is a /// no-op so the file still compiles under `--no-default-features`. @@ -773,6 +799,7 @@ fn bench_flush_group(c: &mut Criterion) { bench_flush_full_redraw_300x100(c); bench_flush_sparse_change_300x100(c); bench_flush_kitty_images(c); + bench_flush_sprixel_reblit(c); } let _ = c; } diff --git a/demo.tape b/demo.tape index c6231a4..b323f53 100644 --- a/demo.tape +++ b/demo.tape @@ -15,12 +15,6 @@ Set Margin 0 Require cargo Hide -Type "cargo build --release --example demo 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Type "./target/release/examples/demo" Enter Sleep 2s diff --git a/demo_dashboard.tape b/demo_dashboard.tape index ea2a2a1..1f1b23d 100644 --- a/demo_dashboard.tape +++ b/demo_dashboard.tape @@ -15,12 +15,6 @@ Set Margin 0 Require cargo Hide -Type "cargo build --release --example demo_dashboard 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Type "./target/release/examples/demo_dashboard" Enter Sleep 2s diff --git a/demo_fire.tape b/demo_fire.tape index 744d7ee..4915ed3 100644 --- a/demo_fire.tape +++ b/demo_fire.tape @@ -18,12 +18,6 @@ Set LoopOffset 20% Require cargo Hide -Type "cargo build --release --example demo_fire 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Show Type "./target/release/examples/demo_fire" diff --git a/demo_game.tape b/demo_game.tape index 3c7524b..9ffe3da 100644 --- a/demo_game.tape +++ b/demo_game.tape @@ -18,12 +18,6 @@ Set LoopOffset 20% Require cargo Hide -Type "cargo build --release --example demo_game 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Show Type "./target/release/examples/demo_game" diff --git a/demo_pretext.tape b/demo_pretext.tape index 289d0d2..db9cb7c 100644 --- a/demo_pretext.tape +++ b/demo_pretext.tape @@ -18,12 +18,6 @@ Set LoopOffset 20% Require cargo Hide -Type "cargo build --release --example demo_pretext 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Show Type "./target/release/examples/demo_pretext" diff --git a/demo_website.tape b/demo_website.tape index 619e4a4..5eabcc7 100644 --- a/demo_website.tape +++ b/demo_website.tape @@ -15,12 +15,6 @@ Set Margin 0 Require cargo Hide -Type "cargo build --release --example demo_website 2>&1" -Enter -Sleep 60s -Type "clear" -Enter -Sleep 1s Type "./target/release/examples/demo_website" Enter Sleep 2s diff --git a/examples/v0211_tour.rs b/examples/v0211_tour.rs new file mode 100644 index 0000000..be8dd31 --- /dev/null +++ b/examples/v0211_tour.rs @@ -0,0 +1,193 @@ +//! v0.21.1 Tour — a single-screen showcase of the additive APIs shipped in +//! 0.21.1: ergonomic `Color` (HSL / hex parse / hue rotation), multi-stop and +//! background text gradients, spinner presets, the reorderable list, intrinsic +//! `measure_text`, declarative `KeyMap` dispatch, programmatic focus traversal, +//! and the new `Response` interaction signals (`submitted` / `double_clicked` / +//! `scroll_delta`) with callback chaining. +//! +//! Run: `cargo run --example v0211_tour` +//! +//! Keys: +//! Tab / Shift-Tab — cycle focus +//! n — focus_next() (programmatic, same as Tab) +//! Shift+Up/Down — reorder the selected list item +//! Enter — submit the search box (reported via Response.submitted) +//! r — reset counters (routed through KeyMap::matched) +//! q / Esc / Ctrl-Q — quit +//! +//! Mouse: click / double-click / scroll-wheel over the "target" button to see +//! `Response.clicked` / `.double_clicked` / `.scroll_delta` live. + +use slt::widgets::{ListState, SpinnerState, TextInputState}; +use slt::{Color, Context, KeyCode, KeyMap, KeyModifiers, RunConfig, SpinnerPreset}; + +const ITEMS: &[&str] = &[ + "Reorder me with Shift+Up/Down", + "flexbox layout", + "double-buffer diff", + "tree-sitter syntax", + "sixel + kitty images", +]; + +const SAMPLE: &str = "measure_text returns the (width, rows) a string occupies under the layout \ + engine's own wrap kernel — handy for sizing a tooltip or panel before you \ + draw it."; + +/// Persistent across frames via `run_with`'s `move` closure. +pub struct TourState { + query: TextInputState, + last_submit: Option, + items: ListState, + last_reorder: Option<(usize, usize)>, + clicks: u32, + double_clicks: u32, + wheel: i64, +} + +impl Default for TourState { + fn default() -> Self { + Self { + query: TextInputState::with_placeholder("type then press Enter…"), + last_submit: None, + items: ListState::new(ITEMS.iter().map(|s| s.to_string()).collect::>()), + last_reorder: None, + clicks: 0, + double_clicks: 0, + wheel: 0, + } + } +} + +/// The seven named spinner presets added in 0.21.1. +const PRESETS: &[(&str, SpinnerPreset)] = &[ + ("moon", SpinnerPreset::Moon), + ("bounce", SpinnerPreset::Bounce), + ("circle", SpinnerPreset::Circle), + ("points", SpinnerPreset::Points), + ("arc", SpinnerPreset::Arc), + ("toggle", SpinnerPreset::Toggle), + ("arrow", SpinnerPreset::Arrow), +]; + +pub fn render(ui: &mut Context, state: &mut TourState) { + // ── Title: a multi-stop foreground gradient + a background-gradient banner. + let _ = ui + .text("SuperLightTUI · v0.21.1 tour") + .bold() + .gradient_stops(&[ + (0.0, Color::from_hsl(200.0, 0.9, 0.6)), + (0.5, Color::from_hsl(280.0, 0.9, 0.65)), + (1.0, Color::from_hsl(330.0, 0.9, 0.6)), + ]); + let _ = ui.text(" additive APIs, no breaking changes ").bg_gradient( + Color::from_hsl(210.0, 0.7, 0.35), + Color::from_hsl(330.0, 0.7, 0.35), + ); + + // ── Color ergonomics: HSL swatch row, a parsed hex, and rotate_hue. + let _ = ui.container().gap(1).row(|ui| { + ui.text("Color:").dim(); + for i in 0..12 { + let hue = i as f32 / 12.0 * 360.0; + ui.text("██").fg(Color::from_hsl(hue, 0.85, 0.6)); + } + }); + let _ = ui.container().gap(1).row(|ui| { + let parsed: Color = "#ff6b6b".parse().unwrap_or(Color::Reset); + ui.text("\"#ff6b6b\".parse()").dim(); + ui.text("██").fg(parsed); + ui.text("rotate_hue(180)").dim(); + ui.text("██").fg(parsed.rotate_hue(180.0)); + }); + + // ── Spinner presets. + let _ = ui.container().gap(2).row(|ui| { + ui.text("Spinners:").dim(); + for (name, preset) in PRESETS { + let _ = ui.container().gap(0).row(|ui| { + let _ = ui.spinner(&SpinnerState::preset(*preset)); + ui.text(*name).dim(); + }); + } + }); + + // ── Reorderable list (Shift+Up/Down moves the selected item). + let r = ui.list_reorderable(&mut state.items); + if let Some(mv) = r.reordered { + state.last_reorder = Some(mv); + } + if let Some((from, to)) = state.last_reorder { + ui.text(format!("last reorder: {from} → {to}")) + .fg(Color::Green); + } + + // ── Search box: Enter reports Response.submitted (chained via on_submit). + let submitted = ui.text_input(&mut state.query).submitted; + if submitted { + state.last_submit = Some(state.query.value.clone()); + } + if let Some(q) = &state.last_submit { + ui.text(format!("submitted: {q:?}")).fg(Color::Cyan); + } + + // ── A mouse "target": clicked / double_clicked / scroll_delta in one Response. + let target = ui.button("◎ click / double-click / scroll me"); + if target.clicked { + state.clicks += 1; + } + if target.double_clicked { + state.double_clicks += 1; + } + state.wheel += target.scroll_delta as i64; + ui.text(format!( + "clicks: {} double: {} wheel: {}", + state.clicks, state.double_clicks, state.wheel + )) + .dim(); + + // ── Intrinsic measurement. + let (w, h) = ui.measure_text(SAMPLE, Some(46)); + ui.text(format!("measure_text(SAMPLE, 46) = {w}×{h} cells")) + .dim(); + + ui.text("Tab/n: focus · Shift+Up/Down: reorder · r: reset · q: quit") + .dim(); +} + +fn main() -> std::io::Result<()> { + let mut state = TourState::default(); + slt::run_with( + RunConfig::default() + .mouse(true) + .tick_rate(std::time::Duration::from_millis(80)), + move |ui: &mut Context| { + if ui.key_mod('q', KeyModifiers::CONTROL) { + ui.quit(); + return; + } + + // Declarative KeyMap dispatch: 'r' resets the counters. + let km = KeyMap::new() + .bind('r', "reset counters") + .bind('n', "focus next"); + if let Some(binding) = ui.keymap_match(&km) { + match binding.key { + KeyCode::Char('r') => { + state.clicks = 0; + state.double_clicks = 0; + state.wheel = 0; + state.last_reorder = None; + } + KeyCode::Char('n') => ui.focus_next(), + _ => {} + } + } + + render(ui, &mut state); + + if ui.key_code(KeyCode::Esc) || ui.key('q') { + ui.quit(); + } + }, + ) +} diff --git a/src/lib.rs b/src/lib.rs index e3fb0ff..d050751 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,11 +143,13 @@ pub use terminal::__bench_flush_buffer_diff_mut; #[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::__bench_flush_buffer_diff_mut_with_buf; +#[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::__bench_flush_kitty; #[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture}; +#[cfg(feature = "crossterm")] #[doc(hidden)] pub use terminal::{__BenchSprixelFixture, __bench_flush_sprixels, __bench_new_sprixel_fixture}; /// Runtime terminal capability probe (issue #264): read-only [`Capabilities`] diff --git a/tests/v020_hooks_focus.rs b/tests/v020_hooks_focus.rs index 6c49145..1d688ed 100644 --- a/tests/v020_hooks_focus.rs +++ b/tests/v020_hooks_focus.rs @@ -749,11 +749,11 @@ fn scroll_delta_is_hover_gated() { // Wheel down off the widget -> 0 for the widget (hover-gated). let off = Cell::new(99i32); - let ofr = &off; + let off_ref = &off; let ox = (r.right() + 2).min(19); let oy = (r.bottom() + 1).min(3); tb.run_with_events(EventBuilder::new().scroll_down(ox, oy).build(), |ui| { - ofr.set(ui.button("ok").scroll_delta); + off_ref.set(ui.button("ok").scroll_delta); }); assert_eq!( off.get(), From a00ba879d7b9890ec0316723b4384c9679def4cc Mon Sep 17 00:00:00 2001 From: Subin An Date: Sat, 30 May 2026 17:57:11 +0900 Subject: [PATCH 17/17] ci: pre-install ffmpeg for the VHS gallery job vhs-action@v2's bundled ffmpeg installer fails on the current runner ("Failed to install ffmpeg"), so the advisory gallery job never reaches the (now prebuilt-binary) tapes. Install ffmpeg from apt first so the action finds it present. CI-only; the gallery job is continue-on-error and the README<->tape parity hard gate (gallery_manifest) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd3064b..f8f4f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + # vhs-action's bundled ffmpeg installer is flaky on the GitHub runner + # (observed: "Failed to install ffmpeg"). Pre-install it from apt so the + # action finds it already present and skips its own download. + - name: Install ffmpeg for VHS + run: sudo apt-get update && sudo apt-get install -y ffmpeg - uses: charmbracelet/vhs-action@v2 - name: Build release examples run: cargo build --release --examples