Compare commits

...

5 Commits

Author SHA1 Message Date
jason 6f238cdf25 tweak hotkeys, add info panel, optimize fonts and hotkeys for about and info panel.
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
2025-11-17 00:05:02 -08:00
jason ffe451edaa cargo fmt 2025-10-26 02:32:11 -07:00
jason c9bde52cb1 move logo to them file. 2025-10-26 02:30:46 -07:00
jason 0603746d7c cargo fmt 2025-10-26 02:18:01 -07:00
jason 25632f3427 Add About modal with sock ASCII art 2025-10-26 02:16:42 -07:00
7 changed files with 356 additions and 37 deletions
+65 -18
View File
@@ -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,
@@ -614,7 +615,8 @@ impl App {
{
self.clear_process_details();
}
// Modal was dismissed, continue to normal processing
// Modal was dismissed, skip normal key processing
continue;
}
ModalAction::Confirm => {
// Handle confirmation action here if needed in the future
@@ -654,6 +656,17 @@ impl App {
) {
self.should_quit = true;
}
// Show About modal on 'a' or 'A'
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
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);
@@ -674,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
};
@@ -703,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<usize> = (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();
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn draw_header(
parts.push(tok_txt.into());
}
parts.push(intervals);
parts.push("(q to 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);
}
+166
View File
@@ -55,6 +55,8 @@ impl ModalManager {
self.journal_scroll_max = 0;
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,
@@ -66,6 +68,8 @@ impl ModalManager {
self.active_button = match next {
ModalType::ConnectionError { .. } => ModalButton::Retry,
ModalType::ProcessDetails { .. } => ModalButton::Ok,
ModalType::About => ModalButton::Ok,
ModalType::Help => ModalButton::Ok,
ModalType::Confirmation { .. } => ModalButton::Confirm,
ModalType::Info { .. } => ModalButton::Ok,
};
@@ -205,6 +209,14 @@ impl ModalManager {
self.pop_modal();
ModalAction::Dismiss
}
(Some(ModalType::About), ModalButton::Ok) => {
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) => {
@@ -253,6 +265,14 @@ impl ModalManager {
// Process details modal uses almost full screen (95% width, 90% height)
self.centered_rect(95, 90, area)
}
ModalType::About => {
// About modal uses medium size
self.centered_rect(90, 90, area)
}
ModalType::Help => {
// Help modal uses medium size
self.centered_rect(80, 80, area)
}
_ => {
// Other modals use smaller size
self.centered_rect(70, 50, area)
@@ -276,6 +296,8 @@ impl ModalManager {
ModalType::ProcessDetails { pid } => {
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,
@@ -378,6 +400,150 @@ impl ModalManager {
);
}
fn render_about(&self, f: &mut Frame, area: Rect) {
//get ASCII art from a constant stored in theme.rs
use super::theme::ASCII_ART;
let version = env!("CARGO_PKG_VERSION");
let about_text = format!(
"{}\n\
Version {}\n\
\n\
A terminal first remote monitoring tool\n\
\n\
Website: https://socktop.io\n\
GitHub: https://github.com/jasonwitty/socktop\n\
\n\
License: MIT License\n\
\n\
Created by Jason Witty\n\
jasonpwitty+socktop@proton.me",
ASCII_ART, version
);
// Render the border block
let block = Block::default()
.title(" About socktop ")
.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(about_text)
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
.alignment(Alignment::Center)
.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,
};
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,
};
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 centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
+2
View File
@@ -38,6 +38,8 @@ pub enum ModalType {
ProcessDetails {
pid: u32,
},
About,
Help,
#[allow(dead_code)]
Confirmation {
title: String,
+93 -15
View File
@@ -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<u32>,
pub selected_process_index: &'a mut Option<usize>,
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<u32>,
selected_process_index: &mut Option<usize>,
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<usize> = (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<usize> = (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
+27 -1
View File
@@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
// Large multi-line warning icon
// warning icon
pub const LARGE_ERROR_ICON: &[&str] = &[
" /\\ ",
" / \\ ",
@@ -60,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
" / !! \\ ",
" /______________\\ ",
];
//about logo
pub const ASCII_ART: &str = r#"
"#;