From 37cf9625b3c895652f11722f5b14f8d291f6f03b Mon Sep 17 00:00:00 2001 From: pagedmov Date: Wed, 25 Feb 2026 21:15:44 -0500 Subject: [PATCH] Implemented visual line mode --- docs/my_prompt.md | 92 +++++++++++++++++++++++++++++++++ src/readline/linebuf.rs | 109 +++++++++++++++++++++++++++------------- src/readline/mod.rs | 14 +++--- src/readline/term.rs | 4 +- src/readline/vicmd.rs | 1 + src/readline/vimode.rs | 18 +++---- 6 files changed, 186 insertions(+), 52 deletions(-) create mode 100644 docs/my_prompt.md diff --git a/docs/my_prompt.md b/docs/my_prompt.md new file mode 100644 index 0000000..460933c --- /dev/null +++ b/docs/my_prompt.md @@ -0,0 +1,92 @@ +## Prompt example + +This is the `shed` code for the prompt that I currently use. Note that the scripting language for `shed` is essentially identical to bash. This prompt code uses the `\!` escape sequence which lets you use the output of a function as your prompt. + +Also note that in `shed`, the `echo` builtin has a new `-p` flag which expands prompt escape sequences. This allows you to access these escape sequences in any context. + +```bash +prompt_topline() { + local user_and_host="\e[0m\e[1m$USER\e[1;36m@\e[1;31m$HOST\e[0m" + echo -n "\e[1;34m┏━ $user_and_host\n" +} + +prompt_stat_line() { + local last_exit_code="$?" + local last_cmd_status + local last_cmd_runtime + if [ "$last_exit_code" -eq "0" ]; then + last_cmd_status="\e[1;32m\e[0m" + else + last_cmd_status="\e[1;31m\e[0m" + fi + local last_runtime_raw="$(echo -p "\t")" + if [ -z "$last_runtime_raw" ]; then + return 0 + else + last_cmd_runtime="\e[1;38;2;249;226;175m󰔛 $(echo -p "\T")\e[0m" + fi + + echo -n "\e[1;34m┃ $last_cmd_runtime ($last_cmd_status)\n" +} + +prompt_git_line() { + git rev-parse --is-inside-work-tree > /dev/null 2>&1 || return + + local gitsigns + local status="$(git status --porcelain 2>/dev/null)" + local branch="$(git branch --show-current 2>/dev/null)" + + [ -n "$status" ] && echo "$status" | command grep -q '^ [MADR]' && gitsigns="$gitsigns!" + [ -n "$status" ] && echo "$status" | command grep -q '^??' && gitsigns="$gitsigns?" + [ -n "$status" ] && echo "$status" | command grep -q '^[MADR]' && gitsigns="$gitsigns+" + + local ahead="$(git rev-list --count @{upstream}..HEAD 2>/dev/null)" + local behind="$(git rev-list --count HEAD..@{upstream} 2>/dev/null)" + [ $ahead -gt 0 ] && gitsigns="$gitsigns↑" + [ $behind -gt 0 ] && gitsigns="$gitsigns↓" + + if [ -n "$gitsigns" ] || [ -n "$branch" ]; then + if [ -n "$gitsigns" ]; then + gitsigns="\e[1;31m[$gitsigns]" + fi + echo -n "\e[1;34m┃ \e[1;35m ${branch}$gitsigns\e[0m\n" + fi +} + +prompt_jobs_line() { + local job_count="$(echo -p '\j')" + if [ "$job_count" -gt 0 ]; then + echo -n "\e[1;34m┃ \e[1;33m󰒓 $job_count job(s) running\e[0m\n" + fi +} + +prompt_ssh_line() { + local ssh_server="$(echo $SSH_CONNECTION | cut -f3 -d' ')" + [ -n "$ssh_server" ] && echo -n "\e[1;34m┃ \e[1;39m🌐 $ssh_server\e[0m\n" +} + +prompt_pwd_line() { + echo -p "\e[1;34m┣━━ \e[1;36m\W\e[1;32m/" +} + +prompt_dollar_line() { + local dollar="$(echo -p "\$ ")" + local dollar="$(echo -e "\e[1;32m$dollar\e[0m")" + echo -n "\e[1;34m┗━ $dollar " +} + +prompt() { + local statline="$(prompt_stat_line)" + local topline="$(prompt_topline)" + local gitline="$(prompt_git_line)" + local jobsline="$(prompt_jobs_line)" + local sshline="$(prompt_ssh_line)" + local pwdline="$(prompt_pwd_line)" + local dollarline="$(prompt_dollar_line)" + local prompt="$topline$statline$gitline$jobsline$sshline$pwdline\n$dollarline" + + echo -en "$prompt" +} + +export PS1="\!prompt " +``` diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index 3cba456..8dfb2b9 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -321,7 +321,7 @@ pub struct LineBuf { pub cursor: ClampedUsize, // Used to index grapheme_indices pub select_mode: Option, - pub select_range: Option<(usize, usize)>, + select_range: Option<(usize, usize)>, pub last_selection: Option<(usize, usize)>, pub insert_mode_start_pos: Option, @@ -542,6 +542,9 @@ impl LineBuf { } pub fn slice_to(&mut self, end: usize) -> Option<&str> { self.update_graphemes_lazy(); + self.read_slice_to(end) + } + pub fn read_slice_to(&self, end: usize) -> Option<&str> { let grapheme_index = self.grapheme_indices().get(end).copied().or_else(|| { if end == self.grapheme_indices().len() { Some(self.buffer.len()) @@ -559,6 +562,9 @@ impl LineBuf { pub fn slice_to_cursor(&mut self) -> Option<&str> { self.slice_to(self.cursor.get()) } + pub fn read_slice_to_cursor(&self) -> Option<&str> { + self.read_slice_to(self.cursor.get()) + } pub fn slice_to_cursor_inclusive(&mut self) -> Option<&str> { self.slice_to(self.cursor.ret_add(1)) } @@ -614,14 +620,31 @@ impl LineBuf { self.update_graphemes(); } pub fn select_range(&self) -> Option<(usize, usize)> { - self.select_range + match self.select_mode? { + SelectMode::Char(_) => { + self.select_range + } + SelectMode::Line(_) => { + let (start, end) = self.select_range?; + let start = self.pos_line_number(start); + let end = self.pos_line_number(end); + let (select_start,_) = self.line_bounds(start); + let (_,select_end) = self.line_bounds(end); + if self.read_grapheme_before(select_end).is_some_and(|gr| gr == "\n") { + Some((select_start, select_end - 1)) + } else { + Some((select_start, select_end)) + } + } + SelectMode::Block(_) => todo!(), + } } pub fn start_selecting(&mut self, mode: SelectMode) { + let range_start = self.cursor; + let mut range_end = self.cursor; + range_end.add(1); + self.select_range = Some((range_start.get(), range_end.get())); self.select_mode = Some(mode); - let range_start = self.cursor; - let mut range_end = self.cursor; - range_end.add(1); - self.select_range = Some((range_start.get(), range_end.get())); } pub fn stop_selecting(&mut self) { self.select_mode = None; @@ -629,12 +652,18 @@ impl LineBuf { self.last_selection = self.select_range.take(); } } - pub fn total_lines(&mut self) -> usize { + pub fn total_lines(&self) -> usize { self.buffer.graphemes(true).filter(|g| *g == "\n").count() } - pub fn cursor_line_number(&mut self) -> usize { + + pub fn pos_line_number(&self, pos: usize) -> usize { + self.read_slice_to(pos) + .map(|slice| slice.graphemes(true).filter(|g| *g == "\n").count()) + .unwrap_or(0) + } + pub fn cursor_line_number(&self) -> usize { self - .slice_to_cursor() + .read_slice_to_cursor() .map(|slice| slice.graphemes(true).filter(|g| *g == "\n").count()) .unwrap_or(0) } @@ -772,7 +801,7 @@ impl LineBuf { Some((start, end)) } - pub fn line_bounds(&mut self, n: usize) -> (usize, usize) { + pub fn line_bounds(&self, n: usize) -> (usize, usize) { if n > self.total_lines() { panic!( "Attempted to find line {n} when there are only {} lines", @@ -2321,7 +2350,7 @@ impl LineBuf { return; }; match mode { - SelectMode::Char(anchor) => match anchor { + SelectMode::Line(anchor) | SelectMode::Char(anchor) => match anchor { SelectAnchor::Start => { start = self.cursor.get(); } @@ -2329,14 +2358,6 @@ impl LineBuf { end = self.cursor.get(); } }, - SelectMode::Line(anchor) => match anchor { - SelectAnchor::Start => { - start = self.start_of_line(); - } - SelectAnchor::End => { - end = self.end_of_line(); - } - } SelectMode::Block(anchor) => todo!(), } if start >= end { @@ -2381,7 +2402,7 @@ impl LineBuf { } } MotionKind::Exclusive((start,end)) => { - if self.select_range().is_none() { + if self.select_range.is_none() { self.cursor.set(start) } else { let end = end.saturating_sub(1); @@ -2395,7 +2416,14 @@ impl LineBuf { } pub fn range_from_motion(&mut self, motion: &MotionKind) -> Option<(usize, usize)> { let range = match motion { - MotionKind::On(pos) => ordered(self.cursor.get(), *pos), + MotionKind::On(pos) => { + let cursor_pos = self.cursor.get(); + if cursor_pos == *pos { + ordered(cursor_pos, pos + 1) // scary + } else { + ordered(cursor_pos, *pos) + } + } MotionKind::Onto(pos) => { // For motions which include the character at the cursor during operations // but exclude the character during movements @@ -2439,20 +2467,29 @@ impl LineBuf { ) -> ShResult<()> { match verb { Verb::Delete | Verb::Yank | Verb::Change => { + log::debug!("Executing verb: {verb:?} with motion: {motion:?}"); let Some((mut start, mut end)) = self.range_from_motion(&motion) else { + log::debug!("No range from motion, nothing to do"); return Ok(()); }; + log::debug!("Initial range from motion: ({start}, {end})"); + log::debug!("self.grapheme_indices().len(): {}", self.grapheme_indices().len()); let mut do_indent = false; if verb == Verb::Change && (start,end) == self.this_line() { do_indent = read_shopts(|o| o.prompt.auto_indent); } - let text = if verb == Verb::Yank { + let mut text = if verb == Verb::Yank { self .slice(start..end) .map(|c| c.to_string()) .unwrap_or_default() + } else if start == self.grapheme_indices().len() && end == self.grapheme_indices().len() { + // user is in normal mode and pressed 'x' on the last char in the buffer + let drained = self.drain(end.saturating_sub(1)..end); + self.update_graphemes(); + drained } else { let drained = self.drain(start..end); self.update_graphemes(); @@ -2460,9 +2497,13 @@ impl LineBuf { }; let is_linewise = matches!( motion, - MotionKind::InclusiveWithTargetCol(..) | MotionKind::ExclusiveWithTargetCol(..) - ); + MotionKind::InclusiveWithTargetCol(..) | + MotionKind::ExclusiveWithTargetCol(..) + ) || matches!(self.select_mode, Some(SelectMode::Line(_))); let register_content = if is_linewise { + if !text.ends_with('\n') && !text.is_empty() { + text.push('\n'); + } RegisterContent::Line(text) } else { RegisterContent::Span(text) @@ -2649,7 +2690,7 @@ impl LineBuf { if content.is_empty() { return Ok(()); } - if let Some(range) = self.select_range { + if let Some(range) = self.select_range() { let register_text = self.drain_inclusive(range.0..=range.1); write_register(None, RegisterContent::Span(register_text)); // swap deleted text into register @@ -2688,7 +2729,7 @@ impl LineBuf { } } Verb::SwapVisualAnchor => { - if let Some((start, end)) = self.select_range() + if let Some((start, end)) = self.select_range && let Some(mut mode) = self.select_mode { mode.invert_anchor(); @@ -2960,7 +3001,7 @@ impl LineBuf { .map(|m| self.eval_motion(verb_ref.as_ref(), m)) .unwrap_or({ self - .select_range + .select_range() .map(MotionKind::Inclusive) .unwrap_or(MotionKind::Null) }) @@ -3028,15 +3069,11 @@ impl LineBuf { impl Display for LineBuf { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut full_buf = self.buffer.clone(); - if let Some((start, end)) = self.select_range { + if let Some((start, end)) = self.select_range() { let mode = self.select_mode.unwrap(); let start_byte = self.read_idx_byte_pos(start); - let end_byte = self.read_idx_byte_pos(end); + let end_byte = self.read_idx_byte_pos(end).min(full_buf.len()); - if start_byte >= full_buf.len() || end_byte >= full_buf.len() { - log::warn!("Selection range '{:?}' is out of bounds for buffer of length {}, clearing selection", (start, end), full_buf.len()); - return write!(f, "{}", full_buf); - } match mode.anchor() { SelectAnchor::Start => { @@ -3044,11 +3081,13 @@ impl Display for LineBuf { if *inclusive.end() == full_buf.len() { inclusive = start_byte..=end_byte.saturating_sub(1); } - let selected = format!("{}{}{}", markers::VISUAL_MODE_START, &full_buf[inclusive.clone()], markers::VISUAL_MODE_END); + let selected = format!("{}{}{}", markers::VISUAL_MODE_START, &full_buf[inclusive.clone()], markers::VISUAL_MODE_END) + .replace("\n", format!("\n{}",markers::VISUAL_MODE_START).as_str()); full_buf.replace_range(inclusive, &selected); } SelectAnchor::End => { - let selected = format!("{}{}{}", markers::VISUAL_MODE_START, &full_buf[start..end], markers::VISUAL_MODE_END); + let selected = format!("{}{}{}", markers::VISUAL_MODE_START, &full_buf[start_byte..end_byte], markers::VISUAL_MODE_END) + .replace("\n", format!("\n{}",markers::VISUAL_MODE_START).as_str()); full_buf.replace_range(start_byte..end_byte, &selected); } } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 6ba8664..f3778eb 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -561,7 +561,7 @@ impl ShedVi { } pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> { - let mut selecting = false; + let mut select_mode = None; let mut is_insert_mode = false; if cmd.is_mode_transition() { let count = cmd.verb_count(); @@ -588,7 +588,11 @@ impl ShedVi { return self.editor.exec_cmd(cmd); } Verb::VisualMode => { - selecting = true; + select_mode = Some(SelectMode::Char(SelectAnchor::End)); + Box::new(ViVisual::new()) + } + Verb::VisualModeLine => { + select_mode = Some(SelectMode::Line(SelectAnchor::End)); Box::new(ViVisual::new()) } @@ -606,10 +610,8 @@ impl ShedVi { self.editor.set_cursor_clamp(self.mode.clamp_cursor()); self.editor.exec_cmd(cmd)?; - if selecting { - self - .editor - .start_selecting(SelectMode::Char(SelectAnchor::End)); + if let Some(sel_mode) = select_mode { + self.editor.start_selecting(sel_mode); } else { self.editor.stop_selecting(); } diff --git a/src/readline/term.rs b/src/readline/term.rs index 305b5e1..81c6141 100644 --- a/src/readline/term.rs +++ b/src/readline/term.rs @@ -117,12 +117,12 @@ fn enumerate_lines(s: &str, left_pad: usize) -> String { let trail_pad = left_pad.saturating_sub(prefix_len); if i == total_lines - 1 { // Don't add a newline to the last line - write!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}", + write!(acc, "\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}", " ".repeat(num_pad), " ".repeat(trail_pad), ).unwrap(); } else { - writeln!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}", + writeln!(acc, "\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}", " ".repeat(num_pad), " ".repeat(trail_pad), ).unwrap(); diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index 7959b82..e92555c 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -182,6 +182,7 @@ impl ViCmd { | Verb::NormalMode | Verb::VisualModeSelectLast | Verb::VisualMode + | Verb::VisualModeLine | Verb::ReplaceMode ) }) diff --git a/src/readline/vimode.rs b/src/readline/vimode.rs index 3d5987f..fc78950 100644 --- a/src/readline/vimode.rs +++ b/src/readline/vimode.rs @@ -1018,6 +1018,15 @@ impl ViNormal { impl ViMode for ViNormal { fn handle_key(&mut self, key: E) -> Option { let mut cmd = match key { + E(K::Char('V'), M::NONE) => { + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::VisualModeLine)), + motion: None, + raw_seq: "".into(), + flags: self.flags(), + }) + } E(K::Char(ch), M::NONE) => self.try_parse(ch), E(K::Backspace, M::NONE) => Some(ViCmd { register: Default::default(), @@ -1041,15 +1050,6 @@ impl ViMode for ViNormal { self.clear_cmd(); None } - E(K::Char('V'), M::SHIFT) => { - Some(ViCmd { - register: Default::default(), - verb: Some(VerbCmd(1, Verb::VisualModeLine)), - motion: None, - raw_seq: "".into(), - flags: self.flags() | CmdFlags::VISUAL_LINE, - }) - } _ => { if let Some(cmd) = common_cmds(key) { self.clear_cmd();