use std::{ collections::HashSet, fmt::Display, ops::{Index, IndexMut}, slice::SliceIndex }; use smallvec::SmallVec; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthChar; use super::vicmd::{ Anchor, Bound, Dest, Direction, Motion, MotionCmd, TextObj, To, Verb, ViCmd, Word, }; use crate::{ expand::expand_cmd_sub, libsh::{error::ShResult, guards::{RawModeGuard, var_ctx_guard}}, parse::{ Redir, RedirType, execute::exec_input, lex::{LexFlags, LexStream, Tk, TkFlags}, }, prelude::*, procio::{IoFrame, IoMode, IoStack}, readline::{ highlight::Highlighter, markers, register::RegisterContent, term::get_win_size, vicmd::{ReadSrc, VerbCmd, WriteDest} }, state::{self, VarFlags, VarKind, read_shopts, read_vars, write_meta, write_vars}, }; const PUNCTUATION: [&str; 3] = ["?", "!", "."]; const DEFAULT_VIEWPORT_HEIGHT: usize = 40; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Grapheme(SmallVec<[char; 4]>); impl Grapheme { pub fn chars(&self) -> &[char] { &self.0 } /// Returns the display width of the Grapheme, treating unprintable chars as width 0 pub fn width(&self) -> usize { self.0.iter().map(|c| c.width().unwrap_or(0)).sum() } /// Returns true if the Grapheme is wrapping a linefeed ('\n') pub fn is_lf(&self) -> bool { self.is_char('\n') } /// Returns true if the Grapheme consists of exactly one char and that char is `c` pub fn is_char(&self, c: char) -> bool { self.0.len() == 1 && self.0[0] == c } /// Returns the CharClass of the Grapheme, which is determined by the properties of its chars pub fn class(&self) -> CharClass { CharClass::from(self) } pub fn as_char(&self) -> Option { if self.0.len() == 1 { Some(self.0[0]) } else { None } } /// Returns true if the Grapheme is classified as whitespace (i.e. all chars are whitespace) pub fn is_ws(&self) -> bool { self.class() == CharClass::Whitespace } } impl From for Grapheme { fn from(value: char) -> Self { let mut new = SmallVec::<[char; 4]>::new(); new.push(value); Self(new) } } impl From<&str> for Grapheme { fn from(value: &str) -> Self { assert_eq!(value.graphemes(true).count(), 1); let mut new = SmallVec::<[char; 4]>::new(); for char in value.chars() { new.push(char); } Self(new) } } impl From for Grapheme { fn from(value: String) -> Self { Into::::into(value.as_str()) } } impl From<&String> for Grapheme { fn from(value: &String) -> Self { Into::::into(value.as_str()) } } impl Display for Grapheme { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for ch in &self.0 { write!(f, "{ch}")?; } Ok(()) } } pub fn to_graphemes(s: impl ToString) -> Vec { let s = s.to_string(); s.graphemes(true).map(Grapheme::from).collect() } pub fn to_lines(s: impl ToString) -> Vec { let s = s.to_string(); s.split("\n").map(to_graphemes).map(Line::from).collect() } pub fn join_lines(lines: &[Line]) -> String { lines .iter() .map(|line| line.to_string()) .collect::>() .join("\n") } pub fn trim_lines(lines: &mut Vec) { while lines.last().is_some_and(|line| line.is_empty()) { lines.pop(); } } pub fn split_lines_at(lines: &mut Vec, pos: Pos) -> Vec { let tail = lines[pos.row].split_off(pos.col); let mut rest: Vec = lines.drain(pos.row + 1..).collect(); rest.insert(0, tail); rest } pub fn attach_lines(lines: &mut Vec, other: &mut Vec) { if other.is_empty() { return; } if lines.is_empty() { 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)] pub struct Line(Vec); impl Line { pub fn graphemes(&self) -> &[Grapheme] { &self.0 } pub fn len(&self) -> usize { self.0.len() } pub fn is_empty(&self) -> bool { self.len() == 0 } pub fn push_str(&mut self, s: &str) { for g in s.graphemes(true) { self.0.push(Grapheme::from(g)); } } pub fn push_char(&mut self, c: char) { self.0.push(Grapheme::from(c)); } pub fn split_off(&mut self, at: usize) -> Line { if at > self.0.len() { return Line::default(); } Line(self.0.split_off(at)) } pub fn append(&mut self, other: &mut Line) { self.0.append(&mut other.0); } pub fn insert_char(&mut self, at: usize, c: char) { self.0.insert(at, Grapheme::from(c)); } pub fn insert(&mut self, at: usize, g: Grapheme) { self.0.insert(at, g); } pub fn width(&self) -> usize { self.0.iter().map(|g| g.width()).sum() } pub fn trim_start(&mut self) -> Line { let mut clone = self.clone(); while clone.0.first().is_some_and(|g| g.is_ws()) { clone.0.remove(0); } clone } } impl IndexMut for Line { fn index_mut(&mut self, index: usize) -> &mut Self::Output { &mut self.0[index] } } impl> Index for Line { type Output = T::Output; fn index(&self, index: T) -> &Self::Output { &self.0[index] } } impl From> for Line { fn from(value: Vec) -> Self { Self(value) } } impl Display for Line { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for gr in &self.0 { write!(f, "{gr}")?; } Ok(()) } } #[derive(PartialEq, Eq, Debug, Clone, Copy)] pub enum Delim { Paren, Brace, Bracket, Angle, } #[derive(Default, PartialEq, Eq, Debug, Clone, Copy)] pub enum CharClass { #[default] Alphanum, Symbol, Whitespace, Other, } impl CharClass { pub fn is_other_class(&self, other: &CharClass) -> bool { !self.eq(other) } pub fn is_other_class_not_ws(&self, other: &CharClass) -> bool { if self.is_ws() || other.is_ws() { false } else { self.is_other_class(other) } } pub fn is_other_class_or_ws(&self, other: &CharClass) -> bool { if self.is_ws() || other.is_ws() { true } else { self.is_other_class(other) } } pub fn is_ws(&self) -> bool { *self == CharClass::Whitespace } } impl From<&Grapheme> for CharClass { fn from(g: &Grapheme) -> Self { let Some(&first) = g.0.first() else { return Self::Other; }; if first.is_alphanumeric() && g.0[1..] .iter() .all(|&c| c.is_ascii_punctuation() || c == '\u{0301}' || c == '\u{0308}') { // Handles things like `ï`, `é`, etc., by manually allowing common diacritics return CharClass::Alphanum; } if g.0.iter().all(|&c| c.is_alphanumeric() || c == '_') { CharClass::Alphanum } else if g.0.iter().all(|c| c.is_whitespace()) { CharClass::Whitespace } else if g.0.iter().all(|c| !c.is_alphanumeric()) { CharClass::Symbol } else { CharClass::Other } } } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SelectMode { Char(Pos), Line(Pos), Block(Pos), } #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Pos { pub row: usize, pub col: usize, } impl Pos { /// make sure you clamp this pub const MAX: Self = Pos { row: usize::MAX, col: usize::MAX, }; pub const MIN: Self = Pos { row: usize::MIN, // just in case we discover something smaller than '0' col: usize::MIN, }; pub fn row_col_add(&self, row: isize, col: isize) -> Self { Self { row: self.row.saturating_add_signed(row), col: self.col.saturating_add_signed(col), } } pub fn col_add(&self, rhs: usize) -> Self { self.row_col_add(0, rhs as isize) } pub fn col_add_signed(&self, rhs: isize) -> Self { self.row_col_add(0, rhs) } pub fn col_sub(&self, rhs: usize) -> Self { self.row_col_add(0, -(rhs as isize)) } pub fn row_add(&self, rhs: usize) -> Self { self.row_col_add(rhs as isize, 0) } pub fn row_sub(&self, rhs: usize) -> Self { self.row_col_add(-(rhs as isize), 0) } pub fn clamp_row(&mut self, other: &[T]) { self.row = self.row.clamp(0, other.len().saturating_sub(1)); } pub fn clamp_col(&mut self, other: &[T], exclusive: bool) { let mut max = other.len(); if exclusive && max > 0 { max = max.saturating_sub(1); } self.col = self.col.clamp(0, max); } } #[derive(Debug, Copy, Clone)] pub enum MotionKind { /// A flat range from one grapheme position to another /// `start` is not necessarily less than `end`. `start` in most cases /// is the cursor's position. Char { start: Pos, end: Pos, inclusive: bool, }, /// A range of whole lines. Line { start: usize, end: usize, inclusive: bool }, Block { start: Pos, end: Pos, }, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct Cursor { pub pos: Pos, pub exclusive: bool, } #[derive(Default, Clone, Debug)] pub struct Edit { pub old_cursor: Pos, pub new_cursor: Pos, pub old: Vec, pub new: Vec, pub merging: bool, } impl Edit { pub fn start_merge(&mut self) { self.merging = true } pub fn stop_merge(&mut self) { self.merging = false } pub fn is_empty(&self) -> bool { self.old == self.new } } #[derive(Default, Clone, Debug)] pub struct IndentCtx { depth: usize, ctx: Vec, } impl IndentCtx { pub fn new() -> Self { Self::default() } pub fn depth(&self) -> usize { self.depth } pub fn ctx(&self) -> &[Tk] { &self.ctx } pub fn descend(&mut self, tk: Tk) { self.ctx.push(tk); self.depth += 1; } pub fn ascend(&mut self) { self.depth = self.depth.saturating_sub(1); self.ctx.pop(); } pub fn reset(&mut self) { std::mem::take(self); } pub fn check_tk(&mut self, tk: Tk) { if tk.is_opener() { self.descend(tk); } else if self.ctx.last().is_some_and(|t| tk.is_closer_for(t)) { self.ascend(); } } pub fn calculate(&mut self, input: &str) -> usize { self.depth = 0; self.ctx.clear(); let input_arc = Arc::new(input.to_string()); let Ok(tokens) = LexStream::new(input_arc, LexFlags::LEX_UNFINISHED).collect::>>() else { log::error!("Lexing failed during depth calculation: {:?}", input); return 0; }; for tk in tokens { self.check_tk(tk); } self.depth } } fn extract_range_contiguous(buf: &mut Vec, start: Pos, end: Pos) -> Vec { let start_col = start.col.min(buf[start.row].len()); let end_col = end.col.min(buf[end.row].len()); if start.row == end.row { // single line case let line = &mut buf[start.row]; let removed: Vec = line.0.drain(start_col..end_col).collect(); return vec![Line(removed)]; } // multi line case // tail of first line let first_tail: Line = buf[start.row].split_off(start_col); // all inbetween lines. extracts nothing if only two rows let middle: Vec = buf.drain(start.row + 1..end.row).collect(); // head of last line let last_col = end_col.min(buf[start.row + 1].len()); let last_head: Line = Line::from(buf[start.row + 1].0.drain(..last_col).collect::>()); // tail of last line let mut last_remainder = buf.remove(start.row + 1); // attach tail of last line to head of first line buf[start.row].append(&mut last_remainder); // construct vector of extracted content let mut extracts = vec![first_tail]; extracts.extend(middle); extracts.push(last_head); extracts } #[derive(Debug, Clone)] pub struct LineBuf { pub lines: Vec, pub hint: Option>, pub cursor: Cursor, pub select_mode: Option, pub last_selection: Option<(SelectMode, Pos)>, pub insert_mode_start_pos: Option, pub saved_col: Option, pub indent_ctx: IndentCtx, pub scroll_offset: usize, pub undo_stack: Vec, pub redo_stack: Vec, } impl Default for LineBuf { fn default() -> Self { Self { lines: vec![Line::from(vec![])], hint: None, cursor: Cursor { pos: Pos { row: 0, col: 0 }, exclusive: false, }, select_mode: None, last_selection: None, insert_mode_start_pos: None, saved_col: None, indent_ctx: IndentCtx::new(), scroll_offset: 0, undo_stack: vec![], redo_stack: vec![], } } } impl LineBuf { pub fn new() -> Self { Self::default() } pub fn get_viewport_height(&self) -> usize { let raw = read_shopts(|o| { let height = o.line.viewport_height.as_str(); if let Ok(num) = height.parse::() { num } else if let Some(pre) = height.strip_suffix('%') && let Ok(num) = pre.parse::() { if !isatty(STDIN_FILENO).unwrap_or_default() { return DEFAULT_VIEWPORT_HEIGHT }; let (_,rows) = get_win_size(STDIN_FILENO); (rows as f64 * (num as f64 / 100.0)).round() as usize } else { log::warn!("Invalid viewport height shopt value: '{}', using 50% of terminal height as default", height); if !isatty(STDIN_FILENO).unwrap_or_default() { return DEFAULT_VIEWPORT_HEIGHT }; let (_,rows) = get_win_size(STDIN_FILENO); (rows as f64 * 0.5).round() as usize } }); (raw.min(100)).min(self.lines.len()) } pub fn update_scroll_offset(&mut self) { let height = self.get_viewport_height(); let scrolloff = read_shopts(|o| o.line.scroll_offset); if self.cursor.pos.row < self.scroll_offset + scrolloff { self.scroll_offset = self.cursor.pos.row.saturating_sub(scrolloff); } if self.cursor.pos.row + scrolloff >= self.scroll_offset + height { self.scroll_offset = self.cursor.pos.row + scrolloff + 1 - height; } let max_offset = self.lines.len().saturating_sub(height); self.scroll_offset = self.scroll_offset.min(max_offset); } pub fn get_window(&self) -> Vec { let height = self.get_viewport_height(); self.lines .iter() .skip(self.scroll_offset) .take(height) .cloned() .collect() } pub fn window_joined(&self) -> String { join_lines(&self.get_window()) } pub fn display_window_joined(&self) -> String { let display = self.to_string(); let do_hl = state::read_shopts(|s| s.prompt.highlight); let mut highlighter = Highlighter::new(); highlighter.only_visual(!do_hl); highlighter.load_input(&display, self.cursor_byte_pos()); highlighter.expand_control_chars(); highlighter.highlight(); let highlighted = highlighter.take(); let hint = self.get_hint_text(); let lines = to_lines(format!("{highlighted}{hint}")); let offset = self.scroll_offset.min(lines.len()); let (_,mid) = lines.split_at(offset); let height = self.get_viewport_height().min(mid.len()); let (mid,_) = mid.split_at(height); join_lines(mid) } pub fn window_slice_to_cursor(&self) -> Option { let mut result = String::new(); let start_row = self.scroll_offset; for i in start_row..self.cursor.pos.row { result.push_str(&self.lines[i].to_string()); result.push('\n'); } let line = &self.lines[self.cursor.pos.row]; let col = self.cursor.pos.col.min(line.len()); for g in &line.graphemes()[..col] { result.push_str(&g.to_string()); } Some(result) } pub fn is_empty(&self) -> bool { self.lines.len() == 0 || (self.lines.len() == 1 && self.count_graphemes() == 0) } pub fn count_graphemes(&self) -> usize { self.lines.iter().map(|line| line.len()).sum() } #[track_caller] fn cur_line(&self) -> &Line { let caller = std::panic::Location::caller(); log::trace!("cur_line called from {}:{}", caller.file(), caller.line()); &self.lines[self.cursor.pos.row] } fn cur_line_mut(&mut self) -> &mut Line { &mut self.lines[self.cursor.pos.row] } fn line(&self, row: usize) -> &Line { &self.lines[row] } fn line_mut(&mut self, row: usize) -> &mut Line { &mut self.lines[row] } /// Takes an inclusive range of line numbers and returns an iterator over immutable borrows of those lines. fn line_iter(&mut self, start: usize, end: usize) -> impl Iterator { let (start,end) = ordered(start,end); self.lines.iter().take(end + 1).skip(start) } fn line_iter_mut(&mut self, start: usize, end: usize) -> impl Iterator { let (start,end) = ordered(start,end); self.lines.iter_mut().take(end + 1).skip(start) } fn line_to_cursor(&self) -> &[Grapheme] { let line = self.cur_line(); let col = self.cursor.pos.col.min(line.len()); &line[..col] } fn line_from_cursor(&self) -> &[Grapheme] { let line = self.cur_line(); let col = self.cursor.pos.col.min(line.len()); &line[col..] } fn row_col(&self) -> (usize, usize) { (self.row(), self.col()) } fn row(&self) -> usize { self.cursor.pos.row } fn offset_row(&self, offset: isize) -> usize { let mut row = self.cursor.pos.row.saturating_add_signed(offset); row = row.clamp(0, self.lines.len().saturating_sub(1)); row } fn col(&self) -> usize { self.cursor.pos.col } fn offset_col(&self, row: usize, offset: isize) -> usize { let mut col = self.cursor.pos.col.saturating_add_signed(offset); let max = if self.cursor.exclusive { self.lines[row].len().saturating_sub(1) } else { self.lines[row].len() }; col = col.clamp(0, max); col } fn offset_col_wrapping(&self, row: usize, offset: isize) -> (usize, usize) { let mut row = row; let mut col = self.cursor.pos.col as isize + offset; while col < 0 { if row == 0 { col = 0; break; } row -= 1; col += self.lines[row].len() as isize + 1; } while col > self.lines[row].len() as isize { if row >= self.lines.len() - 1 { col = self.lines[row].len() as isize; break; } col -= self.lines[row].len() as isize + 1; row += 1; } (row, col as usize) } fn set_cursor(&mut self, mut pos: Pos) { pos.clamp_row(&self.lines); pos.clamp_col(&self.lines[pos.row].0, false); self.cursor.pos = pos; } fn set_row(&mut self, row: usize) { self.set_cursor(Pos { row, col: self.saved_col.unwrap_or(self.cursor.pos.col), }); } fn set_col(&mut self, col: usize) { self.set_cursor(Pos { row: self.cursor.pos.row, col, }); } fn offset_cursor(&self, row_offset: isize, col_offset: isize) -> Pos { let row = self.offset_row(row_offset); let col = self.offset_col(row, col_offset); Pos { row, col } } fn offset_cursor_wrapping(&self, row_offset: isize, col_offset: isize) -> Pos { let row = self.offset_row(row_offset); let (row, col) = self.offset_col_wrapping(row, col_offset); Pos { row, col } } fn break_line(&mut self) { let (row, col) = self.row_col(); let level = self.calc_indent_level(); let mut rest = self.lines[row].split_off(col); let mut col = 0; for tab in std::iter::repeat_n(Grapheme::from('\t'), level) { rest.insert(0, tab); col += 1; } self.lines.insert(row + 1, rest); self.cursor.pos = Pos { row: row + 1, col, }; } fn verb_shell_cmd(&mut self, cmd: &str) -> ShResult<()> { let mut vars = HashSet::new(); vars.insert("_BUFFER".into()); vars.insert("_CURSOR".into()); vars.insert("_ANCHOR".into()); let _guard = var_ctx_guard(vars); let mut buf = self.joined(); let mut cursor = self.cursor_to_flat(); let mut anchor = self.select_mode.map(|r| { match r { SelectMode::Char(pos) | SelectMode::Block(pos) | SelectMode::Line(pos) => { self.pos_to_flat(pos).to_string() } } }).unwrap_or_default(); write_vars(|v| { v.set_var("_BUFFER", VarKind::Str(buf.clone()), VarFlags::EXPORT)?; v.set_var( "_CURSOR", VarKind::Str(cursor.to_string()), VarFlags::EXPORT, )?; v.set_var( "_ANCHOR", VarKind::Str(anchor.clone()), VarFlags::EXPORT, ) })?; RawModeGuard::with_cooked_mode(|| exec_input(cmd.to_string(), None, true, Some("".into())))?; let keys = write_vars(|v| { buf = v.take_var("_BUFFER"); cursor = v.take_var("_CURSOR").parse().unwrap_or(cursor); anchor = v.take_var("_ANCHOR"); v.take_var("_KEYS") }); self.set_buffer(buf); self.set_cursor_from_flat(cursor); if let Ok(pos) = anchor.parse() && pos != cursor && self.select_mode.is_some() { let new_pos = self.pos_from_flat(pos); match self.select_mode.as_mut() { Some(SelectMode::Line(pos)) | Some(SelectMode::Block(pos)) | Some(SelectMode::Char(pos)) => { *pos = new_pos } None => unreachable!() } } if !keys.is_empty() { write_meta(|m| m.set_pending_widget_keys(&keys)) } Ok(()) } fn insert_at(&mut self, pos: Pos, gr: Grapheme) { if gr.is_lf() { self.set_cursor(pos); self.break_line(); } else { let row = pos.row; let col = pos.col; self.lines[row].insert(col, gr); } } fn insert(&mut self, gr: Grapheme) { self.insert_at(self.cursor.pos, gr); } fn insert_str(&mut self, s: &str) { for gr in s.graphemes(true) { let gr = Grapheme::from(gr); if gr.is_lf() { self.break_line(); } else { self.insert(gr); self.cursor.pos.col += 1; } } } fn push_str(&mut self, s: &str) { let mut lines = to_lines(s); attach_lines(&mut self.lines, &mut lines); } fn push(&mut self, gr: Grapheme) { let last = self.lines.last_mut(); if let Some(last) = last { last.0.push(gr); } else { self.lines.push(Line::from(vec![gr])); } } fn scan_forward bool>(&self, f: F) -> Option { self.scan_forward_from(self.cursor.pos, f) } fn scan_forward_from bool>(&self, mut pos: Pos, mut f: F) -> Option { pos.clamp_row(&self.lines); pos.clamp_col(&self.lines[pos.row].0, false); let Pos { mut row, mut col } = pos; loop { let line = &self.lines[row]; if !line.is_empty() && f(&line[col]) { return Some(Pos { row, col }); } if col < self.lines[row].len().saturating_sub(1) { col += 1; } else if row < self.lines.len().saturating_sub(1) { row += 1; col = 0; } else { return None; } } } fn scan_backward bool>(&self, f: F) -> Option { self.scan_backward_from(self.cursor.pos, f) } fn scan_backward_from bool>(&self, mut pos: Pos, mut f: F) -> Option { pos.clamp_row(&self.lines); pos.clamp_col(&self.lines[pos.row].0, false); let Pos { mut row, mut col } = pos; loop { let line = &self.lines[row]; if !line.is_empty() && f(&line[col]) { return Some(Pos { row, col }); } if col > 0 { col -= 1; } else if row > 0 { row -= 1; col = self.lines[row].len().saturating_sub(1); } else { return None; } } } fn search_char(&self, dir: &Direction, dest: &Dest, char: &Grapheme) -> isize { match dir { Direction::Forward => { let slice = self.line_from_cursor(); for (i, gr) in slice.iter().enumerate().skip(1) { if gr == char { match dest { Dest::On => return i as isize, Dest::Before => return (i as isize - 1).max(0), Dest::After => unreachable!(), } } } } Direction::Backward => { let slice = self.line_to_cursor(); for (i, gr) in slice.iter().rev().enumerate().skip(1) { if gr == char { match dest { Dest::On => return -(i as isize) - 1, Dest::Before => return -(i as isize), Dest::After => unreachable!(), } } } } } 0 } fn eval_word_motion( &self, count: usize, to: &To, word: &Word, dir: &Direction, ignore_trailing_ws: bool, mut inclusive: bool, ) -> Option { let mut target = self.cursor.pos; for _ in 0..count { match (to, dir) { (To::Start, Direction::Forward) => { target = self // 'w' is a special snowflake motion so we need these two extra arguments // if we hit the ignore_trailing_ws path in the function, // inclusive is flipped to true. .word_motion_w(word, target, ignore_trailing_ws, &mut inclusive) .unwrap_or_else(|| { // we set inclusive to true so that we catch the entire word // instead of ignoring the last character inclusive = true; Pos::MAX }); } (To::End, Direction::Forward) => { inclusive = true; target = self.word_motion_e(word, target).unwrap_or(Pos::MAX); } (To::Start, Direction::Backward) => { target = self.word_motion_b(word, target).unwrap_or(Pos::MIN); } (To::End, Direction::Backward) => { inclusive = true; target = self.word_motion_ge(word, target).unwrap_or(Pos::MIN); } } } target.clamp_row(&self.lines); target.clamp_col(&self.lines[target.row].0, true); Some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive, }) } fn word_motion_w( &self, word: &Word, start: Pos, ignore_trailing_ws: bool, inclusive: &mut bool, ) -> Option { use CharClass as C; // get our iterator of char classes // we dont actually care what the chars are // just what they look like. // we are going to use .find() a lot to advance the iterator let mut classes = self.char_classes_forward_from(start).peekable(); match word { Word::Big => { if let Some((_, C::Whitespace)) = classes.peek() { // we are on whitespace. advance to the next non-ws char class return classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p); } let last_non_ws = classes.find(|(_, c)| c.is_ws()); if ignore_trailing_ws { return last_non_ws.map(|(p, _)| p); } classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p) } Word::Normal => { if let Some((_, C::Whitespace)) = classes.peek() { // we are on whitespace. advance to the next non-ws char class return classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p); } // go forward until we find some char class that isnt this one let mut last = classes.next()?; let first_c = last.1; while let Some((p,c)) = classes.next() { match c { C::Whitespace => { if ignore_trailing_ws { *inclusive = true; return Some(last.0) } else { break } } c if !c.is_other_class_or_ws(&first_c) => { last = (p,c); } _ => return Some(p) } } // we found whitespace previously, look for the next non-whitespace char class classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p) } } } fn word_motion_b(&self, word: &Word, start: Pos) -> Option { use CharClass as C; // get our iterator again let mut classes = self.char_classes_backward_from(start).peekable(); match word { Word::Big => { classes.next(); // for 'b', we handle starting on whitespace differently than 'w' // we don't return immediately if find() returns Some() here. let first_non_ws = if let Some((_, C::Whitespace)) = classes.peek() { // we use find() to advance the iterator as usual // but we can also be clever and use the question mark // to return early if we don't find a word backwards classes.find(|(_, c)| !c.is_ws())? } else { classes.next()? }; // ok now we are off that whitespace // now advance backwards until we find more whitespace, or next() is None let mut last = first_non_ws; while let Some((_, c)) = classes.peek() { if c.is_ws() { break; } last = classes.next()?; } Some(last.0) } Word::Normal => { classes.next(); let first_non_ws = if let Some((_, C::Whitespace)) = classes.peek() { classes.find(|(_, c)| !c.is_ws())? } else { classes.next()? }; // ok, off the whitespace // now advance until we find any different char class at all let mut last = first_non_ws; while let Some((_, c)) = classes.peek() { if c.is_other_class(&last.1) { break; } last = classes.next()?; } Some(last.0) } } } fn word_motion_e(&self, word: &Word, start: Pos) -> Option { use CharClass as C; let mut classes = self.char_classes_forward_from(start).peekable(); match word { Word::Big => { classes.next(); // unconditionally skip first position for 'e' let first_non_ws = if let Some((_, C::Whitespace)) = classes.peek() { classes.find(|(_, c)| !c.is_ws())? } else { classes.next()? }; let mut last = first_non_ws; while let Some((_, c)) = classes.peek() { if c.is_ws() { return Some(last.0); } last = classes.next()?; } None } Word::Normal => { classes.next(); let first_non_ws = if let Some((_, C::Whitespace)) = classes.peek() { classes.find(|(_, c)| !c.is_ws())? } else { classes.next()? }; let mut last = first_non_ws; while let Some((_, c)) = classes.peek() { if c.is_other_class_or_ws(&first_non_ws.1) { return Some(last.0); } last = classes.next()?; } None } } } fn word_motion_ge(&self, word: &Word, start: Pos) -> Option { use CharClass as C; let mut classes = self.char_classes_backward_from(start).peekable(); match word { Word::Big => { classes.next(); // unconditionally skip first position for 'ge' if matches!(classes.peek(), Some((_, c)) if !c.is_ws()) { classes.find(|(_, c)| c.is_ws()); } classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p) } Word::Normal => { classes.next(); if let Some((_, C::Whitespace)) = classes.peek() { return classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p); } let cur_class = classes.peek()?.1; let bound = classes.find(|(_, c)| c.is_other_class(&cur_class))?; if bound.1.is_ws() { classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p) } else { Some(bound.0) } } } } fn char_classes_forward_from(&self, pos: Pos) -> impl Iterator { CharClassIter::new(&self.lines, pos) } fn char_classes_forward(&self) -> impl Iterator { self.char_classes_forward_from(self.cursor.pos) } fn char_classes_backward_from(&self, pos: Pos) -> impl Iterator { CharClassIterRev::new(&self.lines, pos) } fn char_classes_backward(&self) -> impl Iterator { 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 dispatch_text_obj(&mut self, count: u16, obj: TextObj) -> Option { match obj { // text structures TextObj::Word(word, bound) => self.text_obj_word(count, word, obj, bound), TextObj::Sentence(_) | TextObj::Paragraph(_) | TextObj::WholeSentence(_) | TextObj::Tag(_) | TextObj::Custom(_) | TextObj::WholeParagraph(_) => { log::warn!("{:?} text objects are not implemented yet", obj); None } // quote stuff TextObj::DoubleQuote(bound) | TextObj::SingleQuote(bound) | TextObj::BacktickQuote(bound) => { self.text_obj_quote(count, obj, bound) } // delimited blocks TextObj::Paren(bound) | TextObj::Bracket(bound) | TextObj::Brace(bound) | TextObj::Angle(bound) => self.text_obj_delim(count, obj, bound), } } fn text_obj_word( &mut self, count: u16, word: Word, obj: TextObj, bound: Bound, ) -> Option { use CharClass as C; let mut fwd_classes = self.char_classes_forward(); let first_class = fwd_classes.next()?; match first_class { (pos,C::Whitespace) => { match bound { Bound::Inside => { let mut fwd_classes = self.char_classes_forward_from(pos).peekable(); let mut bkwd_classes = self.char_classes_backward_from(pos).peekable(); let mut first = (pos,C::Whitespace); let mut last = (pos,C::Whitespace); while let Some((_,c)) = bkwd_classes.peek() { if !c.is_ws() { break; } first = bkwd_classes.next()?; } while let Some((_,c)) = fwd_classes.peek() { if !c.is_ws() { break; } last = fwd_classes.next()?; } Some(MotionKind::Char { start: first.0, end: last.0, inclusive: true }) } Bound::Around => { let mut fwd_classes = self.char_classes_forward_from(pos).peekable(); let mut bkwd_classes = self.char_classes_backward_from(pos).peekable(); let mut first = (pos,C::Whitespace); let mut last = (pos,C::Whitespace); while let Some((_,cl)) = bkwd_classes.peek() { if !cl.is_ws() { break; } first = bkwd_classes.next()?; } while let Some((_,cl)) = fwd_classes.peek() { if !cl.is_ws() { break; } last = fwd_classes.next()?; } let word_class = fwd_classes.next()?.1; while let Some((_,cl)) = fwd_classes.peek() { match word { Word::Big => { if cl.is_ws() { break } } Word::Normal => { if cl.is_other_class_or_ws(&word_class) { break } } } last = fwd_classes.next()?; } Some(MotionKind::Char { start: first.0, end: last.0, inclusive: true }) } } } (pos, c) => { let break_cond = |cl: &C, c: &C| -> bool { match word { Word::Big => cl.is_ws(), Word::Normal => cl.is_other_class(c), } }; match bound { Bound::Inside => { let mut fwd_classes = self.char_classes_forward_from(pos).peekable(); let mut bkwd_classes = self.char_classes_backward_from(pos).peekable(); let mut first = (pos,c); let mut last = (pos,c); while let Some((_,cl)) = bkwd_classes.peek() { if break_cond(cl, &c) { break; } first = bkwd_classes.next()?; } while let Some((_,cl)) = fwd_classes.peek() { if break_cond(cl, &c) { break; } last = fwd_classes.next()?; } Some(MotionKind::Char { start: first.0, end: last.0, inclusive: true }) } Bound::Around => { let mut fwd_classes = self.char_classes_forward_from(pos).peekable(); let mut bkwd_classes = self.char_classes_backward_from(pos).peekable(); let mut first = (pos,c); let mut last = (pos,c); while let Some((_,cl)) = bkwd_classes.peek() { if break_cond(cl, &c) { break; } first = bkwd_classes.next()?; } while let Some((_,cl)) = fwd_classes.peek() { if break_cond(cl, &c) { break; } last = fwd_classes.next()?; } // Include trailing whitespace while let Some((_,cl)) = fwd_classes.peek() { if !cl.is_ws() { break; } last = fwd_classes.next()?; } Some(MotionKind::Char { start: first.0, end: last.0, inclusive: true }) } } } } } fn text_obj_quote( &mut self, count: u16, obj: TextObj, bound: Bound, ) -> Option { let q_ch = match obj { TextObj::DoubleQuote(_) => '"', TextObj::SingleQuote(_) => '\'', TextObj::BacktickQuote(_) => '`', _ => unreachable!(), }; let start_pos = self .scan_backward(|g| g.as_char() == Some(q_ch)) .or_else(|| self.scan_forward(|g| g.as_char() == Some(q_ch)))?; let mut scan_start_pos = start_pos; scan_start_pos.col += 1; let mut end_pos = self.scan_forward_from(scan_start_pos, |g| g.as_char() == Some(q_ch))?; match bound { Bound::Around => { // Around for quoted structures is weird. We have to include any trailing whitespace in the range. end_pos.col += 1; let mut classes = self.char_classes_forward_from(end_pos); end_pos = classes .find(|(_, c)| !c.is_ws()) .map(|(p, _)| p) .unwrap_or(self.end_pos()); (start_pos <= end_pos).then_some(MotionKind::Char { start: start_pos, end: end_pos, inclusive: false, }) } Bound::Inside => { let mut start_pos = start_pos; start_pos.col += 1; (start_pos <= end_pos).then_some(MotionKind::Char { start: start_pos, end: end_pos, inclusive: false, }) } } } fn text_obj_delim(&mut self, count: u16, obj: TextObj, bound: Bound) -> Option { let (opener, closer) = match obj { TextObj::Paren(_) => ('(', ')'), TextObj::Bracket(_) => ('[', ']'), TextObj::Brace(_) => ('{', '}'), TextObj::Angle(_) => ('<', '>'), _ => unreachable!(), }; let mut depth = 0; let start_pos = self .scan_backward(|g| { if g.as_char() == Some(closer) { depth += 1; } if g.as_char() == Some(opener) { if depth == 0 { return true; } depth -= 1; } false }) .or_else(|| self.scan_forward(|g| g.as_char() == Some(opener)))?; depth = 0; let end_pos = self.scan_forward_from(start_pos, |g| { if g.as_char() == Some(opener) { depth += 1; } if g.as_char() == Some(closer) { depth -= 1; } depth == 0 })?; match bound { Bound::Around => Some(MotionKind::Char { start: start_pos, end: end_pos, inclusive: true, }), Bound::Inside => { let mut start_pos = start_pos; start_pos.col += 1; (start_pos <= end_pos).then_some(MotionKind::Char { start: start_pos, end: end_pos, inclusive: false, }) } } } fn gr_at(&self, pos: Pos) -> Option<&Grapheme> { self.lines.get(pos.row)?.0.get(pos.col) } fn clamp_pos(&self, mut pos: Pos) -> Pos { pos.clamp_row(&self.lines); pos.clamp_col(&self.lines[pos.row].0, false); pos } fn number_at_cursor(&self) -> Option<(Pos,Pos)> { self.number_at(self.cursor.pos) } /// Returns the start/end span of a number at a given position, if any fn number_at(&self, mut pos: Pos) -> Option<(Pos,Pos)> { let is_number_char = |gr: &Grapheme| gr.as_char().is_some_and(|c| c == '.' || c == '-' || c.is_ascii_digit()); let is_digit = |gr: &Grapheme| gr.as_char().is_some_and(|c| c.is_ascii_digit()); pos = self.clamp_pos(pos); if !is_number_char(self.gr_at(pos)?) { return None; } // If cursor is on '-', advance to the first digit if self.gr_at(pos)?.as_char() == Some('-') { pos = pos.col_add(1); } let mut start = self.scan_backward_from(pos, |g| !is_digit(g)) .map(|pos| Pos { row: pos.row, col: pos.col + 1 }) .unwrap_or(Pos::MIN); let end = self.scan_forward_from(pos, |g| !is_digit(g)) .map(|pos| Pos { row: pos.row, col: pos.col.saturating_sub(1) }) .unwrap_or(Pos { row: pos.row, col: self.lines[pos.row].len().saturating_sub(1) }); if start > Pos::MIN && self.lines[start.row][start.col.saturating_sub(1)].as_char() == Some('-') { start.col -= 1; } Some((start, end)) } fn adjust_number(&mut self, inc: i64) -> Option<()> { let (s,e) = if let Some(range) = self.select_range() { match range { Motion::CharRange(s, e) => (s,e), _ => return None, } } else if let Some((s,e)) = self.number_at_cursor() { (s,e) } else { return None; }; let word = self.pos_slice_str(s,e); let num_fmt = if word.starts_with("0x") { let body = word.strip_prefix("0x").unwrap(); let width = body.len(); let num = i64::from_str_radix(body, 16).ok()?; let new_num = num + inc; format!("0x{new_num:0>width$x}") } else if word.starts_with("0b") { let body = word.strip_prefix("0b").unwrap(); let width = body.len(); let num = i64::from_str_radix(body, 2).ok()?; let new_num = num + inc; format!("0b{new_num:0>width$b}") } else if word.starts_with("0o") { let body = word.strip_prefix("0o").unwrap(); let width = body.len(); let num = i64::from_str_radix(body, 8).ok()?; let new_num = num + inc; format!("0o{new_num:0>width$o}") } else if let Ok(num) = word.parse::() { let width = word.len(); let new_num = num + inc; if new_num < 0 { let abs = new_num.unsigned_abs(); let digit_width = if num < 0 { width - 1 } else { width }; format!("-{abs:0>digit_width$}") } else if num < 0 { let digit_width = width - 1; format!("{new_num:0>digit_width$}") } else { format!("{new_num:0>width$}") } } else { return None }; self.replace_range(s, e, &num_fmt); self.cursor.pos.col -= 1; Some(()) } fn replace_range(&mut self, s: Pos, e: Pos, new: &str) -> Vec { let motion = MotionKind::Char { start: s, end: e, inclusive: true }; let content = self.extract_range(&motion); self.set_cursor(s); self.insert_str(new); content } fn pos_slice_str(&self, s: Pos, e: Pos) -> String { let (s,e) = ordered(s,e); if s.row == e.row { self.lines[s.row].0[s.col..=e.col] .iter() .map(|g| g.to_string()) .collect() } else { let mut result = String::new(); // First line from s.col to end for g in &self.lines[s.row].0[s.col..] { result.push_str(&g.to_string()); } // Middle lines for line in &self.lines[s.row + 1..e.row] { result.push('\n'); result.push_str(&line.to_string()); } // Last line from start to e.col result.push('\n'); for g in &self.lines[e.row].0[..=e.col] { result.push_str(&g.to_string()); } result } } fn find_delim_match(&mut self) -> Option { let is_opener = |g: &Grapheme| matches!(g.as_char(), Some(c) if "([{<".contains(c)); let is_closer = |g: &Grapheme| matches!(g.as_char(), Some(c) if ")]}>".contains(c)); let is_delim = |g: &Grapheme| is_opener(g) || is_closer(g); let first = self.scan_forward(is_delim)?; let delim_match = if is_closer(self.gr_at(first)?) { let opener = match self.gr_at(first)?.as_char()? { ')' => '(', ']' => '[', '}' => '{', '>' => '<', _ => unreachable!(), }; self.scan_backward_from(first, |g| g.as_char() == Some(opener))? } else if is_opener(self.gr_at(first)?) { let closer = match self.gr_at(first)?.as_char()? { '(' => ')', '[' => ']', '{' => '}', '<' => '>', _ => unreachable!(), }; self.scan_forward_from(first, |g| g.as_char() == Some(closer))? } else { unreachable!() }; Some(MotionKind::Char { start: self.cursor.pos, end: delim_match, inclusive: true, }) } /// Wrapper for eval_motion_inner that calls it with `check_hint: false` fn eval_motion(&mut self, cmd: &ViCmd) -> Option { self.eval_motion_inner(cmd, false) } fn eval_motion_inner(&mut self, cmd: &ViCmd, check_hint: bool) -> Option { let ViCmd { verb, motion, .. } = cmd; 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); } let kind = match motion { Motion::WholeLine => { let start = self.row(); let end = (self.row() + (count.saturating_sub(1))).min(self.lines.len().saturating_sub(1)); Some(MotionKind::Line { start, end, inclusive: true }) } Motion::TextObj(text_obj) => self.dispatch_text_obj(*count as u16, text_obj.clone()), Motion::EndOfLastWord => { let row = self.row() + (count.saturating_sub(1)); let line = self.line_mut(row); let mut target = Pos { row, col: 0 }; for (i, gr) in line.0.iter().enumerate() { if !gr.is_ws() { target.col = i; } } (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: true, }) } Motion::StartOfFirstWord => { let mut target = Pos { row: self.row(), col: 0, }; let line = self.cur_line(); for (i, gr) in line.0.iter().enumerate() { target.col = i; if !gr.is_ws() { break; } } (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: true, }) } dir @ (Motion::StartOfLine | Motion::EndOfLine) => { let (inclusive,off) = match dir { Motion::StartOfLine => (false,isize::MIN), Motion::EndOfLine => (true,isize::MAX), _ => unreachable!(), }; let target = self.offset_cursor(0, off); (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive, }) } Motion::WordMotion(to, word, dir) => { // 'cw' is a weird case // if you are on the word's left boundary, it will not delete whitespace after // the end of the word let ignore_trailing_ws = matches!(verb, Some(VerbCmd(_, Verb::Change)),) && matches!( motion, Motion::WordMotion(To::Start, _, Direction::Forward,) ); let inclusive = verb.is_none(); self.eval_word_motion(*count, to, word, dir, ignore_trailing_ws, inclusive) } Motion::CharSearch(dir, dest, char) => { let off = self.search_char(dir, dest, char); let target = self.offset_cursor(0, off); (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: true, }) } dir @ (Motion::BackwardChar | Motion::ForwardChar) | dir @ (Motion::BackwardCharForced | Motion::ForwardCharForced) => { let (off, wrap) = match dir { Motion::BackwardChar => (-(*count as isize), false), Motion::ForwardChar => (*count as isize, false), Motion::BackwardCharForced => (-(*count as isize), true), Motion::ForwardCharForced => (*count as isize, true), _ => unreachable!(), }; let target = if wrap { self.offset_cursor_wrapping(0, off) } else { self.offset_cursor(0, off) }; (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: false, }) } dir @ (Motion::LineDown | Motion::LineUp) => { let off = match dir { Motion::LineUp => -(*count as isize), Motion::LineDown => *count as isize, _ => unreachable!(), }; if verb.is_some() { let row = self.row(); let target_row = self.offset_row(off); let (s, e) = ordered(row, target_row); Some(MotionKind::Line { start: s, end: e, inclusive: true }) } else { if self.saved_col.is_none() { self.saved_col = Some(self.cursor.pos.col); } let row = self.offset_row(off); 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 }; (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: true, }) } } dir @ (Motion::EndOfBuffer | Motion::StartOfBuffer) => { let off = match dir { Motion::StartOfBuffer => isize::MIN, Motion::EndOfBuffer => isize::MAX, _ => unreachable!(), }; if verb.is_some() { let row = self.row(); let target_row = self.offset_row(off); let (s, e) = ordered(row, target_row); Some(MotionKind::Line { start: s, end: e, inclusive: false }) } else { let target = self.offset_cursor(off, 0); (target != self.cursor.pos).then_some(MotionKind::Char { start: self.cursor.pos, end: target, inclusive: true, }) } } Motion::WholeBuffer => Some(MotionKind::Line { start: 0, end: self.lines.len().saturating_sub(1), inclusive: false }), Motion::ToColumn => { let row = self.row(); let end = Pos { row, col: count.saturating_sub(1) }; Some(MotionKind::Char { start: self.cursor.pos, end, inclusive: end > self.cursor.pos }) } Motion::ToDelimMatch => self.find_delim_match(), Motion::ToBracket(direction) | Motion::ToParen(direction) | Motion::ToBrace(direction) => { let (opener,closer) = match motion { Motion::ToBracket(_) => ('[', ']'), Motion::ToParen(_) => ('(', ')'), Motion::ToBrace(_) => ('{', '}'), _ => unreachable!(), }; match direction { Direction::Forward => { let mut depth = 0; let target_pos = self.scan_forward(|g| { if g.as_char() == Some(opener) { depth += 1; } if g.as_char() == Some(closer) { depth -= 1; if depth <= 0 { return true; } } false })?; return Some(MotionKind::Char { start: self.cursor.pos, end: target_pos, inclusive: true, }) } Direction::Backward => { let mut depth = 0; let target_pos = self.scan_backward(|g| { if g.as_char() == Some(closer) { depth += 1; } if g.as_char() == Some(opener) { depth -= 1; if depth <= 0 { return true; } } false })?; return Some(MotionKind::Char { start: self.cursor.pos, end: target_pos, inclusive: true, }); } } } Motion::CharRange(s, e) => { let (s, e) = ordered(*s, *e); Some(MotionKind::Char { start: s, end: e, inclusive: true, }) } Motion::LineRange(s, e) => { let (s, e) = ordered(*s, *e); Some(MotionKind::Line { start: s, end: e, inclusive: false }) } Motion::BlockRange(s, e) => { let (s, e) = ordered(*s, *e); Some(MotionKind::Block { start: s, end: e }) } Motion::RepeatMotion | Motion::RepeatMotionRev => unreachable!("Repeat motions should have been resolved in readline/mod.rs"), Motion::Global(val) | Motion::NotGlobal(val) => { log::warn!("Global motions are not implemented yet (val: {:?})", val); None } Motion::Null => None, }; self.lines = buffer; kind } fn move_to_start(&mut self, motion: MotionKind) { match motion { MotionKind::Char { start, end, .. } => { let (s,_) = ordered(start, end); self.set_cursor(s); } MotionKind::Line { start, end, .. } => { let (s,_) = ordered(start, end); self.set_cursor(Pos { row: s, col: 0 }); } MotionKind::Block { start, end } => todo!(), } } /// Wrapper for apply_motion_inner that calls it with `accept_hint: false` fn apply_motion(&mut self, motion: MotionKind) -> ShResult<()> { self.apply_motion_inner(motion, false) } fn apply_motion_inner(&mut self, motion: MotionKind, accept_hint: bool) -> ShResult<()> { match motion { MotionKind::Char { end, .. } => { if accept_hint && self.has_hint() && end >= self.end_pos() { self.accept_hint_to(end); } else { self.set_cursor(end); } } MotionKind::Line { start, .. } => { self.set_row(start); } MotionKind::Block { start, end } => todo!(), } Ok(()) } fn extract_range(&mut self, motion: &MotionKind) -> Vec { log::debug!("Extracting range for motion: {:?}", motion); let extracted = match motion { MotionKind::Char { start, end, inclusive, } => { let (s, e) = ordered(*start, *end); let end = if *inclusive { Pos { row: e.row, col: e.col + 1, } } else { e }; let mut buf = std::mem::take(&mut self.lines); let extracted = extract_range_contiguous(&mut buf, s, end); self.lines = buf; extracted } MotionKind::Line { start, end, inclusive } => { let end = if *inclusive { *end } else { end.saturating_sub(1) }; self.lines.drain(*start..=end).collect() } MotionKind::Block { start, end } => { let (s, e) = ordered(*start, *end); (s.row..=e.row) .map(|row| { let sc = s.col.min(self.lines[row].len()); let ec = (e.col + 1).min(self.lines[row].len()); Line(self.lines[row].0.drain(sc..ec).collect()) }) .collect() } }; if self.lines.is_empty() { self.lines.push(Line::default()); } extracted } fn yank_range(&self, motion: &MotionKind) -> Vec { let mut tmp = Self { lines: self.lines.clone(), cursor: self.cursor, ..Default::default() }; tmp.extract_range(motion) } fn delete_range(&mut self, motion: &MotionKind) -> Vec { self.extract_range(motion) } pub fn calc_indent_level(&mut self) -> usize { self.calc_indent_level_for_pos(self.cursor.pos) } pub fn calc_indent_level_for_pos(&mut self, pos: Pos) -> usize { let mut lines = self.lines.clone(); split_lines_at(&mut lines, pos); let raw = join_lines(&lines); self.indent_ctx.calculate(&raw) } fn motion_mutation(&mut self, motion: MotionKind, f: impl Fn(&Grapheme) -> Grapheme) { match motion { MotionKind::Char { start, end, inclusive, } => { let (s, e) = ordered(start, end); if s.row == e.row { let range = if inclusive { s.col..e.col + 1 } else { s.col..e.col }; for col in range { if col >= self.lines[s.row].len() { break; } self.lines[s.row][col] = f(&self.lines[s.row][col]); } return; } let end = if inclusive { e.col + 1 } else { e.col }; for col in s.col..self.lines[s.row].len() { self.lines[s.row][col] = f(&self.lines[s.row][col]); } for row in s.row + 1..e.row { for col in 0..self.lines[row].len() { self.lines[row][col] = f(&self.lines[row][col]); } } for col in 0..end { if col >= self.lines[e.row].len() { break; } self.lines[e.row][col] = f(&self.lines[e.row][col]); } } MotionKind::Line { start, end, inclusive } => { let end = if inclusive { end } else { end.saturating_sub(1) }; let end = end.min(self.lines.len().saturating_sub(1)); for row in start..=end { let line = self.line_mut(row); for col in 0..line.len() { line[col] = f(&line[col]); } } } MotionKind::Block { start, end } => todo!(), } } fn inplace_mutation(&mut self, count: u16, f: impl Fn(&Grapheme) -> Grapheme) { let mut first = true; for i in 0..count { if first { first = false } else { self.cursor.pos = self.offset_cursor(0, 1); } let pos = self.cursor.pos; let motion = MotionKind::Char { start: pos, end: pos, inclusive: true, }; self.motion_mutation(motion, &f); } } fn exec_verb(&mut self, cmd: &ViCmd) -> ShResult<()> { let ViCmd { register, verb, motion, .. } = cmd; let Some(VerbCmd(_, verb)) = verb 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_inner(cmd, true); if let Some(motion_kind) = result { self.apply_motion_inner(motion_kind, true)?; } return Ok(()); }; let count = motion.as_ref().map(|m| m.0).unwrap_or(1); match verb { Verb::Delete | Verb::Change | Verb::Yank => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; let content = if *verb == Verb::Yank { self.yank_range(&motion) } else if *verb == Verb::Change && matches!(motion, MotionKind::Line {..}) { let n_lines = self.lines.len(); let content = self.delete_range(&motion); let row = self.row(); if n_lines > 1 { self.lines.insert(row, Line::default()); } content } else { self.delete_range(&motion) }; let reg_content = match &motion { MotionKind::Char { .. } => RegisterContent::Span(content), MotionKind::Line { .. } => RegisterContent::Line(content), MotionKind::Block { .. } => RegisterContent::Block(content), }; register.write_to_register(reg_content); match motion { MotionKind::Char { start, end, .. } => { let (s, _) = ordered(start, end); self.set_cursor(s); } MotionKind::Line { start, end, inclusive } => { let end = if inclusive { end } else { end.saturating_sub(1) }; let (s, _) = ordered(start, end); self.set_row(s); if *verb == Verb::Change { // we've gotta indent let level = self.calc_indent_level(); let line = self.cur_line_mut(); let mut col = 0; for tab in std::iter::repeat_n(Grapheme::from('\t'), level) { line.0.insert(col, tab); col += 1; } self.cursor.pos = self.offset_cursor(0, col as isize); } } MotionKind::Block { start, .. } => { let (s, _) = ordered(self.cursor.pos, start); self.set_cursor(s); } } } Verb::Rot13 => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |gr| { gr.as_char() .map(rot13_char) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); self.move_to_start(motion); } Verb::ReplaceChar(ch) => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |_| Grapheme::from(*ch)); self.move_to_start(motion); } Verb::ReplaceCharInplace(ch, count) => self.inplace_mutation(*count, |_| Grapheme::from(*ch)), Verb::ToggleCaseInplace(count) => { self.inplace_mutation(*count, |gr| { gr.as_char() .map(toggle_case_char) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); self.cursor.pos = self.cursor.pos.col_add(1); } Verb::ToggleCaseRange => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |gr| { gr.as_char() .map(toggle_case_char) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); self.move_to_start(motion); } Verb::IncrementNumber(n) => { self.adjust_number(*n as i64); }, Verb::DecrementNumber(n) => { self.adjust_number(-(*n as i64)); }, Verb::ToLower => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |gr| { gr.as_char() .map(|c| c.to_ascii_lowercase()) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); self.move_to_start(motion); } Verb::ToUpper => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; self.motion_mutation(motion, |gr| { gr.as_char() .map(|c| c.to_ascii_uppercase()) .map(Grapheme::from) .unwrap_or_else(|| gr.clone()) }); self.move_to_start(motion); } Verb::Undo => { if let Some(edit) = self.undo_stack.pop() { self.lines = edit.old.clone(); self.cursor.pos = edit.old_cursor; self.redo_stack.push(edit); } } Verb::Redo => { if let Some(edit) = self.redo_stack.pop() { self.lines = edit.new.clone(); self.cursor.pos = edit.new_cursor; self.undo_stack.push(edit); } } Verb::Put(anchor) => { let Some(content) = register.read_from_register() else { return Ok(()); }; match content { RegisterContent::Span(lines) => { let move_cursor = lines.len() == 1 && lines[0].len() > 1; let content_len: usize = lines.iter().map(|l| l.len()).sum(); let row = self.row(); let col = match anchor { Anchor::After => (self.col() + 1).min(self.cur_line().len()), Anchor::Before => self.col(), }; let start_len = self.lines[row].len(); let mut right = self.lines[row].split_off(col); let mut lines = lines.clone(); let last = lines.len() - 1; // First line appends to current line self.lines[row].append(&mut lines[0]); // Middle + last lines get inserted after for (i, line) in lines[1..].iter().cloned().enumerate() { self.lines.insert(row + 1 + i, line); } // Reattach right half to the last inserted line self.lines[row + last].append(&mut right); let end_len = self.lines[row].len(); let mut delta = end_len.saturating_sub(start_len); if let Anchor::Before = anchor { delta = delta.saturating_sub(1); } if move_cursor { self.cursor.pos = self.offset_cursor(0, delta as isize); } else if content_len > 1 || *anchor == Anchor::After { self.cursor.pos = self.offset_cursor(0, 1); } } RegisterContent::Line(lines) => { let row = match anchor { Anchor::After => self.row() + 1, Anchor::Before => self.row(), }; for (i, line) in lines.iter().cloned().enumerate() { self.lines.insert(row + i, line); self.set_row(row + i); } } RegisterContent::Block(lines) => todo!(), RegisterContent::Empty => {} } } Verb::InsertModeLineBreak(anchor) => match anchor { Anchor::After => { let row = self.row(); let target = (row + 1).min(self.lines.len()); self.lines.insert(target, Line::default()); let level = self.calc_indent_level_for_pos(Pos { row: target, col: 0 }); let line = self.line_mut(target); let mut col = 0; for tab in std::iter::repeat_n(Grapheme::from('\t'), level) { line.insert(0, tab); col += 1; } self.cursor.pos = Pos { row: row + 1, col, }; } Anchor::Before => { let row = self.row(); self.lines.insert(row, Line::default()); self.cursor.pos = Pos { row, col: 0 }; } }, Verb::SwapVisualAnchor => { let cur_pos = self.cursor.pos; let new_anchor; { let Some(select) = self.select_mode.as_mut() else { return Ok(()); }; match select { SelectMode::Block(select_anchor) | SelectMode::Line(select_anchor) | SelectMode::Char(select_anchor) => { new_anchor = *select_anchor; *select_anchor = cur_pos; } } } self.set_cursor(new_anchor); } Verb::JoinLines => { let old_exclusive = self.cursor.exclusive; self.cursor.exclusive = false; for _ in 0..count { let row = self.row(); let target_pos = Pos { row, col: self.offset_col(row, isize::MAX), }; if row == self.lines.len() - 1 { break; } let mut next_line = self.lines.remove(row + 1).trim_start(); let this_line = self.cur_line_mut(); let this_has_ws = this_line.0.last().is_some_and(|g| g.is_ws()); let join_with_space = !this_has_ws && !this_line.is_empty() && !next_line.is_empty(); if join_with_space { next_line.insert_char(0, ' '); } this_line.append(&mut next_line); self.set_cursor(target_pos); } self.cursor.exclusive = old_exclusive; } Verb::InsertChar(ch) => { let level = self.calc_indent_level(); self.insert(Grapheme::from(*ch)); if let Some(motion) = self.eval_motion(cmd) { self.apply_motion(motion)?; } let new_level = self.calc_indent_level(); if new_level < level { let delta = level - new_level; let line = self.cur_line_mut(); for _ in 0..delta { if line.0.first().is_some_and(|c| c.as_char() == Some('\t')) { line.0.remove(0); } else { break } } } } Verb::Insert(s) => self.insert_str(s), Verb::Indent | Verb::Dedent => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()); }; let (s, e) = match motion { MotionKind::Char { start, end, .. } => ordered(start.row, end.row), MotionKind::Line { start, end, .. } => ordered(start, end), MotionKind::Block { .. } => todo!(), }; let mut col_offset = 0; for line in self.line_iter_mut(s, e) { match verb { Verb::Indent => { line.insert(0, Grapheme::from('\t')); col_offset += 1; } Verb::Dedent => { if line.0.first().is_some_and(|c| c.as_char() == Some('\t')) { line.0.remove(0); col_offset -= 1; } } _ => unreachable!(), } } self.cursor.pos = self.cursor.pos.col_add_signed(col_offset) } Verb::Equalize => { let Some(motion) = self.eval_motion(cmd) else { return Ok(()) }; let (s,e) = match motion { MotionKind::Char { start, end, inclusive } => ordered(start.row, end.row), MotionKind::Line { start, end, inclusive } => ordered(start, end), MotionKind::Block { start, end } => todo!(), }; for row in s..=e { let line_len = self.line(row).len(); // we are going to calculate the level twice, once at column = 0 and once at column = line.len() // "b-b-b-b-but the performance" i dont care // the number of tabs we use for the line is the lesser of these two calculations // if level_start > level_end, the line has an closer // if level_end > level_start, the line has a opener let level_start = self.calc_indent_level_for_pos(Pos { row, col: 0 }); let level_end = self.calc_indent_level_for_pos(Pos { row, col: line_len }); let num_tabs = level_start.min(level_end); let line = self.line_mut(row); while line.0.first().is_some_and(|c| c.as_char() == Some('\t')) { line.0.remove(0); } for tab in std::iter::repeat_n(Grapheme::from('\t'), num_tabs) { line.insert(0, tab); } } } Verb::AcceptLineOrNewline => { // If we are here, we did not accept the line // so we break to a new line self.break_line(); } Verb::ShellCmd(cmd) => self.verb_shell_cmd(cmd)?, Verb::Read(src) => match src { ReadSrc::File(path_buf) => { if !path_buf.is_file() { write_meta(|m| m.post_system_message(format!("{} is not a file", path_buf.display()))); return Ok(()); } let Ok(contents) = std::fs::read_to_string(path_buf) else { write_meta(|m| { m.post_system_message(format!("Failed to read file {}", path_buf.display())) }); return Ok(()); }; self.insert_str(&contents); } ReadSrc::Cmd(cmd) => { let output = match expand_cmd_sub(&cmd) { Ok(out) => out, Err(e) => { e.print_error(); return Ok(()); } }; self.insert_str(&output); } }, Verb::Write(dest) => match dest { WriteDest::FileAppend(path_buf) | WriteDest::File(path_buf) => { let Ok(mut file) = (if matches!(dest, WriteDest::File(_)) { OpenOptions::new() .create(true) .truncate(true) .write(true) .open(path_buf) } else { OpenOptions::new().create(true).append(true).open(path_buf) }) else { write_meta(|m| { m.post_system_message(format!("Failed to open file {}", path_buf.display())) }); return Ok(()); }; if let Err(e) = file.write_all(self.joined().as_bytes()) { write_meta(|m| { m.post_system_message(format!( "Failed to write to file {}: {e}", path_buf.display() )) }); } return Ok(()); } WriteDest::Cmd(cmd) => { let buf = self.joined(); let io_mode = IoMode::Buffer { tgt_fd: STDIN_FILENO, buf, flags: TkFlags::IS_HEREDOC | TkFlags::LIT_HEREDOC, }; let redir = Redir::new(io_mode, RedirType::Input); let mut frame = IoFrame::new(); frame.push(redir); let mut stack = IoStack::new(); stack.push_frame(frame); exec_input(cmd.to_string(), Some(stack), false, Some("ex write".into()))?; } }, Verb::Edit(path) => { if read_vars(|v| v.try_get_var("EDITOR")).is_none() { write_meta(|m| { m.post_system_message( "$EDITOR is unset. Aborting edit.".into(), ) }); } else { let input = format!("$EDITOR {}", path.display()); exec_input(input, None, true, Some("ex edit".into()))?; } } Verb::Complete | Verb::ExMode | Verb::EndOfFile | Verb::InsertMode | Verb::NormalMode | Verb::VisualMode | Verb::VerbatimMode | Verb::ReplaceMode | Verb::VisualModeLine | Verb::VisualModeBlock | Verb::CompleteBackward | Verb::VisualModeSelectLast => { let Some(motion_kind) = self.eval_motion_inner(cmd, true) else { return Ok(()); }; self.apply_motion_inner(motion_kind, true)?; } Verb::Normal(_) | Verb::Substitute(..) | Verb::RepeatSubstitute | Verb::Quit | Verb::RepeatGlobal => { log::warn!("Verb {:?} is not implemented yet", verb); } Verb::RepeatLast => unreachable!("Verb::RepeatLast should be handled in readline/mod.rs"), } Ok(()) } pub fn exec_cmd(&mut self, cmd: ViCmd) -> ShResult<()> { let is_char_insert = cmd.verb.as_ref().is_some_and(|v| v.1.is_char_insert()); let starts_merge = cmd.verb.as_ref().is_some_and(|v| matches!(v.1, Verb::Change)); let is_line_motion = cmd.is_line_motion() || cmd .verb .as_ref() .is_some_and(|v| v.1 == Verb::AcceptLineOrNewline); let is_undo_op = cmd.is_undo_op(); let is_vertical = matches!( cmd.motion().map(|m| &m.1), Some(Motion::LineUp | Motion::LineDown) ); if !is_vertical { self.saved_col = None; } let before = self.lines.clone(); let old_cursor = self.cursor.pos; // Execute the command let res = self.exec_verb(&cmd); if self.is_empty() { self.set_hint(None); } let new_cursor = self.cursor.pos; // Stop merging on any non-char-insert command, even if buffer didn't change if !is_char_insert && !is_undo_op { if let Some(edit) = self.undo_stack.last_mut() { edit.merging = false; } } if self.lines != before && !is_undo_op { self.redo_stack.clear(); if is_char_insert { // Merge consecutive char inserts into one undo entry if let Some(edit) = self.undo_stack.last_mut().filter(|e| e.merging) { edit.new = self.lines.clone(); edit.new_cursor = new_cursor; } else { self.undo_stack.push(Edit { old_cursor, new_cursor, old: before, new: self.lines.clone(), merging: true, }); } } else { self.handle_edit(before, new_cursor, old_cursor); // Change starts a new merge chain so subsequent InsertChars merge into it if starts_merge { if let Some(edit) = self.undo_stack.last_mut() { edit.merging = true; } } } } self.fix_cursor(); res } pub fn handle_edit(&mut self, old: Vec, new_cursor: Pos, old_cursor: Pos) { let edit_is_merging = self.undo_stack.last().is_some_and(|edit| edit.merging); if edit_is_merging { // Update the `new` snapshot on the existing edit if let Some(edit) = self.undo_stack.last_mut() { edit.new = self.lines.clone(); } } else { self.undo_stack.push(Edit { new_cursor, old_cursor, old, new: self.lines.clone(), merging: false, }); } } pub fn fix_cursor(&mut self) { if self.cursor.pos.row >= self.lines.len() { self.cursor.pos.row = self.lines.len().saturating_sub(1); } if self.cursor.exclusive { let line = self.cur_line(); let col = self.col(); if col > 0 && col >= line.len() { self.cursor.pos.col = line.len().saturating_sub(1); } } else { let line = self.cur_line(); let col = self.col(); if col > 0 && col > line.len() { self.cursor.pos.col = line.len(); } } self.update_scroll_offset(); } pub fn joined(&self) -> String { let mut lines = vec![]; for line in &self.lines { lines.push(line.to_string()); } lines.join("\n") } // ───── Compatibility shims for old flat-string interface ───── /// Compat shim: replace buffer contents from a string, parsing into lines. pub fn set_buffer(&mut self, s: String) { self.lines = to_lines(&s); if self.lines.is_empty() { self.lines.push(Line::default()); } // Clamp cursor to valid position self.cursor.pos.row = self.cursor.pos.row.min(self.lines.len().saturating_sub(1)); let max_col = self.lines[self.cursor.pos.row].len(); self.cursor.pos.col = self.cursor.pos.col.min(max_col); } /// Compat shim: set hint text. None clears the hint. pub fn set_hint(&mut self, hint: Option) { let joined = self.joined(); self.hint = hint .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. pub fn has_hint(&self) -> bool { 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. 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 hint = self.hint.clone().unwrap_or_default(); trim_lines(&mut hint); for line in hint { lines.push(line.to_string()); } 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); let split_col = if self.cursor.exclusive { target.col + 1 } else { target.col }; // 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. pub fn accept_hint(&mut self) { let hint_str = self.get_hint_text_raw(); if hint_str.is_empty() { return; } self.push_str(&hint_str); self.set_cursor(Pos::MAX); self.fix_cursor(); self.hint = None; } /// Compat shim: return a constructor that sets initial buffer contents and cursor. pub fn with_initial(mut self, s: &str, cursor_pos: usize) -> Self { self.set_buffer(s.to_string()); // In the flat model, cursor_pos was a flat offset. Map to col on row . self.cursor.pos = Pos { row: 0, col: cursor_pos.min(s.len()), }; self } /// Compat shim: move cursor to end of buffer. pub fn move_cursor_to_end(&mut self) { 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, }; } /// Compat shim: returns the maximum cursor position (flat grapheme count). pub fn cursor_max(&self) -> usize { // In single-line mode this is the length of the first line // In multi-line mode this returns total grapheme count (for flat compat) if self.lines.len() == 1 { self.lines[0].len() } else { self.count_graphemes() } } /// Compat shim: returns true if cursor is at the max position. pub fn cursor_at_max(&self) -> bool { let last_row = self.lines.len().saturating_sub(1); let max = if self.cursor.exclusive { self.lines[last_row].len().saturating_sub(1) } else { self.lines[last_row].len() }; self.cursor.pos.row == last_row && self.cursor.pos.col >= max } /// Compat shim: set cursor with clamping. pub fn set_cursor_clamp(&mut self, exclusive: bool) { self.cursor.exclusive = exclusive; } /// Compat shim: returns the flat column of the start of the current line. /// In the old flat model this returned 0 for single-line; for multi-line it's the /// flat offset of the beginning of the current row. pub fn start_of_line(&self) -> usize { // Return 0-based flat offset of start of current row let mut offset = 0; for i in 0..self.cursor.pos.row { offset += self.lines[i].len() + 1; // +1 for '\n' } offset } pub fn on_last_line(&self) -> bool { self.cursor.pos.row == self.lines.len().saturating_sub(1) } /// Compat shim: returns slice of joined buffer from grapheme indices. pub fn slice(&self, range: std::ops::Range) -> Option { let joined = self.joined(); let graphemes: Vec<&str> = joined.graphemes(true).collect(); if range.start > graphemes.len() || range.end > graphemes.len() { return None; } Some(graphemes[range].join("")) } /// Compat shim: returns the string from buffer start to cursor position. pub fn slice_to_cursor(&self) -> Option { let mut result = String::new(); for i in 0..self.cursor.pos.row { result.push_str(&self.lines[i].to_string()); result.push('\n'); } let line = &self.lines[self.cursor.pos.row]; let col = self.cursor.pos.col.min(line.len()); for g in &line.graphemes()[..col] { result.push_str(&g.to_string()); } Some(result) } /// Compat shim: returns cursor byte position in the joined string. pub fn cursor_byte_pos(&self) -> usize { let mut pos = 0; for i in 0..self.cursor.pos.row { pos += self.lines[i].to_string().len() + 1; // +1 for '\n' } let line_str = self.lines[self.cursor.pos.row].to_string(); let col = self .cursor .pos .col .min(self.lines[self.cursor.pos.row].len()); // Sum bytes of graphemes up to col let mut byte_count = 0; for (i, g) in line_str.graphemes(true).enumerate() { if i >= col { break; } byte_count += g.len(); } pos + byte_count } pub fn start_char_select(&mut self) { self.select_mode = Some(SelectMode::Char(self.cursor.pos)); } pub fn start_line_select(&mut self) { self.select_mode = Some(SelectMode::Line(self.cursor.pos)); } pub fn start_block_select(&mut self) { self.select_mode = Some(SelectMode::Block(self.cursor.pos)); } /// Compat shim: stop visual selection. pub fn stop_selecting(&mut self) { if self.select_mode.is_some() { self.last_selection = self.select_mode.map(|m| { let anchor = match m { SelectMode::Char(a) | SelectMode::Block(a) | SelectMode::Line(a) => a, }; (m, anchor) }); } self.select_mode = None; } pub fn select_range(&self) -> Option { let mode = self.select_mode.as_ref()?; match mode { SelectMode::Char(pos) => { let (s, e) = ordered(self.cursor.pos, *pos); Some(Motion::CharRange(s, e)) } SelectMode::Line(pos) => { let (s, e) = ordered(self.row(), pos.row); Some(Motion::LineRange(s, e)) } SelectMode::Block(pos) => { let (s, e) = ordered(self.cursor.pos, *pos); Some(Motion::BlockRange(s, e)) } } } /// Helper: convert a Pos to a flat grapheme offset. fn pos_to_flat(&self, pos: Pos) -> usize { let mut offset = 0; let row = pos.row.min(self.lines.len().saturating_sub(1)); for i in 0..row { offset += self.lines[i].len() + 1; // +1 for '\n' } offset + pos.col.min(self.lines[row].len()) } fn pos_from_flat(&self, mut flat: usize) -> Pos { for (i, line) in self.lines.iter().enumerate() { if flat <= line.len() { return Pos { row: i, col: flat }; } flat = flat.saturating_sub(line.len() + 1); // +1 for '\n' } // If we exceed the total length, clamp to end let last_row = self.lines.len().saturating_sub(1); let last_col = self.lines[last_row].len(); Pos { row: last_row, col: last_col } } pub fn cursor_to_flat(&self) -> usize { self.pos_to_flat(self.cursor.pos) } pub fn set_cursor_from_flat(&mut self, flat: usize) { self.cursor.pos = self.pos_from_flat(flat); self.fix_cursor(); } /// Compat shim: attempt history expansion. Stub that returns false. pub fn attempt_history_expansion(&mut self, _history: &super::history::History) -> bool { // TODO: implement history expansion for 2D buffer false } /// Compat shim: check if cursor is on an escaped char. pub fn cursor_is_escaped(&self) -> bool { if self.cursor.pos.col == 0 { return false; } let line = &self.lines[self.cursor.pos.row]; if self.cursor.pos.col > line.len() { return false; } line .graphemes() .get(self.cursor.pos.col.saturating_sub(1)) .is_some_and(|g| g.is_char('\\')) } /// Compat shim: take buffer contents and reset. pub fn take_buf(&mut self) -> String { let result = self.joined(); self.lines = vec![Line::default()]; self.cursor.pos = Pos { row: 0, col: 0 }; result } /// Compat shim: mark where insert mode started. pub fn mark_insert_mode_start_pos(&mut self) { self.insert_mode_start_pos = Some(self.cursor.pos); } /// Compat shim: clear insert mode start position. pub fn clear_insert_mode_start_pos(&mut self) { self.insert_mode_start_pos = None; } } impl Display for LineBuf { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(select) = self.select_mode.as_ref() { let mut cloned = self.lines.clone(); match select { SelectMode::Char(pos) => { let (s, e) = ordered(self.cursor.pos, *pos); if s.row == e.row { // Same line: insert end first to avoid shifting start index let line = &mut cloned[s.row]; if e.col + 1 >= line.len() { line.push_char(markers::VISUAL_MODE_END); } else { line.insert(e.col + 1, markers::VISUAL_MODE_END.into()); } line.insert(s.col, markers::VISUAL_MODE_START.into()); } else { // Start line: highlight from s.col to end cloned[s.row].insert(s.col, markers::VISUAL_MODE_START.into()); cloned[s.row].push_char(markers::VISUAL_MODE_END); // Middle lines: fully highlighted for row in cloned.iter_mut().skip(s.row + 1).take(e.row - s.row - 1) { row.insert(0, markers::VISUAL_MODE_START.into()); row.push_char(markers::VISUAL_MODE_END); } // End line: highlight from start to e.col let end_line = &mut cloned[e.row]; if e.col + 1 >= end_line.len() { end_line.push_char(markers::VISUAL_MODE_END); } else { end_line.insert(e.col + 1, markers::VISUAL_MODE_END.into()); } end_line.insert(0, markers::VISUAL_MODE_START.into()); } } SelectMode::Line(pos) => { let (s, e) = ordered(self.row(), pos.row); for row in cloned.iter_mut().take(e + 1).skip(s) { row.insert(0, markers::VISUAL_MODE_START.into()); } cloned[e].push_char(markers::VISUAL_MODE_END); } SelectMode::Block(pos) => todo!(), } let mut lines = vec![]; for line in &cloned { lines.push(line.to_string()); } let joined = lines.join("\n"); write!(f, "{joined}") } else { write!(f, "{}", self.joined()) } } } struct CharClassIter<'a> { lines: &'a [Line], row: usize, col: usize, exhausted: bool, at_boundary: bool, } impl<'a> CharClassIter<'a> { pub fn new(lines: &'a [Line], start_pos: Pos) -> Self { Self { lines, row: start_pos.row, col: start_pos.col, exhausted: false, at_boundary: false, } } fn get_pos(&self) -> Pos { Pos { row: self.row, col: self.col, } } } impl<'a> Iterator for CharClassIter<'a> { type Item = (Pos, CharClass); fn next(&mut self) -> Option<(Pos, CharClass)> { if self.exhausted { return None; } // Synthetic whitespace for line boundary if self.at_boundary { self.at_boundary = false; let pos = self.get_pos(); return Some((pos, CharClass::Whitespace)); } if self.row >= self.lines.len() { self.exhausted = true; return None; } if self.row >= self.lines.len() { self.exhausted = true; return None; } let line = &self.lines[self.row]; // Empty line = whitespace if line.is_empty() { let pos = Pos { row: self.row, col: 0, }; self.row += 1; self.col = 0; return Some((pos, CharClass::Whitespace)); } if self.col >= line.len() { self.row += 1; self.col = 0; self.at_boundary = self.row < self.lines.len(); return self.next(); } let pos = self.get_pos(); let class = line[self.col].class(); self.col += 1; if self.col >= line.len() { self.row += 1; self.col = 0; self.at_boundary = self.row < self.lines.len(); } Some((pos, class)) } } struct CharClassIterRev<'a> { lines: &'a [Line], row: usize, col: usize, exhausted: bool, at_boundary: bool, } impl<'a> CharClassIterRev<'a> { pub fn new(lines: &'a [Line], start_pos: Pos) -> Self { let row = start_pos.row.min(lines.len().saturating_sub(1)); let col = if lines.is_empty() || lines[row].is_empty() { 0 } else { start_pos.col.min(lines[row].len().saturating_sub(1)) }; Self { lines, row, col, exhausted: false, at_boundary: false, } } fn get_pos(&self) -> Pos { Pos { row: self.row, col: self.col, } } } impl<'a> Iterator for CharClassIterRev<'a> { type Item = (Pos, CharClass); fn next(&mut self) -> Option<(Pos, CharClass)> { if self.exhausted { return None; } // Synthetic whitespace for line boundary if self.at_boundary { self.at_boundary = false; let pos = self.get_pos(); return Some((pos, CharClass::Whitespace)); } if self.row >= self.lines.len() { self.exhausted = true; return None; } let line = &self.lines[self.row]; // Empty line = whitespace if line.is_empty() { let pos = Pos { row: self.row, col: 0, }; if self.row == 0 { self.exhausted = true; } else { self.row -= 1; self.col = self.lines[self.row].len().saturating_sub(1); } return Some((pos, CharClass::Whitespace)); } let pos = self.get_pos(); let class = line[self.col].class(); if self.col == 0 { if self.row == 0 { self.exhausted = true; } else { self.row -= 1; self.col = self.lines[self.row].len().saturating_sub(1); self.at_boundary = true; } } else { self.col -= 1; } Some((pos, class)) } } /// Rotate alphabetic characters by 13 alphabetic positions pub fn rot13(input: &str) -> String { input .chars() .map(|c| { if c.is_ascii_lowercase() { let offset = b'a'; (((c as u8 - offset + 13) % 26) + offset) as char } else if c.is_ascii_uppercase() { let offset = b'A'; (((c as u8 - offset + 13) % 26) + offset) as char } else { c } }) .collect() } pub fn rot13_char(c: char) -> char { let offset = if c.is_ascii_lowercase() { b'a' } else if c.is_ascii_uppercase() { b'A' } else { return c; }; (((c as u8 - offset + 13) % 26) + offset) as char } pub fn toggle_case_char(c: char) -> char { if c.is_ascii_lowercase() { c.to_ascii_uppercase() } else if c.is_ascii_uppercase() { c.to_ascii_lowercase() } else { c } } pub fn ordered(start: T, end: T) -> (T, T) { if start > end { (end, start) } else { (start, end) } }