mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16: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) {
|
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
|
/// 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());
|
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) {
|
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
|
/// 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"));
|
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]
|
#[test]
|
||||||
fn set_with_cursor_sets_line_and_clamps() {
|
fn set_with_cursor_sets_line_and_clamps() {
|
||||||
let mut e = ed("abc", 1);
|
let mut e = ed("abc", 1);
|
||||||
|
|||||||
@@ -1550,10 +1550,16 @@ impl Element for TerminalElement {
|
|||||||
|
|
||||||
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
||||||
|
|
||||||
// A pointing-hand cursor over a hovered link reinforces that it's
|
// Mouse pointer over the surface: a pointing hand over a hovered link
|
||||||
// clickable (Cmd+click opens it).
|
// (Cmd+click opens it); otherwise an I-beam over the selectable text,
|
||||||
if self.view.read(cx).hovered_link.is_some() {
|
// 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);
|
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 {
|
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
|
/// 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.
|
/// (keeping the anchor word intact). `None` for a plain char-granular drag.
|
||||||
editor_drag_word: Option<(usize, usize)>,
|
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
|
/// 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
|
/// 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
|
/// cursor that mark a link as clickable. Stored in scroll-stable grid
|
||||||
@@ -914,6 +919,7 @@ impl TerminalView {
|
|||||||
editor_selecting: false,
|
editor_selecting: false,
|
||||||
editor_select_gesture: false,
|
editor_select_gesture: false,
|
||||||
editor_drag_word: None,
|
editor_drag_word: None,
|
||||||
|
editor_goal_col: None,
|
||||||
hovered_link: None,
|
hovered_link: None,
|
||||||
_focus_subs: focus_subs,
|
_focus_subs: focus_subs,
|
||||||
}
|
}
|
||||||
@@ -1372,6 +1378,11 @@ impl TerminalView {
|
|||||||
let m = &ks.modifiers;
|
let m = &ks.modifiers;
|
||||||
let key = ks.key.as_str();
|
let key = ks.key.as_str();
|
||||||
self.cursor_visible = true;
|
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.
|
// A reverse search, when active, owns the keyboard.
|
||||||
if self.reverse_search.is_some() {
|
if self.reverse_search.is_some() {
|
||||||
@@ -1521,6 +1532,14 @@ impl TerminalView {
|
|||||||
|
|
||||||
match key {
|
match key {
|
||||||
"enter" => {
|
"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);
|
self.submit_command(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1574,11 +1593,23 @@ impl TerminalView {
|
|||||||
"home" => self.editor_move_edge(false, m.shift),
|
"home" => self.editor_move_edge(false, m.shift),
|
||||||
"end" => self.editor_move_edge(true, m.shift),
|
"end" => self.editor_move_edge(true, m.shift),
|
||||||
"up" => {
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
"down" => {
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
"escape" => {
|
"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 {
|
fn has_selection(&self) -> bool {
|
||||||
self.terminal.term.lock().selection.is_some()
|
self.terminal.term.lock().selection.is_some()
|
||||||
}
|
}
|
||||||
@@ -1811,6 +1915,7 @@ impl TerminalView {
|
|||||||
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
|
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
|
||||||
self.cmd.insert_str(trimmed);
|
self.cmd.insert_str(trimmed);
|
||||||
self.history_nav = None;
|
self.history_nav = None;
|
||||||
|
self.editor_goal_col = None;
|
||||||
self.close_completion();
|
self.close_completion();
|
||||||
self.cursor_visible = true;
|
self.cursor_visible = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -2290,6 +2395,7 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.editor_select_gesture = true;
|
self.editor_select_gesture = true;
|
||||||
|
self.editor_goal_col = None;
|
||||||
self.close_completion();
|
self.close_completion();
|
||||||
self.cursor_visible = true;
|
self.cursor_visible = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -2499,7 +2605,10 @@ impl TerminalView {
|
|||||||
// "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U
|
// "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U
|
||||||
// ahead of the line bytes.
|
// ahead of the line bytes.
|
||||||
self.wipe_pending_typeahead();
|
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');
|
bytes.push(b'\r');
|
||||||
self.terminal.write(bytes);
|
self.terminal.write(bytes);
|
||||||
self.cmd.clear();
|
self.cmd.clear();
|
||||||
@@ -3009,6 +3118,7 @@ impl TerminalView {
|
|||||||
// re-filters to the extended word (and closes once nothing matches).
|
// re-filters to the extended word (and closes once nothing matches).
|
||||||
self.cmd.insert_str(text);
|
self.cmd.insert_str(text);
|
||||||
self.history_nav = None;
|
self.history_nav = None;
|
||||||
|
self.editor_goal_col = None;
|
||||||
self.completion_refilter();
|
self.completion_refilter();
|
||||||
self.cursor_visible = true;
|
self.cursor_visible = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -3584,35 +3694,73 @@ impl TerminalView {
|
|||||||
d.into_any_element()
|
d.into_any_element()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Leading spacer the width of the shell prompt: the first input row begins
|
// A blank cell of the given width and the line height — used for the
|
||||||
// right after the prompt, while wrapped rows start at the grid's left edge
|
// leading prompt spacer and for a selected/caret slot standing in for a
|
||||||
// — matching how a real terminal wraps a long command line.
|
// hard line break.
|
||||||
let mut children: Vec<gpui::AnyElement> = vec![
|
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
|
||||||
div()
|
|
||||||
.flex_none()
|
// The buffer's logical lines, each rendered as its own `flex_wrap` row and
|
||||||
.w(cell_w * (ccol as f32))
|
// stacked in a column, so an embedded `'\n'` (from a pasted multi-line
|
||||||
.h(lh)
|
// command, or Shift/Opt+Enter) shows as a real line break instead of
|
||||||
.into_any_element(),
|
// 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 {
|
for i in 0..len {
|
||||||
// IME pre-edit shows underlined at the caret; the bar caret is hidden
|
// IME pre-edit shows underlined at the caret; the bar caret is hidden
|
||||||
// while composing.
|
// while composing.
|
||||||
if i == cursor && has_marked {
|
if i == cursor && has_marked {
|
||||||
for mc in marked.chars() {
|
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 selected = selection.is_some_and(|(s, e)| i >= s && i < e);
|
||||||
let caret = selection.is_none() && !has_marked && cursor_on && cursor == i;
|
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
|
// 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
|
// selection / IME / newline), computed up front so the end-of-line caret can
|
||||||
// the first ghost cell instead of needing its own (which would push the
|
// ride on the first ghost cell instead of needing its own (which would push
|
||||||
// ghost a full cell to the right).
|
// the ghost a full cell to the right).
|
||||||
let ghost: Option<String> = if selection.is_none() && !has_marked {
|
let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline {
|
||||||
self.ghost_suggestion()
|
self.ghost_suggestion()
|
||||||
.map(|full| full.chars().skip(len).collect::<String>())
|
.map(|full| full.chars().skip(len).collect::<String>())
|
||||||
.filter(|r| !r.is_empty())
|
.filter(|r| !r.is_empty())
|
||||||
@@ -3620,31 +3768,44 @@ impl TerminalView {
|
|||||||
None
|
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 {
|
if cursor == len {
|
||||||
|
let last = lines.last_mut().unwrap();
|
||||||
if has_marked {
|
if has_marked {
|
||||||
for mc in marked.chars() {
|
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() {
|
} else if ghost.is_none() {
|
||||||
// No ghost following: a trailing cell carries the caret (and is the
|
// No ghost following: a trailing cell carries the caret (and is the
|
||||||
// click target for "end of line").
|
// 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 {
|
if selection.is_none() && cursor_on {
|
||||||
tail = tail.child(caret_bar());
|
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.
|
// else: the caret rides on the first ghost cell below.
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(rem) = ghost {
|
if let Some(rem) = ghost {
|
||||||
|
let last = lines.last_mut().unwrap();
|
||||||
for (gi, gc) in rem.chars().enumerate() {
|
for (gi, gc) in rem.chars().enumerate() {
|
||||||
let caret = gi == 0 && cursor == len && cursor_on;
|
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()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.left(px(GRID_PAD_X))
|
.left(px(GRID_PAD_X))
|
||||||
@@ -3652,15 +3813,14 @@ impl TerminalView {
|
|||||||
.right_4()
|
.right_4()
|
||||||
.min_h(lh)
|
.min_h(lh)
|
||||||
.flex()
|
.flex()
|
||||||
.flex_wrap()
|
.flex_col()
|
||||||
.items_center()
|
|
||||||
// Transparent: the text overlays the grid in place, reading as a
|
// Transparent: the text overlays the grid in place, reading as a
|
||||||
// natural continuation of the shell prompt rather than a separate bar.
|
// natural continuation of the shell prompt rather than a separate bar.
|
||||||
.font_family(self.font.family.clone())
|
.font_family(self.font.family.clone())
|
||||||
.text_size(self.font_size)
|
.text_size(self.font_size)
|
||||||
.line_height(lh)
|
.line_height(lh)
|
||||||
.text_color(fg)
|
.text_color(fg)
|
||||||
.children(children)
|
.children(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The floating completion menu, shown below the word while a completion is
|
/// 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 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
|
/// 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).
|
/// `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(
|
fn wrapped_click_index(
|
||||||
chars: &[char],
|
chars: &[char],
|
||||||
scol: usize,
|
scol: usize,
|
||||||
@@ -4561,19 +4753,9 @@ fn wrapped_click_index(
|
|||||||
clamp: bool,
|
clamp: bool,
|
||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
let len = chars.len();
|
let len = chars.len();
|
||||||
// `positions[i]` is the (row, start-col, width) of char `i`.
|
// `positions[i]` is the (row, start-col, width) of char `i`; `r`/`c` are the
|
||||||
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(len);
|
// pen position after the last char.
|
||||||
let mut r = 0usize;
|
let (positions, r, c) = input_char_positions(chars, scol, cols);
|
||||||
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;
|
|
||||||
}
|
|
||||||
// The renderer appends a one-cell end-of-line caret slot after the last
|
// 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
|
// 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
|
// row (where the caret is visibly drawn), so clicks there must still count
|
||||||
@@ -4594,7 +4776,16 @@ fn wrapped_click_index(
|
|||||||
return Some(fi);
|
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) {
|
match positions.iter().position(|&(pr, _, _)| pr > target) {
|
||||||
Some(ni) => Some(ni),
|
Some(ni) => Some(ni),
|
||||||
None => Some(len),
|
None => Some(len),
|
||||||
@@ -5135,6 +5326,25 @@ mod tests {
|
|||||||
assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None);
|
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]
|
#[test]
|
||||||
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
|
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
|
||||||
// Plenty of room below: all 5 rows drop under the input row.
|
// Plenty of room below: all 5 rows drop under the input row.
|
||||||
|
|||||||
Reference in New Issue
Block a user