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.