feat(ui): Ctrl-O navigation mode — peek + expand the schema sidebar (#21, ADR-0046 DC1/DC2/DC4)

Ctrl-O enters a navigation mode orthogonal to the input mode, cycling
focus Input -> Tables -> Relationships -> Input (Esc exits). While a
sidebar panel is focused the sidebar is revealed (a peek, even when
width-hidden) and drawn as an expanded 45-column overlay over a cleared
main area, so the schema is browsable without the cramped 26-column
unfocused width. The focused panel gets an accent border.

Routing lives in the main key handler after the modal gate, so Ctrl-O
and nav keys are inert while a modal is open; in nav mode every
non-navigation key (printable/Enter/Tab/Backspace/...) is inert because
the input is occluded. Scroll keys (Up/Down, PageUp/PageDown) are
reserved for DC3 (next).

New App state: NavFocus { Input, SidebarTables, SidebarRelationships }.
Tests: the focus cycle, Esc exit, input-keys-inert, overlay reveal +
expansion, the accent-border style, and an overlay snapshot.
This commit is contained in:
claude@clouddev1
2026-06-10 18:56:39 +00:00
parent 94825d0f36
commit c9da6ff785
3 changed files with 258 additions and 3 deletions
+121 -3
View File
@@ -12,7 +12,9 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
use crate::app::{App, EchoStatus, EffectiveMode, OutputKind, OutputLine, OutputStyleClass};
use crate::app::{
App, EchoStatus, EffectiveMode, NavFocus, OutputKind, OutputLine, OutputStyleClass,
};
use crate::mode::Mode;
use crate::theme::Theme;
@@ -96,6 +98,15 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
render_project_label(app, theme, frame, outer[1]);
render_status_bar(app, theme, frame, outer[2]);
// ADR-0046 DC2: in navigation mode, draw the focused sidebar as an
// expanded overlay over the (unchanged) base render — revealing it
// if it was hidden (peek) and widening it for browsing. Drawn below
// the modal layer; a modal can't open in navigation mode, but if one
// is somehow up it still wins.
if app.nav_focus.in_sidebar() {
render_nav_sidebar_overlay(app, theme, frame, outer[0]);
}
// Modal dialogs (rebuild confirm, save-as prompt, load
// picker, …) are drawn last so they overlay the rest of
// the frame.
@@ -104,6 +115,54 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
}
}
/// Width (columns) of the navigation-mode expanded sidebar overlay
/// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on
/// one line, turning horizontal truncation into vertical scrolling.
const NAV_EXPANDED_WIDTH: u16 = 45;
/// 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.
let width = NAV_EXPANDED_WIDTH.min(area.width);
let sidebar = Rect {
x: area.x,
y: area.y,
width,
height: area.height,
};
let rel_content = (app.relationships.len() as u16).saturating_mul(3);
let rel_h = relationships_panel_height(sidebar.height, rel_content);
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(rel_h)])
.split(sidebar);
render_items_panel(app, theme, frame, parts[0]);
render_relationships_panel(app, theme, frame, parts[1]);
}
/// Border style for a sidebar panel: an accented, bold border when it
/// holds navigation focus (ADR-0046 DC3), the muted border otherwise.
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
if focused {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.border)
}
}
fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
use crate::app::Modal;
match modal {
@@ -684,7 +743,10 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.border_style(panel_border_style(
theme,
app.nav_focus == NavFocus::SidebarTables,
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
@@ -747,7 +809,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.border_style(panel_border_style(
theme,
app.nav_focus == NavFocus::SidebarRelationships,
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")),
Style::default()
@@ -2788,4 +2853,57 @@ mod tests {
let snapshot = render_to_string(&mut app, &theme, 110, 24);
insta::assert_snapshot!("relationships_panel_dark", snapshot);
}
#[test]
fn navigation_mode_reveals_and_expands_the_sidebar() {
// ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but
// focusing a sidebar panel peeks it open as an expanded overlay.
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
app.relationships = vec![one_relationship()];
let theme = Theme::dark();
let normal = render_to_string(&mut app, &theme, 80, 24);
assert!(
!normal.contains("Tables"),
"sidebar hidden at 80 wide when not browsing:\n{normal}"
);
app.nav_focus = NavFocus::SidebarTables;
let focused = render_to_string(&mut app, &theme, 80, 24);
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
assert!(
focused.contains("Relationships"),
"relationships panel in the overlay:\n{focused}"
);
assert!(
focused.contains("Customers_Orders"),
"relationship listed in the overlay:\n{focused}"
);
}
#[test]
fn focused_panel_gets_an_accent_border() {
// ADR-0046 DC3: the focused sidebar panel is accent-bordered.
let theme = Theme::dark();
let focused = panel_border_style(&theme, true);
let normal = panel_border_style(&theme, false);
assert_eq!(focused.fg, Some(theme.fg));
assert!(focused.add_modifier.contains(Modifier::BOLD));
assert_eq!(normal.fg, Some(theme.border));
assert!(!normal.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn navigation_overlay_snapshot() {
// The expanded overlay over a full-width base (sidebar hidden at
// 80), with the Relationships panel focused (accent border).
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.relationships = vec![one_relationship()];
app.nav_focus = NavFocus::SidebarRelationships;
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot);
}
}