diff --git a/socktop/src/app.rs b/socktop/src/app.rs index bb0153d..9f35a79 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -29,7 +29,8 @@ use crate::ui::cpu::{ }; use crate::ui::modal::{ModalAction, ModalManager, ModalType}; use crate::ui::processes::{ - ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection, + ProcSortBy, ProcessKeyParams, 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, @@ -661,6 +662,11 @@ impl App { self.modal_manager.push_modal(ModalType::About); } + // Show Help modal on 'h' or 'H' + if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) { + self.modal_manager.push_modal(ModalType::Help); + } + // Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End) let sz = terminal.size()?; let area = Rect::new(0, 0, sz.width, sz.height); @@ -681,22 +687,14 @@ impl App { let content = per_core_content_area(top[1]); // First try process selection (only handles arrows if a process is selected) - let process_handled = if let Some(p_area) = self.last_procs_area { - let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1) - let total_rows = self - .last_metrics - .as_ref() - .map(|m| m.top_processes.len()) - .unwrap_or(0); - processes_handle_key_with_selection( - &mut self.procs_scroll_offset, - &mut self.selected_process_pid, - &mut self.selected_process_index, - k, - page, - total_rows, - self.last_metrics.as_ref(), - ) + let process_handled = if self.last_procs_area.is_some() { + processes_handle_key_with_selection(ProcessKeyParams { + selected_process_pid: &mut self.selected_process_pid, + selected_process_index: &mut self.selected_process_index, + key: k, + metrics: self.last_metrics.as_ref(), + sort_by: self.procs_sort_by, + }) } else { false }; @@ -710,6 +708,48 @@ 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) + { + // Calculate viewport size (excluding borders and header) + let viewport_rows = p_area.height.saturating_sub(3) as usize; // borders (2) + header (1) + + // 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) + { + // 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); + } + } + } + } + // Check if process selection changed and clear details if so if self.selected_process_pid != self.prev_selected_process_pid { self.clear_process_details(); diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index bd2e3a1..831ae9b 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -42,8 +42,8 @@ pub fn per_core_content_area(area: Rect) -> Rect { /// Handles key events for per-core CPU bars. pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) { match key.code { - KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1), - KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1), + KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1), + KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1), KeyCode::PageUp => { let step = page_size.max(1); *scroll_offset = scroll_offset.saturating_sub(step); diff --git a/socktop/src/ui/header.rs b/socktop/src/ui/header.rs index 6987176..f190d2b 100644 --- a/socktop/src/ui/header.rs +++ b/socktop/src/ui/header.rs @@ -46,7 +46,7 @@ pub fn draw_header( parts.push(tok_txt.into()); } parts.push(intervals); - parts.push("(a: about, q: quit)".into()); + parts.push("(a: about, h: help, q: quit)".into()); let title = parts.join(" | "); f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area); } diff --git a/socktop/src/ui/modal.rs b/socktop/src/ui/modal.rs index 7b5eadb..c687f09 100644 --- a/socktop/src/ui/modal.rs +++ b/socktop/src/ui/modal.rs @@ -56,6 +56,7 @@ impl ModalManager { ModalButton::Ok } Some(ModalType::About) => ModalButton::Ok, + Some(ModalType::Help) => ModalButton::Ok, Some(ModalType::Confirmation { .. }) => ModalButton::Confirm, Some(ModalType::Info { .. }) => ModalButton::Ok, None => ModalButton::Ok, @@ -68,6 +69,7 @@ impl ModalManager { ModalType::ConnectionError { .. } => ModalButton::Retry, ModalType::ProcessDetails { .. } => ModalButton::Ok, ModalType::About => ModalButton::Ok, + ModalType::Help => ModalButton::Ok, ModalType::Confirmation { .. } => ModalButton::Confirm, ModalType::Info { .. } => ModalButton::Ok, }; @@ -211,6 +213,10 @@ impl ModalManager { self.pop_modal(); ModalAction::Dismiss } + (Some(ModalType::Help), ModalButton::Ok) => { + self.pop_modal(); + ModalAction::Dismiss + } (Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm, (Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel, (Some(ModalType::Info { .. }), ModalButton::Ok) => { @@ -261,7 +267,11 @@ impl ModalManager { } ModalType::About => { // About modal uses medium size - self.centered_rect(60, 60, area) + self.centered_rect(90, 90, area) + } + ModalType::Help => { + // Help modal uses medium size + self.centered_rect(80, 80, area) } _ => { // Other modals use smaller size @@ -287,6 +297,7 @@ impl ModalManager { self.render_process_details(f, modal_area, *pid, data) } ModalType::About => self.render_about(f, modal_area), + ModalType::Help => self.render_help(f, modal_area), ModalType::Confirmation { title, message, @@ -415,7 +426,7 @@ impl ModalManager { let block = Block::default() .title(" About socktop ") .borders(Borders::ALL) - .style(Style::default().bg(Color::Black).fg(Color::Green)); + .style(Style::default().bg(Color::Black).fg(Color::DarkGray)); f.render_widget(block, area); // Calculate inner area manually to avoid any parent styling @@ -423,7 +434,7 @@ impl ModalManager { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), - height: area.height.saturating_sub(3), // Leave room for button at bottom + height: area.height.saturating_sub(2), // Leave room for button at bottom }; // Render content area with explicit black background @@ -438,7 +449,80 @@ impl ModalManager { // Button area let button_area = Rect { x: area.x + 1, - y: area.y + area.height.saturating_sub(3), + y: area.y + area.height.saturating_sub(2), + width: area.width.saturating_sub(2), + height: 1, + }; + + let ok_style = if self.active_button == ModalButton::Ok { + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Blue).bg(Color::Black) + }; + + f.render_widget( + Paragraph::new("[ Enter ] Close") + .style(ok_style) + .alignment(Alignment::Center), + button_area, + ); + } + + fn render_help(&self, f: &mut Frame, area: Rect) { + let help_text = "\ +GLOBAL + q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help + +PROCESS LIST + ↑/↓ ............ Select/navigate processes + Enter .......... Open Process Details + x/X ............ Clear selection + Click header ... Sort by column (CPU/Mem) + Click row ...... Select process + +CPU PER-CORE + ←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down + Home/End ....... Jump to first/last core + +PROCESS DETAILS MODAL + x/X ............ Close modal (all parent modals) + p/P ............ Navigate to parent process + j/k ............ Scroll threads ↓/↑ (1 line) + d/u ............ Scroll threads ↓/↑ (10 lines) + [ / ] .......... Scroll journal ↑/↓ + Esc/Enter ...... Close modal"; + + // Render the border block + let block = Block::default() + .title(" Hotkey Help ") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black).fg(Color::DarkGray)); + f.render_widget(block, area); + + // Calculate inner area manually to avoid any parent styling + let inner_area = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), // Leave room for button at bottom + }; + + // Render content area with explicit black background + f.render_widget( + Paragraph::new(help_text) + .style(Style::default().fg(Color::Cyan).bg(Color::Black)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }), + inner_area, + ); + + // Button area + let button_area = Rect { + x: area.x + 1, + y: area.y + area.height.saturating_sub(2), width: area.width.saturating_sub(2), height: 1, }; diff --git a/socktop/src/ui/modal_types.rs b/socktop/src/ui/modal_types.rs index 1c6de20..c101d2f 100644 --- a/socktop/src/ui/modal_types.rs +++ b/socktop/src/ui/modal_types.rs @@ -39,6 +39,7 @@ pub enum ModalType { pid: u32, }, About, + Help, #[allow(dead_code)] Confirmation { title: String, diff --git a/socktop/src/ui/processes.rs b/socktop/src/ui/processes.rs index d506016..2c99e64 100644 --- a/socktop/src/ui/processes.rs +++ b/socktop/src/ui/processes.rs @@ -254,6 +254,15 @@ fn fmt_cpu_pct(v: f32) -> String { } /// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End) +/// Parameters for process key event handling +pub struct ProcessKeyParams<'a> { + pub selected_process_pid: &'a mut Option, + pub selected_process_index: &'a mut Option, + pub key: crossterm::event::KeyEvent, + pub metrics: Option<&'a Metrics>, + pub sort_by: ProcSortBy, +} + /// LEGACY: Use processes_handle_key_with_selection for enhanced functionality #[allow(dead_code)] pub fn processes_handle_key( @@ -264,24 +273,93 @@ pub fn processes_handle_key( crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size); } -/// Enhanced keyboard handler that also manages process selection -pub fn processes_handle_key_with_selection( - _scroll_offset: &mut usize, - selected_process_pid: &mut Option, - selected_process_index: &mut Option, - key: crossterm::event::KeyEvent, - _page_size: usize, - _total_rows: usize, - _metrics: Option<&Metrics>, -) -> bool { +pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool { use crossterm::event::KeyCode; - match key.code { + match params.key.code { + KeyCode::Up => { + // Build sorted index list to navigate through display order + 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) + }), + } + + 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() { + 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 + 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) + }), + } + + 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() { + 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 + } KeyCode::Char('x') | KeyCode::Char('X') => { // Unselect any selected process - if selected_process_pid.is_some() || selected_process_index.is_some() { - *selected_process_pid = None; - *selected_process_index = None; + if params.selected_process_pid.is_some() || params.selected_process_index.is_some() { + *params.selected_process_pid = None; + *params.selected_process_index = None; true // Handled } else { false // No selection to clear @@ -289,7 +367,7 @@ pub fn processes_handle_key_with_selection( } KeyCode::Enter => { // Signal that Enter was pressed with a selection - selected_process_pid.is_some() // Return true if we have a selection to handle + params.selected_process_pid.is_some() // Return true if we have a selection to handle } _ => { // No other keys handled - let scrollbar handle all navigation