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"); + } + } }