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:
@@ -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