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
+108
View File
@@ -226,6 +226,28 @@ impl EffectiveMode {
} }
} }
/// Navigation-mode focus cursor (ADR-0046 DC1).
///
/// `Input` means not in navigation mode — keystrokes edit the command
/// input as usual. `Ctrl-O` cycles Input → SidebarTables →
/// SidebarRelationships → Input; while a sidebar panel is focused the
/// sidebar is revealed (peek) and expanded as an overlay, and scroll
/// keys drive it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavFocus {
#[default]
Input,
SidebarTables,
SidebarRelationships,
}
impl NavFocus {
/// True while a sidebar panel is focused (navigation mode is active).
pub const fn in_sidebar(self) -> bool {
matches!(self, Self::SidebarTables | Self::SidebarRelationships)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct App { pub struct App {
pub mode: Mode, pub mode: Mode,
@@ -242,6 +264,10 @@ pub struct App {
/// the cursor in view by adjusting this; it resets to 0 whenever the /// the cursor in view by adjusting this; it resets to 0 whenever the
/// buffer is replaced wholesale (submit / history navigation). /// buffer is replaced wholesale (submit / history navigation).
pub input_scroll_offset: usize, pub input_scroll_offset: usize,
/// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in
/// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals
/// + expands the focused sidebar panel as an overlay.
pub nav_focus: NavFocus,
pub output: VecDeque<OutputLine>, pub output: VecDeque<OutputLine>,
pub hint: Option<String>, pub hint: Option<String>,
/// The validity indicator's currently-visible verdict /// The validity indicator's currently-visible verdict
@@ -451,6 +477,7 @@ impl App {
input: String::new(), input: String::new(),
input_cursor: 0, input_cursor: 0,
input_scroll_offset: 0, input_scroll_offset: 0,
nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY), output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None, hint: None,
input_indicator: None, input_indicator: None,
@@ -922,6 +949,36 @@ impl App {
} }
} }
/// ADR-0046 DC1: advance the navigation focus cycle. From `Input`
/// it enters navigation mode on the Tables panel (revealing +
/// expanding the sidebar via the renderer); the third press returns
/// to the command input.
fn nav_advance(&mut self) {
self.nav_focus = match self.nav_focus {
NavFocus::Input => NavFocus::SidebarTables,
NavFocus::SidebarTables => NavFocus::SidebarRelationships,
NavFocus::SidebarRelationships => NavFocus::Input,
};
trace!(nav_focus = ?self.nav_focus, "navigation focus advanced");
}
/// Leave navigation mode, returning focus to the command input
/// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step).
const fn nav_exit(&mut self) {
self.nav_focus = NavFocus::Input;
}
/// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused.
/// `Esc` exits navigation mode; scroll keys drive the focused panel
/// (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();
}
Vec::new()
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> { fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; // On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other // honour only Press to avoid double-handling. Other
@@ -938,6 +995,20 @@ impl App {
return self.handle_modal_key(key); return self.handle_modal_key(key);
} }
// ADR-0046 DC1: `Ctrl-O` cycles navigation focus from any state
// (Input → Tables → Relationships → Input), inert only behind a
// modal (handled above).
if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) {
self.nav_advance();
return Vec::new();
}
// DC3/DC4: in navigation mode, keys drive the focused sidebar
// panel (scroll) or are inert; the command input is occluded.
if self.nav_focus.in_sidebar() {
return self.handle_nav_key(key);
}
// ADR-0022 stage 8 — non-modal completion. Tab / // ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole // Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo // last-Tab insertion in one keystroke while the memo
@@ -5132,6 +5203,43 @@ mod tests {
assert_eq!(app.relationships[0].name, "Customers_Orders"); assert_eq!(app.relationships[0].name, "Customers_Orders");
} }
#[test]
fn ctrl_o_cycles_navigation_focus() {
// ADR-0046 DC1: Input → Tables → Relationships → Input.
let mut app = App::new();
assert_eq!(app.nav_focus, NavFocus::Input);
let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarTables);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarRelationships);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn esc_exits_navigation_mode() {
let mut app = App::new();
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(app.nav_focus.in_sidebar());
app.update(key(KeyCode::Esc));
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn navigation_mode_ignores_input_keys() {
// ADR-0046 DC4: the input is occluded; printable/Enter/Backspace
// are inert while a sidebar panel is focused.
let mut app = App::new();
type_str(&mut app, "select");
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
app.update(key(KeyCode::Char('x')));
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
}
#[test] #[test]
fn input_scroll_offset_resets_when_the_buffer_is_replaced() { fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
// ADR-0046 DA3: the horizontal scroll offset must not leak from // ADR-0046 DA3: the horizontal scroll offset must not leak from
@@ -0,0 +1,29 @@
---
source: src/ui.rs
assertion_line: 2895
expression: snapshot
---
╭ Tables ───────────────────────────────────╮
│Customers │
│Orders │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────────────────╯
╭ Relationships ────────────────────────────╮
│Customers_Orders │
│ Customers.id -> │
│ Orders.customer_id │
╰───────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+121 -3
View File
@@ -12,7 +12,9 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; 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::mode::Mode;
use crate::theme::Theme; 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_project_label(app, theme, frame, outer[1]);
render_status_bar(app, theme, frame, outer[2]); 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 // Modal dialogs (rebuild confirm, save-as prompt, load
// picker, …) are drawn last so they overlay the rest of // picker, …) are drawn last so they overlay the rest of
// the frame. // 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) { fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
use crate::app::Modal; use crate::app::Modal;
match modal { match modal {
@@ -684,7 +743,10 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .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( .title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")), format!(" {} ", crate::t!("panel.tables_title")),
Style::default() Style::default()
@@ -747,7 +809,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .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( .title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")), format!(" {} ", crate::t!("panel.relationships_title")),
Style::default() Style::default()
@@ -2788,4 +2853,57 @@ mod tests {
let snapshot = render_to_string(&mut app, &theme, 110, 24); let snapshot = render_to_string(&mut app, &theme, 110, 24);
insta::assert_snapshot!("relationships_panel_dark", snapshot); 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);
}
} }