feat(ui): scroll the focused sidebar panel + refine the nav overlay (#21, ADR-0046 DC3 + DC2)

DC3 — navigation-mode scroll: the focused Tables / Relationships panel
scrolls (Up/Down by a line, PageUp/PageDown by its visible-row count).
Per-panel offsets are clamped to content at render time, and the
renderer reports each panel's visible rows for paging — mirroring the
output panel's scroll. render_items_panel / render_relationships_panel
take &mut App, count their rows, and store+clamp the offset before
building the borrowing lines.

DC2 refinement: the expand-on-focus overlay now clears only the sidebar
strip plus a one-column gutter, leaving the base output/input/hint
visible (unchanged) to the right rather than blanking the whole area —
truer to "underneath keeps its layout", with the gutter keeping the
cut-off edge clean (chosen after eyeballing both variants). ADR DC2 and
the overlay snapshot updated to match.

Tests: line/page scroll move only the focused panel and clamp; the
render clamps a past-the-end offset so the last row stays visible.
This commit is contained in:
claude@clouddev1
2026-06-10 21:27:13 +00:00
parent c9da6ff785
commit 22bec61d11
4 changed files with 191 additions and 47 deletions
+77 -2
View File
@@ -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<Action> {
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<Action> {
// 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
@@ -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
+74 -14
View File
@@ -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