Add scrollbar to CPU per core area.

This commit is contained in:
jasonwitty 2025-08-10 23:32:44 -07:00
parent 9b1643afac
commit a4f69a5f7d
2 changed files with 407 additions and 31 deletions

View File

@ -7,21 +7,25 @@ use std::{
}; };
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode}, event::{self, Event, KeyCode, EnableMouseCapture, DisableMouseCapture},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Direction}, layout::{Constraint, Direction, Rect}, // add Rect
Terminal, Terminal,
}; };
use tokio::time::sleep; use tokio::time::sleep;
use crate::history::{push_capped, PerCoreHistory}; use crate::history::{push_capped, PerCoreHistory};
use crate::types::Metrics; 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::{ use crate::ui::{
cpu::{draw_cpu_avg_graph, draw_per_core_bars},
disks::draw_disks, disks::draw_disks,
header::draw_header, header::draw_header,
mem::draw_mem, mem::draw_mem,
@ -50,6 +54,9 @@ pub struct App {
// Quit flag // Quit flag
should_quit: bool, should_quit: bool,
pub per_core_scroll: usize,
pub per_core_drag: Option<PerCoreScrollDrag>, // new: drag state
} }
impl App { impl App {
@ -64,6 +71,8 @@ impl App {
rx_peak: 0, rx_peak: 0,
tx_peak: 0, tx_peak: 0,
should_quit: false, should_quit: false,
per_core_scroll: 0,
per_core_drag: None,
} }
} }
@ -74,7 +83,7 @@ impl App {
// Terminal setup // Terminal setup
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@ -85,7 +94,7 @@ impl App {
// Teardown // Teardown
disable_raw_mode()?; disable_raw_mode()?;
let backend = terminal.backend_mut(); let backend = terminal.backend_mut();
execute!(backend, LeaveAlternateScreen)?; execute!(backend, DisableMouseCapture, LeaveAlternateScreen)?;
terminal.show_cursor()?; terminal.show_cursor()?;
res res
@ -99,13 +108,90 @@ impl App {
loop { loop {
// Input (non-blocking) // Input (non-blocking)
while event::poll(Duration::from_millis(10))? { while event::poll(Duration::from_millis(10))? {
if let Event::Key(k) = event::read()? { match event::read()? {
if matches!( Event::Key(k) => {
k.code, if matches!(k.code, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc) {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
) {
self.should_quit = true; 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 { if self.should_quit {
@ -179,7 +265,13 @@ impl App {
.split(rows[1]); .split(rows[1]);
draw_cpu_avg_graph(f, top[0], &self.cpu_hist, self.last_metrics.as_ref()); 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_mem(f, rows[2], self.last_metrics.as_ref());
draw_swap(f, rows[3], 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()); 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,
}
}
}

View File

@ -7,10 +7,236 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Sparkline}, widgets::{Block, Borders, Paragraph, Sparkline},
}; };
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; // + MouseButton
use crate::history::PerCoreHistory; use crate::history::PerCoreHistory;
use crate::types::Metrics; 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<PerCoreScrollDrag>,
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( pub fn draw_cpu_avg_graph(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
@ -33,11 +259,13 @@ pub fn draw_cpu_avg_graph(
f.render_widget(spark, area); f.render_widget(spark, area);
} }
/// Draws the per-core CPU bars with sparklines and trends.
pub fn draw_per_core_bars( pub fn draw_per_core_bars(
f: &mut ratatui::Frame<'_>, f: &mut ratatui::Frame<'_>,
area: Rect, area: Rect,
m: Option<&Metrics>, m: Option<&Metrics>,
per_core_hist: &PerCoreHistory, per_core_hist: &PerCoreHistory,
scroll_offset: usize,
) { ) {
f.render_widget( f.render_widget(
Block::default().borders(Borders::ALL).title("Per-core"), Block::default().borders(Borders::ALL).title("Per-core"),
@ -47,45 +275,51 @@ pub fn draw_per_core_bars(
return; return;
}; };
// Compute inner rect and content area
let inner = Rect { let inner = Rect {
x: area.x + 1, x: area.x + 1,
y: area.y + 1, y: area.y + 1,
width: area.width.saturating_sub(2), width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2), height: area.height.saturating_sub(2),
}; };
if inner.height == 0 { if inner.height == 0 || inner.width <= 2 {
return; 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<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect(); let constraints: Vec<Constraint> = (0..show_n).map(|_| Constraint::Length(1)).collect();
let vchunks = Layout::default() let vchunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
.split(inner); .split(content);
for i in 0..show_n { for i in 0..show_n {
let idx = offset + i;
let rect = vchunks[i]; let rect = vchunks[i];
let hchunks = Layout::default() let hchunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(6), Constraint::Length(12)]) .constraints([Constraint::Min(6), Constraint::Length(12)])
.split(rect); .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 let older = per_core_hist
.deques .deques
.get(i) .get(idx)
.and_then(|d| d.iter().rev().nth(20).copied()) .and_then(|d| d.iter().rev().nth(20).copied())
.map(|v| v as f32) .map(|v| v as f32)
.unwrap_or(curr); .unwrap_or(curr);
let trend = if curr > older + 0.2 { let trend = if curr > older + 0.2 { "" } else if curr + 0.2 < older { "" } else { "" };
""
} else if curr + 0.2 < older {
""
} else {
""
};
let fg = match curr { let fg = match curr {
x if x < 25.0 => Color::Green, x if x < 25.0 => Color::Green,
@ -95,7 +329,7 @@ pub fn draw_per_core_bars(
let hist: Vec<u64> = per_core_hist let hist: Vec<u64> = per_core_hist
.deques .deques
.get(i) .get(idx)
.map(|d| { .map(|d| {
let max_points = hchunks[0].width as usize; let max_points = hchunks[0].width as usize;
let start = d.len().saturating_sub(max_points); let start = d.len().saturating_sub(max_points);
@ -103,17 +337,49 @@ pub fn draw_per_core_bars(
}) })
.unwrap_or_default(); .unwrap_or_default();
let spark = Sparkline::default() let spark = Sparkline::default().data(&hist).max(100).style(Style::default().fg(fg));
.data(&hist)
.max(100)
.style(Style::default().fg(fg));
f.render_widget(spark, hchunks[0]); 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( let line = Line::from(Span::styled(
label, label,
Style::default().fg(fg).add_modifier(Modifier::BOLD), Style::default().fg(fg).add_modifier(Modifier::BOLD),
)); ));
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]); 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<Line> = 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);
}
} }