@@ -54,6 +54,10 @@ pub struct App { |
| 54 | search_query: String, | 54 | search_query: String, |
| 55 | /// Help overlay visible | 55 | /// Help overlay visible |
| 56 | show_help: bool, | 56 | show_help: bool, |
| | 57 | + /// Type-to-jump fuzzy pattern |
| | 58 | + jump_pattern: String, |
| | 59 | + /// Last time a jump character was typed (for timeout) |
| | 60 | + jump_time: Option<Instant>, |
| 57 | status: Option<StatusInfo>, | 61 | status: Option<StatusInfo>, |
| 58 | cpu_stats: Option<CpuStats>, | 62 | cpu_stats: Option<CpuStats>, |
| 59 | memory_stats: Option<MemoryStats>, | 63 | memory_stats: Option<MemoryStats>, |
@@ -130,6 +134,8 @@ impl App { |
| 130 | search_mode: false, | 134 | search_mode: false, |
| 131 | search_query: String::new(), | 135 | search_query: String::new(), |
| 132 | show_help: false, | 136 | show_help: false, |
| | 137 | + jump_pattern: String::new(), |
| | 138 | + jump_time: None, |
| 133 | status: None, | 139 | status: None, |
| 134 | cpu_stats: None, | 140 | cpu_stats: None, |
| 135 | memory_stats: None, | 141 | memory_stats: None, |
@@ -410,19 +416,19 @@ impl App { |
| 410 | | 416 | |
| 411 | let bindings = [ | 417 | let bindings = [ |
| 412 | ("?", "Toggle this help"), | 418 | ("?", "Toggle this help"), |
| 413 | - ("q / Esc", "Quit / clear search"), | 419 | + ("q / Esc", "Quit / clear"), |
| 414 | ("", ""), | 420 | ("", ""), |
| 415 | ("1-4", "Switch tab"), | 421 | ("1-4", "Switch tab"), |
| 416 | ("Tab", "Cycle tabs"), | 422 | ("Tab", "Cycle tabs"), |
| 417 | ("", ""), | 423 | ("", ""), |
| 418 | - ("j / \u{2193}", "Next process"), | 424 | + ("\u{2191} / \u{2193}", "Navigate list"), |
| 419 | - ("k / \u{2191}", "Previous process"), | 425 | + ("Home / End", "First / last"), |
| 420 | - ("Home", "First process"), | | |
| 421 | - ("End", "Last process"), | | |
| 422 | ("PgUp/PgDn", "Jump 10 rows"), | 426 | ("PgUp/PgDn", "Jump 10 rows"), |
| 423 | ("", ""), | 427 | ("", ""), |
| 424 | - ("f", "Freeze list"), | 428 | + ("Alt+f", "Freeze list"), |
| 425 | - ("/", "Search by name"), | 429 | + ("/", "Search filter"), |
| | 430 | + ("a-z", "Fuzzy jump"), |
| | 431 | + ("Backspace", "Delete jump char"), |
| 426 | ("", ""), | 432 | ("", ""), |
| 427 | ("K", "Kill (SIGTERM)"), | 433 | ("K", "Kill (SIGTERM)"), |
| 428 | ("X", "Kill (SIGKILL)"), | 434 | ("X", "Kill (SIGKILL)"), |
@@ -719,6 +725,22 @@ impl App { |
| 719 | self.renderer.text(&search_text, CONTENT_PADDING as f64, search_y as f64, &search_style)?; | 725 | self.renderer.text(&search_text, CONTENT_PADDING as f64, search_y as f64, &search_style)?; |
| 720 | } | 726 | } |
| 721 | | 727 | |
| | 728 | + // Jump pattern indicator (right side, above process list) |
| | 729 | + if !self.jump_pattern.is_empty() { |
| | 730 | + let jump_y = (HEADER_HEIGHT + TAB_BAR_HEIGHT + SECTION_GAP + 20 + GRAPH_HEIGHT + SECTION_GAP) as i32 - 20; |
| | 731 | + let jump_style = TextStyle { |
| | 732 | + font_family: "monospace".to_string(), |
| | 733 | + font_size: 11.0, |
| | 734 | + color: self.theme.network_color, |
| | 735 | + ..Default::default() |
| | 736 | + }; |
| | 737 | + let jump_text = format!("jump: {}", self.jump_pattern); |
| | 738 | + // Position on right side |
| | 739 | + let text_width = self.renderer.measure_text(&jump_text, &jump_style)?.width as f64; |
| | 740 | + let jump_x = (self.width as f64) - CONTENT_PADDING as f64 - text_width; |
| | 741 | + self.renderer.text(&jump_text, jump_x, jump_y as f64, &jump_style)?; |
| | 742 | + } |
| | 743 | + |
| 722 | // Filter processes if search query is active | 744 | // Filter processes if search query is active |
| 723 | let display_processes: Vec<ProcessInfo> = if self.search_query.is_empty() { | 745 | let display_processes: Vec<ProcessInfo> = if self.search_query.is_empty() { |
| 724 | self.processes.clone() | 746 | self.processes.clone() |
@@ -872,6 +894,35 @@ impl App { |
| 872 | } | 894 | } |
| 873 | } | 895 | } |
| 874 | | 896 | |
| | 897 | + /// Handle type-to-jump fuzzy matching. |
| | 898 | + fn handle_fuzzy_jump(&mut self, c: char) { |
| | 899 | + // Add character to pattern |
| | 900 | + self.jump_pattern.push(c); |
| | 901 | + self.jump_time = Some(Instant::now()); |
| | 902 | + |
| | 903 | + // Find first fuzzy match |
| | 904 | + if let Some(idx) = self.find_fuzzy_match(&self.jump_pattern) { |
| | 905 | + // Select and scroll to the match |
| | 906 | + if idx < self.processes.len() { |
| | 907 | + let pid = self.processes[idx].pid; |
| | 908 | + self.process_list.select_by_index(idx, pid); |
| | 909 | + } |
| | 910 | + } |
| | 911 | + } |
| | 912 | + |
| | 913 | + /// Find first process matching fuzzy pattern. |
| | 914 | + /// Returns index of first match or None. |
| | 915 | + fn find_fuzzy_match(&self, pattern: &str) -> Option<usize> { |
| | 916 | + let pattern_lower = pattern.to_lowercase(); |
| | 917 | + |
| | 918 | + for (idx, proc) in self.processes.iter().enumerate() { |
| | 919 | + if fuzzy_match(&proc.name.to_lowercase(), &pattern_lower) { |
| | 920 | + return Some(idx); |
| | 921 | + } |
| | 922 | + } |
| | 923 | + None |
| | 924 | + } |
| | 925 | + |
| 875 | /// Run the GUI event loop. | 926 | /// Run the GUI event loop. |
| 876 | pub fn run(mut self) -> Result<()> { | 927 | pub fn run(mut self) -> Result<()> { |
| 877 | let config = EventLoopConfig { | 928 | let config = EventLoopConfig { |
@@ -956,8 +1007,13 @@ impl App { |
| 956 | // Normal mode key handling | 1007 | // Normal mode key handling |
| 957 | match key_event.key { | 1008 | match key_event.key { |
| 958 | Key::Escape => { | 1009 | Key::Escape => { |
| 959 | - if !self.search_query.is_empty() { | 1010 | + if !self.jump_pattern.is_empty() { |
| 960 | - // Clear search filter first | 1011 | + // Clear jump pattern first |
| | 1012 | + self.jump_pattern.clear(); |
| | 1013 | + self.jump_time = None; |
| | 1014 | + ev_loop.request_redraw(); |
| | 1015 | + } else if !self.search_query.is_empty() { |
| | 1016 | + // Clear search filter second |
| 961 | self.search_query.clear(); | 1017 | self.search_query.clear(); |
| 962 | ev_loop.request_redraw(); | 1018 | ev_loop.request_redraw(); |
| 963 | } else { | 1019 | } else { |
@@ -1015,20 +1071,35 @@ impl App { |
| 1015 | self.sort_processes(sort_field); | 1071 | self.sort_processes(sort_field); |
| 1016 | ev_loop.request_redraw(); | 1072 | ev_loop.request_redraw(); |
| 1017 | } | 1073 | } |
| 1018 | - // Freeze mode toggle (pause process list updates) | 1074 | + // Freeze mode toggle (Alt+f) |
| 1019 | - Key::Char('f') => { | 1075 | + Key::Char('f') if key_event.modifiers.alt => { |
| 1020 | self.frozen = !self.frozen; | 1076 | self.frozen = !self.frozen; |
| 1021 | ev_loop.request_redraw(); | 1077 | ev_loop.request_redraw(); |
| 1022 | } | 1078 | } |
| 1023 | - // Process list navigation | 1079 | + // Process list navigation (arrow keys only - j/k go to fuzzy jump) |
| 1024 | - Key::Down | Key::Char('j') => { | 1080 | + Key::Down => { |
| 1025 | self.process_list.select_next(&self.processes); | 1081 | self.process_list.select_next(&self.processes); |
| 1026 | ev_loop.request_redraw(); | 1082 | ev_loop.request_redraw(); |
| 1027 | } | 1083 | } |
| 1028 | - Key::Up | Key::Char('k') => { | 1084 | + Key::Up => { |
| 1029 | self.process_list.select_prev(&self.processes); | 1085 | self.process_list.select_prev(&self.processes); |
| 1030 | ev_loop.request_redraw(); | 1086 | ev_loop.request_redraw(); |
| 1031 | } | 1087 | } |
| | 1088 | + // Backspace - delete last character from jump pattern |
| | 1089 | + Key::Backspace if !self.jump_pattern.is_empty() => { |
| | 1090 | + self.jump_pattern.pop(); |
| | 1091 | + self.jump_time = Some(Instant::now()); |
| | 1092 | + // Re-run match with shorter pattern |
| | 1093 | + if !self.jump_pattern.is_empty() { |
| | 1094 | + if let Some(idx) = self.find_fuzzy_match(&self.jump_pattern.clone()) { |
| | 1095 | + if idx < self.processes.len() { |
| | 1096 | + let pid = self.processes[idx].pid; |
| | 1097 | + self.process_list.select_by_index(idx, pid); |
| | 1098 | + } |
| | 1099 | + } |
| | 1100 | + } |
| | 1101 | + ev_loop.request_redraw(); |
| | 1102 | + } |
| 1032 | Key::Home => { | 1103 | Key::Home => { |
| 1033 | self.process_list.select_first(&self.processes); | 1104 | self.process_list.select_first(&self.processes); |
| 1034 | ev_loop.request_redraw(); | 1105 | ev_loop.request_redraw(); |
@@ -1064,6 +1135,11 @@ impl App { |
| 1064 | ev_loop.request_redraw(); | 1135 | ev_loop.request_redraw(); |
| 1065 | } | 1136 | } |
| 1066 | } | 1137 | } |
| | 1138 | + // Type-to-jump fuzzy matching (lowercase letters only, no modifiers) |
| | 1139 | + Key::Char(c) if c.is_ascii_lowercase() && !key_event.modifiers.alt && !key_event.modifiers.ctrl => { |
| | 1140 | + self.handle_fuzzy_jump(c); |
| | 1141 | + ev_loop.request_redraw(); |
| | 1142 | + } |
| 1067 | _ => {} | 1143 | _ => {} |
| 1068 | } | 1144 | } |
| 1069 | } | 1145 | } |
@@ -1074,6 +1150,15 @@ impl App { |
| 1074 | } | 1150 | } |
| 1075 | | 1151 | |
| 1076 | InputEvent::Idle => { | 1152 | InputEvent::Idle => { |
| | 1153 | + // Clear stale jump pattern (1.5s timeout) |
| | 1154 | + if let Some(t) = self.jump_time { |
| | 1155 | + if t.elapsed().as_millis() > 1500 && !self.jump_pattern.is_empty() { |
| | 1156 | + self.jump_pattern.clear(); |
| | 1157 | + self.jump_time = None; |
| | 1158 | + ev_loop.request_redraw(); |
| | 1159 | + } |
| | 1160 | + } |
| | 1161 | + |
| 1077 | if self.last_refresh.elapsed().as_secs_f64() >= self.refresh_interval { | 1162 | if self.last_refresh.elapsed().as_secs_f64() >= self.refresh_interval { |
| 1078 | if self.daemon_available { | 1163 | if self.daemon_available { |
| 1079 | self.refresh_data(); | 1164 | self.refresh_data(); |
@@ -1148,3 +1233,23 @@ fn format_rate(bytes_per_sec: f64) -> String { |
| 1148 | format!("{:.0} B/s", bytes_per_sec) | 1233 | format!("{:.0} B/s", bytes_per_sec) |
| 1149 | } | 1234 | } |
| 1150 | } | 1235 | } |
| | 1236 | + |
| | 1237 | +/// Fuzzy match: check if all characters of pattern appear in target in order. |
| | 1238 | +/// Example: "grtrm" matches "garterm" because g-a-r-t-e-r-m contains g-r-t-r-m in order. |
| | 1239 | +fn fuzzy_match(target: &str, pattern: &str) -> bool { |
| | 1240 | + let mut pattern_chars = pattern.chars().peekable(); |
| | 1241 | + |
| | 1242 | + for c in target.chars() { |
| | 1243 | + if let Some(&p) = pattern_chars.peek() { |
| | 1244 | + if c == p { |
| | 1245 | + pattern_chars.next(); |
| | 1246 | + } |
| | 1247 | + } else { |
| | 1248 | + // All pattern chars matched |
| | 1249 | + return true; |
| | 1250 | + } |
| | 1251 | + } |
| | 1252 | + |
| | 1253 | + // Check if all pattern chars were consumed |
| | 1254 | + pattern_chars.peek().is_none() |
| | 1255 | +} |