Merge pull request #528 from kinode-dao/dr/terminal-special-chars

terminal: refactor to use unicode graphemes properly, fix special character bugs
This commit is contained in:
doria 2024-09-13 07:00:28 +09:00 committed by GitHub
commit 44d47da20c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 384 additions and 376 deletions

2
Cargo.lock generated
View File

@ -3591,6 +3591,8 @@ dependencies = [
"thiserror",
"tokio",
"tokio-tungstenite 0.21.0",
"unicode-segmentation",
"unicode-width",
"url",
"walkdir",
"warp",

View File

@ -84,6 +84,8 @@ static_dir = "0.2.0"
thiserror = "1.0"
tokio = { version = "1.28", features = ["fs", "macros", "rt-multi-thread", "signal", "sync"] }
tokio-tungstenite = { version = "0.21.0", features = ["native-tls"] }
unicode-segmentation = "1.11"
unicode-width = "0.1.13"
url = "2.4.1"
warp = "0.3.5"
wasi-common = "19.0.1"

View File

@ -14,13 +14,12 @@ use kinode_process_lib::{
await_message, call_init, eth, get_blob, get_state, http, kernel_types as kt, kimap,
print_to_terminal, println, timer, Address, Message, PackageId, Request, Response,
};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
str::FromStr,
};
use serde::{Deserialize, Serialize};
wit_bindgen::generate!({
path: "target/wit",
generate_unused_types: true,
@ -256,10 +255,7 @@ fn handle_eth_log(our: &Address, state: &mut State, log: eth::Log) -> anyhow::Re
_ => Err(e),
},
}
.map_err(|e| {
println!("Couldn't find {hash_note}: {e:?}");
anyhow::anyhow!("metadata hash mismatch")
})?;
.map_err(|e| anyhow::anyhow!("Couldn't find {hash_note}: {e:?}"))?;
match data {
None => {

View File

@ -20,7 +20,7 @@ enum TerminalAction {
#[derive(Debug, Serialize, Deserialize)]
enum ScriptError {
UnknownName,
UnknownName(String),
FailedToReadWasm,
NoScriptsManifest,
NoScriptInManifest,
@ -30,18 +30,16 @@ enum ScriptError {
impl std::fmt::Display for ScriptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ScriptError::UnknownName => "script not found, either as an alias or process ID",
ScriptError::FailedToReadWasm => "failed to read script Wasm from VFS",
ScriptError::NoScriptsManifest => "no scripts manifest in package",
ScriptError::NoScriptInManifest => "script not in scripts.json file",
ScriptError::InvalidScriptsManifest => "could not parse scripts.json file",
ScriptError::KernelUnresponsive => "kernel unresponsive",
match self {
ScriptError::UnknownName(name) => {
write!(f, "'{name}' not found, either as an alias or process ID")
}
)
ScriptError::FailedToReadWasm => write!(f, "failed to read script Wasm from VFS"),
ScriptError::NoScriptsManifest => write!(f, "no scripts manifest in package"),
ScriptError::NoScriptInManifest => write!(f, "script not in scripts.json file"),
ScriptError::InvalidScriptsManifest => write!(f, "could not parse scripts.json file"),
ScriptError::KernelUnresponsive => write!(f, "kernel unresponsive"),
}
}
}
@ -187,7 +185,7 @@ fn parse_command(state: &mut TerminalState, line: String) -> Result<(), ScriptEr
Some(process) => handle_run(&state.our, process, args.to_string()),
None => match head.parse::<ProcessId>() {
Ok(pid) => handle_run(&state.our, &pid, args.to_string()),
Err(_) => Err(ScriptError::UnknownName),
Err(_) => Err(ScriptError::UnknownName(head.to_string())),
},
}
}

View File

@ -77,6 +77,9 @@ async fn main() {
let rpc = matches.get_one::<String>("rpc");
let password = matches.get_one::<String>("password");
// logging mode is toggled at runtime by CTRL+L
let is_logging = *matches.get_one::<bool>("logging").unwrap();
// detached determines whether terminal is interactive
let detached = *matches.get_one::<bool>("detached").unwrap();
@ -423,6 +426,7 @@ async fn main() {
print_receiver,
detached,
verbose_mode,
is_logging,
) => {
match quit {
Ok(()) => {
@ -630,7 +634,7 @@ fn build_command() -> Command {
.about("A General Purpose Sovereign Cloud Computing Platform")
.arg(arg!([home] "Path to home directory").required(true))
.arg(
arg!(--port <PORT> "Port to bind [default: first unbound at or above 8080]")
arg!(-p --port <PORT> "Port to bind [default: first unbound at or above 8080]")
.value_parser(value_parser!(u16)),
)
.arg(
@ -644,17 +648,21 @@ fn build_command() -> Command {
.value_parser(value_parser!(u16)),
)
.arg(
arg!(--verbosity <VERBOSITY> "Verbosity level: higher is more verbose")
arg!(-v --verbosity <VERBOSITY> "Verbosity level: higher is more verbose")
.default_value("0")
.value_parser(value_parser!(u8)),
)
.arg(
arg!(-l --logging <IS_LOGGING> "Run in logging mode (toggled at runtime by CTRL+L): write all terminal output to .terminal_log file")
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(--"reveal-ip" "If set to false, as an indirect node, always use routers to connect to other nodes.")
.default_value("true")
.value_parser(value_parser!(bool)),
)
.arg(
arg!(--detached <IS_DETACHED> "Run in detached mode (don't accept input)")
arg!(-d --detached <IS_DETACHED> "Run in detached mode (don't accept input)")
.action(clap::ArgAction::SetTrue),
)
.arg(arg!(--rpc <RPC> "Add a WebSockets RPC URL at boot"))

View File

@ -16,29 +16,161 @@ use std::{
io::{BufWriter, Write},
};
use tokio::signal::unix::{signal, SignalKind};
use unicode_segmentation::UnicodeSegmentation;
pub mod utils;
pub struct State {
struct State {
pub stdout: std::io::Stdout,
/// handle for writing to on-disk log (disabled by default, triggered by CTRL+L)
pub log_writer: BufWriter<std::fs::File>,
/// in-memory searchable command history that persists itself on disk (default size: 1000)
pub command_history: utils::CommandHistory,
/// terminal window width, 0 is leftmost column
pub win_cols: u16,
/// terminal window height, 0 is topmost row
pub win_rows: u16,
pub prompt_len: usize,
pub line_col: usize,
pub cursor_col: u16,
pub current_line: String,
/// the input line (bottom row)
pub current_line: CurrentLine,
/// flag representing whether we are in step-through mode (activated by CTRL+J, stepped by CTRL+S)
pub in_step_through: bool,
/// flag representing whether we are in search mode (activated by CTRL+R, exited by CTRL+G)
pub search_mode: bool,
/// depth of search mode (activated by CTRL+R, increased by CTRL+R)
pub search_depth: usize,
/// flag representing whether we are in logging mode (activated by CTRL+L)
pub logging_mode: bool,
/// verbosity mode (increased by CTRL+V)
pub verbose_mode: u8,
}
/*
* terminal driver
*/
impl State {
fn display_current_input_line(&mut self, show_end: bool) -> Result<(), std::io::Error> {
execute!(
self.stdout,
cursor::MoveTo(0, self.win_rows),
terminal::Clear(ClearType::CurrentLine),
style::SetForegroundColor(style::Color::Reset),
Print(self.current_line.prompt),
Print(utils::truncate_in_place(
&self.current_line.line,
self.win_cols - self.current_line.prompt_len as u16,
self.current_line.line_col,
self.current_line.cursor_col,
show_end
)),
cursor::MoveTo(
self.current_line.prompt_len as u16 + self.current_line.cursor_col,
self.win_rows
),
)
}
fn search(&mut self, our_name: &str) -> Result<(), std::io::Error> {
let search_prompt = format!("{} *", our_name);
let search_query = &self.current_line.line;
if let Some(result) = self.command_history.search(search_query, self.search_depth) {
let (result_underlined, search_cursor_col) = utils::underline(result, search_query);
execute!(
self.stdout,
cursor::MoveTo(0, self.win_rows),
terminal::Clear(terminal::ClearType::CurrentLine),
style::SetForegroundColor(style::Color::Reset),
style::Print(&search_prompt),
style::Print(utils::truncate_in_place(
&result_underlined,
self.win_cols - self.current_line.prompt_len as u16,
self.current_line.line_col,
search_cursor_col,
false,
)),
cursor::MoveTo(
self.current_line.prompt_len as u16 + search_cursor_col,
self.win_rows
),
)
} else {
execute!(
self.stdout,
cursor::MoveTo(0, self.win_rows),
terminal::Clear(terminal::ClearType::CurrentLine),
style::SetForegroundColor(style::Color::Reset),
style::Print(&search_prompt),
style::Print(utils::truncate_in_place(
&format!("{}: no results", &self.current_line.line),
self.win_cols - self.current_line.prompt_len as u16,
self.current_line.line_col,
self.current_line.cursor_col,
false,
)),
cursor::MoveTo(
self.current_line.prompt_len as u16 + self.current_line.cursor_col,
self.win_rows
),
)
}
}
}
struct CurrentLine {
/// prompt for user input (e.g. "mynode.os > ")
pub prompt: &'static str,
pub prompt_len: usize,
/// the grapheme index of the cursor in the current line
pub line_col: usize,
/// the column index of the cursor in the terminal window (not including prompt)
pub cursor_col: u16,
/// the line itself, which does not include the prompt
pub line: String,
}
impl CurrentLine {
fn byte_index(&self) -> usize {
self.line
.grapheme_indices(true)
.nth(self.line_col)
.map(|(i, _)| i)
.unwrap_or_else(|| self.line.len())
}
fn current_char_left(&self) -> Option<&str> {
if self.line_col == 0 {
None
} else {
self.line.graphemes(true).nth(self.line_col - 1)
}
}
fn current_char_right(&self) -> Option<&str> {
self.line.graphemes(true).nth(self.line_col)
}
fn insert_char(&mut self, c: char) {
let byte_index = self.byte_index();
self.line.insert(byte_index, c);
}
fn insert_str(&mut self, s: &str) {
let byte_index = self.byte_index();
self.line.insert_str(byte_index, s);
}
/// returns the deleted character
fn delete_char(&mut self) -> String {
let byte_index = self.byte_index();
let next_grapheme = self.line[byte_index..]
.graphemes(true)
.next()
.map(|g| g.len())
.unwrap_or(0);
self.line
.drain(byte_index..byte_index + next_grapheme)
.collect()
}
}
/// main entry point for terminal process
/// called by main.rs
pub async fn terminal(
our: Identity,
version: &str,
@ -49,22 +181,22 @@ pub async fn terminal(
mut print_rx: PrintReceiver,
is_detached: bool,
verbose_mode: u8,
is_logging: bool,
) -> anyhow::Result<()> {
let (stdout, _maybe_raw_mode) = utils::startup(&our, version, is_detached)?;
let (stdout, _maybe_raw_mode) = utils::splash(&our, version, is_detached)?;
let (win_cols, win_rows) = crossterm::terminal::size().unwrap_or_else(|_| (0, 0));
let current_line = format!("{} > ", our.name);
let prompt_len: usize = our.name.len() + 3;
let cursor_col: u16 = prompt_len as u16;
let line_col: usize = cursor_col as usize;
let (prompt, prompt_len) = utils::make_prompt(&our.name);
let cursor_col: u16 = 0;
let line_col: usize = 0;
let in_step_through: bool = false;
let in_step_through = false;
let search_mode: bool = false;
let search_mode = false;
let search_depth: usize = 0;
let logging_mode: bool = false;
let logging_mode = is_logging;
// the terminal stores the most recent 1000 lines entered by user
// in history. TODO should make history size adjustable.
@ -99,10 +231,13 @@ pub async fn terminal(
command_history,
win_cols,
win_rows,
prompt_len,
line_col,
cursor_col,
current_line,
current_line: CurrentLine {
prompt,
prompt_len,
line_col,
cursor_col,
line: "".to_string(),
},
in_step_through,
search_mode,
search_depth,
@ -219,22 +354,10 @@ fn handle_printout(printout: Printout, state: &mut State) -> anyhow::Result<()>
}),
)?;
for line in printout.content.lines() {
execute!(stdout, Print(format!("{}\r\n", line)),)?;
execute!(stdout, Print(format!("{line}\r\n")))?;
}
// reset color and re-display the current input line
// re-place cursor where user had it at input line
execute!(
stdout,
style::ResetColor,
cursor::MoveTo(0, state.win_rows),
Print(utils::truncate_in_place(
&state.current_line,
state.prompt_len,
state.win_cols,
(state.line_col, state.cursor_col)
)),
cursor::MoveTo(state.cursor_col, state.win_rows),
)?;
// re-display the current input line
state.display_current_input_line(false)?;
Ok(())
}
@ -252,12 +375,8 @@ async fn handle_event(
command_history,
win_cols,
win_rows,
prompt_len,
line_col,
cursor_col,
current_line,
in_step_through,
search_mode,
search_depth,
logging_mode,
verbose_mode,
@ -275,8 +394,19 @@ async fn handle_event(
// generally stable way.
//
Event::Resize(width, height) => {
*win_cols = width;
// this is critical at moment of resize not to double-up lines
execute!(
state.stdout,
cursor::MoveTo(0, height),
terminal::Clear(ClearType::CurrentLine)
)?;
*win_cols = width - 1;
*win_rows = height;
if current_line.cursor_col + current_line.prompt_len as u16 > *win_cols {
current_line.cursor_col = *win_cols - current_line.prompt_len as u16;
// can't do this because of wide graphemes :/
// current_line.line_col = current_line.cursor_col as usize;
}
}
//
// PASTE: handle pasting of text from outside
@ -287,23 +417,12 @@ async fn handle_event(
.chars()
.filter(|c| !c.is_control() && !c.is_ascii_control())
.collect::<String>();
current_line.insert_str(*line_col, &pasted);
*line_col = *line_col + pasted.len();
*cursor_col = std::cmp::min(
line_col.to_owned().try_into().unwrap_or(*win_cols),
*win_cols,
current_line.insert_str(&pasted);
current_line.line_col = current_line.line_col + pasted.graphemes(true).count();
current_line.cursor_col = std::cmp::min(
current_line.cursor_col + utils::display_width(&pasted) as u16,
*win_cols - current_line.prompt_len as u16,
);
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
Print(utils::truncate_in_place(
&current_line,
*prompt_len,
*win_cols,
(*line_col, *cursor_col)
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
}
//
// CTRL+C, CTRL+D: turn off the node
@ -370,6 +489,7 @@ async fn handle_event(
)
.send(&print_tx)
.await;
return Ok(false);
}
//
// CTRL+J: toggle debug mode -- makes system-level event loop step-through
@ -393,6 +513,7 @@ async fn handle_event(
)
.send(&print_tx)
.await;
return Ok(false);
}
//
// CTRL+S: step through system-level event loop (when in step-through mode)
@ -403,6 +524,7 @@ async fn handle_event(
..
}) => {
let _ = debug_event_loop.send(DebugCommand::Step).await;
return Ok(false);
}
//
// CTRL+L: toggle logging mode
@ -419,6 +541,7 @@ async fn handle_event(
)
.send(&print_tx)
.await;
return Ok(false);
}
//
// UP / CTRL+P: go up one command in history
@ -431,28 +554,25 @@ async fn handle_event(
modifiers: KeyModifiers::CONTROL,
..
}) => {
if state.search_mode {
return Ok(false);
}
// go up one command in history
match command_history.get_prev(&current_line[*prompt_len..]) {
match command_history.get_prev(&current_line.line) {
Some(line) => {
*current_line = format!("{} > {}", our.name, line);
*line_col = current_line.len();
let width = utils::display_width(&line);
current_line.line_col = line.graphemes(true).count();
current_line.line = line;
current_line.cursor_col =
std::cmp::min(width as u16, *win_cols - current_line.prompt_len as u16);
}
None => {
// the "no-no" ding
print!("\x07");
}
}
*cursor_col = std::cmp::min(current_line.len() as u16, *win_cols);
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_rightward(
current_line,
*prompt_len,
*win_cols
)),
)?;
state.display_current_input_line(true)?;
return Ok(false);
}
//
// DOWN / CTRL+N: go down one command in history
@ -466,28 +586,25 @@ async fn handle_event(
modifiers: KeyModifiers::CONTROL,
..
}) => {
if state.search_mode {
return Ok(false);
}
// go down one command in history
match command_history.get_next() {
Some(line) => {
*current_line = format!("{} > {}", our.name, line);
*line_col = current_line.len();
let width = utils::display_width(&line);
current_line.line_col = line.graphemes(true).count();
current_line.line = line;
current_line.cursor_col =
std::cmp::min(width as u16, *win_cols - current_line.prompt_len as u16);
}
None => {
// the "no-no" ding
print!("\x07");
}
}
*cursor_col = std::cmp::min(current_line.len() as u16, *win_cols);
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_rightward(
&current_line,
*prompt_len,
*win_cols
)),
)?;
state.display_current_input_line(true)?;
return Ok(false);
}
//
// CTRL+A: jump to beginning of line
@ -497,19 +614,11 @@ async fn handle_event(
modifiers: KeyModifiers::CONTROL,
..
}) => {
*line_col = *prompt_len;
*cursor_col = *prompt_len as u16;
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
Print(utils::truncate_from_left(
&current_line,
*prompt_len,
*win_cols,
*line_col
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
if state.search_mode {
return Ok(false);
}
current_line.line_col = 0;
current_line.cursor_col = 0;
}
//
// CTRL+E: jump to end of line
@ -519,21 +628,14 @@ async fn handle_event(
modifiers: KeyModifiers::CONTROL,
..
}) => {
*line_col = current_line.len();
*cursor_col = std::cmp::min(
line_col.to_owned().try_into().unwrap_or(*win_cols),
*win_cols,
if state.search_mode {
return Ok(false);
}
current_line.line_col = current_line.line.graphemes(true).count();
current_line.cursor_col = std::cmp::min(
utils::display_width(&current_line.line) as u16,
*win_cols - current_line.prompt_len as u16,
);
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
Print(utils::truncate_from_right(
&current_line,
*prompt_len,
*win_cols,
*line_col
)),
)?;
}
//
// CTRL+R: enter search mode
@ -544,20 +646,10 @@ async fn handle_event(
modifiers: KeyModifiers::CONTROL,
..
}) => {
if *search_mode {
if state.search_mode {
*search_depth += 1;
}
*search_mode = true;
utils::execute_search(
&our,
&mut stdout,
&current_line,
*prompt_len,
(*win_cols, *win_rows),
(*line_col, *cursor_col),
command_history,
*search_depth,
)?;
state.search_mode = true;
}
//
// CTRL+G: exit search mode
@ -568,20 +660,8 @@ async fn handle_event(
..
}) => {
// just show true current line as usual
*search_mode = false;
state.search_mode = false;
*search_depth = 0;
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_in_place(
&format!("{} > {}", our.name, &current_line[*prompt_len..]),
*prompt_len,
*win_cols,
(*line_col, *cursor_col)
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
}
//
// KEY: handle keypress events
@ -592,165 +672,81 @@ async fn handle_event(
// CHAR: write a single character
//
KeyCode::Char(c) => {
current_line.insert(*line_col, c);
if cursor_col < win_cols {
*cursor_col += 1;
current_line.insert_char(c);
if (current_line.cursor_col + current_line.prompt_len as u16) < *win_cols {
current_line.cursor_col += utils::display_width(&c.to_string()) as u16;
}
*line_col += 1;
if *search_mode {
utils::execute_search(
&our,
&mut stdout,
&current_line,
*prompt_len,
(*win_cols, *win_rows),
(*line_col, *cursor_col),
command_history,
*search_depth,
)?;
return Ok(false);
}
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_in_place(
&current_line,
*prompt_len,
*win_cols,
(*line_col, *cursor_col)
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
current_line.line_col += 1;
}
//
// BACKSPACE: delete a single character at cursor
//
KeyCode::Backspace => {
if line_col == prompt_len {
if current_line.line_col == 0 {
return Ok(false);
} else {
current_line.line_col -= 1;
let c = current_line.delete_char();
current_line.cursor_col -= utils::display_width(&c) as u16;
}
if *cursor_col as usize == *line_col {
*cursor_col -= 1;
}
*line_col -= 1;
current_line.remove(*line_col);
if *search_mode {
utils::execute_search(
&our,
&mut stdout,
&current_line,
*prompt_len,
(*win_cols, *win_rows),
(*line_col, *cursor_col),
command_history,
*search_depth,
)?;
return Ok(false);
}
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_in_place(
&current_line,
*prompt_len,
*win_cols,
(*line_col, *cursor_col)
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
}
//
// DELETE: delete a single character at right of cursor
//
KeyCode::Delete => {
if *line_col == current_line.len() {
if current_line.line_col == current_line.line.graphemes(true).count() {
return Ok(false);
}
current_line.remove(*line_col);
if *search_mode {
utils::execute_search(
&our,
&mut stdout,
&current_line,
*prompt_len,
(*win_cols, *win_rows),
(*line_col, *cursor_col),
command_history,
*search_depth,
)?;
return Ok(false);
}
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(utils::truncate_in_place(
&current_line,
*prompt_len,
*win_cols,
(*line_col, *cursor_col)
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
current_line.delete_char();
}
//
// LEFT: move cursor one spot left
//
KeyCode::Left => {
if *cursor_col as usize == *prompt_len {
if line_col == prompt_len {
if current_line.cursor_col as usize == 0 {
if current_line.line_col == 0 {
// at the very beginning of the current typed line
return Ok(false);
} else {
// virtual scroll leftward through line
*line_col -= 1;
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
Print(utils::truncate_from_left(
&current_line,
*prompt_len,
*win_cols,
*line_col
)),
cursor::MoveTo(*cursor_col, *win_rows),
)?;
current_line.line_col -= 1;
}
} else {
// simply move cursor and line position left
execute!(stdout, cursor::MoveLeft(1),)?;
*cursor_col -= 1;
*line_col -= 1;
let width = current_line
.current_char_left()
.map_or_else(|| 1, |c| utils::display_width(&c))
as u16;
execute!(stdout, cursor::MoveLeft(width))?;
current_line.cursor_col -= width;
if current_line.line_col != 0 {
current_line.line_col -= 1;
}
return Ok(false);
}
}
//
// RIGHT: move cursor one spot right
//
KeyCode::Right => {
if *line_col == current_line.len() {
if current_line.line_col == current_line.line.graphemes(true).count() {
// at the very end of the current typed line
return Ok(false);
};
if *cursor_col < (*win_cols - 1) {
if (current_line.cursor_col + current_line.prompt_len as u16) < (*win_cols - 1)
{
// simply move cursor and line position right
execute!(stdout, cursor::MoveRight(1))?;
*cursor_col += 1;
*line_col += 1;
let width = current_line
.current_char_right()
.map_or_else(|| 1, |c| utils::display_width(&c))
as u16;
execute!(stdout, cursor::MoveRight(width))?;
current_line.cursor_col += width;
current_line.line_col += 1;
return Ok(false);
} else {
// virtual scroll rightward through line
*line_col += 1;
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
Print(utils::truncate_from_right(
&current_line,
*prompt_len,
*win_cols,
*line_col
)),
)?;
current_line.line_col += 1;
}
}
//
@ -758,29 +754,27 @@ async fn handle_event(
//
KeyCode::Enter => {
// if we were in search mode, pull command from that
let command = if !*search_mode {
current_line[*prompt_len..].to_string()
let command = if !state.search_mode {
current_line.line.clone()
} else {
command_history
.search(&current_line[*prompt_len..], *search_depth)
.search(&current_line.line, *search_depth)
.unwrap_or_default()
.to_string()
};
let next = format!("{} > ", our.name);
execute!(
stdout,
cursor::MoveTo(0, *win_rows),
terminal::Clear(ClearType::CurrentLine),
Print(&format!("{} > {}", our.name, command)),
Print(&current_line.prompt),
Print(&command),
Print("\r\n"),
Print(&next),
)?;
*search_mode = false;
state.search_mode = false;
*search_depth = 0;
*current_line = next;
command_history.add(command.clone());
*cursor_col = *prompt_len as u16;
*line_col = *prompt_len;
current_line.cursor_col = 0;
current_line.line_col = 0;
command_history.add(command.to_string());
KernelMessage::builder()
.id(rand::random())
.source((our.name.as_str(), TERMINAL_PROCESS_ID.clone()))
@ -796,6 +790,7 @@ async fn handle_event(
.unwrap()
.send(&event_loop)
.await;
current_line.line = "".to_string();
}
_ => {
// some keycode we don't care about, yet
@ -806,5 +801,10 @@ async fn handle_event(
// some terminal event we don't care about, yet
}
}
if state.search_mode {
state.search(&our.name)?;
} else {
state.display_current_input_line(false)?;
}
Ok(false)
}

View File

@ -5,6 +5,8 @@ use std::{
fs::File,
io::{BufWriter, Stdout, Write},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub struct RawMode;
impl RawMode {
@ -24,7 +26,7 @@ impl Drop for RawMode {
}
}
pub fn startup(
pub fn splash(
our: &Identity,
version: &str,
is_detached: bool,
@ -123,6 +125,16 @@ pub fn startup(
))
}
pub fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
/// produce command line prompt and its length
pub fn make_prompt(our_name: &str) -> (&'static str, usize) {
let prompt = Box::leak(format!("{} > ", our_name).into_boxed_str());
(prompt, display_width(prompt))
}
pub fn cleanup(quit_msg: &str) {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
@ -202,7 +214,6 @@ impl CommandHistory {
/// if depth = 0, find most recent command in history that contains the
/// provided string. otherwise, skip the first <depth> matches.
/// yes this is O(n) to provide desired ordering, can revisit if slow
pub fn search(&mut self, find: &str, depth: usize) -> Option<&str> {
let mut skips = 0;
if find.is_empty() {
@ -224,108 +235,99 @@ impl CommandHistory {
}
}
pub fn execute_search(
our: &Identity,
stdout: &mut std::io::StdoutLock,
current_line: &str,
prompt_len: usize,
(win_cols, win_rows): (u16, u16),
(line_col, cursor_col): (usize, u16),
command_history: &mut CommandHistory,
search_depth: usize,
) -> Result<(), std::io::Error> {
let search_query = &current_line[prompt_len..];
if let Some(result) = command_history.search(search_query, search_depth) {
let (result_underlined, u_end) = underline(result, search_query);
let search_cursor_col = u_end + prompt_len as u16;
crossterm::execute!(
stdout,
crossterm::cursor::MoveTo(0, win_rows),
crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine),
crossterm::style::Print(truncate_in_place(
&format!("{} * {}", our.name, result_underlined),
prompt_len,
win_cols,
(line_col, search_cursor_col)
)),
crossterm::cursor::MoveTo(search_cursor_col, win_rows),
)
} else {
crossterm::execute!(
stdout,
crossterm::cursor::MoveTo(0, win_rows),
crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine),
crossterm::style::Print(truncate_in_place(
&format!("{} * {}: no results", our.name, &current_line[prompt_len..]),
prompt_len,
win_cols,
(line_col, cursor_col)
)),
crossterm::cursor::MoveTo(cursor_col, win_rows),
)
}
}
pub fn underline(s: &str, to_underline: &str) -> (String, u16) {
// format result string to have query portion underlined
let mut result = s.to_string();
let u_start = s.find(to_underline).unwrap();
let u_end = u_start + to_underline.len();
let mut u_end = u_start + to_underline.len();
result.insert_str(u_end, "\x1b[24m");
result.insert_str(u_start, "\x1b[4m");
(result, u_end as u16)
}
pub fn truncate_rightward(s: &str, prompt_len: usize, width: u16) -> String {
if s.len() <= width as usize {
// no adjustment to be made
return s.to_string();
// check if u_end is at a character boundary
loop {
if result.is_char_boundary(u_end) {
break;
}
u_end += 1;
}
let sans_prompt = &s[prompt_len..];
s[..prompt_len].to_string() + &sans_prompt[(s.len() - width as usize)..]
}
/// print prompt, then as many chars as will fit in term starting from line_col
pub fn truncate_from_left(s: &str, prompt_len: usize, width: u16, line_col: usize) -> String {
if s.len() <= width as usize {
// no adjustment to be made
return s.to_string();
}
s[..prompt_len].to_string() + &s[line_col..(width as usize - prompt_len + line_col)]
}
/// print prompt, then as many chars as will fit in term leading up to line_col
pub fn truncate_from_right(s: &str, prompt_len: usize, width: u16, line_col: usize) -> String {
if s.len() <= width as usize {
// no adjustment to be made
return s.to_string();
}
s[..prompt_len].to_string() + &s[(prompt_len + (line_col - width as usize))..line_col]
let cursor_end = display_width(&result[..u_end]);
(result, cursor_end as u16)
}
/// if line is wider than the terminal, truncate it intelligently,
/// keeping the cursor in the same relative position.
pub fn truncate_in_place(
s: &str,
prompt_len: usize,
width: u16,
(line_col, cursor_col): (usize, u16),
term_width: u16,
line_col: usize,
cursor_col: u16,
show_end: bool,
) -> String {
if s.len() <= width as usize {
let width = display_width(s);
if width <= term_width as usize {
// no adjustment to be made
return s.to_string();
}
// always keep prompt at left
let prompt = &s[..prompt_len];
// print as much of the command fits left of col_in_command before cursor_col,
// then fill out the rest up to width
let end = width as usize + line_col - cursor_col as usize;
if end > s.len() {
return s.to_string();
let graphemes_with_width = s.graphemes(true).map(|g| (g, display_width(g)));
let adjusted_cursor_col = graphemes_with_width
.clone()
.take(cursor_col as usize)
.map(|(_, w)| w)
.sum::<usize>();
// input line is wider than terminal, clip start/end/both while keeping cursor
// in same relative position.
if show_end || cursor_col >= term_width {
// show end of line, truncate everything before
let mut width = 0;
graphemes_with_width
.rev()
.take_while(|(_, w)| {
width += w;
width <= term_width as usize
})
.map(|(g, _)| g)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
} else if adjusted_cursor_col as usize == line_col {
// beginning of line is placed at left end, truncate everything past term_width
let mut width = 0;
graphemes_with_width
.take_while(|(_, w)| {
width += w;
width <= term_width as usize
})
.map(|(g, _)| g)
.collect::<String>()
} else if adjusted_cursor_col < line_col {
// some amount of the line is to the left of the terminal, clip from the right
// skip the difference between line_col and cursor_col *after adjusting for
// wide characters
let mut width = 0;
graphemes_with_width
.skip(line_col - adjusted_cursor_col)
.take_while(|(_, w)| {
width += w;
width <= term_width as usize
})
.map(|(g, _)| g)
.collect::<String>()
} else {
// show end of line, truncate everything before
let mut width = 0;
graphemes_with_width
.rev()
.take_while(|(_, w)| {
width += w;
width <= term_width as usize
})
.map(|(g, _)| g)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
}
let start = prompt_len + line_col - cursor_col as usize;
if start >= end {
return prompt.to_string();
}
prompt.to_string() + &s[start..end]
}