feat(ui): horizontal-scroll long input so the cursor stays visible (#23, ADR-0046 DA3)

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.
This commit is contained in:
claude@clouddev1
2026-06-10 18:08:45 +00:00
parent 9f5f76b05d
commit e0b9470feb
3 changed files with 189 additions and 15 deletions
+30
View File
@@ -237,6 +237,11 @@ pub struct App {
/// Byte offset into `input` where the next character will be /// Byte offset into `input` where the next character will be
/// inserted. Always lies on a UTF-8 character boundary. /// inserted. Always lies on a UTF-8 character boundary.
pub input_cursor: usize, 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<OutputLine>, pub output: VecDeque<OutputLine>,
pub hint: Option<String>, pub hint: Option<String>,
/// The validity indicator's currently-visible verdict /// The validity indicator's currently-visible verdict
@@ -439,6 +444,7 @@ impl App {
messages_verbosity: crate::friendly::Verbosity::default(), messages_verbosity: crate::friendly::Verbosity::default(),
input: String::new(), input: String::new(),
input_cursor: 0, input_cursor: 0,
input_scroll_offset: 0,
output: VecDeque::with_capacity(OUTPUT_CAPACITY), output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None, hint: None,
input_indicator: None, input_indicator: None,
@@ -1232,6 +1238,7 @@ impl App {
self.history_cursor = Some(next_index); self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone(); self.input = self.history[next_index].clone();
self.input_cursor = self.input.len(); self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
} }
/// Move forwards in history (towards newer entries; eventually /// 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 = self.history_draft.take().unwrap_or_default();
} }
self.input_cursor = self.input.len(); self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
} }
fn cancel_history_navigation(&mut self) { fn cancel_history_navigation(&mut self) {
@@ -1284,6 +1292,7 @@ impl App {
fn submit(&mut self) -> Vec<Action> { fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input); let raw = std::mem::take(&mut self.input);
self.input_cursor = 0; self.input_cursor = 0;
self.input_scroll_offset = 0;
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Vec::new(); return Vec::new();
@@ -5089,6 +5098,27 @@ mod tests {
assert_eq!(app.input_cursor, 0); 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] #[test]
fn page_up_scrolls_output_back() { fn page_up_scrolls_output_back() {
let mut app = App::new(); let mut app = App::new();
@@ -1,6 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1976 assertion_line: 2063
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -19,7 +19,7 @@ expression: snapshot
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││insert into T values (1, 'hi', null) --all-r │ │ ││<nto T values (1, 'hi', null) --all-rows $
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││after `insert into T values (1, 'hi', null)`, │ │ ││after `insert into T values (1, 'hi', null)`, │
+157 -13
View File
@@ -955,7 +955,41 @@ fn render_output_line<'a>(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 effective = app.effective_mode();
let (border_color, mode_color, label) = match effective { let (border_color, mode_color, label) = match effective {
EffectiveMode::Simple => ( 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 / // walker with the active mode so SQL keywords / operators /
// CASE / function calls colour correctly in Advanced mode. // CASE / function calls colour correctly in Advanced mode.
let cursor = app.input_cursor.min(app.input.len()); 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 { let mode_for_render = match effective {
EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::Simple => crate::mode::Mode::Simple,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
@@ -1018,18 +1082,41 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
mode_for_render, mode_for_render,
); );
let spans = runs_to_spans(&app.input, &runs); 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 if line_cols > tw || offset > 0 {
// unconditionally, so the text area is always // Overflow: reserve one column each side for `<` / `>` markers,
// `inner.width - 6` and the typed command never shifts // render the windowed text between them, then draw the markers
// sideways when the validity indicator appears or hides. // for whichever side still has hidden content.
let inner = block.inner(area); let eff = if tw > 2 { tw - 2 } else { 1 };
frame.render_widget(block, area); let mid = Rect {
let text_area = Rect { x: text_area.x + 1,
width: inner.width.saturating_sub(6), width: eff as u16,
..inner ..text_area
}; };
frame.render_widget(Paragraph::new(Line::from(spans)), 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 { if let Some(severity) = app.input_indicator {
let (indicator_label, color) = match severity { let (indicator_label, color) = match severity {
@@ -1872,6 +1959,63 @@ mod tests {
assert_eq!(at(90, 11), 1); 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 /// Count the content rows inside the Hint panel of a rendered
/// screen: the rows between the `╭ Hint …` title border and the /// screen: the rows between the `╭ Hint …` title border and the
/// next `╰…╯` bottom border. /// next `╰…╯` bottom border.