diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index aed79a2..cdc4ac6 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -320,14 +320,23 @@ input-editing arms and routes keys per DC3/DC4. ### DC2 — Expand-on-focus as an overlay -A focused sidebar panel widens to **~40–50 columns**, rendered as an -**overlay**: the renderer draws a `Clear` over the affected right-column -region and paints the wide panel on top. The output/input/hint panels -underneath keep their exact layout — **unused and unchanging** while -browsing — and are restored by the next frame on exit. This is cheap -because the renderer is a pure function of `App` state: focus state -selects the width and the overlay path. (The input underneath is -inactive in navigation mode, so occluding it is harmless.) +A focused sidebar panel widens to a **45-column** overlay +(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded +panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and +paints the wide panel on top. The output/input/hint panels underneath +keep their exact layout — **unused and unchanging** while browsing, +**still visible to the right** of the overlay (just partially occluded +on the left) — and are restored fully by the next frame on exit. The +gutter keeps them from butting against the expanded panel's border so +the overlay edge reads cleanly. This is cheap because the renderer is a +pure function of `App` state: focus state selects the width and the +overlay path. (The input underneath is inactive in navigation mode.) + +*Implementation note (2026-06-10):* a full-area clear (hiding the base +panels entirely during browse) was tried first and rejected — leaving +the base visible is truer to "underneath keep their layout," and the +one-column gutter resolves the only wrinkle (the panels' left edges +being cut by the overlay reading harshly without separation). ### DC3 — Scroll the focused panel; focus highlight diff --git a/src/app.rs b/src/app.rs index db13064..69d3c2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -323,6 +323,14 @@ pub struct App { /// diagram's side-by-side vs vertical layout choice. Defaults to /// `80` until the first render measures the real width. pub last_output_width: u16, + /// Top visible row of the Tables / Relationships sidebar panels + /// while scrolled in navigation mode (ADR-0046 DC3), with the most + /// recent visible-row count the renderer reported for each (used to + /// page-scroll and to clamp the offset). `0` = showing from the top. + pub tables_scroll: usize, + pub relationships_scroll: usize, + pub last_tables_visible: usize, + pub last_relationships_visible: usize, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a @@ -491,6 +499,10 @@ impl App { last_output_visible: 0, last_output_total_wrapped: 0, last_output_width: 80, + tables_scroll: 0, + relationships_scroll: 0, + last_tables_visible: 0, + last_relationships_visible: 0, project_name: None, project_is_temp: false, fatal_message: None, @@ -973,12 +985,40 @@ impl App { /// (wired in DC3); every other key is inert because the command /// input is occluded by the expanded sidebar overlay. fn handle_nav_key(&mut self, key: KeyEvent) -> Vec { - if key.code == KeyCode::Esc { - self.nav_exit(); + match key.code { + KeyCode::Esc => self.nav_exit(), + KeyCode::Up => self.nav_scroll(-1), + KeyCode::Down => self.nav_scroll(1), + KeyCode::PageUp => self.nav_scroll_page(-1), + KeyCode::PageDown => self.nav_scroll_page(1), + _ => {} } Vec::new() } + /// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the + /// renderer clamps the offset to the panel's content on the next + /// frame, so over-scrolling is harmless. + const fn nav_scroll(&mut self, lines: i32) { + let slot = match self.nav_focus { + NavFocus::SidebarTables => &mut self.tables_scroll, + NavFocus::SidebarRelationships => &mut self.relationships_scroll, + NavFocus::Input => return, + }; + *slot = slot.saturating_add_signed(lines as isize); + } + + /// Page-scroll the focused panel by its last reported visible-row + /// count (ADR-0046 DC3). + fn nav_scroll_page(&mut self, dir: i32) { + let visible = match self.nav_focus { + NavFocus::SidebarTables => self.last_tables_visible, + NavFocus::SidebarRelationships => self.last_relationships_visible, + NavFocus::Input => return, + }; + self.nav_scroll(dir * (visible.max(1) as i32)); + } + fn handle_key(&mut self, key: KeyEvent) -> Vec { // On Windows, key events fire for both Press and Release; // honour only Press to avoid double-handling. Other @@ -5240,6 +5280,41 @@ mod tests { assert!(actions.is_empty(), "Enter does not submit in navigation mode"); } + #[test] + fn nav_scroll_keys_move_only_the_focused_panel() { + // ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.update(key(KeyCode::Down)); + app.update(key(KeyCode::Down)); + assert_eq!(app.tables_scroll, 2); + assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls"); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 1); + // Up saturates at the top. + app.update(key(KeyCode::Up)); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 0); + // Switching focus moves the other panel instead. + app.nav_focus = NavFocus::SidebarRelationships; + app.update(key(KeyCode::Down)); + assert_eq!(app.relationships_scroll, 1); + assert_eq!(app.tables_scroll, 0); + } + + #[test] + fn nav_page_scroll_uses_the_panels_visible_rows() { + // ADR-0046 DC3: PageUp/PageDown move by the last reported + // visible-row count. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.last_tables_visible = 10; + app.update(key(KeyCode::PageDown)); + assert_eq!(app.tables_scroll, 10); + app.update(key(KeyCode::PageUp)); + assert_eq!(app.tables_scroll, 0); + } + #[test] fn input_scroll_offset_resets_when_the_buffer_is_replaced() { // ADR-0046 DA3: the horizontal scroll offset must not leak from diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap index 2009a79..57a76f8 100644 --- a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 2895 +assertion_line: 2967 expression: snapshot --- -╭ Tables ───────────────────────────────────╮ -│Customers │ -│Orders │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -╰───────────────────────────────────────────╯ -╭ Relationships ────────────────────────────╮ -│Customers_Orders │ -│ Customers.id -> │ -│ Orders.customer_id │ -╰───────────────────────────────────────────╯ +╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮ +│Customers │ │ +│Orders │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ ─────────────────────────────────╯ +│ │ ─────────────────────────────────╮ +╰───────────────────────────────────────────╯ │ +╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯ +│Customers_Orders │ ─────────────────────────────────╮ +│ Customers.id -> │ ` for a list │ +│ Orders.customer_id │ │ +╰───────────────────────────────────────────╯ ─────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 428deea..187936e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -120,21 +120,32 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { /// one line, turning horizontal truncation into vertical scrolling. const NAV_EXPANDED_WIDTH: u16 = 45; +/// Blank columns cleared to the right of the expanded sidebar overlay +/// (ADR-0046 DC2), separating it from the base panels left visible +/// behind it so the overlay's right edge reads cleanly. +const NAV_OVERLAY_GUTTER: u16 = 1; + /// Draw the focused sidebar, expanded, as an overlay over the left of /// the main content area (ADR-0046 DC2/DC3). `Clear` + a background /// repaint hide the base render underneath; the two panels keep the /// DB4 split. The focused panel is accent-bordered (DC3). -fn render_nav_sidebar_overlay(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - // Clear the whole main content region (the "affected right-column - // region", DC2) and repaint the background, so the base output / - // input / hint do not show through half-occluded. They are restored - // unchanged on the next frame when navigation mode exits. - frame.render_widget(ratatui::widgets::Clear, area); - paint_background(theme, frame, area); - - // Paint the expanded sidebar over the left; the rest stays blank - // background while browsing. +fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + // ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and + // paint the expanded sidebar over it. The base output / input / hint + // stay visible to the right — unchanged, just partially occluded — + // and the gutter keeps them from butting against the sidebar's + // border. They are restored fully on the next frame when navigation + // mode exits. let width = NAV_EXPANDED_WIDTH.min(area.width); + let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width); + let cleared = Rect { + x: area.x, + y: area.y, + width: cleared_w, + height: area.height, + }; + frame.render_widget(ratatui::widgets::Clear, cleared); + paint_background(theme, frame, cleared); let sidebar = Rect { x: area.x, y: area.y, @@ -739,7 +750,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { frame.render_widget(block, area); } -fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -755,6 +766,13 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // ADR-0046 DC3: clamp + store the scroll before the (borrowing) + // lines are built. Visible rows and the content total are computed + // by counting (one row per table + one per index), so the `&mut + // app` writes finish before the immutable line borrows begin. + let visible = area.height.saturating_sub(2) as usize; + app.last_tables_visible = visible; + if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.tables_empty"), @@ -767,6 +785,14 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec return; } + let total: usize = app + .tables + .iter() + .map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len)) + .sum(); + let offset = app.tables_scroll.min(total.saturating_sub(visible)); + app.tables_scroll = offset; + let highlight = app .current_table .as_ref() @@ -796,7 +822,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec } } } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -805,7 +831,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec /// name, then the endpoints broken at the arrow to fit — every line /// ellipsized past the inner width. Phase C adds focus + scroll for the /// overflow; for now content beyond the panel's height is clipped. -fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -821,6 +847,11 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // ADR-0046 DC3: clamp + store the scroll before the borrowing lines + // (three rows per relationship). + let visible = area.height.saturating_sub(2) as usize; + app.last_relationships_visible = visible; + if app.relationships.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.relationships_empty"), @@ -833,6 +864,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a return; } + let total = app.relationships.len() * 3; + let offset = app.relationships_scroll.min(total.saturating_sub(visible)); + app.relationships_scroll = offset; + let inner_w = area.width.saturating_sub(2) as usize; let name_style = Style::default().fg(theme.fg); let detail_style = Style::default().fg(theme.muted); @@ -847,7 +882,7 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -2894,6 +2929,31 @@ mod tests { assert!(!normal.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn focused_tables_panel_scrolls_and_clamps() { + // ADR-0046 DC3: more tables than fit → a large offset reveals the + // lower entries and clamps so it can't scroll past the end. + let mut app = App::new(); + app.tables = (0..30).map(|i| format!("Table{i:02}")).collect(); + app.nav_focus = NavFocus::SidebarTables; + app.tables_scroll = 1000; // way past the end + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!( + out.contains("Table29"), + "the last table is visible after the offset clamps:\n{out}" + ); + assert!( + !out.contains("Table00"), + "the top tables are scrolled off:\n{out}" + ); + assert!( + app.tables_scroll < 30, + "the stored offset was clamped to the content: {}", + app.tables_scroll + ); + } + #[test] fn navigation_overlay_snapshot() { // The expanded overlay over a full-width base (sidebar hidden at