Implemented visual line mode

This commit is contained in:
2026-02-25 21:15:44 -05:00
parent e7e9bfbcb6
commit fae2a9eeca
6 changed files with 186 additions and 52 deletions

92
docs/my_prompt.md Normal file
View File

@@ -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 "
```

View File

@@ -321,7 +321,7 @@ pub struct LineBuf {
pub cursor: ClampedUsize, // Used to index grapheme_indices pub cursor: ClampedUsize, // Used to index grapheme_indices
pub select_mode: Option<SelectMode>, pub select_mode: Option<SelectMode>,
pub select_range: Option<(usize, usize)>, select_range: Option<(usize, usize)>,
pub last_selection: Option<(usize, usize)>, pub last_selection: Option<(usize, usize)>,
pub insert_mode_start_pos: Option<usize>, pub insert_mode_start_pos: Option<usize>,
@@ -542,6 +542,9 @@ impl LineBuf {
} }
pub fn slice_to(&mut self, end: usize) -> Option<&str> { pub fn slice_to(&mut self, end: usize) -> Option<&str> {
self.update_graphemes_lazy(); 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(|| { let grapheme_index = self.grapheme_indices().get(end).copied().or_else(|| {
if end == self.grapheme_indices().len() { if end == self.grapheme_indices().len() {
Some(self.buffer.len()) Some(self.buffer.len())
@@ -559,6 +562,9 @@ impl LineBuf {
pub fn slice_to_cursor(&mut self) -> Option<&str> { pub fn slice_to_cursor(&mut self) -> Option<&str> {
self.slice_to(self.cursor.get()) 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> { pub fn slice_to_cursor_inclusive(&mut self) -> Option<&str> {
self.slice_to(self.cursor.ret_add(1)) self.slice_to(self.cursor.ret_add(1))
} }
@@ -614,14 +620,31 @@ impl LineBuf {
self.update_graphemes(); self.update_graphemes();
} }
pub fn select_range(&self) -> Option<(usize, usize)> { pub fn select_range(&self) -> Option<(usize, usize)> {
match self.select_mode? {
SelectMode::Char(_) => {
self.select_range 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) { pub fn start_selecting(&mut self, mode: SelectMode) {
self.select_mode = Some(mode);
let range_start = self.cursor; let range_start = self.cursor;
let mut range_end = self.cursor; let mut range_end = self.cursor;
range_end.add(1); range_end.add(1);
self.select_range = Some((range_start.get(), range_end.get())); self.select_range = Some((range_start.get(), range_end.get()));
self.select_mode = Some(mode);
} }
pub fn stop_selecting(&mut self) { pub fn stop_selecting(&mut self) {
self.select_mode = None; self.select_mode = None;
@@ -629,12 +652,18 @@ impl LineBuf {
self.last_selection = self.select_range.take(); 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() 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 self
.slice_to_cursor() .read_slice_to_cursor()
.map(|slice| slice.graphemes(true).filter(|g| *g == "\n").count()) .map(|slice| slice.graphemes(true).filter(|g| *g == "\n").count())
.unwrap_or(0) .unwrap_or(0)
} }
@@ -772,7 +801,7 @@ impl LineBuf {
Some((start, end)) 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() { if n > self.total_lines() {
panic!( panic!(
"Attempted to find line {n} when there are only {} lines", "Attempted to find line {n} when there are only {} lines",
@@ -2321,7 +2350,7 @@ impl LineBuf {
return; return;
}; };
match mode { match mode {
SelectMode::Char(anchor) => match anchor { SelectMode::Line(anchor) | SelectMode::Char(anchor) => match anchor {
SelectAnchor::Start => { SelectAnchor::Start => {
start = self.cursor.get(); start = self.cursor.get();
} }
@@ -2329,14 +2358,6 @@ impl LineBuf {
end = self.cursor.get(); 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!(), SelectMode::Block(anchor) => todo!(),
} }
if start >= end { if start >= end {
@@ -2381,7 +2402,7 @@ impl LineBuf {
} }
} }
MotionKind::Exclusive((start,end)) => { MotionKind::Exclusive((start,end)) => {
if self.select_range().is_none() { if self.select_range.is_none() {
self.cursor.set(start) self.cursor.set(start)
} else { } else {
let end = end.saturating_sub(1); let end = end.saturating_sub(1);
@@ -2395,7 +2416,14 @@ impl LineBuf {
} }
pub fn range_from_motion(&mut self, motion: &MotionKind) -> Option<(usize, usize)> { pub fn range_from_motion(&mut self, motion: &MotionKind) -> Option<(usize, usize)> {
let range = match motion { 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) => { MotionKind::Onto(pos) => {
// For motions which include the character at the cursor during operations // For motions which include the character at the cursor during operations
// but exclude the character during movements // but exclude the character during movements
@@ -2439,20 +2467,29 @@ impl LineBuf {
) -> ShResult<()> { ) -> ShResult<()> {
match verb { match verb {
Verb::Delete | Verb::Yank | Verb::Change => { 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 { let Some((mut start, mut end)) = self.range_from_motion(&motion) else {
log::debug!("No range from motion, nothing to do");
return Ok(()); 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; let mut do_indent = false;
if verb == Verb::Change && (start,end) == self.this_line() { if verb == Verb::Change && (start,end) == self.this_line() {
do_indent = read_shopts(|o| o.prompt.auto_indent); do_indent = read_shopts(|o| o.prompt.auto_indent);
} }
let text = if verb == Verb::Yank { let mut text = if verb == Verb::Yank {
self self
.slice(start..end) .slice(start..end)
.map(|c| c.to_string()) .map(|c| c.to_string())
.unwrap_or_default() .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 { } else {
let drained = self.drain(start..end); let drained = self.drain(start..end);
self.update_graphemes(); self.update_graphemes();
@@ -2460,9 +2497,13 @@ impl LineBuf {
}; };
let is_linewise = matches!( let is_linewise = matches!(
motion, motion,
MotionKind::InclusiveWithTargetCol(..) | MotionKind::ExclusiveWithTargetCol(..) MotionKind::InclusiveWithTargetCol(..) |
); MotionKind::ExclusiveWithTargetCol(..)
) || matches!(self.select_mode, Some(SelectMode::Line(_)));
let register_content = if is_linewise { let register_content = if is_linewise {
if !text.ends_with('\n') && !text.is_empty() {
text.push('\n');
}
RegisterContent::Line(text) RegisterContent::Line(text)
} else { } else {
RegisterContent::Span(text) RegisterContent::Span(text)
@@ -2649,7 +2690,7 @@ impl LineBuf {
if content.is_empty() { if content.is_empty() {
return Ok(()); 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); let register_text = self.drain_inclusive(range.0..=range.1);
write_register(None, RegisterContent::Span(register_text)); // swap deleted text into register write_register(None, RegisterContent::Span(register_text)); // swap deleted text into register
@@ -2688,7 +2729,7 @@ impl LineBuf {
} }
} }
Verb::SwapVisualAnchor => { 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 && let Some(mut mode) = self.select_mode
{ {
mode.invert_anchor(); mode.invert_anchor();
@@ -2960,7 +3001,7 @@ impl LineBuf {
.map(|m| self.eval_motion(verb_ref.as_ref(), m)) .map(|m| self.eval_motion(verb_ref.as_ref(), m))
.unwrap_or({ .unwrap_or({
self self
.select_range .select_range()
.map(MotionKind::Inclusive) .map(MotionKind::Inclusive)
.unwrap_or(MotionKind::Null) .unwrap_or(MotionKind::Null)
}) })
@@ -3028,15 +3069,11 @@ impl LineBuf {
impl Display for LineBuf { impl Display for LineBuf {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut full_buf = self.buffer.clone(); 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 mode = self.select_mode.unwrap();
let start_byte = self.read_idx_byte_pos(start); 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() { match mode.anchor() {
SelectAnchor::Start => { SelectAnchor::Start => {
@@ -3044,11 +3081,13 @@ impl Display for LineBuf {
if *inclusive.end() == full_buf.len() { if *inclusive.end() == full_buf.len() {
inclusive = start_byte..=end_byte.saturating_sub(1); 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); full_buf.replace_range(inclusive, &selected);
} }
SelectAnchor::End => { 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); full_buf.replace_range(start_byte..end_byte, &selected);
} }
} }

View File

@@ -561,7 +561,7 @@ impl ShedVi {
} }
pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> { 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; let mut is_insert_mode = false;
if cmd.is_mode_transition() { if cmd.is_mode_transition() {
let count = cmd.verb_count(); let count = cmd.verb_count();
@@ -588,7 +588,11 @@ impl ShedVi {
return self.editor.exec_cmd(cmd); return self.editor.exec_cmd(cmd);
} }
Verb::VisualMode => { 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()) Box::new(ViVisual::new())
} }
@@ -606,10 +610,8 @@ impl ShedVi {
self.editor.set_cursor_clamp(self.mode.clamp_cursor()); self.editor.set_cursor_clamp(self.mode.clamp_cursor());
self.editor.exec_cmd(cmd)?; self.editor.exec_cmd(cmd)?;
if selecting { if let Some(sel_mode) = select_mode {
self self.editor.start_selecting(sel_mode);
.editor
.start_selecting(SelectMode::Char(SelectAnchor::End));
} else { } else {
self.editor.stop_selecting(); self.editor.stop_selecting();
} }

View File

@@ -117,12 +117,12 @@ fn enumerate_lines(s: &str, left_pad: usize) -> String {
let trail_pad = left_pad.saturating_sub(prefix_len); let trail_pad = left_pad.saturating_sub(prefix_len);
if i == total_lines - 1 { if i == total_lines - 1 {
// Don't add a newline to the last line // 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(num_pad),
" ".repeat(trail_pad), " ".repeat(trail_pad),
).unwrap(); ).unwrap();
} else { } else {
writeln!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}", writeln!(acc, "\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}",
" ".repeat(num_pad), " ".repeat(num_pad),
" ".repeat(trail_pad), " ".repeat(trail_pad),
).unwrap(); ).unwrap();

View File

@@ -182,6 +182,7 @@ impl ViCmd {
| Verb::NormalMode | Verb::NormalMode
| Verb::VisualModeSelectLast | Verb::VisualModeSelectLast
| Verb::VisualMode | Verb::VisualMode
| Verb::VisualModeLine
| Verb::ReplaceMode | Verb::ReplaceMode
) )
}) })

View File

@@ -1018,6 +1018,15 @@ impl ViNormal {
impl ViMode for ViNormal { impl ViMode for ViNormal {
fn handle_key(&mut self, key: E) -> Option<ViCmd> { fn handle_key(&mut self, key: E) -> Option<ViCmd> {
let mut cmd = match key { 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::Char(ch), M::NONE) => self.try_parse(ch),
E(K::Backspace, M::NONE) => Some(ViCmd { E(K::Backspace, M::NONE) => Some(ViCmd {
register: Default::default(), register: Default::default(),
@@ -1041,15 +1050,6 @@ impl ViMode for ViNormal {
self.clear_cmd(); self.clear_cmd();
None 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) { if let Some(cmd) = common_cmds(key) {
self.clear_cmd(); self.clear_cmd();