From e0b9470febc3453aa43e5d88f3d91065f19d4e0c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:08:45 +0000 Subject: [PATCH] feat(ui): horizontal-scroll long input so the cursor stays visible (#23, ADR-0046 DA3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command longer than the input field used to clip silently at the right edge, hiding the cursor and the command tail. Now the single logical input line scrolls horizontally to keep the cursor in view, with muted `<` / `>` markers at the reserved edge columns signalling hidden content on either side. The offset is a pure function of (line length, cursor column, field width, previous offset) — input_scroll_offset — so the view only moves when the cursor would leave the window, and one column is held on each side for the markers so a marker never hides the cursor. The stored App::input_scroll_offset resets when the buffer is replaced wholesale (submit, history recall). The ADR-0027 6-column indicator reserve is preserved. Tests: pure-offset cases, tail-visible + head-visible render checks, and the reset-on-submit/history check. One layout snapshot now shows a long command's tail instead of its clipped head. --- src/app.rs | 30 ++++ ...hlighted_input_all_token_classes_dark.snap | 4 +- src/ui.rs | 170 ++++++++++++++++-- 3 files changed, 189 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1e6ff1c..189b367 100644 --- a/src/app.rs +++ b/src/app.rs @@ -237,6 +237,11 @@ pub struct App { /// Byte offset into `input` where the next character will be /// inserted. Always lies on a UTF-8 character boundary. pub input_cursor: usize, + /// First visible display column of the input line when it is too + /// long to fit the input panel (ADR-0046 DA3). The renderer keeps + /// 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, pub output: VecDeque, pub hint: Option, /// The validity indicator's currently-visible verdict @@ -439,6 +444,7 @@ impl App { messages_verbosity: crate::friendly::Verbosity::default(), input: String::new(), input_cursor: 0, + input_scroll_offset: 0, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, input_indicator: None, @@ -1232,6 +1238,7 @@ impl App { self.history_cursor = Some(next_index); self.input = self.history[next_index].clone(); self.input_cursor = self.input.len(); + self.input_scroll_offset = 0; } /// Move forwards in history (towards newer entries; eventually @@ -1250,6 +1257,7 @@ impl App { self.input = self.history_draft.take().unwrap_or_default(); } self.input_cursor = self.input.len(); + self.input_scroll_offset = 0; } fn cancel_history_navigation(&mut self) { @@ -1284,6 +1292,7 @@ impl App { fn submit(&mut self) -> Vec { let raw = std::mem::take(&mut self.input); self.input_cursor = 0; + self.input_scroll_offset = 0; let trimmed = raw.trim(); if trimmed.is_empty() { return Vec::new(); @@ -5089,6 +5098,27 @@ mod tests { assert_eq!(app.input_cursor, 0); } + #[test] + fn input_scroll_offset_resets_when_the_buffer_is_replaced() { + // ADR-0046 DA3: the horizontal scroll offset must not leak from + // one command to the next. Submitting and recalling from history + // both replace the buffer wholesale, so both reset it. + let mut app = App::new(); + type_str(&mut app, "a long command line that would have scrolled"); + app.input_scroll_offset = 25; + submit(&mut app); + assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll"); + + // Recall the submitted line from history — also a reset. + type_str(&mut app, "another draft line entirely"); + app.input_scroll_offset = 25; + app.update(key(KeyCode::Up)); + assert_eq!( + app.input_scroll_offset, 0, + "history recall resets the input scroll" + ); + } + #[test] fn page_up_scrolls_output_back() { let mut app = App::new(); diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index fa8dbd9..ec4f4bf 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 1976 +assertion_line: 2063 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -19,7 +19,7 @@ expression: snapshot │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││insert into T values (1, 'hi', null) --all-r │ +│ ││(line: &'a OutputLine, theme: &Theme) -> Line<'a> { ]) } -fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +/// Horizontal scroll offset (in display columns) for a single-line +/// input that may overflow its `text_width`-column viewport (ADR-0046 +/// DA3). Keeps the cursor visible; when the line overflows, reserves one +/// column on each side for the `<` / `>` overflow markers so a marker +/// never hides the cursor. `cursor_col` is the column of the cursor +/// cell (which can be `line_cols`, one past the last char, when the +/// cursor sits at the end). Returns the new offset given the previous +/// one, so the view only scrolls when the cursor would leave the window. +const fn input_scroll_offset( + line_cols: usize, + cursor_col: usize, + text_width: usize, + offset: usize, +) -> usize { + // The line (including the cursor-at-end cell) fits: no scroll. + if line_cols < text_width || text_width == 0 { + return 0; + } + // Reserve a column each side for the `<` / `>` markers. + let eff = if text_width > 2 { text_width - 2 } else { 1 }; + let mut off = offset; + if cursor_col < off { + off = cursor_col; + } else if cursor_col >= off + eff { + off = cursor_col + 1 - eff; + } + // Never scroll past the point where the cursor-at-end cell shows. + let max_off = (line_cols + 1).saturating_sub(eff); + if off > max_off { + off = max_off; + } + off +} + +fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { EffectiveMode::Simple => ( @@ -1004,6 +1038,36 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec // walker with the active mode so SQL keywords / operators / // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); + + // ADR-0027 §4: the rightmost six columns of the input row + // (a five-column label plus a one-column gap) are reserved + // unconditionally, so the text area is always + // `inner.width - 6` and the typed command never shifts + // sideways when the validity indicator appears or hides. + let inner = block.inner(area); + let text_area = Rect { + width: inner.width.saturating_sub(6), + ..inner + }; + + // ADR-0046 DA3: horizontally scroll a long line so the cursor stays + // visible, rather than clipping it off the right edge silently. + // Computed (and the offset stored) *before* the highlight spans + // borrow `app.input`, so the `&mut app` write does not clash. + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let tw = text_area.width as usize; + let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); + app.input_scroll_offset = offset; + + frame.render_widget(block, area); + + // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — + // `render_input_runs_in_mode` runs the highlight walker with the + // active mode so SQL keywords / operators / CASE / function calls + // colour correctly in Advanced mode. The cursor cell is rendered + // inverted (an empty-range run) so it is visible without a real + // terminal cursor. let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { @@ -1018,18 +1082,41 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec mode_for_render, ); let spans = runs_to_spans(&app.input, &runs); - // ADR-0027 §4: the rightmost six columns of the input row - // (a five-column label plus a one-column gap) are reserved - // unconditionally, so the text area is always - // `inner.width - 6` and the typed command never shifts - // sideways when the validity indicator appears or hides. - let inner = block.inner(area); - frame.render_widget(block, area); - let text_area = Rect { - width: inner.width.saturating_sub(6), - ..inner - }; - frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + + if line_cols > tw || offset > 0 { + // Overflow: reserve one column each side for `<` / `>` markers, + // render the windowed text between them, then draw the markers + // for whichever side still has hidden content. + let eff = if tw > 2 { tw - 2 } else { 1 }; + let mid = Rect { + x: text_area.x + 1, + width: eff as u16, + ..text_area + }; + frame.render_widget( + Paragraph::new(Line::from(spans)).scroll((0, offset as u16)), + mid, + ); + let marker = Style::default().fg(theme.muted); + if offset > 0 { + frame.render_widget( + Paragraph::new(Span::styled("<", marker)), + Rect { width: 1, ..text_area }, + ); + } + if offset + eff < line_cols { + frame.render_widget( + Paragraph::new(Span::styled(">", marker)), + Rect { + x: text_area.x + text_area.width.saturating_sub(1), + width: 1, + ..text_area + }, + ); + } + } else { + frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + } if let Some(severity) = app.input_indicator { let (indicator_label, color) = match severity { @@ -1872,6 +1959,63 @@ mod tests { assert_eq!(at(90, 11), 1); } + // ---- ADR-0046 DA3: input horizontal scroll ------------------- + + #[test] + fn input_scroll_offset_keeps_the_cursor_in_view() { + // Fits (line shorter than the viewport) → never scrolls. + assert_eq!(input_scroll_offset(10, 10, 20, 0), 0); + assert_eq!(input_scroll_offset(19, 19, 20, 5), 0); + // Overflow, cursor at end → window shows the tail, reserving the + // two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33. + assert_eq!(input_scroll_offset(50, 50, 20, 0), 33); + // Cursor jumped left of the window → scroll left to the cursor. + assert_eq!(input_scroll_offset(50, 5, 20, 33), 5); + // Cursor still inside the current window → stable, no change. + assert_eq!(input_scroll_offset(50, 40, 20, 33), 33); + // Never scroll past the cursor-at-end cell, even from a stale + // over-large offset. + assert_eq!(input_scroll_offset(50, 50, 20, 999), 33); + } + + const LONG_INPUT: &str = + "select * from Customers where id = 12345 and name = 'Alice Wonderland'"; + + #[test] + fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() { + // #23: a command longer than the input field must not clip the + // cursor off the right edge — it scrolls so the tail is visible, + // with a `<` marker for the hidden head. + let mut app = App::new(); + 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); + assert!( + out.contains("'Alice Wonderland'"), + "the tail around the cursor must be visible:\n{out}" + ); + assert!( + !out.lines().any(|l| l.contains("select * from Customers where")), + "the head must be scrolled off:\n{out}" + ); + assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}"); + } + + #[test] + fn input_at_home_shows_the_head_with_a_right_marker() { + // With the cursor at Home, the head is visible and a `>` marker + // signals the hidden tail. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = 0; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 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}"); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border.