Bump version to 0.6.0 and add viewport scrolling to the line editor

This commit is contained in:
2026-03-20 16:09:02 -04:00
parent d83cda616b
commit 939888e579
8 changed files with 173 additions and 62 deletions

2
Cargo.lock generated
View File

@@ -573,7 +573,7 @@ dependencies = [
[[package]] [[package]]
name = "shed" name = "shed"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"bitflags", "bitflags",

View File

@@ -2,7 +2,7 @@
name = "shed" name = "shed"
description = "A linux shell written in rust" description = "A linux shell written in rust"
publish = false publish = false
version = "0.5.0" version = "0.6.0"
edition = "2024" edition = "2024"

View File

@@ -14,7 +14,7 @@
{ {
packages.default = pkgs.rustPlatform.buildRustPackage { packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "shed"; pname = "shed";
version = "0.5.0"; version = "0.6.0";
src = self; src = self;

View File

@@ -21,13 +21,13 @@ use crate::{
prelude::*, prelude::*,
procio::{IoFrame, IoMode, IoStack}, procio::{IoFrame, IoMode, IoStack},
readline::{ readline::{
markers, highlight::Highlighter, markers, register::RegisterContent, term::get_win_size, vicmd::{ReadSrc, VerbCmd, WriteDest}
register::RegisterContent, 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 PUNCTUATION: [&str; 3] = ["?", "!", "."];
const DEFAULT_VIEWPORT_HEIGHT: usize = 40;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Grapheme(SmallVec<[char; 4]>); pub struct Grapheme(SmallVec<[char; 4]>);
@@ -517,6 +517,8 @@ pub struct LineBuf {
pub saved_col: Option<usize>, pub saved_col: Option<usize>,
pub indent_ctx: IndentCtx, pub indent_ctx: IndentCtx,
pub scroll_offset: usize,
pub undo_stack: Vec<Edit>, pub undo_stack: Vec<Edit>,
pub redo_stack: Vec<Edit>, pub redo_stack: Vec<Edit>,
} }
@@ -535,6 +537,7 @@ impl Default for LineBuf {
insert_mode_start_pos: None, insert_mode_start_pos: None,
saved_col: None, saved_col: None,
indent_ctx: IndentCtx::new(), indent_ctx: IndentCtx::new(),
scroll_offset: 0,
undo_stack: vec![], undo_stack: vec![],
redo_stack: vec![], redo_stack: vec![],
} }
@@ -545,6 +548,86 @@ impl LineBuf {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() 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::<usize>() {
num
} else if let Some(pre) = height.strip_suffix('%')
&& let Ok(num) = pre.parse::<usize>() {
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<Line> {
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<String> {
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 { pub fn is_empty(&self) -> bool {
self.lines.len() == 0 || (self.lines.len() == 1 && self.count_graphemes() == 0) self.lines.len() == 0 || (self.lines.len() == 1 && self.count_graphemes() == 0)
} }
@@ -864,7 +947,10 @@ impl LineBuf {
match (to, dir) { match (to, dir) {
(To::Start, Direction::Forward) => { (To::Start, Direction::Forward) => {
target = self 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(|| { .unwrap_or_else(|| {
// we set inclusive to true so that we catch the entire word // we set inclusive to true so that we catch the entire word
// instead of ignoring the last character // instead of ignoring the last character
@@ -895,7 +981,13 @@ impl LineBuf {
inclusive, inclusive,
}) })
} }
fn word_motion_w(&self, word: &Word, start: Pos, ignore_trailing_ws: bool) -> Option<Pos> { fn word_motion_w(
&self,
word: &Word,
start: Pos,
ignore_trailing_ws: bool,
inclusive: &mut bool,
) -> Option<Pos> {
use CharClass as C; use CharClass as C;
// get our iterator of char classes // get our iterator of char classes
@@ -924,13 +1016,24 @@ impl LineBuf {
} }
// go forward until we find some char class that isnt this one // go forward until we find some char class that isnt this one
let first_c = classes.next()?.1; let mut last = classes.next()?;
let first_c = last.1;
match classes.find(|(_, c)| c.is_other_class_or_ws(&first_c))? { while let Some((p,c)) = classes.next() {
(pos, C::Whitespace) if ignore_trailing_ws => return Some(pos), match c {
(_, C::Whitespace) => { /* fall through */ } C::Whitespace => {
(pos, _) => return Some(pos), 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 // we found whitespace previously, look for the next non-whitespace char class
classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p) classes.find(|(_, c)| !c.is_ws()).map(|(p, _)| p)
@@ -1804,6 +1907,7 @@ impl LineBuf {
Ok(()) Ok(())
} }
fn extract_range(&mut self, motion: &MotionKind) -> Vec<Line> { fn extract_range(&mut self, motion: &MotionKind) -> Vec<Line> {
log::debug!("Extracting range for motion: {:?}", motion);
let extracted = match motion { let extracted = match motion {
MotionKind::Char { MotionKind::Char {
start, start,
@@ -2512,6 +2616,7 @@ impl LineBuf {
self.cursor.pos.col = line.len(); self.cursor.pos.col = line.len();
} }
} }
self.update_scroll_offset();
} }
pub fn joined(&self) -> String { pub fn joined(&self) -> String {

View File

@@ -642,11 +642,6 @@ impl ShedVi {
self.needs_redraw = true; self.needs_redraw = true;
continue; continue;
} else { } else {
log::debug!(
"Ambiguous key sequence: {:?}, matches: {:?}",
self.pending_keymap,
matches
);
// There is ambiguity. Allow the timeout in the main loop to handle this. // There is ambiguity. Allow the timeout in the main loop to handle this.
continue; continue;
} }
@@ -944,7 +939,7 @@ impl ShedVi {
} }
pub fn get_layout(&mut self, line: &str) -> Layout { 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); let (cols, _) = get_win_size(self.tty);
Layout::from_parts(cols, self.prompt.get_ps1(), &to_cursor, line) Layout::from_parts(cols, self.prompt.get_ps1(), &to_cursor, line)
} }
@@ -1012,24 +1007,9 @@ impl ShedVi {
&& self.editor.on_last_line()) && 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<()> { pub fn print_line(&mut self, final_draw: bool) -> ShResult<()> {
let line = self.line_text(); let line = self.editor.display_window_joined();
let mut new_layout = self.get_layout(&line); let mut 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();
@@ -1070,7 +1050,7 @@ impl ShedVi {
self self
.writer .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 let seq_fits = pending_seq
.as_ref() .as_ref()
@@ -1129,15 +1109,30 @@ impl ShedVi {
if let ModeReport::Ex = self.mode.report_mode() { if let ModeReport::Ex = self.mode.report_mode() {
let pending_seq = self.mode.pending_seq().unwrap_or_default(); 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.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(); write!(buf, "{}", &self.mode.cursor_style()).unwrap();
self.writer.flush_write(&buf)?; 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 // Tell the completer the width of the prompt line above its \n so it can
// account for wrapping when clearing after a resize. // account for wrapping when clearing after a resize.
let preceding_width = if new_layout.psr_end.is_some() { 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 // Without PSR, use the content width on the cursor's row
(new_layout.end.col + 1).max(new_layout.cursor.col + 1) (new_layout.end.col + 1).max(new_layout.cursor.col + 1)
}; };
self self.completer
.completer .set_prompt_line_context(preceding_width, new_layout.end.col);
.set_prompt_line_context(preceding_width, new_layout.cursor.col);
self.completer.draw(&mut self.writer)?; 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); let mut writer = std::mem::take(&mut self.writer);
self.focused_history().fuzzy_finder.draw(&mut writer)?; self.focused_history().fuzzy_finder.draw(&mut writer)?;
self.writer = writer; self.writer = writer;
@@ -1279,7 +1272,6 @@ impl ShedVi {
// Set cursor clamp BEFORE executing the command so that motions // Set cursor clamp BEFORE executing the command so that motions
// (like EndOfLine for 'A') can reach positions valid in the new mode // (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.set_cursor_clamp(self.mode.clamp_cursor());
self.editor.exec_cmd(cmd)?; self.editor.exec_cmd(cmd)?;

View File

@@ -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 lines: Vec<&str> = s.split('\n').collect();
let total_lines = lines.len(); let visible_count = lines.len();
let max_num_len = total_lines.to_string().len(); let max_num_len = (offset + visible_count).to_string().len();
lines lines
.into_iter() .into_iter()
.enumerate() .enumerate()
@@ -81,7 +81,7 @@ fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String {
acc.push_str(ln); acc.push_str(ln);
acc.push('\n'); acc.push('\n');
} else { } else {
let num = (i + 1).to_string(); let num = (i + offset + 1).to_string();
let num_pad = max_num_len - num.len(); let num_pad = max_num_len - num.len();
// " 2 | " — num + padding + " | " // " 2 | " — num + padding + " | "
let prefix_len = max_num_len + 3; // "N | " 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 { } else {
" ".repeat(prefix_len + 1).to_string() " ".repeat(prefix_len + 1).to_string()
}; };
if i == total_lines - 1 { if i == visible_count - 1 {
write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
} else { } else {
writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
@@ -220,7 +220,7 @@ pub trait KeyReader {
pub trait LineWriter { pub trait LineWriter {
fn clear_rows(&mut self, layout: &Layout) -> ShResult<()>; 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 flush_write(&mut self, buf: &str) -> ShResult<()>;
fn send_bell(&mut self) -> ShResult<()>; fn send_bell(&mut self) -> ShResult<()>;
} }
@@ -541,7 +541,6 @@ impl Perform for KeyCollector {
// SS3 sequences // SS3 sequences
if byte == b'O' { if byte == b'O' {
self.ss3_pending = true; self.ss3_pending = true;
return;
} }
} }
} }
@@ -1095,7 +1094,7 @@ impl LineWriter for TermWriter {
Ok(()) 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 = |_| { let err = |_| {
ShErr::simple( ShErr::simple(
ShErrKind::InternalErr, ShErrKind::InternalErr,
@@ -1121,7 +1120,7 @@ impl LineWriter for TermWriter {
if multiline { if multiline {
let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0, false); 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 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); self.buffer.push_str(&display_line);
} else { } else {
self.buffer.push_str(line); self.buffer.push_str(line);

View File

@@ -497,7 +497,8 @@ vi_test! {
vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4; vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4;
vi_vw_doesnt_crash : "" => "vw" => "", 0; vi_vw_doesnt_crash : "" => "vw" => "", 0;
vi_indent_cursor_pos : "echo foo" => ">>" => "\techo foo", 1; 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] #[test]

View File

@@ -149,16 +149,17 @@ macro_rules! shopt_group {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ShOpts { pub struct ShOpts {
pub core: ShOptCore, pub core: ShOptCore,
pub line: ShOptLine,
pub prompt: ShOptPrompt, pub prompt: ShOptPrompt,
} }
impl Default for ShOpts { impl Default for ShOpts {
fn default() -> Self { fn default() -> Self {
let core = ShOptCore::default(); let core = ShOptCore::default();
let line = ShOptLine::default();
let prompt = ShOptPrompt::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<String> { pub fn display_opts(&mut self) -> ShResult<String> {
let output = [ let output = [
self.query("core")?.unwrap_or_default().to_string(), self.query("core")?.unwrap_or_default().to_string(),
self.query("line")?.unwrap_or_default().to_string(),
self.query("prompt")?.unwrap_or_default().to_string(), self.query("prompt")?.unwrap_or_default().to_string(),
]; ];
@@ -194,6 +196,7 @@ impl ShOpts {
match key { match key {
"core" => self.core.set(&remainder, val)?, "core" => self.core.set(&remainder, val)?,
"line" => self.line.set(&remainder, val)?,
"prompt" => self.prompt.set(&remainder, val)?, "prompt" => self.prompt.set(&remainder, val)?,
_ => { _ => {
return Err(ShErr::simple( return Err(ShErr::simple(
@@ -218,6 +221,7 @@ impl ShOpts {
match key { match key {
"core" => self.core.get(&remainder), "core" => self.core.get(&remainder),
"line" => self.line.get(&remainder),
"prompt" => self.prompt.get(&remainder), "prompt" => self.prompt.get(&remainder),
_ => Err(ShErr::simple( _ => Err(ShErr::simple(
ShErrKind::SyntaxErr, 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! { shopt_group! {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ShOptCore ("core") { pub struct ShOptCore ("core") {