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
@@ -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 **~4050 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
+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