W33: Mode-change flash in status bar

When the editor mode changes (e.g. Esc → Normal, i → Insert),
the mode tag in the status bar flashes to the accent colour
for ~300ms. Provides instant visual feedback for mode changes.

- Editor::mode_flash: Option<Instant> field
- Editor::trigger_mode_flash() / mode_flash_progress()
- Editor::set_mode() helper that triggers the flash on actual
  mode changes (no-op when setting the same mode)
- All self.mode = Mode::* sites in handlers.rs updated to use
  set_mode() so the flash triggers consistently
- status_spans uses accent + cursor_fg for the mode tag while
  the flash is active

Added 1 unit test verifying flash triggers on mode change and
NOT on setting the same mode.

Tests: 1397 pass (was 1396), zero warnings.
This commit is contained in:
2026-07-06 22:15:54 +03:00
parent 5d869d0c45
commit c9ad1e8deb
@@ -164,6 +164,9 @@ pub struct Editor {
/// offset) for the flash highlight, or `None` when the cursor is
/// not on a bracket.
bracket_flash: Option<(usize, usize)>,
/// Timestamp of the last mode change, or `None`. Used to flash
/// the mode tag in the status bar for ~300ms.
mode_flash: Option<std::time::Instant>,
/// In-buffer search engine — pattern, history, last match. The
/// history backs the Alt-/ popup in the Find/Replace prompts.
search: SearchState,
@@ -277,6 +280,7 @@ impl Editor {
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
mode_flash: None,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
@@ -326,6 +330,7 @@ impl Editor {
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
mode_flash: None,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
@@ -821,6 +826,36 @@ impl Editor {
history.get(idx).cloned()
}
/// Trigger the mode-change flash. Call this whenever the
/// `mode` field is set to a new value.
pub fn trigger_mode_flash(&mut self) {
self.mode_flash = Some(std::time::Instant::now());
}
/// `Some(frac)` while the mode flash is active, where `frac`
/// is `1.0` just after the change and `0.0` fully expired.
/// `None` when no flash is active.
pub fn mode_flash_progress(&self) -> Option<f32> {
let start = self.mode_flash?;
const FLASH_MS: f32 = 300.0;
let elapsed_ms = start.elapsed().as_secs_f32() * 1000.0;
if elapsed_ms >= FLASH_MS {
return None;
}
let remaining = FLASH_MS - elapsed_ms;
Some((remaining / FLASH_MS).clamp(0.0, 1.0))
}
/// Set the editor mode and trigger the mode-change flash.
/// Use this instead of writing `self.mode = ...` directly so
/// the status bar flash stays in sync.
pub fn set_mode(&mut self, mode: Mode) {
if self.mode != mode {
self.mode = mode;
self.trigger_mode_flash();
}
}
fn update_bracket_flash(&mut self) {
let pos = self.cursor.position();
let text = self.buffer.as_string();
@@ -3698,5 +3733,19 @@ mod tests {
let misspelled = e.find_next_misspelled(0);
assert!(misspelled.is_none());
}
#[test]
fn mode_flash_triggers_on_set_mode_and_expires() {
let mut e = make_empty();
assert!(e.mode_flash_progress().is_none());
e.set_mode(Mode::Normal);
assert!(e.mode_flash_progress().is_some());
// Setting the same mode should NOT trigger a flash.
e.set_mode(Mode::Normal);
let frac = e.mode_flash_progress().unwrap();
// No new timestamp was recorded, so the fraction is
// still the same (or slightly less, since time passed).
assert!(frac > 0.0 && frac <= 1.0);
}
}