implemented Ctrl+R command history searching and command history expansion with '\!'

This commit is contained in:
2026-03-05 00:16:07 -05:00
parent 0e3f2afe99
commit 9223e4848d
15 changed files with 676 additions and 216 deletions

View File

@@ -528,6 +528,12 @@ pub enum CompResponse {
Consumed, // key was handled, but completion remains active
}
pub enum SelectorResponse {
Accept(String),
Dismiss,
Consumed,
}
pub trait Completer {
fn complete(
&mut self,
@@ -702,23 +708,28 @@ impl QueryEditor {
}
#[derive(Clone, Debug)]
pub struct FuzzyCompleter {
completer: SimpleCompleter,
pub struct FuzzySelector {
query: QueryEditor,
filtered: Vec<ScoredCandidate>,
candidates: Vec<String>,
cursor: ClampedUsize,
number_candidates: bool,
old_layout: Option<FuzzyLayout>,
max_height: usize,
scroll_offset: usize,
active: bool,
/// Context from the prompt: width of the line above the fuzzy window
prompt_line_width: u16,
/// Context from the prompt: cursor column on the line above the fuzzy window
prompt_cursor_col: u16,
title: String,
}
impl FuzzyCompleter {
#[derive(Clone, Debug)]
pub struct FuzzyCompleter {
completer: SimpleCompleter,
pub selector: FuzzySelector,
}
impl FuzzySelector {
const BOT_LEFT: &str = "\x1b[90m╰\x1b[0m";
const BOT_RIGHT: &str = "\x1b[90m╯\x1b[0m";
const TOP_LEFT: &str = "\x1b[90m╭\x1b[0m";
@@ -730,29 +741,122 @@ impl FuzzyCompleter {
const PROMPT_ARROW: &str = "\x1b[1;36m>\x1b[0m";
const TREE_LEFT: &str = "\x1b[90m├\x1b[0m";
const TREE_RIGHT: &str = "\x1b[90m┤\x1b[0m";
//const TREE_BOT: &str = "\x1b[90m┴\x1b[0m";
//const TREE_TOP: &str = "\x1b[90m┬\x1b[0m";
//const CROSS: &str = "\x1b[90m┼\x1b[0m";
pub fn new(title: impl Into<String>) -> Self {
Self {
max_height: 8,
query: QueryEditor::default(),
filtered: vec![],
candidates: vec![],
cursor: ClampedUsize::new(0, 0, true),
number_candidates: false,
old_layout: None,
scroll_offset: 0,
active: false,
prompt_line_width: 0,
prompt_cursor_col: 0,
title: title.into(),
}
}
pub fn number_candidates(self, enable: bool) -> Self {
Self {
number_candidates: enable,
..self
}
}
pub fn activate(&mut self, candidates: Vec<String>) {
self.active = true;
self.candidates = candidates;
self.score_candidates();
}
pub fn set_query(&mut self, query: String) {
self.query.linebuf = LineBuf::new().with_initial(&query, query.len());
self.query.update_scroll_offset();
self.score_candidates();
}
pub fn reset(&mut self) {
self.query.clear();
self.filtered.clear();
self.candidates.clear();
self.cursor = ClampedUsize::new(0, 0, true);
self.old_layout = None;
self.scroll_offset = 0;
self.active = false;
}
pub fn reset_stay_active(&mut self) {
if self.active {
self.query.clear();
self.score_candidates();
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn selected_candidate(&self) -> Option<String> {
self
.filtered
.get(self.cursor.get())
.map(|c| c.content.clone())
}
pub fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) {
self.prompt_line_width = line_width;
self.prompt_cursor_col = cursor_col;
}
fn candidate_height(&self, idx: usize) -> usize {
self.filtered.get(idx)
.map(|c| c.content.trim_end().lines().count().max(1))
.unwrap_or(1)
}
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]
let mut lines = 0;
let mut end = self.scroll_offset;
while end < self.filtered.len() {
if lines >= self.max_height {
break;
}
lines += self.candidate_height(end);
end += 1;
}
&self.filtered[self.scroll_offset..end]
}
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);
let cursor = self.cursor.get();
// Scroll up: cursor above window
if cursor < self.scroll_offset {
self.scroll_offset = cursor;
return;
}
if self.cursor.get() >= self.scroll_offset + height.saturating_sub(1) {
self.scroll_offset = self.cursor.ret_sub(height.saturating_sub(2));
// Scroll down: ensure all candidates from scroll_offset through cursor
// fit within max_height rendered lines
loop {
let mut lines = 0;
let last = cursor.min(self.filtered.len().saturating_sub(1));
for idx in self.scroll_offset..=last {
lines += self.candidate_height(idx);
}
if lines <= self.max_height || self.scroll_offset >= cursor {
break;
}
self.scroll_offset += 1;
}
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
@@ -769,83 +873,13 @@ impl FuzzyCompleter {
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,
prompt_line_width: 0,
prompt_cursor_col: 0,
}
}
}
impl Completer for FuzzyCompleter {
fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) {
self.prompt_line_width = line_width;
self.prompt_cursor_col = cursor_col;
}
fn reset_stay_active(&mut self) {
if self.is_active() {
self.query.clear();
self.score_candidates();
}
}
fn get_completed_line(&self, _candidate: &str) -> String {
log::debug!("Getting completed line for candidate: {}", _candidate);
let selected = &self.filtered[self.cursor.get()].content;
log::debug!("Selected candidate: {}", selected);
let (start, end) = self.completer.token_span;
log::debug!("Token span: ({}, {})", start, end);
let ret = format!(
"{}{}{}",
&self.completer.original_input[..start],
selected,
&self.completer.original_input[end..]
);
log::debug!("Completed line: {}", ret);
ret
}
fn complete(
&mut self,
line: String,
cursor_pos: usize,
direction: i32,
) -> ShResult<Option<String>> {
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);
} else if candidates.len() == 1 {
self.filtered = candidates.into_iter().map(ScoredCandidate::from).collect();
let completed = self.get_completed_line(&self.filtered[0].content);
self.active = false;
return Ok(Some(completed));
}
self.active = true;
self.candidates = candidates;
self.score_candidates();
Ok(None)
}
fn handle_key(&mut self, key: K) -> ShResult<CompResponse> {
pub fn handle_key(&mut self, key: K) -> ShResult<SelectorResponse> {
match key {
K(C::Char('D'), M::CTRL) | K(C::Esc, M::NONE) => {
self.active = false;
self.filtered.clear();
Ok(CompResponse::Dismiss)
Ok(SelectorResponse::Dismiss)
}
K(C::Enter, M::NONE) => {
self.active = false;
@@ -854,76 +888,30 @@ impl Completer for FuzzyCompleter {
.get(self.cursor.get())
.map(|c| c.content.clone())
{
Ok(CompResponse::Accept(selected))
Ok(SelectorResponse::Accept(selected))
} else {
Ok(CompResponse::Dismiss)
Ok(SelectorResponse::Dismiss)
}
}
K(C::Tab, M::SHIFT) | K(C::Up, M::NONE) => {
self.cursor.wrap_sub(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
Ok(SelectorResponse::Consumed)
}
K(C::Tab, M::NONE) | K(C::Down, M::NONE) => {
self.cursor.wrap_add(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
Ok(SelectorResponse::Consumed)
}
_ => {
self.query.handle_key(key)?;
self.score_candidates();
Ok(CompResponse::Consumed)
Ok(SelectorResponse::Consumed)
}
}
}
fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if let Some(layout) = self.old_layout.take() {
let (new_cols, _) = get_win_size(*TTY_FILENO);
// The fuzzy window is one continuous auto-wrapped block (no hard
// newlines between rows). After a resize the terminal re-joins
// soft wraps and re-wraps as a flat buffer.
let total_cells = layout.rows as u32 * layout.cols as u32;
let physical_rows = if new_cols > 0 {
total_cells.div_ceil(new_cols as u32) as u16
} else {
layout.rows
};
let cursor_offset = layout.cols as u32 + layout.cursor_col as u32;
let cursor_phys_row = if new_cols > 0 {
(cursor_offset / new_cols as u32) as u16
} else {
1
};
let lines_below = physical_rows.saturating_sub(cursor_phys_row + 1);
// The prompt line above the \n may have wrapped (e.g. due to PSR
// filling to t_cols). Compute how many extra rows that adds
// between the prompt cursor and the fuzzy content.
let gap_extra = if new_cols > 0 && layout.preceding_line_width > new_cols {
let wrap_rows = (layout.preceding_line_width as u32).div_ceil(new_cols as u32) as u16;
let cursor_wrap_row = layout.preceding_cursor_col / new_cols;
wrap_rows.saturating_sub(cursor_wrap_row + 1)
} else {
0
};
let mut buf = String::new();
if lines_below > 0 {
write!(buf, "\x1b[{}B", lines_below).unwrap();
}
for _ in 0..physical_rows {
buf.push_str("\x1b[2K\x1b[A");
}
buf.push_str("\x1b[2K");
// Clear extra rows from prompt line wrapping (PSR)
for _ in 0..gap_extra {
buf.push_str("\x1b[A\x1b[2K");
}
writer.flush_write(&buf)?;
}
Ok(())
}
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> {
pub fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if !self.active {
return Ok(());
}
@@ -939,13 +927,19 @@ impl Completer for FuzzyCompleter {
let query = self.query.get_window();
let num_filtered = format!("\x1b[33m{}\x1b[0m", self.filtered.len());
let num_candidates = format!("\x1b[33m{}\x1b[0m", self.candidates.len());
let title = self.title.clone();
let title_width = title.len() as u16;
let number_candidates = self.number_candidates;
let min_pad = self.candidates.len().to_string().len().saturating_add(1).max(6);
let max_height = self.max_height;
let visible = self.get_window();
let mut rows: u16 = 0;
let top_bar = format!(
"\n{}{} \x1b[1mComplete\x1b[0m {}{}",
"\n{}{} \x1b[1m{}\x1b[0m {}{}",
Self::TOP_LEFT,
Self::HOR_LINE,
Self::HOR_LINE.repeat(cols.saturating_sub(13) as usize),
title,
Self::HOR_LINE.repeat(cols.saturating_sub(title_width + 5) as usize),
Self::TOP_RIGHT
);
buf.push_str(&top_bar);
@@ -972,24 +966,51 @@ impl Completer for FuzzyCompleter {
buf.push_str(&sep_line_final);
rows += 1;
let mut lines_drawn = 0;
for (i, candidate) in visible.iter().enumerate() {
if lines_drawn >= max_height {
break;
}
let selector = if i + offset == cursor_pos {
Self::SELECTOR_HL
} else {
Self::SELECTOR_GRAY
};
let mut content = candidate.content.clone();
let col_lim = cols.saturating_sub(3);
if calc_str_width(&content) > col_lim {
content.truncate(col_lim.saturating_sub(6) as usize); // ui bars + elipses length
content.push_str("...");
}
let left = format!("{} {}{}\x1b[0m", Self::VERT_LINE, &selector, &content);
let cols_used = calc_str_width(&left);
let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize);
let hl_cand_line = format!("{}{}{}", left, right_pad, Self::VERT_LINE);
buf.push_str(&hl_cand_line);
rows += 1;
let mut drew_number = false;
for line in candidate.content.trim_end().lines() {
if lines_drawn >= max_height {
break;
}
let mut line = line.trim_end().replace('\t', " ");
let col_lim = if number_candidates{
cols.saturating_sub(3 + min_pad as u16)
} else {
cols.saturating_sub(3)
};
if calc_str_width(&line) > col_lim {
line.truncate(col_lim.saturating_sub(6) as usize);
line.push_str("...");
}
let left = if number_candidates {
if !drew_number {
let this_num = i + offset + 1;
let right_pad = " ".repeat(min_pad.saturating_sub(this_num.to_string().len()));
format!("{} {}\x1b[33m{}\x1b[39m{right_pad}{}\x1b[0m", Self::VERT_LINE, &selector,i + offset + 1, &line)
} else {
let right_pad = " ".repeat(min_pad);
format!("{} {}{}{}\x1b[0m", Self::VERT_LINE, &selector,right_pad, &line)
}
} else {
format!("{} {}{}\x1b[0m", Self::VERT_LINE, &selector, &line)
};
let cols_used = calc_str_width(&left);
let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize);
let hl_cand_line = format!("{}{}{}", left, right_pad, Self::VERT_LINE);
buf.push_str(&hl_cand_line);
rows += 1;
drew_number = true;
lines_drawn += 1;
}
}
let bot_bar = format!(
@@ -1003,15 +1024,14 @@ impl Completer for FuzzyCompleter {
buf.push_str(&bot_bar);
rows += 1;
// Move cursor back up to the prompt line (skip: separator + candidates + bottom border)
let lines_below_prompt = rows.saturating_sub(2); // total rows minus top_bar and prompt
let lines_below_prompt = rows.saturating_sub(2);
let cursor_in_window = self
.query
.linebuf
.cursor
.get()
.saturating_sub(self.query.scroll_offset);
let cursor_col = (cursor_in_window + 4) as u16; // "| > ".len() == 4
let cursor_col = (cursor_in_window + 4) as u16;
write!(buf, "\x1b[{}A\r\x1b[{}C", lines_below_prompt, cursor_col).unwrap();
let new_layout = FuzzyLayout {
@@ -1026,20 +1046,129 @@ impl Completer for FuzzyCompleter {
Ok(())
}
pub fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if let Some(layout) = self.old_layout.take() {
let (new_cols, _) = get_win_size(*TTY_FILENO);
let total_cells = layout.rows as u32 * layout.cols as u32;
let physical_rows = if new_cols > 0 {
total_cells.div_ceil(new_cols as u32) as u16
} else {
layout.rows
};
let cursor_offset = layout.cols as u32 + layout.cursor_col as u32;
let cursor_phys_row = if new_cols > 0 {
(cursor_offset / new_cols as u32) as u16
} else {
1
};
let lines_below = physical_rows.saturating_sub(cursor_phys_row + 1);
let gap_extra = if new_cols > 0 && layout.preceding_line_width > new_cols {
let wrap_rows = (layout.preceding_line_width as u32).div_ceil(new_cols as u32) as u16;
let cursor_wrap_row = layout.preceding_cursor_col / new_cols;
wrap_rows.saturating_sub(cursor_wrap_row + 1)
} else {
0
};
let mut buf = String::new();
if lines_below > 0 {
write!(buf, "\x1b[{}B", lines_below).unwrap();
}
for _ in 0..physical_rows {
buf.push_str("\x1b[2K\x1b[A");
}
buf.push_str("\x1b[2K");
for _ in 0..gap_extra {
buf.push_str("\x1b[A\x1b[2K");
}
writer.flush_write(&buf)?;
}
Ok(())
}
}
impl Default for FuzzyCompleter {
fn default() -> Self {
Self {
completer: SimpleCompleter::default(),
selector: FuzzySelector::new("Complete"),
}
}
}
impl Completer for FuzzyCompleter {
fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) {
self.selector.set_prompt_line_context(line_width, cursor_col);
}
fn reset_stay_active(&mut self) {
self.selector.reset_stay_active();
}
fn get_completed_line(&self, _candidate: &str) -> String {
log::debug!("Getting completed line for candidate: {}", _candidate);
let selected = self.selector.selected_candidate().unwrap_or_default();
log::debug!("Selected candidate: {}", selected);
let (start, end) = self.completer.token_span;
log::debug!("Token span: ({}, {})", start, end);
let ret = format!(
"{}{}{}",
&self.completer.original_input[..start],
selected,
&self.completer.original_input[end..]
);
log::debug!("Completed line: {}", ret);
ret
}
fn complete(
&mut self,
line: String,
cursor_pos: usize,
direction: i32,
) -> ShResult<Option<String>> {
self.completer.complete(line, cursor_pos, direction)?;
let candidates: Vec<_> = self.completer.candidates.clone();
if candidates.is_empty() {
self.completer.reset();
self.selector.active = false;
return Ok(None);
} else if candidates.len() == 1 {
self.selector.filtered = candidates.into_iter().map(ScoredCandidate::from).collect();
let selected = self.selector.filtered[0].content.clone();
let completed = self.get_completed_line(&selected);
self.selector.active = false;
return Ok(Some(completed));
}
self.selector.activate(candidates);
Ok(None)
}
fn handle_key(&mut self, key: K) -> ShResult<CompResponse> {
match self.selector.handle_key(key)? {
SelectorResponse::Accept(s) => Ok(CompResponse::Accept(s)),
SelectorResponse::Dismiss => Ok(CompResponse::Dismiss),
SelectorResponse::Consumed => Ok(CompResponse::Consumed),
}
}
fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> {
self.selector.clear(writer)
}
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> {
self.selector.draw(writer)
}
fn reset(&mut self) {
*self = Self::default();
self.completer.reset();
self.selector.reset();
}
fn token_span(&self) -> (usize, usize) {
self.completer.token_span()
}
fn is_active(&self) -> bool {
self.active
self.selector.is_active()
}
fn selected_candidate(&self) -> Option<String> {
self
.filtered
.get(self.cursor.get())
.map(|c| c.content.clone())
self.selector.selected_candidate()
}
fn original_input(&self) -> &str {
&self.completer.original_input

View File

@@ -105,14 +105,17 @@ impl Highlighter {
self.in_selection = false;
}
_ if self.only_hl_visual => {
self.output.push(ch);
if !is_marker(ch) {
self.output.push(ch);
}
}
markers::STRING_DQ_END
| markers::STRING_SQ_END
| markers::VAR_SUB_END
| markers::CMD_SUB_END
| markers::PROC_SUB_END
| markers::SUBSH_END => self.pop_style(),
| markers::SUBSH_END
| markers::HIST_EXP_END => self.pop_style(),
markers::CMD_SEP | markers::RESET => self.clear_styles(),
@@ -276,6 +279,23 @@ impl Highlighter {
}
self.last_was_reset = false;
}
markers::HIST_EXP => {
let mut hist_exp = String::new();
while let Some(ch) = input_chars.peek() {
if *ch == markers::HIST_EXP_END {
input_chars.next();
break;
} else if markers::is_marker(*ch) {
input_chars.next();
continue;
}
hist_exp.push(*ch);
input_chars.next();
}
self.push_style(Style::Blue);
self.output.push_str(&hist_exp);
self.pop_style();
}
markers::VAR_SUB => {
let mut var_sub = String::new();
while let Some(ch) = input_chars.peek() {

View File

@@ -1,17 +1,10 @@
use std::{
collections::HashSet,
env,
fmt::{Display, Write},
fs::{self, OpenOptions},
io::Write as IoWrite,
path::{Path, PathBuf},
str::FromStr,
time::{Duration, SystemTime, UNIX_EPOCH},
cmp::Ordering, collections::HashSet, env, fmt::{Display, Write}, fs::{self, OpenOptions}, io::Write as IoWrite, path::{Path, PathBuf}, str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}
};
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
readline::linebuf::LineBuf,
readline::{complete::FuzzySelector, linebuf::LineBuf},
};
#[derive(Default, Clone, Copy, Debug)]
@@ -207,6 +200,7 @@ pub struct History {
pub pending: Option<LineBuf>, // command, cursor_pos
entries: Vec<HistEntry>,
search_mask: Vec<HistEntry>,
pub fuzzy_finder: FuzzySelector,
no_matches: bool,
pub cursor: usize,
//search_direction: Direction,
@@ -232,6 +226,7 @@ impl History {
Ok(Self {
path,
entries,
fuzzy_finder: FuzzySelector::new("History").number_candidates(true),
pending: None,
search_mask,
no_matches: false,
@@ -242,6 +237,20 @@ impl History {
})
}
pub fn start_search(&mut self, initial: &str) -> Option<String> {
if self.search_mask.is_empty() {
None
} else if self.search_mask.len() == 1 {
Some(self.search_mask[0].command().to_string())
} else {
self.fuzzy_finder.set_query(initial.to_string());
let raw_entries = self.search_mask.clone().into_iter()
.map(|ent| ent.command().to_string());
self.fuzzy_finder.activate(raw_entries.collect());
None
}
}
pub fn reset(&mut self) {
self.search_mask = dedupe_entries(&self.entries);
self.cursor = self.search_mask.len();
@@ -291,6 +300,36 @@ impl History {
pub fn last_mut(&mut self) -> Option<&mut HistEntry> {
self.entries.last_mut()
}
pub fn last(&self) -> Option<&HistEntry> {
self.entries.last()
}
pub fn resolve_hist_token(&self, token: &str) -> Option<String> {
let token = token.strip_prefix('!').unwrap_or(token).to_string();
if let Ok(num) = token.parse::<i32>() && num != 0 {
match num.cmp(&0) {
Ordering::Less => {
if num.unsigned_abs() > self.entries.len() as u32 {
return None;
}
let rev_idx = self.entries.len() - num.unsigned_abs() as usize;
self.entries.get(rev_idx)
.map(|e| e.command().to_string())
}
Ordering::Greater => {
self.entries.get(num as usize)
.map(|e| e.command().to_string())
}
_ => unreachable!()
}
} else {
let mut rev_search = self.entries.iter();
rev_search
.rfind(|e| e.command().starts_with(&token))
.map(|e| e.command().to_string())
}
}
pub fn get_new_id(&self) -> u32 {
let Some(ent) = self.entries.last() else {

View File

@@ -114,6 +114,10 @@ impl KeyEvent {
"Cannot convert unknown escape sequence to Vim key sequence".to_string(),
));
}
KeyCode::ExMode => {
seq.push_str("CMD");
needs_angle_bracket = true;
}
KeyCode::Backspace => {
seq.push_str("BS");
needs_angle_bracket = true;
@@ -222,6 +226,9 @@ pub enum KeyCode {
Right,
Tab,
Up,
// weird stuff
ExMode, // keycode emitted by the <cmd> byte alias in vim keymaps
}
bitflags::bitflags! {

View File

@@ -15,13 +15,11 @@ use crate::{
libsh::{error::ShResult, guards::var_ctx_guard},
parse::{
execute::exec_input,
lex::{LexFlags, LexStream, Tk, TkFlags, TkRule},
lex::{LexFlags, LexStream, QuoteState, Tk, TkFlags, TkRule},
},
prelude::*,
readline::{
markers,
register::{RegisterContent, write_register},
term::RawModeGuard,
history::History, markers, register::{RegisterContent, write_register}, term::RawModeGuard
},
state::{VarFlags, VarKind, read_shopts, write_meta, write_vars},
};
@@ -299,6 +297,13 @@ impl ClampedUsize {
let max = self.upper_bound();
self.value = (self.value + value).clamp(0, max)
}
pub fn add_signed(&mut self, value: isize) {
if value.is_negative() {
self.sub(value.wrapping_abs() as usize);
} else {
self.add(value as usize);
}
}
pub fn sub(&mut self, value: usize) {
self.value = self.value.saturating_sub(value)
}
@@ -645,6 +650,10 @@ impl LineBuf {
self.buffer.push_str(slice);
self.update_graphemes();
}
pub fn insert_str_at_cursor(&mut self, slice: &str) {
let pos = self.index_byte_pos(self.cursor.get());
self.insert_str_at(pos, slice);
}
pub fn insert_at_cursor(&mut self, ch: char) {
self.insert_at(self.cursor.get(), ch);
}
@@ -2893,7 +2902,7 @@ impl LineBuf {
}
}
Verb::Insert(string) => {
self.push_str(&string);
self.insert_str_at_cursor(&string);
let graphemes = string.graphemes(true).count();
log::debug!("Inserted string: {string:?}, graphemes: {graphemes}");
log::debug!("buffer after insert: {:?}", self.buffer);
@@ -3317,6 +3326,73 @@ impl LineBuf {
Ok(())
}
pub fn attempt_history_expansion(&mut self, hist: &History) -> bool {
self.update_graphemes();
let mut changes: Vec<(Range<usize>,String)> = vec![];
let mut graphemes = self.buffer.grapheme_indices(true);
let mut qt_state = QuoteState::default();
while let Some((i,gr)) = graphemes.next() {
match gr {
"\\" => {
graphemes.next();
}
"'" => qt_state.toggle_single(),
"\"" => qt_state.toggle_double(),
"!" if !qt_state.in_single() => {
let start = i;
match graphemes.next() {
Some((_,"!")) => {
// we have "!!", which expands to the previous command
if let Some(prev) = hist.last() {
let raw = prev.command();
changes.push((start..start+2, raw.to_string()));
}
}
Some((_,"$")) => {
// we have "!$", which expands to the last word of the previous command
if let Some(prev) = hist.last() {
let raw = prev.command();
if let Some(last_word) = raw.split_whitespace().last() {
changes.push((start..start+2, last_word.to_string()));
}
}
}
Some((j,gr)) if !is_whitespace(gr) => {
let mut end = j + gr.len();
while let Some((k, gr2)) = graphemes.next() {
if is_whitespace(gr2) { break; }
end = k + gr2.len();
}
let token = &self.buffer[j..end];
let cmd = hist.resolve_hist_token(token).unwrap_or(token.into());
changes.push((start..end, cmd));
}
_ => { /* not a hist expansion */ }
}
}
_ => { /* carry on */ }
}
}
let ret = !changes.is_empty();
let buf_len = self.grapheme_indices().len();
for (range,change) in changes.into_iter().rev() {
self.buffer.replace_range(range, &change);
}
self.update_graphemes();
let new_len = self.grapheme_indices().len();
let delta = new_len as isize - buf_len as isize;
self.cursor.set_max(new_len);
self.cursor.add_signed(delta);
ret
}
pub fn as_str(&self) -> &str {
&self.buffer // FIXME: this will have to be fixed up later
}

View File

@@ -12,11 +12,11 @@ use crate::expand::expand_prompt;
use crate::libsh::sys::TTY_FILENO;
use crate::libsh::utils::AutoCmdVecUtils;
use crate::parse::lex::{LexStream, QuoteState};
use crate::readline::complete::FuzzyCompleter;
use crate::readline::complete::{FuzzyCompleter, SelectorResponse};
use crate::readline::term::{Pos, TermReader, calc_str_width};
use crate::readline::vimode::{ViEx, ViVerbatim};
use crate::state::{
AutoCmdKind, ShellParam, VarFlags, VarKind, read_logic, read_shopts, write_meta, write_vars,
AutoCmdKind, ShellParam, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, write_vars
};
use crate::{
libsh::error::ShResult,
@@ -76,6 +76,8 @@ pub mod markers {
pub const STRING_SQ_END: Marker = '\u{e115}';
pub const ESCAPE: Marker = '\u{e116}';
pub const GLOB: Marker = '\u{e117}';
pub const HIST_EXP: Marker = '\u{e11c}';
pub const HIST_EXP_END: Marker = '\u{e11d}';
// other
pub const VISUAL_MODE_START: Marker = '\u{e118}';
@@ -409,11 +411,51 @@ 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() {
// If completer or history search are active, delegate input to it
if self.history.fuzzy_finder.is_active() {
self.print_line(false)?;
match self.history.fuzzy_finder.handle_key(key)? {
SelectorResponse::Accept(cmd) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
self.editor.set_buffer(cmd.to_string());
self.editor.move_cursor_to_end();
self.history.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
self.editor.set_hint(None);
self.history.fuzzy_finder.clear(&mut self.writer)?;
self.history.fuzzy_finder.reset();
with_vars([("_HIST_ENTRY".into(), cmd.clone())], || {
post_cmds.exec_with(&cmd);
});
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok();
self.prompt.refresh();
self.needs_redraw = true;
continue;
}
SelectorResponse::Dismiss => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryClose));
post_cmds.exec();
self.editor.set_hint(None);
self.history.fuzzy_finder.clear(&mut self.writer)?;
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok();
self.prompt.refresh();
self.needs_redraw = true;
continue;
}
SelectorResponse::Consumed => {
self.needs_redraw = true;
continue;
}
}
} else if self.completer.is_active() {
self.print_line(false)?;
match self.completer.handle_key(key.clone())? {
CompResponse::Accept(candidate) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect));
let span_start = self.completer.token_span().0;
let new_cursor = span_start + candidate.len();
let line = self.completer.get_completed_line(&candidate);
@@ -432,12 +474,22 @@ impl ShedVi {
self.completer.clear(&mut self.writer)?;
self.needs_redraw = true;
self.completer.reset();
with_vars([("_COMP_CANDIDATE".into(), candidate.clone())], || {
post_cmds.exec_with(&candidate);
});
continue;
}
CompResponse::Dismiss => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionCancel));
post_cmds.exec();
let hint = self.history.get_hint();
self.editor.set_hint(hint);
self.completer.clear(&mut self.writer)?;
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok();
self.prompt.refresh();
self.completer.reset();
continue;
}
@@ -486,6 +538,9 @@ impl ShedVi {
return Ok(event);
}
}
if !self.completer.is_active() && !self.history.fuzzy_finder.is_active() {
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok();
}
// Redraw if we processed any input
if self.needs_redraw {
@@ -498,6 +553,7 @@ impl ShedVi {
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> {
if self.should_accept_hint(&key) {
log::debug!("Accepting hint on key {key:?} in mode {:?}", self.mode.report_mode());
self.editor.accept_hint();
if !self.history.at_pending() {
self.history.reset_to_pending();
@@ -510,6 +566,12 @@ impl ShedVi {
}
if let KeyEvent(KeyCode::Tab, mod_keys) = key {
if self.editor.attempt_history_expansion(&self.history) {
// If history expansion occurred, don't attempt completion yet
// allow the user to see the expanded command and accept or edit it before completing
return Ok(None);
}
let direction = match mod_keys {
ModKeys::SHIFT => -1,
_ => 1,
@@ -524,7 +586,14 @@ impl ShedVi {
self.old_layout = None;
}
Ok(Some(line)) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect));
let cand = self.completer.selected_candidate().unwrap_or_default();
with_vars([("_COMP_CANDIDATE".into(), cand.clone())], || {
post_cmds.exec_with(&cand);
});
let span_start = self.completer.token_span().0;
let new_cursor = span_start
+ self
.completer
@@ -532,7 +601,7 @@ impl ShedVi {
.map(|c| c.len())
.unwrap_or_default();
self.editor.set_buffer(line);
self.editor.set_buffer(line.clone());
self.editor.cursor.set(new_cursor);
if !self.history.at_pending() {
@@ -543,10 +612,19 @@ impl ShedVi {
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
let hint = self.history.get_hint();
self.editor.set_hint(hint);
}
Ok(None) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart));
post_cmds.exec();
self.writer.send_bell().ok();
if self.completer.is_active() {
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str("COMPLETE".to_string()), VarFlags::NONE)).ok();
self.prompt.refresh();
self.needs_redraw = true;
self.editor.set_hint(None);
}
}
@@ -554,7 +632,34 @@ impl ShedVi {
self.needs_redraw = true;
return Ok(None);
}
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key {
let initial = self.editor.as_str();
match self.history.start_search(initial) {
Some(entry) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
with_vars([("_HIST_ENTRY".into(), entry.clone())], || {
post_cmds.exec_with(&entry);
});
self.editor.set_buffer(entry);
self.editor.move_cursor_to_end();
self.history.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
self.editor.set_hint(None);
}
None => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen));
post_cmds.exec();
self.writer.send_bell().ok();
if self.history.fuzzy_finder.is_active() {
write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str("SEARCH".to_string()), VarFlags::NONE)).ok();
self.prompt.refresh();
self.needs_redraw = true;
self.editor.set_hint(None);
}
}
}
}
if let KeyEvent(KeyCode::Char('\\'), ModKeys::NONE) = key
&& !self.next_is_escaped
@@ -586,6 +691,12 @@ impl ShedVi {
&& !self.editor.buffer.ends_with('\\')
&& (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete))
{
if self.editor.attempt_history_expansion(&self.history) {
// If history expansion occurred, don't submit yet
// allow the user to see the expanded command and accept or edit it before submitting
return Ok(None);
}
self.editor.set_hint(None);
self.editor.cursor.set(self.editor.cursor_max());
self.print_line(true)?;
@@ -747,6 +858,7 @@ impl ShedVi {
let one_line = new_layout.end.row == 0;
self.completer.clear(&mut self.writer)?;
self.history.fuzzy_finder.clear(&mut self.writer)?;
if let Some(layout) = self.old_layout.as_ref() {
self.writer.clear_rows(layout)?;
@@ -837,6 +949,9 @@ impl ShedVi {
.set_prompt_line_context(preceding_width, new_layout.cursor.col);
self.completer.draw(&mut self.writer)?;
self.history.fuzzy_finder.set_prompt_line_context(preceding_width, new_layout.cursor.col);
self.history.fuzzy_finder.draw(&mut self.writer)?;
self.old_layout = Some(new_layout);
self.needs_redraw = false;
@@ -1529,6 +1644,35 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
insertions.push((span_start + index, markers::GLOB));
}
}
'!' if !qt_state.in_single() && cmd_sub_depth == 0 && proc_sub_depth == 0 => {
let bang_pos = index;
token_chars.next(); // consume the '!'
if let Some((_, next_ch)) = token_chars.peek() {
match next_ch {
'!' | '$' => {
// !! or !$
token_chars.next();
insertions.push((span_start + bang_pos, markers::HIST_EXP));
insertions.push((span_start + bang_pos + 2, markers::HIST_EXP_END));
}
c if c.is_ascii_alphanumeric() || *c == '-' => {
// !word, !-N, !N
let mut end_pos = bang_pos + 1;
while let Some((cur_i, wch)) = token_chars.peek() {
if wch.is_ascii_alphanumeric() || *wch == '_' || *wch == '-' {
end_pos = *cur_i + 1;
token_chars.next();
} else {
break;
}
}
insertions.push((span_start + bang_pos, markers::HIST_EXP));
insertions.push((span_start + end_pos, markers::HIST_EXP_END));
}
_ => { /* lone ! before non-expansion char, ignore */ }
}
}
}
_ => {
token_chars.next(); // consume the char with no special handling
}

View File

@@ -69,7 +69,7 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) {
}
}
fn enumerate_lines(s: &str, left_pad: usize) -> String {
fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String {
let total_lines = s.lines().count();
let max_num_len = total_lines.to_string().len();
s.lines()
@@ -84,23 +84,15 @@ fn enumerate_lines(s: &str, left_pad: usize) -> String {
// " 2 | " — num + padding + " | "
let prefix_len = max_num_len + 3; // "N | "
let trail_pad = left_pad.saturating_sub(prefix_len);
if i == total_lines - 1 {
// Don't add a newline to the last line
write!(
acc,
"\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}",
" ".repeat(num_pad),
" ".repeat(trail_pad),
)
.unwrap();
let prefix = if show_numbers {
format!("\x1b[0m\x1b[90m{}{num} |\x1b[0m ", " ".repeat(num_pad))
} else {
writeln!(
acc,
"\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}",
" ".repeat(num_pad),
" ".repeat(trail_pad),
)
.unwrap();
" ".repeat(prefix_len + 1).to_string()
};
if i == total_lines - 1 {
write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
} else {
writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
}
}
acc
@@ -1013,7 +1005,8 @@ impl LineWriter for TermWriter {
let multiline = line.contains('\n');
if multiline {
let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0, false);
let display_line = enumerate_lines(line, prompt_end.col as usize);
let show_numbers = read_shopts(|o| o.prompt.line_numbers);
let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers);
self.buffer.push_str(&display_line);
} else {
self.buffer.push_str(line);

View File

@@ -54,6 +54,15 @@ impl ViMode for ViInsert {
.set_motion(MotionCmd(1, Motion::ForwardChar));
self.register_and_return()
}
E(K::ExMode, _) => {
Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::ExMode)),
motion: None,
raw_seq: String::new(),
flags: Default::default(),
})
}
E(K::Char('W'), M::CTRL) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
self.pending_cmd.set_motion(MotionCmd(

View File

@@ -756,6 +756,15 @@ impl ViMode for ViNormal {
raw_seq: "".into(),
flags: self.flags(),
}),
E(K::ExMode, _) => {
return Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::ExMode)),
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
});
}
E(K::Char('A'), M::CTRL) => {
let count = self
.parse_count(&mut self.pending_seq.chars().peekable())

View File

@@ -41,6 +41,15 @@ impl ViMode for ViReplace {
.set_motion(MotionCmd(1, Motion::ForwardChar));
self.register_and_return()
}
E(K::ExMode, _) => {
Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::ExMode)),
motion: None,
raw_seq: String::new(),
flags: Default::default(),
})
}
E(K::Char('W'), M::CTRL) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
self.pending_cmd.set_motion(MotionCmd(

View File

@@ -614,6 +614,15 @@ impl ViMode for ViVisual {
raw_seq: "".into(),
flags: CmdFlags::empty(),
}),
E(K::ExMode, _) => {
return Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::ExMode)),
motion: None,
raw_seq: String::new(),
flags: Default::default(),
});
}
E(K::Char('A'), M::CTRL) => {
let count = self
.parse_count(&mut self.pending_seq.chars().peekable())