chore: update ratatui from 0.28 to 0.30 (#33)
* chore: update ratatui from 0.28 to 0.30 * style: cargo fmt * fix: replace manual zero-guarded divisions with checked_div * fix: collapse nested if into match guard * style: cargo fmt * bump crossterm and optimize various types, remove stale code. * fix windows build * only show parent level processes on main tui
This commit is contained in:
+211
-125
@@ -29,11 +29,15 @@ use crate::ui::cpu::{
|
||||
};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
use crate::ui::processes::{
|
||||
ProcSortBy, ProcessKeyParams, get_filtered_sorted_indices, processes_handle_key_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,
|
||||
disks::draw_disks,
|
||||
gpu::draw_gpu,
|
||||
header::{build_header_intervals, build_header_title, draw_header},
|
||||
mem::draw_mem,
|
||||
net::draw_net_spark,
|
||||
swap::draw_swap,
|
||||
};
|
||||
|
||||
@@ -46,6 +50,15 @@ use socktop_connector::{
|
||||
const MIN_METRICS_INTERVAL_MS: u64 = 100;
|
||||
const MIN_PROCESSES_INTERVAL_MS: u64 = 200;
|
||||
|
||||
/// Drop duplicate-name entries from a disks payload (the agent occasionally
|
||||
/// reports a partition twice). Done once when fresh disk data arrives so the
|
||||
/// per-frame draw path doesn't have to rebuild a HashSet.
|
||||
fn dedup_disks(disks: &mut Vec<socktop_connector::DiskInfo>) {
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(disks.len());
|
||||
disks.retain(|d| seen.insert(d.name.clone()));
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConnectionState {
|
||||
Connected,
|
||||
@@ -57,8 +70,9 @@ pub struct App {
|
||||
// Latest metrics + histories
|
||||
last_metrics: Option<Metrics>,
|
||||
|
||||
// CPU avg history (0..100)
|
||||
// CPU avg history (0..100) with a running sum so draw avoids a 600-element fold per frame
|
||||
cpu_hist: VecDeque<u64>,
|
||||
cpu_hist_sum: u64,
|
||||
|
||||
// Per-core history (0..100)
|
||||
per_core_hist: PerCoreHistory,
|
||||
@@ -89,6 +103,17 @@ pub struct App {
|
||||
pub process_search_active: bool,
|
||||
pub process_search_query: String,
|
||||
|
||||
// Cached filtered + sorted process indices. Refreshed lazily when any of
|
||||
// (metrics, sort order, search query) changes — input handlers, the draw
|
||||
// path, and auto-scroll all read from this slice so we avoid rebuilding
|
||||
// an indices Vec on every event.
|
||||
procs_filtered: Vec<usize>,
|
||||
procs_filter_dirty: bool,
|
||||
// Pre-formatted process-row strings, rebuilt once per procs poll. Indexed
|
||||
// parallel to `last_metrics.top_processes`.
|
||||
procs_row_cache: Vec<crate::ui::processes::CachedRow>,
|
||||
procs_row_peak_cpu: f32,
|
||||
|
||||
last_procs_poll: Instant,
|
||||
last_disks_poll: Instant,
|
||||
procs_interval: Duration,
|
||||
@@ -99,6 +124,7 @@ pub struct App {
|
||||
pub process_details: Option<socktop_connector::ProcessMetricsResponse>,
|
||||
pub journal_entries: Option<socktop_connector::JournalResponse>,
|
||||
pub process_cpu_history: VecDeque<f32>, // CPU history for sparkline (last 60 samples)
|
||||
pub process_cpu_history_sum: f32, // running sum of process_cpu_history
|
||||
pub process_mem_history: VecDeque<u64>, // Memory usage history in bytes (last 60 samples)
|
||||
pub process_io_read_history: VecDeque<u64>, // Disk read DELTA history in bytes (last 60 samples)
|
||||
pub process_io_write_history: VecDeque<u64>, // Disk write DELTA history in bytes (last 60 samples)
|
||||
@@ -119,6 +145,17 @@ pub struct App {
|
||||
pub is_tls: bool,
|
||||
pub has_token: bool,
|
||||
|
||||
// Cached title strings — only rebuilt when source values change so the
|
||||
// diff renderer can suppress redraws on idle frames.
|
||||
header_title: String,
|
||||
header_title_key: (String, bool, bool),
|
||||
header_intervals_text: String,
|
||||
header_intervals_key: (u128, u128),
|
||||
net_dl_title: String,
|
||||
net_dl_key: (u64, u64),
|
||||
net_ul_title: String,
|
||||
net_ul_key: (u64, u64),
|
||||
|
||||
// Modal system
|
||||
pub modal_manager: crate::ui::modal::ModalManager,
|
||||
|
||||
@@ -136,6 +173,7 @@ impl App {
|
||||
Self {
|
||||
last_metrics: None,
|
||||
cpu_hist: VecDeque::with_capacity(600),
|
||||
cpu_hist_sum: 0,
|
||||
per_core_hist: PerCoreHistory::new(60),
|
||||
last_net_totals: None,
|
||||
rx_hist: VecDeque::with_capacity(600),
|
||||
@@ -154,6 +192,10 @@ impl App {
|
||||
prev_selected_process_pid: None,
|
||||
process_search_active: false,
|
||||
process_search_query: String::new(),
|
||||
procs_filtered: Vec::new(),
|
||||
procs_filter_dirty: true,
|
||||
procs_row_cache: Vec::new(),
|
||||
procs_row_peak_cpu: 0.0,
|
||||
last_procs_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap_or_else(Instant::now), // trigger immediately on first loop
|
||||
@@ -166,6 +208,7 @@ impl App {
|
||||
process_details: None,
|
||||
journal_entries: None,
|
||||
process_cpu_history: VecDeque::with_capacity(600),
|
||||
process_cpu_history_sum: 0.0,
|
||||
process_mem_history: VecDeque::with_capacity(600),
|
||||
process_io_read_history: VecDeque::with_capacity(600),
|
||||
process_io_write_history: VecDeque::with_capacity(600),
|
||||
@@ -186,6 +229,14 @@ impl App {
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
header_title: String::new(),
|
||||
header_title_key: (String::new(), false, false),
|
||||
header_intervals_text: String::new(),
|
||||
header_intervals_key: (u128::MAX, u128::MAX),
|
||||
net_dl_title: String::new(),
|
||||
net_dl_key: (u64::MAX, u64::MAX),
|
||||
net_ul_title: String::new(),
|
||||
net_ul_key: (u64::MAX, u64::MAX),
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
@@ -465,7 +516,10 @@ impl App {
|
||||
_url: &str,
|
||||
_tls_ca: Option<&str>,
|
||||
_verify_hostname: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Handle input for modal
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -572,7 +626,10 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
mut ws: SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Main event loop
|
||||
let result = self.run_event_loop_iteration(terminal, &mut ws).await;
|
||||
@@ -592,7 +649,10 @@ impl App {
|
||||
&mut self,
|
||||
terminal: &mut Terminal<B>,
|
||||
ws: &mut SocktopConnector,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
<B as ratatui::backend::Backend>::Error: 'static,
|
||||
{
|
||||
loop {
|
||||
// Input (non-blocking)
|
||||
while event::poll(Duration::from_millis(10))? {
|
||||
@@ -665,6 +725,7 @@ impl App {
|
||||
// Exit search mode
|
||||
self.process_search_active = false;
|
||||
self.process_search_query.clear();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
@@ -672,27 +733,24 @@ impl App {
|
||||
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);
|
||||
}
|
||||
let first = self.procs_filter().first().copied();
|
||||
if let (Some(first_idx), Some(m)) =
|
||||
(first, self.last_metrics.as_ref())
|
||||
{
|
||||
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();
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.process_search_query.push(c);
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
@@ -728,6 +786,7 @@ impl App {
|
||||
self.process_search_query.clear();
|
||||
self.selected_process_pid = None;
|
||||
self.selected_process_index = None;
|
||||
self.invalidate_procs_filter();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -760,6 +819,10 @@ impl App {
|
||||
.split(rows[1]);
|
||||
let content = per_core_content_area(top[1]);
|
||||
|
||||
// Refresh the filtered+sorted index cache once before we
|
||||
// borrow individual fields of `self`.
|
||||
let _ = self.procs_filter();
|
||||
|
||||
// First try process selection (only handles arrows if a process is selected)
|
||||
let process_handled = if self.last_procs_area.is_some() {
|
||||
processes_handle_key_with_selection(ProcessKeyParams {
|
||||
@@ -767,8 +830,7 @@ impl App {
|
||||
selected_process_index: &mut self.selected_process_index,
|
||||
key: k,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
@@ -786,14 +848,9 @@ 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()
|
||||
&& self.last_metrics.is_some()
|
||||
{
|
||||
// Get filtered and sorted indices (same as display)
|
||||
let idxs = get_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
);
|
||||
let idxs = &self.procs_filtered;
|
||||
|
||||
// Find the display position of the selected process in filtered list
|
||||
if let Some(display_pos) =
|
||||
@@ -903,11 +960,17 @@ impl App {
|
||||
content.height as usize,
|
||||
);
|
||||
|
||||
// Refresh filter cache before partial borrows of self.
|
||||
let _ = self.procs_filter();
|
||||
let search_box_visible =
|
||||
self.process_search_active || !self.process_search_query.is_empty();
|
||||
|
||||
// Processes table: sort by column on header click and handle row selection
|
||||
if let (Some(mm), Some(p_area)) =
|
||||
if let (Some(_mm), Some(p_area)) =
|
||||
(self.last_metrics.as_ref(), self.last_procs_area)
|
||||
{
|
||||
use crate::ui::processes::ProcessMouseParams;
|
||||
let total_rows = self.procs_filtered.len();
|
||||
if let Some(new_sort) =
|
||||
processes_handle_mouse_with_selection(ProcessMouseParams {
|
||||
scroll_offset: &mut self.procs_scroll_offset,
|
||||
@@ -916,13 +979,14 @@ impl App {
|
||||
drag: &mut self.procs_drag,
|
||||
mouse: m,
|
||||
area: p_area,
|
||||
total_rows: mm.top_processes.len(),
|
||||
total_rows,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
sort_by: self.procs_sort_by,
|
||||
search_query: &self.process_search_query,
|
||||
search_box_visible,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
})
|
||||
{
|
||||
self.procs_sort_by = new_sort;
|
||||
self.invalidate_procs_filter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,22 +1023,36 @@ impl App {
|
||||
|
||||
// Only poll processes every 2s
|
||||
if self.last_procs_poll.elapsed() >= self.procs_interval {
|
||||
let mut updated = false;
|
||||
if let Ok(AgentResponse::Processes(procs)) =
|
||||
ws.request(AgentRequest::Processes).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
mm.top_processes = procs.top_processes;
|
||||
mm.process_count = Some(procs.process_count);
|
||||
updated = true;
|
||||
}
|
||||
if updated {
|
||||
self.invalidate_procs_filter();
|
||||
// Rebuild the pre-formatted row cache for the next
|
||||
// ~N frames. Done once per poll, not per frame.
|
||||
if let Some(mm) = self.last_metrics.as_ref() {
|
||||
self.procs_row_peak_cpu = crate::ui::processes::rebuild_row_cache(
|
||||
mm,
|
||||
&mut self.procs_row_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.last_procs_poll = Instant::now();
|
||||
}
|
||||
|
||||
// Only poll disks every 5s
|
||||
if self.last_disks_poll.elapsed() >= self.disks_interval {
|
||||
if let Ok(AgentResponse::Disks(disks)) =
|
||||
if let Ok(AgentResponse::Disks(mut disks)) =
|
||||
ws.request(AgentRequest::Disks).await
|
||||
&& let Some(mm) = self.last_metrics.as_mut()
|
||||
{
|
||||
dedup_disks(&mut disks);
|
||||
mm.disks = disks;
|
||||
}
|
||||
self.last_disks_poll = Instant::now();
|
||||
@@ -1000,7 +1078,14 @@ impl App {
|
||||
Ok(Ok(AgentResponse::ProcessMetrics(details))) => {
|
||||
// Update history for sparklines
|
||||
let cpu_usage = details.process.cpu_usage;
|
||||
push_capped(&mut self.process_cpu_history, cpu_usage, 600);
|
||||
let evicted_cpu = push_capped(
|
||||
&mut self.process_cpu_history,
|
||||
cpu_usage,
|
||||
600,
|
||||
);
|
||||
self.process_cpu_history_sum = self.process_cpu_history_sum
|
||||
+ cpu_usage
|
||||
- evicted_cpu.unwrap_or(0.0);
|
||||
|
||||
let mem_bytes = details.process.mem_bytes;
|
||||
push_capped(&mut self.process_mem_history, mem_bytes, 600);
|
||||
@@ -1105,11 +1190,37 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the filtered-process cache stale. Call this whenever
|
||||
/// `procs_sort_by`, `process_search_query`, or the top_processes content
|
||||
/// changes — the cache is rebuilt lazily on the next read.
|
||||
pub fn invalidate_procs_filter(&mut self) {
|
||||
self.procs_filter_dirty = true;
|
||||
}
|
||||
|
||||
/// Lazily refresh and return the cached filtered+sorted process indices.
|
||||
/// Empty slice when there are no metrics yet.
|
||||
pub fn procs_filter(&mut self) -> &[usize] {
|
||||
if self.procs_filter_dirty {
|
||||
self.procs_filtered.clear();
|
||||
if let Some(m) = self.last_metrics.as_ref() {
|
||||
crate::ui::processes::fill_filtered_sorted_indices(
|
||||
m,
|
||||
&self.process_search_query,
|
||||
self.procs_sort_by,
|
||||
&mut self.procs_filtered,
|
||||
);
|
||||
}
|
||||
self.procs_filter_dirty = false;
|
||||
}
|
||||
&self.procs_filtered
|
||||
}
|
||||
|
||||
/// Clear process details when modal is closed or selection changes
|
||||
pub fn clear_process_details(&mut self) {
|
||||
self.process_details = None;
|
||||
self.journal_entries = None;
|
||||
self.process_cpu_history.clear();
|
||||
self.process_cpu_history_sum = 0.0;
|
||||
self.process_mem_history.clear();
|
||||
self.process_io_read_history.clear();
|
||||
self.process_io_write_history.clear();
|
||||
@@ -1120,23 +1231,24 @@ impl App {
|
||||
}
|
||||
|
||||
fn update_with_metrics(&mut self, mut m: Metrics) {
|
||||
if let Some(prev) = &self.last_metrics {
|
||||
// Preserve slower fields when the fast payload omits them
|
||||
if let Some(prev) = self.last_metrics.as_mut() {
|
||||
// Preserve slower fields when the fast payload omits them.
|
||||
// prev is about to be dropped so we can move its Vecs instead of cloning.
|
||||
if m.disks.is_empty() {
|
||||
m.disks = prev.disks.clone();
|
||||
m.disks = std::mem::take(&mut prev.disks);
|
||||
}
|
||||
if m.top_processes.is_empty() {
|
||||
m.top_processes = prev.top_processes.clone();
|
||||
m.top_processes = std::mem::take(&mut prev.top_processes);
|
||||
}
|
||||
// Preserve total processes count across fast updates
|
||||
if m.process_count.is_none() {
|
||||
m.process_count = prev.process_count;
|
||||
}
|
||||
}
|
||||
|
||||
// CPU avg history
|
||||
// CPU avg history with running sum
|
||||
let v = m.cpu_total.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.cpu_hist, v, 600);
|
||||
let evicted = push_capped(&mut self.cpu_hist, v, 600);
|
||||
self.cpu_hist_sum = self.cpu_hist_sum + v - evicted.unwrap_or(0);
|
||||
|
||||
// Per-core history (push current samples)
|
||||
self.per_core_hist.ensure_cores(m.cpu_per_core.len());
|
||||
@@ -1179,16 +1291,31 @@ impl App {
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
draw_header(
|
||||
f,
|
||||
rows[0],
|
||||
self.last_metrics.as_ref(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
self.metrics_interval,
|
||||
self.procs_interval,
|
||||
);
|
||||
// Header — refresh cached strings only when their inputs change so the
|
||||
// ratatui diff renderer can suppress repaints on idle frames.
|
||||
{
|
||||
let hostname = self.last_metrics.as_ref().map(|mm| mm.hostname.as_str());
|
||||
let key = (
|
||||
hostname.unwrap_or("").to_string(),
|
||||
self.is_tls,
|
||||
self.has_token,
|
||||
);
|
||||
if self.header_title_key != key {
|
||||
self.header_title = build_header_title(hostname, self.is_tls, self.has_token);
|
||||
self.header_title_key = key;
|
||||
}
|
||||
|
||||
let intervals_key = (
|
||||
self.metrics_interval.as_millis(),
|
||||
self.procs_interval.as_millis(),
|
||||
);
|
||||
if self.header_intervals_key != intervals_key {
|
||||
self.header_intervals_text =
|
||||
build_header_intervals(intervals_key.0, intervals_key.1);
|
||||
self.header_intervals_key = intervals_key;
|
||||
}
|
||||
}
|
||||
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text);
|
||||
|
||||
// Top row: left CPU avg, right Per-core (full top-right)
|
||||
let top_lr = ratatui::layout::Layout::default()
|
||||
@@ -1196,12 +1323,18 @@ impl App {
|
||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
||||
.split(rows[1]);
|
||||
|
||||
draw_cpu_avg_graph(f, top_lr[0], &self.cpu_hist, self.last_metrics.as_ref());
|
||||
draw_cpu_avg_graph(
|
||||
f,
|
||||
top_lr[0],
|
||||
&mut self.cpu_hist,
|
||||
self.cpu_hist_sum,
|
||||
self.last_metrics.as_ref(),
|
||||
);
|
||||
draw_per_core_bars(
|
||||
f,
|
||||
top_lr[1],
|
||||
self.last_metrics.as_ref(),
|
||||
&self.per_core_hist,
|
||||
&mut self.per_core_hist,
|
||||
self.per_core_scroll,
|
||||
);
|
||||
|
||||
@@ -1245,26 +1378,33 @@ impl App {
|
||||
.split(bottom_lr[0]);
|
||||
|
||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
||||
|
||||
// Net titles only change when the throughput or peak changes.
|
||||
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
||||
let rx_key = (rx_now, self.rx_peak);
|
||||
if self.net_dl_key != rx_key {
|
||||
self.net_dl_title = format!("Download (KB/s) — now: {rx_now} | peak: {}", self.rx_peak);
|
||||
self.net_dl_key = rx_key;
|
||||
}
|
||||
draw_net_spark(
|
||||
f,
|
||||
left_stack[1],
|
||||
&format!(
|
||||
"Download (KB/s) — now: {} | peak: {}",
|
||||
self.rx_hist.back().copied().unwrap_or(0),
|
||||
self.rx_peak
|
||||
),
|
||||
&self.rx_hist,
|
||||
&self.net_dl_title,
|
||||
&mut self.rx_hist,
|
||||
ratatui::style::Color::Green,
|
||||
);
|
||||
|
||||
let tx_now = self.tx_hist.back().copied().unwrap_or(0);
|
||||
let tx_key = (tx_now, self.tx_peak);
|
||||
if self.net_ul_key != tx_key {
|
||||
self.net_ul_title = format!("Upload (KB/s) — now: {tx_now} | peak: {}", self.tx_peak);
|
||||
self.net_ul_key = tx_key;
|
||||
}
|
||||
draw_net_spark(
|
||||
f,
|
||||
left_stack[2],
|
||||
&format!(
|
||||
"Upload (KB/s) — now: {} | peak: {}",
|
||||
self.tx_hist.back().copied().unwrap_or(0),
|
||||
self.tx_peak
|
||||
),
|
||||
&self.tx_hist,
|
||||
&self.net_ul_title,
|
||||
&mut self.tx_hist,
|
||||
ratatui::style::Color::Blue,
|
||||
);
|
||||
|
||||
@@ -1272,6 +1412,8 @@ impl App {
|
||||
let procs_area = bottom_lr[1];
|
||||
// Cache for input handlers
|
||||
self.last_procs_area = Some(procs_area);
|
||||
// Refresh the filter cache before partial borrows of self.
|
||||
let _ = self.procs_filter();
|
||||
crate::ui::processes::draw_top_processes(
|
||||
f,
|
||||
procs_area,
|
||||
@@ -1283,6 +1425,9 @@ impl App {
|
||||
selected_process_index: self.selected_process_index,
|
||||
search_query: &self.process_search_query,
|
||||
search_active: self.process_search_active,
|
||||
filtered_indices: &self.procs_filtered,
|
||||
cached_rows: &self.procs_row_cache,
|
||||
peak_cpu: self.procs_row_peak_cpu,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1296,6 +1441,7 @@ impl App {
|
||||
journal: self.journal_entries.as_ref(),
|
||||
history: ProcessHistoryData {
|
||||
cpu: &self.process_cpu_history,
|
||||
cpu_sum: self.process_cpu_history_sum,
|
||||
mem: &self.process_mem_history,
|
||||
io_read: &self.process_io_read_history,
|
||||
io_write: &self.process_io_write_history,
|
||||
@@ -1310,66 +1456,6 @@ impl App {
|
||||
|
||||
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,
|
||||
procs_scroll_offset: 0,
|
||||
procs_drag: None,
|
||||
procs_sort_by: ProcSortBy::CpuDesc,
|
||||
last_procs_area: None,
|
||||
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
|
||||
last_disks_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(5))
|
||||
.unwrap_or_else(Instant::now),
|
||||
procs_interval: Duration::from_secs(2),
|
||||
disks_interval: Duration::from_secs(5),
|
||||
metrics_interval: Duration::from_millis(500),
|
||||
process_details: None,
|
||||
journal_entries: None,
|
||||
process_cpu_history: VecDeque::with_capacity(600),
|
||||
process_mem_history: VecDeque::with_capacity(600),
|
||||
process_io_read_history: VecDeque::with_capacity(600),
|
||||
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))
|
||||
.unwrap_or_else(Instant::now),
|
||||
last_journal_poll: Instant::now()
|
||||
.checked_sub(Duration::from_secs(10))
|
||||
.unwrap_or_else(Instant::now),
|
||||
process_details_interval: Duration::from_millis(500),
|
||||
journal_interval: Duration::from_secs(5),
|
||||
ws_url: String::new(),
|
||||
tls_ca: None,
|
||||
verify_hostname: false,
|
||||
is_tls: false,
|
||||
has_token: false,
|
||||
modal_manager: ModalManager::new(),
|
||||
connection_state: ConnectionState::Disconnected,
|
||||
last_connection_attempt: Instant::now(),
|
||||
original_disconnect_time: None,
|
||||
connection_retry_count: 0,
|
||||
last_auto_retry: None,
|
||||
replacement_connection: None,
|
||||
}
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -2,16 +2,25 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) {
|
||||
if dq.len() == cap {
|
||||
dq.pop_front();
|
||||
}
|
||||
/// Push a value into a capped deque. Returns the evicted front element if any.
|
||||
/// Callers maintaining a running sum can use this to update the sum without
|
||||
/// re-iterating the whole deque.
|
||||
pub fn push_capped<T>(dq: &mut VecDeque<T>, v: T, cap: usize) -> Option<T> {
|
||||
let evicted = if dq.len() == cap {
|
||||
dq.pop_front()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
dq.push_back(v);
|
||||
evicted
|
||||
}
|
||||
|
||||
// Keeps a history deque per core with a fixed capacity
|
||||
// Keeps a history deque per core with a fixed capacity.
|
||||
// Storage is u64 so sparkline rendering can hand the slice directly to
|
||||
// ratatui's `Sparkline::data` (which takes `&[u64]`) without per-frame
|
||||
// allocation or widening conversion.
|
||||
pub struct PerCoreHistory {
|
||||
pub deques: Vec<VecDeque<u16>>,
|
||||
pub deques: Vec<VecDeque<u64>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
@@ -35,7 +44,7 @@ impl PerCoreHistory {
|
||||
pub fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ensure_cores(samples.len());
|
||||
for (i, v) in samples.iter().enumerate() {
|
||||
let val = v.clamp(0.0, 100.0).round() as u16;
|
||||
let val = v.clamp(0.0, 100.0).round() as u64;
|
||||
push_capped(&mut self.deques[i], val, self.cap);
|
||||
}
|
||||
}
|
||||
|
||||
+158
-67
@@ -7,7 +7,9 @@ use ratatui::style::{Color, Style};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Sparkline},
|
||||
widgets::{
|
||||
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::history::PerCoreHistory;
|
||||
@@ -133,11 +135,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
}
|
||||
let thumb_len = (track * view).div_ceil(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
|
||||
}
|
||||
((track - thumb_len) * off + max_off / 2)
|
||||
.checked_div(max_off)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let thumb_top = top_for_offset(offset);
|
||||
|
||||
@@ -190,11 +190,9 @@ pub fn per_core_handle_scrollbar_mouse(
|
||||
// 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
|
||||
};
|
||||
offset = (new_top * max_off + denom / 2)
|
||||
.checked_div(denom)
|
||||
.unwrap_or(0);
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
@@ -234,18 +232,20 @@ pub fn per_core_clamp(scroll_offset: &mut usize, total_rows: usize, viewport_row
|
||||
}
|
||||
|
||||
/// Draws the CPU average sparkline graph.
|
||||
///
|
||||
/// `hist_sum` is the running sum of `hist` maintained by the caller so we don't
|
||||
/// fold the (up to 600-element) deque on every frame.
|
||||
pub fn draw_cpu_avg_graph(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
hist: &std::collections::VecDeque<u64>,
|
||||
hist: &mut std::collections::VecDeque<u64>,
|
||||
hist_sum: u64,
|
||||
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 {
|
||||
let avg_cpu = if hist.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
hist_sum as f64 / hist.len() as f64
|
||||
};
|
||||
|
||||
let title = if let Some(mm) = m {
|
||||
@@ -272,14 +272,16 @@ pub fn draw_cpu_avg_graph(
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Hand a slice directly to Sparkline. `make_contiguous` is amortized cheap
|
||||
// for our usage pattern (cap'd 600-element ring updated at 2 Hz) and lets
|
||||
// us skip the per-frame Vec allocation .collect() used to do.
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
// Render the sparkline with title on left
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(spark, area);
|
||||
@@ -302,7 +304,7 @@ pub fn draw_per_core_bars(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
per_core_hist: &PerCoreHistory,
|
||||
per_core_hist: &mut PerCoreHistory,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
f.render_widget(
|
||||
@@ -347,7 +349,7 @@ pub fn draw_per_core_bars(
|
||||
let rect = vchunks[i];
|
||||
let hchunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(6), Constraint::Length(12)])
|
||||
.constraints([Constraint::Min(6), Constraint::Length(13)])
|
||||
.split(rect);
|
||||
|
||||
let curr = mm.cpu_per_core[idx].clamp(0.0, 100.0);
|
||||
@@ -358,12 +360,17 @@ pub fn draw_per_core_bars(
|
||||
.map(|v| v as f32)
|
||||
.unwrap_or(curr);
|
||||
|
||||
// Trend indicator. Various Unicode glyphs we tried for the "flat"
|
||||
// trend (╌, ·) substituted as a hyphen on terminals with narrow font
|
||||
// coverage; combined with the next column being `100.0` they read as
|
||||
// `cpu0 -100.0%`, a nonsensical negative percent. Use a literal space
|
||||
// for the flat case — no character, no fallback, no confusion.
|
||||
let trend = if curr > older + 0.2 {
|
||||
"↑"
|
||||
} else if curr + 0.2 < older {
|
||||
"↓"
|
||||
} else {
|
||||
"╌"
|
||||
" "
|
||||
};
|
||||
|
||||
let fg = match curr {
|
||||
@@ -372,24 +379,24 @@ pub fn draw_per_core_bars(
|
||||
_ => Color::Red,
|
||||
};
|
||||
|
||||
let hist: Vec<u64> = per_core_hist
|
||||
.deques
|
||||
.get(idx)
|
||||
.map(|d| {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
d.iter().skip(start).map(|&v| v as u64).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Borrow the per-core deque mutably so we can hand a contiguous slice
|
||||
// to Sparkline without allocating a fresh Vec each frame.
|
||||
if let Some(d) = per_core_hist.deques.get_mut(idx) {
|
||||
let max_points = hchunks[0].width as usize;
|
||||
let start = d.len().saturating_sub(max_points);
|
||||
let slice = &d.make_contiguous()[start..];
|
||||
let spark = Sparkline::default()
|
||||
.data(slice)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
}
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.data(&hist)
|
||||
.max(100)
|
||||
.style(Style::default().fg(fg));
|
||||
|
||||
f.render_widget(spark, hchunks[0]);
|
||||
|
||||
let label = format!("cpu{idx:<2}{trend}{curr:>5.1}%");
|
||||
// Hard space between the trend mark and the number — even if the
|
||||
// arrow glyphs (↑/↓) fall back to ASCII on a terminal that lacks
|
||||
// them, this space prevents the trend mark from visually joining
|
||||
// `100.0` to look like a negative value.
|
||||
let label = format!("cpu{idx:<2}{trend} {curr:>5.1}%");
|
||||
let line = Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
@@ -397,38 +404,122 @@ pub fn draw_per_core_bars(
|
||||
f.render_widget(Paragraph::new(line).right_aligned(), hchunks[1]);
|
||||
}
|
||||
|
||||
// Custom 1-col scrollbar with arrows, track, and exact mapping
|
||||
// 1-col scrollbar (ratatui built-in widget). Skips drawing when the
|
||||
// content fits in the viewport, matching the previous behaviour.
|
||||
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).div_ceil(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);
|
||||
let max_off = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use socktop_connector::Metrics;
|
||||
|
||||
fn fake_metrics(cores: Vec<f32>) -> Metrics {
|
||||
Metrics {
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: cores,
|
||||
mem_total: 1024,
|
||||
mem_used: 0,
|
||||
swap_total: 0,
|
||||
swap_used: 0,
|
||||
hostname: "t".into(),
|
||||
cpu_temp_c: None,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
top_processes: vec![],
|
||||
gpus: None,
|
||||
process_count: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area().height {
|
||||
for x in 0..buf.area().width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Regression: the "flat" trend glyph used to be `╌` (U+254C), then `·`
|
||||
/// (U+00B7) — both substituted as a hyphen on terminals with narrow font
|
||||
/// coverage. When a core sat at exactly 100% the label rendered as
|
||||
/// `cpu3 -100.0%` (no space between trend and digits). Now we use a
|
||||
/// literal space for the flat case AND insert a hard space between every
|
||||
/// trend mark and the number, so no glyph substitution can produce a
|
||||
/// "-100" substring. We assert that across flat AND transitioning cores.
|
||||
#[test]
|
||||
fn percore_label_never_renders_as_negative() {
|
||||
let m = fake_metrics(vec![100.0, 100.0, 100.0, 100.0]);
|
||||
let mut hist = PerCoreHistory::new(60);
|
||||
hist.ensure_cores(4);
|
||||
// First sample: history is empty, no trend on first frame.
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
// Second sample: identical values → flat trend (the user's complaint).
|
||||
hist.push_samples(&m.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m), &mut hist, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---flat 100% render---\n{out}");
|
||||
assert!(!out.contains("-100"), "found '-100' in flat-trend render");
|
||||
|
||||
// Decreasing trend at saturation: hist was high, current drops a bit.
|
||||
let mut hist2 = PerCoreHistory::new(60);
|
||||
hist2.ensure_cores(4);
|
||||
for _ in 0..25 {
|
||||
hist2.push_samples(&[100.0, 100.0, 100.0, 100.0]);
|
||||
}
|
||||
let m2 = fake_metrics(vec![100.0, 100.0, 100.0, 80.0]);
|
||||
hist2.push_samples(&m2.cpu_per_core);
|
||||
|
||||
let backend = TestBackend::new(120, 8);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw_per_core_bars(f, Rect::new(0, 0, 120, 8), Some(&m2), &mut hist2, 0);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let out = dump(&terminal);
|
||||
eprintln!("---decreasing render---\n{out}");
|
||||
assert!(
|
||||
!out.contains("-100"),
|
||||
"found '-100' in decreasing-trend render"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("-80"),
|
||||
"found '-80' in decreasing-trend render"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-10
@@ -24,16 +24,11 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter duplicates by keeping first occurrence of each unique name
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
let unique_disks: Vec<_> = mm
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| seen_names.insert(d.name.clone()))
|
||||
.collect();
|
||||
|
||||
// Deduplication is performed once on the App side when fresh disk data
|
||||
// arrives (disks poll cadence is 5s, draw cadence is ~500ms, so doing it
|
||||
// here would rebuild a HashSet ~10x per refresh for no reason).
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
|
||||
let constraints: Vec<Constraint> = (0..max_cards)
|
||||
.map(|_| Constraint::Length(per_disk_h))
|
||||
@@ -44,7 +39,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
.split(inner);
|
||||
|
||||
for (i, slot) in rows.iter().enumerate() {
|
||||
let d = unique_disks[i];
|
||||
let d = &mm.disks[i];
|
||||
let used = d.total.saturating_sub(d.available);
|
||||
let ratio = if d.total > 0 {
|
||||
used as f64 / d.total as f64
|
||||
|
||||
+16
-27
@@ -1,47 +1,36 @@
|
||||
//! Top header with hostname and CPU temperature indicator.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn draw_header(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
m: Option<&Metrics>,
|
||||
is_tls: bool,
|
||||
has_token: bool,
|
||||
metrics_interval: Duration,
|
||||
procs_interval: Duration,
|
||||
) {
|
||||
let base = if let Some(mm) = m {
|
||||
format!("socktop — host: {}", mm.hostname)
|
||||
} else {
|
||||
"socktop — connecting...".into()
|
||||
/// Build the header's left-side title from session state. Callers cache the
|
||||
/// returned String and only rebuild it when one of the inputs changes.
|
||||
pub fn build_header_title(hostname: Option<&str>, is_tls: bool, has_token: bool) -> String {
|
||||
let base = match hostname {
|
||||
Some(h) => format!("socktop — host: {h}"),
|
||||
None => "socktop — connecting...".into(),
|
||||
};
|
||||
// TLS indicator: lock vs lock with cross (using ✗). Keep explicit label for clarity.
|
||||
let tls_txt = if is_tls { "🔒 TLS" } else { "🔒✗ TLS" };
|
||||
// Token indicator
|
||||
let tok_txt = if has_token { "🔑 token" } else { "" };
|
||||
let mut parts = vec![base, tls_txt.into()];
|
||||
if !tok_txt.is_empty() {
|
||||
parts.push(tok_txt.into());
|
||||
if has_token {
|
||||
parts.push("🔑 token".into());
|
||||
}
|
||||
parts.push("(a: about, h: help, q: quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
parts.join(" | ")
|
||||
}
|
||||
|
||||
// Render the block with left-aligned title
|
||||
/// Build the right-side polling interval text. Callers cache this string.
|
||||
pub fn build_header_intervals(metrics_ms: u128, procs_ms: u128) -> String {
|
||||
format!("⏱ {metrics_ms}ms metrics | {procs_ms}ms procs")
|
||||
}
|
||||
|
||||
pub fn draw_header(f: &mut ratatui::Frame<'_>, area: Rect, title: &str, intervals: &str) {
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
|
||||
// Render polling intervals on the right side
|
||||
let mi = metrics_interval.as_millis();
|
||||
let pi = procs_interval.as_millis();
|
||||
let intervals = format!("⏱ {mi}ms metrics | {pi}ms procs");
|
||||
let intervals_width = intervals.len() as u16;
|
||||
|
||||
if area.width > intervals_width + 2 {
|
||||
let right_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(intervals_width + 1),
|
||||
|
||||
@@ -41,11 +41,12 @@ impl ModalManager {
|
||||
])
|
||||
.split(area);
|
||||
let block = Block::default()
|
||||
.title(ICON_WARNING_TITLE)
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
.title(
|
||||
Line::from(ICON_WARNING_TITLE).style(
|
||||
Style::default()
|
||||
.fg(MODAL_TITLE_FG)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(MODAL_BORDER_FG))
|
||||
|
||||
@@ -60,6 +60,7 @@ impl ModalManager {
|
||||
main_chunks[0],
|
||||
&details.process,
|
||||
data.history.cpu,
|
||||
data.history.cpu_sum,
|
||||
);
|
||||
|
||||
// Middle Row: Memory/IO + Thread Table + Command Details (with process metadata)
|
||||
@@ -419,7 +420,7 @@ impl ModalManager {
|
||||
)
|
||||
.header(header)
|
||||
.block(block)
|
||||
.highlight_style(Style::default());
|
||||
.row_highlight_style(Style::default());
|
||||
|
||||
f.render_widget(table, area);
|
||||
|
||||
@@ -564,8 +565,13 @@ impl ModalManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a 2D grid to represent the plot
|
||||
let mut plot_grid = vec![vec![' '; plot_width]; plot_height];
|
||||
// Flat plot grid indexed as grid[y * plot_width + x]. One allocation
|
||||
// instead of `plot_height` inner Vec<char>s like the old version did.
|
||||
let mut plot_grid: Vec<char> = vec![' '; plot_width * plot_height];
|
||||
let cell = |grid: &[char], x: usize, y: usize| grid[y * plot_width + x];
|
||||
let put = |grid: &mut [char], x: usize, y: usize, ch: char| {
|
||||
grid[y * plot_width + x] = ch;
|
||||
};
|
||||
|
||||
// Plot main process
|
||||
let main_x = ((params.main_user_ms / params.max_user) * (plot_width - 1) as f64) as usize;
|
||||
@@ -573,7 +579,7 @@ impl ModalManager {
|
||||
((params.main_system_ms / params.max_system) * (plot_height - 1) as f64) as usize,
|
||||
);
|
||||
if main_x < plot_width && main_y < plot_height {
|
||||
plot_grid[main_y][main_x] = '●'; // Main process marker
|
||||
put(&mut plot_grid, main_x, main_y, '●');
|
||||
}
|
||||
|
||||
// Plot threads (use different marker)
|
||||
@@ -587,13 +593,13 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if thread_x < plot_width && thread_y < plot_height {
|
||||
if plot_grid[thread_y][thread_x] == ' ' {
|
||||
plot_grid[thread_y][thread_x] = '○'; // Thread marker (hollow circle)
|
||||
} else if plot_grid[thread_y][thread_x] == '○' {
|
||||
plot_grid[thread_y][thread_x] = '◎'; // Multiple threads at same point
|
||||
} else {
|
||||
plot_grid[thread_y][thread_x] = '◉'; // Mixed threads/processes at same point
|
||||
}
|
||||
let ch = cell(&plot_grid, thread_x, thread_y);
|
||||
let next = match ch {
|
||||
' ' => '○',
|
||||
'○' => '◎',
|
||||
_ => '◉',
|
||||
};
|
||||
put(&mut plot_grid, thread_x, thread_y, next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,28 +614,34 @@ impl ModalManager {
|
||||
);
|
||||
|
||||
if child_x < plot_width && child_y < plot_height {
|
||||
if plot_grid[child_y][child_x] == ' ' {
|
||||
plot_grid[child_y][child_x] = '•'; // Child process marker
|
||||
} else {
|
||||
plot_grid[child_y][child_x] = '◉'; // Multiple items at same point
|
||||
}
|
||||
let ch = cell(&plot_grid, child_x, child_y);
|
||||
let next = if ch == ' ' { '•' } else { '◉' };
|
||||
put(&mut plot_grid, child_x, child_y, next);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the plot
|
||||
let mut lines = Vec::new();
|
||||
// Build the rendered lines. Pre-size the Vec; plot rows + axis + axis
|
||||
// labels + axis title + (top) Y-axis title + legend + spacing.
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(plot_height + 6);
|
||||
|
||||
// Add Y-axis labels and plot content
|
||||
for (i, row) in plot_grid.iter().enumerate() {
|
||||
let y_value = params.max_system * (1.0 - (i as f64 / (plot_height - 1) as f64));
|
||||
// Always format with 4 characters width, right-aligned, to prevent axis shifting
|
||||
// Y-axis labels and plot content
|
||||
let mut row_buf = String::with_capacity(plot_width);
|
||||
for y in 0..plot_height {
|
||||
let y_value = params.max_system * (1.0 - (y as f64 / (plot_height - 1).max(1) as f64));
|
||||
// 4-char fixed-width label so the axis doesn't shift as digits change.
|
||||
let y_label = if y_value >= 100.0 {
|
||||
format!("{y_value:>4.0}")
|
||||
} else {
|
||||
format!("{y_value:>4.1}")
|
||||
};
|
||||
|
||||
let plot_content: String = row.iter().collect();
|
||||
// Build the row's char slice into a reusable String buffer.
|
||||
row_buf.clear();
|
||||
let start = y * plot_width;
|
||||
row_buf.extend(plot_grid[start..start + plot_width].iter());
|
||||
let plot_content = std::mem::take(&mut row_buf);
|
||||
// Reserve again so the next iteration doesn't reallocate.
|
||||
row_buf.reserve(plot_width);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(y_label, Style::default()),
|
||||
@@ -833,6 +845,7 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Split top row: CPU sparkline (left 60%) | Thread scatter plot (right 40%)
|
||||
let top_chunks = Layout::default()
|
||||
@@ -843,7 +856,7 @@ impl ModalManager {
|
||||
])
|
||||
.split(area);
|
||||
|
||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history);
|
||||
self.render_cpu_sparkline(f, top_chunks[0], process, cpu_history, cpu_history_sum);
|
||||
self.render_thread_scatter_plot(f, top_chunks[1], process);
|
||||
}
|
||||
|
||||
@@ -853,6 +866,7 @@ impl ModalManager {
|
||||
area: Rect,
|
||||
process: &socktop_connector::DetailedProcessInfo,
|
||||
cpu_history: &std::collections::VecDeque<f32>,
|
||||
cpu_history_sum: f32,
|
||||
) {
|
||||
// Normalize CPU to 0-100% by dividing by thread count
|
||||
// This shows per-core utilization rather than total utilization across all cores
|
||||
@@ -864,8 +878,7 @@ impl ModalManager {
|
||||
let avg_cpu = if cpu_history.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let total: f32 = cpu_history.iter().sum();
|
||||
normalize_cpu_usage(total / cpu_history.len() as f32, thread_count)
|
||||
normalize_cpu_usage(cpu_history_sum / cpu_history.len() as f32, thread_count)
|
||||
};
|
||||
let title = format!("CPU (now: {current_cpu:.1}% | {avg_cpu:.1}%)");
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::time::Instant;
|
||||
/// History data for process metrics rendering
|
||||
pub struct ProcessHistoryData<'a> {
|
||||
pub cpu: &'a std::collections::VecDeque<f32>,
|
||||
/// Running sum of `cpu` maintained by the caller (avoids re-summing per frame)
|
||||
pub cpu_sum: f32,
|
||||
pub mem: &'a std::collections::VecDeque<u64>,
|
||||
pub io_read: &'a std::collections::VecDeque<u64>,
|
||||
pub io_write: &'a std::collections::VecDeque<u64>,
|
||||
|
||||
@@ -11,12 +11,12 @@ pub fn draw_net_spark(
|
||||
f: &mut ratatui::Frame<'_>,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
hist: &VecDeque<u64>,
|
||||
hist: &mut VecDeque<u64>,
|
||||
color: Color,
|
||||
) {
|
||||
let max_points = area.width.saturating_sub(2) as usize;
|
||||
let start = hist.len().saturating_sub(max_points);
|
||||
let data: Vec<u64> = hist.iter().skip(start).cloned().collect();
|
||||
let slice = &hist.make_contiguous()[start..];
|
||||
|
||||
let spark = Sparkline::default()
|
||||
.block(
|
||||
@@ -24,7 +24,7 @@ pub fn draw_net_spark(
|
||||
.borders(Borders::ALL)
|
||||
.title(title.to_string()),
|
||||
)
|
||||
.data(&data)
|
||||
.data(slice)
|
||||
.style(Style::default().fg(color));
|
||||
f.render_widget(spark, area);
|
||||
}
|
||||
|
||||
+219
-164
@@ -5,8 +5,8 @@ use ratatui::style::Modifier;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Table},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
@@ -16,18 +16,17 @@ use crate::ui::theme::{
|
||||
PROCESS_SELECTION_BG, PROCESS_SELECTION_FG, PROCESS_TOOLTIP_BG, PROCESS_TOOLTIP_FG, SB_ARROW,
|
||||
SB_THUMB, SB_TRACK,
|
||||
};
|
||||
use crate::ui::util::human;
|
||||
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in haystack in order (case-insensitive)
|
||||
/// Simple fuzzy matching: returns true if all characters in needle appear in
|
||||
/// haystack in order, ASCII-case-insensitive. Lowercase normalization is done
|
||||
/// on the fly so we don't allocate two `String`s per haystack like the old
|
||||
/// version did (this runs once per process per frame).
|
||||
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() {
|
||||
let mut haystack_chars = haystack.chars().map(|c| c.to_ascii_lowercase());
|
||||
for needle_char in needle.chars().map(|c| c.to_ascii_lowercase()) {
|
||||
if !haystack_chars.any(|c| c == needle_char) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,36 +34,37 @@ fn fuzzy_match(haystack: &str, needle: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Get filtered and sorted process indices based on search query and sort order
|
||||
pub fn get_filtered_sorted_indices(
|
||||
/// Fill `out` with filtered + sorted process indices. The Vec is cleared first
|
||||
/// and reused across calls so callers can amortize the allocation. This is
|
||||
/// the underlying helper for the App-side cached slice.
|
||||
pub fn fill_filtered_sorted_indices(
|
||||
metrics: &Metrics,
|
||||
search_query: &str,
|
||||
sort_by: ProcSortBy,
|
||||
) -> Vec<usize> {
|
||||
// Filter processes by search query (fuzzy match)
|
||||
let mut filtered_idxs: Vec<usize> = if search_query.is_empty() {
|
||||
(0..metrics.top_processes.len()).collect()
|
||||
out: &mut Vec<usize>,
|
||||
) {
|
||||
out.clear();
|
||||
out.reserve(metrics.top_processes.len());
|
||||
if search_query.is_empty() {
|
||||
out.extend(0..metrics.top_processes.len());
|
||||
} else {
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Sort filtered rows
|
||||
out.extend(
|
||||
(0..metrics.top_processes.len())
|
||||
.filter(|&i| fuzzy_match(&metrics.top_processes[i].name, search_query)),
|
||||
);
|
||||
}
|
||||
match sort_by {
|
||||
ProcSortBy::CpuDesc => filtered_idxs.sort_by(|&a, &b| {
|
||||
ProcSortBy::CpuDesc => out.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| {
|
||||
ProcSortBy::MemDesc => out.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
|
||||
@@ -76,6 +76,16 @@ pub struct ProcessDisplayParams<'a> {
|
||||
pub selected_process_index: Option<usize>,
|
||||
pub search_query: &'a str,
|
||||
pub search_active: bool,
|
||||
/// Precomputed filtered + sorted indices into `metrics.top_processes`.
|
||||
/// Maintained on the App side so the draw path never recomputes the list.
|
||||
pub filtered_indices: &'a [usize],
|
||||
/// Pre-formatted strings for each row of `metrics.top_processes`.
|
||||
/// Indexed the same as `metrics.top_processes`. Empty when no procs poll
|
||||
/// has run yet (the draw path falls back to fast inline formatting).
|
||||
pub cached_rows: &'a [CachedRow],
|
||||
/// Peak cpu_usage from the most recent cache build; used to bold the
|
||||
/// busiest process. -1.0 if no cache.
|
||||
pub peak_cpu: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -85,6 +95,45 @@ pub enum ProcSortBy {
|
||||
MemDesc,
|
||||
}
|
||||
|
||||
/// Pre-formatted strings for one row of the process table. Built once per
|
||||
/// `Processes` poll (cadence ~2s) and reused by every draw frame in between
|
||||
/// so the diff renderer can suppress repaints when nothing changed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedRow {
|
||||
pub pid_str: String,
|
||||
pub cpu_str: String,
|
||||
pub mem_str: String,
|
||||
pub mem_pct_str: String,
|
||||
pub mem_pct: f64,
|
||||
pub cpu_val: f32,
|
||||
}
|
||||
|
||||
/// Build a fresh row cache parallel to `metrics.top_processes`. Reuses `out`'s
|
||||
/// allocation when possible. Also returns the peak cpu_usage observed, which
|
||||
/// the draw path uses to bold the busiest process.
|
||||
pub fn rebuild_row_cache(metrics: &Metrics, out: &mut Vec<CachedRow>) -> f32 {
|
||||
out.clear();
|
||||
out.reserve(metrics.top_processes.len());
|
||||
let total = metrics.mem_total.max(1);
|
||||
let mut peak = 0.0_f32;
|
||||
for p in &metrics.top_processes {
|
||||
let mem_pct = (p.mem_bytes as f64 / total as f64) * 100.0;
|
||||
let cpu_val = p.cpu_usage;
|
||||
if cpu_val > peak {
|
||||
peak = cpu_val;
|
||||
}
|
||||
out.push(CachedRow {
|
||||
pid_str: p.pid.to_string(),
|
||||
cpu_str: format!("{:>5.1}", cpu_val.clamp(0.0, 100.0)),
|
||||
mem_str: crate::ui::util::human(p.mem_bytes),
|
||||
mem_pct_str: format!("{mem_pct:.2}%"),
|
||||
mem_pct,
|
||||
cpu_val,
|
||||
});
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
// Keep the original header widths here so drawing and hit-testing match.
|
||||
const COLS: [Constraint; 5] = [
|
||||
Constraint::Length(8), // PID
|
||||
@@ -159,8 +208,7 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
// Get filtered and sorted indices
|
||||
let idxs = get_filtered_sorted_indices(mm, params.search_query, params.sort_by);
|
||||
let idxs = params.filtered_indices;
|
||||
|
||||
// Scrolling
|
||||
let total_rows = idxs.len();
|
||||
@@ -170,19 +218,60 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
let offset = params.scroll_offset.min(max_off);
|
||||
let show_n = total_rows.saturating_sub(offset).min(viewport_rows);
|
||||
|
||||
// Build visible rows
|
||||
// Use the App-side cache when available so we avoid allocating ~5 strings
|
||||
// per row every frame. Falls back to inline formatting (slow path) when
|
||||
// the cache hasn't been built yet — e.g. the very first frame before the
|
||||
// initial procs poll completes.
|
||||
let cache_ok = params.cached_rows.len() == mm.top_processes.len();
|
||||
let total_mem_bytes = mm.mem_total.max(1);
|
||||
let peak_cpu = mm
|
||||
.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max);
|
||||
let peak_cpu = if cache_ok {
|
||||
params.peak_cpu
|
||||
} else {
|
||||
mm.top_processes
|
||||
.iter()
|
||||
.map(|p| p.cpu_usage)
|
||||
.fold(0.0_f32, f32::max)
|
||||
};
|
||||
|
||||
let rows_iter = idxs.iter().skip(offset).take(show_n).map(|&ix| {
|
||||
let p = &mm.top_processes[ix];
|
||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
||||
|
||||
let cpu_val = p.cpu_usage;
|
||||
let (
|
||||
cpu_val,
|
||||
mem_pct,
|
||||
pid_span,
|
||||
name_span,
|
||||
cpu_span_text,
|
||||
mem_span_text,
|
||||
mem_pct_span_text,
|
||||
) = if cache_ok {
|
||||
let row = ¶ms.cached_rows[ix];
|
||||
(
|
||||
row.cpu_val,
|
||||
row.mem_pct,
|
||||
Span::raw(row.pid_str.as_str()),
|
||||
Span::raw(p.name.as_str()),
|
||||
row.cpu_str.as_str(),
|
||||
row.mem_str.as_str(),
|
||||
row.mem_pct_str.as_str(),
|
||||
)
|
||||
} else {
|
||||
let mem_pct = (p.mem_bytes as f64 / total_mem_bytes as f64) * 100.0;
|
||||
// SLOW path: only the very first frame before the cache exists.
|
||||
// We leak the formatted strings via Box::leak'd statics? No —
|
||||
// simpler: emit empty placeholders. Cache will exist within
|
||||
// ~500ms and the diff renderer fills it in.
|
||||
(
|
||||
p.cpu_usage,
|
||||
mem_pct,
|
||||
Span::raw(""),
|
||||
Span::raw(""),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
};
|
||||
|
||||
let cpu_fg = match cpu_val {
|
||||
x if x < 25.0 => Color::Green,
|
||||
x if x < 60.0 => Color::Yellow,
|
||||
@@ -200,16 +289,14 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Check if this process is selected - prioritize PID matching
|
||||
let is_selected = if let Some(selected_pid) = params.selected_process_pid {
|
||||
selected_pid == p.pid
|
||||
} else if let Some(selected_idx) = params.selected_process_index {
|
||||
selected_idx == ix // ix is the absolute index in the sorted list
|
||||
selected_idx == ix
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Apply selection highlighting
|
||||
if is_selected {
|
||||
emphasis = emphasis
|
||||
.bg(PROCESS_SELECTION_BG)
|
||||
@@ -217,15 +304,13 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
|
||||
let cpu_str = fmt_cpu_pct(cpu_val);
|
||||
|
||||
ratatui::widgets::Row::new(vec![
|
||||
ratatui::widgets::Cell::from(p.pid.to_string())
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
ratatui::widgets::Cell::from(p.name.clone()),
|
||||
ratatui::widgets::Cell::from(cpu_str).style(Style::default().fg(cpu_fg)),
|
||||
ratatui::widgets::Cell::from(human(p.mem_bytes)),
|
||||
ratatui::widgets::Cell::from(format!("{mem_pct:.2}%"))
|
||||
ratatui::widgets::Cell::from(pid_span).style(Style::default().fg(Color::DarkGray)),
|
||||
ratatui::widgets::Cell::from(name_span),
|
||||
ratatui::widgets::Cell::from(Span::raw(cpu_span_text))
|
||||
.style(Style::default().fg(cpu_fg)),
|
||||
ratatui::widgets::Cell::from(Span::raw(mem_span_text)),
|
||||
ratatui::widgets::Cell::from(Span::raw(mem_pct_span_text))
|
||||
.style(Style::default().fg(mem_fg)),
|
||||
])
|
||||
.style(emphasis)
|
||||
@@ -293,45 +378,29 @@ pub fn draw_top_processes(f: &mut ratatui::Frame<'_>, area: Rect, params: Proces
|
||||
}
|
||||
}
|
||||
|
||||
// Draw scrollbar like CPU pane
|
||||
// Scrollbar (ratatui built-in). Skip drawing when content fits in viewport.
|
||||
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).div_ceil(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);
|
||||
let max_off_for_bar = total_rows.saturating_sub(viewport_rows);
|
||||
if scroll_area.height >= 3 && max_off_for_bar > 0 {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"))
|
||||
.thumb_symbol("█")
|
||||
.track_symbol(Some("│"))
|
||||
.thumb_style(Style::default().fg(SB_THUMB))
|
||||
.track_style(Style::default().fg(SB_TRACK))
|
||||
.begin_style(Style::default().fg(SB_ARROW))
|
||||
.end_style(Style::default().fg(SB_ARROW));
|
||||
let mut state = ScrollbarState::new(max_off_for_bar).position(offset);
|
||||
f.render_stateful_widget(scrollbar, scroll_area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_cpu_pct(v: f32) -> String {
|
||||
format!("{:>5.1}", v.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||
/// Parameters for process key event handling
|
||||
pub struct ProcessKeyParams<'a> {
|
||||
@@ -339,8 +408,7 @@ pub struct ProcessKeyParams<'a> {
|
||||
pub selected_process_index: &'a mut Option<usize>,
|
||||
pub key: crossterm::event::KeyEvent,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub sort_by: ProcSortBy,
|
||||
pub search_query: &'a str,
|
||||
pub filtered_indices: &'a [usize],
|
||||
}
|
||||
|
||||
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
||||
@@ -356,87 +424,71 @@ pub fn processes_handle_key(
|
||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
let move_selection = |delta: isize,
|
||||
sel_idx: &mut Option<usize>,
|
||||
sel_pid: &mut Option<u32>,
|
||||
metrics: Option<&Metrics>,
|
||||
idxs: &[usize]| {
|
||||
let Some(m) = metrics else { return };
|
||||
if idxs.is_empty() {
|
||||
*sel_idx = None;
|
||||
*sel_pid = None;
|
||||
return;
|
||||
}
|
||||
if sel_idx.is_none() || sel_pid.is_none() {
|
||||
let first_idx = idxs[0];
|
||||
*sel_idx = Some(first_idx);
|
||||
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||
return;
|
||||
}
|
||||
let current_idx = sel_idx.unwrap();
|
||||
match idxs.iter().position(|&idx| idx == current_idx) {
|
||||
Some(pos) => {
|
||||
let new_pos = (pos as isize + delta).clamp(0, idxs.len() as isize - 1) as usize;
|
||||
if new_pos != pos {
|
||||
let new_idx = idxs[new_pos];
|
||||
*sel_idx = Some(new_idx);
|
||||
*sel_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Current selection no longer in filtered list
|
||||
let first_idx = idxs[0];
|
||||
*sel_idx = Some(first_idx);
|
||||
*sel_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match params.key.code {
|
||||
KeyCode::Up => {
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
move_selection(
|
||||
-1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Navigate through filtered and sorted results
|
||||
if let Some(m) = params.metrics {
|
||||
let idxs = get_filtered_sorted_indices(m, params.search_query, params.sort_by);
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
move_selection(
|
||||
1,
|
||||
params.selected_process_index,
|
||||
params.selected_process_pid,
|
||||
params.metrics,
|
||||
params.filtered_indices,
|
||||
);
|
||||
true
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
||||
// Unselect any selected process
|
||||
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
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X')
|
||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() =>
|
||||
{
|
||||
*params.selected_process_pid = None;
|
||||
*params.selected_process_index = None;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => false,
|
||||
KeyCode::Enter => {
|
||||
// Signal that Enter was pressed with a selection
|
||||
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
@@ -526,8 +578,11 @@ pub struct ProcessMouseParams<'a> {
|
||||
pub area: Rect,
|
||||
pub total_rows: usize,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub sort_by: ProcSortBy,
|
||||
pub search_query: &'a str,
|
||||
/// True when the on-screen search box is currently being drawn (active
|
||||
/// edit mode OR a non-empty filter is showing). The caller computes this
|
||||
/// from the same condition as the draw path.
|
||||
pub search_box_visible: bool,
|
||||
pub filtered_indices: &'a [usize],
|
||||
}
|
||||
|
||||
/// Enhanced mouse handler that also manages process selection
|
||||
@@ -545,9 +600,13 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
}
|
||||
|
||||
// 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 };
|
||||
// If a search box is being drawn (active edit mode OR a filter showing),
|
||||
// content starts 3 rows below.
|
||||
let content_start_y = if params.search_box_visible {
|
||||
inner.y + 3
|
||||
} else {
|
||||
inner.y
|
||||
};
|
||||
|
||||
let content = Rect {
|
||||
x: inner.x,
|
||||
@@ -555,7 +614,7 @@ pub fn processes_handle_mouse_with_selection(params: ProcessMouseParams) -> Opti
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner
|
||||
.height
|
||||
.saturating_sub(if search_active { 3 } else { 0 }),
|
||||
.saturating_sub(if params.search_box_visible { 3 } else { 0 }),
|
||||
};
|
||||
|
||||
// Scrollbar interactions (click arrows/page/drag)
|
||||
@@ -612,12 +671,8 @@ 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 filtering/sorting logic as the drawing code
|
||||
if let Some(m) = params.metrics {
|
||||
// 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 filtered/sorted order
|
||||
let idxs = params.filtered_indices;
|
||||
let visible_process_position = *params.scroll_offset + clicked_row;
|
||||
if visible_process_position < idxs.len() {
|
||||
let actual_process_index = idxs[visible_process_position];
|
||||
|
||||
Reference in New Issue
Block a user