Skip to content

Commit 99029e8

Browse files
m12tjfernandez
authored andcommitted
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.
1 parent 1cc452b commit 99029e8

File tree

2 files changed

+72
-21
lines changed

2 files changed

+72
-21
lines changed

src/app.rs

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use crate::{bpf_program::{BpfProgram, Process}, helpers::program_type_to_string};
1919
use circular_buffer::CircularBuffer;
2020
use libbpf_rs::{query::ProgInfoIter, Iter, Link};
21+
use ratatui::widgets::ScrollbarState;
2122
use ratatui::widgets::TableState;
2223
use std::{
2324
collections::HashMap,
@@ -33,6 +34,8 @@ use tui_input::Input;
3334
pub struct App {
3435
pub mode: Mode,
3536
pub table_state: TableState,
37+
pub vertical_scroll: usize,
38+
pub vertical_scroll_state: ScrollbarState,
3639
pub header_columns: [String; 7],
3740
pub items: Arc<Mutex<Vec<BpfProgram>>>,
3841
pub data_buf: Arc<Mutex<CircularBuffer<20, PeriodMeasure>>>,
@@ -119,6 +122,8 @@ impl App {
119122
pub fn new() -> App {
120123
let mut app = App {
121124
mode: Mode::Table,
125+
vertical_scroll: 0,
126+
vertical_scroll_state: ScrollbarState::new(0),
122127
table_state: TableState::default(),
123128
header_columns: [
124129
String::from("ID"),
@@ -302,14 +307,16 @@ impl App {
302307
let i = match self.table_state.selected() {
303308
Some(i) => {
304309
if i >= items.len() - 1 {
305-
0
310+
items.len() - 1
306311
} else {
312+
self.vertical_scroll = self.vertical_scroll.saturating_add(1);
307313
i + 1
308314
}
309315
}
310316
None => 0,
311317
};
312318
self.table_state.select(Some(i));
319+
self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
313320
}
314321
}
315322

@@ -319,14 +326,16 @@ impl App {
319326
let i = match self.table_state.selected() {
320327
Some(i) => {
321328
if i == 0 {
322-
items.len() - 1
329+
0
323330
} else {
331+
self.vertical_scroll = self.vertical_scroll.saturating_sub(1);
324332
i - 1
325333
}
326334
}
327-
None => items.len() - 1,
335+
None => return, // do nothing if table_state == None && previous_program() called
328336
};
329337
self.table_state.select(Some(i));
338+
self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
330339
}
331340
}
332341

@@ -473,19 +482,24 @@ mod tests {
473482
app.items.lock().unwrap().push(prog_2.clone());
474483

475484
// Initially no item is selected
476-
assert_eq!(app.selected_program(), None);
485+
assert_eq!(app.selected_program(), None, "expected no program");
486+
assert_eq!(app.vertical_scroll, 0, "expected init with 0, got: {}", app.vertical_scroll);
477487

478488
// After calling next, the first item should be selected
479489
app.next_program();
480-
assert_eq!(app.selected_program(), Some(prog_1.clone()));
490+
assert_eq!(app.selected_program(), Some(prog_1.clone()), "expected prog_1");
491+
assert_eq!(app.vertical_scroll, 0, "expected scroll 0, got: {}", app.vertical_scroll);
481492

482493
// After calling next again, the second item should be selected
483494
app.next_program();
484-
assert_eq!(app.selected_program(), Some(prog_2.clone()));
495+
assert_eq!(app.selected_program(), Some(prog_2.clone()), "expected prog_2");
496+
assert_eq!(app.vertical_scroll, 1, "expected scroll 1, got: {}", app.vertical_scroll);
485497

486-
// After calling next again, we should wrap around to the first item
498+
// After calling next again, the second item should still be selected without wrapping
487499
app.next_program();
488-
assert_eq!(app.selected_program(), Some(prog_1.clone()));
500+
assert_eq!(app.selected_program(), Some(prog_2.clone()), "expected prog_2; no wrap around");
501+
assert_eq!(app.vertical_scroll, 1, "expected scroll 1, got: {}", app.vertical_scroll);
502+
489503
}
490504

491505
#[test]
@@ -494,10 +508,17 @@ mod tests {
494508

495509
// Initially no item is selected
496510
assert_eq!(app.selected_program(), None);
511+
512+
// Initially ScrollbarState is 0
513+
assert_eq!(app.vertical_scroll_state, ScrollbarState::new(0), "unexpected ScrollbarState");
514+
assert_eq!(app.vertical_scroll, 0, "expected 0 vertical_scroll, got: {}", app.vertical_scroll);
497515

498516
// After calling previous, no item should be selected
499517
app.previous_program();
500518
assert_eq!(app.selected_program(), None);
519+
520+
assert_eq!(app.vertical_scroll_state, ScrollbarState::new(0), "unexpected ScrollbarState");
521+
assert_eq!(app.vertical_scroll, 0, "expected 0 vertical_scroll, got: {}", app.vertical_scroll);
501522
}
502523

503524
#[test]
@@ -534,19 +555,27 @@ mod tests {
534555
app.items.lock().unwrap().push(prog_2.clone());
535556

536557
// Initially no item is selected
537-
assert_eq!(app.selected_program(), None);
558+
assert_eq!(app.selected_program(), None, "expected no program");
559+
assert_eq!(app.vertical_scroll, 0, "expected init with 0");
538560

539-
// After calling previous, the last item should be selected
561+
// After calling previous with no table state, nothing should be selected
540562
app.previous_program();
541-
assert_eq!(app.selected_program(), Some(prog_2.clone()));
563+
assert_eq!(app.selected_program(), None, "expected None");
564+
assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping");
542565

543-
// After calling previous again, the first item should be selected
566+
// After calling previous again, still nothing should be selected
544567
app.previous_program();
545-
assert_eq!(app.selected_program(), Some(prog_1.clone()));
568+
assert_eq!(app.selected_program(), None, "still None");
569+
assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping");
570+
571+
app.next_program(); // populate table state and expect prog_1 selected
572+
assert_eq!(app.selected_program(), Some(prog_1.clone()), "expected prog_1");
573+
assert_eq!(app.vertical_scroll, 0, "expected scroll 0");
546574

547-
// After calling previous again, we should wrap around to the last item
575+
// After calling previous again, prog_1 should still be selected (0th index)
548576
app.previous_program();
549-
assert_eq!(app.selected_program(), Some(prog_2.clone()));
577+
assert_eq!(app.selected_program(), Some(prog_1.clone()), "still expecting prog_1");
578+
assert_eq!(app.vertical_scroll, 0, "still 0, no wrapping");
550579
}
551580

552581
#[test]

src/main.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ use ratatui::style::{Color, Modifier, Style, Stylize};
3636
use ratatui::text::Line;
3737
use ratatui::widgets::{
3838
Axis, Block, BorderType, Borders, Cell, Chart, Dataset, GraphType, Padding, Paragraph, Row,
39-
Table,
39+
Scrollbar, ScrollbarOrientation, Table,
4040
};
4141
use ratatui::{symbols, Frame, Terminal};
4242
use std::fs;
4343
use std::io::{self, Stdout};
4444
use std::mem::MaybeUninit;
45+
use std::ops::{Add, Mul};
4546
use std::os::fd::{FromRawFd, OwnedFd};
4647
use std::panic;
4748
use std::time::Duration;
@@ -70,6 +71,12 @@ const SORT_INFO_FOOTER: &str = "(Esc) back";
7071

7172
const PROCFS_BPF_STATS_ENABLED: &str = "/proc/sys/kernel/bpf_stats_enabled";
7273

74+
const TABLE_HEADER_HEIGHT: u16 = 1;
75+
const TABLE_HEADER_MARGIN: u16 = 1;
76+
const TABLE_ROW_HEIGHT: u16 = 1;
77+
const TABLE_ROW_MARGIN: u16 = 1;
78+
const TABLE_FOOTER_HEIGHT: u16 = 1; // derived from `TABLE_FOOTER`
79+
7380
#[derive(Parser, Debug)]
7481
#[command(
7582
name = env!("CARGO_PKG_NAME"),
@@ -82,12 +89,10 @@ const PROCFS_BPF_STATS_ENABLED: &str = "/proc/sys/kernel/bpf_stats_enabled";
8289
about = env!("CARGO_PKG_DESCRIPTION"),
8390
override_usage = "sudo bpftop"
8491
)]
85-
struct Bpftop {
86-
}
92+
struct Bpftop {}
8793

8894
impl From<&BpfProgram> for Row<'_> {
8995
fn from(bpf_program: &BpfProgram) -> Self {
90-
let height = 1;
9196
let cells = vec![
9297
Cell::from(bpf_program.id.to_string()),
9398
Cell::from(bpf_program.bpf_type.to_string()),
@@ -98,7 +103,7 @@ impl From<&BpfProgram> for Row<'_> {
98103
Cell::from(format_percent(bpf_program.cpu_time_percent())),
99104
];
100105

101-
Row::new(cells).height(height as u16).bottom_margin(1)
106+
Row::new(cells).height(TABLE_ROW_HEIGHT).bottom_margin(TABLE_ROW_MARGIN)
102107
}
103108
}
104109

@@ -132,7 +137,7 @@ impl Drop for TerminalManager {
132137

133138
fn main() -> Result<()> {
134139
let _ = Bpftop::parse();
135-
140+
136141
if !nix::unistd::Uid::current().is_root() {
137142
return Err(anyhow!("This program must be run as root"));
138143
}
@@ -551,6 +556,18 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) {
551556

552557
let rows: Vec<Row> = items.iter().map(|item| item.into()).collect();
553558

559+
let content_height: u16 = TABLE_HEADER_HEIGHT
560+
.add(TABLE_HEADER_MARGIN)
561+
.add((rows.len() as u16).mul(TABLE_ROW_HEIGHT.add(TABLE_ROW_MARGIN)))
562+
.add(TABLE_FOOTER_HEIGHT);
563+
if content_height > area.height {
564+
// content exceeds screen size; display scrollbar
565+
app.vertical_scroll_state = app.vertical_scroll_state.content_length(rows.len());
566+
} else {
567+
// content fits on screen; hide scrollbar
568+
app.vertical_scroll_state = app.vertical_scroll_state.content_length(0);
569+
}
570+
554571
let widths = [
555572
Constraint::Percentage(5),
556573
Constraint::Percentage(17),
@@ -571,6 +588,11 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) {
571588
.row_highlight_style(selected_style)
572589
.highlight_symbol(">> ");
573590
f.render_stateful_widget(t, area, &mut app.table_state);
591+
f.render_stateful_widget(
592+
Scrollbar::new(ScrollbarOrientation::VerticalRight).thumb_symbol("░"),
593+
area,
594+
&mut app.vertical_scroll_state,
595+
);
574596
}
575597

576598
fn render_footer(f: &mut Frame, app: &mut App, area: Rect) {

0 commit comments

Comments
 (0)