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:
@@ -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