Bump to 0.6.1: add Ctrl-D/U half-screen scrolling, Ctrl-G position info, and status messages

This commit is contained in:
2026-03-20 18:44:43 -04:00
parent 939888e579
commit 4b07990fc5
12 changed files with 204 additions and 34 deletions

View File

@@ -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<usize>;
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<usize> {
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<usize> {
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<usize> {
Ok(0)
}
fn original_input(&self) -> &str {

View File

@@ -138,6 +138,13 @@ pub fn split_lines_at(lines: &mut Vec<Line>, pos: Pos) -> Vec<Line> {
rest
}
pub fn split_lines(mut lines: Vec<Line>, pos: Pos) -> (Vec<Line>,Vec<Line>) {
let tail = lines[pos.row].split_off(pos.col);
let mut rest: Vec<Line> = lines.drain(pos.row + 1..).collect();
rest.insert(0, tail);
(lines, rest)
}
pub fn attach_lines(lines: &mut Vec<Line>, other: &mut Vec<Line>) {
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<Line> {
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::<usize>();
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();
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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))
}
}

View File

@@ -116,6 +116,7 @@ pub fn common_cmds(key: E) -> Option<ViCmd> {
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) => {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -1369,7 +1369,12 @@ pub struct MetaTab {
runtime_stop: Option<Instant>,
// pending system messages
system_msg: Vec<String>,
// are drawn above the prompt and survive redraws
system_msg: VecDeque<String>,
// same as system messages,
// but they appear under the prompt and are erased on redraw
status_msg: VecDeque<String>,
// pushd/popd stack
dir_stack: VecDeque<PathBuf>,
@@ -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<String> {
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<String> {
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()
}