feat(ui): width-derived sidebar visibility — hide at <=90 cols (#21, ADR-0046 DB1)

The schema sidebar (the left Tables column) is now shown only when the
terminal is wider than 90 columns; at or below that it is hidden and
the output/input panels span the full width. This reclaims horizontal
space on narrow terminals — notably the 90-column screencasts, where
the sidebar added little and cost the output panel its width.

Visibility is a pure function of terminal width (sidebar_visible);
the Ctrl-O peek-reveal lands in Phase C. render() splits the layout
conditionally — full-width right column when the sidebar is hidden.

Snapshots/tests that rendered at 80 wide now reflect the hidden
sidebar; those whose intent IS the sidebar (populated_with_table, the
items-panel and drop-table integration checks) render at 110 so the
Tables list is actually exercised — one masked-intent integration
check (matched "Customers" in the output, not the panel) is corrected
the same way. New tests cover the width gate and the show/hide
boundary.
This commit is contained in:
claude@clouddev1
2026-06-10 18:28:57 +00:00
parent 41bae99ab3
commit 386627a262
10 changed files with 271 additions and 217 deletions
+67 -15
View File
@@ -23,6 +23,19 @@ use crate::theme::Theme;
/// computation — without that feedback, scrolling past the top
/// of the buffer would slide the visible window off and
/// "eat" lines from the bottom on subsequent renders.
/// Minimum terminal width at which the schema sidebar (the left items
/// column) is shown by default (ADR-0046 DB1). At or below this the
/// sidebar is hidden so the output/input panels get the full width —
/// notably the 90-column screencasts. Tunable.
const SIDEBAR_MIN_WIDTH: u16 = 90;
/// Whether the schema sidebar is visible — a pure function of terminal
/// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar
/// panel is focused via the Ctrl-O peek.
const fn sidebar_visible(total_width: u16) -> bool {
total_width > SIDEBAR_MIN_WIDTH
}
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
let area = frame.area();
paint_background(theme, frame, area);
@@ -39,13 +52,19 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
])
.split(area);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
// ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed
// left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the
// right column spans the full width.
if sidebar_visible(area.width) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
} else {
render_right_column(app, theme, frame, outer[0]);
}
render_project_label(app, theme, frame, outer[1]);
render_status_bar(app, theme, frame, outer[2]);
@@ -2132,7 +2151,8 @@ mod tests {
app.input.push_str(LONG_INPUT);
app.input_cursor = app.input.len();
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 24);
// Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24);
assert!(
out.contains("'Alice Wonderland'"),
"the tail around the cursor must be visible:\n{out}"
@@ -2152,7 +2172,8 @@ mod tests {
app.input.push_str(LONG_INPUT);
app.input_cursor = 0;
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 24);
// Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24);
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
@@ -2169,7 +2190,9 @@ mod tests {
app.input.push_str(LONG_INPUT);
app.input_cursor = app.input.len();
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 44);
// Narrow (sidebar hidden, DB1) so the line wraps across the two
// rows rather than fitting on the first.
let out = render_to_string(&mut app, &theme, 60, 44);
let head = out
.lines()
.position(|l| l.contains("select * from Customers"));
@@ -2193,7 +2216,8 @@ mod tests {
app.input.push_str(LONG_INPUT);
app.input_cursor = app.input.len();
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 50, 44);
// Very narrow + tall: two rows, but the line exceeds both.
let out = render_to_string(&mut app, &theme, 38, 44);
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
}
@@ -2208,7 +2232,8 @@ mod tests {
app.input_cursor = app.input.len();
app.input_indicator = Some(crate::dsl::walker::Severity::Error);
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 44);
// Narrow (sidebar hidden, DB1) so the line wraps across two rows.
let out = render_to_string(&mut app, &theme, 60, 44);
let err_line = out
.lines()
.position(|l| l.contains("[ERR]"))
@@ -2235,7 +2260,8 @@ mod tests {
app.input.push_str(LONG_INPUT);
app.input_cursor = app.input.len();
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 44);
// Narrow (sidebar hidden, DB1) so the command wraps across rows.
let snapshot = render_to_string(&mut app, &theme, 60, 44);
insta::assert_snapshot!("two_row_input_dark", snapshot);
}
@@ -2557,7 +2583,9 @@ mod tests {
});
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
// Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows
// alongside the output panel (DB1).
let snapshot = render_to_string(&mut app, &theme, 110, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
@@ -2577,10 +2605,34 @@ mod tests {
],
);
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 24);
// Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1).
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
}
#[test]
fn sidebar_visible_is_width_gated() {
// ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below.
assert!(!sidebar_visible(80));
assert!(!sidebar_visible(90)); // the 90-col screencast: hidden
assert!(sidebar_visible(91));
assert!(sidebar_visible(120));
}
#[test]
fn sidebar_hidden_at_or_below_threshold_width() {
// The Tables panel disappears at a narrow width (the output
// panel then spans the full width) and returns when wide.
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
let theme = Theme::dark();
let narrow = render_to_string(&mut app, &theme, 80, 24);
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
let wide = render_to_string(&mut app, &theme, 110, 24);
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
}
}