mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
feat(terminal): multi-line prompt editor + I-beam mouse pointer (#80)
* feat(terminal): multi-line command editor at the prompt The inline prompt editor was single-line: a pasted multi-line command kept its newlines inline but rendered them as blank cells in one flex-wrapped row, flattening the command into a ragged blob with the original indentation left as runs of spaces. Make the editor genuinely line-aware: - render_input_bar splits the buffer on '\n' and stacks each logical line as its own flex-wrap row, so newlines become real breaks (soft-wrap within a line is unchanged); ghost autosuggestion is suppressed for multi-line input. - submit replays each embedded newline as an Enter so the shell's own line editor assembles the command (backslash / open-quote continuation, PS2). - up/down move the caret between visual rows with a sticky goal column, falling through to history recall only from the top/bottom row. - Home/End (Ctrl-A/E) act within the current logical line. - click mapping treats '\n' as a hard break (past a line's content snaps to its end, not onto the next line). - Shift+Enter / Opt+Enter insert a newline to author multi-line commands. Adds tests for logical-line Home/End and newline-aware click mapping. * fix(terminal): I-beam mouse pointer over selectable text The surface only set a pointer style (PointingHand) over hovered links, so everywhere else the OS default arrow showed over the terminal text. Default to an I-beam over the text like every other terminal, keep the pointing hand for links, and leave the arrow only once a program takes over mouse reporting (matching Terminal.app / iTerm). * style: cargo fmt --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
@@ -277,8 +277,31 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Char index of the start of the logical line containing `idx` — just after
|
||||
/// the preceding `'\n'`, or `0`. A multi-line buffer (from a pasted command)
|
||||
/// keeps its `'\n'`s inline; Home / Ctrl-A act within the current line.
|
||||
pub fn line_start(&self, idx: usize) -> usize {
|
||||
let mut s = idx.min(self.chars.len());
|
||||
while s > 0 && self.chars[s - 1] != '\n' {
|
||||
s -= 1;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Char index of the end of the logical line containing `idx` — the next
|
||||
/// `'\n'`, or the buffer end.
|
||||
pub fn line_end(&self, idx: usize) -> usize {
|
||||
let mut e = idx.min(self.chars.len());
|
||||
while e < self.chars.len() && self.chars[e] != '\n' {
|
||||
e += 1;
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Move to the start of the current logical line (Home / Ctrl-A). On a
|
||||
/// single-line buffer this is column 0, unchanged.
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
self.cursor = self.line_start(self.cursor);
|
||||
}
|
||||
|
||||
/// Place the cursor at char index `idx` (clamped to the line length). Used to
|
||||
@@ -287,8 +310,10 @@ impl CmdEditor {
|
||||
self.cursor = idx.min(self.chars.len());
|
||||
}
|
||||
|
||||
/// Move to the end of the current logical line (End / Ctrl-E). On a
|
||||
/// single-line buffer this is the buffer end, unchanged.
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.chars.len();
|
||||
self.cursor = self.line_end(self.cursor);
|
||||
}
|
||||
|
||||
/// Move left to the start of the previous word (skip trailing whitespace, then
|
||||
@@ -739,6 +764,33 @@ mod tests {
|
||||
assert_eq!(e.selected_text().as_deref(), Some("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_end_are_logical_line_relative_in_a_multiline_buffer() {
|
||||
// A pasted multi-line command keeps its '\n's inline; Home/End act within
|
||||
// the line the caret sits on, not the whole buffer.
|
||||
let mut e = ed("one\ntwo\nthree", 5); // caret in "two" (after 't', 'w')
|
||||
e.move_home();
|
||||
assert_eq!(e.cursor(), 4, "start of the 'two' line");
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 7, "end of the 'two' line (before the '\\n')");
|
||||
// First line: Home is column 0, End is just before the first '\n'.
|
||||
e.set_cursor(1);
|
||||
e.move_home();
|
||||
assert_eq!(e.cursor(), 0);
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 3);
|
||||
// Last line has no trailing '\n': End is the buffer end.
|
||||
e.set_cursor(10);
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 13);
|
||||
// A single-line buffer is unaffected: Home/End are the buffer edges.
|
||||
let mut s = ed("git push", 4);
|
||||
s.move_home();
|
||||
assert_eq!(s.cursor(), 0);
|
||||
s.move_end();
|
||||
assert_eq!(s.cursor(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_cursor_sets_line_and_clamps() {
|
||||
let mut e = ed("abc", 1);
|
||||
|
||||
@@ -1550,10 +1550,16 @@ impl Element for TerminalElement {
|
||||
|
||||
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
||||
|
||||
// A pointing-hand cursor over a hovered link reinforces that it's
|
||||
// clickable (Cmd+click opens it).
|
||||
if self.view.read(cx).hovered_link.is_some() {
|
||||
// Mouse pointer over the surface: a pointing hand over a hovered link
|
||||
// (Cmd+click opens it); otherwise an I-beam over the selectable text,
|
||||
// like every other terminal. Once a program takes over mouse reporting
|
||||
// the pointer stays the default arrow, signalling "the app owns this"
|
||||
// (matching Terminal.app / iTerm).
|
||||
let view = self.view.read(cx);
|
||||
if view.hovered_link.is_some() {
|
||||
window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox);
|
||||
} else if !view.mouse_mode() {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &prepaint.hitbox);
|
||||
}
|
||||
|
||||
if let Some(start) = fps_start {
|
||||
|
||||
+251
-41
@@ -324,6 +324,11 @@ pub struct TerminalView {
|
||||
/// double-click selected, so the drag can grow the selection by whole words
|
||||
/// (keeping the anchor word intact). `None` for a plain char-granular drag.
|
||||
editor_drag_word: Option<(usize, usize)>,
|
||||
/// Sticky target column for vertical caret motion (↑/↓ across a multi-line
|
||||
/// buffer). Set on the first vertical step from the caret's current visual
|
||||
/// column and preserved across a run of ↑/↓ so passing through a short line
|
||||
/// doesn't lose the column; any other motion or edit clears it (`None`).
|
||||
editor_goal_col: Option<usize>,
|
||||
/// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found
|
||||
/// in the row text), if any. Drives the hover underline and the pointing-hand
|
||||
/// cursor that mark a link as clickable. Stored in scroll-stable grid
|
||||
@@ -914,6 +919,7 @@ impl TerminalView {
|
||||
editor_selecting: false,
|
||||
editor_select_gesture: false,
|
||||
editor_drag_word: None,
|
||||
editor_goal_col: None,
|
||||
hovered_link: None,
|
||||
_focus_subs: focus_subs,
|
||||
}
|
||||
@@ -1372,6 +1378,11 @@ impl TerminalView {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
self.cursor_visible = true;
|
||||
// Any key other than a vertical step drops the sticky goal column, so the
|
||||
// next ↑/↓ takes its column from wherever the caret ends up.
|
||||
if key != "up" && key != "down" {
|
||||
self.editor_goal_col = None;
|
||||
}
|
||||
|
||||
// A reverse search, when active, owns the keyboard.
|
||||
if self.reverse_search.is_some() {
|
||||
@@ -1521,6 +1532,14 @@ impl TerminalView {
|
||||
|
||||
match key {
|
||||
"enter" => {
|
||||
// Shift+Enter / Opt+Enter inserts a newline to author (or extend)
|
||||
// a multi-line command; a plain Enter submits the whole buffer.
|
||||
if (m.shift || m.alt) && !m.control && !m.platform {
|
||||
self.cmd.insert_str("\n");
|
||||
self.history_nav = None;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
self.submit_command(cx);
|
||||
return;
|
||||
}
|
||||
@@ -1574,11 +1593,23 @@ impl TerminalView {
|
||||
"home" => self.editor_move_edge(false, m.shift),
|
||||
"end" => self.editor_move_edge(true, m.shift),
|
||||
"up" => {
|
||||
self.history_prev(cx);
|
||||
// Within a multi-line buffer ↑ moves up a visual row; from the
|
||||
// top row it recalls the previous history entry.
|
||||
if self.editor_move_v(false, m.shift) {
|
||||
cx.notify();
|
||||
} else {
|
||||
self.history_prev(cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
"down" => {
|
||||
self.history_next(cx);
|
||||
// The mirror of ↑: down a visual row, or newer history from the
|
||||
// bottom row.
|
||||
if self.editor_move_v(true, m.shift) {
|
||||
cx.notify();
|
||||
} else {
|
||||
self.history_next(cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
"escape" => {
|
||||
@@ -1736,6 +1767,79 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical caret motion across a multi-line / wrapped input buffer (↑/↓),
|
||||
/// with a sticky goal column so passing through a short line keeps the
|
||||
/// target column. Returns `true` if the caret moved within the buffer;
|
||||
/// `false` means it was already on the top row (↑) or bottom row (↓), so the
|
||||
/// caller falls through to history recall — matching how fish/zsh edit a
|
||||
/// multi-line line. Shift extends the selection.
|
||||
fn editor_move_v(&mut self, down: bool, shift: bool) -> bool {
|
||||
let Some((_, scol)) = self.cursor_cell() else {
|
||||
return false;
|
||||
};
|
||||
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);
|
||||
// The caret renders on the cell of the char it sits before, or on a
|
||||
// trailing slot at the buffer end (a fresh row when the buffer ends in a
|
||||
// newline).
|
||||
let end_caret = if len == 0 {
|
||||
(0usize, scol)
|
||||
} else {
|
||||
let (r, c, w) = positions[len - 1];
|
||||
if chars[len - 1] == '\n' {
|
||||
(r + 1, 0)
|
||||
} else {
|
||||
(r, c + w)
|
||||
}
|
||||
};
|
||||
let (cur_row, cur_col) = if self.cmd.cursor() < len {
|
||||
let (r, c, _) = positions[self.cmd.cursor()];
|
||||
(r, c)
|
||||
} else {
|
||||
end_caret
|
||||
};
|
||||
let mut max_row = positions.iter().map(|&(r, _, _)| r).max().unwrap_or(0);
|
||||
if chars.last() == Some(&'\n') {
|
||||
max_row += 1;
|
||||
}
|
||||
// On the boundary row in the travel direction, defer to history recall.
|
||||
if (down && cur_row >= max_row) || (!down && cur_row == 0) {
|
||||
self.editor_goal_col = None;
|
||||
return false;
|
||||
}
|
||||
let target = if down { cur_row + 1 } else { cur_row - 1 };
|
||||
let goal = *self.editor_goal_col.get_or_insert(cur_col);
|
||||
// Land on the caret slot of the target row nearest the goal column. Char
|
||||
// `i`'s slot is the caret *before* it; the buffer-end slot is `len`.
|
||||
let mut best: Option<(usize, usize)> = None; // (index, |col - goal|)
|
||||
for (i, &(r, c, _)) in positions.iter().enumerate() {
|
||||
if r == target {
|
||||
let dist = c.abs_diff(goal);
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
best = Some((i, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if end_caret.0 == target {
|
||||
let dist = end_caret.1.abs_diff(goal);
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
best = Some((len, dist));
|
||||
}
|
||||
}
|
||||
let Some((idx, _)) = best else {
|
||||
return false;
|
||||
};
|
||||
if shift {
|
||||
self.cmd.begin_selection();
|
||||
} else {
|
||||
self.cmd.clear_selection();
|
||||
}
|
||||
self.cmd.set_cursor(idx);
|
||||
true
|
||||
}
|
||||
|
||||
fn has_selection(&self) -> bool {
|
||||
self.terminal.term.lock().selection.is_some()
|
||||
}
|
||||
@@ -1811,6 +1915,7 @@ impl TerminalView {
|
||||
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
|
||||
self.cmd.insert_str(trimmed);
|
||||
self.history_nav = None;
|
||||
self.editor_goal_col = None;
|
||||
self.close_completion();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -2290,6 +2395,7 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
self.editor_select_gesture = true;
|
||||
self.editor_goal_col = None;
|
||||
self.close_completion();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -2499,7 +2605,10 @@ impl TerminalView {
|
||||
// "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U
|
||||
// ahead of the line bytes.
|
||||
self.wipe_pending_typeahead();
|
||||
let mut bytes = line.into_bytes();
|
||||
// Replay each embedded newline as an Enter so the shell's own line editor
|
||||
// assembles the multi-line command — backslash / open-quote continuation
|
||||
// with its PS2 prompts — exactly as if it had been typed line by line.
|
||||
let mut bytes = line.replace('\n', "\r").into_bytes();
|
||||
bytes.push(b'\r');
|
||||
self.terminal.write(bytes);
|
||||
self.cmd.clear();
|
||||
@@ -3009,6 +3118,7 @@ impl TerminalView {
|
||||
// re-filters to the extended word (and closes once nothing matches).
|
||||
self.cmd.insert_str(text);
|
||||
self.history_nav = None;
|
||||
self.editor_goal_col = None;
|
||||
self.completion_refilter();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -3584,35 +3694,73 @@ impl TerminalView {
|
||||
d.into_any_element()
|
||||
};
|
||||
|
||||
// Leading spacer the width of the shell prompt: the first input row begins
|
||||
// right after the prompt, while wrapped rows start at the grid's left edge
|
||||
// — matching how a real terminal wraps a long command line.
|
||||
let mut children: Vec<gpui::AnyElement> = vec![
|
||||
div()
|
||||
.flex_none()
|
||||
.w(cell_w * (ccol as f32))
|
||||
.h(lh)
|
||||
.into_any_element(),
|
||||
];
|
||||
// A blank cell of the given width and the line height — used for the
|
||||
// leading prompt spacer and for a selected/caret slot standing in for a
|
||||
// hard line break.
|
||||
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
|
||||
|
||||
// The buffer's logical lines, each rendered as its own `flex_wrap` row and
|
||||
// stacked in a column, so an embedded `'\n'` (from a pasted multi-line
|
||||
// command, or Shift/Opt+Enter) shows as a real line break instead of
|
||||
// flowing into one wrapped blob. Within a line, soft-wrapping is left to
|
||||
// `flex_wrap` exactly as before. `lines` grows a fresh row on each `'\n'`.
|
||||
let mut lines: Vec<Vec<gpui::AnyElement>> = vec![vec![
|
||||
// Leading spacer the width of the shell prompt: the first line begins
|
||||
// right after the prompt; continuation lines start at the grid's left
|
||||
// edge, matching how the shell lays a multi-line command out.
|
||||
blank(cell_w * (ccol as f32)).into_any_element(),
|
||||
]];
|
||||
|
||||
// Ghost suggestion only makes sense for a single-line command (it completes
|
||||
// the whole history entry); suppress it once the buffer holds a newline.
|
||||
let is_multiline = chars.contains(&'\n');
|
||||
|
||||
for i in 0..len {
|
||||
// IME pre-edit shows underlined at the caret; the bar caret is hidden
|
||||
// while composing.
|
||||
if i == cursor && has_marked {
|
||||
for mc in marked.chars() {
|
||||
children.push(cell(fg, mc, false, false, true));
|
||||
lines
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.push(cell(fg, mc, false, false, true));
|
||||
}
|
||||
}
|
||||
if chars[i] == '\n' {
|
||||
// The newline is a hard break, not a glyph. If the caret sits on it
|
||||
// (end of this visual line) draw a trailing caret slot before the
|
||||
// break so it stays visible; if the newline falls inside a selection
|
||||
// draw a thin selected slot so a multi-line selection reads across
|
||||
// the break. Then start the next row.
|
||||
if selection.is_none() && !has_marked && cursor_on && cursor == i {
|
||||
lines.last_mut().unwrap().push(
|
||||
blank(cell_w)
|
||||
.relative()
|
||||
.child(caret_bar())
|
||||
.into_any_element(),
|
||||
);
|
||||
} else if selection.is_some_and(|(s, e)| i >= s && i < e) {
|
||||
lines
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.push(blank(cell_w).bg(sel_bg).into_any_element());
|
||||
}
|
||||
lines.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
let selected = selection.is_some_and(|(s, e)| i >= s && i < e);
|
||||
let caret = selection.is_none() && !has_marked && cursor_on && cursor == i;
|
||||
children.push(cell(colors[i], chars[i], selected, caret, false));
|
||||
lines
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.push(cell(colors[i], chars[i], selected, caret, false));
|
||||
}
|
||||
|
||||
// Ghost autosuggestion remainder (only when caret is at the end, no
|
||||
// selection / IME), computed up front so the end-of-line caret can ride on
|
||||
// the first ghost cell instead of needing its own (which would push the
|
||||
// ghost a full cell to the right).
|
||||
let ghost: Option<String> = if selection.is_none() && !has_marked {
|
||||
// selection / IME / newline), computed up front so the end-of-line caret can
|
||||
// ride on the first ghost cell instead of needing its own (which would push
|
||||
// the ghost a full cell to the right).
|
||||
let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline {
|
||||
self.ghost_suggestion()
|
||||
.map(|full| full.chars().skip(len).collect::<String>())
|
||||
.filter(|r| !r.is_empty())
|
||||
@@ -3620,31 +3768,44 @@ impl TerminalView {
|
||||
None
|
||||
};
|
||||
|
||||
// Caret / pre-edit at end of line.
|
||||
// Caret / pre-edit at the end of the buffer — lands on the last row (a
|
||||
// fresh empty row when the buffer ends in a newline).
|
||||
if cursor == len {
|
||||
let last = lines.last_mut().unwrap();
|
||||
if has_marked {
|
||||
for mc in marked.chars() {
|
||||
children.push(cell(fg, mc, false, false, true));
|
||||
last.push(cell(fg, mc, false, false, true));
|
||||
}
|
||||
} else if ghost.is_none() {
|
||||
// No ghost following: a trailing cell carries the caret (and is the
|
||||
// click target for "end of line").
|
||||
let mut tail = div().relative().flex_none().w(cell_w).h(lh);
|
||||
let mut tail = blank(cell_w).relative();
|
||||
if selection.is_none() && cursor_on {
|
||||
tail = tail.child(caret_bar());
|
||||
}
|
||||
children.push(tail.into_any_element());
|
||||
last.push(tail.into_any_element());
|
||||
}
|
||||
// else: the caret rides on the first ghost cell below.
|
||||
}
|
||||
|
||||
if let Some(rem) = ghost {
|
||||
let last = lines.last_mut().unwrap();
|
||||
for (gi, gc) in rem.chars().enumerate() {
|
||||
let caret = gi == 0 && cursor == len && cursor_on;
|
||||
children.push(cell(muted, gc, false, caret, false));
|
||||
last.push(cell(muted, gc, false, caret, false));
|
||||
}
|
||||
}
|
||||
|
||||
let rows = lines.into_iter().map(move |cells| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.min_h(lh)
|
||||
.children(cells)
|
||||
});
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(GRID_PAD_X))
|
||||
@@ -3652,15 +3813,14 @@ impl TerminalView {
|
||||
.right_4()
|
||||
.min_h(lh)
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.flex_col()
|
||||
// Transparent: the text overlays the grid in place, reading as a
|
||||
// natural continuation of the shell prompt rather than a separate bar.
|
||||
.font_family(self.font.family.clone())
|
||||
.text_size(self.font_size)
|
||||
.line_height(lh)
|
||||
.text_color(fg)
|
||||
.children(children)
|
||||
.children(rows)
|
||||
}
|
||||
|
||||
/// The floating completion menu, shown below the word while a completion is
|
||||
@@ -4552,6 +4712,38 @@ fn menu_layout(
|
||||
/// first char snaps to that char; past a row's content snaps to the next row's
|
||||
/// first char (or the line end). Rows beyond the input return `len` with
|
||||
/// `clamp` (for drags) and `None` without (so the click isn't an editor click).
|
||||
/// Visual `(row, start-col, width)` of every char in the wrapped input line,
|
||||
/// matching `render_input_bar`'s layout: char 0 starts at column `scol` (right
|
||||
/// after the prompt), a `'\n'` is a hard break to column 0 of the next row (and
|
||||
/// occupies no cell — width 0), and within a line a char that would overflow
|
||||
/// wraps whole to column 0 of the next row. Also returns the pen `(row, col)`
|
||||
/// after the last char, so callers can place the trailing end-of-line caret.
|
||||
fn input_char_positions(
|
||||
chars: &[char],
|
||||
scol: 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;
|
||||
for &ch in chars {
|
||||
if ch == '\n' {
|
||||
positions.push((r, c, 0));
|
||||
r += 1;
|
||||
c = 0;
|
||||
continue;
|
||||
}
|
||||
let w = display_width(ch).max(1);
|
||||
if c + w > cols {
|
||||
r += 1;
|
||||
c = 0;
|
||||
}
|
||||
positions.push((r, c, w));
|
||||
c += w;
|
||||
}
|
||||
(positions, r, c)
|
||||
}
|
||||
|
||||
fn wrapped_click_index(
|
||||
chars: &[char],
|
||||
scol: usize,
|
||||
@@ -4561,19 +4753,9 @@ fn wrapped_click_index(
|
||||
clamp: bool,
|
||||
) -> Option<usize> {
|
||||
let len = chars.len();
|
||||
// `positions[i]` is the (row, start-col, width) of char `i`.
|
||||
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(len);
|
||||
let mut r = 0usize;
|
||||
let mut c = scol;
|
||||
for &ch in chars {
|
||||
let w = display_width(ch).max(1);
|
||||
if c + w > cols {
|
||||
r += 1;
|
||||
c = 0;
|
||||
}
|
||||
positions.push((r, c, w));
|
||||
c += w;
|
||||
}
|
||||
// `positions[i]` is the (row, start-col, width) of char `i`; `r`/`c` are the
|
||||
// pen position after the last char.
|
||||
let (positions, r, c) = input_char_positions(chars, scol, cols);
|
||||
// The renderer appends a one-cell end-of-line caret slot after the last
|
||||
// char; when the content exactly fills its row, that slot wraps to the next
|
||||
// row (where the caret is visibly drawn), so clicks there must still count
|
||||
@@ -4594,7 +4776,16 @@ fn wrapped_click_index(
|
||||
return Some(fi);
|
||||
}
|
||||
}
|
||||
// Past the row's content → start of the next row, or end of line.
|
||||
// Past the row's content. If the row ends at a hard line break, snap to that
|
||||
// newline — the end of this logical line — rather than jumping onto the next
|
||||
// line. (A soft-wrapped row has no newline, so it continues below.)
|
||||
if let Some(last) = positions.iter().rposition(|&(pr, _, _)| pr == target) {
|
||||
if chars[last] == '\n' {
|
||||
return Some(last);
|
||||
}
|
||||
}
|
||||
// Otherwise the line soft-wraps: snap to the first char of the next visual
|
||||
// row, or the buffer end.
|
||||
match positions.iter().position(|&(pr, _, _)| pr > target) {
|
||||
Some(ni) => Some(ni),
|
||||
None => Some(len),
|
||||
@@ -5135,6 +5326,25 @@ mod tests {
|
||||
assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_click_index_treats_newlines_as_hard_breaks() {
|
||||
// "a\nbc" after a 4-col prompt lays out as row 0 = "a" (col 4) and
|
||||
// row 1 = "bc" (cols 0..2). Indices: 0='a', 1='\n', 2='b', 3='c'.
|
||||
assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0)); // 'a'
|
||||
assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2)); // 'b' on the next line
|
||||
assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3)); // 'c'
|
||||
// Clicking past the end of the first line snaps to the newline (the end
|
||||
// of that logical line), not onto the second line.
|
||||
assert_eq!(click("a\nbc", 4, 80, 40, 0), Some(1));
|
||||
// Past the last line's content → buffer end.
|
||||
assert_eq!(click("a\nbc", 4, 80, 40, 1), Some(4));
|
||||
// A blank line in the middle ("a\n\nb") is its own row; clicking it lands
|
||||
// on that empty line rather than falling through to "b".
|
||||
// Indices: 0='a', 1='\n', 2='\n', 3='b'. Row 1 holds the second newline.
|
||||
assert_eq!(click("a\n\nb", 4, 80, 3, 1), Some(2));
|
||||
assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3)); // 'b' on row 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
|
||||
// Plenty of room below: all 5 rows drop under the input row.
|
||||
|
||||
Reference in New Issue
Block a user