W46: Animated cursor movement trail

When the user moves the cursor (up/down/home/end), the previous
cursor position briefly shows a thin accent-coloured bar (▏)
to the left of the row. Creates a subtle trail effect that makes
the new cursor position more obvious.

- Panel::prev_cursor: usize field set on every move
- Panel::cursor_flash: Option<Instant> tracks the flash
- Panel::cursor_flash_progress() returns Some(frac) for ~200ms
  after each move
- cursor_up / cursor_down / cursor_home / cursor_end set the
  previous position and trigger the flash
- Render path draws the bar at the previous row in accent colour

Added 1 unit test verifying prev_cursor and cursor_flash on move.

Tests: 1406 pass (was 1405), zero warnings.
This commit is contained in:
2026-07-07 08:54:36 +03:00
parent ba05902883
commit affb6690c5
2 changed files with 79 additions and 0 deletions
@@ -115,6 +115,12 @@ pub struct Panel {
entries: Vec<Entry>,
/// Cursor position (index into `entries`). 0 is the ".." entry.
cursor: usize,
/// Previous cursor position (set when the cursor moves). Used by
/// the renderer to draw a brief trail behind the active cursor.
prev_cursor: usize,
/// `Some(timestamp)` while the cursor-trail flash is active.
/// `None` when no flash is active.
cursor_flash: Option<std::time::Instant>,
/// Top-line scroll position.
top: usize,
/// Files marked with Insert/Space.
@@ -164,6 +170,8 @@ impl Panel {
path: PathBuf::new(),
entries: Vec::new(),
cursor: 0,
prev_cursor: 0,
cursor_flash: None,
top: 0,
marked: HashSet::new(),
sort_field: SortField::from_config(&cfg.sort_field),
@@ -239,6 +247,12 @@ impl Panel {
self.cursor
}
/// Previous cursor index (set on every move; reset by render).
#[must_use]
pub fn prev_cursor(&self) -> usize {
self.prev_cursor
}
/// Top-line scroll index.
#[must_use]
pub fn top(&self) -> usize {
@@ -497,6 +511,20 @@ impl Panel {
v
}
/// `Some(frac)` while the cursor-trail flash is active, where
/// `1.0` = just after move and `0.0` = fully expired. `None`
/// when no flash is active.
pub fn cursor_flash_progress(&self) -> Option<f32> {
let start = self.cursor_flash?;
const FLASH_MS: f32 = 200.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))
}
/// Snapshot of file-system state for every non-`..` entry, in
/// display order. The returned vector preserves the panel's
/// current sort order so callers can build a `name -> size` map
@@ -586,25 +614,37 @@ impl Panel {
/// Move the cursor up by one line.
pub fn cursor_up(&mut self) {
if self.cursor > 0 {
self.prev_cursor = self.cursor;
self.cursor -= 1;
self.cursor_flash = Some(std::time::Instant::now());
}
}
/// Move the cursor down by one line.
pub fn cursor_down(&mut self) {
if self.cursor + 1 < self.entries.len() {
self.prev_cursor = self.cursor;
self.cursor += 1;
self.cursor_flash = Some(std::time::Instant::now());
}
}
/// Move the cursor to the top of the list.
pub fn cursor_home(&mut self) {
if self.cursor != 0 {
self.prev_cursor = self.cursor;
self.cursor_flash = Some(std::time::Instant::now());
}
self.cursor = 0;
}
/// Move the cursor to the bottom of the list.
pub fn cursor_end(&mut self) {
if !self.entries.is_empty() {
if self.cursor != self.entries.len() - 1 {
self.prev_cursor = self.cursor;
self.cursor_flash = Some(std::time::Instant::now());
}
self.cursor = self.entries.len() - 1;
}
}
@@ -1243,6 +1283,25 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn cursor_movement_records_prev_cursor_and_triggers_flash() {
let dir = std::env::temp_dir().join("tlc-panel-cursor-trail");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.txt"), b"x").unwrap();
fs::write(dir.join("b.txt"), b"x").unwrap();
let mut p = Panel::new(&dir, &empty_cfg()).unwrap();
// Initially no flash, prev_cursor == cursor.
assert!(p.cursor_flash_progress().is_none());
// cursor_down sets prev_cursor and triggers the flash.
p.cursor_down();
assert!(p.cursor_flash_progress().is_some());
// prev_cursor holds the index before the move.
// The exact value depends on which entry was at cursor 0.
assert_ne!(p.prev_cursor(), p.cursor());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn filter_match_len_returns_filter_prefix() {
let dir = std::env::temp_dir().join("tlc-panel-filter-match-len");
@@ -449,6 +449,26 @@ impl FileManager {
.style(Style::default().fg(panel_fg).bg(panel_bg));
frame.render_widget(list, list_area);
// Cursor-trail: briefly highlight the previous
// cursor row with a thin accent-coloured bar.
if let Some(frac) = panel.cursor_flash_progress() {
let prev_visible = (panel.prev_cursor() as i64) - (top as i64);
if prev_visible >= 0 && (prev_visible as u16) < body_height {
let prev_y = body_y + prev_visible as u16;
let bar = Paragraph::new(Span::styled(
"",
Style::default()
.fg(self.theme.accent)
.bg(panel_bg),
));
frame.render_widget(
bar,
Rect::new(inner.x, prev_y, 1, 1),
);
let _ = frac; // currently unused; reserved for future fade
}
}
let total = panel.entry_count().max(1);
let mut sb_state = ScrollbarState::new(total)
.viewport_content_length(body_height as usize)