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:
@@ -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
|
||||
|
||||
|
||||
+77
-2
@@ -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
|
||||
|
||||
+23
-23
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user