use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; use std::env::current_dir; use std::fs; use std::io::{self, Read, Write}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; /// Typical file descriptor for STDIN on Linux on other U**X-likes pub const STDIN: i32 = 0; /// Keys recognized by [getchar] #[derive(Clone, PartialEq)] pub enum Key { /// Up arrow key Up, /// Down arrow key Down, /// Right arrow key Right, /// Left arrow key Left, /// Tab key Tab, /// Shift + Tab key combo ShiftTab, /// Backspace key Backspace, /// Return (or Enter) key Newline, /// Ctrl + c key combo Ctrlc, /// Ctrl + d key combo Ctrld, /// An ASCII or UTF-8 character Else(char), /// Key not recognized by [getchar] Ignored, } /// Flush STDIN macro_rules! flush { () => { io::stdout().lock().flush().unwrap(); }; } /// Read the next (single) byte of input from STDIN macro_rules! getc { ($buffer:expr) => { match io::stdin().read_exact($buffer) { Ok(_) => {} Err(_) => return Key::Ignored, } }; } /// Try to convert a UTF-8 byte sequence into a [std::char] macro_rules! char { ($extension:expr) => { match String::from_utf8_lossy($extension).chars().next() { Some(c) => Key::Else(c), None => Key::Ignored, } }; } /// This bit of code is annoying, but it appears that the unicode_width library is (incorrectly) /// computing some character's width to be 0. So, we need to loop through each character from /// the last path, and if unicode_width says its width is 0, add 1 to it. /// /// The string I have been testing with is: "01\ エレクトリック・パブリック.flac". /// There are some unicode characters with a width of zero /// (https://unicode-explorer.com/articles/space-characters), however, I have analyzed the raw /// byte sequence of all the characters in the string above, and I do not believe that any of /// them are zero width chars. /// /// In theory, this workaround may mess up completion for strings that DO have real zero width /// characters. However, as those characters seem to be mostly for typesetting, I am not going /// to worry about it, unless I run into it myself, or someone complains to me about it. /// /// Here is a simple ruby script that might help anyone looking to do their own analysis: /// #!/usr/bin/env ruby /// # frozen_string_literal: true /// /// str = '01\ エレクトリック・パブリック.flac' /// puts "len: #{str.length}" /// /// str.bytes do |b| /// # puts b /// puts b if b == 191 /// end /// /// puts /// # oth = "hi\u200chi" /// oth = "hi\ufeffhi" /// puts oth /// puts oth.bytes do |b| /// puts b /// end macro_rules! width { ($c:expr) => {{ let w = UnicodeWidthChar::width($c).unwrap_or(1); if w == 0 { 1 } else { w } }}; } /// Retrieve the next character from STDIN /// /// A UTF-8 compatible function used to get the next character from STDIN. A UTF-8 character may be /// anywhere from 1 byte to 4 bytes. The function does not (yet) account for every single key (or /// key combination) that a user might input. For any key (and/or combo) that is not recognized, /// this returns [Key::Ignored], and is functionally treated as a no op. /// /// Please see [Key] for a list of keys and combos that are recognized. fn getchar() -> Key { flush!(); let mut header = [0; 1]; let mut extension = Vec::new(); getc!(&mut header); extension.push(header[0]); if header[0] <= 0x7f { match header[0] { 3 => return Key::Ctrlc, 4 => return Key::Ctrld, 9 => return Key::Tab, b'\n' => return Key::Newline, 127 => return Key::Backspace, _ => {} } if header[0] == 27 { getc!(&mut header); match header[0] { 91 => { getc!(&mut header); match header[0] { 65 => return Key::Up, 66 => return Key::Down, 67 => return Key::Right, 68 => return Key::Left, 90 => return Key::ShiftTab, _ => return Key::Ignored, } } _ => return Key::Ignored, } } char!(&extension) } else if header[0] >= 0xc0 && header[0] <= 0xdf { let mut continuation = [0; 1]; getc!(&mut continuation); extension.extend(continuation); char!(&extension) } else if header[0] >= 0xe0 && header[0] <= 0xef { let mut continuation = [0; 2]; getc!(&mut continuation); extension.extend(continuation); char!(&extension) } else if header[0] >= 0xf0 && header[0] <= 0xf7 { let mut continuation = [0; 3]; getc!(&mut continuation); extension.extend(continuation); char!(&extension) } else { Key::Ignored } } /// Typical (tab drivem) completion /// /// This function provides a primitive form of shell completion (tab autocomplete). Currently, it /// only works with file paths, there is NO completion for commands, for instance. Also, completion /// is only enabled if the cursor is at the end of the buffer. Otherwise, pressing Tab will have no /// effect. /// /// This function cycles through possible completion paths, keeping track of the position, present /// working directory, etc. fn comp( buffer: &mut Vec, bpos: &mut usize, pos: &mut usize, len: &mut usize, pwd: &mut PathBuf, last_key: Key, reverse: bool, ) -> String { // Reset the buffer position if *bpos >= *len { *bpos -= *len; } let ori_path = buffer[*bpos..].into_iter().collect::>(); let mut width = 0; for c in ori_path.clone() { width += width!(*c); } // Remove the last autocomplete value from the buffer while *len > 0 { buffer.pop(); *len -= 1; } // Remove the last autocomplete value from the shell while width > 0 { print!("\u{8} \u{8}"); width -= 1; } // Reverse the buffer, which will make our while loop further down much easier let mut rev = buffer.iter().rev(); // Holds the last word (i.e. anything after the last space (' ') or forward slash ('/')) let mut word = Vec::new(); while let Some(c) = rev.next() { match rev.clone().peekable().peek() { Some(next) if **next == '\\' => { word.push(*c); } Some(_) => { if *c == ' ' || *c == '/' { break; } word.push(*c); } None => { word.push(*c); break; } } } // Collect the word into a String let word = word .iter() .rev() .filter(|c| **c != '\\') .collect::(); // Get a file listing, filtering for the word, if it is not empty let paths = match fs::read_dir(pwd) { Ok(paths) => paths, Err(_) => return String::new(), }; let paths = if word.is_empty() { paths .into_iter() .filter(|path| { !path .as_ref() .unwrap() .file_name() .to_string_lossy() .starts_with(".") }) .collect::>() } else { paths .into_iter() .filter(|path| { path.as_ref() .unwrap() .file_name() .to_string_lossy() .starts_with(&word) }) .collect::>() }; // Return nothing if there are not matches if paths.is_empty() { return String::new(); } // Collect path into DirEntry(s) let mut paths = paths .iter() .map(|path| path.as_ref().unwrap()) .collect::>(); // Sort the entries in alphabetical order paths.sort_by(|a, b| { a.file_name() .to_ascii_lowercase() .cmp(&b.file_name().to_ascii_lowercase()) }); // Do extra maths on the position if switching autocomplete directions if last_key == Key::Tab && reverse { let ori_pos = *pos; if *pos == 0 || *pos == 1 { *pos = paths.len(); } if ori_pos == 1 { *pos -= 1; } else { *pos -= 2; } } // Do extra maths on the position if switching autocomplete directions if last_key == Key::ShiftTab && !reverse { if *pos == 0 { *pos = paths.len(); } *pos += 2; } // Reset the position if it's larger than the number of entries if *pos >= paths.len() { *pos = *pos - paths.len(); // Need to double-check the newly computed pos if *pos >= paths.len() { *pos = 0; } } // Get the path (or only part of the path if we matched with word) let path = paths[*pos].path(); let path = if paths[*pos].path().is_dir() { (path.file_name().unwrap().to_string_lossy()[word.len()..].to_string() + "/").to_string() } else { path.file_name().unwrap().to_string_lossy()[word.len()..].to_string() }; let mut j = 0; let mut chars = path.chars().collect::>(); for (i, c) in chars.clone().iter().enumerate() { if *c == ' ' || *c == '#' || *c == '\'' || *c == '"' { chars.insert(i + j, '\\'); j += 1; } } let path = chars.iter().collect::(); // Print out the path print!("{}", path); // Update the buffer buffer.append(&mut path.chars().collect::>()); // Math the position if reverse { if *pos == 0 { *pos = paths.len(); } *pos -= 1; } else { *pos += 1; } // Update the length of the last comp *len += path.chars().collect::>().len(); // Update the buffer position *bpos += *len; path } pub fn getline( buffer: &mut Arc>>, pos: &mut Arc>, comp_pos: &mut Arc>, comp_len: &mut Arc>, last_key: &mut Arc>, ) -> usize { // Position in the buffer. Subject to change based on input from the user. Typing a character // increments the position, while backspacing will decrement it. The user may also move it // manually using the arrow keys to insert or delete at an arbitrary location in the buffer. // let mut pos = 0; // Present working directory. Keeps track of the user's PWD for autocomplete. let mut pwd = current_dir().unwrap_or(PathBuf::from(".")); // Position in the autocomplete list. This value gets reset if a new autocomplete list is // generated (for instance, if the user presses '/' to start autocomplete in a new directory). // let mut comp_pos = 0; // Keep track of the length of the last buffer from comp(). // let mut comp_len = 0; // Keep track of the last key for autocomplete, as we may need to add or sub additionally from // comp_pos before calling comp(). // let mut last_key = Key::Ignored; // Always clear our state variables before proceeding buffer.lock().unwrap().clear(); *pos.lock().unwrap() = 0; *comp_pos.lock().unwrap() = 0; *comp_len.lock().unwrap() = 0; *last_key.lock().unwrap() = Key::Ignored; // Receive input loop { match getchar() { Key::Tab => { if *pos.lock().unwrap() != buffer.lock().unwrap().len() { continue; } comp( &mut buffer.lock().unwrap(), &mut pos.lock().unwrap(), &mut comp_pos.lock().unwrap(), &mut comp_len.lock().unwrap(), &mut pwd, last_key.lock().unwrap().clone(), false, ); *last_key.lock().unwrap() = Key::Tab; } Key::ShiftTab => { if *pos.lock().unwrap() != buffer.lock().unwrap().len() { continue; } comp( &mut buffer.lock().unwrap(), &mut pos.lock().unwrap(), &mut comp_pos.lock().unwrap(), &mut comp_len.lock().unwrap(), &mut pwd, last_key.lock().unwrap().clone(), true, ); *last_key.lock().unwrap() = Key::ShiftTab; } Key::Ctrlc => kill(Pid::from_raw(0 as i32), Signal::SIGINT).unwrap(), Key::Ctrld => return 0, Key::Backspace => { if *pos.lock().unwrap() == 0 { continue; } *pos.lock().unwrap() -= 1; let mut buffer = buffer.lock().unwrap(); let trunc = &buffer[*pos.lock().unwrap()..].iter().collect::>(); let mut trunc_width = 0; for c in trunc { trunc_width += width!(**c); } let c = buffer.remove(*pos.lock().unwrap()); let mut width = UnicodeWidthChar::width(c).unwrap_or(1); if width == 0 { width += 1; } if *pos.lock().unwrap() == buffer.len() { (0..width).for_each(|_| print!("\u{8}")); print!(" \u{8}"); } else { (0..trunc_width).for_each(|_| print!(" ")); (0..trunc_width + width).for_each(|_| print!("\u{8}")); buffer[*pos.lock().unwrap()..] .iter() .for_each(|c| print!("{}", c)); (0..trunc_width - width).for_each(|_| print!("\u{8}")); } // Update directory for autocomplete let comp_path = match buffer.last() { Some(c) if *c == ' ' => String::new(), None => String::new(), _ => { let mut path = Vec::new(); for c in buffer.iter().rev() { if *c == ' ' { break; } path.push(*c); } let mut path = path.iter().rev().collect::(); if path.starts_with("..") && path.chars().filter(|c| *c == '/').count() == 1 { path = String::from(".."); } loop { match path.chars().last() { Some(c) if c == '/' || c == '.' || c == '~' => { break; } Some(_) => { path.pop(); } None => { break; } } } path } }; // Reset comp variables pwd = if comp_path.is_empty() { current_dir().unwrap_or(PathBuf::from(".")) } else if comp_path.starts_with("~") { PathBuf::from(format!("{}{}", env!("HOME"), &comp_path[1..]).to_string()) } else { PathBuf::from(comp_path) }; *comp_pos.lock().unwrap() = 0; let mut comp_len = comp_len.lock().unwrap(); if *comp_len > 0 { *comp_len -= 1; } } Key::Up => continue, Key::Down => continue, Key::Right => { if *pos.lock().unwrap() >= buffer.lock().unwrap().len() { continue; } let width = width!(buffer.lock().unwrap()[*pos.lock().unwrap()]); for _ in 0..width { print!("\x1b[1C"); } *pos.lock().unwrap() += 1; } Key::Left => { if *pos.lock().unwrap() == 0 { continue; } *pos.lock().unwrap() -= 1; let width = width!(buffer.lock().unwrap()[*pos.lock().unwrap()]); for _ in 0..width { print!("\u{8}"); } } Key::Ignored => continue, Key::Newline => break, Key::Else(c) => { let mut buffer = buffer.lock().unwrap(); match buffer.last() { Some(last) if *comp_pos.lock().unwrap() != 0 && *last == '/' && c == '/' => { buffer.pop(); *pos.lock().unwrap() -= 1; print!("\u{8} \u{8}") } _ => {} } let trunc = &buffer[*pos.lock().unwrap()..].iter().collect::(); let trunc_width = UnicodeWidthStr::width(trunc.as_str()); buffer.insert(*pos.lock().unwrap(), c); *pos.lock().unwrap() += 1; if *pos.lock().unwrap() == buffer.len() { print!("{}", c); } else { (0..trunc_width).for_each(|_| print!(" ")); (0..trunc_width).for_each(|_| print!("\u{8}")); buffer[*pos.lock().unwrap() - 1..] .iter() .for_each(|c| print!("{}", c)); (0..trunc_width).for_each(|_| print!("\u{8}")); } // Update directory for autocomplete let comp_path = match buffer.last() { Some(c) if *c == ' ' => String::new(), None => String::new(), _ => { let mut path = Vec::new(); for c in buffer.iter().rev() { if *c == ' ' { break; } path.push(*c); } let mut path = path.iter().rev().collect::(); if path.starts_with("..") && path.chars().filter(|c| *c == '/').count() == 1 { path = String::from(".."); } loop { match path.chars().last() { Some(c) if c == '/' || c == '.' || c == '~' => { break; } Some(_) => { path.pop(); } None => { break; } } } path } }; // Reset comp variables pwd = if comp_path.is_empty() { current_dir().unwrap_or(PathBuf::from(".")) } else if comp_path.starts_with("~") { PathBuf::from(format!("{}{}", env!("HOME"), &comp_path[1..]).to_string()) } else { PathBuf::from(comp_path) }; *comp_pos.lock().unwrap() = 0; *comp_len.lock().unwrap() = 0; } }; } println!(); buffer.lock().unwrap().push('\n'); let mut bytes = 0; buffer .lock() .unwrap() .iter() .for_each(|c| bytes += c.len_utf8()); bytes }