W8b: Error flash on status line for visual feedback
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.
This commit is contained in:
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +94,12 @@ pub struct StatusLine {
|
||||
hint: String,
|
||||
spinner_char: String,
|
||||
disk_path: Option<PathBuf>,
|
||||
error_flash: Option<Instant>,
|
||||
}
|
||||
|
||||
/// 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<String>) {
|
||||
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<f32> {
|
||||
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<Span> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user