From 4b07990fc58f11f88ffe0dd17f589444512a134a Mon Sep 17 00:00:00 2001 From: pagedmov Date: Fri, 20 Mar 2026 18:44:43 -0400 Subject: [PATCH] Bump to 0.6.1: add Ctrl-D/U half-screen scrolling, Ctrl-G position info, and status messages --- Cargo.lock | 2 +- Cargo.toml | 2 +- flake.nix | 2 +- src/readline/complete.rs | 14 +++---- src/readline/linebuf.rs | 50 +++++++++++++++++++++- src/readline/mod.rs | 79 +++++++++++++++++++++++++++-------- src/readline/vicmd.rs | 3 ++ src/readline/vimode/ex.rs | 2 +- src/readline/vimode/mod.rs | 1 + src/readline/vimode/normal.rs | 30 +++++++++++++ src/readline/vimode/visual.rs | 30 +++++++++++++ src/state.rs | 23 ++++++++-- 12 files changed, 204 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2475f92..cd79d53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ dependencies = [ [[package]] name = "shed" -version = "0.6.0" +version = "0.6.1" dependencies = [ "ariadne", "bitflags", diff --git a/Cargo.toml b/Cargo.toml index 74a841b..5678222 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "shed" description = "A linux shell written in rust" publish = false -version = "0.6.0" +version = "0.6.1" edition = "2024" diff --git a/flake.nix b/flake.nix index dd1ff48..4beda02 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,7 @@ { packages.default = pkgs.rustPlatform.buildRustPackage { pname = "shed"; - version = "0.6.0"; + version = "0.6.1"; src = self; diff --git a/src/readline/complete.rs b/src/readline/complete.rs index c617334..b812b8c 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -734,7 +734,7 @@ pub trait Completer { let (s, e) = self.token_span(); orig.get(s..e).unwrap_or(orig) } - fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>; + fn draw(&mut self, writer: &mut TermWriter) -> ShResult; fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) } @@ -1122,9 +1122,9 @@ impl FuzzySelector { } } - pub fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { + pub fn draw(&mut self, writer: &mut TermWriter) -> ShResult { if !self.active { - return Ok(()); + return Ok(0); } let (cols, _) = get_win_size(*TTY_FILENO); @@ -1272,7 +1272,7 @@ impl FuzzySelector { writer.flush_write(&buf)?; self.old_layout = Some(new_layout); - Ok(()) + Ok(rows as usize) } pub fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { @@ -1421,7 +1421,7 @@ impl Completer for FuzzyCompleter { fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { self.selector.clear(writer) } - fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { + fn draw(&mut self, writer: &mut TermWriter) -> ShResult { self.selector.draw(writer) } fn reset(&mut self) { @@ -1497,8 +1497,8 @@ impl Completer for SimpleCompleter { self.token_span } - fn draw(&mut self, _writer: &mut TermWriter) -> ShResult<()> { - Ok(()) + fn draw(&mut self, _writer: &mut TermWriter) -> ShResult { + Ok(0) } fn original_input(&self) -> &str { diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index fff81fd..aa5d580 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -138,6 +138,13 @@ pub fn split_lines_at(lines: &mut Vec, pos: Pos) -> Vec { rest } +pub fn split_lines(mut lines: Vec, pos: Pos) -> (Vec,Vec) { + let tail = lines[pos.row].split_off(pos.col); + let mut rest: Vec = lines.drain(pos.row + 1..).collect(); + rest.insert(0, tail); + (lines, rest) +} + pub fn attach_lines(lines: &mut Vec, other: &mut Vec) { if other.is_empty() { return; @@ -1860,6 +1867,16 @@ impl LineBuf { let (s, e) = ordered(*s, *e); Some(MotionKind::Block { start: s, end: e }) } + dir @ (Motion::HalfScreenUp | Motion::HalfScreenDown) => { + let off = match dir { + Motion::HalfScreenUp => -(self.get_viewport_height() as isize / 2), + Motion::HalfScreenDown => self.get_viewport_height() as isize / 2, + _ => unreachable!(), + }; + let row = self.row(); + let target_row = self.offset_row(off); + Some(MotionKind::Line { start: target_row, end: row, inclusive: false }) + } Motion::RepeatMotion | Motion::RepeatMotionRev => unreachable!("Repeat motions should have been resolved in readline/mod.rs"), Motion::Global(val) | @@ -1907,7 +1924,6 @@ 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, @@ -2484,9 +2500,31 @@ impl LineBuf { } } + Verb::EndOfFile => { + self.lines.clear(); + } + + Verb::PrintPosition => { + let num_lines = self.lines.len(); + let row = self.row() + 1; + let col = self.col() + 1; + let total_graphemes = self.count_graphemes(); + let (left,_) = split_lines(self.lines.clone(), self.cursor.pos); + let total_in_left = left.iter().map(|l| l.len()).sum::(); + let percentage = if total_graphemes > 0 { + (total_in_left as f64 / total_graphemes as f64) * 100.0 + } else { + 100.0 + }.round() as usize; + + let msg = format!("line: {row}/{num_lines}, col: {col} --{percentage}%--"); + write_meta(|m| { + m.post_status_message(msg); + }) + } + Verb::Complete | Verb::ExMode - | Verb::EndOfFile | Verb::InsertMode | Verb::NormalMode | Verb::VisualMode @@ -2600,7 +2638,13 @@ impl LineBuf { } pub fn fix_cursor(&mut self) { + // we are now going to enforce some invariants and do some bookkeeping + if self.lines.is_empty() { + // self.lines must always have at least one line + self.lines.push(Line::default()); + } if self.cursor.pos.row >= self.lines.len() { + // clamp this now so self.cur_line() cannot panic self.cursor.pos.row = self.lines.len().saturating_sub(1); } if self.cursor.exclusive { @@ -2616,6 +2660,8 @@ impl LineBuf { self.cursor.pos.col = line.len(); } } + + // update viewport scroll offset self.update_scroll_offset(); } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 9fb8cf8..16f977c 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -1,6 +1,7 @@ use history::History; use keys::{KeyCode, KeyEvent, ModKeys}; use linebuf::{LineBuf, SelectMode}; +use std::collections::VecDeque; use std::fmt::Write; use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size}; use unicode_width::UnicodeWidthStr; @@ -16,8 +17,7 @@ use crate::readline::complete::{FuzzyCompleter, SelectorResponse}; use crate::readline::term::{Pos, TermReader, calc_str_width}; use crate::readline::vimode::{ViEx, ViVerbatim}; use crate::state::{ - AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, - write_vars, + AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_meta, read_shopts, with_vars, write_meta, write_vars }; use crate::{ libsh::error::ShResult, @@ -272,6 +272,8 @@ pub struct ShedVi { pub ex_history: History, pub needs_redraw: bool, + pub ctrl_d_warning_counter: usize, + pub status_msgs: VecDeque<(String, Instant)> } impl ShedVi { @@ -293,6 +295,8 @@ impl ShedVi { history: History::new()?, ex_history: History::empty(), needs_redraw: true, + ctrl_d_warning_counter: 0, + status_msgs: VecDeque::new() }; write_vars(|v| { v.set_var( @@ -325,6 +329,8 @@ impl ShedVi { history: History::empty(), ex_history: History::empty(), needs_redraw: true, + ctrl_d_warning_counter: 0, + status_msgs: VecDeque::new() }; write_vars(|v| { v.set_var( @@ -883,17 +889,10 @@ impl ShedVi { return Ok(Some(ReadlineEvent::Line(buf))); } - if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) { - if self.focused_editor().joined().is_empty() { - return Ok(Some(ReadlineEvent::Eof)); - } else { - *self.focused_editor() = LineBuf::new(); - self.mode = Box::new(ViInsert::new()); - self.needs_redraw = true; - return Ok(None); - } - } else if cmd.verb().is_some_and(|v| v.1 == Verb::Quit) { - return Ok(Some(ReadlineEvent::Eof)); + if (cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) + && self.focused_editor().joined().is_empty()) + || cmd.verb().is_some_and(|v| v.1 == Verb::Quit) { + return Ok(Some(ReadlineEvent::Eof)); } // check if it's an edit @@ -902,6 +901,8 @@ impl ShedVi { // this is only used for ringing the bell let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit() && v.1 != Verb::Change); + let is_ctrl_d_motion = cmd.motion().is_some_and(|m| m.1 == Motion::HalfScreenDown); + let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_))); let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD); if is_shell_cmd { @@ -913,6 +914,7 @@ impl ShedVi { } let before = self.editor.joined(); + let before_cursor = self.editor.cursor; self.exec_cmd(cmd, false)?; @@ -922,6 +924,7 @@ impl ShedVi { } } let after = self.editor.joined(); + let after_cursor = self.editor.cursor; if before != after { self @@ -929,7 +932,18 @@ impl ShedVi { .update_pending_cmd((&self.editor.joined(), self.editor.cursor_to_flat())); } else if before == after && has_edit_verb { self.writer.send_bell().ok(); - } + } else if before_cursor == after_cursor && is_ctrl_d_motion { + if self.ctrl_d_warning_counter == 3 || self.editor.is_empty() { + // our silly user is spamming ctrl+d for some reason + // maybe they want to exit the shell? + write_meta(|m| { + m.post_status_message("Ctrl+D only quits in insert mode. try ':q' or entering insert mode with 'i'".into()) + }); + self.ctrl_d_warning_counter = 0; + } else { + self.ctrl_d_warning_counter += 1; + } + } let hint = self.history.get_hint(); @@ -1126,7 +1140,9 @@ impl ShedVi { 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 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"))?; @@ -1141,9 +1157,11 @@ impl ShedVi { // Without PSR, use the content width on the cursor's row (new_layout.end.col + 1).max(new_layout.cursor.col + 1) }; + + let mut fuzzy_window_rows = 0; self.completer .set_prompt_line_context(preceding_width, new_layout.end.col); - self.completer.draw(&mut self.writer)?; + fuzzy_window_rows += self.completer.draw(&mut self.writer)?; { self.focused_history() @@ -1151,10 +1169,37 @@ impl ShedVi { .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)?; + fuzzy_window_rows += self.focused_history().fuzzy_finder.draw(&mut writer)?; self.writer = writer; } + while let Some(msg) = write_meta(|m| m.pop_status_message()) { + let now = Instant::now(); + self.status_msgs.push_back((msg,now)); + } + + while let Some((msg,time)) = self.status_msgs.front() { + if time.elapsed().as_secs() < 5 { + log::debug!("drawing status message: {msg}"); + let down = new_layout.end.row - new_layout.cursor.row; + log::debug!("status message down: {down}"); + let fuzzy_rows = fuzzy_window_rows.saturating_sub(1); // the cursor is one row below the top + let total = down.saturating_add(fuzzy_rows as u16); + let move_down = if total > 0 { + format!("\x1b[{total}B") + } else { + String::new() + }; + let move_up = total + 2; + let col = new_layout.cursor.col + 1; + self.writer.flush_write(&format!("{move_down}\n\n\x1b7\x1b[2K{msg}\x1b8\x1b[{move_up}A\x1b[{col}G"))?; + new_layout.end.row += (2 + msg.chars().filter(|c| *c == '\n').count()) as u16; + break + } else { + self.status_msgs.pop_front(); + } + } + self.old_layout = Some(new_layout); self.needs_redraw = false; diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index a528c26..393bbcc 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -243,6 +243,7 @@ pub enum Verb { Equalize, AcceptLineOrNewline, EndOfFile, + PrintPosition, // Ex-mode verbs ExMode, ShellCmd(String), @@ -335,6 +336,8 @@ pub enum Motion { EndOfBuffer, ToColumn, ToDelimMatch, + HalfScreenDown, + HalfScreenUp, ToBrace(Direction), ToBracket(Direction), ToParen(Direction), diff --git a/src/readline/vimode/ex.rs b/src/readline/vimode/ex.rs index 9dfed65..e2dc258 100644 --- a/src/readline/vimode/ex.rs +++ b/src/readline/vimode/ex.rs @@ -120,7 +120,7 @@ impl ViMode for ViEx { Ok(cmd) => Ok(cmd), Err(e) => { let msg = e.unwrap_or(format!("Not an editor command: {}", &input)); - write_meta(|m| m.post_system_message(msg.clone())); + write_meta(|m| m.post_status_message(msg.clone())); Err(ShErr::simple(ShErrKind::ParseErr, msg)) } } diff --git a/src/readline/vimode/mod.rs b/src/readline/vimode/mod.rs index e9a62fc..16c568a 100644 --- a/src/readline/vimode/mod.rs +++ b/src/readline/vimode/mod.rs @@ -116,6 +116,7 @@ pub fn common_cmds(key: E) -> Option { E(K::Right, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::ForwardChar)), E(K::Up, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::LineUp)), E(K::Down, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::LineDown)), + E(K::Enter, M::SHIFT) => pending_cmd.set_verb(VerbCmd(1, Verb::InsertChar('\n'))), E(K::Enter, M::NONE) => pending_cmd.set_verb(VerbCmd(1, Verb::AcceptLineOrNewline)), E(K::Char('D'), M::CTRL) => pending_cmd.set_verb(VerbCmd(1, Verb::EndOfFile)), E(K::Delete, M::NONE) => { diff --git a/src/readline/vimode/normal.rs b/src/readline/vimode/normal.rs index 132d8fd..ea42df0 100644 --- a/src/readline/vimode/normal.rs +++ b/src/readline/vimode/normal.rs @@ -776,6 +776,36 @@ impl ViMode for ViNormal { flags: self.flags(), }) } + E(K::Char('G'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::PrintPosition)), + motion: None, + raw_seq: "".into(), + flags: self.flags(), + }) + } + E(K::Char('D'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: None, + motion: Some(MotionCmd(1, Motion::HalfScreenDown)), + raw_seq: "".into(), + flags: self.flags(), + }) + } + E(K::Char('U'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: None, + motion: Some(MotionCmd(1, Motion::HalfScreenUp)), + raw_seq: "".into(), + flags: self.flags(), + }) + } E(K::Char(ch), M::NONE) => self.try_parse(ch), E(K::Backspace, M::NONE) => Some(ViCmd { diff --git a/src/readline/vimode/visual.rs b/src/readline/vimode/visual.rs index 7f4138f..060ddea 100644 --- a/src/readline/vimode/visual.rs +++ b/src/readline/vimode/visual.rs @@ -663,6 +663,36 @@ impl ViMode for ViVisual { flags: CmdFlags::empty(), }) } + E(K::Char('G'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::PrintPosition)), + motion: None, + raw_seq: "".into(), + flags: CmdFlags::empty() + }) + } + E(K::Char('D'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: None, + motion: Some(MotionCmd(1, Motion::HalfScreenDown)), + raw_seq: "".into(), + flags: CmdFlags::empty(), + }) + } + E(K::Char('U'), M::CTRL) => { + self.pending_seq.clear(); + Some(ViCmd { + register: Default::default(), + verb: None, + motion: Some(MotionCmd(1, Motion::HalfScreenUp)), + raw_seq: "".into(), + flags: CmdFlags::empty(), + }) + } E(K::Char('R'), M::CTRL) => { let mut chars = self.pending_seq.chars().peekable(); let count = self.parse_count(&mut chars).unwrap_or(1); diff --git a/src/state.rs b/src/state.rs index 211e607..9a84df8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1369,7 +1369,12 @@ pub struct MetaTab { runtime_stop: Option, // pending system messages - system_msg: Vec, + // are drawn above the prompt and survive redraws + system_msg: VecDeque, + + // same as system messages, + // but they appear under the prompt and are erased on redraw + status_msg: VecDeque, // pushd/popd stack dir_stack: VecDeque, @@ -1394,7 +1399,8 @@ impl Default for MetaTab { shell_time: Instant::now(), runtime_start: None, runtime_stop: None, - system_msg: vec![], + system_msg: VecDeque::new(), + status_msg: VecDeque::new(), dir_stack: VecDeque::new(), getopts_offset: 0, old_path: None, @@ -1614,14 +1620,23 @@ impl MetaTab { } } pub fn post_system_message(&mut self, message: String) { - self.system_msg.push(message); + self.system_msg.push_back(message); } pub fn pop_system_message(&mut self) -> Option { - self.system_msg.pop() + self.system_msg.pop_front() } pub fn system_msg_pending(&self) -> bool { !self.system_msg.is_empty() } + pub fn post_status_message(&mut self, message: String) { + self.status_msg.push_back(message); + } + pub fn pop_status_message(&mut self) -> Option { + self.status_msg.pop_front() + } + pub fn status_msg_pending(&self) -> bool { + !self.status_msg.is_empty() + } pub fn dir_stack_top(&self) -> Option<&PathBuf> { self.dir_stack.front() }