From b4eb93f4ea32d670ec5c8ab0c8ce7bdbef5cb6a1 Mon Sep 17 00:00:00 2001 From: ychampion Date: Sat, 11 Jul 2026 07:24:59 +0000 Subject: [PATCH] feat(tui): show scrollbar for long tables Constraint: Keep sticky headers and table width unchanged. Rejected: An always-visible scrollbar | Short tables should keep the existing uncluttered border. Confidence: high Scope-risk: narrow Directive: Derive the indicator from the existing vertical offset rather than adding separate scroll state. Tested: cargo test; cargo test --no-default-features --bin xleak; strict all-target Clippy with all and no-default features; default and no-default release builds; 80x24 PTY top, bottom, and short-table smokes --- CHANGELOG.md | 1 + README.md | 1 + src/tui/rendering.rs | 66 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0bf64..e9677c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Vertical scrollbar for tables taller than the interactive viewport ([#38](https://github.com/bgreenwell/xleak/issues/38)) - OSC 52 clipboard support: `c`/`C` now copy via OSC 52 (works over SSH) in addition to the system clipboard - CSV/TSV support: read and interactively view `.csv`/`.tsv` files as a single sheet (behind the default-on `csv` feature) - `--csv-delimiter` option to override the inferred CSV/TSV field delimiter diff --git a/README.md b/README.md index 851d0db..77db684 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Inspired by [doxx](https://github.com/bgreenwell/doxx), `xleak` brings Excel spr - **Formula display** - view Excel formulas in cell detail view (Enter key) - **Jump to row/column** - press `Ctrl+G` to jump to any cell (e.g., `A100`, `500`, `10,5`) - **Large file optimization** - lazy loading for files with 1000+ rows +- **Vertical scrollbar** - shows the current position when table rows exceed the viewport - **Progress indicators** - real-time feedback for long operations - **Visual cell highlighting** - current row, column, and cell clearly marked diff --git a/src/tui/rendering.rs b/src/tui/rendering.rs index 5910288..ea9f4c1 100644 --- a/src/tui/rendering.rs +++ b/src/tui/rendering.rs @@ -4,12 +4,27 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}, + widgets::{ + Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, + ScrollbarState, Table, Wrap, + }, }; use std::time::Duration; use super::state::TuiState; +fn table_scrollbar_state( + content_length: usize, + viewport_height: usize, + scroll_offset: usize, +) -> Option { + (viewport_height > 0 && content_length > viewport_height).then(|| { + ScrollbarState::new(content_length) + .position(scroll_offset) + .viewport_content_length(viewport_height) + }) +} + impl TuiState { pub fn render(&mut self, frame: &mut Frame) { let area = frame.area(); @@ -342,6 +357,29 @@ impl TuiState { frame.render_widget(table, chunks[0]); + if let Some(mut scrollbar_state) = + table_scrollbar_state(self.sheet_data.height(), table_height, self.scroll_offset) + { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("│")) + .thumb_symbol("█") + .track_style(Style::default().fg(colors.border_fg)) + .thumb_style(Style::default().fg(colors.current_col_fg)); + let scrollbar_area = Rect::new( + chunks[0].right().saturating_sub(1), + chunks[0] + .y + .saturating_add(1) + .saturating_add(header_row_count), + 1, + table_height as u16, + ); + + frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + // Info bar: left segment shows the cell address (and live search query while // searching); right segment shows the theme and key hints. let cell_addr = self.current_cell_address(); @@ -1075,3 +1113,29 @@ impl TuiState { frame.render_widget(feedback_paragraph, popup_area); } } + +#[cfg(test)] +mod tests { + use ratatui::widgets::ScrollbarState; + + use super::table_scrollbar_state; + + #[test] + fn table_scrollbar_is_hidden_when_all_rows_fit() { + assert_eq!(table_scrollbar_state(8, 8, 0), None); + assert_eq!(table_scrollbar_state(5, 8, 0), None); + assert_eq!(table_scrollbar_state(5, 0, 0), None); + } + + #[test] + fn table_scrollbar_tracks_the_visible_rows_and_offset() { + assert_eq!( + table_scrollbar_state(20, 8, 7), + Some( + ScrollbarState::new(20) + .position(7) + .viewport_content_length(8) + ) + ); + } +}