Implemented a gutter with line numbers for multi-line editing

This commit is contained in:
2026-02-25 19:55:48 -05:00
parent 85951c4acc
commit b013a9513d
6 changed files with 96 additions and 40 deletions

View File

@@ -1,18 +1,18 @@
# shed # shed
A Unix shell written in Rust. The name is a nod to the two oldest Unix utilities — `sh` and `ed` — reflecting the shell's emphasis on a capable built-in line editor alongside standard shell functionality. A Unix shell written in Rust. The name is a nod to both `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing.
## Features ## Features
### Line Editor ### Line Editor
`shed` includes a built-in `vim` emulator as its line editor, written from scratch — not a readline wrapper or external library. It aims to provide a more precise vim-like editing experience at the shell prompt. `shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt.
- **Normal mode** motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts - **Normal mode** - motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts
- **Insert mode** insert, append, replace, with Ctrl+W word deletion and undo/redo - **Insert mode** - insert, append, replace, with Ctrl+W word deletion and undo/redo
- **Visual mode** character-wise selection with operator support - **Visual mode** - character-wise selection with operator support
- **Real-time syntax highlighting** commands, keywords, strings, variables, redirections, and operators are colored as you type - **Real-time syntax highlighting** - commands, keywords, strings, variables, redirections, and operators are colored as you type
- **Tab completion** context-aware completion for commands, file paths, and variables - **Tab completion** - context-aware completion for commands, file paths, and variables
### Prompt ### Prompt
@@ -29,7 +29,7 @@ The prompt string supports escape sequences for dynamic content:
| `\e[...` | ANSI escape sequences for colors and styling | | `\e[...` | ANSI escape sequences for colors and styling |
| `\!name` | Execute a shell function and embed its output | | `\!name` | Execute a shell function and embed its output |
The `\!` escape is particularly useful — it lets you embed the output of any shell function directly in your prompt. Define a function that prints something, then reference it in your prompt string: The `\!` escape is particularly useful. It lets you embed the output of any shell function directly in your prompt. Define a function that prints something, then reference it in your prompt string:
```sh ```sh
gitbranch() { git branch --show-current 2>/dev/null; } gitbranch() { git branch --show-current 2>/dev/null; }
@@ -42,20 +42,20 @@ Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, s
shed's scripting language contains all of the essentials. shed's scripting language contains all of the essentials.
- **Control flow** `if`/`elif`/`else`, `for`, `while`, `until`, `case` with pattern matching and fallthrough - **Control flow** - `if`/`elif`/`else`, `for`, `while`, `until`, `case` with pattern matching and fallthrough
- **Functions** user-defined with local variable scoping, recursion depth limits, and `return` - **Functions** - user-defined with local variable scoping, recursion depth limits, and `return`
- **Pipes and redirections** `|`, `|&` (pipe stderr), `<`, `>`, `>>`, `<<` (heredoc), `<<<` (herestring), fd duplication (`2>&1`) - **Pipes and redirections** - `|`, `|&` (pipe stderr), `<`, `>`, `>>`, `<<` (heredoc), `<<<` (herestring), fd duplication (`2>&1`)
- **Process substitution** `<(...)` and `>(...)` - **Process substitution** - `<(...)` and `>(...)`
- **Command substitution** `$(...)` and backticks - **Command substitution** - `$(...)` and backticks
- **Arithmetic expansion** `$((...))` with `+`, `-`, `*`, `/`, `%`, `**` - **Arithmetic expansion** - `$((...))` with `+`, `-`, `*`, `/`, `%`, `**`
- **Parameter expansion** `${var}`, `${var:-default}`, `${var:=default}`, `${var:+alt}`, `${var:?err}`, `${#var}`, `${var#pattern}`, `${var%pattern}`, `${var/pat/rep}` - **Parameter expansion** - `${var}`, `${var:-default}`, `${var:=default}`, `${var:+alt}`, `${var:?err}`, `${#var}`, `${var#pattern}`, `${var%pattern}`, `${var/pat/rep}`
- **Brace expansion** `{a,b,c}`, `{1..10}`, `{1..10..2}` - **Brace expansion** - `{a,b,c}`, `{1..10}`, `{1..10..2}`
- **Glob expansion** `*`, `?`, `[...]` with optional dotglob - **Glob expansion** - `*`, `?`, `[...]` with optional dotglob
- **Tilde expansion** `~` and `~user` - **Tilde expansion** - `~` and `~user`
- **Logical operators** `&&`, `||`, `&` (background) - **Logical operators** - `&&`, `||`, `&` (background)
- **Test expressions** `[[ ... ]]` with file tests, string comparison, arithmetic comparison, and regex matching - **Test expressions** - `[[ ... ]]` with file tests, string comparison, arithmetic comparison, and regex matching
- **Subshells** `(...)` for isolated execution - **Subshells** - `(...)` for isolated execution
- **Variable attributes** `export`, `local`, `readonly` - **Variable attributes** - `export`, `local`, `readonly`
### Job Control ### Job Control
@@ -126,8 +126,8 @@ imports = [ shed.homeModules.shed ];
## Status ## Status
`shed` is experimental software and is currently under active development. It covers most day-to-day interactive shell usage and a good portion of POSIX shell scripting, but it is not yet fully POSIX-compliant. `shed` is experimental software and is currently under active development. It covers most day-to-day interactive shell usage and a good portion of POSIX shell scripting, but it is not yet fully POSIX-compliant. There is no guarantee that your computer will not explode when you run this. Use it at your own risk, the software is provided as-is.
## Why shed? ## Why shed?
This originally started as an educational hobby project, but over the course of about a year or so it's taken the form of an actual daily-drivable shell. I mainly wanted to create a shell where line editing is more frictionless than standard choices. I use vim a lot so I've built up a lot of muscle memory, and a fair amount of that muscle memory does not apply to vi modes in `bash`/`zsh`. For instance, the standard vi mode in `zsh` does not support selection via text objects. I wanted to create a line editor that includes even the obscure stuff like 'g?'. This originally started as an educational hobby project, but over the course of about a year or so it's taken the form of an actual daily-drivable shell. I mainly wanted to create a shell where line editing is more frictionless than standard choices. I use vim a lot so I've built up a lot of muscle memory, and a fair amount of that muscle memory does not apply to vi modes in `bash`/`zsh`. For instance, the standard vi mode in `zsh` does not support selection via text objects. I wanted to create a line editor that actually feels like you're in an editor.

View File

@@ -691,7 +691,6 @@ impl Dispatcher {
if fork_builtin { if fork_builtin {
cmd.flags |= NdFlags::FORK_BUILTINS; cmd.flags |= NdFlags::FORK_BUILTINS;
} }
log::debug!("current io_frame stack: {:#?}", self.io_stack.curr_frame());
self.dispatch_node(cmd)?; self.dispatch_node(cmd)?;
} }
let job = self.job_stack.finalize_job().unwrap(); let job = self.job_stack.finalize_job().unwrap();
@@ -812,6 +811,7 @@ impl Dispatcher {
let no_fork = cmd.flags.contains(NdFlags::NO_FORK); let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
if argv.is_empty() { if argv.is_empty() {
state::set_status(0);
return Ok(()); return Ok(());
} }

View File

@@ -2329,7 +2329,14 @@ impl LineBuf {
end = self.cursor.get(); end = self.cursor.get();
} }
}, },
SelectMode::Line(anchor) => todo!(), 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 {
@@ -3008,11 +3015,13 @@ impl LineBuf {
} }
pub fn get_hint_text(&self) -> String { pub fn get_hint_text(&self) -> String {
self let text = self
.hint .hint
.clone() .clone()
.map(|h| h.styled(Style::BrightBlack)) .map(|h| format!("\x1b[90m{h}\x1b[0m"))
.unwrap_or_default() .unwrap_or_default();
text.replace("\n", "\n\x1b[90m")
} }
} }

View File

@@ -513,7 +513,6 @@ impl ShedVi {
let new_layout = self.get_layout(&line); let new_layout = self.get_layout(&line);
let pending_seq = self.mode.pending_seq(); let pending_seq = self.mode.pending_seq();
let mut prompt_string_right = self.prompt.psr_expanded.clone(); let mut prompt_string_right = self.prompt.psr_expanded.clone();
log::debug!("prompt_string_right before truncation: {prompt_string_right:?}");
if prompt_string_right.as_ref().is_some_and(|psr| psr.lines().count() > 1) { if prompt_string_right.as_ref().is_some_and(|psr| psr.lines().count() > 1) {
log::warn!("PSR has multiple lines, truncating to one line"); log::warn!("PSR has multiple lines, truncating to one line");
@@ -524,7 +523,7 @@ impl ShedVi {
.get_ps1() .get_ps1()
.lines() .lines()
.next() .next()
.map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 })) .map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 }, 0))
.map(|p| p.col) .map(|p| p.col)
.unwrap_or_default() as usize; .unwrap_or_default() as usize;
let one_line = new_layout.end.row == 0; let one_line = new_layout.end.row == 0;
@@ -537,7 +536,7 @@ impl ShedVi {
self.writer.redraw(self.prompt.get_ps1(), &line, &new_layout)?; self.writer.redraw(self.prompt.get_ps1(), &line, &new_layout)?;
let seq_fits = pending_seq.as_ref().is_some_and(|seq| row0_used + 1 < self.writer.t_cols as usize - seq.width()); let seq_fits = pending_seq.as_ref().is_some_and(|seq| row0_used + 1 < self.writer.t_cols as usize - seq.width());
let psr_fits = prompt_string_right.as_ref().is_some_and(|psr| new_layout.end.col as usize + 1 < self.writer.t_cols as usize - psr.width()); let psr_fits = prompt_string_right.as_ref().is_some_and(|psr| new_layout.end.col as usize + 1 < (self.writer.t_cols as usize).saturating_sub(psr.width()));
if !final_draw && let Some(seq) = pending_seq && !seq.is_empty() && !(prompt_string_right.is_some() && one_line) && seq_fits { if !final_draw && let Some(seq) = pending_seq && !seq.is_empty() && !(prompt_string_right.is_some() && one_line) && seq_fits {
let to_col = self.writer.t_cols - calc_str_width(&seq); let to_col = self.writer.t_cols - calc_str_width(&seq);

View File

@@ -100,6 +100,38 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) {
} }
} }
fn enumerate_lines(s: &str, left_pad: usize) -> String {
let total_lines = s.lines().count();
let max_num_len = total_lines.to_string().len();
s.lines()
.enumerate()
.fold(String::new(), |mut acc, (i, ln)| {
if i == 0 {
acc.push_str(ln);
acc.push('\n');
} else {
let num = (i + 1).to_string();
let num_pad = max_num_len - num.len();
// " 2 | " — num + padding + " | "
let prefix_len = max_num_len + 3; // "N | "
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}",
" ".repeat(num_pad),
" ".repeat(trail_pad),
).unwrap();
} else {
writeln!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}",
" ".repeat(num_pad),
" ".repeat(trail_pad),
).unwrap();
}
}
acc
})
}
fn write_all(fd: RawFd, buf: &str) -> nix::Result<()> { fn write_all(fd: RawFd, buf: &str) -> nix::Result<()> {
let mut bytes = buf.as_bytes(); let mut bytes = buf.as_bytes();
while !bytes.is_empty() { while !bytes.is_empty() {
@@ -771,9 +803,9 @@ impl Layout {
} }
} }
pub fn from_parts(term_width: u16, prompt: &str, to_cursor: &str, to_end: &str) -> Self { pub fn from_parts(term_width: u16, prompt: &str, to_cursor: &str, to_end: &str) -> Self {
let prompt_end = Self::calc_pos(term_width, prompt, Pos { col: 0, row: 0 }); let prompt_end = Self::calc_pos(term_width, prompt, Pos { col: 0, row: 0 }, 0);
let cursor = Self::calc_pos(term_width, to_cursor, prompt_end); let cursor = Self::calc_pos(term_width, to_cursor, prompt_end, prompt_end.col);
let end = Self::calc_pos(term_width, to_end, prompt_end); let end = Self::calc_pos(term_width, to_end, prompt_end, prompt_end.col);
Layout { Layout {
w_calc: width_calculator(), w_calc: width_calculator(),
prompt_end, prompt_end,
@@ -782,14 +814,14 @@ impl Layout {
} }
} }
pub fn calc_pos(term_width: u16, s: &str, orig: Pos) -> Pos { pub fn calc_pos(term_width: u16, s: &str, orig: Pos, left_margin: u16) -> Pos {
const TAB_STOP: u16 = 8; const TAB_STOP: u16 = 8;
let mut pos = orig; let mut pos = orig;
let mut esc_seq = 0; let mut esc_seq = 0;
for c in s.graphemes(true) { for c in s.graphemes(true) {
if c == "\n" { if c == "\n" {
pos.row += 1; pos.row += 1;
pos.col = 0; pos.col = left_margin;
} }
let c_width = if c == "\t" { let c_width = if c == "\t" {
TAB_STOP - (pos.col % TAB_STOP) TAB_STOP - (pos.col % TAB_STOP)
@@ -799,12 +831,12 @@ impl Layout {
pos.col += c_width; pos.col += c_width;
if pos.col > term_width { if pos.col > term_width {
pos.row += 1; pos.row += 1;
pos.col = c_width; pos.col = left_margin + c_width;
} }
} }
if pos.col >= term_width { if pos.col >= term_width {
pos.row += 1; pos.row += 1;
pos.col = 0; pos.col = left_margin;
} }
pos pos
@@ -987,7 +1019,14 @@ impl LineWriter for TermWriter {
} }
self.buffer.push_str(prompt); self.buffer.push_str(prompt);
self.buffer.push_str(line); let multiline = line.contains('\n');
if multiline {
let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0);
let display_line = enumerate_lines(line, prompt_end.col as usize);
self.buffer.push_str(&display_line);
} else {
self.buffer.push_str(line);
}
if end.col == 0 && end.row > 0 && !ends_with_newline(&self.buffer) { if end.col == 0 && end.row > 0 && !ends_with_newline(&self.buffer) {
// The line has wrapped. We need to use our own line break. // The line has wrapped. We need to use our own line break.

View File

@@ -1041,6 +1041,15 @@ 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();