From a4f69a5f7dd8548294ea48dbb6268710a36c7af2 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Sun, 10 Aug 2025 23:32:44 -0700 Subject: [PATCH] Add scrollbar to CPU per core area. --- socktop/src/app.rs | 134 +++++++++++++++++-- socktop/src/ui/cpu.rs | 304 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 407 insertions(+), 31 deletions(-) diff --git a/socktop/src/app.rs b/socktop/src/app.rs index e58bf10..89f6e56 100644 --- a/socktop/src/app.rs +++ b/socktop/src/app.rs @@ -7,21 +7,25 @@ use std::{ }; use crossterm::{ - event::{self, Event, KeyCode}, + event::{self, Event, KeyCode, EnableMouseCapture, DisableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, - layout::{Constraint, Direction}, + layout::{Constraint, Direction, Rect}, // add Rect Terminal, }; use tokio::time::sleep; use crate::history::{push_capped, PerCoreHistory}; use crate::types::Metrics; +use crate::ui::cpu::{ + draw_cpu_avg_graph, draw_per_core_bars, per_core_clamp, per_core_content_area, + per_core_handle_key, per_core_handle_mouse, per_core_handle_scrollbar_mouse, + PerCoreScrollDrag, +}; use crate::ui::{ - cpu::{draw_cpu_avg_graph, draw_per_core_bars}, disks::draw_disks, header::draw_header, mem::draw_mem, @@ -50,6 +54,9 @@ pub struct App { // Quit flag should_quit: bool, + + pub per_core_scroll: usize, + pub per_core_drag: Option, // new: drag state } impl App { @@ -64,6 +71,8 @@ impl App { rx_peak: 0, tx_peak: 0, should_quit: false, + per_core_scroll: 0, + per_core_drag: None, } } @@ -74,7 +83,7 @@ impl App { // Terminal setup enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -85,7 +94,7 @@ impl App { // Teardown disable_raw_mode()?; let backend = terminal.backend_mut(); - execute!(backend, LeaveAlternateScreen)?; + execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?; terminal.show_cursor()?; res @@ -99,13 +108,90 @@ impl App { loop { // Input (non-blocking) while event::poll(Duration::from_millis(10))? { - if let Event::Key(k) = event::read()? { - if matches!( - k.code, - KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc - ) { - self.should_quit = true; + match event::read()? { + Event::Key(k) => { + if matches!(k.code, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc) { + self.should_quit = true; + } + // 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); + let rows = ratatui::layout::Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Ratio(1, 3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(10), + ]) + .split(area); + let top = ratatui::layout::Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) + .split(rows[1]); + let content = per_core_content_area(top[1]); + + per_core_handle_key(&mut self.per_core_scroll, k, content.height as usize); + + let total_rows = self + .last_metrics + .as_ref() + .map(|mm| mm.cpu_per_core.len()) + .unwrap_or(0); + per_core_clamp(&mut self.per_core_scroll, total_rows, content.height as usize); } + Event::Mouse(m) => { + // Layout to get areas + let sz = terminal.size()?; + let area = Rect::new(0, 0, sz.width, sz.height); + let rows = ratatui::layout::Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Ratio(1, 3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(10), + ]) + .split(area); + let top = ratatui::layout::Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) + .split(rows[1]); + + // Content wheel scrolling + let content = per_core_content_area(top[1]); + per_core_handle_mouse( + &mut self.per_core_scroll, + m, + content, + content.height as usize, + ); + + // Scrollbar clicks/drag + let total_rows = self + .last_metrics + .as_ref() + .map(|mm| mm.cpu_per_core.len()) + .unwrap_or(0); + per_core_handle_scrollbar_mouse( + &mut self.per_core_scroll, + &mut self.per_core_drag, + m, + top[1], + total_rows, + ); + + // Clamp to bounds + per_core_clamp( + &mut self.per_core_scroll, + total_rows, + content.height as usize, + ); + } + Event::Resize(_, _) => {} + _ => {} } } if self.should_quit { @@ -179,7 +265,13 @@ impl App { .split(rows[1]); draw_cpu_avg_graph(f, top[0], &self.cpu_hist, self.last_metrics.as_ref()); - draw_per_core_bars(f, top[1], self.last_metrics.as_ref(), &self.per_core_hist); + draw_per_core_bars( + f, + top[1], + self.last_metrics.as_ref(), + &self.per_core_hist, + self.per_core_scroll, + ); draw_mem(f, rows[2], self.last_metrics.as_ref()); draw_swap(f, rows[3], self.last_metrics.as_ref()); @@ -225,3 +317,21 @@ impl App { draw_top_processes(f, bottom[1], self.last_metrics.as_ref()); } } + +impl Default for App { + fn default() -> Self { + Self { + last_metrics: None, + cpu_hist: VecDeque::with_capacity(600), + per_core_hist: PerCoreHistory::new(60), + last_net_totals: None, + rx_hist: VecDeque::with_capacity(600), + tx_hist: VecDeque::with_capacity(600), + rx_peak: 0, + tx_peak: 0, + should_quit: false, + per_core_scroll: 0, + per_core_drag: None, + } + } +} diff --git a/socktop/src/ui/cpu.rs b/socktop/src/ui/cpu.rs index 99c65dd..e14901a 100644 --- a/socktop/src/ui/cpu.rs +++ b/socktop/src/ui/cpu.rs @@ -7,10 +7,236 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Sparkline}, }; +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; // + MouseButton use crate::history::PerCoreHistory; use crate::types::Metrics; +/// Subtle grey theme for the custom scrollbar +const SB_ARROW: Color = Color::Rgb(170,170,180); +const SB_TRACK: Color = Color::Rgb(170,170,180); +const SB_THUMB: Color = Color::Rgb(170,170,180); + +/// State for dragging the scrollbar thumb +#[derive(Clone, Copy, Debug, Default)] +pub struct PerCoreScrollDrag { + pub active: bool, + pub start_y: u16, // mouse row where drag started + pub start_top: usize, // thumb top (in track rows) at drag start +} + +/// Returns the content area for per-core CPU bars, excluding borders and reserving space for scrollbar. +pub fn per_core_content_area(area: Rect) -> Rect { + // Inner minus borders + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + // Reserve 1 column on the right for a gutter and 1 for the scrollbar + Rect { + x: inner.x, + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height, + } +} + +/// 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::PageUp => { + let step = page_size.max(1); + *scroll_offset = scroll_offset.saturating_sub(step); + } + KeyCode::PageDown => { + let step = page_size.max(1); + *scroll_offset = scroll_offset.saturating_add(step); + } + KeyCode::Home => *scroll_offset = 0, + KeyCode::End => *scroll_offset = usize::MAX, // draw() clamps to max + _ => {} + } +} + +/// Handles mouse wheel over the content. +pub fn per_core_handle_mouse( + scroll_offset: &mut usize, + mouse: MouseEvent, + content_area: Rect, + page_size: usize, +) { + let inside = mouse.column >= content_area.x + && mouse.column < content_area.x + content_area.width + && mouse.row >= content_area.y + && mouse.row < content_area.y + content_area.height; + + if !inside { + return; + } + + match mouse.kind { + MouseEventKind::ScrollUp => *scroll_offset = scroll_offset.saturating_sub(1), + MouseEventKind::ScrollDown => *scroll_offset = scroll_offset.saturating_add(1), + // Optional paging via horizontal wheel + MouseEventKind::ScrollLeft => { + let step = page_size.max(1); + *scroll_offset = scroll_offset.saturating_sub(step); + } + MouseEventKind::ScrollRight => { + let step = page_size.max(1); + *scroll_offset = scroll_offset.saturating_add(step); + } + _ => {} + } +} + +/// Handles mouse interaction with the scrollbar itself (click arrows/page/drag). +pub fn per_core_handle_scrollbar_mouse( + scroll_offset: &mut usize, + drag: &mut Option, + mouse: MouseEvent, + per_core_area: Rect, + total_rows: usize, +) { + // Geometry + let inner = Rect { + x: per_core_area.x + 1, + y: per_core_area.y + 1, + width: per_core_area.width.saturating_sub(2), + height: per_core_area.height.saturating_sub(2), + }; + if inner.height < 3 || inner.width < 1 { + return; + } + let content = Rect { + x: inner.x, + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height, + }; + let scroll_area = Rect { + x: inner.x + inner.width.saturating_sub(1), + y: inner.y, + width: 1, + height: inner.height, + }; + let viewport_rows = content.height as usize; + let total = total_rows.max(1); + let view = viewport_rows.clamp(1, total); + let max_off = total.saturating_sub(view); + let mut offset = (*scroll_offset).min(max_off); + + // Track and current thumb + let track = (scroll_area.height - 2) as usize; + if track == 0 { + return; + } + let thumb_len = ((track * view + total - 1) / total).max(1).min(track); + let top_for_offset = |off: usize| -> usize { + if max_off == 0 { + 0 + } else { + ((track - thumb_len) * off + max_off / 2) / max_off + } + }; + let thumb_top = top_for_offset(offset); + + let inside_scrollbar = mouse.column == scroll_area.x + && mouse.row >= scroll_area.y + && mouse.row < scroll_area.y + scroll_area.height; + + // Helper to page + let page_up = || offset.saturating_sub(view.max(1)); + let page_down = || offset.saturating_add(view.max(1)); + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if inside_scrollbar => { + // Where within the track? + let row = mouse.row; + if row == scroll_area.y { + // Top arrow + offset = offset.saturating_sub(1); + } else if row + 1 == scroll_area.y + scroll_area.height { + // Bottom arrow + offset = offset.saturating_add(1); + } else { + // Inside track + let rel = (row - (scroll_area.y + 1)) as usize; + let thumb_end = thumb_top + thumb_len; + if rel < thumb_top { + // Page up + offset = page_up(); + } else if rel >= thumb_end { + // Page down + offset = page_down(); + } else { + // Start dragging + *drag = Some(PerCoreScrollDrag { + active: true, + start_y: row, + start_top: thumb_top, + }); + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(mut d) = drag.take() { + if d.active { + let dy = (mouse.row as i32) - (d.start_y as i32); + let new_top = (d.start_top as i32 + dy) + .clamp(0, (track.saturating_sub(thumb_len)) as i32) as usize; + // Inverse mapping top -> offset + if track > thumb_len { + let denom = track - thumb_len; + offset = if max_off == 0 { + 0 + } else { + (new_top * max_off + denom / 2) / denom + }; + } else { + offset = 0; + } + // Keep dragging + d.start_top = new_top; + d.start_y = mouse.row; + *drag = Some(d); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + // End drag + *drag = None; + } + // Also allow wheel scrolling when cursor is over the scrollbar + MouseEventKind::ScrollUp if inside_scrollbar => { + offset = offset.saturating_sub(1); + } + MouseEventKind::ScrollDown if inside_scrollbar => { + offset = offset.saturating_add(1); + } + _ => {} + } + + // Clamp and write back + if offset > max_off { + offset = max_off; + } + *scroll_offset = offset; +} + +/// Clamp scroll offset to the valid range given content and viewport. +pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_rows: usize) { + let max_offset = total_rows.saturating_sub(viewport_rows); + if *scroll_offset > max_offset { + *scroll_offset = max_offset; + } +} + +/// Draws the CPU average sparkline graph. pub fn draw_cpu_avg_graph( f: &mut ratatui::Frame<'_>, area: Rect, @@ -33,11 +259,13 @@ pub fn draw_cpu_avg_graph( f.render_widget(spark, area); } +/// Draws the per-core CPU bars with sparklines and trends. pub fn draw_per_core_bars( f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>, per_core_hist: &PerCoreHistory, + scroll_offset: usize, ) { f.render_widget( Block::default().borders(Borders::ALL).title("Per-core"), @@ -47,45 +275,51 @@ pub fn draw_per_core_bars( return; }; + // Compute inner rect and content area let inner = Rect { x: area.x + 1, y: area.y + 1, width: area.width.saturating_sub(2), height: area.height.saturating_sub(2), }; - if inner.height == 0 { + if inner.height == 0 || inner.width <= 2 { return; } + let content = Rect { + x: inner.x, + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height, + }; + + let total_rows = mm.cpu_per_core.len(); + let viewport_rows = content.height as usize; + let max_offset = total_rows.saturating_sub(viewport_rows); + let offset = scroll_offset.min(max_offset); + let show_n = total_rows.saturating_sub(offset).min(viewport_rows); - let rows = inner.height as usize; - let show_n = rows.min(mm.cpu_per_core.len()); let constraints: Vec = (0..show_n).map(|_| Constraint::Length(1)).collect(); let vchunks = Layout::default() .direction(Direction::Vertical) .constraints(constraints) - .split(inner); + .split(content); for i in 0..show_n { + let idx = offset + i; let rect = vchunks[i]; let hchunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(6), Constraint::Length(12)]) .split(rect); - let curr = mm.cpu_per_core[i].clamp(0.0, 100.0); + let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0); let older = per_core_hist .deques - .get(i) + .get(idx) .and_then(|d| d.iter().rev().nth(20).copied()) .map(|v| v as f32) .unwrap_or(curr); - let trend = if curr > older + 0.2 { - "↑" - } else if curr + 0.2 < older { - "↓" - } else { - "╌" - }; + let trend = if curr > older + 0.2 { "↑" } else if curr + 0.2 < older { "↓" } else { "╌" }; let fg = match curr { x if x < 25.0 => Color::Green, @@ -95,7 +329,7 @@ pub fn draw_per_core_bars( let hist: Vec = per_core_hist .deques - .get(i) + .get(idx) .map(|d| { let max_points = hchunks[0].width as usize; let start = d.len().saturating_sub(max_points); @@ -103,17 +337,49 @@ pub fn draw_per_core_bars( }) .unwrap_or_default(); - let spark = Sparkline::default() - .data(&hist) - .max(100) - .style(Style::default().fg(fg)); + let spark = Sparkline::default().data(&hist).max(100).style(Style::default().fg(fg)); f.render_widget(spark, hchunks[0]); - let label = format!("cpu{:<2}{}{:>5.1}%", i, trend, curr); + let label = format!("cpu{:<2}{}{:>5.1}%", idx, trend, curr); let line = Line::from(Span::styled( label, Style::default().fg(fg).add_modifier(Modifier::BOLD), )); f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); } + + // Custom 1-col scrollbar with arrows, track, and exact mapping + let scroll_area = Rect { + x: inner.x + inner.width.saturating_sub(1), + y: inner.y, + width: 1, + height: inner.height, + }; + if scroll_area.height >= 3 { + let track = (scroll_area.height - 2) as usize; + let total = total_rows.max(1); + let view = viewport_rows.clamp(1, total); + let max_off = total.saturating_sub(view); + + let thumb_len = ((track * view + total - 1) / total).max(1).min(track); + let thumb_top = if max_off == 0 { + 0 + } else { + ((track - thumb_len) * offset + max_off / 2) / max_off + }; + + // Build lines: top arrow, track (with thumb), bottom arrow + let mut lines: Vec = Vec::with_capacity(scroll_area.height as usize); + lines.push(Line::from(Span::styled("▲", Style::default().fg(SB_ARROW)))); + for i in 0..track { + if i >= thumb_top && i < thumb_top + thumb_len { + lines.push(Line::from(Span::styled("█", Style::default().fg(SB_THUMB)))); + } else { + lines.push(Line::from(Span::styled("│", Style::default().fg(SB_TRACK)))); + } + } + lines.push(Line::from(Span::styled("▼", Style::default().fg(SB_ARROW)))); + + f.render_widget(Paragraph::new(lines), scroll_area); + } }