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:
@@ -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 (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.
|
||||
|
||||
Reference in New Issue
Block a user