Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add scrollbar to tabular view #121

Merged
merged 1 commit into from
Feb 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading