Bump version to 0.6.0 and add viewport scrolling to the line editor
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -573,7 +573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shed"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"bitflags",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "shed"
|
||||
description = "A linux shell written in rust"
|
||||
publish = false
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
|
||||
edition = "2024"
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "shed";
|
||||
version = "0.5.0";
|
||||
version = "0.6.0";
|
||||
|
||||
src = self;
|
||||
|
||||
|
||||
@@ -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,12 +1016,23 @@ 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,23 +1007,8 @@ 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 line = self.editor.display_window_joined();
|
||||
let mut new_layout = self.get_layout(&line);
|
||||
|
||||
let pending_seq = self.mode.pending_seq();
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
18
src/shopt.rs
18
src/shopt.rs
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user