From 99029e85020999b29d9219367fe32cce9cdaaac0 Mon Sep 17 00:00:00 2001 From: Michael Willett <51060085+m12t@users.noreply.github.com> Date: Thu, 13 Feb 2025 04:49:14 +0000 Subject: [PATCH] Add scrollbar widget to the tabular view. Rendering of the scrollbar is limited to only when content exceeds the vertical capacity of the terminal window. This threshold dynamically adjust if the terminal is resized at runtime. Wrapping navigation behavior on the table was removed to improve UX. --- src/app.rs | 59 +++++++++++++++++++++++++++++++++++++++-------------- src/main.rs | 34 ++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 961d2ca..1128472 100755 --- a/src/app.rs +++ b/src/app.rs @@ -18,6 +18,7 @@ use crate::{bpf_program::{BpfProgram, Process}, helpers::program_type_to_string}; use circular_buffer::CircularBuffer; use libbpf_rs::{query::ProgInfoIter, Iter, Link}; +use ratatui::widgets::ScrollbarState; use ratatui::widgets::TableState; use std::{ collections::HashMap, @@ -33,6 +34,8 @@ use tui_input::Input; pub struct App { pub mode: Mode, pub table_state: TableState, + pub vertical_scroll: usize, + pub vertical_scroll_state: ScrollbarState, pub header_columns: [String; 7], pub items: Arc>>, pub data_buf: Arc>>, @@ -119,6 +122,8 @@ impl App { pub fn new() -> App { let mut app = App { mode: Mode::Table, + vertical_scroll: 0, + vertical_scroll_state: ScrollbarState::new(0), table_state: TableState::default(), header_columns: [ String::from("ID"), @@ -302,14 +307,16 @@ impl App { let i = match self.table_state.selected() { Some(i) => { if i >= items.len() - 1 { - 0 + items.len() - 1 } else { + self.vertical_scroll = self.vertical_scroll.saturating_add(1); i + 1 } } None => 0, }; self.table_state.select(Some(i)); + self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); } } @@ -319,14 +326,16 @@ impl App { let i = match self.table_state.selected() { Some(i) => { if i == 0 { - items.len() - 1 + 0 } else { + self.vertical_scroll = self.vertical_scroll.saturating_sub(1); i - 1 } } - None => items.len() - 1, + None => return, // do nothing if table_state == None && previous_program() called }; self.table_state.select(Some(i)); + self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); } } @@ -473,19 +482,24 @@ mod tests { app.items.lock().unwrap().push(prog_2.clone()); // Initially no item is selected - assert_eq!(app.selected_program(), None); + assert_eq!(app.selected_program(), None, "expected no program"); + assert_eq!(app.vertical_scroll, 0, "expected init with 0, got: {}", app.vertical_scroll); // After calling next, the first item should be selected app.next_program(); - assert_eq!(app.selected_program(), Some(prog_1.clone())); + assert_eq!(app.selected_program(), Some(prog_1.clone()), "expected prog_1"); + assert_eq!(app.vertical_scroll, 0, "expected scroll 0, got: {}", app.vertical_scroll); // After calling next again, the second item should be selected app.next_program(); - assert_eq!(app.selected_program(), Some(prog_2.clone())); + assert_eq!(app.selected_program(), Some(prog_2.clone()), "expected prog_2"); + assert_eq!(app.vertical_scroll, 1, "expected scroll 1, got: {}", app.vertical_scroll); - // After calling next again, we should wrap around to the first item + // After calling next again, the second item should still be selected without wrapping app.next_program(); - assert_eq!(app.selected_program(), Some(prog_1.clone())); + assert_eq!(app.selected_program(), Some(prog_2.clone()), "expected prog_2; no wrap around"); + assert_eq!(app.vertical_scroll, 1, "expected scroll 1, got: {}", app.vertical_scroll); + } #[test] @@ -494,10 +508,17 @@ mod tests { // Initially no item is selected assert_eq!(app.selected_program(), None); + + // Initially ScrollbarState is 0 + assert_eq!(app.vertical_scroll_state, ScrollbarState::new(0), "unexpected ScrollbarState"); + assert_eq!(app.vertical_scroll, 0, "expected 0 vertical_scroll, got: {}", app.vertical_scroll); // After calling previous, no item should be selected app.previous_program(); assert_eq!(app.selected_program(), None); + + assert_eq!(app.vertical_scroll_state, ScrollbarState::new(0), "unexpected ScrollbarState"); + assert_eq!(app.vertical_scroll, 0, "expected 0 vertical_scroll, got: {}", app.vertical_scroll); } #[test] @@ -534,19 +555,27 @@ mod tests { app.items.lock().unwrap().push(prog_2.clone()); // Initially no item is selected - assert_eq!(app.selected_program(), None); + assert_eq!(app.selected_program(), None, "expected no program"); + assert_eq!(app.vertical_scroll, 0, "expected init with 0"); - // After calling previous, the last item should be selected + // After calling previous with no table state, nothing should be selected app.previous_program(); - assert_eq!(app.selected_program(), Some(prog_2.clone())); + assert_eq!(app.selected_program(), None, "expected None"); + assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping"); - // After calling previous again, the first item should be selected + // After calling previous again, still nothing should be selected app.previous_program(); - assert_eq!(app.selected_program(), Some(prog_1.clone())); + assert_eq!(app.selected_program(), None, "still None"); + assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping"); + + app.next_program(); // populate table state and expect prog_1 selected + assert_eq!(app.selected_program(), Some(prog_1.clone()), "expected prog_1"); + assert_eq!(app.vertical_scroll, 0, "expected scroll 0"); - // After calling previous again, we should wrap around to the last item + // After calling previous again, prog_1 should still be selected (0th index) app.previous_program(); - assert_eq!(app.selected_program(), Some(prog_2.clone())); + assert_eq!(app.selected_program(), Some(prog_1.clone()), "still expecting prog_1"); + assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping"); } #[test] diff --git a/src/main.rs b/src/main.rs index fd977fd..a034ca0 100755 --- a/src/main.rs +++ b/src/main.rs @@ -36,12 +36,13 @@ use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::Line; use ratatui::widgets::{ Axis, Block, BorderType, Borders, Cell, Chart, Dataset, GraphType, Padding, Paragraph, Row, - Table, + Scrollbar, ScrollbarOrientation, Table, }; use ratatui::{symbols, Frame, Terminal}; use std::fs; use std::io::{self, Stdout}; use std::mem::MaybeUninit; +use std::ops::{Add, Mul}; use std::os::fd::{FromRawFd, OwnedFd}; use std::panic; use std::time::Duration; @@ -70,6 +71,12 @@ const SORT_INFO_FOOTER: &str = "(Esc) back"; const PROCFS_BPF_STATS_ENABLED: &str = "/proc/sys/kernel/bpf_stats_enabled"; +const TABLE_HEADER_HEIGHT: u16 = 1; +const TABLE_HEADER_MARGIN: u16 = 1; +const TABLE_ROW_HEIGHT: u16 = 1; +const TABLE_ROW_MARGIN: u16 = 1; +const TABLE_FOOTER_HEIGHT: u16 = 1; // derived from `TABLE_FOOTER` + #[derive(Parser, Debug)] #[command( name = env!("CARGO_PKG_NAME"), @@ -82,12 +89,10 @@ const PROCFS_BPF_STATS_ENABLED: &str = "/proc/sys/kernel/bpf_stats_enabled"; about = env!("CARGO_PKG_DESCRIPTION"), override_usage = "sudo bpftop" )] -struct Bpftop { -} +struct Bpftop {} impl From<&BpfProgram> for Row<'_> { fn from(bpf_program: &BpfProgram) -> Self { - let height = 1; let cells = vec![ Cell::from(bpf_program.id.to_string()), Cell::from(bpf_program.bpf_type.to_string()), @@ -98,7 +103,7 @@ impl From<&BpfProgram> for Row<'_> { Cell::from(format_percent(bpf_program.cpu_time_percent())), ]; - Row::new(cells).height(height as u16).bottom_margin(1) + Row::new(cells).height(TABLE_ROW_HEIGHT).bottom_margin(TABLE_ROW_MARGIN) } } @@ -132,7 +137,7 @@ impl Drop for TerminalManager { fn main() -> Result<()> { let _ = Bpftop::parse(); - + if !nix::unistd::Uid::current().is_root() { return Err(anyhow!("This program must be run as root")); } @@ -551,6 +556,18 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) { let rows: Vec = items.iter().map(|item| item.into()).collect(); + let content_height: u16 = TABLE_HEADER_HEIGHT + .add(TABLE_HEADER_MARGIN) + .add((rows.len() as u16).mul(TABLE_ROW_HEIGHT.add(TABLE_ROW_MARGIN))) + .add(TABLE_FOOTER_HEIGHT); + if content_height > area.height { + // content exceeds screen size; display scrollbar + app.vertical_scroll_state = app.vertical_scroll_state.content_length(rows.len()); + } else { + // content fits on screen; hide scrollbar + app.vertical_scroll_state = app.vertical_scroll_state.content_length(0); + } + let widths = [ Constraint::Percentage(5), Constraint::Percentage(17), @@ -571,6 +588,11 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) { .row_highlight_style(selected_style) .highlight_symbol(">> "); f.render_stateful_widget(t, area, &mut app.table_state); + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight).thumb_symbol("░"), + area, + &mut app.vertical_scroll_state, + ); } fn render_footer(f: &mut Frame, app: &mut App, area: Rect) {