From 80eb8d278a5a64e83e5edc2bf9eeeab432b2e83b Mon Sep 17 00:00:00 2001 From: Kyler Clay Date: Sat, 7 Jun 2025 23:45:51 -0400 Subject: [PATCH] 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 --- Cargo.toml | 6 +- src/prompt/readline/linebuf.rs | 221 ++++++++++++++++++-- src/prompt/readline/mod.rs | 60 ++++-- src/prompt/readline/term.rs | 272 +++++++++++++----------- src/prompt/readline/vicmd.rs | 4 +- src/prompt/readline/vimode.rs | 52 +++-- src/tests/readline.rs | 368 ++++++++++++++++++++++++++------- 7 files changed, 726 insertions(+), 257 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4f8fe23..dda9159 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/prompt/readline/linebuf.rs b/src/prompt/readline/linebuf.rs index cbadc15..3b2579f 100644 --- a/src/prompt/readline/linebuf.rs +++ b/src/prompt/readline/linebuf.rs @@ -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 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) -> 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, pub saved_col: Option, pub undo_stack: Vec, @@ -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) -> 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::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 end = self.end_of_line(); - self.insert_at(end,'\n'); - self.cursor.set(end); + 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.add(2), - Anchor::Before => { /* Do nothing */ } + 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 } diff --git a/src/prompt/readline/mod.rs b/src/prompt/readline/mod.rs index d0a8d18..869edb5 100644 --- a/src/prompt/readline/mod.rs +++ b/src/prompt/readline/mod.rs @@ -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, - old_layout: Option, - repeat_action: Option, - repeat_motion: Option, - editor: LineBuf + pub reader: Box, + pub writer: Box, + pub prompt: String, + pub mode: Box, + pub old_layout: Option, + pub repeat_action: Option, + pub repeat_motion: Option, + pub editor: LineBuf } impl Readline for FernVi { fn readline(&mut self) -> ShResult { - 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) -> 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 = 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 { diff --git a/src/prompt/readline/term.rs b/src/prompt/readline/term.rs index 2002c59..7f8c7a3 100644 --- a/src/prompt/readline/term.rs +++ b/src/prompt/readline/term.rs @@ -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; +} + +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 { - 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 it’s invalid at this point, bail - if collected.len() >= 4 { - break; - } - } - - Ok(KeyEvent(KeyCode::Null, ModKeys::empty())) - } pub fn parse_esc_seq(&mut self) -> ShResult { 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 { + 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 it’s 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 { 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(()) + } +} diff --git a/src/prompt/readline/vicmd.rs b/src/prompt/readline/vicmd.rs index 8363a06..d5f782d 100644 --- a/src/prompt/readline/vicmd.rs +++ b/src/prompt/readline/vicmd.rs @@ -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,_) | diff --git a/src/prompt/readline/vimode.rs b/src/prompt/readline/vimode.rs index 8ecec72..143514a 100644 --- a/src/prompt/readline/vimode.rs +++ b/src/prompt/readline/vimode.rs @@ -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,15 +112,9 @@ 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() - } + 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) => { @@ -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 diff --git a/src/tests/readline.rs b/src/tests/readline.rs index 4edc0e2..644fa80 100644 --- a/src/tests/readline.rs +++ b/src/tests/readline.rs @@ -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 +} + +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 { + 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 { + 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,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." + ) }