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

View File

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

View File

@@ -642,11 +642,6 @@ impl ShedVi {
self.needs_redraw = true;
continue;
} else {
log::debug!(
"Ambiguous key sequence: {:?}, matches: {:?}",
self.pending_keymap,
matches
);
// There is ambiguity. Allow the timeout in the main loop to handle this.
continue;
}
@@ -944,7 +939,7 @@ impl ShedVi {
}
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);
Layout::from_parts(cols, self.prompt.get_ps1(), &to_cursor, line)
}
@@ -1012,24 +1007,9 @@ impl ShedVi {
&& 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<()> {
let line = self.line_text();
let mut new_layout = self.get_layout(&line);
let line = self.editor.display_window_joined();
let mut new_layout = self.get_layout(&line);
let pending_seq = self.mode.pending_seq();
let mut prompt_string_right = self.prompt.psr_expanded.clone();
@@ -1070,7 +1050,7 @@ impl ShedVi {
self
.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
.as_ref()
@@ -1129,15 +1109,30 @@ impl ShedVi {
if let ModeReport::Ex = self.mode.report_mode() {
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.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();
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
// account for wrapping when clearing after a resize.
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
(new_layout.end.col + 1).max(new_layout.cursor.col + 1)
};
self
.completer
.set_prompt_line_context(preceding_width, new_layout.cursor.col);
self.completer
.set_prompt_line_context(preceding_width, new_layout.end.col);
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);
self.focused_history().fuzzy_finder.draw(&mut writer)?;
self.writer = writer;
@@ -1279,7 +1272,6 @@ impl ShedVi {
// Set cursor clamp BEFORE executing the command so that motions
// (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.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 total_lines = lines.len();
let max_num_len = total_lines.to_string().len();
let visible_count = lines.len();
let max_num_len = (offset + visible_count).to_string().len();
lines
.into_iter()
.enumerate()
@@ -81,7 +81,7 @@ fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String {
acc.push_str(ln);
acc.push('\n');
} else {
let num = (i + 1).to_string();
let num = (i + offset + 1).to_string();
let num_pad = max_num_len - num.len();
// " 2 | " — num + padding + " | "
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 {
" ".repeat(prefix_len + 1).to_string()
};
if i == total_lines - 1 {
if i == visible_count - 1 {
write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
} else {
writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
@@ -220,7 +220,7 @@ pub trait KeyReader {
pub trait LineWriter {
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 send_bell(&mut self) -> ShResult<()>;
}
@@ -541,7 +541,6 @@ impl Perform for KeyCollector {
// SS3 sequences
if byte == b'O' {
self.ss3_pending = true;
return;
}
}
}
@@ -1095,7 +1094,7 @@ impl LineWriter for TermWriter {
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 = |_| {
ShErr::simple(
ShErrKind::InternalErr,
@@ -1121,7 +1120,7 @@ impl LineWriter for TermWriter {
if multiline {
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 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);
} else {
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_vw_doesnt_crash : "" => "vw" => "", 0;
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]

View File

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