@@ -3,6 +3,7 @@ |
| 3 | 3 | use gartk_core::{Point, Rect}; |
| 4 | 4 | use gartk_render::{Renderer, TextStyle}; |
| 5 | 5 | use gartop_ipc::{ProcessInfo, SortField}; |
| 6 | +use std::time::Instant; |
| 6 | 7 | use super::theme::Theme; |
| 7 | 8 | |
| 8 | 9 | /// Format rate (bytes/second) to human-readable compact string. |
@@ -30,6 +31,9 @@ const ROW_HEIGHT: u32 = 22; |
| 30 | 31 | /// Header row height. |
| 31 | 32 | const HEADER_HEIGHT: u32 = 24; |
| 32 | 33 | |
| 34 | +/// Grace period after user navigation before auto-scrolling resumes (ms) |
| 35 | +const NAVIGATION_GRACE_MS: u64 = 400; |
| 36 | + |
| 33 | 37 | /// Process list component - renders process data without owning it. |
| 34 | 38 | /// Tracks selection by PID so cursor follows the process when list reorders. |
| 35 | 39 | pub struct ProcessList { |
@@ -42,6 +46,10 @@ pub struct ProcessList { |
| 42 | 46 | sort_field: SortField, |
| 43 | 47 | visible_rows: usize, |
| 44 | 48 | process_count: usize, |
| 49 | + /// Last time user navigated (for grace period) |
| 50 | + last_nav_time: Option<Instant>, |
| 51 | + /// Whether cursor lost its target and needs visual indicator |
| 52 | + cursor_lost: bool, |
| 45 | 53 | } |
| 46 | 54 | |
| 47 | 55 | impl ProcessList { |
@@ -56,6 +64,8 @@ impl ProcessList { |
| 56 | 64 | sort_field: SortField::Cpu, |
| 57 | 65 | visible_rows, |
| 58 | 66 | process_count: 0, |
| 67 | + last_nav_time: None, |
| 68 | + cursor_lost: false, |
| 59 | 69 | } |
| 60 | 70 | } |
| 61 | 71 | |
@@ -67,40 +77,48 @@ impl ProcessList { |
| 67 | 77 | |
| 68 | 78 | /// Sync selection with updated process list. |
| 69 | 79 | /// Call this after refreshing process data to update the cursor position. |
| 70 | | - /// Returns true if selection is still valid and visible. |
| 80 | + /// Returns true if selection is still valid. |
| 71 | 81 | pub fn sync_selection(&mut self, processes: &[ProcessInfo]) -> bool { |
| 72 | 82 | self.process_count = processes.len(); |
| 83 | + self.cursor_lost = false; |
| 73 | 84 | |
| 74 | 85 | // Clamp scroll offset |
| 75 | 86 | if self.scroll_offset > self.max_scroll() { |
| 76 | 87 | self.scroll_offset = self.max_scroll(); |
| 77 | 88 | } |
| 78 | 89 | |
| 90 | + // Check if we're in grace period after user navigation |
| 91 | + let in_grace_period = self.last_nav_time |
| 92 | + .map(|t| t.elapsed().as_millis() < NAVIGATION_GRACE_MS as u128) |
| 93 | + .unwrap_or(false); |
| 94 | + |
| 79 | 95 | // Find the selected PID in the new list |
| 80 | 96 | if let Some(pid) = self.selected_pid { |
| 81 | 97 | if let Some(idx) = processes.iter().position(|p| p.pid == pid) { |
| 98 | + // Process found - update index |
| 82 | 99 | self.selected_index = Some(idx); |
| 83 | 100 | |
| 84 | | - // If process moved out of visible area, scroll to keep it visible |
| 85 | | - // but only if it's still in the first "page" of results |
| 86 | | - if idx < self.visible_rows * 2 { |
| 87 | | - // Process is near the top, keep tracking |
| 101 | + // Only auto-scroll if NOT in grace period |
| 102 | + if !in_grace_period { |
| 103 | + // Keep cursor visible if it moved |
| 88 | 104 | if idx < self.scroll_offset { |
| 89 | 105 | self.scroll_offset = idx; |
| 90 | 106 | } else if idx >= self.scroll_offset + self.visible_rows { |
| 91 | 107 | self.scroll_offset = idx.saturating_sub(self.visible_rows - 1); |
| 92 | 108 | } |
| 93 | | - return true; |
| 109 | + } |
| 110 | + return true; |
| 111 | + } else { |
| 112 | + // Process no longer exists - jump to last visible row |
| 113 | + self.cursor_lost = true; |
| 114 | + let last_visible = (self.scroll_offset + self.visible_rows - 1).min(processes.len().saturating_sub(1)); |
| 115 | + if !processes.is_empty() { |
| 116 | + self.selected_index = Some(last_visible); |
| 117 | + self.selected_pid = Some(processes[last_visible].pid); |
| 94 | 118 | } else { |
| 95 | | - // Process moved too far down, clear selection |
| 96 | 119 | self.selected_pid = None; |
| 97 | 120 | self.selected_index = None; |
| 98 | | - return false; |
| 99 | 121 | } |
| 100 | | - } else { |
| 101 | | - // Process no longer exists, clear selection |
| 102 | | - self.selected_pid = None; |
| 103 | | - self.selected_index = None; |
| 104 | 122 | return false; |
| 105 | 123 | } |
| 106 | 124 | } |
@@ -159,6 +177,8 @@ impl ProcessList { |
| 159 | 177 | let process_idx = self.scroll_offset + row; |
| 160 | 178 | |
| 161 | 179 | if process_idx < processes.len() { |
| 180 | + self.last_nav_time = Some(Instant::now()); |
| 181 | + self.cursor_lost = false; |
| 162 | 182 | let pid = processes[process_idx].pid; |
| 163 | 183 | self.selected_pid = Some(pid); |
| 164 | 184 | self.selected_index = Some(process_idx); |
@@ -184,6 +204,9 @@ impl ProcessList { |
| 184 | 204 | if processes.is_empty() { |
| 185 | 205 | return; |
| 186 | 206 | } |
| 207 | + self.last_nav_time = Some(Instant::now()); |
| 208 | + self.cursor_lost = false; |
| 209 | + |
| 187 | 210 | match self.selected_index { |
| 188 | 211 | None => { |
| 189 | 212 | // Select first visible item |
@@ -210,6 +233,9 @@ impl ProcessList { |
| 210 | 233 | if processes.is_empty() { |
| 211 | 234 | return; |
| 212 | 235 | } |
| 236 | + self.last_nav_time = Some(Instant::now()); |
| 237 | + self.cursor_lost = false; |
| 238 | + |
| 213 | 239 | match self.selected_index { |
| 214 | 240 | None => { |
| 215 | 241 | // Select first visible item |
@@ -234,6 +260,8 @@ impl ProcessList { |
| 234 | 260 | /// Select first item (Home key). |
| 235 | 261 | pub fn select_first(&mut self, processes: &[ProcessInfo]) { |
| 236 | 262 | if !processes.is_empty() { |
| 263 | + self.last_nav_time = Some(Instant::now()); |
| 264 | + self.cursor_lost = false; |
| 237 | 265 | self.selected_index = Some(0); |
| 238 | 266 | self.selected_pid = Some(processes[0].pid); |
| 239 | 267 | self.scroll_offset = 0; |
@@ -243,6 +271,8 @@ impl ProcessList { |
| 243 | 271 | /// Select last item (End key). |
| 244 | 272 | pub fn select_last(&mut self, processes: &[ProcessInfo]) { |
| 245 | 273 | if !processes.is_empty() { |
| 274 | + self.last_nav_time = Some(Instant::now()); |
| 275 | + self.cursor_lost = false; |
| 246 | 276 | let last_idx = processes.len() - 1; |
| 247 | 277 | self.selected_index = Some(last_idx); |
| 248 | 278 | self.selected_pid = Some(processes[last_idx].pid); |
@@ -255,6 +285,11 @@ impl ProcessList { |
| 255 | 285 | self.selected_index |
| 256 | 286 | } |
| 257 | 287 | |
| 288 | + /// Check if cursor just lost its target (for visual feedback). |
| 289 | + pub fn is_cursor_lost(&self) -> bool { |
| 290 | + self.cursor_lost |
| 291 | + } |
| 292 | + |
| 258 | 293 | /// Render the process list. |
| 259 | 294 | pub fn render(&self, renderer: &Renderer, theme: &Theme, processes: &[ProcessInfo]) -> anyhow::Result<()> { |
| 260 | 295 | // Background |
@@ -358,7 +393,13 @@ impl ProcessList { |
| 358 | 393 | self.bounds.width - 4, |
| 359 | 394 | ROW_HEIGHT - 4, |
| 360 | 395 | ); |
| 361 | | - renderer.fill_rounded_rect(row_rect, 2.0, theme.header_bg)?; |
| 396 | + // Use magenta highlight when cursor lost its target |
| 397 | + let highlight_color = if self.cursor_lost { |
| 398 | + gartk_core::Color::new(0.6, 0.2, 0.6, 1.0) // Magenta |
| 399 | + } else { |
| 400 | + theme.header_bg |
| 401 | + }; |
| 402 | + renderer.fill_rounded_rect(row_rect, 2.0, highlight_color)?; |
| 362 | 403 | } |
| 363 | 404 | |
| 364 | 405 | // PID |