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:
+108
@@ -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<OutputLine>,
|
||||
pub hint: Option<String>,
|
||||
/// 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<Action> {
|
||||
if key.code == KeyCode::Esc {
|
||||
self.nav_exit();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
+29
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user