feat(prompt-editor): start input on its own row when the prompt leaves too little room (#767)

With a long prompt the inline editor started at the prompt's end column,
leaving e.g. 24 of 143 columns for the first row of input. When fewer than
max(20, cols/3) columns remain (and the prompt is at least as wide as what
it left, so a new row actually gains room), the input overlay now starts at
column 0 of the row below the prompt.

The start position is decided by one pure fn, input_start(), and fed to
everything that lays out the input: the overlay rows and overflow shift,
click-to-index mapping, vertical caret movement, the rendered bar, the
completion menu anchor and the IME candidate anchor.
This commit is contained in:
l0ng-ai
2026-09-23 15:32:26 +08:00
parent d534c085b9
commit 5c3d2a9e8a
2 changed files with 181 additions and 33 deletions
+9 -1
View File
@@ -2193,7 +2193,15 @@ impl Element for TerminalElement {
invert_cursor_cell(&mut buf, geom.cols, row, col, &colors);
}
let cursor_bounds = cursor_cell.map(|(row, col)| geom.cell_rect(row, col, 1));
// With the input bar up, the IME composes into the bar, which starts a
// row below the prompt when the prompt left it too little room.
let ime_cell = cursor.map(|c| {
self.view
.read(cx)
.input_ime_cell(c.row, c.col, geom.cols)
.unwrap_or((c.row, c.ime_col))
});
let cursor_bounds = ime_cell.map(|(row, col)| geom.cell_rect(row, col, 1));
let focus_handle = self.view.read(cx).focus_handle.clone();
window.handle_input(
&focus_handle,
+172 -32
View File
@@ -2933,9 +2933,10 @@ impl TerminalView {
let cols = self.terminal.term.lock().columns().max(1);
let chars: Vec<char> = self.cmd.text().chars().collect();
let len = chars.len();
let (positions, _r, _c) = input_char_positions(&chars, scol, cols);
let start = input_start(scol, cols);
let (positions, _r, _c) = input_char_positions(&chars, start, cols);
let end_caret = if len == 0 {
(0usize, scol)
start
} else {
let (r, c, w) = positions[len - 1];
if chars[len - 1] == '\n' {
@@ -2950,11 +2951,15 @@ impl TerminalView {
} else {
end_caret
};
let mut max_row = positions.iter().map(|&(r, _, _)| r).max().unwrap_or(0);
let mut max_row = positions
.iter()
.map(|&(r, _, _)| r)
.max()
.unwrap_or(start.0);
if chars.last() == Some(&'\n') {
max_row += 1;
}
if (down && cur_row >= max_row) || (!down && cur_row == 0) {
if (down && cur_row >= max_row) || (!down && cur_row <= start.0) {
self.editor_goal_col = None;
return false;
}
@@ -4192,6 +4197,26 @@ impl TerminalView {
(row >= 0).then_some((row as usize, col))
}
/// Where the IME should compose when the input bar has moved below the
/// prompt (see [`input_start`]): the start of the bar's first row. `None`
/// when the bar is beside the prompt, or not up at all — the prompt's own
/// cursor cell is right then. Takes the cursor as the frame snapshot saw
/// it rather than locking the terminal again mid-paint.
pub(super) fn input_ime_cell(
&self,
row: usize,
col: usize,
cols: usize,
) -> Option<(usize, usize)> {
if !self.input_active() || self.reverse_search.is_some() {
return None;
}
match input_start(col, cols.max(1)) {
(0, _) => None,
(rows, col) => Some((row + rows, col)),
}
}
pub(super) fn input_scroll_rows(&self) -> usize {
if !self.input_active() || self.reverse_search.is_some() {
return 0;
@@ -4215,7 +4240,7 @@ impl TerminalView {
&chars,
self.cmd.cursor(),
&self.marked_text,
ccol,
input_start(ccol, cols.max(1)),
cols.max(1),
);
input_overflow_shift(crow, caret_vrow, visual_rows, rows)
@@ -4231,7 +4256,14 @@ impl TerminalView {
}
let cols = self.terminal.term.lock().columns().max(1);
let chars: Vec<char> = self.cmd.text().chars().collect();
wrapped_click_index(&chars, scol, cols, col, row - srow, clamp)
wrapped_click_index(
&chars,
input_start(scol, cols),
cols,
col,
row - srow,
clamp,
)
}
pub fn editor_click(
@@ -6568,8 +6600,13 @@ impl TerminalView {
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
let mut lines: Vec<Vec<gpui::AnyElement>> =
vec![vec![blank(cell_w * (ccol as f32)).into_any_element()]];
// The prompt's row, then the input's. Starting below the prompt leaves
// that row empty — the prompt shows through it — and the input begins
// at column 0 of the next.
let cols = self.terminal.term.lock().columns().max(1);
let (start_row, start_col) = input_start(ccol, cols);
let mut lines: Vec<Vec<gpui::AnyElement>> = (0..start_row).map(|_| Vec::new()).collect();
lines.push(vec![blank(cell_w * (start_col as f32)).into_any_element()]);
let is_multiline = chars.contains(&'\n');
@@ -6686,13 +6723,16 @@ impl TerminalView {
return None;
}
let (srow, scol) = self.cursor_cell()?;
let srow = srow.saturating_sub(self.input_scroll_rows());
const MAX_ROWS: usize = 10;
let (total_rows, total_cols) = {
let term = self.terminal.term.lock();
(term.screen_lines(), term.columns())
};
// Anchored to where the input starts, which is a row below the prompt
// when the prompt left it too little room.
let (start_row, scol) = input_start(scol, total_cols.max(1));
let srow = (srow + start_row).saturating_sub(self.input_scroll_rows());
// How wide the menu ends up, decided before the rows so their
// descriptions can be elided against it. A menu wider than the pane
// has its right-hand column clipped by the pane, and the clip takes
@@ -7869,8 +7909,41 @@ fn menu_layout(
(place_above, visible, first)
}
/// The fewest columns the input is given beside the prompt, however wide the
/// pane. Below this nearly any command with an argument or two wraps on its
/// first row, and a row that short is harder to read than a row lower down.
const INPUT_MIN_BESIDE_PROMPT: usize = 20;
/// Where the input bar starts, as `(row, column)` with the prompt's own row as
/// row 0: right after the prompt, or — when the prompt has left too little of
/// its row — at column 0 of the row below, with the whole width to itself
/// (#767).
///
/// "Too little" is under a third of the pane, and never under
/// [`INPUT_MIN_BESIDE_PROMPT`]. A third keeps an ordinary prompt on its own
/// line in any pane you would type in (`user@host ~/src/app % ` is about 25
/// columns; an 80-column pane still leaves 55), and only moves the input when
/// a deep path or a busy theme has eaten most of the row — the report behind
/// this had a 119-column prompt in a 143-column pane, 24 columns left. The
/// floor covers narrow panes, where a third is a handful of columns.
///
/// The move also has to gain something: in a pane barely wider than the
/// floor, a short prompt leaves under 20 columns and a new row would hardly
/// give more. So it only happens when the prompt is at least as wide as what
/// it left — the new row at least doubles the room.
fn input_start(scol: usize, cols: usize) -> (usize, usize) {
let left = cols.saturating_sub(scol);
let floor = INPUT_MIN_BESIDE_PROMPT.max(cols / 3);
if left < floor && scol >= left {
(1, 0)
} else {
(0, scol)
}
}
/// Where each character of the input bar lands: `(row, column, width)`, one
/// entry per character, plus the row and column the text ends on.
/// entry per character, plus the row and column the text ends on. `start` is
/// where the first one goes, from [`input_start`].
///
/// Walks the same cells the bar draws rather than re-deriving widths per
/// character — an emoji presentation sequence is two columns and a stranded
@@ -7882,12 +7955,11 @@ fn menu_layout(
/// caret takes after the cell, which is where a caret sitting on one belongs.
fn input_char_positions(
chars: &[char],
scol: usize,
start: (usize, usize),
cols: usize,
) -> (Vec<(usize, usize, usize)>, usize, usize) {
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(chars.len());
let mut r = 0usize;
let mut c = scol;
let (mut r, mut c) = start;
for cell in input_cells(chars) {
if chars[cell.start] == '\n' {
positions.push((r, c, 0));
@@ -7912,7 +7984,7 @@ fn input_overlay_rows(
chars: &[char],
cursor: usize,
marked: &str,
scol: usize,
start: (usize, usize),
cols: usize,
) -> (usize, usize) {
let mut merged: Vec<char> = Vec::with_capacity(chars.len() + marked.len());
@@ -7920,7 +7992,7 @@ fn input_overlay_rows(
merged.extend_from_slice(&chars[..cursor]);
merged.extend(marked.chars());
merged.extend_from_slice(&chars[cursor..]);
let (positions, r, c) = input_char_positions(&merged, scol, cols);
let (positions, r, c) = input_char_positions(&merged, start, cols);
let end_row = if cursor >= chars.len() && marked.is_empty() && c >= cols {
r + 1
} else {
@@ -7938,14 +8010,14 @@ fn input_overflow_shift(crow: usize, caret_vrow: usize, visual_rows: usize, rows
fn wrapped_click_index(
chars: &[char],
scol: usize,
start: (usize, usize),
cols: usize,
col: usize,
target: usize,
clamp: bool,
) -> Option<usize> {
let len = chars.len();
let (positions, r, c) = input_char_positions(chars, scol, cols);
let (positions, r, c) = input_char_positions(chars, start, cols);
let end_row = if c >= cols { r + 1 } else { r };
if target > end_row {
return clamp.then_some(len);
@@ -8169,9 +8241,9 @@ mod tests {
use super::{
description_budget, drag_scroll_step, elide, encode_mouse, expand_file_command_template,
fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, highlight_runs,
input_cells, input_char_positions, input_overflow_shift, input_overlay_rows, menu_layout,
paste_bytes, select_end_copy, should_show_context_menu, smooth_scroll_step, submit_bytes,
trim_trailing_spaces, wheel_route, wrapped_click_index,
input_cells, input_char_positions, input_overflow_shift, input_overlay_rows, input_start,
menu_layout, paste_bytes, select_end_copy, should_show_context_menu, smooth_scroll_step,
submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
@@ -9540,7 +9612,7 @@ mod tests {
#[test]
fn input_char_positions_reserve_two_columns_for_wide_chars() {
let chars: Vec<char> = "a🀄b".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
let (positions, _, _) = input_char_positions(&chars, (0, 0), 80);
assert_eq!(positions, vec![(0, 0, 1), (0, 1, 2), (0, 3, 1)]);
}
@@ -9557,7 +9629,7 @@ mod tests {
"a\nb",
] {
let chars: Vec<char> = text.chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
let (positions, _, _) = input_char_positions(&chars, (0, 0), 80);
assert_eq!(positions.len(), chars.len(), "{text:?}");
for cell in input_cells(&chars) {
let drawn = if chars[cell.start] == '\n' {
@@ -9578,7 +9650,7 @@ mod tests {
#[test]
fn input_char_positions_reserve_two_columns_for_an_emoji_presentation_sequence() {
let chars: Vec<char> = "\u{2764}\u{FE0F}X".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
let (positions, _, _) = input_char_positions(&chars, (0, 0), 80);
assert_eq!(positions, vec![(0, 0, 2), (0, 2, 0), (0, 2, 1)]);
assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 0, 0), Some(0));
assert_eq!(click("\u{2764}\u{FE0F}X", 0, 80, 1, 0), Some(0));
@@ -9590,7 +9662,7 @@ mod tests {
#[test]
fn input_char_positions_give_a_stranded_combining_mark_a_column() {
let chars: Vec<char> = "\u{0301}ab".chars().collect();
let (positions, _, _) = input_char_positions(&chars, 0, 80);
let (positions, _, _) = input_char_positions(&chars, (0, 0), 80);
assert_eq!(positions, vec![(0, 0, 1), (0, 1, 1), (0, 2, 1)]);
}
@@ -9599,7 +9671,7 @@ mod tests {
#[test]
fn input_char_positions_wrap_a_cell_without_splitting_it() {
let chars: Vec<char> = "abc\u{2764}\u{FE0F}".chars().collect();
let (positions, r, c) = input_char_positions(&chars, 0, 4);
let (positions, r, c) = input_char_positions(&chars, (0, 0), 4);
assert_eq!(positions[3], (1, 0, 2));
assert_eq!((r, c), (1, 2));
}
@@ -9688,7 +9760,7 @@ mod tests {
fn click(text: &str, scol: usize, cols: usize, col: usize, row: usize) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
wrapped_click_index(&chars, scol, cols, col, row, false)
wrapped_click_index(&chars, (0, scol), cols, col, row, false)
}
#[test]
@@ -9720,10 +9792,10 @@ mod tests {
#[test]
fn wrapped_click_index_rows_past_the_input_need_clamp() {
let chars: Vec<char> = "ls".chars().collect();
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, false), None);
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, true), Some(2));
assert_eq!(wrapped_click_index(&[], 4, 80, 30, 0, false), Some(0));
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 1, false), None);
assert_eq!(wrapped_click_index(&chars, (0, 4), 80, 3, 2, false), None);
assert_eq!(wrapped_click_index(&chars, (0, 4), 80, 3, 2, true), Some(2));
assert_eq!(wrapped_click_index(&[], (0, 4), 80, 30, 0, false), Some(0));
assert_eq!(wrapped_click_index(&chars, (0, 4), 80, 3, 1, false), None);
}
#[test]
@@ -9731,7 +9803,7 @@ mod tests {
assert_eq!(click("abcdef", 4, 10, 0, 1), Some(6));
assert_eq!(click("abcdef", 4, 10, 7, 1), Some(6));
let chars: Vec<char> = "abcdef".chars().collect();
assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None);
assert_eq!(wrapped_click_index(&chars, (0, 4), 10, 0, 2, false), None);
}
#[test]
@@ -9749,7 +9821,7 @@ mod tests {
fn input_overlay_rows_counts_wraps_slot_marked_and_newlines() {
let rows = |text: &str, cursor: usize, marked: &str, scol: usize, cols: usize| {
let chars: Vec<char> = text.chars().collect();
input_overlay_rows(&chars, cursor, marked, scol, cols)
input_overlay_rows(&chars, cursor, marked, (0, scol), cols)
};
assert_eq!(rows("", 0, "", 3, 8), (1, 0));
assert_eq!(rows("aaaaaaaaaa", 10, "", 6, 8), (3, 2));
@@ -9758,6 +9830,74 @@ mod tests {
assert_eq!(rows("ab", 1, "漢", 6, 8), (2, 1));
}
#[test]
fn the_input_stays_beside_an_ordinary_prompt() {
// `user@host ~/src/app % ` in an 80-column pane: 55 columns left.
assert_eq!(input_start(25, 80), (0, 25));
// No prompt at all, and a bare `$ `.
assert_eq!(input_start(0, 80), (0, 0));
assert_eq!(input_start(2, 143), (0, 2));
// Exactly a third left is enough.
assert_eq!(input_start(60, 90), (0, 60));
}
#[test]
fn a_prompt_that_eats_the_row_pushes_the_input_below_it() {
// #767: a 119-column prompt in a 143-column pane left 24.
assert_eq!(input_start(119, 143), (1, 0));
// One column short of a third.
assert_eq!(input_start(61, 90), (1, 0));
// A narrow pane falls back to the 20-column floor, not a third of it.
assert_eq!(input_start(25, 40), (1, 0));
assert_eq!(input_start(20, 40), (0, 20));
// A cursor parked in the last column, or past it, has no room at all.
assert_eq!(input_start(79, 80), (1, 0));
assert_eq!(input_start(80, 80), (1, 0));
}
#[test]
fn a_new_row_has_to_gain_something() {
// A 20-column pane: a `$ ` prompt leaves 18, under the floor, but a
// fresh row would give only two more.
assert_eq!(input_start(2, 20), (0, 2));
// Half the row is the break-even point.
assert_eq!(input_start(9, 20), (0, 9));
assert_eq!(input_start(10, 20), (1, 0));
}
/// Everything that reads the layout — the overlay's height, the caret's
/// row, clicks — sees the input a row down, with the prompt's row empty.
#[test]
fn input_below_the_prompt_is_laid_out_from_the_next_row() {
let start = input_start(119, 143);
let chars: Vec<char> = "git status".chars().collect();
let (positions, r, c) = input_char_positions(&chars, start, 143);
assert_eq!(positions[0], (1, 0, 1));
assert_eq!((r, c), (1, 10));
// Two rows tall — the prompt's and the input's — with the caret on
// the second, so an overflow shift keeps the input on screen.
assert_eq!(input_overlay_rows(&chars, 10, "", start, 143), (2, 1));
assert_eq!(input_overlay_rows(&[], 0, "", start, 143), (2, 1));
// Wrapping uses the whole width of the new row.
let long: Vec<char> = "a".repeat(150).chars().collect();
assert_eq!(input_overlay_rows(&long, 150, "", start, 143), (3, 2));
// A click on the prompt's row lands before the first character; on
// the input row it hits the character under it.
assert_eq!(
wrapped_click_index(&chars, start, 143, 130, 0, false),
Some(0)
);
assert_eq!(
wrapped_click_index(&chars, start, 143, 4, 1, false),
Some(4)
);
assert_eq!(
wrapped_click_index(&chars, start, 143, 60, 1, false),
Some(10)
);
assert_eq!(wrapped_click_index(&chars, start, 143, 0, 2, false), None);
}
#[test]
fn input_overflow_shift_keeps_the_tail_and_caret_visible() {
assert_eq!(input_overflow_shift(5, 2, 3, 22), 0);