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
|
||||
|
||||
Reference in New Issue
Block a user