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
This commit is contained in:
2026-02-25 01:13:12 -05:00
parent adff53aaab
commit 854e127545
7 changed files with 178 additions and 124 deletions

13
.gitignore vendored
View File

@@ -8,27 +8,14 @@ target
default.nix default.nix
shell.nix shell.nix
*~ *~
TODO.md
AUDIT.md
KNOWN_ISSUES.md
rust-toolchain.toml rust-toolchain.toml
/ref /ref
# cachix tmp file
store-path-pre-build store-path-pre-build
# Devenv
.devenv* .devenv*
devenv.local.nix devenv.local.nix
# direnv
.direnv .direnv
# pre-commit
.pre-commit-config.yaml .pre-commit-config.yaml
template/flake.lock template/flake.lock
ideas.md ideas.md
roadmap.md roadmap.md
README.md
file* file*

View File

@@ -6,7 +6,7 @@ A Unix shell written in Rust. The name is a nod to the two oldest Unix utilities
### Line Editor ### 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 - **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 - **Insert mode** — insert, append, replace, with Ctrl+W word deletion and undo/redo
@@ -127,3 +127,7 @@ imports = [ shed.homeModules.shed ];
## Status ## 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. `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?'.

View File

@@ -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::lex::{LexFlags, LexStream, Tk, TkFlags, TkRule, is_field_sep, is_hard_sep};
use crate::parse::{Redir, RedirType}; use crate::parse::{Redir, RedirType};
use crate::procio::{IoBuf, IoFrame, IoMode, IoStack}; use crate::procio::{IoBuf, IoFrame, IoMode, IoStack};
use crate::prompt::readline::markers;
use crate::state::{ use crate::state::{
LogTab, VarFlags, read_logic, read_vars, write_jobs, write_meta, write_vars, 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']; 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 { impl Tk {
/// Create a new expanded token /// Create a new expanded token
@@ -105,10 +83,10 @@ impl Expander {
'outer: while let Some(ch) = chars.next() { 'outer: while let Some(ch) = chars.next() {
match ch { match ch {
DUB_QUOTE | SNG_QUOTE | SUBSH => { markers::DUB_QUOTE | markers::SNG_QUOTE | markers::SUBSH => {
while let Some(q_ch) = chars.next() { while let Some(q_ch) = chars.next() {
match q_ch { match q_ch {
ARG_SEP if ch == DUB_QUOTE => { markers::ARG_SEP if ch == markers::DUB_QUOTE => {
words.push(mem::take(&mut cur_word)); words.push(mem::take(&mut cur_word));
} }
_ if q_ch == ch => { _ 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 { if cur_word.is_empty() && !was_quoted {
cur_word.clear(); cur_word.clear();
} else { } else {
@@ -137,7 +115,7 @@ impl Expander {
words.push(cur_word); words.push(cur_word);
} }
words.retain(|w| w != &NULL_EXPAND.to_string()); words.retain(|w| w != &markers::NULL_EXPAND.to_string());
words words
} }
} }
@@ -500,33 +478,33 @@ pub fn expand_raw(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
TILDE_SUB => { markers::TILDE_SUB => {
let home = env::var("HOME").unwrap_or_default(); let home = env::var("HOME").unwrap_or_default();
result.push_str(&home); result.push_str(&home);
} }
PROC_SUB_OUT => { markers::PROC_SUB_OUT => {
let mut inner = String::new(); let mut inner = String::new();
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
PROC_SUB_OUT => break, markers::PROC_SUB_OUT => break,
_ => inner.push(ch), _ => inner.push(ch),
} }
} }
let fd_path = expand_proc_sub(&inner, false)?; let fd_path = expand_proc_sub(&inner, false)?;
result.push_str(&fd_path); result.push_str(&fd_path);
} }
PROC_SUB_IN => { markers::PROC_SUB_IN => {
let mut inner = String::new(); let mut inner = String::new();
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
PROC_SUB_IN => break, markers::PROC_SUB_IN => break,
_ => inner.push(ch), _ => inner.push(ch),
} }
} }
let fd_path = expand_proc_sub(&inner, true)?; let fd_path = expand_proc_sub(&inner, true)?;
result.push_str(&fd_path); result.push_str(&fd_path);
} }
VAR_SUB => { markers::VAR_SUB => {
let expanded = expand_var(chars)?; let expanded = expand_var(chars)?;
result.push_str(&expanded); result.push_str(&expanded);
} }
@@ -541,12 +519,12 @@ pub fn expand_var(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
let mut in_brace = false; let mut in_brace = false;
while let Some(&ch) = chars.peek() { while let Some(&ch) = chars.peek() {
match ch { match ch {
SUBSH if var_name.is_empty() => { markers::SUBSH if var_name.is_empty() => {
chars.next(); // now safe to consume chars.next(); // now safe to consume
let mut subsh_body = String::new(); let mut subsh_body = String::new();
let mut found_end = false; let mut found_end = false;
while let Some(c) = chars.next() { while let Some(c) = chars.next() {
if c == SUBSH { if c == markers::SUBSH {
found_end = true; found_end = true;
break; break;
} }
@@ -579,7 +557,7 @@ pub fn expand_var(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
let val = read_vars(|v| v.get_var(&parameter)); let val = read_vars(|v| v.get_var(&parameter));
if (ch == '@' || ch == '*') && val.is_empty() { if (ch == '@' || ch == '*') && val.is_empty() {
return Ok(NULL_EXPAND.to_string()); return Ok(markers::NULL_EXPAND.to_string());
} }
return Ok(val); return Ok(val);
@@ -929,14 +907,14 @@ pub fn unescape_str(raw: &str) -> String {
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
'~' if first_char => result.push(TILDE_SUB), '~' if first_char => result.push(markers::TILDE_SUB),
'\\' => { '\\' => {
if let Some(next_ch) = chars.next() { if let Some(next_ch) = chars.next() {
result.push(next_ch) result.push(next_ch)
} }
} }
'(' => { '(' => {
result.push(SUBSH); result.push(markers::SUBSH);
let mut paren_count = 1; let mut paren_count = 1;
while let Some(subsh_ch) = chars.next() { while let Some(subsh_ch) = chars.next() {
match subsh_ch { match subsh_ch {
@@ -946,7 +924,7 @@ pub fn unescape_str(raw: &str) -> String {
result.push(next_ch) result.push(next_ch)
} }
} }
'$' if chars.peek() != Some(&'(') => result.push(VAR_SUB), '$' if chars.peek() != Some(&'(') => result.push(markers::VAR_SUB),
'(' => { '(' => {
paren_count += 1; paren_count += 1;
result.push(subsh_ch) result.push(subsh_ch)
@@ -954,7 +932,7 @@ pub fn unescape_str(raw: &str) -> String {
')' => { ')' => {
paren_count -= 1; paren_count -= 1;
if paren_count == 0 { if paren_count == 0 {
result.push(SUBSH); result.push(markers::SUBSH);
break; break;
} else { } else {
result.push(subsh_ch) 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() { while let Some(q_ch) = chars.next() {
match q_ch { 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(&'(') { if chars.peek() == Some(&'(') {
chars.next(); chars.next();
let mut paren_count = 1; let mut paren_count = 1;
result.push(SUBSH); result.push(markers::SUBSH);
while let Some(subsh_ch) = chars.next() { while let Some(subsh_ch) = chars.next() {
match subsh_ch { match subsh_ch {
'\\' => { '\\' => {
@@ -1002,7 +980,7 @@ pub fn unescape_str(raw: &str) -> String {
')' => { ')' => {
paren_count -= 1; paren_count -= 1;
if paren_count <= 0 { if paren_count <= 0 {
result.push(SUBSH); result.push(markers::SUBSH);
break; break;
} else { } else {
result.push(subsh_ch); 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; break;
} }
_ => result.push(q_ch), _ => 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() { while let Some(q_ch) = chars.next() {
match q_ch { match q_ch {
'\'' => { '\'' => {
result.push(SNG_QUOTE); result.push(markers::SNG_QUOTE);
break; break;
} }
_ => result.push(q_ch), _ => result.push(q_ch),
@@ -1036,7 +1014,7 @@ pub fn unescape_str(raw: &str) -> String {
'<' if chars.peek() == Some(&'(') => { '<' if chars.peek() == Some(&'(') => {
chars.next(); chars.next();
let mut paren_count = 1; let mut paren_count = 1;
result.push(PROC_SUB_OUT); result.push(markers::PROC_SUB_OUT);
while let Some(subsh_ch) = chars.next() { while let Some(subsh_ch) = chars.next() {
match subsh_ch { match subsh_ch {
'\\' => { '\\' => {
@@ -1052,7 +1030,7 @@ pub fn unescape_str(raw: &str) -> String {
')' => { ')' => {
paren_count -= 1; paren_count -= 1;
if paren_count <= 0 { if paren_count <= 0 {
result.push(PROC_SUB_OUT); result.push(markers::PROC_SUB_OUT);
break; break;
} else { } else {
result.push(subsh_ch); result.push(subsh_ch);
@@ -1065,7 +1043,7 @@ pub fn unescape_str(raw: &str) -> String {
'>' if chars.peek() == Some(&'(') => { '>' if chars.peek() == Some(&'(') => {
chars.next(); chars.next();
let mut paren_count = 1; let mut paren_count = 1;
result.push(PROC_SUB_IN); result.push(markers::PROC_SUB_IN);
while let Some(subsh_ch) = chars.next() { while let Some(subsh_ch) = chars.next() {
match subsh_ch { match subsh_ch {
'\\' => { '\\' => {
@@ -1081,7 +1059,7 @@ pub fn unescape_str(raw: &str) -> String {
')' => { ')' => {
paren_count -= 1; paren_count -= 1;
if paren_count <= 0 { if paren_count <= 0 {
result.push(PROC_SUB_IN); result.push(markers::PROC_SUB_IN);
break; break;
} else { } else {
result.push(subsh_ch); result.push(subsh_ch);
@@ -1093,11 +1071,11 @@ pub fn unescape_str(raw: &str) -> String {
} }
'$' if chars.peek() == Some(&'\'') => { '$' if chars.peek() == Some(&'\'') => {
chars.next(); chars.next();
result.push(SNG_QUOTE); result.push(markers::SNG_QUOTE);
while let Some(q_ch) = chars.next() { while let Some(q_ch) = chars.next() {
match q_ch { match q_ch {
'\'' => { '\'' => {
result.push(SNG_QUOTE); result.push(markers::SNG_QUOTE);
break; 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(&'$') { if chars.peek() == Some(&'$') {
chars.next(); chars.next();
result.push('$'); 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(&'(') { if chars.peek() == Some(&'(') {
result.push(SUBSH); result.push(markers::SUBSH);
chars.next(); chars.next();
let mut paren_count = 1; let mut paren_count = 1;
while let Some(subsh_ch) = chars.next() { while let Some(subsh_ch) = chars.next() {
@@ -1201,7 +1179,7 @@ pub fn unescape_math(raw: &str) -> String {
result.push(next_ch) result.push(next_ch)
} }
} }
'$' if chars.peek() != Some(&'(') => result.push(VAR_SUB), '$' if chars.peek() != Some(&'(') => result.push(markers::VAR_SUB),
'(' => { '(' => {
paren_count += 1; paren_count += 1;
result.push(subsh_ch) result.push(subsh_ch)
@@ -1209,7 +1187,7 @@ pub fn unescape_math(raw: &str) -> String {
')' => { ')' => {
paren_count -= 1; paren_count -= 1;
if paren_count == 0 { if paren_count == 0 {
result.push(SUBSH); result.push(markers::SUBSH);
break; break;
} else { } else {
result.push(subsh_ch) result.push(subsh_ch)
@@ -1840,10 +1818,12 @@ fn tokenize_prompt(raw: &str) -> Vec<PromptTk> {
'!' => { '!' => {
let mut func_name = String::new(); let mut func_name = String::new();
let is_braced = chars.peek() == Some(&'{'); let is_braced = chars.peek() == Some(&'{');
let mut handled = false;
while let Some(ch) = chars.peek() { while let Some(ch) = chars.peek() {
match ch { match ch {
'}' if is_braced => { '}' if is_braced => {
chars.next(); chars.next();
handled = true;
break; break;
} }
'A'..='Z' | 'a'..='z' | '0'..='9' | '_' => { 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' => {
@@ -1851,23 +1831,32 @@ fn tokenize_prompt(raw: &str) -> Vec<PromptTk> {
chars.next(); chars.next();
} }
_ => { _ => {
handled = true;
if is_braced { if is_braced {
// Invalid character in braced function name // Invalid character in braced function name
tokens.push(PromptTk::Text(format!("\\!{{{func_name}"))); tokens.push(PromptTk::Text(format!("\\!{{{func_name}")));
break;
} else { } else {
// End of unbraced function name // End of unbraced function name
let func_exists = read_logic(|l| l.get_func(&func_name).is_some()); let func_exists = read_logic(|l| l.get_func(&func_name).is_some());
if func_exists { if func_exists {
tokens.push(PromptTk::Function(func_name)); tokens.push(PromptTk::Function(func_name.clone()));
} else { } else {
tokens.push(PromptTk::Text(format!("\\!{func_name}"))); 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' => { 'e' => {
if chars.next() == Some('[') { if chars.next() == Some('[') {
@@ -2017,6 +2006,7 @@ pub fn expand_prompt(raw: &str) -> ShResult<String> {
PromptTk::FailureSymbol => todo!(), PromptTk::FailureSymbol => todo!(),
PromptTk::JobCount => todo!(), PromptTk::JobCount => todo!(),
PromptTk::Function(f) => { PromptTk::Function(f) => {
log::debug!("Expanding prompt function: {f}");
let output = expand_cmd_sub(&f)?; let output = expand_cmd_sub(&f)?;
result.push_str(&output); result.push_str(&output);
} }

View File

@@ -32,7 +32,7 @@ use crate::libsh::sys::TTY_FILENO;
use crate::parse::execute::exec_input; use crate::parse::execute::exec_input;
use crate::prelude::*; use crate::prelude::*;
use crate::prompt::get_prompt; 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::prompt::readline::{ShedVi, ReadlineEvent};
use crate::signal::{QUIT_CODE, check_signals, sig_setup, signals_pending}; use crate::signal::{QUIT_CODE, check_signals, sig_setup, signals_pending};
use crate::state::{read_logic, source_rc, write_jobs, write_meta}; 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 // Poll for stdin input
let mut fds = [PollFd::new( let mut fds = [PollFd::new(
@@ -251,6 +251,7 @@ fn shed_interactive() -> ShResult<()> {
let command_run_time = start.elapsed(); let command_run_time = start.elapsed();
log::info!("Command executed in {:.2?}", command_run_time); log::info!("Command executed in {:.2?}", command_run_time);
write_meta(|m| m.stop_timer()); write_meta(|m| m.stop_timer());
readline.writer.flush_write("\n")?;
// Reset for next command with fresh prompt // Reset for next command with fresh prompt
readline.reset(get_prompt().ok()); readline.reset(get_prompt().ok());
@@ -271,7 +272,7 @@ fn shed_interactive() -> ShResult<()> {
return Ok(()); return Ok(());
} }
_ => eprintln!("{e}"), _ => eprintln!("{e}"),
}, }
} }
} }

View File

@@ -3,12 +3,15 @@ use keys::{KeyCode, KeyEvent, ModKeys};
use linebuf::{LineBuf, SelectAnchor, SelectMode}; use linebuf::{LineBuf, SelectAnchor, SelectMode};
use nix::libc::STDOUT_FILENO; use nix::libc::STDOUT_FILENO;
use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size}; use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size};
use unicode_width::UnicodeWidthStr;
use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd}; use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd};
use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual}; use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual};
use crate::expand::expand_prompt;
use crate::libsh::sys::TTY_FILENO; use crate::libsh::sys::TTY_FILENO;
use crate::parse::lex::LexStream; use crate::parse::lex::LexStream;
use crate::prelude::*; use crate::prelude::*;
use crate::prompt::readline::term::{Pos, calc_str_width};
use crate::state::read_shopts; use crate::state::read_shopts;
use crate::{ use crate::{
libsh::{ libsh::{
@@ -33,41 +36,66 @@ pub mod vimode;
pub mod markers { pub mod markers {
use super::Marker; use super::Marker;
/* Highlight Markers */
// token-level (derived from token class) // token-level (derived from token class)
pub const COMMAND: Marker = '\u{fdd0}'; pub const COMMAND: Marker = '\u{e100}';
pub const BUILTIN: Marker = '\u{fdd1}'; pub const BUILTIN: Marker = '\u{e101}';
pub const ARG: Marker = '\u{fdd2}'; pub const ARG: Marker = '\u{e102}';
pub const KEYWORD: Marker = '\u{fdd3}'; pub const KEYWORD: Marker = '\u{e103}';
pub const OPERATOR: Marker = '\u{fdd4}'; pub const OPERATOR: Marker = '\u{e104}';
pub const REDIRECT: Marker = '\u{fdd5}'; pub const REDIRECT: Marker = '\u{e105}';
pub const COMMENT: Marker = '\u{fdd6}'; pub const COMMENT: Marker = '\u{e106}';
pub const ASSIGNMENT: Marker = '\u{fdd7}'; pub const ASSIGNMENT: Marker = '\u{e107}';
pub const CMD_SEP: Marker = '\u{fde0}'; pub const CMD_SEP: Marker = '\u{e108}';
pub const CASE_PAT: Marker = '\u{fde1}'; pub const CASE_PAT: Marker = '\u{e109}';
pub const SUBSH: Marker = '\u{fde7}'; pub const SUBSH: Marker = '\u{e10a}';
pub const SUBSH_END: Marker = '\u{fde8}'; pub const SUBSH_END: Marker = '\u{e10b}';
// sub-token (needs scanning) // sub-token (needs scanning)
pub const VAR_SUB: Marker = '\u{fdda}'; pub const VAR_SUB: Marker = '\u{e10c}';
pub const VAR_SUB_END: Marker = '\u{fde3}'; pub const VAR_SUB_END: Marker = '\u{e10d}';
pub const CMD_SUB: Marker = '\u{fdd8}'; pub const CMD_SUB: Marker = '\u{e10e}';
pub const CMD_SUB_END: Marker = '\u{fde4}'; pub const CMD_SUB_END: Marker = '\u{e10f}';
pub const PROC_SUB: Marker = '\u{fdd9}'; pub const PROC_SUB: Marker = '\u{e110}';
pub const PROC_SUB_END: Marker = '\u{fde9}'; pub const PROC_SUB_END: Marker = '\u{e111}';
pub const STRING_DQ: Marker = '\u{fddb}'; pub const STRING_DQ: Marker = '\u{e112}';
pub const STRING_DQ_END: Marker = '\u{fde5}'; pub const STRING_DQ_END: Marker = '\u{e113}';
pub const STRING_SQ: Marker = '\u{fddc}'; pub const STRING_SQ: Marker = '\u{e114}';
pub const STRING_SQ_END: Marker = '\u{fde6}'; pub const STRING_SQ_END: Marker = '\u{e115}';
pub const ESCAPE: Marker = '\u{fddd}'; pub const ESCAPE: Marker = '\u{e116}';
pub const GLOB: Marker = '\u{fdde}'; pub const GLOB: Marker = '\u{e117}';
// other // other
pub const VISUAL_MODE_START: Marker = '\u{fdea}'; pub const VISUAL_MODE_START: Marker = '\u{e118}';
pub const VISUAL_MODE_END: Marker = '\u{fdeb}'; 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] = [ pub const END_MARKERS: [Marker; 7] = [
VAR_SUB_END, VAR_SUB_END,
@@ -86,7 +114,7 @@ pub mod markers {
pub const MISC: [Marker; 3] = [ESCAPE, VISUAL_MODE_START, VISUAL_MODE_END]; pub const MISC: [Marker; 3] = [ESCAPE, VISUAL_MODE_START, VISUAL_MODE_END];
pub fn is_marker(c: Marker) -> bool { 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; type Marker = char;
@@ -103,7 +131,7 @@ pub enum ReadlineEvent {
pub struct ShedVi { pub struct ShedVi {
pub reader: PollReader, pub reader: PollReader,
pub writer: Box<dyn LineWriter>, pub writer: TermWriter,
pub prompt: String, pub prompt: String,
pub highlighter: Highlighter, pub highlighter: Highlighter,
@@ -124,7 +152,7 @@ impl ShedVi {
pub fn new(prompt: Option<String>, tty: RawFd) -> ShResult<Self> { pub fn new(prompt: Option<String>, tty: RawFd) -> ShResult<Self> {
let mut new = Self { let mut new = Self {
reader: PollReader::new(), reader: PollReader::new(),
writer: Box::new(TermWriter::new(tty)), writer: TermWriter::new(tty),
prompt: prompt.unwrap_or("$ ".styled(Style::Green)), prompt: prompt.unwrap_or("$ ".styled(Style::Green)),
completer: Completer::new(), completer: Completer::new(),
highlighter: Highlighter::new(), highlighter: Highlighter::new(),
@@ -136,7 +164,7 @@ impl ShedVi {
history: History::new()?, history: History::new()?,
needs_redraw: true, needs_redraw: true,
}; };
new.print_line()?; new.print_line(false)?;
Ok(new) Ok(new)
} }
@@ -201,7 +229,7 @@ impl ShedVi {
pub fn process_input(&mut self) -> ShResult<ReadlineEvent> { pub fn process_input(&mut self) -> ShResult<ReadlineEvent> {
// Redraw if needed // Redraw if needed
if self.needs_redraw { if self.needs_redraw {
self.print_line()?; self.print_line(false)?;
self.needs_redraw = 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)) { if cmd.is_submit_action() && (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete)) {
self.editor.set_hint(None); self.editor.set_hint(None);
self.editor.cursor.set(self.editor.cursor_max()); // Move the cursor to the very end 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")?; self.writer.flush_write("\n")?;
let buf = self.editor.take_buf(); let buf = self.editor.take_buf();
// Save command to history if auto_hist is enabled // Save command to history if auto_hist is enabled
@@ -322,7 +350,7 @@ impl ShedVi {
// Redraw if we processed any input // Redraw if we processed any input
if self.needs_redraw { if self.needs_redraw {
self.print_line()?; self.print_line(false)?;
self.needs_redraw = 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 line = self.line_text();
let new_layout = self.get_layout(&line); 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() { if let Some(layout) = self.old_layout.as_ref() {
self.writer.clear_rows(layout)?; self.writer.clear_rows(layout)?;
} }
self.writer.redraw(&self.prompt, &line, &new_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.writer.flush_write(&self.mode.cursor_style())?;
self.old_layout = Some(new_layout); self.old_layout = Some(new_layout);

View File

@@ -63,8 +63,8 @@ pub type Col = u16;
#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] #[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Pos { pub struct Pos {
col: Col, pub col: Col,
row: Row, pub row: Row,
} }
// I'd like to thank rustyline for this idea // 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' 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 // Big credit to rustyline for this
fn width(s: &str, esc_seq: &mut u8) -> u16 { fn width(s: &str, esc_seq: &mut u8) -> u16 {
let w_calc = width_calculator(); let w_calc = width_calculator();
@@ -155,10 +160,11 @@ fn width(s: &str, esc_seq: &mut u8) -> u16 {
/*} else if s == "m" { /*} else if s == "m" {
// last // last
*esc_seq = 0;*/ *esc_seq = 0;*/
} else { } else {
// not supported // not supported
*esc_seq = 0; *esc_seq = 0;
} }
0 0
} else if s == "\x1b" { } else if s == "\x1b" {
*esc_seq = 1; *esc_seq = 1;
@@ -813,7 +819,7 @@ impl Default for Layout {
pub struct TermWriter { pub struct TermWriter {
out: RawFd, out: RawFd,
t_cols: Col, // terminal width pub t_cols: Col, // terminal width
buffer: String, buffer: String,
w_calc: Box<dyn WidthCalculator>, w_calc: Box<dyn WidthCalculator>,
} }

View File

@@ -10,10 +10,10 @@ use std::{
use nix::unistd::{User, gethostname, getppid}; use nix::unistd::{User, gethostname, getppid};
use crate::{ 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}, error::{ShErr, ShErrKind, ShResult},
utils::VecDequeExt, utils::VecDequeExt,
}, parse::{ConjunctNode, NdRule, Node, ParsedSrc}, prelude::*, shopt::ShOpts }, parse::{ConjunctNode, NdRule, Node, ParsedSrc}, prelude::*, prompt::readline::markers, shopt::ShOpts
}; };
pub struct Shed { pub struct Shed {
@@ -604,7 +604,7 @@ impl VarTab {
fn update_arg_params(&mut self) { fn update_arg_params(&mut self) {
self.set_param( self.set_param(
ShellParam::AllArgs, 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()); self.set_param(ShellParam::ArgCount, &(self.sh_argv.len() - 1).to_string());
} }