Skip to content

Commit

Permalink
Add scrollbar widget to the tabular view.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
m12t authored and jfernandez committed Feb 16, 2025
1 parent 1cc452b commit 99029e8
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 21 deletions.
59 changes: 44 additions & 15 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Mutex<Vec<BpfProgram>>>,
pub data_buf: Arc<Mutex<CircularBuffer<20, PeriodMeasure>>>,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
34 changes: 28 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand All @@ -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()),
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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"));
}
Expand Down Expand Up @@ -551,6 +556,18 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) {

let rows: Vec<Row> = 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),
Expand All @@ -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) {
Expand Down

0 comments on commit 99029e8

Please sign in to comment.