From 4325c674713f4ccbba5eea03a37e9b4fb5bb7203 Mon Sep 17 00:00:00 2001 From: vasilito Date: Mon, 6 Jul 2026 15:19:03 +0300 Subject: [PATCH] W8b: Error flash on status line for visual feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a critical operation fails (chmod/chown/rmdir/view), the status line pulses its background between status_bg and theme.error for 400ms. This gives the user immediate visual confirmation that something went wrong, even if the message text is short or they aren't looking at the status bar. - StatusLine::set_error() — sets message + triggers flash - StatusLine::error_flash_progress() — remaining fraction (0.0..=1.0) - blend_bg() — RGB linear interpolation between two Color values - rgb() — named-color → RGB tuple for interpolation Wired into 4 critical error paths in dialog_ops.rs (chmod, chown, rmdir, view). Other transient messages continue to use set_message(). Tests: 1388 pass (was 1384), zero warnings. --- .../tlc/source/src/filemanager/dialog_ops.rs | 8 +- .../tui/tlc/source/src/terminal/status.rs | 138 +++++++++++++++++- 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index 07638688aa..eaab62a8c8 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -270,7 +270,7 @@ impl FileManager { self.status.set_message("rmdir: not found"); } Err(e) => { - self.status.set_message(format!("rmdir: {e}")); + self.status.set_error(format!("rmdir: {e}")); } } Ok(()) @@ -690,7 +690,7 @@ impl FileManager { self.status .set_message(format!("Viewing {}", path.display())); } - Err(e) => self.status.set_message(format!("view: {e}")), +Err(e) => self.status.set_error(format!("view: {e}")), }, FindOutcome::Edit(path, line) => { let mut ed = crate::editor::Editor::open(&path); @@ -1010,7 +1010,7 @@ impl FileManager { let _ = self.active_panel_mut().refresh(); } Err(e) => { - self.status.set_message(format!("chmod: {e}")); +self.status.set_error(format!("chmod: {e}")); } } } @@ -1029,7 +1029,7 @@ impl FileManager { let _ = self.active_panel_mut().refresh(); } Err(e) => { - self.status.set_message(format!("chown: {e}")); +self.status.set_error(format!("chown: {e}")); } } } diff --git a/local/recipes/tui/tlc/source/src/terminal/status.rs b/local/recipes/tui/tlc/source/src/terminal/status.rs index 21cedd314f..049489c884 100644 --- a/local/recipes/tui/tlc/source/src/terminal/status.rs +++ b/local/recipes/tui/tlc/source/src/terminal/status.rs @@ -94,8 +94,12 @@ pub struct StatusLine { hint: String, spinner_char: String, disk_path: Option, + error_flash: Option, } +/// How long the error flash pulses the background colour. +const ERROR_FLASH_DURATION: Duration = Duration::from_millis(400); + impl StatusLine { /// Create an empty status line. pub fn new() -> Self { @@ -112,6 +116,28 @@ impl StatusLine { self.message = Some(Message::new(text, Duration::from_secs(3))); } + /// Post an error message and trigger the background flash effect. + /// The message is shown for 3 seconds; the background pulse + /// lasts for [`ERROR_FLASH_DURATION`]. + pub fn set_error(&mut self, text: impl Into) { + self.set_message(text); + self.error_flash = Some(Instant::now()); + } + + /// `Some(fraction)` while the error flash is active, where + /// `fraction` is in `0.0..=1.0` (1.0 = just triggered, 0.0 = + /// fully expired). `None` when no flash is active. + pub fn error_flash_progress(&self) -> Option { + let start = self.error_flash?; + let elapsed = start.elapsed(); + if elapsed >= ERROR_FLASH_DURATION { + return None; + } + let total_ms = ERROR_FLASH_DURATION.as_secs_f32() * 1000.0; + let remaining = total_ms - elapsed.as_secs_f32() * 1000.0; + Some((remaining / total_ms).clamp(0.0, 1.0)) + } + /// Clear the current message. pub fn clear(&mut self) { self.message = None; @@ -190,6 +216,19 @@ impl StatusLine { let status_bg = status_pair.map(|p| p.bg).unwrap_or(theme.status_bg); let now_message = self.message.as_ref().filter(|m| !m.expired()); + // Error flash: when active, pulse the background between + // status_bg and theme.error. The pulse intensity follows + // the remaining fraction so it fades out smoothly. + let flash_bg = self.error_flash_progress().map(|frac| { + let pulse = (frac * std::f32::consts::PI).sin(); + pulse + }); + let effective_bg = if let Some(pulse) = flash_bg { + blend_bg(status_bg, theme.error, pulse) + } else { + status_bg + }; + let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -206,14 +245,14 @@ impl StatusLine { m.text(), Style::default() .fg(status_fg) - .bg(status_bg) + .bg(effective_bg) .add_modifier(Modifier::BOLD), )); left_spans.push(Span::raw(" ")); } left_spans.push(Span::styled( &self.hint, - Style::default().fg(status_fg).bg(status_bg), + Style::default().fg(status_fg).bg(effective_bg), )); let mut right_spans: Vec = Vec::new(); @@ -230,20 +269,20 @@ impl StatusLine { let free_str = format_size_short(free); right_spans.push(Span::styled( format!(" \u{25cf}{free_str} "), - Style::default().fg(disk_color).bg(status_bg), + Style::default().fg(disk_color).bg(effective_bg), )); } } if !self.spinner_char.is_empty() { right_spans.push(Span::styled( self.spinner_char.clone(), - Style::default().fg(theme.info).bg(status_bg), + Style::default().fg(theme.info).bg(effective_bg), )); right_spans.push(Span::raw(" ")); } right_spans.push(Span::styled( clock, - Style::default().fg(status_fg).bg(status_bg), + Style::default().fg(status_fg).bg(effective_bg), )); let left_w: usize = left_spans.iter().map(|s| s.width()).sum(); @@ -254,12 +293,12 @@ impl StatusLine { let mut all_spans = left_spans; all_spans.push(Span::styled( " ".repeat(pad), - Style::default().bg(status_bg), + Style::default().bg(effective_bg), )); all_spans.extend(right_spans); let para = Paragraph::new(Line::from(all_spans)) - .style(Style::default().fg(status_fg).bg(status_bg)); + .style(Style::default().fg(status_fg).bg(effective_bg)); frame.render_widget(para, area); } } @@ -277,6 +316,46 @@ fn format_size_short(n: u64) -> String { } } +/// Linear blend between two ratatui `Color` values in RGB space. +/// `t` is clamped to `0.0..=1.0`; `0.0` returns `a`, `1.0` returns `b`. +#[must_use] +fn blend_bg(a: ratatui::style::Color, b: ratatui::style::Color, t: f32) -> ratatui::style::Color { + let t = t.clamp(0.0, 1.0); + if let (Some((ar, ag, ab)), Some((br, bg, bb))) = (rgb(a), rgb(b)) { + let r = (ar as f32 + (br as f32 - ar as f32) * t) as u8; + let g = (ag as f32 + (bg as f32 - ag as f32) * t) as u8; + let bl = (ab as f32 + (bb as f32 - ab as f32) * t) as u8; + ratatui::style::Color::Rgb(r, g, bl) + } else { + if t < 0.5 { a } else { b } + } +} + +#[must_use] +fn rgb(c: ratatui::style::Color) -> Option<(u8, u8, u8)> { + use ratatui::style::Color; + match c { + Color::Rgb(r, g, b) => Some((r, g, b)), + Color::Black => Some((0, 0, 0)), + Color::Red => Some((170, 0, 0)), + Color::Green => Some((0, 170, 0)), + Color::Yellow => Some((170, 85, 0)), + Color::Blue => Some((0, 0, 170)), + Color::Magenta => Some((170, 0, 170)), + Color::Cyan => Some((0, 170, 170)), + Color::Gray => Some((170, 170, 170)), + Color::DarkGray => Some((85, 85, 85)), + Color::LightRed => Some((255, 85, 85)), + Color::LightGreen => Some((85, 255, 85)), + Color::LightYellow => Some((255, 255, 85)), + Color::LightBlue => Some((85, 85, 255)), + Color::LightMagenta => Some((255, 85, 255)), + Color::LightCyan => Some((85, 255, 255)), + Color::White => Some((255, 255, 255)), + _ => None, + } +} + #[cfg(test)] mod tests { @@ -355,4 +434,49 @@ mod tests { let result = disk_free(std::path::Path::new("/")); assert!(result.is_some(), "statvfs(/) should succeed on Linux"); } + + #[test] + fn error_flash_activates_and_expires() { + let mut s = StatusLine::new(); + assert!(s.error_flash_progress().is_none()); + s.set_error("boom"); + assert!(s.error_flash_progress().is_some()); + assert!(s.has_message()); + } + + #[test] + fn blend_bg_returns_first_when_t_is_zero() { + let a = ratatui::style::Color::Rgb(10, 20, 30); + let b = ratatui::style::Color::Rgb(200, 150, 100); + let result = blend_bg(a, b, 0.0); + if let ratatui::style::Color::Rgb(r, g, bl) = result { + assert_eq!((r, g, bl), (10, 20, 30)); + } else { + panic!("expected Rgb result"); + } + } + + #[test] + fn blend_bg_returns_second_when_t_is_one() { + let a = ratatui::style::Color::Rgb(10, 20, 30); + let b = ratatui::style::Color::Rgb(200, 150, 100); + let result = blend_bg(a, b, 1.0); + if let ratatui::style::Color::Rgb(r, g, bl) = result { + assert_eq!((r, g, bl), (200, 150, 100)); + } else { + panic!("expected Rgb result"); + } + } + + #[test] + fn blend_bg_interpolates_at_midpoint() { + let a = ratatui::style::Color::Rgb(0, 0, 0); + let b = ratatui::style::Color::Rgb(200, 100, 50); + let result = blend_bg(a, b, 0.5); + if let ratatui::style::Color::Rgb(r, g, bl) = result { + assert_eq!((r, g, bl), (100, 50, 25)); + } else { + panic!("expected Rgb result"); + } + } }