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