From 854e127545e4a6bc4618955d61a47532abf5fbab Mon Sep 17 00:00:00 2001 From: pagedmov Date: Wed, 25 Feb 2026 01:13:12 -0500 Subject: [PATCH] Added PSR environment variable for drawing a string on the right side of the prompt Pending normal mode sequences are now shown in the top right of the prompt --- .gitignore | 13 ---- README.md | 6 +- src/expand.rs | 112 +++++++++++++---------------- src/main.rs | 7 +- src/prompt/readline/mod.rs | 138 ++++++++++++++++++++++++++---------- src/prompt/readline/term.rs | 20 ++++-- src/state.rs | 6 +- 7 files changed, 178 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 33916db..5138ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,27 +8,14 @@ target default.nix shell.nix *~ -TODO.md -AUDIT.md -KNOWN_ISSUES.md rust-toolchain.toml /ref - -# cachix tmp file store-path-pre-build - -# Devenv .devenv* devenv.local.nix - -# direnv .direnv - -# pre-commit .pre-commit-config.yaml - template/flake.lock ideas.md roadmap.md -README.md file* diff --git a/README.md b/README.md index 91f315e..cc27592 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Unix shell written in Rust. The name is a nod to the two oldest Unix utilities ### Line Editor -shed includes a built-in vim emulator as its line editor, written from scratch — not a readline wrapper or external library. It aims to provide a more precise vim editing experience at the shell prompt. +`shed` includes a built-in `vim` emulator as its line editor, written from scratch — not a readline wrapper or external library. It aims to provide a more precise vim-like editing experience at the shell prompt. - **Normal mode** — motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts - **Insert mode** — insert, append, replace, with Ctrl+W word deletion and undo/redo @@ -127,3 +127,7 @@ imports = [ shed.homeModules.shed ]; ## Status `shed` is experimental software and is currently under active development. It covers most day-to-day interactive shell usage and a good portion of POSIX shell scripting, but it is not yet fully POSIX-compliant. + +## Why shed? + +This originally started as an educational hobby project, but over the course of about a year or so it's taken the form of an actual daily-drivable shell. I mainly wanted to create a shell where line editing is more frictionless than standard choices. I use vim a lot so I've built up a lot of muscle memory, and a fair amount of that muscle memory does not apply to vi modes in `bash`/`zsh`. For instance, the standard vi mode in `zsh` does not support selection via text objects. I wanted to create a line editor that includes even the obscure stuff like 'g?'. diff --git a/src/expand.rs b/src/expand.rs index 9bd919a..5391d8e 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -10,6 +10,7 @@ use crate::parse::execute::exec_input; use crate::parse::lex::{LexFlags, LexStream, Tk, TkFlags, TkRule, is_field_sep, is_hard_sep}; use crate::parse::{Redir, RedirType}; use crate::procio::{IoBuf, IoFrame, IoMode, IoStack}; +use crate::prompt::readline::markers; use crate::state::{ LogTab, VarFlags, read_logic, read_vars, write_jobs, write_meta, write_vars, }; @@ -17,29 +18,6 @@ use crate::{jobs, prelude::*}; const PARAMETERS: [char; 7] = ['@', '*', '#', '$', '?', '!', '0']; -/// Variable substitution marker -pub const VAR_SUB: char = '\u{fdd0}'; -/// Double quote '"' marker -pub const DUB_QUOTE: char = '\u{fdd1}'; -/// Single quote '\\'' marker -pub const SNG_QUOTE: char = '\u{fdd2}'; -/// Tilde sub marker -pub const TILDE_SUB: char = '\u{fdd3}'; -/// Subshell marker -pub const SUBSH: char = '\u{fdd4}'; -/// Input process sub marker -pub const PROC_SUB_IN: char = '\u{fdd5}'; -/// Output process sub marker -pub const PROC_SUB_OUT: char = '\u{fdd6}'; -/// Marker for null expansion -/// This is used for when "$@" or "$*" are used in quotes and there are no -/// arguments Without this marker, it would be handled like an empty string, -/// which breaks some commands -pub const NULL_EXPAND: char = '\u{fdd7}'; -/// Explicit marker for argument separation -/// This is used to join the arguments given by "$@", and preserves exact formatting -/// of the original arguments, including quoting -pub const ARG_SEP: char = '\u{fdd8}'; impl Tk { /// Create a new expanded token @@ -105,10 +83,10 @@ impl Expander { 'outer: while let Some(ch) = chars.next() { match ch { - DUB_QUOTE | SNG_QUOTE | SUBSH => { + markers::DUB_QUOTE | markers::SNG_QUOTE | markers::SUBSH => { while let Some(q_ch) = chars.next() { match q_ch { - ARG_SEP if ch == DUB_QUOTE => { + markers::ARG_SEP if ch == markers::DUB_QUOTE => { words.push(mem::take(&mut cur_word)); } _ if q_ch == ch => { @@ -119,7 +97,7 @@ impl Expander { } } } - _ if is_field_sep(ch) || ch == ARG_SEP => { + _ if is_field_sep(ch) || ch == markers::ARG_SEP => { if cur_word.is_empty() && !was_quoted { cur_word.clear(); } else { @@ -137,7 +115,7 @@ impl Expander { words.push(cur_word); } - words.retain(|w| w != &NULL_EXPAND.to_string()); + words.retain(|w| w != &markers::NULL_EXPAND.to_string()); words } } @@ -500,33 +478,33 @@ pub fn expand_raw(chars: &mut Peekable>) -> ShResult { while let Some(ch) = chars.next() { match ch { - TILDE_SUB => { + markers::TILDE_SUB => { let home = env::var("HOME").unwrap_or_default(); result.push_str(&home); } - PROC_SUB_OUT => { + markers::PROC_SUB_OUT => { let mut inner = String::new(); while let Some(ch) = chars.next() { match ch { - PROC_SUB_OUT => break, + markers::PROC_SUB_OUT => break, _ => inner.push(ch), } } let fd_path = expand_proc_sub(&inner, false)?; result.push_str(&fd_path); } - PROC_SUB_IN => { + markers::PROC_SUB_IN => { let mut inner = String::new(); while let Some(ch) = chars.next() { match ch { - PROC_SUB_IN => break, + markers::PROC_SUB_IN => break, _ => inner.push(ch), } } let fd_path = expand_proc_sub(&inner, true)?; result.push_str(&fd_path); } - VAR_SUB => { + markers::VAR_SUB => { let expanded = expand_var(chars)?; result.push_str(&expanded); } @@ -541,12 +519,12 @@ pub fn expand_var(chars: &mut Peekable>) -> ShResult { let mut in_brace = false; while let Some(&ch) = chars.peek() { match ch { - SUBSH if var_name.is_empty() => { + markers::SUBSH if var_name.is_empty() => { chars.next(); // now safe to consume let mut subsh_body = String::new(); let mut found_end = false; while let Some(c) = chars.next() { - if c == SUBSH { + if c == markers::SUBSH { found_end = true; break; } @@ -579,7 +557,7 @@ pub fn expand_var(chars: &mut Peekable>) -> ShResult { let val = read_vars(|v| v.get_var(¶meter)); if (ch == '@' || ch == '*') && val.is_empty() { - return Ok(NULL_EXPAND.to_string()); + return Ok(markers::NULL_EXPAND.to_string()); } return Ok(val); @@ -929,14 +907,14 @@ pub fn unescape_str(raw: &str) -> String { while let Some(ch) = chars.next() { match ch { - '~' if first_char => result.push(TILDE_SUB), + '~' if first_char => result.push(markers::TILDE_SUB), '\\' => { if let Some(next_ch) = chars.next() { result.push(next_ch) } } '(' => { - result.push(SUBSH); + result.push(markers::SUBSH); let mut paren_count = 1; while let Some(subsh_ch) = chars.next() { match subsh_ch { @@ -946,7 +924,7 @@ pub fn unescape_str(raw: &str) -> String { result.push(next_ch) } } - '$' if chars.peek() != Some(&'(') => result.push(VAR_SUB), + '$' if chars.peek() != Some(&'(') => result.push(markers::VAR_SUB), '(' => { paren_count += 1; result.push(subsh_ch) @@ -954,7 +932,7 @@ pub fn unescape_str(raw: &str) -> String { ')' => { paren_count -= 1; if paren_count == 0 { - result.push(SUBSH); + result.push(markers::SUBSH); break; } else { result.push(subsh_ch) @@ -965,7 +943,7 @@ pub fn unescape_str(raw: &str) -> String { } } '"' => { - result.push(DUB_QUOTE); + result.push(markers::DUB_QUOTE); while let Some(q_ch) = chars.next() { match q_ch { '\\' => { @@ -982,11 +960,11 @@ pub fn unescape_str(raw: &str) -> String { } } '$' => { - result.push(VAR_SUB); + result.push(markers::VAR_SUB); if chars.peek() == Some(&'(') { chars.next(); let mut paren_count = 1; - result.push(SUBSH); + result.push(markers::SUBSH); while let Some(subsh_ch) = chars.next() { match subsh_ch { '\\' => { @@ -1002,7 +980,7 @@ pub fn unescape_str(raw: &str) -> String { ')' => { paren_count -= 1; if paren_count <= 0 { - result.push(SUBSH); + result.push(markers::SUBSH); break; } else { result.push(subsh_ch); @@ -1014,7 +992,7 @@ pub fn unescape_str(raw: &str) -> String { } } '"' => { - result.push(DUB_QUOTE); + result.push(markers::DUB_QUOTE); break; } _ => result.push(q_ch), @@ -1022,11 +1000,11 @@ pub fn unescape_str(raw: &str) -> String { } } '\'' => { - result.push(SNG_QUOTE); + result.push(markers::SNG_QUOTE); while let Some(q_ch) = chars.next() { match q_ch { '\'' => { - result.push(SNG_QUOTE); + result.push(markers::SNG_QUOTE); break; } _ => result.push(q_ch), @@ -1036,7 +1014,7 @@ pub fn unescape_str(raw: &str) -> String { '<' if chars.peek() == Some(&'(') => { chars.next(); let mut paren_count = 1; - result.push(PROC_SUB_OUT); + result.push(markers::PROC_SUB_OUT); while let Some(subsh_ch) = chars.next() { match subsh_ch { '\\' => { @@ -1052,7 +1030,7 @@ pub fn unescape_str(raw: &str) -> String { ')' => { paren_count -= 1; if paren_count <= 0 { - result.push(PROC_SUB_OUT); + result.push(markers::PROC_SUB_OUT); break; } else { result.push(subsh_ch); @@ -1065,7 +1043,7 @@ pub fn unescape_str(raw: &str) -> String { '>' if chars.peek() == Some(&'(') => { chars.next(); let mut paren_count = 1; - result.push(PROC_SUB_IN); + result.push(markers::PROC_SUB_IN); while let Some(subsh_ch) = chars.next() { match subsh_ch { '\\' => { @@ -1081,7 +1059,7 @@ pub fn unescape_str(raw: &str) -> String { ')' => { paren_count -= 1; if paren_count <= 0 { - result.push(PROC_SUB_IN); + result.push(markers::PROC_SUB_IN); break; } else { result.push(subsh_ch); @@ -1093,11 +1071,11 @@ pub fn unescape_str(raw: &str) -> String { } '$' if chars.peek() == Some(&'\'') => { chars.next(); - result.push(SNG_QUOTE); + result.push(markers::SNG_QUOTE); while let Some(q_ch) = chars.next() { match q_ch { '\'' => { - result.push(SNG_QUOTE); + result.push(markers::SNG_QUOTE); break; } '\\' => { @@ -1163,7 +1141,7 @@ pub fn unescape_str(raw: &str) -> String { } } '$' => { - result.push(VAR_SUB); + result.push(markers::VAR_SUB); if chars.peek() == Some(&'$') { chars.next(); result.push('$'); @@ -1188,9 +1166,9 @@ pub fn unescape_math(raw: &str) -> String { } } '$' => { - result.push(VAR_SUB); + result.push(markers::VAR_SUB); if chars.peek() == Some(&'(') { - result.push(SUBSH); + result.push(markers::SUBSH); chars.next(); let mut paren_count = 1; while let Some(subsh_ch) = chars.next() { @@ -1201,7 +1179,7 @@ pub fn unescape_math(raw: &str) -> String { result.push(next_ch) } } - '$' if chars.peek() != Some(&'(') => result.push(VAR_SUB), + '$' if chars.peek() != Some(&'(') => result.push(markers::VAR_SUB), '(' => { paren_count += 1; result.push(subsh_ch) @@ -1209,7 +1187,7 @@ pub fn unescape_math(raw: &str) -> String { ')' => { paren_count -= 1; if paren_count == 0 { - result.push(SUBSH); + result.push(markers::SUBSH); break; } else { result.push(subsh_ch) @@ -1840,10 +1818,12 @@ fn tokenize_prompt(raw: &str) -> Vec { '!' => { let mut func_name = String::new(); let is_braced = chars.peek() == Some(&'{'); + let mut handled = false; while let Some(ch) = chars.peek() { match ch { '}' if is_braced => { chars.next(); + handled = true; break; } 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' => { @@ -1851,23 +1831,32 @@ fn tokenize_prompt(raw: &str) -> Vec { chars.next(); } _ => { + handled = true; if is_braced { // Invalid character in braced function name tokens.push(PromptTk::Text(format!("\\!{{{func_name}"))); - break; } else { // End of unbraced function name let func_exists = read_logic(|l| l.get_func(&func_name).is_some()); if func_exists { - tokens.push(PromptTk::Function(func_name)); + tokens.push(PromptTk::Function(func_name.clone())); } else { tokens.push(PromptTk::Text(format!("\\!{func_name}"))); } - break; } + break; } } } + // Handle end-of-input: function name collected but loop ended without pushing + if !handled && !func_name.is_empty() { + let func_exists = read_logic(|l| l.get_func(&func_name).is_some()); + if func_exists { + tokens.push(PromptTk::Function(func_name)); + } else { + tokens.push(PromptTk::Text(format!("\\!{func_name}"))); + } + } } 'e' => { if chars.next() == Some('[') { @@ -2017,6 +2006,7 @@ pub fn expand_prompt(raw: &str) -> ShResult { PromptTk::FailureSymbol => todo!(), PromptTk::JobCount => todo!(), PromptTk::Function(f) => { + log::debug!("Expanding prompt function: {f}"); let output = expand_cmd_sub(&f)?; result.push_str(&output); } diff --git a/src/main.rs b/src/main.rs index d6af0a6..40d8bed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ use crate::libsh::sys::TTY_FILENO; use crate::parse::execute::exec_input; use crate::prelude::*; use crate::prompt::get_prompt; -use crate::prompt::readline::term::{RawModeGuard, raw_mode}; +use crate::prompt::readline::term::{LineWriter, RawModeGuard, raw_mode}; use crate::prompt::readline::{ShedVi, ReadlineEvent}; use crate::signal::{QUIT_CODE, check_signals, sig_setup, signals_pending}; use crate::state::{read_logic, source_rc, write_jobs, write_meta}; @@ -192,7 +192,7 @@ fn shed_interactive() -> ShResult<()> { } } - readline.print_line()?; + readline.print_line(false)?; // Poll for stdin input let mut fds = [PollFd::new( @@ -251,6 +251,7 @@ fn shed_interactive() -> ShResult<()> { let command_run_time = start.elapsed(); log::info!("Command executed in {:.2?}", command_run_time); write_meta(|m| m.stop_timer()); + readline.writer.flush_write("\n")?; // Reset for next command with fresh prompt readline.reset(get_prompt().ok()); @@ -271,7 +272,7 @@ fn shed_interactive() -> ShResult<()> { return Ok(()); } _ => eprintln!("{e}"), - }, + } } } diff --git a/src/prompt/readline/mod.rs b/src/prompt/readline/mod.rs index 285a66b..8636643 100644 --- a/src/prompt/readline/mod.rs +++ b/src/prompt/readline/mod.rs @@ -3,12 +3,15 @@ use keys::{KeyCode, KeyEvent, ModKeys}; use linebuf::{LineBuf, SelectAnchor, SelectMode}; use nix::libc::STDOUT_FILENO; use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size}; +use unicode_width::UnicodeWidthStr; use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd}; use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual}; +use crate::expand::expand_prompt; use crate::libsh::sys::TTY_FILENO; use crate::parse::lex::LexStream; use crate::prelude::*; +use crate::prompt::readline::term::{Pos, calc_str_width}; use crate::state::read_shopts; use crate::{ libsh::{ @@ -33,41 +36,66 @@ pub mod vimode; pub mod markers { use super::Marker; + /* Highlight Markers */ + // token-level (derived from token class) - pub const COMMAND: Marker = '\u{fdd0}'; - pub const BUILTIN: Marker = '\u{fdd1}'; - pub const ARG: Marker = '\u{fdd2}'; - pub const KEYWORD: Marker = '\u{fdd3}'; - pub const OPERATOR: Marker = '\u{fdd4}'; - pub const REDIRECT: Marker = '\u{fdd5}'; - pub const COMMENT: Marker = '\u{fdd6}'; - pub const ASSIGNMENT: Marker = '\u{fdd7}'; - pub const CMD_SEP: Marker = '\u{fde0}'; - pub const CASE_PAT: Marker = '\u{fde1}'; - pub const SUBSH: Marker = '\u{fde7}'; - pub const SUBSH_END: Marker = '\u{fde8}'; + pub const COMMAND: Marker = '\u{e100}'; + pub const BUILTIN: Marker = '\u{e101}'; + pub const ARG: Marker = '\u{e102}'; + pub const KEYWORD: Marker = '\u{e103}'; + pub const OPERATOR: Marker = '\u{e104}'; + pub const REDIRECT: Marker = '\u{e105}'; + pub const COMMENT: Marker = '\u{e106}'; + pub const ASSIGNMENT: Marker = '\u{e107}'; + pub const CMD_SEP: Marker = '\u{e108}'; + pub const CASE_PAT: Marker = '\u{e109}'; + pub const SUBSH: Marker = '\u{e10a}'; + pub const SUBSH_END: Marker = '\u{e10b}'; // sub-token (needs scanning) - pub const VAR_SUB: Marker = '\u{fdda}'; - pub const VAR_SUB_END: Marker = '\u{fde3}'; - pub const CMD_SUB: Marker = '\u{fdd8}'; - pub const CMD_SUB_END: Marker = '\u{fde4}'; - pub const PROC_SUB: Marker = '\u{fdd9}'; - pub const PROC_SUB_END: Marker = '\u{fde9}'; - pub const STRING_DQ: Marker = '\u{fddb}'; - pub const STRING_DQ_END: Marker = '\u{fde5}'; - pub const STRING_SQ: Marker = '\u{fddc}'; - pub const STRING_SQ_END: Marker = '\u{fde6}'; - pub const ESCAPE: Marker = '\u{fddd}'; - pub const GLOB: Marker = '\u{fdde}'; + pub const VAR_SUB: Marker = '\u{e10c}'; + pub const VAR_SUB_END: Marker = '\u{e10d}'; + pub const CMD_SUB: Marker = '\u{e10e}'; + pub const CMD_SUB_END: Marker = '\u{e10f}'; + pub const PROC_SUB: Marker = '\u{e110}'; + pub const PROC_SUB_END: Marker = '\u{e111}'; + pub const STRING_DQ: Marker = '\u{e112}'; + pub const STRING_DQ_END: Marker = '\u{e113}'; + pub const STRING_SQ: Marker = '\u{e114}'; + pub const STRING_SQ_END: Marker = '\u{e115}'; + pub const ESCAPE: Marker = '\u{e116}'; + pub const GLOB: Marker = '\u{e117}'; // other - pub const VISUAL_MODE_START: Marker = '\u{fdea}'; - pub const VISUAL_MODE_END: Marker = '\u{fdeb}'; + pub const VISUAL_MODE_START: Marker = '\u{e118}'; + pub const VISUAL_MODE_END: Marker = '\u{e119}'; - pub const RESET: Marker = '\u{fde2}'; + pub const RESET: Marker = '\u{e11a}'; - pub const NULL: Marker = '\u{fdef}'; + pub const NULL: Marker = '\u{e11b}'; + + /* Expansion Markers */ + /// Double quote '"' marker + pub const DUB_QUOTE: Marker = '\u{e001}'; + /// Single quote '\\'' marker + pub const SNG_QUOTE: Marker = '\u{e002}'; + /// Tilde sub marker + pub const TILDE_SUB: Marker = '\u{e003}'; + /// Input process sub marker + pub const PROC_SUB_IN: Marker = '\u{e005}'; + /// Output process sub marker + pub const PROC_SUB_OUT: Marker = '\u{e006}'; + /// Marker for null expansion + /// This is used for when "$@" or "$*" are used in quotes and there are no + /// arguments Without this marker, it would be handled like an empty string, + /// which breaks some commands + pub const NULL_EXPAND: Marker = '\u{e007}'; + /// Explicit marker for argument separation + /// This is used to join the arguments given by "$@", and preserves exact formatting + /// of the original arguments, including quoting + pub const ARG_SEP: Marker = '\u{e008}'; + + pub const VI_SEQ_EXP: Marker = '\u{e009}'; pub const END_MARKERS: [Marker; 7] = [ VAR_SUB_END, @@ -86,7 +114,7 @@ pub mod markers { pub const MISC: [Marker; 3] = [ESCAPE, VISUAL_MODE_START, VISUAL_MODE_END]; pub fn is_marker(c: Marker) -> bool { - TOKEN_LEVEL.contains(&c) || SUB_TOKEN.contains(&c) || END_MARKERS.contains(&c) || MISC.contains(&c) + c >= '\u{e000}' && c <= '\u{efff}' } } type Marker = char; @@ -103,7 +131,7 @@ pub enum ReadlineEvent { pub struct ShedVi { pub reader: PollReader, - pub writer: Box, + pub writer: TermWriter, pub prompt: String, pub highlighter: Highlighter, @@ -124,7 +152,7 @@ impl ShedVi { pub fn new(prompt: Option, tty: RawFd) -> ShResult { let mut new = Self { reader: PollReader::new(), - writer: Box::new(TermWriter::new(tty)), + writer: TermWriter::new(tty), prompt: prompt.unwrap_or("$ ".styled(Style::Green)), completer: Completer::new(), highlighter: Highlighter::new(), @@ -136,7 +164,7 @@ impl ShedVi { history: History::new()?, needs_redraw: true, }; - new.print_line()?; + new.print_line(false)?; Ok(new) } @@ -201,7 +229,7 @@ impl ShedVi { pub fn process_input(&mut self) -> ShResult { // Redraw if needed if self.needs_redraw { - self.print_line()?; + self.print_line(false)?; self.needs_redraw = false; } @@ -276,7 +304,7 @@ impl ShedVi { if cmd.is_submit_action() && (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete)) { self.editor.set_hint(None); self.editor.cursor.set(self.editor.cursor_max()); // Move the cursor to the very end - self.print_line()?; // Redraw + self.print_line(true)?; // Redraw self.writer.flush_write("\n")?; let buf = self.editor.take_buf(); // Save command to history if auto_hist is enabled @@ -322,7 +350,7 @@ impl ShedVi { // Redraw if we processed any input if self.needs_redraw { - self.print_line()?; + self.print_line(false)?; self.needs_redraw = false; } @@ -409,15 +437,53 @@ impl ShedVi { } } - pub fn print_line(&mut self) -> ShResult<()> { + pub fn print_line(&mut self, final_draw: bool) -> ShResult<()> { let line = self.line_text(); let new_layout = self.get_layout(&line); + let pending_seq = self.mode.pending_seq(); + let mut prompt_string_right = env::var("PSR") + .map(|psr| expand_prompt(&psr).unwrap()) + .ok(); + + if prompt_string_right.as_ref().is_some_and(|psr| psr.lines().count() > 1) { + log::warn!("PSR has multiple lines, truncating to one line"); + prompt_string_right = prompt_string_right.map(|psr| psr.lines().next().unwrap_or_default().to_string()); + } + + let row0_used = self.prompt + .lines() + .next() + .map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 })) + .map(|p| p.col) + .unwrap_or_default() as usize; + let one_line = new_layout.end.row == 0; + + if let Some(layout) = self.old_layout.as_ref() { self.writer.clear_rows(layout)?; } self.writer.redraw(&self.prompt, &line, &new_layout)?; + let seq_fits = pending_seq.as_ref().is_some_and(|seq| row0_used + 1 < self.writer.t_cols as usize - seq.width()); + let psr_fits = prompt_string_right.as_ref().is_some_and(|psr| new_layout.end.col as usize + 1 < self.writer.t_cols as usize - psr.width()); + + if !final_draw && let Some(seq) = pending_seq && !seq.is_empty() && !(prompt_string_right.is_some() && one_line) && seq_fits { + let to_col = self.writer.t_cols - calc_str_width(&seq); + let up = new_layout.cursor.row; // rows to move up from cursor to top line of prompt + + let move_up = if up > 0 { format!("\x1b[{up}A") } else { String::new() }; + + // Save cursor, move up to top row, move right to column, write sequence, restore cursor + self.writer.flush_write(&format!("\x1b[s{move_up}\x1b[{to_col}G{seq}\x1b[u"))?; + } else if !final_draw && let Some(psr) = prompt_string_right && psr_fits { + let to_col = self.writer.t_cols - calc_str_width(&psr); + let down = new_layout.end.row - new_layout.cursor.row; + let move_down = if down > 0 { format!("\x1b[{down}B") } else { String::new() }; + + self.writer.flush_write(&format!("\x1b[s{move_down}\x1b[{to_col}G{psr}\x1b[u"))?; + } + self.writer.flush_write(&self.mode.cursor_style())?; self.old_layout = Some(new_layout); diff --git a/src/prompt/readline/term.rs b/src/prompt/readline/term.rs index f7563d9..66b8525 100644 --- a/src/prompt/readline/term.rs +++ b/src/prompt/readline/term.rs @@ -63,8 +63,8 @@ pub type Col = u16; #[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct Pos { - col: Col, - row: Row, + pub col: Col, + pub row: Row, } // I'd like to thank rustyline for this idea @@ -138,6 +138,11 @@ fn ends_with_newline(s: &str) -> bool { i > 0 && bytes[i - 1] == b'\n' } +pub fn calc_str_width(s: &str) -> u16 { + let mut esc_seq = 0; + s.graphemes(true).map(|g| width(g, &mut esc_seq)).sum() +} + // Big credit to rustyline for this fn width(s: &str, esc_seq: &mut u8) -> u16 { let w_calc = width_calculator(); @@ -155,10 +160,11 @@ fn width(s: &str, esc_seq: &mut u8) -> u16 { /*} else if s == "m" { // last *esc_seq = 0;*/ - } else { - // not supported - *esc_seq = 0; - } + } else { + // not supported + *esc_seq = 0; + } + 0 } else if s == "\x1b" { *esc_seq = 1; @@ -813,7 +819,7 @@ impl Default for Layout { pub struct TermWriter { out: RawFd, - t_cols: Col, // terminal width + pub t_cols: Col, // terminal width buffer: String, w_calc: Box, } diff --git a/src/state.rs b/src/state.rs index 70bb8b0..63d81ac 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,10 +10,10 @@ use std::{ use nix::unistd::{User, gethostname, getppid}; use crate::{ - builtin::trap::TrapTarget, exec_input, expand::ARG_SEP, jobs::JobTab, libsh::{ + builtin::trap::TrapTarget, exec_input, jobs::JobTab, libsh::{ error::{ShErr, ShErrKind, ShResult}, utils::VecDequeExt, - }, parse::{ConjunctNode, NdRule, Node, ParsedSrc}, prelude::*, shopt::ShOpts + }, parse::{ConjunctNode, NdRule, Node, ParsedSrc}, prelude::*, prompt::readline::markers, shopt::ShOpts }; pub struct Shed { @@ -604,7 +604,7 @@ impl VarTab { fn update_arg_params(&mut self) { self.set_param( ShellParam::AllArgs, - &self.sh_argv.clone().to_vec()[1..].join(&ARG_SEP.to_string()), + &self.sh_argv.clone().to_vec()[1..].join(&markers::ARG_SEP.to_string()), ); self.set_param(ShellParam::ArgCount, &(self.sh_argv.len() - 1).to_string()); }