Improved logical accuracy of Ctrl+W in insert mode

Moved test libraries to dev-dependencies

Implemented some more motion types

Implemented ToLower, ToUpper, JoinLines, Indent, Undo, and Redo verbs

'O' and 'o' operators now behave correctly

Added many more unit tests for the readline module
This commit is contained in:
2025-06-07 23:45:51 -04:00
parent 3cfc49d638
commit 80eb8d278a
7 changed files with 726 additions and 257 deletions

View File

@@ -13,13 +13,15 @@ debug = true
bitflags = "2.8.0"
clap = { version = "4.5.38", features = ["derive"] }
glob = "0.3.2"
insta = "1.42.2"
nix = { version = "0.29.0", features = ["uio", "term", "user", "hostname", "fs", "default", "signal", "process", "event", "ioctl", "poll"] }
pretty_assertions = "1.4.1"
regex = "1.11.1"
unicode-segmentation = "1.12.0"
unicode-width = "0.2.0"
[dev-dependencies]
insta = "1.42.2"
pretty_assertions = "1.4.1"
[[bin]]
name = "fern"
path = "src/fern.rs"

View File

@@ -4,10 +4,11 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use super::{term::Layout, vicmd::{Anchor, Dest, Direction, Motion, MotionBehavior, MotionCmd, RegisterName, To, Verb, ViCmd, Word}};
use crate::{libsh::error::ShResult, prelude::*};
use crate::{libsh::error::{ShErr, ShErrKind, ShResult}, prelude::*};
#[derive(PartialEq,Eq,Debug,Clone,Copy)]
#[derive(Default,PartialEq,Eq,Debug,Clone,Copy)]
pub enum CharClass {
#[default]
Alphanum,
Symbol,
Whitespace,
@@ -40,6 +41,14 @@ impl From<&str> for CharClass {
}
}
impl From<char> for CharClass {
fn from(value: char) -> Self {
let mut buf = [0u8; 4]; // max UTF-8 char size
let slice = value.encode_utf8(&mut buf); // get str slice
CharClass::from(slice as &str)
}
}
fn is_whitespace(a: &str) -> bool {
CharClass::from(a) == CharClass::Whitespace
}
@@ -98,6 +107,9 @@ pub enum MotionKind {
Null
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MotionRange {}
impl MotionKind {
pub fn inclusive(range: RangeInclusive<usize>) -> Self {
Self::Inclusive((*range.start(),*range.end()))
@@ -265,6 +277,7 @@ pub struct LineBuf {
pub select_range: Option<(usize,usize)>,
pub last_selection: Option<(usize,usize)>,
pub insert_mode_start_pos: Option<usize>,
pub saved_col: Option<usize>,
pub undo_stack: Vec<Edit>,
@@ -337,6 +350,12 @@ impl LineBuf {
pub fn grapheme_at_cursor(&mut self) -> Option<&str> {
self.grapheme_at(self.cursor.get())
}
pub fn mark_insert_mode_start_pos(&mut self) {
self.insert_mode_start_pos = Some(self.cursor.get())
}
pub fn clear_insert_mode_start_pos(&mut self) {
self.insert_mode_start_pos = None
}
pub fn slice(&mut self, range: Range<usize>) -> Option<&str> {
self.update_graphemes_lazy();
let start_index = self.grapheme_indices().get(range.start).copied()?;
@@ -380,9 +399,17 @@ impl LineBuf {
pub fn slice_to_cursor(&mut self) -> Option<&str> {
self.slice_to(self.cursor.get())
}
pub fn slice_to_cursor_inclusive(&mut self) -> Option<&str> {
self.slice_to(self.cursor.ret_add(1))
}
pub fn slice_from_cursor(&mut self) -> Option<&str> {
self.slice_from(self.cursor.get())
}
pub fn remove(&mut self, pos: usize) {
let idx = self.index_byte_pos(pos);
self.buffer.remove(idx);
self.update_graphemes();
}
pub fn drain(&mut self, start: usize, end: usize) -> String {
let drained = if end == self.grapheme_indices().len() {
if start == self.grapheme_indices().len() {
@@ -588,7 +615,26 @@ impl LineBuf {
To::Start => {
match dir {
Direction::Forward => self.start_of_word_forward_or_end_of_word_backward_from(pos.get(), word, dir),
Direction::Backward => self.end_of_word_forward_or_start_of_word_backward_from(pos.get(), word, dir)
Direction::Backward => 'backward: {
// We also need to handle insert mode's Ctrl+W behaviors here
let target = self.end_of_word_forward_or_start_of_word_backward_from(pos.get(), word, dir);
// Check to see if we are in insert mode
let Some(start_pos) = self.insert_mode_start_pos else {
break 'backward target
};
// If we are in front of start_pos, and we would cross start_pos to reach target
// then stop at start_pos
if start_pos > target && self.cursor.get() > start_pos {
return start_pos
} else {
// We are behind start_pos, now we just reset it
if self.cursor.get() < start_pos {
self.clear_insert_mode_start_pos();
}
break 'backward target
}
}
}
}
To::End => {
@@ -865,6 +911,15 @@ impl LineBuf {
pub fn replace_at_cursor(&mut self, new: &str) {
self.replace_at(self.cursor.get(), new);
}
pub fn force_replace_at(&mut self, pos: usize, new: &str) {
let Some(gr) = self.grapheme_at(pos).map(|gr| gr.to_string()) else {
self.buffer.push_str(new);
return
};
let start = self.index_byte_pos(pos);
let end = start + gr.len();
self.buffer.replace_range(start..end, new);
}
pub fn replace_at(&mut self, pos: usize, new: &str) {
let Some(gr) = self.grapheme_at(pos).map(|gr| gr.to_string()) else {
self.buffer.push_str(new);
@@ -1112,10 +1167,10 @@ impl LineBuf {
MotionCmd(count,Motion::FirstGraphicalOnScreenLine) => todo!(),
MotionCmd(count,Motion::HalfOfScreen) => todo!(),
MotionCmd(count,Motion::HalfOfScreenLineText) => todo!(),
MotionCmd(count,Motion::WholeBuffer) => todo!(),
MotionCmd(count,Motion::BeginningOfBuffer) => todo!(),
MotionCmd(count,Motion::EndOfBuffer) => todo!(),
MotionCmd(count,Motion::ToColumn(col)) => todo!(),
MotionCmd(_count,Motion::WholeBuffer) => MotionKind::Exclusive((0,self.grapheme_indices().len())),
MotionCmd(_count,Motion::BeginningOfBuffer) => MotionKind::On(0),
MotionCmd(_count,Motion::EndOfBuffer) => MotionKind::To(self.grapheme_indices().len()),
MotionCmd(_count,Motion::ToColumn) => todo!(),
MotionCmd(count,Motion::ToDelimMatch) => todo!(),
MotionCmd(count,Motion::ToBrace(direction)) => todo!(),
MotionCmd(count,Motion::ToBracket(direction)) => todo!(),
@@ -1233,6 +1288,7 @@ impl LineBuf {
};
Some(range)
}
#[allow(clippy::unnecessary_to_owned)]
pub fn exec_verb(&mut self, verb: Verb, motion: MotionKind, register: RegisterName) -> ShResult<()> {
match verb {
Verb::Delete |
@@ -1318,16 +1374,101 @@ impl LineBuf {
self.replace_at(i,new);
}
}
Verb::ToLower => todo!(),
Verb::ToUpper => todo!(),
Verb::Complete => todo!(),
Verb::CompleteBackward => todo!(),
Verb::Undo => todo!(),
Verb::Redo => todo!(),
Verb::ToLower => {
let Some((start,end)) = self.range_from_motion(&motion) else {
return Ok(())
};
for i in start..end {
let Some(gr) = self.grapheme_at(i) else {
continue
};
if gr.len() > 1 || gr.is_empty() {
continue
}
let ch = gr.chars().next().unwrap();
if !ch.is_alphabetic() {
continue
}
let mut buf = [0u8;4];
let new = if ch.is_ascii_uppercase() {
ch.to_ascii_lowercase().encode_utf8(&mut buf)
} else {
ch.encode_utf8(&mut buf)
};
self.replace_at(i,new);
}
}
Verb::ToUpper => {
let Some((start,end)) = self.range_from_motion(&motion) else {
return Ok(())
};
for i in start..end {
let Some(gr) = self.grapheme_at(i) else {
continue
};
if gr.len() > 1 || gr.is_empty() {
continue
}
let ch = gr.chars().next().unwrap();
if !ch.is_alphabetic() {
continue
}
let mut buf = [0u8;4];
let new = if ch.is_ascii_lowercase() {
ch.to_ascii_uppercase().encode_utf8(&mut buf)
} else {
ch.encode_utf8(&mut buf)
};
self.replace_at(i,new);
}
}
Verb::Redo |
Verb::Undo => {
let (edit_provider,edit_receiver) = match verb {
Verb::Redo => (&mut self.redo_stack, &mut self.undo_stack),
Verb::Undo => (&mut self.undo_stack, &mut self.redo_stack),
_ => unreachable!()
};
let Some(edit) = edit_provider.pop() else { return Ok(()) };
let Edit { pos, cursor_pos, old, new, merging: _ } = edit;
self.buffer.replace_range(pos..pos + new.len(), &old);
let new_cursor_pos = self.cursor.get();
let in_insert_mode = !self.cursor.exclusive;
if in_insert_mode {
self.cursor.set(cursor_pos)
}
let new_edit = Edit { pos, cursor_pos: new_cursor_pos, old: new, new: old, merging: false };
edit_receiver.push(new_edit);
self.update_graphemes();
}
Verb::RepeatLast => todo!(),
Verb::Put(anchor) => todo!(),
Verb::SwapVisualAnchor => todo!(),
Verb::JoinLines => todo!(),
Verb::JoinLines => {
let start = self.start_of_line();
let Some((_,mut end)) = self.nth_next_line(1) else {
return Ok(())
};
end = end.saturating_sub(1); // exclude the last newline
let mut last_was_whitespace = false;
for i in start..end {
let Some(gr) = self.grapheme_at(i) else {
continue
};
if gr == "\n" {
if last_was_whitespace {
self.remove(i);
} else {
self.force_replace_at(i, " ");
}
last_was_whitespace = false;
continue
}
last_was_whitespace = is_whitespace(gr);
}
}
Verb::InsertChar(ch) => {
self.insert_at_cursor(ch);
self.cursor.add(1);
@@ -1338,19 +1479,56 @@ impl LineBuf {
self.cursor.add(graphemes);
}
Verb::Breakline(anchor) => todo!(),
Verb::Indent => todo!(),
Verb::Dedent => todo!(),
Verb::Equalize => todo!(),
Verb::InsertModeLineBreak(anchor) => {
let end = self.end_of_line();
self.insert_at(end,'\n');
self.cursor.set(end);
match anchor {
Anchor::After => self.cursor.add(2),
Anchor::Before => { /* Do nothing */ }
Verb::Indent => {
let Some((start,end)) = self.range_from_motion(&motion) else {
return Ok(())
};
self.insert_at(start, '\t');
let mut range_indices = self.grapheme_indices()[start..end].to_vec().into_iter();
while let Some(idx) = range_indices.next() {
let gr = self.grapheme_at(idx).unwrap();
if gr == "\n" {
let Some(idx) = range_indices.next() else {
self.push('\t');
break
};
self.insert_at(idx, '\t');
}
}
match motion {
MotionKind::ExclusiveWithTargetCol((_,_),pos) |
MotionKind::InclusiveWithTargetCol((_,_),pos) => {
self.cursor.set(start);
let end = self.end_of_line();
self.cursor.add(end.min(pos));
}
_ => self.cursor.set(start),
}
}
Verb::Dedent => {
let (start,end) = self.this_line();
}
Verb::Equalize => todo!(),
Verb::InsertModeLineBreak(anchor) => {
let (mut start,end) = self.this_line();
// We want the position of the newline, or start of buffer
start = start.saturating_sub(1).min(self.cursor.max);
match anchor {
Anchor::After => {
self.cursor.set(end);
self.insert_at_cursor('\n');
}
Anchor::Before => {
self.cursor.set(start);
self.insert_at_cursor('\n');
self.cursor.add(1);
}
}
}
Verb::Complete |
Verb::EndOfFile |
Verb::InsertMode |
Verb::NormalMode |
@@ -1358,6 +1536,7 @@ impl LineBuf {
Verb::ReplaceMode |
Verb::VisualModeLine |
Verb::VisualModeBlock |
Verb::CompleteBackward |
Verb::AcceptLineOrNewline |
Verb::VisualModeSelectLast => self.apply_motion(motion), // Already handled logic for these
}

View File

@@ -1,11 +1,11 @@
use keys::{KeyCode, KeyEvent, ModKeys};
use linebuf::{LineBuf, SelectAnchor, SelectMode};
use nix::libc::STDOUT_FILENO;
use term::{Layout, LineWriter, TermReader};
use term::{get_win_size, raw_mode, KeyReader, Layout, LineWriter, TermReader, TermWriter};
use vicmd::{Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd};
use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual};
use crate::libsh::{error::ShResult, sys::sh_quit, term::{Style, Styled}};
use crate::libsh::{error::{ShErr, ShErrKind, ShResult}, sys::sh_quit, term::{Style, Styled}};
use crate::prelude::*;
pub mod term;
@@ -21,20 +21,19 @@ pub trait Readline {
}
pub struct FernVi {
reader: TermReader,
writer: LineWriter,
prompt: String,
mode: Box<dyn ViMode>,
old_layout: Option<Layout>,
repeat_action: Option<CmdReplay>,
repeat_motion: Option<MotionCmd>,
editor: LineBuf
pub reader: Box<dyn KeyReader>,
pub writer: Box<dyn LineWriter>,
pub prompt: String,
pub mode: Box<dyn ViMode>,
pub old_layout: Option<Layout>,
pub repeat_action: Option<CmdReplay>,
pub repeat_motion: Option<MotionCmd>,
pub editor: LineBuf
}
impl Readline for FernVi {
fn readline(&mut self) -> ShResult<String> {
self.editor = LineBuf::new().with_initial("\nThe quick brown fox jumps over\n the lazy dogThe quick\nbrown fox jumps over the a", 1004);
let raw_mode_guard = self.reader.raw_mode(); // Restores termios state on drop
let raw_mode_guard = raw_mode(); // Restores termios state on drop
loop {
let new_layout = self.get_layout();
@@ -42,7 +41,11 @@ impl Readline for FernVi {
self.writer.clear_rows(layout)?;
}
raw_mode_guard.disable_for(|| self.print_line(new_layout))?;
let key = self.reader.read_key()?;
let Some(key) = self.reader.read_key() else {
raw_mode_guard.disable_for(|| self.writer.flush_write("\n"))?;
std::mem::drop(raw_mode_guard);
return Err(ShErr::simple(ShErrKind::ReadlineErr, "EOF"))
};
flog!(DEBUG, key);
let Some(mut cmd) = self.mode.handle_key(key) else {
@@ -52,6 +55,7 @@ impl Readline for FernVi {
if cmd.should_submit() {
raw_mode_guard.disable_for(|| self.writer.flush_write("\n"))?;
std::mem::drop(raw_mode_guard);
return Ok(std::mem::take(&mut self.editor.buffer))
}
@@ -81,21 +85,28 @@ impl Default for FernVi {
impl FernVi {
pub fn new(prompt: Option<String>) -> Self {
Self {
reader: TermReader::new(),
writer: LineWriter::new(STDOUT_FILENO),
reader: Box::new(TermReader::new()),
writer: Box::new(TermWriter::new(STDOUT_FILENO)),
prompt: prompt.unwrap_or("$ ".styled(Style::Green)),
mode: Box::new(ViInsert::new()),
old_layout: None,
repeat_action: None,
repeat_motion: None,
editor: LineBuf::new()
editor: LineBuf::new().with_initial("\nThe quick brown fox jumps over\n the lazy dogThe quick\nbrown fox jumps over the a", 1004)
}
}
pub fn get_layout(&mut self) -> Layout {
let line = self.editor.as_str().to_string();
let to_cursor = self.editor.slice_to_cursor().unwrap_or_default();
self.writer.get_layout_from_parts(&self.prompt, to_cursor, &line)
let (cols,_) = get_win_size(STDIN_FILENO);
Layout::from_parts(
/*tab_stop:*/ 8,
cols,
&self.prompt,
to_cursor,
&line
)
}
pub fn print_line(&mut self, new_layout: Layout) -> ShResult<()> {
@@ -114,14 +125,20 @@ impl FernVi {
pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> {
let mut selecting = false;
let mut is_insert_mode = false;
if cmd.is_mode_transition() {
let count = cmd.verb_count();
let mut mode: Box<dyn ViMode> = match cmd.verb().unwrap().1 {
Verb::Change |
Verb::InsertModeLineBreak(_) |
Verb::InsertMode => Box::new(ViInsert::new().with_count(count as u16)),
Verb::InsertMode => {
is_insert_mode = true;
Box::new(ViInsert::new().with_count(count as u16))
}
Verb::NormalMode => Box::new(ViNormal::new()),
Verb::NormalMode => {
Box::new(ViNormal::new())
}
Verb::ReplaceMode => Box::new(ViReplace::new()),
@@ -157,6 +174,11 @@ impl FernVi {
} else {
self.editor.stop_selecting();
}
if is_insert_mode {
self.editor.mark_insert_mode_start_pos();
} else {
self.editor.clear_insert_mode_start_pos();
}
return Ok(())
} else if cmd.is_cmd_repeat() {
let Some(replay) = self.repeat_action.clone() else {

View File

@@ -1,4 +1,4 @@
use std::{env, fmt::{Debug, Write}, io::{BufRead, BufReader, Read}, os::fd::{AsFd, BorrowedFd, RawFd}};
use std::{env, fmt::{Debug, Write}, io::{BufRead, BufReader, Read}, iter::Peekable, os::fd::{AsFd, BorrowedFd, RawFd}, str::Chars};
use nix::{errno::Errno, libc::{self, STDIN_FILENO}, poll::{self, PollFlags, PollTimeout}, sys::termios, unistd::isatty};
use unicode_segmentation::UnicodeSegmentation;
@@ -9,6 +9,15 @@ use crate::prelude::*;
use super::{keys::KeyEvent, linebuf::LineBuf};
pub fn raw_mode() -> RawModeGuard {
let orig = termios::tcgetattr(unsafe{BorrowedFd::borrow_raw(STDIN_FILENO)}).expect("Failed to get terminal attributes");
let mut raw = orig.clone();
termios::cfmakeraw(&mut raw);
termios::tcsetattr(unsafe{BorrowedFd::borrow_raw(STDIN_FILENO)}, termios::SetArg::TCSANOW, &raw)
.expect("Failed to set terminal to raw mode");
RawModeGuard { orig, fd: STDIN_FILENO }
}
pub type Row = u16;
pub type Col = u16;
@@ -135,6 +144,21 @@ pub trait WidthCalculator {
fn width(&self, text: &str) -> usize;
}
pub trait KeyReader {
fn read_key(&mut self) -> Option<KeyEvent>;
}
pub trait LineWriter {
fn clear_rows(&mut self, layout: &Layout) -> ShResult<()>;
fn redraw(
&mut self,
prompt: &str,
line: &LineBuf,
new_layout: &Layout,
) -> ShResult<()>;
fn flush_write(&mut self, buf: &str) -> ShResult<()>;
}
#[derive(Clone,Copy,Debug)]
pub struct UnicodeWidth;
@@ -256,15 +280,6 @@ impl TermReader {
}
}
pub fn raw_mode(&self) -> RawModeGuard {
let fd = self.buffer.get_ref().tty;
let orig = termios::tcgetattr(unsafe{BorrowedFd::borrow_raw(STDIN_FILENO)}).expect("Failed to get terminal attributes");
let mut raw = orig.clone();
termios::cfmakeraw(&mut raw);
termios::tcsetattr(unsafe{BorrowedFd::borrow_raw(STDIN_FILENO)}, termios::SetArg::TCSANOW, &raw)
.expect("Failed to set terminal to raw mode");
RawModeGuard { orig, fd }
}
/// Execute some logic in raw mode
///
@@ -303,33 +318,7 @@ impl TermReader {
self.buffer.consume(1);
}
pub fn read_key(&mut self) -> ShResult<KeyEvent> {
use core::str;
let mut collected = Vec::with_capacity(4);
loop {
let byte = self.next_byte()?;
collected.push(byte);
// If it's an escape seq, delegate to ESC sequence handler
if collected[0] == 0x1b && collected.len() == 1 && self.poll(PollTimeout::ZERO)? {
return self.parse_esc_seq();
}
// Try parse as valid UTF-8
if let Ok(s) = str::from_utf8(&collected) {
return Ok(KeyEvent::new(s, ModKeys::empty()));
}
// UTF-8 max 4 bytes — if its invalid at this point, bail
if collected.len() >= 4 {
break;
}
}
Ok(KeyEvent(KeyCode::Null, ModKeys::empty()))
}
pub fn parse_esc_seq(&mut self) -> ShResult<KeyEvent> {
let mut seq = vec![0x1b];
@@ -409,6 +398,38 @@ impl TermReader {
_ => Ok(KeyEvent(KeyCode::Esc, ModKeys::empty())),
}
}
}
impl KeyReader for TermReader {
fn read_key(&mut self) -> Option<KeyEvent> {
use core::str;
let mut collected = Vec::with_capacity(4);
loop {
let byte = self.next_byte().ok()?;
flog!(DEBUG, "read byte: {:?}",byte as char);
collected.push(byte);
// If it's an escape seq, delegate to ESC sequence handler
if collected[0] == 0x1b && collected.len() == 1 && self.poll(PollTimeout::ZERO).ok()? {
return self.parse_esc_seq().ok();
}
// Try parse as valid UTF-8
if let Ok(s) = str::from_utf8(&collected) {
return Some(KeyEvent::new(s, ModKeys::empty()));
}
// UTF-8 max 4 bytes — if its invalid at this point, bail
if collected.len() >= 4 {
break;
}
}
None
}
}
impl AsFd for TermReader {
@@ -444,6 +465,46 @@ impl Layout {
end: Pos::default(),
}
}
pub fn from_parts(
tab_stop: u16,
term_width: u16,
prompt: &str,
to_cursor: &str,
to_end: &str,
) -> Self {
flog!(DEBUG,to_cursor);
let prompt_end = Self::calc_pos(tab_stop, term_width, prompt, Pos { col: 0, row: 0 });
let cursor = Self::calc_pos(tab_stop, term_width, to_cursor, prompt_end);
let end = Self::calc_pos(tab_stop, term_width, to_end, prompt_end);
Layout { w_calc: width_calculator(), prompt_end, cursor, end }
}
pub fn calc_pos(tab_stop: u16, term_width: u16, s: &str, orig: Pos) -> Pos {
let mut pos = orig;
let mut esc_seq = 0;
for c in s.graphemes(true) {
if c == "\n" {
pos.row += 1;
pos.col = 0;
}
let c_width = if c == "\t" {
tab_stop - (pos.col % tab_stop)
} else {
width(c, &mut esc_seq)
};
pos.col += c_width;
if pos.col > term_width {
pos.row += 1;
pos.col = c_width;
}
}
if pos.col >= term_width {
pos.row += 1;
pos.col = 0;
}
pos
}
}
impl Default for Layout {
@@ -452,7 +513,7 @@ impl Default for Layout {
}
}
pub struct LineWriter {
pub struct TermWriter {
out: RawFd,
t_cols: Col, // terminal width
buffer: String,
@@ -460,7 +521,7 @@ pub struct LineWriter {
tab_stop: u16,
}
impl LineWriter {
impl TermWriter {
pub fn new(out: RawFd) -> Self {
let w_calc = width_calculator();
let (t_cols,_) = get_win_size(out);
@@ -472,28 +533,6 @@ impl LineWriter {
tab_stop: 8 // TODO: add a way to configure this
}
}
pub fn flush_write(&mut self, buf: &str) -> ShResult<()> {
write_all(self.out, buf)?;
Ok(())
}
pub fn clear_rows(&mut self, layout: &Layout) -> ShResult<()> {
self.buffer.clear();
let rows_to_clear = layout.end.row;
let cursor_row = layout.cursor.row;
let cursor_motion = rows_to_clear.saturating_sub(cursor_row);
if cursor_motion > 0 {
write!(self.buffer, "\x1b[{cursor_motion}B").unwrap()
}
for _ in 0..rows_to_clear {
self.buffer.push_str("\x1b[2K\x1b[A");
}
self.buffer.push_str("\x1b[2K");
write_all(self.out,self.buffer.as_str())?;
self.buffer.clear();
Ok(())
}
pub fn get_cursor_movement(&self, old: Pos, new: Pos) -> ShResult<String> {
let mut buffer = String::new();
let err = |_| ShErr::simple(ShErrKind::InternalErr, "Failed to write to cursor movement buffer");
@@ -543,67 +582,7 @@ impl LineWriter {
Ok(())
}
pub fn redraw(
&mut self,
prompt: &str,
line: &LineBuf,
new_layout: &Layout,
) -> ShResult<()> {
let err = |_| ShErr::simple(ShErrKind::InternalErr, "Failed to write to LineWriter internal buffer");
self.buffer.clear();
let end = new_layout.end;
let cursor = new_layout.cursor;
self.buffer.push_str(prompt);
self.buffer.push_str(line.as_str());
if end.col == 0 && end.row > 0 && !self.buffer.ends_with('\n') {
// The line has wrapped. We need to use our own line break.
self.buffer.push('\n');
}
let movement = self.get_cursor_movement(end, cursor)?;
write!(self.buffer, "{}", &movement).map_err(err)?;
write_all(self.out, self.buffer.as_str())?;
Ok(())
}
pub fn get_layout_from_parts(&mut self, prompt: &str, to_cursor: &str, to_end: &str) -> Layout {
self.update_t_cols();
let prompt_end = self.calc_pos(prompt, Pos { col: 0, row: 0 });
let cursor = self.calc_pos(to_cursor, prompt_end);
let end = self.calc_pos(to_end, prompt_end);
Layout { w_calc: width_calculator(), prompt_end, cursor, end }
}
pub fn calc_pos(&self, s: &str, orig: Pos) -> Pos {
let mut pos = orig;
let mut esc_seq = 0;
for c in s.graphemes(true) {
if c == "\n" {
pos.row += 1;
pos.col = 0;
}
let c_width = if c == "\t" {
self.tab_stop - (pos.col % self.tab_stop)
} else {
width(c, &mut esc_seq)
};
pos.col += c_width;
if pos.col > self.t_cols {
pos.row += 1;
pos.col = c_width;
}
}
if pos.col >= self.t_cols {
pos.row += 1;
pos.col = 0;
}
pos
}
pub fn update_t_cols(&mut self) {
let (t_cols,_) = get_win_size(self.out);
@@ -656,3 +635,56 @@ impl LineWriter {
Ok(())
}
}
impl LineWriter for TermWriter {
fn clear_rows(&mut self, layout: &Layout) -> ShResult<()> {
self.buffer.clear();
let rows_to_clear = layout.end.row;
let cursor_row = layout.cursor.row;
let cursor_motion = rows_to_clear.saturating_sub(cursor_row);
if cursor_motion > 0 {
write!(self.buffer, "\x1b[{cursor_motion}B").unwrap()
}
for _ in 0..rows_to_clear {
self.buffer.push_str("\x1b[2K\x1b[A");
}
self.buffer.push_str("\x1b[2K");
write_all(self.out,self.buffer.as_str())?;
self.buffer.clear();
Ok(())
}
fn redraw(
&mut self,
prompt: &str,
line: &LineBuf,
new_layout: &Layout,
) -> ShResult<()> {
let err = |_| ShErr::simple(ShErrKind::InternalErr, "Failed to write to LineWriter internal buffer");
self.buffer.clear();
let end = new_layout.end;
let cursor = new_layout.cursor;
self.buffer.push_str(prompt);
self.buffer.push_str(line.as_str());
if end.col == 0 && end.row > 0 && !self.buffer.ends_with('\n') {
// The line has wrapped. We need to use our own line break.
self.buffer.push('\n');
}
let movement = self.get_cursor_movement(end, cursor)?;
write!(self.buffer, "{}", &movement).map_err(err)?;
write_all(self.out, self.buffer.as_str())?;
Ok(())
}
fn flush_write(&mut self, buf: &str) -> ShResult<()> {
write_all(self.out, buf)?;
Ok(())
}
}

View File

@@ -279,7 +279,7 @@ pub enum Motion {
WholeBuffer,
BeginningOfBuffer,
EndOfBuffer,
ToColumn(usize),
ToColumn,
ToDelimMatch,
ToBrace(Direction),
ToBracket(Direction),
@@ -317,7 +317,7 @@ impl Motion {
Self::LineUpCharwise |
Self::ScreenLineUpCharwise |
Self::ScreenLineDownCharwise |
Self::ToColumn(_) |
Self::ToColumn |
Self::TextObj(TextObj::ForwardSentence,_) |
Self::TextObj(TextObj::BackwardSentence,_) |
Self::TextObj(TextObj::ForwardParagraph,_) |

View File

@@ -5,6 +5,7 @@ use nix::NixPath;
use unicode_segmentation::UnicodeSegmentation;
use super::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
use super::linebuf::CharClass;
use super::vicmd::{Anchor, Bound, Dest, Direction, Motion, MotionCmd, RegisterName, TextObj, To, Verb, VerbCmd, ViCmd, Word};
use crate::prelude::*;
@@ -111,16 +112,10 @@ impl ViMode for ViInsert {
self.register_and_return()
}
E(K::Char('W'), M::CTRL) => {
if self.ctrl_w_is_undo() {
self.pending_cmd.set_verb(VerbCmd(1,Verb::Undo));
self.cmds.clear();
Some(self.take_cmd())
} else {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
self.pending_cmd.set_motion(MotionCmd(1, Motion::WordMotion(To::Start, Word::Normal, Direction::Backward)));
self.register_and_return()
}
}
E(K::Char('H'), M::CTRL) |
E(K::Backspace, M::NONE) => {
self.pending_cmd.set_verb(VerbCmd(1,Verb::Delete));
@@ -297,6 +292,7 @@ impl ViNormal {
pub fn take_cmd(&mut self) -> String {
std::mem::take(&mut self.pending_seq)
}
#[allow(clippy::unnecessary_unwrap)]
fn validate_combination(&self, verb: Option<&Verb>, motion: Option<&Motion>) -> CmdState {
if verb.is_none() {
match motion {
@@ -663,7 +659,9 @@ impl ViNormal {
let Some(ch) = chars_clone.next() else {
break 'motion_parse None
};
// Double inputs like 'dd' and 'cc', and some special cases
match (ch, &verb) {
// Double inputs
('?', Some(VerbCmd(_,Verb::Rot13))) |
('d', Some(VerbCmd(_,Verb::Delete))) |
('c', Some(VerbCmd(_,Verb::Change))) |
@@ -674,7 +672,20 @@ impl ViNormal {
('~', Some(VerbCmd(_,Verb::ToggleCaseRange))) |
('>', Some(VerbCmd(_,Verb::Indent))) |
('<', Some(VerbCmd(_,Verb::Dedent))) => break 'motion_parse Some(MotionCmd(count, Motion::WholeLine)),
_ => {}
// Exception cases
('w', Some(VerbCmd(_, Verb::Change))) => {
// 'w' usually means 'to the start of the next word'
// e.g. 'dw' deleted to the start of the next word
// however, 'cw' only changes to the end of the current word
// 'caw' is used to change to the start of the next word
break 'motion_parse Some(MotionCmd(count, Motion::WordMotion(To::End, Word::Normal, Direction::Forward)));
}
('W', Some(VerbCmd(_, Verb::Change))) => {
// Same with 'W'
break 'motion_parse Some(MotionCmd(count, Motion::WordMotion(To::End, Word::Big, Direction::Forward)));
}
_ => { /* Nothing weird, so let's continue */ }
}
match ch {
'g' => {
@@ -761,7 +772,7 @@ impl ViNormal {
}
'|' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(1, Motion::ToColumn(count)));
break 'motion_parse Some(MotionCmd(count, Motion::ToColumn));
}
'^' => {
chars = chars_clone;
@@ -956,6 +967,8 @@ impl ViVisual {
pub fn take_cmd(&mut self) -> String {
std::mem::take(&mut self.pending_seq)
}
#[allow(clippy::unnecessary_unwrap)]
fn validate_combination(&self, verb: Option<&Verb>, motion: Option<&Motion>) -> CmdState {
if verb.is_none() {
match motion {
@@ -964,7 +977,7 @@ impl ViVisual {
None => return CmdState::Pending
}
}
if verb.is_some() && motion.is_none() {
if motion.is_none() && verb.is_some() {
match verb.unwrap() {
Verb::Put(_) => CmdState::Complete,
_ => CmdState::Pending
@@ -1307,28 +1320,28 @@ impl ViVisual {
break 'motion_parse None
};
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Forward, Dest::On, (*ch).into())))
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Forward, Dest::On, *ch)))
}
'F' => {
let Some(ch) = chars_clone.peek() else {
break 'motion_parse None
};
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Backward, Dest::On, (*ch).into())))
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Backward, Dest::On, *ch)))
}
't' => {
let Some(ch) = chars_clone.peek() else {
break 'motion_parse None
};
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Forward, Dest::Before, (*ch).into())))
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Forward, Dest::Before, *ch)))
}
'T' => {
let Some(ch) = chars_clone.peek() else {
break 'motion_parse None
};
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Backward, Dest::Before, (*ch).into())))
break 'motion_parse Some(MotionCmd(count, Motion::CharSearch(Direction::Backward, Dest::Before, *ch)))
}
';' => {
chars = chars_clone;
@@ -1340,7 +1353,7 @@ impl ViVisual {
}
'|' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(1, Motion::ToColumn(count)));
break 'motion_parse Some(MotionCmd(count, Motion::ToColumn));
}
'0' => {
chars = chars_clone;
@@ -1427,15 +1440,14 @@ impl ViVisual {
match self.validate_combination(verb_ref, motion_ref) {
CmdState::Complete => {
let cmd = Some(
Some(
ViCmd {
register,
verb,
motion,
raw_seq: std::mem::take(&mut self.pending_seq)
}
);
cmd
)
}
CmdState::Pending => {
None

View File

@@ -1,15 +1,196 @@
use crate::prompt::readline::{linebuf::LineBuf, vimode::{ViInsert, ViMode, ViNormal}};
use std::collections::VecDeque;
use crate::{libsh::term::{Style, Styled}, prompt::readline::{keys::{KeyCode, KeyEvent, ModKeys}, linebuf::LineBuf, term::{raw_mode, KeyReader, LineWriter}, vimode::{ViInsert, ViMode, ViNormal}, FernVi, Readline}};
use pretty_assertions::assert_eq;
use super::super::*;
fn normal_cmd(cmd: &str, buf: &str, cursor: usize, expected_buf: &str, expected_cursor: usize) -> bool {
#[derive(Default,Debug)]
struct TestReader {
pub bytes: VecDeque<u8>
}
impl TestReader {
pub fn new() -> Self {
Self::default()
}
pub fn with_initial(mut self, bytes: &[u8]) -> Self {
let bytes = bytes.iter();
self.bytes.extend(bytes);
self
}
pub fn parse_esc_seq_from_bytes(&mut self) -> Option<KeyEvent> {
let mut seq = vec![0x1b];
let b1 = self.bytes.pop_front()?;
seq.push(b1);
match b1 {
b'[' => {
let b2 = self.bytes.pop_front()?;
seq.push(b2);
match b2 {
b'A' => Some(KeyEvent(KeyCode::Up, ModKeys::empty())),
b'B' => Some(KeyEvent(KeyCode::Down, ModKeys::empty())),
b'C' => Some(KeyEvent(KeyCode::Right, ModKeys::empty())),
b'D' => Some(KeyEvent(KeyCode::Left, ModKeys::empty())),
b'1'..=b'9' => {
let mut digits = vec![b2];
while let Some(&b) = self.bytes.front() {
seq.push(b);
self.bytes.pop_front();
if b == b'~' || b == b';' {
break;
} else if b.is_ascii_digit() {
digits.push(b);
} else {
break;
}
}
let key = match digits.as_slice() {
[b'1'] => KeyCode::Home,
[b'3'] => KeyCode::Delete,
[b'4'] => KeyCode::End,
[b'5'] => KeyCode::PageUp,
[b'6'] => KeyCode::PageDown,
[b'7'] => KeyCode::Home, // xterm alternate
[b'8'] => KeyCode::End, // xterm alternate
[b'1', b'5'] => KeyCode::F(5),
[b'1', b'7'] => KeyCode::F(6),
[b'1', b'8'] => KeyCode::F(7),
[b'1', b'9'] => KeyCode::F(8),
[b'2', b'0'] => KeyCode::F(9),
[b'2', b'1'] => KeyCode::F(10),
[b'2', b'3'] => KeyCode::F(11),
[b'2', b'4'] => KeyCode::F(12),
_ => KeyCode::Esc,
};
Some(KeyEvent(key, ModKeys::empty()))
}
_ => Some(KeyEvent(KeyCode::Esc, ModKeys::empty())),
}
}
b'O' => {
let b2 = self.bytes.pop_front()?;
seq.push(b2);
let key = match b2 {
b'P' => KeyCode::F(1),
b'Q' => KeyCode::F(2),
b'R' => KeyCode::F(3),
b'S' => KeyCode::F(4),
_ => KeyCode::Esc,
};
Some(KeyEvent(key, ModKeys::empty()))
}
_ => Some(KeyEvent(KeyCode::Esc, ModKeys::empty())),
}
}
}
impl KeyReader for TestReader {
fn read_key(&mut self) -> Option<KeyEvent> {
use core::str;
let mut collected = Vec::with_capacity(4);
loop {
let byte = self.bytes.pop_front()?;
collected.push(byte);
// If it's an escape sequence, delegate
if collected[0] == 0x1b && collected.len() == 1 {
if let Some(&_next @ (b'[' | b'0')) = self.bytes.front() {
println!("found escape seq");
let seq = self.parse_esc_seq_from_bytes();
println!("{seq:?}");
return seq
}
}
// Try parse as valid UTF-8
if let Ok(s) = str::from_utf8(&collected) {
return Some(KeyEvent::new(s, ModKeys::empty()));
}
if collected.len() >= 4 {
break;
}
}
None
}
}
pub struct TestWriter {
}
impl TestWriter {
pub fn new() -> Self {
Self {}
}
}
impl LineWriter for TestWriter {
fn clear_rows(&mut self, _layout: &prompt::readline::term::Layout) -> libsh::error::ShResult<()> {
Ok(())
}
fn redraw(
&mut self,
_prompt: &str,
_line: &LineBuf,
_new_layout: &prompt::readline::term::Layout,
) -> libsh::error::ShResult<()> {
Ok(())
}
fn flush_write(&mut self, _buf: &str) -> libsh::error::ShResult<()> {
Ok(())
}
}
impl FernVi {
pub fn new_test(prompt: Option<String>,input: &str, initial: &str) -> Self {
Self {
reader: Box::new(TestReader::new().with_initial(input.as_bytes())),
writer: Box::new(TestWriter::new()),
prompt: prompt.unwrap_or("$ ".styled(Style::Green)),
mode: Box::new(ViInsert::new()),
old_layout: None,
repeat_action: None,
repeat_motion: None,
editor: LineBuf::new().with_initial(initial, 0)
}
}
}
fn fernvi_test(input: &str, initial: &str) -> String {
let mut fernvi = FernVi::new_test(None,input,initial);
let raw_mode = raw_mode();
let line = fernvi.readline().unwrap();
std::mem::drop(raw_mode);
line
}
fn normal_cmd(cmd: &str, buf: &str, cursor: usize) -> (String,usize) {
let cmd = ViNormal::new()
.cmds_from_raw(cmd)
.pop()
.unwrap();
let mut buf = LineBuf::new().with_initial(buf, cursor);
buf.exec_cmd(cmd).unwrap();
buf.as_str() == expected_buf && buf.cursor.get() == expected_cursor
(buf.as_str().to_string(),buf.cursor.get())
}
#[test]
@@ -200,129 +381,126 @@ fn linebuf_cursor_motion() {
#[test]
fn editor_delete_word() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"dw",
"The quick brown fox jumps over the lazy dog",
16,
"The quick brown jumps over the lazy dog",
16
));
16),
("The quick brown jumps over the lazy dog".into(), 16)
);
}
#[test]
fn editor_delete_backwards() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"2db",
"The quick brown fox jumps over the lazy dog",
16,
"The fox jumps over the lazy dog",
4
));
16),
("The fox jumps over the lazy dog".into(), 4)
);
}
#[test]
fn editor_rot13_five_words_backwards() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"g?5b",
"The quick brown fox jumps over the lazy dog",
31,
"The dhvpx oebja sbk whzcf bire the lazy dog",
4
));
31),
("The dhvpx oebja sbk whzcf bire the lazy dog".into(), 4)
);
}
#[test]
fn editor_delete_word_on_whitespace() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"dw",
"The quick brown fox",
10, // on the whitespace between "quick" and "brown"
"The quick brown fox",
10
));
10), //on the whitespace between "quick" and "brown"
("The quick brown fox".into(), 10)
);
}
#[test]
fn editor_delete_5_words() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"5dw",
"The quick brown fox jumps over the lazy dog",
16,
"The quick brown dog",
16
));
16,),
("The quick brown dog".into(), 16)
);
}
#[test]
fn editor_delete_end_includes_last() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"de",
"The quick brown fox::::jumps over the lazy dog",
16,
"The quick brown ::::jumps over the lazy dog",
16
));
16),
("The quick brown ::::jumps over the lazy dog".into(), 16)
);
}
#[test]
fn editor_delete_end_unicode_word() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"de",
"naïve café world",
0,
" café world", // deletes "naïve"
0
));
0),
(" café world".into(), 0)
);
}
#[test]
fn editor_inplace_edit_cursor_position() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"5~",
"foobar",
0,
"FOOBAr", // deletes "naïve"
4
));
assert!(normal_cmd(
0),
("FOOBAr".into(), 4)
);
assert_eq!(normal_cmd(
"5rg",
"foobar",
0,
"gggggr", // deletes "naïve"
4
));
0),
("gggggr".into(), 4)
);
}
#[test]
fn editor_insert_mode_not_clamped() {
assert_eq!(normal_cmd(
"a",
"foobar",
5),
("foobar".into(), 6)
)
}
#[test]
fn editor_overshooting_motions() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"5dw",
"foo bar",
0,
"", // deletes "naïve"
0
));
assert!(normal_cmd(
0),
("".into(), 0)
);
assert_eq!(normal_cmd(
"3db",
"foo bar",
0,
"foo bar", // deletes "naïve"
0
));
assert!(normal_cmd(
0),
("foo bar".into(), 0)
);
assert_eq!(normal_cmd(
"3dj",
"foo bar",
0,
"foo bar", // deletes "naïve"
0
));
assert!(normal_cmd(
0),
("foo bar".into(), 0)
);
assert_eq!(normal_cmd(
"3dk",
"foo bar",
0,
"foo bar", // deletes "naïve"
0
));
0),
("foo bar".into(), 0)
);
}
@@ -330,11 +508,55 @@ const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing el
#[test]
fn editor_delete_line_up() {
assert!(normal_cmd(
assert_eq!(normal_cmd(
"dk",
LOREM_IPSUM,
237,
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra.",
240,
))
237),
("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra.".into(), 240,)
)
}
#[test]
fn fernvi_test_simple() {
assert_eq!(fernvi_test(
"foo bar\x1bbdw\r",
""),
"foo "
)
}
#[test]
fn fernvi_test_mode_change() {
assert_eq!(fernvi_test(
"foo bar biz buzz\x1bbbb2cwbiz buzz bar\r",
""),
"foo biz buzz bar buzz"
)
}
#[test]
fn fernvi_test_lorem_ipsum_1() {
assert_eq!(fernvi_test(
"\x1bwwwwwwww5dWdBdBjjdwjdwbbbcwasdasdasdasd\x1b\r",
LOREM_IPSUM),
"Lorem ipsum dolor sit amet, incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in repin voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur asdasdasdasd occaecat cupinon proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra."
)
}
#[test]
fn fernvi_test_lorem_ipsum_undo() {
assert_eq!(fernvi_test(
"\x1bwwwwwwwwainserting some characters now...\x1bu\r",
LOREM_IPSUM),
LOREM_IPSUM
)
}
#[test]
fn fernvi_test_lorem_ipsum_ctrl_w() {
assert_eq!(fernvi_test(
"\x1bj5wiasdasdkjhaksjdhkajshd\x17wordswordswords\x17somemorewords\x17\x1b[D\x1b[D\x17\x1b\r",
LOREM_IPSUM),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim am, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra."
)
}