diff --git a/src/main.rs b/src/main.rs index 5e5516d..b217361 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,7 +278,8 @@ fn shed_interactive() -> ShResult<()> { let command_run_time = start.elapsed(); log::info!("Command executed in {:.2?}", command_run_time); write_meta(|m| m.stop_timer()); - readline.writer.flush_write("\n")?; + readline.fix_column()?; + readline.writer.flush_write("\n\r")?; // Reset for next command with fresh prompt readline.reset(true)?; diff --git a/src/readline/complete.rs b/src/readline/complete.rs index 056b1c3..2df25dd 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -8,16 +8,14 @@ use unicode_width::UnicodeWidthStr; use crate::{ builtin::complete::{CompFlags, CompOptFlags, CompOpts}, libsh::{ - error::ShResult, - guards::var_ctx_guard, - utils::TkVecUtils, + error::ShResult, guards::var_ctx_guard, sys::TTY_FILENO, utils::TkVecUtils }, parse::{ execute::exec_input, lex::{self, LexFlags, Tk, TkRule, ends_with_unescaped}, }, readline::{ - 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} + Marker, annotate_input_recursive, keys::{KeyCode as C, KeyEvent as K, ModKeys as M}, linebuf::{ClampedUsize, LineBuf}, markers::{self, is_marker}, term::{LineWriter, TermWriter, calc_str_width, get_win_size}, vimode::{ViInsert, ViMode} }, state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars}, }; @@ -529,11 +527,7 @@ pub trait Completer { 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..]) - } + fn get_completed_line(&self, candidate: &str) -> String; } #[derive(Default, Debug, Clone)] @@ -622,13 +616,41 @@ pub struct FuzzyLayout { #[derive(Default, Debug, Clone)] pub struct QueryEditor { mode: ViInsert, + scroll_offset: usize, + available_width: usize, linebuf: LineBuf } impl QueryEditor { pub fn clear(&mut self) { - self.linebuf = LineBuf::default(); + self.linebuf = LineBuf::new(); self.mode = ViInsert::default(); + self.scroll_offset = 0; + } + pub fn set_available_width(&mut self, width: usize) { + self.available_width = width; + } + pub fn update_scroll_offset(&mut self) { + self.linebuf.update_graphemes(); + let cursor_pos = self.linebuf.cursor.get(); + if cursor_pos < self.scroll_offset + 1 { + self.scroll_offset = self.linebuf.cursor.ret_sub(1); + } + if cursor_pos >= self.scroll_offset + self.available_width.saturating_sub(1) { + self.scroll_offset = self.linebuf.cursor.ret_sub(self.available_width.saturating_sub(1)); + } + let max_offset = self.linebuf.grapheme_indices().len().saturating_sub(self.available_width); + self.scroll_offset = self.scroll_offset.min(max_offset); + } + pub fn get_window(&mut self) -> String { + self.linebuf.update_graphemes(); + let buf_len = self.linebuf.grapheme_indices().len(); + if buf_len <= self.available_width { + return self.linebuf.as_str().to_string(); + } + let start = self.scroll_offset.min(buf_len.saturating_sub(self.available_width)); + let end = (start + self.available_width).min(buf_len); + self.linebuf.slice(start..end).unwrap_or("").to_string() } pub fn handle_key(&mut self, key: K) -> ShResult<()> { let Some(cmd) = self.mode.handle_key(key) else { @@ -653,6 +675,22 @@ pub struct FuzzyCompleter { } impl FuzzyCompleter { + const BOT_LEFT: &str = "\x1b[90m╰\x1b[0m"; + const BOT_RIGHT: &str = "\x1b[90m╯\x1b[0m"; + const TOP_LEFT: &str = "\x1b[90m╭\x1b[0m"; + const TOP_RIGHT: &str = "\x1b[90m╮\x1b[0m"; + const HOR_LINE: &str = "\x1b[90m─\x1b[0m"; + const VERT_LINE: &str = "\x1b[90m│\x1b[0m"; + const SELECTOR_GRAY: &str = "\x1b[90m▌\x1b[0m"; + const SELECTOR_HL: &str = "\x1b[38;2;200;0;120m▌\x1b[1;39;48;5;237m"; + 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"; + + fn get_window(&mut self) -> &[ScoredCandidate] { let height = self.filtered.len().min(self.max_height); @@ -707,6 +745,22 @@ impl Default for FuzzyCompleter { } impl Completer for FuzzyCompleter { + 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> { self.completer.complete(line, cursor_pos, direction)?; let candidates: Vec<_> = self.completer.candidates.clone(); @@ -714,12 +768,16 @@ impl Completer for FuzzyCompleter { 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(); - self.completer.reset(); - Ok(None) // FuzzyCompleter itself doesn't directly return a completed line, it manages the state of the filtered candidates and selection + Ok(None) } fn handle_key(&mut self, key: K) -> ShResult { @@ -730,13 +788,11 @@ impl Completer for FuzzyCompleter { Ok(CompResponse::Dismiss) } K(C::Enter, M::NONE) => { + self.active = false; 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) + Ok(CompResponse::Dismiss) } } K(C::Tab, M::SHIFT) | @@ -761,16 +817,17 @@ impl Completer for FuzzyCompleter { 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(); + // Cursor is on the prompt line. Move down to the bottom border. + let lines_below_prompt = layout.rows.saturating_sub(2); + if lines_below_prompt > 0 { + write!(buf, "\x1b[{}B", lines_below_prompt).unwrap(); } - // Erase each line and move up, back to the query line + // Erase each line moving up, back to the top border 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"); + // Erase the top border line + buf.push_str("\x1b[2K"); writer.flush_write(&buf)?; } Ok(()) @@ -779,31 +836,89 @@ impl Completer for FuzzyCompleter { if !self.active { return Ok(()); } + let (cols,_) = get_win_size(*TTY_FILENO); 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(); + self.query.set_available_width(cols.saturating_sub(6) as usize); + self.query.update_scroll_offset(); + 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 visible = self.get_window(); - buf.push_str("\n\r> "); - buf.push_str(&query); + let mut rows = 0; + let top_bar = format!("\n{}{}{}", + Self::TOP_LEFT, + Self::HOR_LINE.to_string().repeat(cols.saturating_sub(2) as usize), + Self::TOP_RIGHT + ); + buf.push_str(&top_bar); + rows += 1; + for _ in 0..rows { + } + + let prompt = format!("{} {} {}", Self::VERT_LINE, Self::PROMPT_ARROW, &query); + let cols_used = calc_str_width(&prompt); + let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize); + let prompt_line_final = format!("{}{}{}", prompt, right_pad, Self::VERT_LINE); + buf.push_str(&prompt_line_final); + rows += 1; + + let sep_line_left = format!("{}{}{}/{}", + Self::TREE_LEFT, + Self::HOR_LINE.repeat(2), + &num_filtered, + &num_candidates + ); + let cols_used = calc_str_width(&sep_line_left); + let right_pad = Self::HOR_LINE.repeat(cols.saturating_sub(cols_used + 1) as usize); + let sep_line_final = format!("{}{}{}", sep_line_left, right_pad, Self::TREE_RIGHT); + buf.push_str(&sep_line_final); + rows += 1; + 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"); + let selector = if i + offset == cursor_pos { + Self::SELECTOR_HL } else { - buf.push_str(&candidate.content); + 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 bot_bar = format!("{}{}{}", + Self::BOT_LEFT, + Self::HOR_LINE.to_string().repeat(cols.saturating_sub(2) as usize), + Self::BOT_RIGHT + ); + buf.push_str(&bot_bar); + rows += 1; + let new_layout = FuzzyLayout { - rows: visible.len() as u16, // +1 for the query line + rows, // +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(); + // Move cursor back up to the prompt line (skip: separator + candidates + bottom border) + let lines_below_prompt = new_layout.rows.saturating_sub(2); // total rows minus top_bar and prompt + let cursor_in_window = self.query.linebuf.cursor.get().saturating_sub(self.query.scroll_offset); + let cursor_col = cursor_in_window + 4; // "| > ".len() == 4 + write!(buf, "\x1b[{}A\r\x1b[{}C", lines_below_prompt, cursor_col).unwrap(); writer.flush_write(&buf)?; self.old_layout = Some(new_layout); @@ -838,6 +953,9 @@ pub struct SimpleCompleter { } impl Completer for SimpleCompleter { + fn get_completed_line(&self, _candidate: &str) -> String { + self.get_completed_line() + } fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult> { if self.active { Ok(Some(self.cycle_completion(direction))) @@ -1046,7 +1164,7 @@ impl SimpleCompleter { 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 { diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 316d7be..fef6ba5 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -338,7 +338,12 @@ pub struct LineBuf { impl LineBuf { pub fn new() -> Self { - Self::default() + let mut new = Self { + grapheme_indices: Some(vec![]), // We know the buffer is empty, so this keeps us safe from unwrapping None + ..Default::default() + }; + new.update_graphemes(); + new } /// Only update self.grapheme_indices if it is None pub fn update_graphemes_lazy(&mut self) { @@ -418,7 +423,17 @@ impl LineBuf { self.cursor.set_max(indices.len()); self.grapheme_indices = Some(indices) } + #[track_caller] pub fn grapheme_indices(&self) -> &[usize] { + if self.grapheme_indices.is_none() { + let caller = std::panic::Location::caller(); + panic!( + "grapheme_indices is None. This likely means you forgot to call update_graphemes() before calling a method that relies on grapheme_indices, or you called a method that relies on grapheme_indices from another method that also relies on grapheme_indices without updating graphemes in between. Caller: {}:{}:{}", + caller.file(), + caller.line(), + caller.column(), + ); + } self.grapheme_indices.as_ref().unwrap() } pub fn grapheme_indices_owned(&self) -> Vec { @@ -777,6 +792,19 @@ impl LineBuf { } Some(self.line_bounds(line_no)) } + pub fn this_word(&mut self, word: Word) -> (usize, usize) { + let start = if self.is_word_bound(self.cursor.get(), word, Direction::Backward) { + self.cursor.get() + } else { + self.start_of_word_backward(self.cursor.get(), word) + }; + let end = if self.is_word_bound(self.cursor.get(), word, Direction::Forward) { + self.cursor.get() + } else { + self.end_of_word_forward(self.cursor.get(), word) + }; + (start, end) + } pub fn this_line_exclusive(&mut self) -> (usize, usize) { let line_no = self.cursor_line_number(); let (start, mut end) = self.line_bounds(line_no); @@ -2983,6 +3011,55 @@ impl LineBuf { } } } + Verb::IncrementNumber(n) | + Verb::DecrementNumber(n) => { + let inc = if matches!(verb, Verb::IncrementNumber(_)) { n as i64 } else { -(n as i64) }; + let (s, e) = self.this_word(Word::Normal); + let end = (e + 1).min(self.grapheme_indices().len()); // inclusive → exclusive, capped at buffer len + let word = self.slice(s..end).unwrap_or_default().to_lowercase(); + + let byte_start = self.index_byte_pos(s); + let byte_end = if end >= self.grapheme_indices().len() { + self.buffer.len() + } else { + self.index_byte_pos(end) + }; + + if word.starts_with("0x") { + let body = word.strip_prefix("0x").unwrap(); + let width = body.len(); + if let Ok(num) = i64::from_str_radix(body, 16) { + let new_num = num + inc; + self.buffer.replace_range(byte_start..byte_end, &format!("0x{new_num:0>width$x}")); + self.update_graphemes(); + self.cursor.set(s); + } + } else if word.starts_with("0b") { + let body = word.strip_prefix("0b").unwrap(); + let width = body.len(); + if let Ok(num) = i64::from_str_radix(body, 2) { + let new_num = num + inc; + self.buffer.replace_range(byte_start..byte_end, &format!("0b{new_num:0>width$b}")); + self.update_graphemes(); + self.cursor.set(s); + } + } else if word.starts_with("0o") { + let body = word.strip_prefix("0o").unwrap(); + let width = body.len(); + if let Ok(num) = i64::from_str_radix(body, 8) { + let new_num = num + inc; + self.buffer.replace_range(byte_start..byte_end, &format!("0o{new_num:0>width$o}")); + self.update_graphemes(); + self.cursor.set(s); + } + } else if let Ok(num) = word.parse::() { + let width = word.len(); + let new_num = num + inc; + self.buffer.replace_range(byte_start..byte_end, &format!("{new_num:0>width$}")); + self.update_graphemes(); + self.cursor.set(s); + } + } Verb::Complete | Verb::EndOfFile diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 63b077f..407bdcc 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -11,7 +11,7 @@ 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::readline::term::{Pos, TermReader, calc_str_width}; use crate::state::{ShellParam, read_shopts}; use crate::{ libsh::error::ShResult, @@ -259,6 +259,10 @@ impl ShedVi { self.needs_redraw = true; } + pub fn fix_column(&mut self) -> ShResult<()> { + self.writer.fix_cursor_column(&mut TermReader::new(*TTY_FILENO)) + } + /// Reset readline state for a new prompt pub fn reset(&mut self, full_redraw: bool) -> ShResult<()> { // Clear old display before resetting state — old_layout must survive @@ -327,6 +331,8 @@ impl ShedVi { let span_start = self.completer.token_span().0; let new_cursor = span_start + candidate.len(); let line = self.completer.get_completed_line(&candidate); + log::debug!("Completer accepted candidate: {candidate}"); + log::debug!("New line after completion: {line}"); self.editor.set_buffer(line); self.editor.cursor.set(new_cursor); // Don't reset yet — clear() needs old_layout to erase the selector. @@ -341,12 +347,12 @@ impl ShedVi { self.editor.set_hint(hint); self.completer.clear(&mut self.writer)?; self.needs_redraw = true; + self.completer.reset(); 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. + self.completer.reset(); continue; } CompResponse::Consumed => { diff --git a/src/readline/term.rs b/src/readline/term.rs index eaea139..3a1b4df 100644 --- a/src/readline/term.rs +++ b/src/readline/term.rs @@ -828,6 +828,48 @@ impl TermWriter { self.t_cols = t_cols; } + /// Called before the prompt is drawn. If we are not on column 1, push a vid-inverted '%' and then a '\n\r'. + /// + /// Aping zsh with this but it's a nice feature. + pub fn fix_cursor_column(&mut self, rdr: &mut TermReader) -> ShResult<()> { + let Some((_,c)) = self.get_cursor_pos(rdr)? else { + return Ok(()); + }; + + if c != 1 { + self.flush_write("\x1b[7m%\x1b[0m\n\r")?; + } + Ok(()) + } + + pub fn get_cursor_pos(&mut self, rdr: &mut TermReader) -> ShResult> { + // Ping the cursor's position + self.flush_write("\x1b[6n")?; + + if !rdr.poll(PollTimeout::from(255u8))? { + return Ok(None); + } + + if rdr.next_byte()? as char != '\x1b' { + return Ok(None); + } + + if rdr.next_byte()? as char != '[' { + return Ok(None); + } + + let row = read_digits_until(rdr, ';')?; + + let col = read_digits_until(rdr, 'R')?; + let pos = if let Some(row) = row && let Some(col) = col { + Some((row as usize, col as usize)) + } else { + None + }; + + Ok(pos) + } + pub fn move_cursor_at_leftmost( &mut self, rdr: &mut TermReader, diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index 28b54c3..23688ac 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -218,6 +218,8 @@ pub enum Verb { ReplaceCharInplace(char, u16), // char to replace with, number of chars to replace ToggleCaseInplace(u16), // Number of chars to toggle ToggleCaseRange, + IncrementNumber(u16), + DecrementNumber(u16), ToLower, ToUpper, Complete, diff --git a/src/readline/vimode.rs b/src/readline/vimode.rs index 7501246..5d9e081 100644 --- a/src/readline/vimode.rs +++ b/src/readline/vimode.rs @@ -1025,6 +1025,29 @@ impl ViMode for ViNormal { raw_seq: "".into(), flags: self.flags(), }), + E(K::Char('A'), M::CTRL) => { + let count = self.parse_count(&mut self.pending_seq.chars().peekable()).unwrap_or(1) as u16; + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::IncrementNumber(count))), + motion: None, + raw_seq: "".into(), + flags: self.flags(), + }) + }, + E(K::Char('X'), M::CTRL) => { + let count = self.parse_count(&mut self.pending_seq.chars().peekable()).unwrap_or(1) as u16; + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::DecrementNumber(count))), + motion: None, + raw_seq: "".into(), + flags: self.flags(), + }) + }, + E(K::Char(ch), M::NONE) => self.try_parse(ch), E(K::Backspace, M::NONE) => Some(ViCmd { register: Default::default(),