From 0d789fb97c563946b7627b7db0cf476dde59fbec Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Mon, 17 Nov 2025 11:24:32 -0800 Subject: [PATCH] Add TUI improvements: CPU averaging, max memory tracking, and fuzzy process search (#23) This commit implements several major improvements to the TUI experience: 1. CPU Average Display in Main Window - Show average CPU usage over monitoring period alongside current value - Format: "CPU avg (now: 45.2% | avg: 52.3%)" - Helps identify sustained vs momentary CPU spikes 2. Max Memory Tracking in Process Details Modal - Track and display peak memory usage since monitoring started - Shown as "Max Memory: 67.8 MB" in yellow for emphasis - Helps identify memory leaks and usage patterns - Resets when switching to different process 3. Fuzzy Process Search - Press / to activate search mode with bordered search box - Type to fuzzy-match process names (case-insensitive) - Press Enter to auto-select first result - Navigate results with arrow keys while typing - Press c to clear filter - Press / again to edit existing search Search box features: - Yellow bordered box for high visibility - Active mode: "Search: query_" - Filter mode: "Filter: query (press / to edit, c to clear)" Technical implementation: - Centralized filtering with get_filtered_sorted_indices() - Consistent filtering across display, navigation, mouse, and auto-scroll - Proper content area offset calculation for search box - Real-time filtering as user types 4. Code Quality Improvements - Created ProcessDisplayParams and ProcessKeyParams structs - Created MemoryIoParams struct for process modal rendering - Reduced function arguments to stay under clippy limits - Exported get_filtered_sorted_indices for reuse Files Modified: - socktop/src/app.rs: Search state, auto-scroll with filtering, max memory tracking - socktop/src/ui/cpu.rs: CPU average calculation and display - socktop/src/ui/processes.rs: Fuzzy search, filtering, parameter structs - socktop/src/ui/modal.rs: Updated help modal with new shortcuts - socktop/src/ui/modal_process.rs: Max memory display, MemoryIoParams struct - socktop/src/ui/modal_types.rs: Added max_mem_bytes field Testing: - All tests pass - No clippy warnings - Cargo fmt applied - Tested search, navigation, mouse clicks, and auto-scroll - Verified on both filtered and unfiltered process lists Breaking Changes: - None (all changes are additive features) Closes: (performance monitoring improvements) --- socktop/src/app.rs | 166 ++++++++++++++----- socktop/src/ui/cpu.rs | 13 +- socktop/src/ui/modal.rs | 15 +- socktop/src/ui/modal_process.rs | 93 +++++++---- socktop/src/ui/modal_types.rs | 1 + socktop/src/ui/processes.rs | 277 ++++++++++++++++++++------------ 6 files changed, 385 insertions(+), 180 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index 9f35a79..95f6132 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -29,13 +29,14 @@ use crate::ui::cpu::{ }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ - ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection, + ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_with_selection, processes_handle_mouse_with_selection, }; use crate::ui::{ disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark, swap::draw_swap, }; + use socktop_connector::{ AgentRequest, AgentResponse, SocktopConnector, connect_to_socktop_agent, connect_to_socktop_agent_with_tls, @@ -84,6 +85,10 @@ pub struct App { pub selected_process_index: Option, // Index in the visible/sorted list prev_selected_process_pid: Option, // Track previous selection to detect changes + // Process search state + pub process_search_active: bool, + pub process_search_query: String, + last_procs_poll: Instant, last_disks_poll: Instant, procs_interval: Duration, @@ -99,7 +104,8 @@ pub struct App { pub process_io_write_history: VecDeque, // Disk write DELTA history in bytes (last 60 samples) last_io_read_bytes: Option, // Previous read bytes for delta calculation last_io_write_bytes: Option, // Previous write bytes for delta calculation - pub process_details_unsupported: bool, // Track if agent doesn't support process details + pub max_process_mem_bytes: u64, // Maximum memory usage observed for current process + pub process_details_unsupported: bool, // Track if agent doesn't support process details last_process_details_poll: Instant, last_journal_poll: Instant, process_details_interval: Duration, @@ -146,6 +152,8 @@ impl App { selected_process_pid: None, selected_process_index: None, prev_selected_process_pid: None, + process_search_active: false, + process_search_query: String::new(), last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -163,6 +171,7 @@ impl App { process_io_write_history: VecDeque::with_capacity(600), last_io_read_bytes: None, last_io_write_bytes: None, + max_process_mem_bytes: 0, process_details_unsupported: false, last_process_details_poll: Instant::now() .checked_sub(Duration::from_secs(10)) @@ -649,6 +658,53 @@ impl App { } } + // Handle search mode + if self.process_search_active { + match k.code { + KeyCode::Esc => { + // Exit search mode + self.process_search_active = false; + self.process_search_query.clear(); + continue; + } + KeyCode::Enter => { + // Exit search mode, keep filter active, and auto-select first result + self.process_search_active = false; + + // Auto-select first filtered result + if let Some(m) = self.last_metrics.as_ref() { + let idxs = get_filtered_sorted_indices( + m, + &self.process_search_query, + self.procs_sort_by, + ); + if !idxs.is_empty() { + let first_idx = idxs[0]; + self.selected_process_index = Some(first_idx); + self.selected_process_pid = + Some(m.top_processes[first_idx].pid); + } + } + continue; + } + KeyCode::Backspace => { + self.process_search_query.pop(); + continue; + } + KeyCode::Char(c) => { + self.process_search_query.push(c); + continue; + } + KeyCode::Up | KeyCode::Down => { + // Allow arrow keys to navigate even while in search mode + // Fall through to normal navigation handling + } + _ => { + continue; // Block other keys in search mode + } + } + } + // Normal key handling (only if no modal is active or modal didn't consume the key) if matches!( k.code, @@ -657,6 +713,24 @@ impl App { self.should_quit = true; } + // Activate search mode on '/' (clears query if starting new search, or edits existing) + if matches!(k.code, KeyCode::Char('/')) { + self.process_search_active = true; + // Don't clear query - allow editing existing search + continue; + } + + // Clear search filter on 'c' or 'C' (when not in search mode) + if matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')) + && !self.process_search_query.is_empty() + && !self.process_search_active + { + self.process_search_query.clear(); + self.selected_process_pid = None; + self.selected_process_index = None; + continue; + } + // Show About modal on 'a' or 'A' if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) { self.modal_manager.push_modal(ModalType::About); @@ -694,6 +768,7 @@ impl App { key: k, metrics: self.last_metrics.as_ref(), sort_by: self.procs_sort_by, + search_query: &self.process_search_query, }) } else { false @@ -711,41 +786,39 @@ impl App { // Auto-scroll to keep selected process visible if let (Some(selected_idx), Some(p_area)) = (self.selected_process_index, self.last_procs_area) + && let Some(m) = self.last_metrics.as_ref() { - // Calculate viewport size (excluding borders and header) - let viewport_rows = p_area.height.saturating_sub(3) as usize; // borders (2) + header (1) + // Get filtered and sorted indices (same as display) + let idxs = get_filtered_sorted_indices( + m, + &self.process_search_query, + self.procs_sort_by, + ); - // Build sorted index list to find display position - if let Some(m) = self.last_metrics.as_ref() { - let mut idxs: Vec = (0..m.top_processes.len()).collect(); - match self.procs_sort_by { - ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].cpu_usage; - let bb = m.top_processes[b].cpu_usage; - bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) - }), - ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].mem_bytes; - let bb = m.top_processes[b].mem_bytes; - bb.cmp(&aa) - }), - } - - // Find the display position of the selected process - if let Some(display_pos) = - idxs.iter().position(|&idx| idx == selected_idx) + // Find the display position of the selected process in filtered list + if let Some(display_pos) = + idxs.iter().position(|&idx| idx == selected_idx) + { + // Calculate viewport size + // Account for: borders (2) + header (1) + search box if active (3) + let extra_rows = if self.process_search_active + || !self.process_search_query.is_empty() { - // Adjust scroll offset to keep selection visible - if display_pos < self.procs_scroll_offset { - // Selection is above viewport, scroll up - self.procs_scroll_offset = display_pos; - } else if display_pos - >= self.procs_scroll_offset + viewport_rows - { - // Selection is below viewport, scroll down - self.procs_scroll_offset = - display_pos.saturating_sub(viewport_rows - 1); - } + 3 // search box with border + } else { + 0 + }; + let viewport_rows = + p_area.height.saturating_sub(3 + extra_rows) as usize; + + // Adjust scroll offset to keep selection visible + if display_pos < self.procs_scroll_offset { + // Selection is above viewport, scroll up + self.procs_scroll_offset = display_pos; + } else if display_pos >= self.procs_scroll_offset + viewport_rows { + // Selection is below viewport, scroll down + self.procs_scroll_offset = + display_pos.saturating_sub(viewport_rows - 1); } } } @@ -846,6 +919,7 @@ impl App { total_rows: mm.top_processes.len(), metrics: self.last_metrics.as_ref(), sort_by: self.procs_sort_by, + search_query: &self.process_search_query, }) { self.procs_sort_by = new_sort; @@ -931,6 +1005,11 @@ impl App { let mem_bytes = details.process.mem_bytes; push_capped(&mut self.process_mem_history, mem_bytes, 600); + // Track maximum memory usage + if mem_bytes > self.max_process_mem_bytes { + self.max_process_mem_bytes = mem_bytes; + } + // I/O bytes from agent are cumulative, calculate deltas if let Some(read) = details.process.read_bytes { let delta = if let Some(last) = self.last_io_read_bytes @@ -1036,6 +1115,7 @@ impl App { self.process_io_write_history.clear(); self.last_io_read_bytes = None; self.last_io_write_bytes = None; + self.max_process_mem_bytes = 0; self.process_details_unsupported = false; } @@ -1195,11 +1275,15 @@ impl App { crate::ui::processes::draw_top_processes( f, procs_area, - self.last_metrics.as_ref(), - self.procs_scroll_offset, - self.procs_sort_by, - self.selected_process_pid, - self.selected_process_index, + crate::ui::processes::ProcessDisplayParams { + metrics: self.last_metrics.as_ref(), + scroll_offset: self.procs_scroll_offset, + sort_by: self.procs_sort_by, + selected_process_pid: self.selected_process_pid, + selected_process_index: self.selected_process_index, + search_query: &self.process_search_query, + search_active: self.process_search_active, + }, ); // Render modals on top of everything else @@ -1216,6 +1300,7 @@ impl App { io_read: &self.process_io_read_history, io_write: &self.process_io_write_history, }, + max_mem_bytes: self.max_process_mem_bytes, unsupported: self.process_details_unsupported, }, ); @@ -1244,6 +1329,8 @@ impl Default for App { selected_process_pid: None, selected_process_index: None, prev_selected_process_pid: None, + process_search_active: false, + process_search_query: String::new(), last_procs_poll: Instant::now() .checked_sub(Duration::from_secs(2)) .unwrap_or_else(Instant::now), // trigger immediately on first loop @@ -1261,6 +1348,7 @@ impl Default for App { process_io_write_history: VecDeque::with_capacity(600), last_io_read_bytes: None, last_io_write_bytes: None, + max_process_mem_bytes: 0, process_details_unsupported: false, last_process_details_poll: Instant::now() .checked_sub(Duration::from_secs(10)) diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index 831ae9b..f61cbe7 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -240,8 +240,19 @@ pub fn draw_cpu_avg_graph( hist: &std::collections::VecDeque, m: Option<&Metrics>, ) { + // Calculate average CPU over the monitoring period + let avg_cpu = if !hist.is_empty() { + let sum: u64 = hist.iter().sum(); + sum as f64 / hist.len() as f64 + } else { + 0.0 + }; + let title = if let Some(mm) = m { - format!("CPU avg (now: {:>5.1}%)", mm.cpu_total) + format!( + "CPU avg (now: {:>5.1}% | avg: {:>5.1}%)", + mm.cpu_total, avg_cpu + ) } else { "CPU avg".into() }; diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index c687f09..f4a236a 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -271,7 +271,7 @@ impl ModalManager { } ModalType::Help => { // Help modal uses medium size - self.centered_rect(80, 80, area) + self.centered_rect(70, 80, area) } _ => { // Other modals use smaller size @@ -477,12 +477,20 @@ GLOBAL q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help PROCESS LIST + / .............. Start/edit fuzzy search + c/C ............ Clear search filter ↑/↓ ............ Select/navigate processes Enter .......... Open Process Details x/X ............ Clear selection Click header ... Sort by column (CPU/Mem) Click row ...... Select process +SEARCH MODE (after pressing /) + Type ........... Enter search query (fuzzy match) + ↑/↓ ............ Navigate results while typing + Esc ............ Cancel search and clear filter + Enter .......... Apply filter and select first result + CPU PER-CORE ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down Home/End ....... Jump to first/last core @@ -493,8 +501,11 @@ PROCESS DETAILS MODAL j/k ............ Scroll threads ↓/↑ (1 line) d/u ............ Scroll threads ↓/↑ (10 lines) [ / ] .......... Scroll journal ↑/↓ - Esc/Enter ...... Close modal"; + Esc/Enter ...... Close modal +MODAL NAVIGATION + Tab/→ .......... Next button │ Shift+Tab/← ... Previous button + Enter .......... Confirm/OK │ Esc ............ Cancel/Close"; // Render the border block let block = Block::default() .title(" Hotkey Help ") diff --git a/socktop/src/ui/modal_process.rs b/socktop/src/ui/modal_process.rs index 8388cd8..fb9f6d5 100644 --- a/socktop/src/ui/modal_process.rs +++ b/socktop/src/ui/modal_process.rs @@ -16,6 +16,15 @@ use super::modal_format::{calculate_dynamic_y_max, format_uptime, normalize_cpu_ use super::modal_types::{ProcessModalData, ScatterPlotParams}; use super::theme::{MODAL_BG, MODAL_HINT_FG, PROCESS_DETAILS_ACCENT}; +/// Parameters for rendering memory and I/O graphs +struct MemoryIoParams<'a> { + process: &'a socktop_connector::DetailedProcessInfo, + mem_history: &'a std::collections::VecDeque, + io_read_history: &'a std::collections::VecDeque, + io_write_history: &'a std::collections::VecDeque, + max_mem_bytes: u64, +} + impl ModalManager { pub(super) fn render_process_details( &mut self, @@ -57,10 +66,13 @@ impl ModalManager { self.render_middle_row_with_metadata( f, main_chunks[1], - &details.process, - data.history.mem, - data.history.io_read, - data.history.io_write, + MemoryIoParams { + process: &details.process, + mem_history: data.history.mem, + io_read_history: data.history.io_read, + io_write_history: data.history.io_write, + max_mem_bytes: data.max_mem_bytes, + }, ); // Bottom Row: Journal Events @@ -169,22 +181,14 @@ impl ModalManager { f.render_widget(plot_block, area); } - fn render_memory_io_graphs( - &self, - f: &mut Frame, - area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, - ) { + fn render_memory_io_graphs(&self, f: &mut Frame, area: Rect, params: MemoryIoParams) { let graphs_block = Block::default() .title("Memory & I/O") .borders(Borders::ALL) .padding(Padding::horizontal(1)); - let mem_mb = process.mem_bytes as f64 / 1_048_576.0; - let virtual_mb = process.virtual_mem_bytes as f64 / 1_048_576.0; + let mem_mb = params.process.mem_bytes as f64 / 1_048_576.0; + let virtual_mb = params.process.virtual_mem_bytes as f64 / 1_048_576.0; let mut content_lines = vec![ Line::from(vec![ @@ -198,8 +202,12 @@ impl ModalManager { ]; // Add memory sparkline if we have history - if mem_history.len() >= 2 { - let mem_data: Vec = mem_history.iter().map(|&bytes| bytes / 1_048_576).collect(); // Convert to MB + if params.mem_history.len() >= 2 { + let mem_data: Vec = params + .mem_history + .iter() + .map(|&bytes| bytes / 1_048_576) + .collect(); // Convert to MB let max_mem = mem_data.iter().copied().max().unwrap_or(1).max(1); // Create mini sparkline using Unicode blocks @@ -228,8 +236,23 @@ impl ModalManager { Span::raw(format!("{virtual_mb:.1} MB")), ])); + // Add max memory if we have tracked it + if params.max_mem_bytes > 0 { + let max_mb = params.max_mem_bytes as f64 / 1_048_576.0; + content_lines.push(Line::from(vec![ + Span::styled( + " Max Memory: ", + Style::default().add_modifier(Modifier::DIM), + ), + Span::styled( + format!("{max_mb:.1} MB"), + Style::default().fg(Color::Yellow), + ), + ])); + } + // Add shared memory if available - if let Some(shared_bytes) = process.shared_mem_bytes { + if let Some(shared_bytes) = params.process.shared_mem_bytes { let shared_mb = shared_bytes as f64 / 1_048_576.0; content_lines.push(Line::from(vec![ Span::styled(" Shared: ", Style::default().add_modifier(Modifier::DIM)), @@ -244,7 +267,7 @@ impl ModalManager { ])); // Add I/O stats if available - match (process.read_bytes, process.write_bytes) { + match (params.process.read_bytes, params.process.write_bytes) { (Some(read), Some(write)) => { let read_mb = read as f64 / 1_048_576.0; let write_mb = write as f64 / 1_048_576.0; @@ -254,8 +277,9 @@ impl ModalManager { ])); // Add read I/O sparkline if we have history - if io_read_history.len() >= 2 { - let read_data: Vec = io_read_history + if params.io_read_history.len() >= 2 { + let read_data: Vec = params + .io_read_history .iter() .map(|&bytes| bytes / 1_048_576) .collect(); // Convert to MB @@ -282,8 +306,9 @@ impl ModalManager { ])); // Add write I/O sparkline if we have history - if io_write_history.len() >= 2 { - let write_data: Vec = io_write_history + if params.io_write_history.len() >= 2 { + let write_data: Vec = params + .io_write_history .iter() .map(|&bytes| bytes / 1_048_576) .collect(); // Convert to MB @@ -842,7 +867,7 @@ impl ModalManager { let total: f32 = cpu_history.iter().sum(); normalize_cpu_usage(total / cpu_history.len() as f32, thread_count) }; - let title = format!("📊 CPU avg: {avg_cpu:.1}% (now: {current_cpu:.1}%)"); + let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)"); // Similar to main CPU rendering but for process CPU if cpu_history.len() < 2 { @@ -913,10 +938,7 @@ impl ModalManager { &mut self, f: &mut Frame, area: Rect, - process: &socktop_connector::DetailedProcessInfo, - mem_history: &std::collections::VecDeque, - io_read_history: &std::collections::VecDeque, - io_write_history: &std::collections::VecDeque, + params: MemoryIoParams, ) { // Split middle row: Memory/IO (30%) | Thread table (40%) | Command + Metadata (30%) let middle_chunks = Layout::default() @@ -931,13 +953,16 @@ impl ModalManager { self.render_memory_io_graphs( f, middle_chunks[0], - process, - mem_history, - io_read_history, - io_write_history, + MemoryIoParams { + process: params.process, + mem_history: params.mem_history, + io_read_history: params.io_read_history, + io_write_history: params.io_write_history, + max_mem_bytes: params.max_mem_bytes, + }, ); - self.render_thread_table(f, middle_chunks[1], process); - self.render_command_and_metadata(f, middle_chunks[2], process); + self.render_thread_table(f, middle_chunks[1], params.process); + self.render_command_and_metadata(f, middle_chunks[2], params.process); } fn render_command_and_metadata( diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs index c101d2f..eb3a850 100644 --- a/socktop/src/ui/modal_types.rs +++ b/socktop/src/ui/modal_types.rs @@ -15,6 +15,7 @@ pub struct ProcessModalData<'a> { pub details: Option<&'a socktop_connector::ProcessMetricsResponse>, pub journal: Option<&'a socktop_connector::JournalResponse>, pub history: ProcessHistoryData<'a>, + pub max_mem_bytes: u64, pub unsupported: bool, } diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index 2c99e64..b0d278d 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -18,6 +18,66 @@ use crate::ui::theme::{ }; use crate::ui::util::human; +/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive) +fn fuzzy_match(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + let haystack_lower = haystack.to_lowercase(); + let needle_lower = needle.to_lowercase(); + let mut haystack_chars = haystack_lower.chars(); + + for needle_char in needle_lower.chars() { + if !haystack_chars.any(|c| c == needle_char) { + return false; + } + } + true +} + +/// Get filtered and sorted process indices based on search query and sort order +pub fn get_filtered_sorted_indices( + metrics: &Metrics, + search_query: &str, + sort_by: ProcSortBy, +) -> Vec { + // Filter processes by search query (fuzzy match) + let mut filtered_idxs: Vec = if search_query.is_empty() { + (0..metrics.top_processes.len()).collect() + } else { + (0..metrics.top_processes.len()) + .filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)) + .collect() + }; + + // Sort filtered rows + match sort_by { + ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| { + let aa = metrics.top_processes[a].cpu_usage; + let bb = metrics.top_processes[b].cpu_usage; + bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) + }), + ProcSortBy::MemDesc => filtered_idxs.sort_by(|&a, &b| { + let aa = metrics.top_processes[a].mem_bytes; + let bb = metrics.top_processes[b].mem_bytes; + bb.cmp(&aa) + }), + } + + filtered_idxs +} + +/// Parameters for drawing the top processes table +pub struct ProcessDisplayParams<'a> { + pub metrics: Option<&'a Metrics>, + pub scroll_offset: usize, + pub sort_by: ProcSortBy, + pub selected_process_pid: Option, + pub selected_process_index: Option, + pub search_query: &'a str, + pub search_active: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ProcSortBy { #[default] @@ -34,30 +94,61 @@ const COLS: [Constraint; 5] = [ Constraint::Length(8), // Mem % ]; -pub fn draw_top_processes( - f: &mut ratatui::Frame<'_>, - area: Rect, - m: Option<&Metrics>, - scroll_offset: usize, - sort_by: ProcSortBy, - selected_process_pid: Option, - selected_process_index: Option, -) { +pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: ProcessDisplayParams) { // Draw outer block and title - let Some(mm) = m else { return }; + let Some(mm) = params.metrics else { return }; let total = mm.process_count.unwrap_or(mm.top_processes.len()); let block = Block::default() .borders(Borders::ALL) .title(format!("Top Processes ({total} total)")); f.render_widget(block, area); - // Inner area and content area (reserve 2 columns for scrollbar) + // Inner area (reserve space for search box if active) let inner = Rect { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), height: area.height.saturating_sub(2), }; + + // Draw search box if active + let content_start_y = if params.search_active || !params.search_query.is_empty() { + let search_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 3, // Height for border + content + }; + + let search_text = if params.search_active { + format!("Search: {}_", params.search_query) + } else { + format!( + "Filter: {} (press / to edit, c to clear)", + params.search_query + ) + }; + + let search_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + let search_paragraph = Paragraph::new(search_text) + .block(search_block) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(search_paragraph, search_area); + + inner.y + 3 + } else { + inner.y + }; + + // Content area (reserve 2 columns for scrollbar) + let inner = Rect { + x: inner.x, + y: content_start_y, + width: inner.width, + height: inner.height.saturating_sub(content_start_y - (area.y + 1)), + }; if inner.height < 1 || inner.width < 3 { return; } @@ -68,27 +159,15 @@ pub fn draw_top_processes( height: inner.height, }; - // Sort rows (by CPU% or Mem bytes), descending. - let mut idxs: Vec = (0..mm.top_processes.len()).collect(); - match sort_by { - ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { - let aa = mm.top_processes[a].cpu_usage; - let bb = mm.top_processes[b].cpu_usage; - bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) - }), - ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { - let aa = mm.top_processes[a].mem_bytes; - let bb = mm.top_processes[b].mem_bytes; - bb.cmp(&aa) - }), - } + // Get filtered and sorted indices + let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by); // Scrolling let total_rows = idxs.len(); let header_rows = 1usize; let viewport_rows = content.height.saturating_sub(header_rows as u16) as usize; let max_off = total_rows.saturating_sub(viewport_rows); - let offset = scroll_offset.min(max_off); + let offset = params.scroll_offset.min(max_off); let show_n = total_rows.saturating_sub(offset).min(viewport_rows); // Build visible rows @@ -122,9 +201,9 @@ pub fn draw_top_processes( }; // Check if this process is selected - prioritize PID matching - let is_selected = if let Some(selected_pid) = selected_process_pid { + let is_selected = if let Some(selected_pid) = params.selected_process_pid { selected_pid == p.pid - } else if let Some(selected_idx) = selected_process_index { + } else if let Some(selected_idx) = params.selected_process_index { selected_idx == ix // ix is the absolute index in the sorted list } else { false @@ -153,11 +232,11 @@ pub fn draw_top_processes( }); // Header with sort indicator - let cpu_hdr = match sort_by { + let cpu_hdr = match params.sort_by { ProcSortBy::CpuDesc => "CPU % •", _ => "CPU %", }; - let mem_hdr = match sort_by { + let mem_hdr = match params.sort_by { ProcSortBy::MemDesc => "Mem •", _ => "Mem", }; @@ -174,9 +253,9 @@ pub fn draw_top_processes( f.render_widget(table, content); // Draw tooltip if a process is selected - if let Some(selected_pid) = selected_process_pid { + if let Some(selected_pid) = params.selected_process_pid { // Find the selected process to get its name - let process_info = if let Some(metrics) = m { + let process_info = if let Some(metrics) = params.metrics { metrics .top_processes .iter() @@ -261,6 +340,7 @@ pub struct ProcessKeyParams<'a> { pub key: crossterm::event::KeyEvent, pub metrics: Option<&'a Metrics>, pub sort_by: ProcSortBy, + pub search_query: &'a str, } /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality @@ -278,79 +358,71 @@ pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { match params.key.code { KeyCode::Up => { - // Build sorted index list to navigate through display order + // Navigate through filtered and sorted results if let Some(m) = params.metrics { - let mut idxs: Vec = (0..m.top_processes.len()).collect(); - match params.sort_by { - ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].cpu_usage; - let bb = m.top_processes[b].cpu_usage; - bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) - }), - ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].mem_bytes; - let bb = m.top_processes[b].mem_bytes; - bb.cmp(&aa) - }), - } + let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - if params.selected_process_index.is_none() || params.selected_process_pid.is_none() + if idxs.is_empty() { + // No filtered results, clear selection + *params.selected_process_index = None; + *params.selected_process_pid = None; + } else if params.selected_process_index.is_none() + || params.selected_process_pid.is_none() { - // No selection - select the first process in sorted order - if !idxs.is_empty() { + // No selection - select the first process in filtered/sorted order + let first_idx = idxs[0]; + *params.selected_process_index = Some(first_idx); + *params.selected_process_pid = Some(m.top_processes[first_idx].pid); + } else if let Some(current_idx) = *params.selected_process_index { + // Find current position in filtered/sorted list + if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) { + if pos > 0 { + // Move up in filtered/sorted list + let new_idx = idxs[pos - 1]; + *params.selected_process_index = Some(new_idx); + *params.selected_process_pid = Some(m.top_processes[new_idx].pid); + } + } else { + // Current selection not in filtered list, select first result let first_idx = idxs[0]; *params.selected_process_index = Some(first_idx); *params.selected_process_pid = Some(m.top_processes[first_idx].pid); } - } else if let Some(current_idx) = *params.selected_process_index { - // Find current position in sorted list - if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) - && pos > 0 - { - // Move up in sorted list - let new_idx = idxs[pos - 1]; - *params.selected_process_index = Some(new_idx); - *params.selected_process_pid = Some(m.top_processes[new_idx].pid); - } } } true // Handled } KeyCode::Down => { - // Build sorted index list to navigate through display order + // Navigate through filtered and sorted results if let Some(m) = params.metrics { - let mut idxs: Vec = (0..m.top_processes.len()).collect(); - match params.sort_by { - ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].cpu_usage; - let bb = m.top_processes[b].cpu_usage; - bb.partial_cmp(&aa).unwrap_or(Ordering::Equal) - }), - ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].mem_bytes; - let bb = m.top_processes[b].mem_bytes; - bb.cmp(&aa) - }), - } + let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - if params.selected_process_index.is_none() || params.selected_process_pid.is_none() + if idxs.is_empty() { + // No filtered results, clear selection + *params.selected_process_index = None; + *params.selected_process_pid = None; + } else if params.selected_process_index.is_none() + || params.selected_process_pid.is_none() { - // No selection - select the first process in sorted order - if !idxs.is_empty() { + // No selection - select the first process in filtered/sorted order + let first_idx = idxs[0]; + *params.selected_process_index = Some(first_idx); + *params.selected_process_pid = Some(m.top_processes[first_idx].pid); + } else if let Some(current_idx) = *params.selected_process_index { + // Find current position in filtered/sorted list + if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) { + if pos + 1 < idxs.len() { + // Move down in filtered/sorted list + let new_idx = idxs[pos + 1]; + *params.selected_process_index = Some(new_idx); + *params.selected_process_pid = Some(m.top_processes[new_idx].pid); + } + } else { + // Current selection not in filtered list, select first result let first_idx = idxs[0]; *params.selected_process_index = Some(first_idx); *params.selected_process_pid = Some(m.top_processes[first_idx].pid); } - } else if let Some(current_idx) = *params.selected_process_index { - // Find current position in sorted list - if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx) - && pos + 1 < idxs.len() - { - // Move down in sorted list - let new_idx = idxs[pos + 1]; - *params.selected_process_index = Some(new_idx); - *params.selected_process_pid = Some(m.top_processes[new_idx].pid); - } } } true // Handled @@ -455,6 +527,7 @@ pub struct ProcessMouseParams<'a> { pub total_rows: usize, pub metrics: Option<&'a Metrics>, pub sort_by: ProcSortBy, + pub search_query: &'a str, } /// Enhanced mouse handler that also manages process selection @@ -470,11 +543,19 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti if inner.height == 0 || inner.width <= 2 { return None; } + + // Calculate content area - must match draw_top_processes exactly! + // If search is active or query exists, content starts after search box (3 lines) + let search_active = !params.search_query.is_empty(); + let content_start_y = if search_active { inner.y + 3 } else { inner.y }; + let content = Rect { x: inner.x, - y: inner.y, + y: content_start_y, width: inner.width.saturating_sub(2), - height: inner.height, + height: inner + .height + .saturating_sub(if search_active { 3 } else { 0 }), }; // Scrollbar interactions (click arrows/page/drag) @@ -531,24 +612,12 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti { let clicked_row = (params.mouse.row - data_start_row) as usize; - // Find the actual process using the same sorting logic as the drawing code + // Find the actual process using the same filtering/sorting logic as the drawing code if let Some(m) = params.metrics { - // Create the same sorted index array as in draw_top_processes - let mut idxs: Vec = (0..m.top_processes.len()).collect(); - match params.sort_by { - ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].cpu_usage; - let bb = m.top_processes[b].cpu_usage; - bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal) - }), - ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| { - let aa = m.top_processes[a].mem_bytes; - let bb = m.top_processes[b].mem_bytes; - bb.cmp(&aa) - }), - } + // Use the same filtered and sorted indices as display + let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by); - // Calculate which process was actually clicked based on sorted order + // Calculate which process was actually clicked based on filtered/sorted order let visible_process_position = *params.scroll_offset + clicked_row; if visible_process_position < idxs.len() { let actual_process_index = idxs[visible_process_position];