diff --git a/Cargo.lock b/Cargo.lock index cb162b7..2475f92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ dependencies = [ [[package]] name = "shed" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ariadne", "bitflags", diff --git a/Cargo.toml b/Cargo.toml index 11d8ec0..74a841b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "shed" description = "A linux shell written in rust" publish = false -version = "0.5.0" +version = "0.6.0" edition = "2024" diff --git a/flake.nix b/flake.nix index 7acd0f9..dd1ff48 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,7 @@ { packages.default = pkgs.rustPlatform.buildRustPackage { pname = "shed"; - version = "0.5.0"; + version = "0.6.0"; src = self; diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 411c118..fff81fd 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -21,13 +21,13 @@ use crate::{ prelude::*, procio::{IoFrame, IoMode, IoStack}, readline::{ - markers, - register::RegisterContent, vicmd::{ReadSrc, VerbCmd, WriteDest}, + highlight::Highlighter, markers, register::RegisterContent, term::get_win_size, vicmd::{ReadSrc, VerbCmd, WriteDest} }, - state::{VarFlags, VarKind, read_vars, write_meta, write_vars}, + 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]>); @@ -517,6 +517,8 @@ pub struct LineBuf { pub saved_col: Option, pub indent_ctx: IndentCtx, + pub scroll_offset: usize, + pub undo_stack: Vec, pub redo_stack: Vec, } @@ -535,6 +537,7 @@ impl Default for LineBuf { insert_mode_start_pos: None, saved_col: None, indent_ctx: IndentCtx::new(), + scroll_offset: 0, undo_stack: vec![], redo_stack: vec![], } @@ -545,6 +548,86 @@ 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) } @@ -864,7 +947,10 @@ impl LineBuf { match (to, dir) { (To::Start, Direction::Forward) => { target = self - .word_motion_w(word, target, ignore_trailing_ws) + // '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 @@ -895,7 +981,13 @@ impl LineBuf { inclusive, }) } - fn word_motion_w(&self, word: &Word, start: Pos, ignore_trailing_ws: bool) -> Option { + 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 @@ -924,13 +1016,24 @@ impl LineBuf { } // go forward until we find some char class that isnt this one - let first_c = classes.next()?.1; - - match classes.find(|(_, c)| c.is_other_class_or_ws(&first_c))? { - (pos, C::Whitespace) if ignore_trailing_ws => return Some(pos), - (_, C::Whitespace) => { /* fall through */ } - (pos, _) => return Some(pos), - } + 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) @@ -1804,6 +1907,7 @@ impl LineBuf { Ok(()) } fn extract_range(&mut self, motion: &MotionKind) -> Vec { + log::debug!("Extracting range for motion: {:?}", motion); let extracted = match motion { MotionKind::Char { start, @@ -2512,6 +2616,7 @@ impl LineBuf { self.cursor.pos.col = line.len(); } } + self.update_scroll_offset(); } pub fn joined(&self) -> String { diff --git a/src/readline/mod.rs b/src/readline/mod.rs index e37c118..9fb8cf8 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -642,11 +642,6 @@ impl ShedVi { self.needs_redraw = true; continue; } else { - log::debug!( - "Ambiguous key sequence: {:?}, matches: {:?}", - self.pending_keymap, - matches - ); // There is ambiguity. Allow the timeout in the main loop to handle this. continue; } @@ -944,7 +939,7 @@ impl ShedVi { } pub fn get_layout(&mut self, line: &str) -> Layout { - let to_cursor = self.editor.slice_to_cursor().unwrap_or_default(); + let to_cursor = self.editor.window_slice_to_cursor().unwrap_or_default(); let (cols, _) = get_win_size(self.tty); Layout::from_parts(cols, self.prompt.get_ps1(), &to_cursor, line) } @@ -1012,24 +1007,9 @@ impl ShedVi { && self.editor.on_last_line()) } - pub fn line_text(&mut self) -> String { - let line = self.editor.to_string(); - let hint = self.editor.get_hint_text(); - let do_hl = state::read_shopts(|s| s.prompt.highlight); - self.highlighter.only_visual(!do_hl); - self - .highlighter - .load_input(&line, self.editor.cursor_byte_pos()); - self.highlighter.expand_control_chars(); - self.highlighter.highlight(); - let highlighted = self.highlighter.take(); - let res = format!("{highlighted}{hint}"); - res - } - pub fn print_line(&mut self, final_draw: bool) -> ShResult<()> { - let line = self.line_text(); - let mut new_layout = self.get_layout(&line); + let line = self.editor.display_window_joined(); + let mut new_layout = self.get_layout(&line); let pending_seq = self.mode.pending_seq(); let mut prompt_string_right = self.prompt.psr_expanded.clone(); @@ -1070,7 +1050,7 @@ impl ShedVi { self .writer - .redraw(self.prompt.get_ps1(), &line, &new_layout)?; + .redraw(self.prompt.get_ps1(), &line, &new_layout, self.editor.scroll_offset, self.editor.lines.len())?; let seq_fits = pending_seq .as_ref() @@ -1129,15 +1109,30 @@ impl ShedVi { if let ModeReport::Ex = self.mode.report_mode() { let pending_seq = self.mode.pending_seq().unwrap_or_default(); - write!(buf, "\n: {pending_seq}").unwrap(); + let down = new_layout.end.row - new_layout.cursor.row; + let move_down = if down > 0 { + format!("\x1b[{down}B") + } else { + String::new() + }; + write!(buf, "{move_down}\x1b[1G\n: {pending_seq}").unwrap(); new_layout.end.row += 1; - new_layout.cursor.row += 1; + new_layout.cursor.row = new_layout.end.row; + new_layout.cursor.col = (2 + pending_seq.width()) as u16; } write!(buf, "{}", &self.mode.cursor_style()).unwrap(); self.writer.flush_write(&buf)?; + // Move to end of layout for overlay draws (completer, history search) + let has_overlays = self.completer.is_active() || self.focused_history().fuzzy_finder.is_active(); + let down = new_layout.end.row.saturating_sub(new_layout.cursor.row); + if has_overlays && down > 0 { + self.writer.flush_write(&format!("\x1b[{down}B"))?; + new_layout.cursor.row = new_layout.end.row; + } + // Tell the completer the width of the prompt line above its \n so it can // account for wrapping when clearing after a resize. let preceding_width = if new_layout.psr_end.is_some() { @@ -1146,17 +1141,15 @@ impl ShedVi { // Without PSR, use the content width on the cursor's row (new_layout.end.col + 1).max(new_layout.cursor.col + 1) }; - self - .completer - .set_prompt_line_context(preceding_width, new_layout.cursor.col); + self.completer + .set_prompt_line_context(preceding_width, new_layout.end.col); self.completer.draw(&mut self.writer)?; - self - .focused_history() - .fuzzy_finder - .set_prompt_line_context(preceding_width, new_layout.cursor.col); - { + self.focused_history() + .fuzzy_finder + .set_prompt_line_context(preceding_width, new_layout.end.col); + let mut writer = std::mem::take(&mut self.writer); self.focused_history().fuzzy_finder.draw(&mut writer)?; self.writer = writer; @@ -1279,7 +1272,6 @@ impl ShedVi { // Set cursor clamp BEFORE executing the command so that motions // (like EndOfLine for 'A') can reach positions valid in the new mode - log::debug!("cmd: {:?}", cmd); self.editor.set_cursor_clamp(self.mode.clamp_cursor()); self.editor.exec_cmd(cmd)?; diff --git a/src/readline/term.rs b/src/readline/term.rs index 4add73f..25232b6 100644 --- a/src/readline/term.rs +++ b/src/readline/term.rs @@ -69,10 +69,10 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) { } } -fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String { +fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool, offset: usize, total_buf_lines: usize) -> String { let lines: Vec<&str> = s.split('\n').collect(); - let total_lines = lines.len(); - let max_num_len = total_lines.to_string().len(); + let visible_count = lines.len(); + let max_num_len = (offset + visible_count).to_string().len(); lines .into_iter() .enumerate() @@ -81,7 +81,7 @@ fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String { acc.push_str(ln); acc.push('\n'); } else { - let num = (i + 1).to_string(); + let num = (i + offset + 1).to_string(); let num_pad = max_num_len - num.len(); // " 2 | " — num + padding + " | " let prefix_len = max_num_len + 3; // "N | " @@ -91,7 +91,7 @@ fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String { } else { " ".repeat(prefix_len + 1).to_string() }; - if i == total_lines - 1 { + if i == visible_count - 1 { write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); } else { writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); @@ -220,7 +220,7 @@ pub trait KeyReader { pub trait LineWriter { fn clear_rows(&mut self, layout: &Layout) -> ShResult<()>; - fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout) -> ShResult<()>; + fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout, offset: usize, total_buf_lines: usize) -> ShResult<()>; fn flush_write(&mut self, buf: &str) -> ShResult<()>; fn send_bell(&mut self) -> ShResult<()>; } @@ -541,7 +541,6 @@ impl Perform for KeyCollector { // SS3 sequences if byte == b'O' { self.ss3_pending = true; - return; } } } @@ -1095,7 +1094,7 @@ impl LineWriter for TermWriter { Ok(()) } - fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout) -> ShResult<()> { + fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout, offset: usize, total_buf_lines: usize) -> ShResult<()> { let err = |_| { ShErr::simple( ShErrKind::InternalErr, @@ -1121,7 +1120,7 @@ impl LineWriter for TermWriter { if multiline { let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0, false); let show_numbers = read_shopts(|o| o.prompt.line_numbers); - let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers); + let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers, offset, total_buf_lines); self.buffer.push_str(&display_line); } else { self.buffer.push_str(line); diff --git a/src/readline/tests.rs b/src/readline/tests.rs index 95ac4c7..c55a359 100644 --- a/src/readline/tests.rs +++ b/src/readline/tests.rs @@ -497,7 +497,8 @@ vi_test! { vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4; vi_vw_doesnt_crash : "" => "vw" => "", 0; vi_indent_cursor_pos : "echo foo" => ">>" => "\techo foo", 1; - vi_join_indent_lines : "echo foo\n\t\techo bar" => "J" => "echo foo echo bar", 8 + vi_join_indent_lines : "echo foo\n\t\techo bar" => "J" => "echo foo echo bar", 8; + vi_cw_stays_on_line : "echo foo\necho bar" => "wcw" => "echo \necho bar", 5 } #[test] diff --git a/src/shopt.rs b/src/shopt.rs index c3e0f95..5823d25 100644 --- a/src/shopt.rs +++ b/src/shopt.rs @@ -149,16 +149,17 @@ macro_rules! shopt_group { #[derive(Clone, Debug)] pub struct ShOpts { pub core: ShOptCore, + pub line: ShOptLine, pub prompt: ShOptPrompt, } impl Default for ShOpts { fn default() -> Self { let core = ShOptCore::default(); - + let line = ShOptLine::default(); let prompt = ShOptPrompt::default(); - Self { core, prompt } + Self { core, line, prompt } } } @@ -175,6 +176,7 @@ impl ShOpts { pub fn display_opts(&mut self) -> ShResult { let output = [ self.query("core")?.unwrap_or_default().to_string(), + self.query("line")?.unwrap_or_default().to_string(), self.query("prompt")?.unwrap_or_default().to_string(), ]; @@ -194,6 +196,7 @@ impl ShOpts { match key { "core" => self.core.set(&remainder, val)?, + "line" => self.line.set(&remainder, val)?, "prompt" => self.prompt.set(&remainder, val)?, _ => { return Err(ShErr::simple( @@ -218,6 +221,7 @@ impl ShOpts { match key { "core" => self.core.get(&remainder), + "line" => self.line.get(&remainder), "prompt" => self.prompt.get(&remainder), _ => Err(ShErr::simple( ShErrKind::SyntaxErr, @@ -227,6 +231,16 @@ impl ShOpts { } } +shopt_group! { + #[derive(Clone, Debug)] + pub struct ShOptLine ("line") { + /// The maximum height of the line editor viewport window. Can be a positive number or a percentage of terminal height like "50%" + viewport_height: String = "50%".to_string(), + /// The line offset from the top or bottom of the viewport to trigger scrolling + scroll_offset: usize = 2, + } +} + shopt_group! { #[derive(Clone, Debug)] pub struct ShOptCore ("core") {