From 61e56754db60888c6ad0595dffa0b555979666f2 Mon Sep 17 00:00:00 2001 From: Clement Tsang <34804052+ClementTsang@users.noreply.github.com> Date: Sat, 25 Apr 2026 05:47:15 -0400 Subject: [PATCH] feature: basic scrollbar support for tables and dialogs (#2046) Adds scrollbars to dialogs (help, process kill) and optionally tables. --- CHANGELOG.md | 5 +- .../configuration/config-file/flags.md | 1 + sample_configs/default_config.toml | 3 + schema/nightly/bottom.json | 6 ++ src/app.rs | 1 + src/canvas/components/data_table.rs | 1 + src/canvas/components/data_table/draw.rs | 43 ++++++++++++-- src/canvas/components/data_table/props.rs | 3 + src/canvas/components/data_table/sortable.rs | 1 + src/canvas/components/mod.rs | 1 + src/canvas/components/scroll_bar.rs | 56 +++++++++++++++++++ src/canvas/dialogs/help_dialog.rs | 41 ++++++++++++-- src/canvas/dialogs/process_kill_dialog.rs | 22 +++++++- src/constants.rs | 3 + src/options.rs | 9 +++ src/options/config/flags.rs | 1 + src/widgets/cpu_graph.rs | 1 + src/widgets/disk_table.rs | 1 + src/widgets/process_table.rs | 2 + src/widgets/temperature_table.rs | 1 + 20 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 src/canvas/components/scroll_bar.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f6138d8..c04f051e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/content/configuration/config-file/flags.md b/docs/content/configuration/config-file/flags.md index 34546b1d..41ab5a62 100644 --- a/docs/content/configuration/config-file/flags.md +++ b/docs/content/configuration/config-file/flags.md @@ -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). | diff --git a/sample_configs/default_config.toml b/sample_configs/default_config.toml index 5b1ed6ee..80deab27 100644 --- a/sample_configs/default_config.toml +++ b/sample_configs/default_config.toml @@ -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 diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index 9fcdadca..78761cda 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -519,6 +519,12 @@ } ] }, + "show_table_scroll_bar": { + "type": [ + "boolean", + "null" + ] + }, "show_table_scroll_position": { "type": [ "boolean", diff --git a/src/app.rs b/src/app.rs index d22b1aac..f864827f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, diff --git a/src/canvas/components/data_table.rs b/src/canvas/components/data_table.rs index c35f3d7c..e9d859bc 100644 --- a/src/canvas/components/data_table.rs +++ b/src/canvas/components/data_table.rs @@ -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(); diff --git a/src/canvas/components/data_table/draw.rs b/src/canvas/components/data_table/draw.rs index 8c98f011..a2c06d3b 100644 --- a/src/canvas/components/data_table/draw.rs +++ b/src/canvas/components/data_table/draw.rs @@ -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(); diff --git a/src/canvas/components/data_table/props.rs b/src/canvas/components/data_table/props.rs index bf6931a3..f63489ad 100644 --- a/src/canvas/components/data_table/props.rs +++ b/src/canvas/components/data_table/props.rs @@ -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, } diff --git a/src/canvas/components/data_table/sortable.rs b/src/canvas/components/data_table/sortable.rs index 9116d7df..38c16c05 100644 --- a/src/canvas/components/data_table/sortable.rs +++ b/src/canvas/components/data_table/sortable.rs @@ -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, }; diff --git a/src/canvas/components/mod.rs b/src/canvas/components/mod.rs index 9de30696..19b945e9 100644 --- a/src/canvas/components/mod.rs +++ b/src/canvas/components/mod.rs @@ -2,5 +2,6 @@ pub mod data_table; pub mod pipe_gauge; +pub mod scroll_bar; pub mod time_graph; pub mod widget_carousel; diff --git a/src/canvas/components/scroll_bar.rs b/src/canvas/components/scroll_bar.rs new file mode 100644 index 00000000..e35e80c5 --- /dev/null +++ b/src/canvas/components/scroll_bar.rs @@ -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); +} diff --git a/src/canvas/dialogs/help_dialog.rs b/src/canvas/dialogs/help_dialog.rs index a4e9aec9..e9d466b0 100644 --- a/src/canvas/dialogs/help_dialog.rs +++ b/src/canvas/dialogs/help_dialog.rs @@ -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, + }, + ); } } diff --git a/src/canvas/dialogs/process_kill_dialog.rs b/src/canvas/dialogs/process_kill_dialog.rs index 40b8be40..5bfb3181 100644 --- a/src/canvas/dialogs/process_kill_dialog.rs +++ b/src/canvas/dialogs/process_kill_dialog.rs @@ -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, diff --git a/src/constants.rs b/src/constants.rs index 3bfed224..284f131e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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 diff --git a/src/options.rs b/src/options.rs index b3ba1ca5..ecc081a2 100644 --- a/src/options.rs +++ b/src/options.rs @@ -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 diff --git a/src/options/config/flags.rs b/src/options/config/flags.rs index 8225d877..c8c99bbd 100644 --- a/src/options/config/flags.rs +++ b/src/options/config/flags.rs @@ -58,6 +58,7 @@ pub(crate) struct GeneralConfig { pub(crate) process_memory_as_value: Option, pub(crate) tree: Option, pub(crate) show_table_scroll_position: Option, + pub(crate) show_table_scroll_bar: Option, pub(crate) process_command: Option, // This does nothing on Windows, but we leave it enabled to make the config file consistent // across platforms. diff --git a/src/widgets/cpu_graph.rs b/src/widgets/cpu_graph.rs index 34be2309..ac442718 100644 --- a/src/widgets/cpu_graph.rs +++ b/src/widgets/cpu_graph.rs @@ -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, }; diff --git a/src/widgets/disk_table.rs b/src/widgets/disk_table.rs index 40e78a10..e7624d7f 100644 --- a/src/widgets/disk_table.rs +++ b/src/widgets/disk_table.rs @@ -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 { diff --git a/src/widgets/process_table.rs b/src/widgets/process_table.rs index 6dfd9653..331da4c5 100644 --- a/src/widgets/process_table.rs +++ b/src/widgets/process_table.rs @@ -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 { diff --git a/src/widgets/temperature_table.rs b/src/widgets/temperature_table.rs index e96f86a7..353e531d 100644 --- a/src/widgets/temperature_table.rs +++ b/src/widgets/temperature_table.rs @@ -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.