Skip to content

Commit ef00759

Browse files
committed
feat(tui): add PageUp/PageDown scrolling to all panes
Add PageUp/PageDown key support to the policy, logs, and draft/rules views. All three panes now scroll by one viewport height per keypress. Also fix scroll_policy() clamping to stop at the last viewport of content instead of the last line, preventing a blank-screen overshoot on G and PageDown. Signed-off-by: Major Hayden <major@redhat.com>
1 parent 28ee296 commit ef00759

2 files changed

Lines changed: 148 additions & 15 deletions

File tree

crates/openshell-tui/src/app.rs

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,8 @@ pub struct App {
495495
pub sandbox_providers_list: Vec<String>,
496496
pub policy_lines: Vec<ratatui::text::Line<'static>>,
497497
pub policy_scroll: usize,
498+
/// Visible line count in the policy pane, set during draw for PageUp/PageDown.
499+
pub policy_viewport_height: usize,
498500

499501
// Create sandbox modal
500502
pub create_form: Option<CreateSandboxForm>,
@@ -656,6 +658,7 @@ impl App {
656658
sandbox_providers_list: Vec::new(),
657659
policy_lines: Vec::new(),
658660
policy_scroll: 0,
661+
policy_viewport_height: 0,
659662
create_form: None,
660663
pending_create_sandbox: false,
661664
pending_forward_ports: Vec::new(),
@@ -1155,9 +1158,21 @@ impl App {
11551158
KeyCode::Char('k') | KeyCode::Up => {
11561159
self.scroll_policy(-1);
11571160
}
1161+
// Page-scroll by one viewport height.
1162+
KeyCode::PageDown => {
1163+
let delta = self.policy_viewport_height.max(1).cast_signed();
1164+
self.scroll_policy(delta);
1165+
}
1166+
KeyCode::PageUp => {
1167+
let delta = self.policy_viewport_height.max(1).cast_signed();
1168+
self.scroll_policy(-delta);
1169+
}
11581170
KeyCode::Char('G') => {
1159-
// Scroll to bottom.
1160-
self.policy_scroll = self.policy_lines.len().saturating_sub(1);
1171+
// Scroll to bottom, keeping a full viewport visible.
1172+
self.policy_scroll = self
1173+
.policy_lines
1174+
.len()
1175+
.saturating_sub(self.policy_viewport_height.max(1));
11611176
}
11621177
KeyCode::Char('g') => {
11631178
self.policy_scroll = 0;
@@ -1426,6 +1441,21 @@ impl App {
14261441
self.draft_scroll -= 1;
14271442
}
14281443
}
1444+
// Page-scroll by one viewport height, clamping the cursor to
1445+
// stay within the visible range.
1446+
KeyCode::PageDown if total > 0 => {
1447+
let page = vh.max(1);
1448+
let max_scroll = total.saturating_sub(vh.min(total));
1449+
self.draft_scroll = (self.draft_scroll + page).min(max_scroll);
1450+
let visible = total.saturating_sub(self.draft_scroll).min(vh);
1451+
self.draft_selected = self.draft_selected.min(visible.saturating_sub(1));
1452+
}
1453+
KeyCode::PageUp if total > 0 => {
1454+
let page = vh.max(1);
1455+
self.draft_scroll = self.draft_scroll.saturating_sub(page);
1456+
let visible = total.saturating_sub(self.draft_scroll).min(vh);
1457+
self.draft_selected = self.draft_selected.min(visible.saturating_sub(1));
1458+
}
14291459
KeyCode::Char('g') => {
14301460
self.draft_scroll = 0;
14311461
self.draft_selected = 0;
@@ -1490,16 +1520,15 @@ impl App {
14901520
}
14911521

14921522
/// Scroll policy pane by a delta (positive = down, negative = up).
1523+
///
1524+
/// Clamps so at least one viewport of content remains visible.
14931525
pub fn scroll_policy(&mut self, delta: isize) {
1494-
let max = self.policy_lines.len().saturating_sub(1);
1495-
if delta < 0 {
1496-
self.policy_scroll = self.policy_scroll.saturating_sub(delta.unsigned_abs());
1497-
} else {
1498-
#[allow(clippy::cast_sign_loss)]
1499-
{
1500-
self.policy_scroll = (self.policy_scroll + delta as usize).min(max);
1501-
}
1502-
}
1526+
self.policy_scroll = clamped_scroll(
1527+
self.policy_scroll,
1528+
delta,
1529+
self.policy_lines.len(),
1530+
self.policy_viewport_height,
1531+
);
15031532
}
15041533

15051534
fn handle_logs_key(&mut self, key: KeyEvent) {
@@ -1608,6 +1637,17 @@ impl App {
16081637
}
16091638
self.log_autoscroll = false;
16101639
}
1640+
// Page-scroll by one viewport height.
1641+
KeyCode::PageDown => {
1642+
let delta = vh.max(1).cast_signed();
1643+
self.scroll_logs(delta);
1644+
self.log_autoscroll = false;
1645+
}
1646+
KeyCode::PageUp => {
1647+
let delta = vh.max(1).cast_signed();
1648+
self.scroll_logs(-delta);
1649+
self.log_autoscroll = false;
1650+
}
16111651
KeyCode::Char('G' | 'f') => {
16121652
self.log_selection_anchor = None;
16131653
self.sandbox_log_scroll = self.log_autoscroll_offset();
@@ -2247,3 +2287,94 @@ fn unique_provider_name(base: &str, existing: &[String]) -> String {
22472287
}
22482288
base.to_string()
22492289
}
2290+
2291+
/// Compute a new scroll position after applying `delta`, clamped so the last
2292+
/// viewport of content remains visible.
2293+
///
2294+
/// * `current` - current scroll offset
2295+
/// * `delta` - lines to scroll (positive = down, negative = up)
2296+
/// * `total` - total number of lines/items
2297+
/// * `viewport` - visible line count (0 before first draw, treated as 1)
2298+
fn clamped_scroll(current: usize, delta: isize, total: usize, viewport: usize) -> usize {
2299+
let max = total.saturating_sub(viewport.max(1));
2300+
if delta < 0 {
2301+
current.saturating_sub(delta.unsigned_abs())
2302+
} else {
2303+
#[allow(clippy::cast_sign_loss)]
2304+
let stepped = current + delta as usize;
2305+
stepped.min(max)
2306+
}
2307+
}
2308+
2309+
#[cfg(test)]
2310+
mod tests {
2311+
use super::*;
2312+
2313+
// -- clamped_scroll -------------------------------------------------
2314+
2315+
#[test]
2316+
fn scroll_empty_content() {
2317+
// No lines at all: scroll should stay at 0 regardless of delta.
2318+
assert_eq!(clamped_scroll(0, 1, 0, 10), 0);
2319+
assert_eq!(clamped_scroll(0, -1, 0, 10), 0);
2320+
assert_eq!(clamped_scroll(0, 20, 0, 10), 0);
2321+
}
2322+
2323+
#[test]
2324+
fn scroll_content_shorter_than_viewport() {
2325+
// 5 lines in a 10-line viewport: max scroll is 0.
2326+
assert_eq!(clamped_scroll(0, 1, 5, 10), 0);
2327+
assert_eq!(clamped_scroll(0, 5, 5, 10), 0);
2328+
}
2329+
2330+
#[test]
2331+
fn scroll_content_equals_viewport() {
2332+
// Exactly 10 lines in a 10-line viewport: max scroll is 0.
2333+
assert_eq!(clamped_scroll(0, 1, 10, 10), 0);
2334+
assert_eq!(clamped_scroll(0, -1, 10, 10), 0);
2335+
}
2336+
2337+
#[test]
2338+
fn scroll_down_one() {
2339+
// 100 lines, viewport 20, start at 0: scroll to 1.
2340+
assert_eq!(clamped_scroll(0, 1, 100, 20), 1);
2341+
}
2342+
2343+
#[test]
2344+
fn scroll_page_down() {
2345+
// 100 lines, viewport 20, start at 0: scroll to 20.
2346+
assert_eq!(clamped_scroll(0, 20, 100, 20), 20);
2347+
}
2348+
2349+
#[test]
2350+
fn scroll_page_down_clamps_at_bottom() {
2351+
// 100 lines, viewport 20: max scroll = 80.
2352+
assert_eq!(clamped_scroll(75, 20, 100, 20), 80);
2353+
assert_eq!(clamped_scroll(80, 20, 100, 20), 80);
2354+
}
2355+
2356+
#[test]
2357+
fn scroll_page_up_from_middle() {
2358+
assert_eq!(clamped_scroll(40, -20, 100, 20), 20);
2359+
}
2360+
2361+
#[test]
2362+
fn scroll_page_up_clamps_at_top() {
2363+
// Scrolling up past 0 saturates to 0.
2364+
assert_eq!(clamped_scroll(5, -20, 100, 20), 0);
2365+
assert_eq!(clamped_scroll(0, -1, 100, 20), 0);
2366+
}
2367+
2368+
#[test]
2369+
fn scroll_viewport_zero_before_first_draw() {
2370+
// viewport=0 is treated as 1 (the .max(1) fallback).
2371+
// 100 lines, viewport 0 -> max = 99.
2372+
assert_eq!(clamped_scroll(0, 1, 100, 0), 1);
2373+
assert_eq!(clamped_scroll(98, 5, 100, 0), 99);
2374+
}
2375+
2376+
#[test]
2377+
fn scroll_up_one() {
2378+
assert_eq!(clamped_scroll(10, -1, 100, 20), 9);
2379+
}
2380+
}

crates/openshell-tui/src/ui/sandbox_policy.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ use crate::app::App;
1111
/// Draw the scrollable policy viewer pane (bottom ~80% of the sandbox screen).
1212
///
1313
/// Always focused when visible (the metadata pane above is non-interactive).
14-
pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) {
14+
/// Stores the inner viewport height on `app.policy_viewport_height` for
15+
/// PageUp/PageDown key handling.
16+
pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
17+
let inner_height = area.height.saturating_sub(2) as usize;
18+
app.policy_viewport_height = inner_height;
19+
1520
let t = &app.theme;
1621
let version = app.sandbox_policy.as_ref().map_or(0, |p| p.version);
1722

1823
let tab_title = super::sandbox_settings::draw_policy_tab_title(app);
1924
let version_hint = format!(" (v{version}) ");
2025

21-
// Calculate inner dimensions (borders + padding).
22-
let inner_height = area.height.saturating_sub(2) as usize;
23-
2426
if app.policy_lines.is_empty() {
2527
let lines = vec![Line::from(Span::styled("Loading...", t.muted))];
2628
let block = Block::default()

0 commit comments

Comments
 (0)