reimplemented incremental autosuggestion acceptance

This commit is contained in:
2026-03-19 01:11:13 -04:00
parent 4a82f29231
commit 406fd57b5a
4 changed files with 143 additions and 43 deletions

View File

@@ -1368,10 +1368,12 @@ pub fn unescape_str(raw: &str) -> String {
} }
} }
'$' => { '$' => {
result.push(markers::VAR_SUB); if chars.peek() == Some(&'$')
if chars.peek() == Some(&'$') { || chars.peek().is_none_or(|ch| ch.is_whitespace()) {
chars.next(); chars.next();
result.push('$'); result.push('$');
} else {
result.push(markers::VAR_SUB);
} }
} }
'`' => { '`' => {

View File

@@ -130,6 +130,27 @@ pub fn trim_lines(lines: &mut Vec<Line>) {
} }
} }
pub fn split_lines_at(lines: &mut Vec<Line>, pos: Pos) -> Vec<Line> {
let tail = lines[pos.row].split_off(pos.col);
let mut rest: Vec<Line> = lines.drain(pos.row + 1..).collect();
rest.insert(0, tail);
rest
}
pub fn attach_lines(lines: &mut Vec<Line>, other: &mut Vec<Line>) {
if other.len() == 0 { return }
if lines.len() == 0 {
lines.append(other);
return;
}
let mut head = other.remove(0);
let mut tail = lines.pop().unwrap();
tail.append(&mut head);
lines.push(tail);
lines.append(other);
}
#[derive(Default, Debug, Clone, PartialEq, Eq)] #[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct Line(Vec<Grapheme>); pub struct Line(Vec<Grapheme>);
@@ -547,7 +568,7 @@ fn extract_range_contiguous(buf: &mut Vec<Line>, start: Pos, end: Pos) -> Vec<Li
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LineBuf { pub struct LineBuf {
pub lines: Vec<Line>, pub lines: Vec<Line>,
pub hint: Vec<Line>, pub hint: Option<Vec<Line>>,
pub cursor: Cursor, pub cursor: Cursor,
pub select_mode: Option<SelectMode>, pub select_mode: Option<SelectMode>,
@@ -565,7 +586,7 @@ impl Default for LineBuf {
fn default() -> Self { fn default() -> Self {
Self { Self {
lines: vec![Line::from(vec![])], lines: vec![Line::from(vec![])],
hint: vec![], hint: None,
cursor: Cursor { cursor: Cursor {
pos: Pos { row: 0, col: 0 }, pos: Pos { row: 0, col: 0 },
exclusive: false, exclusive: false,
@@ -585,6 +606,9 @@ impl LineBuf {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub fn is_empty(&self) -> bool {
self.lines.len() == 0 || (self.lines.len() == 1 && self.count_graphemes() == 0)
}
pub fn count_graphemes(&self) -> usize { pub fn count_graphemes(&self) -> usize {
self.lines.iter().map(|line| line.len()).sum() self.lines.iter().map(|line| line.len()).sum()
} }
@@ -1009,11 +1033,21 @@ impl LineBuf {
fn char_classes_backward(&self) -> impl Iterator<Item = (Pos,CharClass)> { fn char_classes_backward(&self) -> impl Iterator<Item = (Pos,CharClass)> {
self.char_classes_backward_from(self.cursor.pos) self.char_classes_backward_from(self.cursor.pos)
} }
fn end_pos(&self) -> Pos {
let mut pos = Pos::MAX;
pos.clamp_row(&self.lines);
pos.clamp_col(&self.lines[pos.row].0, false);
pos
}
fn eval_motion(&mut self, cmd: &ViCmd) -> Option<MotionKind> { fn eval_motion(&mut self, cmd: &ViCmd) -> Option<MotionKind> {
let ViCmd { verb, motion, .. } = cmd; let ViCmd { verb, motion, .. } = cmd;
let MotionCmd(count, motion) = motion.as_ref()?; let MotionCmd(count, motion) = motion.as_ref()?;
let buffer = self.lines.clone();
if let Some(mut hint) = self.hint.clone() {
attach_lines(&mut self.lines, &mut hint);
}
match motion { let kind = match motion {
Motion::WholeLine => Some(MotionKind::Line(self.row())), Motion::WholeLine => Some(MotionKind::Line(self.row())),
Motion::TextObj(text_obj) => todo!(), Motion::TextObj(text_obj) => todo!(),
Motion::EndOfLastWord => todo!(), Motion::EndOfLastWord => todo!(),
@@ -1076,7 +1110,8 @@ impl LineBuf {
self.saved_col = Some(self.cursor.pos.col); self.saved_col = Some(self.cursor.pos.col);
} }
let row = self.offset_row(off); let row = self.offset_row(off);
let col = self.saved_col.unwrap().min(self.lines[row].len()); let limit = if self.cursor.exclusive { self.lines[row].len().saturating_sub(1) } else { self.lines[row].len() };
let col = self.saved_col.unwrap().min(limit);
let target = Pos { row, col }; let target = Pos { row, col };
(target != self.cursor.pos).then_some(MotionKind::Char { target, inclusive: true }) (target != self.cursor.pos).then_some(MotionKind::Char { target, inclusive: true })
} }
@@ -1106,13 +1141,22 @@ impl LineBuf {
Motion::Global(val) => todo!(), Motion::Global(val) => todo!(),
Motion::NotGlobal(val) => todo!(), Motion::NotGlobal(val) => todo!(),
Motion::Null => None, Motion::Null => None,
} };
self.lines = buffer;
kind
} }
fn apply_motion(&mut self, motion: MotionKind) -> ShResult<()> { fn apply_motion(&mut self, motion: MotionKind) -> ShResult<()> {
log::debug!("Applying motion: {:?}, current cursor: {:?}", motion, self.cursor.pos);
match motion { match motion {
MotionKind::Char { target, inclusive: _ } => { MotionKind::Char { target, inclusive: _ } => {
log::debug!("self.end_pos > target: {}, self.end_pos: {:?}", target > self.end_pos(), self.end_pos());
if self.has_hint() && target >= self.end_pos() {
self.accept_hint_to(target);
} else {
self.set_cursor(target); self.set_cursor(target);
} }
}
MotionKind::Line(ln) => { MotionKind::Line(ln) => {
self.set_row(ln); self.set_row(ln);
} }
@@ -1270,12 +1314,15 @@ impl LineBuf {
motion, motion,
.. ..
} = cmd; } = cmd;
let Some(VerbCmd(count, verb)) = verb else { let Some(VerbCmd(_, verb)) = verb else {
let Some(motion_kind) = self.eval_motion(cmd) else { // For verb-less motions in insert mode, merge hint before evaluating
// so motions like `w` can see into the hint text
let result = self.eval_motion(cmd);
if let Some(motion_kind) = result {
self.apply_motion(motion_kind)?;
}
return Ok(()); return Ok(());
}; };
return self.apply_motion(motion_kind);
};
let count = motion.as_ref().map(|m| m.0).unwrap_or(1); let count = motion.as_ref().map(|m| m.0).unwrap_or(1);
match verb { match verb {
@@ -1597,8 +1644,13 @@ impl LineBuf {
let before = self.lines.clone(); let before = self.lines.clone();
let old_cursor = self.cursor.pos; let old_cursor = self.cursor.pos;
// Execute the command
let res = self.exec_verb(&cmd); let res = self.exec_verb(&cmd);
if self.is_empty() {
self.set_hint(None);
}
let new_cursor = self.cursor.pos; let new_cursor = self.cursor.pos;
if self.lines != before && !is_undo_op { if self.lines != before && !is_undo_op {
@@ -1648,7 +1700,8 @@ impl LineBuf {
} }
} }
fn fix_cursor(&mut self) { pub fn fix_cursor(&mut self) {
log::debug!("Fixing cursor, exclusive: {}, current pos: {:?}", self.cursor.exclusive, self.cursor.pos);
if self.cursor.exclusive { if self.cursor.exclusive {
let line = self.cur_line(); let line = self.cur_line();
let col = self.col(); let col = self.col();
@@ -1688,21 +1741,32 @@ impl LineBuf {
/// Compat shim: set hint text. None clears the hint. /// Compat shim: set hint text. None clears the hint.
pub fn set_hint(&mut self, hint: Option<String>) { pub fn set_hint(&mut self, hint: Option<String>) {
match hint { let joined = self.joined();
Some(s) => self.hint = to_lines(&s), self.hint = hint
None => self.hint.clear(), .and_then(|h| {
} h.strip_prefix(&joined).map(|s| s.to_string())
})
.and_then(|h| {
(!h.is_empty()).then_some(to_lines(h))
});
} }
/// Compat shim: returns true if there is a non-empty hint. /// Compat shim: returns true if there is a non-empty hint.
pub fn has_hint(&self) -> bool { pub fn has_hint(&self) -> bool {
!self.hint.is_empty() && self.hint.iter().any(|l| !l.is_empty()) self.hint.as_ref().is_some_and(|h| !h.is_empty() && h.iter().any(|l| !l.is_empty()))
} }
/// Compat shim: get hint text as a string. /// Compat shim: get hint text as a string.
pub fn get_hint_text(&self) -> String { pub fn get_hint_text(&self) -> String {
let text = self.get_hint_text_raw();
let text = format!("\x1b[90m{text}\x1b[0m");
text.replace("\n", "\n\x1b[90m")
}
pub fn get_hint_text_raw(&self) -> String {
let mut lines = vec![]; let mut lines = vec![];
let mut hint = self.hint.clone(); let mut hint = self.hint.clone().unwrap_or_default();
trim_lines(&mut hint); trim_lines(&mut hint);
for line in hint { for line in hint {
lines.push(line.to_string()); lines.push(line.to_string());
@@ -1710,20 +1774,52 @@ impl LineBuf {
lines.join("\n") lines.join("\n")
} }
/// Accept hint text up to a given target position.
/// Temporarily merges the hint into the buffer, moves the cursor to target,
/// then splits: everything from cursor onward becomes the new hint.
fn accept_hint_to(&mut self, target: Pos) {
let Some(mut hint) = self.hint.take() else {
self.set_cursor(target);
return
};
attach_lines(&mut self.lines, &mut hint);
// Split after the target position so the char at target
// becomes part of the buffer (w lands ON the next word start)
let split_pos = Pos {
row: target.row,
col: target.col + 1,
};
// Clamp to buffer bounds
let split_pos = Pos {
row: split_pos.row.min(self.lines.len().saturating_sub(1)),
col: split_pos.col.min(self.lines[split_pos.row.min(self.lines.len().saturating_sub(1))].len()),
};
let new_hint = split_lines_at(&mut self.lines, split_pos);
self.hint = (!new_hint.is_empty() && new_hint.iter().any(|l| !l.is_empty())).then_some(new_hint);
self.set_cursor(target);
}
/// Compat shim: accept the current hint by appending it to the buffer. /// Compat shim: accept the current hint by appending it to the buffer.
pub fn accept_hint(&mut self) { pub fn accept_hint(&mut self) {
if self.hint.is_empty() { let hint_str = self.get_hint_text_raw();
return; if hint_str.is_empty() {
return
} }
let hint_str = self.get_hint_text(); // Move cursor to end of buffer, then insert so the hint
self.push_str(&hint_str); // joins with the last line's content properly
self.hint.clear(); let last_row = self.lines.len().saturating_sub(1);
let last_col = self.lines[last_row].len();
self.cursor.pos = Pos { row: last_row, col: last_col };
self.insert_str(&hint_str);
self.hint = None;
} }
/// Compat shim: return a constructor that sets initial buffer contents and cursor. /// Compat shim: return a constructor that sets initial buffer contents and cursor.
pub fn with_initial(mut self, s: &str, cursor_pos: usize) -> Self { pub fn with_initial(mut self, s: &str, cursor_pos: usize) -> Self {
self.set_buffer(s.to_string()); self.set_buffer(s.to_string());
// In the flat model, cursor_pos was a flat offset. Map to col on row 0. // In the flat model, cursor_pos was a flat offset. Map to col on row .
self.cursor.pos = Pos { self.cursor.pos = Pos {
row: 0, row: 0,
col: cursor_pos.min(s.len()), col: cursor_pos.min(s.len()),

View File

@@ -961,6 +961,8 @@ impl ShedVi {
// Since there is no "future" history, we should just bell and do nothing // Since there is no "future" history, we should just bell and do nothing
self.writer.send_bell().ok(); self.writer.send_bell().ok();
} }
self.editor.set_cursor_clamp(self.mode.clamp_cursor());
self.editor.fix_cursor();
} }
pub fn should_accept_hint(&self, event: &KeyEvent) -> bool { pub fn should_accept_hint(&self, event: &KeyEvent) -> bool {
if self.editor.cursor_at_max() && self.editor.has_hint() { if self.editor.cursor_at_max() && self.editor.has_hint() {

View File

@@ -120,11 +120,11 @@ pub fn common_cmds(key: E) -> Option<ViCmd> {
E(K::Char('D'), M::CTRL) => pending_cmd.set_verb(VerbCmd(1, Verb::EndOfFile)), E(K::Char('D'), M::CTRL) => pending_cmd.set_verb(VerbCmd(1, Verb::EndOfFile)),
E(K::Delete, M::NONE) => { E(K::Delete, M::NONE) => {
pending_cmd.set_verb(VerbCmd(1, Verb::Delete)); pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
pending_cmd.set_motion(MotionCmd(1, Motion::ForwardChar)); pending_cmd.set_motion(MotionCmd(1, Motion::ForwardCharForced));
} }
E(K::Backspace, M::NONE) | E(K::Char('H'), M::CTRL) => { E(K::Backspace, M::NONE) | E(K::Char('H'), M::CTRL) => {
pending_cmd.set_verb(VerbCmd(1, Verb::Delete)); pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
pending_cmd.set_motion(MotionCmd(1, Motion::BackwardChar)); pending_cmd.set_motion(MotionCmd(1, Motion::BackwardCharForced));
} }
_ => return None, _ => return None,
} }