@@ -54,6 +54,10 @@ pub struct App { |
| 54 | 54 | search_query: String, |
| 55 | 55 | /// Help overlay visible |
| 56 | 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 | 61 | status: Option<StatusInfo>, |
| 58 | 62 | cpu_stats: Option<CpuStats>, |
| 59 | 63 | memory_stats: Option<MemoryStats>, |
@@ -130,6 +134,8 @@ impl App { |
| 130 | 134 | search_mode: false, |
| 131 | 135 | search_query: String::new(), |
| 132 | 136 | show_help: false, |
| 137 | + jump_pattern: String::new(), |
| 138 | + jump_time: None, |
| 133 | 139 | status: None, |
| 134 | 140 | cpu_stats: None, |
| 135 | 141 | memory_stats: None, |
@@ -410,19 +416,19 @@ impl App { |
| 410 | 416 | |
| 411 | 417 | let bindings = [ |
| 412 | 418 | ("?", "Toggle this help"), |
| 413 | | - ("q / Esc", "Quit / clear search"), |
| 419 | + ("q / Esc", "Quit / clear"), |
| 414 | 420 | ("", ""), |
| 415 | 421 | ("1-4", "Switch tab"), |
| 416 | 422 | ("Tab", "Cycle tabs"), |
| 417 | 423 | ("", ""), |
| 418 | | - ("j / \u{2193}", "Next process"), |
| 419 | | - ("k / \u{2191}", "Previous process"), |
| 420 | | - ("Home", "First process"), |
| 421 | | - ("End", "Last process"), |
| 424 | + ("\u{2191} / \u{2193}", "Navigate list"), |
| 425 | + ("Home / End", "First / last"), |
| 422 | 426 | ("PgUp/PgDn", "Jump 10 rows"), |
| 423 | 427 | ("", ""), |
| 424 | | - ("f", "Freeze list"), |
| 425 | | - ("/", "Search by name"), |
| 428 | + ("Alt+f", "Freeze list"), |
| 429 | + ("/", "Search filter"), |
| 430 | + ("a-z", "Fuzzy jump"), |
| 431 | + ("Backspace", "Delete jump char"), |
| 426 | 432 | ("", ""), |
| 427 | 433 | ("K", "Kill (SIGTERM)"), |
| 428 | 434 | ("X", "Kill (SIGKILL)"), |
@@ -719,6 +725,22 @@ impl App { |
| 719 | 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 | 744 | // Filter processes if search query is active |
| 723 | 745 | let display_processes: Vec<ProcessInfo> = if self.search_query.is_empty() { |
| 724 | 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 | 926 | /// Run the GUI event loop. |
| 876 | 927 | pub fn run(mut self) -> Result<()> { |
| 877 | 928 | let config = EventLoopConfig { |
@@ -956,8 +1007,13 @@ impl App { |
| 956 | 1007 | // Normal mode key handling |
| 957 | 1008 | match key_event.key { |
| 958 | 1009 | Key::Escape => { |
| 959 | | - if !self.search_query.is_empty() { |
| 960 | | - // Clear search filter first |
| 1010 | + if !self.jump_pattern.is_empty() { |
| 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 | 1017 | self.search_query.clear(); |
| 962 | 1018 | ev_loop.request_redraw(); |
| 963 | 1019 | } else { |
@@ -1015,20 +1071,35 @@ impl App { |
| 1015 | 1071 | self.sort_processes(sort_field); |
| 1016 | 1072 | ev_loop.request_redraw(); |
| 1017 | 1073 | } |
| 1018 | | - // Freeze mode toggle (pause process list updates) |
| 1019 | | - Key::Char('f') => { |
| 1074 | + // Freeze mode toggle (Alt+f) |
| 1075 | + Key::Char('f') if key_event.modifiers.alt => { |
| 1020 | 1076 | self.frozen = !self.frozen; |
| 1021 | 1077 | ev_loop.request_redraw(); |
| 1022 | 1078 | } |
| 1023 | | - // Process list navigation |
| 1024 | | - Key::Down | Key::Char('j') => { |
| 1079 | + // Process list navigation (arrow keys only - j/k go to fuzzy jump) |
| 1080 | + Key::Down => { |
| 1025 | 1081 | self.process_list.select_next(&self.processes); |
| 1026 | 1082 | ev_loop.request_redraw(); |
| 1027 | 1083 | } |
| 1028 | | - Key::Up | Key::Char('k') => { |
| 1084 | + Key::Up => { |
| 1029 | 1085 | self.process_list.select_prev(&self.processes); |
| 1030 | 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 | 1103 | Key::Home => { |
| 1033 | 1104 | self.process_list.select_first(&self.processes); |
| 1034 | 1105 | ev_loop.request_redraw(); |
@@ -1064,6 +1135,11 @@ impl App { |
| 1064 | 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 | 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 | 1162 | if self.last_refresh.elapsed().as_secs_f64() >= self.refresh_interval { |
| 1078 | 1163 | if self.daemon_available { |
| 1079 | 1164 | self.refresh_data(); |
@@ -1148,3 +1233,23 @@ fn format_rate(bytes_per_sec: f64) -> String { |
| 1148 | 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 | +} |