feature: basic scrollbar support for tables and dialogs (#2046)

Adds scrollbars to dialogs (help, process kill) and optionally tables.
This commit is contained in:
Clement Tsang
2026-04-25 05:47:15 -04:00
committed by GitHub
parent 5d09d18469
commit 61e56754db
20 changed files with 186 additions and 16 deletions
+3 -2
View File
@@ -31,10 +31,11 @@ That said, these are more guidelines rather than hard rules, though the project
- [#1938](https://github.com/ClementTsang/bottom/pull/1938), [#1980](https://github.com/ClementTsang/bottom/pull/1980): Report average packet size and packet rate.
- [#2003](https://github.com/ClementTsang/bottom/pull/2003): Configurable default sort column for temperature and disk table widgets.
- [#1979](https://github.com/ClementTsang/bottom/pull/1979): Add global `bg_color` option to set widget background colour.
- [#2039](https://github.com/ClementTsang/bottom/pull/2039): Add support for drawing a line separator between the column headers and data.
- [#1979](https://github.com/ClementTsang/bottom/pull/1979): Add a global `bg_color` config option to set widget background colour.
- [#2039](https://github.com/ClementTsang/bottom/pull/2039): Add a config option for drawing a line separator (`table_gap`) between the column headers and data.
- [#1948](https://github.com/ClementTsang/bottom/pull/1948): Add support for both an `!=` operator and `!` negation prefixes in query searches.
- [#2045](https://github.com/ClementTsang/bottom/pull/2045): Add support for showing a decimal place for CPU usage
- [#2046](https://github.com/ClementTsang/bottom/pull/2046): Add a `show_table_scroll_bar` config option to show a scroll bar on table widgets.
### Changes
@@ -39,6 +39,7 @@ each time:
| `process_memory_as_value` | Boolean | Defaults to showing process memory usage by value. |
| `tree` | Boolean | Defaults to showing the process widget in tree mode. |
| `show_table_scroll_position` | Boolean | Shows the scroll position tracker in table widgets. |
| `show_table_scroll_bar` | Boolean | Shows a scroll bar on the right edge of table widgets. |
| `process_command` | Boolean | Show processes as their commands by default. |
| `disable_advanced_kill` | Boolean | Disable being able to send signals to processes on supported Unix-like systems. Only available on Linux, macOS, and FreeBSD. |
| `read_only` | Boolean | Prevents performing any actions that affect the system (e.g. stopping processes). |
+3
View File
@@ -92,6 +92,9 @@
# Shows an indicator in table widgets tracking where in the list you are.
#show_table_scroll_position = false
# Show a scroll bar on the right edge of table widgets.
#show_table_scroll_bar = false
# Show processes as their commands by default in the process widget.
#process_command = false
+6
View File
@@ -519,6 +519,12 @@
}
]
},
"show_table_scroll_bar": {
"type": [
"boolean",
"null"
]
},
"show_table_scroll_position": {
"type": [
"boolean",
+1
View File
@@ -60,6 +60,7 @@ pub struct AppConfigFields {
pub enable_gpu: bool,
pub enable_cache_memory: bool,
pub show_table_scroll_position: bool,
pub show_table_scroll_bar: bool,
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
pub is_advanced_kill: bool,
pub is_read_only: bool,
+1
View File
@@ -192,6 +192,7 @@ mod test {
left_to_right: false,
is_basic: false,
show_table_scroll_position: true,
show_table_scroll_bar: false,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::default();
+37 -6
View File
@@ -18,7 +18,11 @@ use super::{
};
use crate::{
app::layout_manager::BottomWidget,
canvas::{Painter, drawing_utils::widget_block},
canvas::{
Painter,
components::scroll_bar::{ScrollBarArgs, draw_scroll_bar},
drawing_utils::widget_block,
},
constants::TABLE_GAP_HEIGHT_LIMIT,
options::config::flags::TableGap,
utils::strings::truncate_to_text,
@@ -146,9 +150,14 @@ where
.split(draw_loc)[0];
let mut block = self.block(draw_info, self.data.len());
if self.props.is_basic && !draw_info.is_on_widget() {
block = block.padding(Padding::horizontal(1))
}
let horizontal_padding = if self.props.is_basic && !draw_info.is_on_widget() {
1
} else {
0
};
let right_padding = horizontal_padding + u16::from(self.props.show_table_scroll_bar);
block = block.padding(Padding::new(horizontal_padding, right_padding, 0, 0));
let (inner_width, inner_height) = {
let inner_rect = block.inner(margined_draw_loc);
@@ -208,10 +217,12 @@ where
}
}
let num_rows: usize = inner_height
.saturating_sub(table_gap + header_height)
.into();
let columns = &self.columns;
let rows = {
let num_rows =
usize::from(inner_height.saturating_sub(table_gap + header_height));
self.state
.get_start_position(num_rows, draw_info.force_redraw);
let start = self.state.display_start_index;
@@ -276,6 +287,26 @@ where
let table_state = &mut self.state.table_state;
f.render_stateful_widget(widget, margined_draw_loc, table_state);
if self.props.show_table_scroll_bar {
let scrollbar_area = Rect {
x: self.state.inner_rect.x + self.state.inner_rect.width,
y: self.state.inner_rect.y + header_height + table_gap,
width: 1,
height: num_rows as u16,
};
draw_scroll_bar(
f,
scrollbar_area,
ScrollBarArgs {
content_length: self.data.len(),
viewport_length: num_rows,
position: self.state.current_index,
style: self.styling.text_style,
},
);
}
if table_gap > 0 && table_gap_setting == TableGap::Line && show_header {
let y = self.state.inner_rect.y + header_height;
let buf = f.buffer_mut();
@@ -18,6 +18,9 @@ pub struct DataTableProps {
/// Whether to show the table scroll position.
pub show_table_scroll_position: bool,
/// Whether to show a scroll bar on the right edge of the table.
pub show_table_scroll_bar: bool,
/// Whether to show the current entry as highlighted when not focused.
pub show_current_entry_when_unfocused: bool,
}
@@ -399,6 +399,7 @@ mod test {
left_to_right: false,
is_basic: false,
show_table_scroll_position: true,
show_table_scroll_bar: false,
show_current_entry_when_unfocused: false,
};
+1
View File
@@ -2,5 +2,6 @@
pub mod data_table;
pub mod pipe_gauge;
pub mod scroll_bar;
pub mod time_graph;
pub mod widget_carousel;
+56
View File
@@ -0,0 +1,56 @@
//! A shared helper for drawing a vertical scroll bar.
use tui::{
Frame,
layout::Rect,
style::Style,
symbols::{self, scrollbar},
widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
};
/// Arguments for [`draw_scroll_bar`].
pub struct ScrollBarArgs {
/// Total number of items in the content.
pub content_length: usize,
/// Number of items that can be seen in the viewport.
pub viewport_length: usize,
/// Current scroll position in the list of items.
pub position: usize,
/// Style to be applied to the scrollbar.
pub style: Style,
}
/// Returns a [`Rect`] for a vertical scroll bar drawn just inside the right
/// border of a dialog block.
pub fn dialog_scroll_bar_area(block_area: Rect) -> Rect {
Rect {
x: block_area.x + block_area.width.saturating_sub(2),
y: block_area.y + 1,
width: 1,
height: block_area.height.saturating_sub(2),
}
}
/// Draw a vertical scroll bar in `area`.
pub fn draw_scroll_bar(f: &mut Frame<'_>, area: Rect, args: ScrollBarArgs) {
if args.content_length <= args.viewport_length || area.width == 0 || area.height == 0 {
return;
}
const SYMBOLS: scrollbar::Set<'_> = scrollbar::Set {
track: "",
thumb: symbols::block::FULL,
begin: "",
end: "",
};
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(args.style)
.symbols(SYMBOLS);
let mut state = ScrollbarState::new(args.content_length)
.position(args.position)
.viewport_content_length(args.viewport_length);
f.render_stateful_widget(scrollbar, area, &mut state);
}
+36 -5
View File
@@ -4,13 +4,17 @@ use tui::{
Frame,
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Paragraph, Wrap},
widgets::{Padding, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::{
app::App,
canvas::{Painter, drawing_utils::dialog_block},
canvas::{
Painter,
components::scroll_bar::{ScrollBarArgs, dialog_scroll_bar_area, draw_scroll_bar},
drawing_utils::dialog_block,
},
constants::{self, HELP_TEXT},
};
@@ -41,20 +45,23 @@ impl Painter {
pub fn draw_help_dialog(&self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect) {
let styled_help_text = self.help_text_lines();
// Reserve one column on the right for the scroll bar.
let block = dialog_block(self.styles.border_type, self.styles.border_style)
.title_top(Line::styled(" Help ", self.styles.widget_title_style))
.title_top(
Line::styled(" Esc to close ", self.styles.widget_title_style).right_aligned(),
);
)
.padding(Padding::right(1));
if app_state.should_get_widget_bounds() {
// We must also recalculate how many lines are wrapping to properly get
// scrolling to work on small terminal sizes... oh joy.
app_state.help_dialog_state.height = block.inner(draw_loc).height;
let inner = block.inner(draw_loc);
app_state.help_dialog_state.height = inner.height;
let mut overflow_buffer = 0;
let paragraph_width = max(draw_loc.width.saturating_sub(2), 1);
let paragraph_width = max(inner.width, 1);
let mut prev_section_len = 0;
constants::HELP_TEXT
@@ -113,5 +120,29 @@ impl Painter {
)),
draw_loc,
);
let scrollbar_area = dialog_scroll_bar_area(draw_loc);
let content_length = app_state
.help_dialog_state
.scroll_state
.max_scroll_index
.into();
let viewport_length = app_state.help_dialog_state.height.into();
let position = app_state
.help_dialog_state
.scroll_state
.current_scroll_index
.into();
draw_scroll_bar(
f,
scrollbar_area,
ScrollBarArgs {
content_length,
viewport_length,
position,
style: self.styles.text_style,
},
);
}
}
+19 -3
View File
@@ -12,6 +12,10 @@ use tui::{
widgets::{Paragraph, Wrap},
};
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
use crate::canvas::components::scroll_bar::{
ScrollBarArgs, dialog_scroll_bar_area, draw_scroll_bar,
};
use crate::{
canvas::drawing_utils::dialog_block, collection::processes::Pid, options::config::style::Styles,
};
@@ -747,13 +751,25 @@ impl ProcessKillDialog {
max as u16
};
let [button_draw_area] =
let [list_area] =
Layout::horizontal([Constraint::Length(LONGEST_SIGNAL_TEXT_LENGTH)])
.flex(Flex::Center)
.areas(button_draw_area);
*last_button_draw_area = button_draw_area;
f.render_stateful_widget(buttons, button_draw_area, state);
*last_button_draw_area = list_area;
f.render_stateful_widget(buttons, list_area, state);
draw_scroll_bar(
f,
dialog_scroll_bar_area(draw_area),
ScrollBarArgs {
content_length: SIGNAL_TEXT.len(),
viewport_length: list_area.height as usize,
position: state.selected().unwrap_or(0),
style: styles.text_style,
},
);
}
ButtonState::Simple {
yes,
+3
View File
@@ -340,6 +340,9 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
# Shows an indicator in table widgets tracking where in the list you are.
#show_table_scroll_position = false
# Show a scroll bar on the right edge of table widgets.
#show_table_scroll_bar = false
# Show processes as their commands by default in the process widget.
#process_command = false
+9
View File
@@ -325,6 +325,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
args.general,
config
),
show_table_scroll_bar: get_show_table_scroll_bar(config),
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
is_advanced_kill,
is_read_only,
@@ -785,6 +786,14 @@ fn get_table_gap(config: &Config) -> TableGap {
.unwrap_or_default()
}
fn get_show_table_scroll_bar(config: &Config) -> bool {
config
.flags
.as_ref()
.and_then(|flags| flags.show_table_scroll_bar)
.unwrap_or(false)
}
fn get_dedicated_avg_row(config: &Config) -> bool {
config
.flags
+1
View File
@@ -58,6 +58,7 @@ pub(crate) struct GeneralConfig {
pub(crate) process_memory_as_value: Option<bool>,
pub(crate) tree: Option<bool>,
pub(crate) show_table_scroll_position: Option<bool>,
pub(crate) show_table_scroll_bar: Option<bool>,
pub(crate) process_command: Option<bool>,
// This does nothing on Windows, but we leave it enabled to make the config file consistent
// across platforms.
+1
View File
@@ -153,6 +153,7 @@ impl CpuWidgetState {
left_to_right: false,
is_basic: false,
show_table_scroll_position: false, // TODO: Should this be possible?
show_table_scroll_bar: config.show_table_scroll_bar,
show_current_entry_when_unfocused: true,
};
+1
View File
@@ -326,6 +326,7 @@ impl DiskTableWidget {
left_to_right: true,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_table_scroll_bar: config.show_table_scroll_bar,
show_current_entry_when_unfocused: false,
},
sort_index: match &config.default_disk_sort_column {
+2
View File
@@ -244,6 +244,7 @@ impl ProcWidgetState {
left_to_right: true,
is_basic: false,
show_table_scroll_position: false,
show_table_scroll_bar: false,
show_current_entry_when_unfocused: false,
};
let styling = DataTableStyling::from_palette(palette);
@@ -261,6 +262,7 @@ impl ProcWidgetState {
left_to_right: true,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_table_scroll_bar: config.show_table_scroll_bar,
show_current_entry_when_unfocused: false,
};
let props = SortDataTableProps {
+1
View File
@@ -135,6 +135,7 @@ impl TempWidgetState {
left_to_right: false,
is_basic: config.use_basic_mode,
show_table_scroll_position: config.show_table_scroll_position,
show_table_scroll_bar: config.show_table_scroll_bar,
show_current_entry_when_unfocused: false,
},
// This is hard-coded, but there's only two columns so it's fine.