diff --git a/Cargo.lock b/Cargo.lock index fc3bcfb8a..ef5b6cd29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,6 +3591,8 @@ dependencies = [ "thiserror", "tokio", "tokio-tungstenite 0.21.0", + "unicode-segmentation", + "unicode-width", "url", "walkdir", "warp", diff --git a/kinode/Cargo.toml b/kinode/Cargo.toml index 2a5ee06ec..cc76e55c5 100644 --- a/kinode/Cargo.toml +++ b/kinode/Cargo.toml @@ -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" diff --git a/kinode/packages/app_store/chain/src/lib.rs b/kinode/packages/app_store/chain/src/lib.rs index 6833747ec..405da7aa8 100644 --- a/kinode/packages/app_store/chain/src/lib.rs +++ b/kinode/packages/app_store/chain/src/lib.rs @@ -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 => { diff --git a/kinode/packages/terminal/terminal/src/lib.rs b/kinode/packages/terminal/terminal/src/lib.rs index 8f29dcb93..78d30b2e5 100644 --- a/kinode/packages/terminal/terminal/src/lib.rs +++ b/kinode/packages/terminal/terminal/src/lib.rs @@ -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::() { Ok(pid) => handle_run(&state.our, &pid, args.to_string()), - Err(_) => Err(ScriptError::UnknownName), + Err(_) => Err(ScriptError::UnknownName(head.to_string())), }, } } diff --git a/kinode/src/main.rs b/kinode/src/main.rs index da7a40110..256e2fe1d 100644 --- a/kinode/src/main.rs +++ b/kinode/src/main.rs @@ -77,6 +77,9 @@ async fn main() { let rpc = matches.get_one::("rpc"); let password = matches.get_one::("password"); + // logging mode is toggled at runtime by CTRL+L + let is_logging = *matches.get_one::("logging").unwrap(); + // detached determines whether terminal is interactive let detached = *matches.get_one::("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 to bind [default: first unbound at or above 8080]") + arg!(-p --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 level: higher is more verbose") + arg!(-v --verbosity "Verbosity level: higher is more verbose") .default_value("0") .value_parser(value_parser!(u8)), ) + .arg( + arg!(-l --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 "Run in detached mode (don't accept input)") + arg!(-d --detached "Run in detached mode (don't accept input)") .action(clap::ArgAction::SetTrue), ) .arg(arg!(--rpc "Add a WebSockets RPC URL at boot")) diff --git a/kinode/src/terminal/mod.rs b/kinode/src/terminal/mod.rs index 3af80b13e..bacfb2b47 100644 --- a/kinode/src/terminal/mod.rs +++ b/kinode/src/terminal/mod.rs @@ -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, + /// 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::(); - 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( - ¤t_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(¤t_line[*prompt_len..]) { + match command_history.get_prev(¤t_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( - ¤t_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( - ¤t_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(¤t_line.line) as u16, + *win_cols - current_line.prompt_len as u16, ); - execute!( - stdout, - cursor::MoveTo(0, *win_rows), - Print(utils::truncate_from_right( - ¤t_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, - ¤t_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, ¤t_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; - } - *line_col += 1; - if *search_mode { - utils::execute_search( - &our, - &mut stdout, - ¤t_line, - *prompt_len, - (*win_cols, *win_rows), - (*line_col, *cursor_col), - command_history, - *search_depth, - )?; - return Ok(false); + 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; } - execute!( - stdout, - cursor::MoveTo(0, *win_rows), - terminal::Clear(ClearType::CurrentLine), - Print(utils::truncate_in_place( - ¤t_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 { - return Ok(false); - } - 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, - ¤t_line, - *prompt_len, - (*win_cols, *win_rows), - (*line_col, *cursor_col), - command_history, - *search_depth, - )?; + 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; } - execute!( - stdout, - cursor::MoveTo(0, *win_rows), - terminal::Clear(ClearType::CurrentLine), - Print(utils::truncate_in_place( - ¤t_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() { - return Ok(false); - } - current_line.remove(*line_col); - if *search_mode { - utils::execute_search( - &our, - &mut stdout, - ¤t_line, - *prompt_len, - (*win_cols, *win_rows), - (*line_col, *cursor_col), - command_history, - *search_depth, - )?; + if current_line.line_col == current_line.line.graphemes(true).count() { return Ok(false); } - execute!( - stdout, - cursor::MoveTo(0, *win_rows), - terminal::Clear(ClearType::CurrentLine), - Print(utils::truncate_in_place( - ¤t_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( - ¤t_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( - ¤t_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(¤t_line[*prompt_len..], *search_depth) + .search(¤t_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(¤t_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) } diff --git a/kinode/src/terminal/utils.rs b/kinode/src/terminal/utils.rs index 17be9c46d..9674562bd 100644 --- a/kinode/src/terminal/utils.rs +++ b/kinode/src/terminal/utils.rs @@ -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 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 = ¤t_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, ¤t_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(); - } - 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(); + // check if u_end is at a character boundary + loop { + if result.is_char_boundary(u_end) { + break; + } + u_end += 1; } - 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 start = prompt_len + line_col - cursor_col as usize; - if start >= end { - return prompt.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::(); + + // 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::() + .chars() + .rev() + .collect::() + } 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::() + } 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::() + } 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::() + .chars() + .rev() + .collect::() } - prompt.to_string() + &s[start..end] }