From 362be13b5b2dbe05681dd52a1d7f87a2ce71ed9f Mon Sep 17 00:00:00 2001 From: pagedmov Date: Mon, 2 Mar 2026 01:54:23 -0500 Subject: [PATCH] Early implementation of fuzzy completion menu --- src/builtin/intro.rs | 2 +- src/builtin/read.rs | 5 - src/expand.rs | 5 +- src/jobs.rs | 19 +- src/parse/execute.rs | 43 ++++- src/readline/complete.rs | 405 +++++++++++++++++++++++++++++++++++---- src/readline/linebuf.rs | 9 +- src/readline/mod.rs | 51 ++++- src/readline/vimode.rs | 2 +- 9 files changed, 469 insertions(+), 72 deletions(-) diff --git a/src/builtin/intro.rs b/src/builtin/intro.rs index 5447e03..d7cd552 100644 --- a/src/builtin/intro.rs +++ b/src/builtin/intro.rs @@ -2,7 +2,7 @@ use std::{env, os::unix::fs::PermissionsExt, path::Path}; use ariadne::{Fmt, Span}; -use crate::{builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS}, state::{self, ShAlias, ShFunc, read_logic, read_vars}}; +use crate::{builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS}, state::{self, ShAlias, ShFunc, read_logic}}; pub fn type_builtin(node: Node) -> ShResult<()> { let NdRule::Command { diff --git a/src/builtin/read.rs b/src/builtin/read.rs index f2a96cf..a128246 100644 --- a/src/builtin/read.rs +++ b/src/builtin/read.rs @@ -80,11 +80,6 @@ pub fn read_builtin(node: Node) -> ShResult<()> { write(borrow_fd(STDOUT_FILENO), prompt.as_bytes())?; } - log::info!( - "read_builtin: starting read with delim={}", - read_opts.delim as char - ); - let input = if isatty(STDIN_FILENO)? { // Restore default terminal settings RawModeGuard::with_cooked_mode(|| { diff --git a/src/expand.rs b/src/expand.rs index 4570f63..ec7acde 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -16,7 +16,7 @@ use crate::state::{ ArrIndex, LogTab, VarFlags, VarKind, read_jobs, read_logic, read_vars, write_jobs, write_meta, write_vars, }; -use crate::{jobs, prelude::*}; +use crate::prelude::*; const PARAMETERS: [char; 7] = ['@', '*', '#', '$', '?', '!', '0']; @@ -949,9 +949,6 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult { } }; - // Reclaim terminal foreground in case child changed it - jobs::take_term()?; - match status { WtStat::Exited(_, _) => Ok(io_buf.as_str()?.trim_end().to_string()), _ => Err(ShErr::simple(ShErrKind::InternalErr, "Command sub failed")), diff --git a/src/jobs.rs b/src/jobs.rs index 1dc9b6f..930cb61 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -332,11 +332,8 @@ impl JobTab { self.fg.as_mut() } pub fn new_fg(&mut self, job: Job) -> ShResult> { - let pgid = job.pgid(); self.fg = Some(job); - attach_tty(pgid)?; let statuses = self.fg.as_mut().unwrap().wait_pgrp()?; - attach_tty(getpgrp())?; Ok(statuses) } pub fn fg_to_bg(&mut self, stat: WtStat) -> ShResult<()> { @@ -354,7 +351,7 @@ impl JobTab { pub fn bg_to_fg(&mut self, id: JobID) -> ShResult<()> { let job = self.remove_job(id); if let Some(job) = job { - wait_fg(job)?; + wait_fg(job, true)?; } Ok(()) } @@ -828,13 +825,15 @@ pub fn wait_bg(id: JobID) -> ShResult<()> { } /// Waits on the current foreground job and updates the shell's last status code -pub fn wait_fg(job: Job) -> ShResult<()> { +pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> { if job.children().is_empty() { return Ok(()); // Nothing to do } let mut code = 0; let mut was_stopped = false; - attach_tty(job.pgid())?; + if interactive { + attach_tty(job.pgid())?; + } disable_reaping(); defer! { enable_reaping(); @@ -862,16 +861,18 @@ pub fn wait_fg(job: Job) -> ShResult<()> { j.take_fg(); }); } - take_term()?; + if interactive { + take_term()?; + } set_status(code); Ok(()) } -pub fn dispatch_job(job: Job, is_bg: bool) -> ShResult<()> { +pub fn dispatch_job(job: Job, is_bg: bool, interactive: bool) -> ShResult<()> { if is_bg { write_jobs(|j| j.insert_job(job, false))?; } else { - wait_fg(job)?; + wait_fg(job, interactive)?; } Ok(()) } diff --git a/src/parse/execute.rs b/src/parse/execute.rs index fd7f2f2..4c08d09 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -152,6 +152,7 @@ pub struct Dispatcher { source_name: String, pub io_stack: IoStack, pub job_stack: JobStack, + fg_job: bool, } impl Dispatcher { @@ -163,6 +164,7 @@ impl Dispatcher { source_name, io_stack: IoStack::new(), job_stack: JobStack::new(), + fg_job: true, } } pub fn begin_dispatch(&mut self) -> ShResult<()> { @@ -660,6 +662,7 @@ impl Dispatcher { let pipes_and_cmds = get_pipe_stack(cmds.len()).into_iter().zip(cmds); let is_bg = pipeline.flags.contains(NdFlags::BACKGROUND); + self.fg_job = !is_bg && self.interactive; let mut tty_attached = false; for ((rpipe, wpipe), mut cmd) in pipes_and_cmds { @@ -686,15 +689,16 @@ impl Dispatcher { // Give the pipeline terminal control as soon as the first child // establishes the PGID, so later children (e.g. nvim) don't get // SIGTTOU when they try to modify terminal attributes. - if !tty_attached && !is_bg { - if let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid() { - attach_tty(pgid).ok(); - tty_attached = true; - } - } + // Only for interactive (top-level) pipelines — command substitution + // and other non-interactive contexts must not steal the terminal. + if !tty_attached && !is_bg && self.interactive + && let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid() { + attach_tty(pgid).ok(); + tty_attached = true; + } } let job = self.job_stack.finalize_job().unwrap(); - dispatch_job(job, is_bg)?; + dispatch_job(job, is_bg, self.interactive)?; Ok(()) } fn exec_builtin(&mut self, cmd: Node) -> ShResult<()> { @@ -866,17 +870,36 @@ impl Dispatcher { let job = self.job_stack.curr_job_mut().unwrap(); let existing_pgid = job.pgid(); + let fg_job = self.fg_job; let child_logic = |pgid: Option| -> ! { // Put ourselves in the correct process group before exec. // For the first child in a pipeline pgid is None, so we // become our own group leader (setpgid(0,0)). For later // children we join the leader's group. - let _ = setpgid(Pid::from_raw(0), pgid.unwrap_or(Pid::from_raw(0))); + let our_pgid = pgid.unwrap_or(Pid::from_raw(0)); + let _ = setpgid(Pid::from_raw(0), our_pgid); + + // For foreground jobs, take the terminal BEFORE resetting + // signals. SIGTTOU is still SIG_IGN (inherited from the shell), + // so tcsetpgrp won't stop us. This prevents a race + // where the child exec's and tries to read stdin before the + // parent has called tcsetpgrp — which would deliver SIGTTIN + // (now SIG_DFL after reset_signals) and stop the child. + if fg_job { + let tty_pgid = if our_pgid == Pid::from_raw(0) { + nix::unistd::getpid() + } else { + our_pgid + }; + let _ = tcsetpgrp( + unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) }, + tty_pgid, + ); + } // Reset signal dispositions before exec. SIG_IGN is preserved // across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would - // leak into child processes and break programs like nvim that - // need default terminal-stop behavior. + // leak into child processes. crate::signal::reset_signals(); let cmd = &exec_args.cmd.0; diff --git a/src/readline/complete.rs b/src/readline/complete.rs index 4f0e706..056b1c3 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -1,8 +1,9 @@ use std::{ - collections::HashSet, fmt::Debug, path::PathBuf, sync::Arc, + collections::HashSet, fmt::{Write,Debug}, path::PathBuf, sync::Arc, }; use nix::sys::signal::Signal; +use unicode_width::UnicodeWidthStr; use crate::{ builtin::complete::{CompFlags, CompOptFlags, CompOpts}, @@ -16,8 +17,7 @@ use crate::{ lex::{self, LexFlags, Tk, TkRule, ends_with_unescaped}, }, readline::{ - Marker, annotate_input_recursive, - markers::{self, is_marker}, + Marker, annotate_input_recursive, keys::{KeyCode as C, KeyEvent as K, ModKeys as M}, linebuf::{ClampedUsize, LineBuf}, markers::{self, is_marker}, term::{LineWriter, TermWriter}, vimode::{ViInsert, ViMode} }, state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars}, }; @@ -512,8 +512,322 @@ impl CompResult { } } +pub enum CompResponse { + Passthrough, // key falls through + Accept(String), // user accepted completion + Dismiss, // user canceled completion + Consumed // key was handled, but completion remains active +} + +pub trait Completer { + fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult>; + fn reset(&mut self); + fn is_active(&self) -> bool; + fn selected_candidate(&self) -> Option; + fn token_span(&self) -> (usize, usize); + fn original_input(&self) -> &str; + fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>; + fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) } + fn handle_key(&mut self, key: K) -> ShResult; + fn get_completed_line(&self, candidate: &str) -> String { + let (start, end) = self.token_span(); + let orig = self.original_input(); + format!("{}{}{}", &orig[..start], candidate, &orig[end..]) + } +} + #[derive(Default, Debug, Clone)] -pub struct Completer { +pub struct ScoredCandidate { + content: String, + score: Option, +} + +impl ScoredCandidate { + const BONUS_BOUNDARY: i32 = 10; + const BONUS_CONSECUTIVE: i32 = 8; + const BONUS_FIRST_CHAR: i32 = 5; + const PENALTY_GAP_START: i32 = 3; + const PENALTY_GAP_EXTEND: i32 = 1; + + pub fn new(content: String) -> Self { + Self { content, score: None } + } + fn is_word_bound(prev: char, curr: char) -> bool { + match prev { + '/' | '_' | '-' | '.' | ' ' => true, + c if c.is_lowercase() && curr.is_uppercase() => true, // camelCase boundary + _ => false, + } + } + pub fn fuzzy_score(&mut self, other: &str) -> i32 { + if other.is_empty() { + self.score = Some(0); + return 0; + } + + let query_chars: Vec = other.chars().collect(); + let content_chars: Vec = self.content.chars().collect(); + let mut indices = vec![]; + let mut qi = 0; + for (ci, c_ch) in self.content.chars().enumerate() { + if qi < query_chars.len() && c_ch.eq_ignore_ascii_case(&query_chars[qi]) { + indices.push(ci); + qi += 1; + } + } + + if indices.len() != query_chars.len() { + self.score = Some(i32::MIN); + return i32::MIN; + } + + let mut score: i32 = 0; + + for (i, &idx) in indices.iter().enumerate() { + if idx == 0 { + score += Self::BONUS_FIRST_CHAR; + } + + + if idx == 0 || Self::is_word_bound(content_chars[idx - 1], content_chars[idx]) { + score += Self::BONUS_BOUNDARY; + } + + if i > 0 { + let gap = idx - indices[i - 1] - 1; + if gap == 0 { + score += Self::BONUS_CONSECUTIVE; + } else { + score -= Self::PENALTY_GAP_START + (gap as i32 - 1) * Self::PENALTY_GAP_EXTEND; + } + } + } + + self.score = Some(score); + score + } +} + +impl From for ScoredCandidate { + fn from(content: String) -> Self { + Self { content, score: None } + } +} + +#[derive(Debug, Clone)] +pub struct FuzzyLayout { + rows: u16 +} + +#[derive(Default, Debug, Clone)] +pub struct QueryEditor { + mode: ViInsert, + linebuf: LineBuf +} + +impl QueryEditor { + pub fn clear(&mut self) { + self.linebuf = LineBuf::default(); + self.mode = ViInsert::default(); + } + pub fn handle_key(&mut self, key: K) -> ShResult<()> { + let Some(cmd) = self.mode.handle_key(key) else { + return Ok(()) + }; + self.linebuf.exec_cmd(cmd) + } +} + + +#[derive(Clone, Debug)] +pub struct FuzzyCompleter { + completer: SimpleCompleter, + query: QueryEditor, + filtered: Vec, + candidates: Vec, + cursor: ClampedUsize, + old_layout: Option, + max_height: usize, + scroll_offset: usize, + active: bool +} + +impl FuzzyCompleter { + fn get_window(&mut self) -> &[ScoredCandidate] { + let height = self.filtered.len().min(self.max_height); + + self.update_scroll_offset(); + + &self.filtered[self.scroll_offset..self.scroll_offset + height] + } + pub fn update_scroll_offset(&mut self) { + let height = self.filtered.len().min(self.max_height); + if self.cursor.get() < self.scroll_offset + 1 { + self.scroll_offset = self.cursor.ret_sub(1); + } + if self.cursor.get() >= self.scroll_offset + height.saturating_sub(1) { + self.scroll_offset = self.cursor.ret_sub(height.saturating_sub(2)); + } + self.scroll_offset = self.scroll_offset.min(self.filtered.len().saturating_sub(height)); + } + pub fn score_candidates(&mut self) { + let mut scored: Vec<_> = self.candidates + .clone() + .into_iter() + .filter_map(|c| { + let mut sc = ScoredCandidate::new(c); + let score = sc.fuzzy_score(self.query.linebuf.as_str()); + if score > i32::MIN { + Some(sc) + } else { + None + } + }).collect(); + scored.sort_by_key(|sc| sc.score.unwrap_or(i32::MIN)); + scored.reverse(); + self.cursor.set_max(scored.len()); + self.filtered = scored; + } +} + +impl Default for FuzzyCompleter { + fn default() -> Self { + Self { + max_height: 8, + completer: SimpleCompleter::default(), + query: QueryEditor::default(), + filtered: vec![], + candidates: vec![], + cursor: ClampedUsize::new(0, 0, true), + old_layout: None, + scroll_offset: 0, + active: false, + } + } +} + +impl Completer for FuzzyCompleter { + fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult> { + self.completer.complete(line, cursor_pos, direction)?; + let candidates: Vec<_> = self.completer.candidates.clone(); + if candidates.is_empty() { + self.completer.reset(); + self.active = false; + return Ok(None); + } + self.active = true; + self.candidates = candidates; + self.score_candidates(); + self.completer.reset(); + Ok(None) // FuzzyCompleter itself doesn't directly return a completed line, it manages the state of the filtered candidates and selection + } + + fn handle_key(&mut self, key: K) -> ShResult { + match key { + K(C::Esc, M::NONE) => { + self.active = false; + self.filtered.clear(); + Ok(CompResponse::Dismiss) + } + K(C::Enter, M::NONE) => { + if let Some(selected) = self.filtered.get(self.cursor.get()).map(|c| c.content.clone()) { + self.active = false; + self.query.clear(); + self.filtered.clear(); + Ok(CompResponse::Accept(selected)) + } else { + Ok(CompResponse::Passthrough) + } + } + K(C::Tab, M::SHIFT) | + K(C::Up, M::NONE) => { + self.cursor.sub(1); + self.update_scroll_offset(); + Ok(CompResponse::Consumed) + } + K(C::Tab, M::NONE) | + K(C::Down, M::NONE) => { + self.cursor.add(1); + self.update_scroll_offset(); + Ok(CompResponse::Consumed) + } + _ => { + self.query.handle_key(key)?; + self.score_candidates(); + Ok(CompResponse::Consumed) + } + } + } + fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { + if let Some(layout) = self.old_layout.take() { + let mut buf = String::new(); + // Cursor is on the query line. Move down to the last candidate. + if layout.rows > 0 { + write!(buf, "\x1b[{}B", layout.rows).unwrap(); + } + // Erase each line and move up, back to the query line + for _ in 0..layout.rows { + buf.push_str("\x1b[2K\x1b[A"); + } + // Erase the query line, then move up to the prompt line + buf.push_str("\x1b[2K\x1b[A"); + writer.flush_write(&buf)?; + } + Ok(()) + } + fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { + if !self.active { + return Ok(()); + } + + let mut buf = String::new(); + let cursor_pos = self.cursor.get(); + let offset = self.scroll_offset; + let query = self.query.linebuf.as_str().to_string(); + let visible = self.get_window(); + buf.push_str("\n\r> "); + buf.push_str(&query); + + for (i, candidate) in visible.iter().enumerate() { + buf.push_str("\n\r"); + if i + offset == cursor_pos { + buf.push_str("\x1b[7m"); + buf.push_str(&candidate.content); + buf.push_str("\x1b[0m"); + } else { + buf.push_str(&candidate.content); + } + } + let new_layout = FuzzyLayout { + rows: visible.len() as u16, // +1 for the query line + }; + + // Move cursor back up to the query line and position after "> " + query text + write!(buf, "\x1b[{}A\r\x1b[{}C", new_layout.rows, self.query.linebuf.as_str().width() + 2).unwrap(); + writer.flush_write(&buf)?; + self.old_layout = Some(new_layout); + + Ok(()) + } + fn reset(&mut self) { + *self = Self::default(); + } + fn token_span(&self) -> (usize, usize) { + self.completer.token_span() + } + fn is_active(&self) -> bool { + self.active + } + fn selected_candidate(&self) -> Option { + self.filtered.get(self.cursor.get()).map(|c| c.content.clone()) + } + fn original_input(&self) -> &str { + &self.completer.original_input + } +} + +#[derive(Default, Debug, Clone)] +pub struct SimpleCompleter { pub candidates: Vec, pub selected_idx: usize, pub original_input: String, @@ -523,7 +837,45 @@ pub struct Completer { pub add_space: bool, } -impl Completer { +impl Completer for SimpleCompleter { + fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult> { + if self.active { + Ok(Some(self.cycle_completion(direction))) + } else { + self.start_completion(line, cursor_pos) + } + } + + fn reset(&mut self) { + *self = Self::default(); + } + + fn is_active(&self) -> bool { + self.active + } + + fn selected_candidate(&self) -> Option { + self.candidates.get(self.selected_idx).cloned() + } + + fn token_span(&self) -> (usize, usize) { + self.token_span + } + + fn draw(&mut self, _writer: &mut TermWriter) -> ShResult<()> { + Ok(()) + } + + fn original_input(&self) -> &str { + &self.original_input + } + + fn handle_key(&mut self, _key: K) -> ShResult { + Ok(CompResponse::Passthrough) + } +} + +impl SimpleCompleter { pub fn new() -> Self { Self::default() } @@ -569,6 +921,12 @@ impl Completer { ctx.push(markers::ARG); } } + markers::RESET => { + if ctx.len() > 1 { + ctx.pop(); + last_priority = 0; + } + } _ => {} }, _ => { @@ -584,31 +942,6 @@ impl Completer { (ctx, ctx_start) } - pub fn reset(&mut self) { - self.candidates.clear(); - self.selected_idx = 0; - self.original_input.clear(); - self.token_span = (0, 0); - self.active = false; - } - - pub fn complete( - &mut self, - line: String, - cursor_pos: usize, - direction: i32, - ) -> ShResult> { - if self.active { - Ok(Some(self.cycle_completion(direction))) - } else { - self.start_completion(line, cursor_pos) - } - } - - pub fn selected_candidate(&self) -> Option { - self.candidates.get(self.selected_idx).cloned() - } - pub fn cycle_completion(&mut self, direction: i32) -> String { if self.candidates.is_empty() { return self.original_input.clone(); @@ -713,7 +1046,7 @@ impl Completer { let cword = if let Some(pos) = relevant .iter() - .position(|tk| cursor_pos >= tk.span.range().start && cursor_pos <= tk.span.range().end) + .position(|tk| cursor_pos >= tk.span.range().start && cursor_pos < tk.span.range().end) { pos } else { @@ -825,10 +1158,12 @@ impl Completer { self.token_span = (cur_token.span.range().start, cur_token.span.range().end); - self.token_span.0 = token_start; - cur_token - .span - .set_range(self.token_span.0..self.token_span.1); + if token_start >= self.token_span.0 && token_start <= self.token_span.1 { + self.token_span.0 = token_start; + cur_token + .span + .set_range(self.token_span.0..self.token_span.1); + } // If token contains '=', only complete after the '=' let token_str = cur_token.span.as_str(); diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index dfe67d5..316d7be 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -162,7 +162,7 @@ impl MotionKind { } } -#[derive(Default, Debug)] +#[derive(Default, Clone, Debug)] pub struct Edit { pub pos: usize, pub cursor_pos: usize, @@ -235,7 +235,7 @@ impl Edit { pub struct ClampedUsize { value: usize, max: usize, - exclusive: bool, + pub exclusive: bool, } impl ClampedUsize { @@ -317,7 +317,7 @@ impl ClampedUsize { } } -#[derive(Default, Debug)] +#[derive(Default, Clone, Debug)] pub struct LineBuf { pub buffer: String, pub hint: Option, @@ -2817,6 +2817,9 @@ impl LineBuf { for _ in 0..delta { if self.grapheme_at(line_start).is_some_and(|gr| gr == "\t") { self.remove(line_start); + if !self.cursor_at_max() { + self.cursor.sub(1); + } } } } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 6f555cd..63b077f 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -10,12 +10,13 @@ use crate::expand::expand_prompt; use crate::libsh::sys::TTY_FILENO; use crate::parse::lex::{LexStream, QuoteState}; use crate::prelude::*; +use crate::readline::complete::FuzzyCompleter; use crate::readline::term::{Pos, calc_str_width}; use crate::state::{ShellParam, read_shopts}; use crate::{ libsh::error::ShResult, parse::lex::{self, LexFlags, Tk, TkFlags, TkRule}, - readline::{complete::Completer, highlight::Highlighter}, + readline::{complete::{CompResponse, Completer}, highlight::Highlighter}, }; pub mod complete; @@ -206,7 +207,7 @@ pub struct ShedVi { pub prompt: Prompt, pub highlighter: Highlighter, - pub completer: Completer, + pub completer: Box, pub mode: Box, pub repeat_action: Option, @@ -225,7 +226,7 @@ impl ShedVi { reader: PollReader::new(), writer: TermWriter::new(tty), prompt, - completer: Completer::new(), + completer: Box::new(FuzzyCompleter::default()), highlighter: Highlighter::new(), mode: Box::new(ViInsert::new()), old_layout: None, @@ -319,6 +320,44 @@ impl ShedVi { // Process all available keys while let Some(key) = self.reader.read_key()? { + // If completer is active, delegate input to it + if self.completer.is_active() { + match self.completer.handle_key(key.clone())? { + CompResponse::Accept(candidate) => { + let span_start = self.completer.token_span().0; + let new_cursor = span_start + candidate.len(); + let line = self.completer.get_completed_line(&candidate); + self.editor.set_buffer(line); + self.editor.cursor.set(new_cursor); + // Don't reset yet — clear() needs old_layout to erase the selector. + + if !self.history.at_pending() { + self.history.reset_to_pending(); + } + self + .history + .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); + let hint = self.history.get_hint(); + self.editor.set_hint(hint); + self.completer.clear(&mut self.writer)?; + self.needs_redraw = true; + continue; + } + CompResponse::Dismiss => { + self.completer.clear(&mut self.writer)?; + // Don't reset yet — clear() needs old_layout to erase the selector. + // The next print_line() will call clear(), then we can reset. + continue; + } + CompResponse::Consumed => { + /* just redraw */ + self.needs_redraw = true; + continue; + } + CompResponse::Passthrough => { /* fall through to normal handling below */ } + } + } + if self.should_accept_hint(&key) { self.editor.accept_hint(); if !self.history.at_pending() { @@ -347,7 +386,7 @@ impl ShedVi { self.old_layout = None; } Ok(Some(line)) => { - let span_start = self.completer.token_span.0; + let span_start = self.completer.token_span().0; let new_cursor = span_start + self .completer @@ -556,6 +595,8 @@ impl ShedVi { .unwrap_or_default() as usize; let one_line = new_layout.end.row == 0; + self.completer.clear(&mut self.writer)?; + if let Some(layout) = self.old_layout.as_ref() { self.writer.clear_rows(layout)?; } @@ -610,6 +651,8 @@ impl ShedVi { self.writer.flush_write(&self.mode.cursor_style())?; + self.completer.draw(&mut self.writer)?; + self.old_layout = Some(new_layout); self.needs_redraw = false; Ok(()) diff --git a/src/readline/vimode.rs b/src/readline/vimode.rs index 23b8d43..7501246 100644 --- a/src/readline/vimode.rs +++ b/src/readline/vimode.rs @@ -66,7 +66,7 @@ pub trait ViMode { } } -#[derive(Default, Debug)] +#[derive(Default, Clone, Debug)] pub struct ViInsert { cmds: Vec, pending_cmd: ViCmd,