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) {