From c9da6ff7852faf5041ecd544dd19ae9387fabbe6 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:56:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Ctrl-O=20navigation=20mode=20?= =?UTF-8?q?=E2=80=94=20peek=20+=20expand=20the=20schema=20sidebar=20(#21,?= =?UTF-8?q?=20ADR-0046=20DC1/DC2/DC4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.rs | 108 +++++++++++++++ ...av_overlay_relationships_focused_dark.snap | 29 ++++ src/ui.rs | 124 +++++++++++++++++- 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap diff --git a/src/app.rs b/src/app.rs index 2e8e6f0..db13064 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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)] pub struct App { pub mode: Mode, @@ -242,6 +264,10 @@ pub struct App { /// the cursor in view by adjusting this; it resets to 0 whenever the /// buffer is replaced wholesale (submit / history navigation). 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, pub hint: Option, /// The validity indicator's currently-visible verdict @@ -451,6 +477,7 @@ impl App { input: String::new(), input_cursor: 0, input_scroll_offset: 0, + nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: 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 { + if key.code == KeyCode::Esc { + self.nav_exit(); + } + Vec::new() + } + fn handle_key(&mut self, key: KeyEvent) -> Vec { // On Windows, key events fire for both Press and Release; // honour only Press to avoid double-handling. Other @@ -938,6 +995,20 @@ impl App { 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 / // Shift-Tab cycle; Esc / Backspace undo the whole // last-Tab insertion in one keystroke while the memo @@ -5132,6 +5203,43 @@ mod tests { 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] fn input_scroll_offset_resets_when_the_buffer_is_replaced() { // ADR-0046 DA3: the horizontal scroll offset must not leak from diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap new file mode 100644 index 0000000..2009a79 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index cdd6adc..428deea 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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); + } }