use std::{ io::{stderr, stdin, BufRead, BufReader, Read, Stderr, Stdin, Write}, os::fd::AsRawFd, borrow::Borrow, }; use keyfork_crossterm::{ cursor, event::{read, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyModifiers}, style::{Print, PrintStyledContent, Stylize}, terminal::{self, EnterAlternateScreen, FdTerminal, LeaveAlternateScreen, TerminalIoctl}, tty::IsTty, ExecutableCommand, QueueableCommand, }; use crate::{PromptHandler, Message, Wordlist, Error}; pub type Result = std::result::Result; struct TerminalGuard<'a, R, W> where W: Write + AsRawFd, { read: &'a mut BufReader, write: &'a mut W, terminal: &'a mut FdTerminal, } impl<'a, R, W> TerminalGuard<'a, R, W> where W: Write + AsRawFd, R: Read, { fn new(read: &'a mut BufReader, write: &'a mut W, terminal: &'a mut FdTerminal) -> Self { Self { read, write, terminal, } } fn alternate_screen(mut self) -> std::io::Result { self.execute(EnterAlternateScreen)?; Ok(self) } fn raw_mode(self) -> std::io::Result { self.terminal.enable_raw_mode()?; Ok(self) } fn bracketed_paste(mut self) -> std::io::Result { self.execute(EnableBracketedPaste)?; Ok(self) } } impl TerminalIoctl for TerminalGuard<'_, R, W> where R: Read, W: Write + AsRawFd, { fn enable_raw_mode(&mut self) -> std::io::Result<()> { self.terminal.enable_raw_mode() } fn disable_raw_mode(&mut self) -> std::io::Result<()> { self.terminal.disable_raw_mode() } fn size(&self) -> std::io::Result<(u16, u16)> { self.terminal.size() } fn window_size(&self) -> std::io::Result { self.terminal.window_size() } } impl Read for TerminalGuard<'_, R, W> where R: Read, W: Write + AsRawFd, { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.read.read(buf) } } impl BufRead for TerminalGuard<'_, R, W> where R: Read, W: Write + AsRawFd, { fn fill_buf(&mut self) -> std::io::Result<&[u8]> { self.read.fill_buf() } fn consume(&mut self, amt: usize) { self.read.consume(amt) } } impl Write for TerminalGuard<'_, R, W> where W: Write + AsRawFd, { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.write.write(buf) } fn flush(&mut self) -> std::io::Result<()> { self.write.flush() } } impl Drop for TerminalGuard<'_, R, W> where W: Write + AsRawFd, { fn drop(&mut self) { self.write.execute(DisableBracketedPaste).unwrap(); self.write.execute(LeaveAlternateScreen).unwrap(); self.terminal.disable_raw_mode().unwrap(); } } pub struct Terminal { read: BufReader, write: W, terminal: FdTerminal, } impl Terminal where R: Read + Sized, W: Write + AsRawFd + Sized, { pub fn new(read_handle: R, write_handle: W) -> Result { if !write_handle.is_tty() { return Err(Error::NotATTY); } Ok(Self { read: BufReader::new(read_handle), terminal: FdTerminal::from(write_handle.as_raw_fd()), write: write_handle, }) } fn lock(&mut self) -> TerminalGuard<'_, R, W> { TerminalGuard::new(&mut self.read, &mut self.write, &mut self.terminal) } } impl PromptHandler for Terminal where R: Read + Sized, W: Write + AsRawFd + Sized { fn prompt_input(&mut self, prompt: &str) -> Result { let mut terminal = self.lock().alternate_screen()?; terminal .queue(terminal::Clear(terminal::ClearType::All))? .queue(cursor::MoveTo(0, 0))?; let mut lines = prompt.lines().peekable(); while let Some(line) = lines.next() { terminal.queue(Print(line))?; if lines.peek().is_some() { terminal .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } } terminal.flush()?; let mut line = String::new(); terminal.read.read_line(&mut line)?; Ok(line) } #[cfg(feature = "mnemonic")] fn prompt_validated_wordlist( &mut self, prompt: &str, wordlist: &Wordlist, retries: u8, validator_fn: F, ) -> Result where F: Fn(String) -> Result, E: std::error::Error, { let mut last_error = None; for _ in 0..retries { let s = self.prompt_wordlist(prompt, wordlist)?; match validator_fn(s) { Ok(v) => return Ok(v), Err(e) => { self.prompt_message(&Message::Text(format!("Error validating wordlist: {e}")))?; let _ = last_error.insert(e); } } } Err(Error::Validation( retries, last_error .map(|e| e.to_string()) .unwrap_or_else(|| "Unknown".to_string()), )) } #[cfg(feature = "mnemonic")] #[allow(clippy::too_many_lines)] fn prompt_wordlist(&mut self, prompt: &str, wordlist: &Wordlist) -> Result { let mut terminal = self .lock() .alternate_screen()? .raw_mode()? .bracketed_paste()?; terminal .queue(terminal::Clear(terminal::ClearType::All))? .queue(cursor::MoveTo(0, 0))?; let mut lines = prompt.lines().peekable(); let mut prefix_length = 0; while let Some(line) = lines.next() { prefix_length = line.len(); terminal.queue(Print(line))?; if lines.peek().is_some() { terminal .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } } terminal.flush()?; let (mut cols, mut _rows) = terminal.size()?; let mut input = String::new(); loop { match read()? { Event::Resize(new_cols, new_rows) => { cols = new_cols; _rows = new_rows; } Event::Paste(mut p) => { p.retain(|c| c != '\n'); input.push_str(&p); } Event::Key(k) => match k.code { KeyCode::Enter => { break; } KeyCode::Backspace => { input.pop(); } KeyCode::Char('w') if k.modifiers.contains(KeyModifiers::CONTROL) => { let mut has_deleted_text = true; while input.pop().is_some_and(char::is_whitespace) { has_deleted_text = false; } while input.pop().is_some_and(|c| !c.is_whitespace()) { has_deleted_text = true; } if !input.is_empty() && has_deleted_text { input.push(' '); } } KeyCode::Char(' ') => { if !input.chars().next_back().is_some_and(char::is_whitespace) { input.push(' '); } } KeyCode::Char(c) => { input.push(c); } _ => (), }, _ => (), } let usable_space = cols as usize - prefix_length - 1; #[allow(clippy::cast_possible_truncation)] terminal .queue(cursor::MoveToColumn( std::cmp::min(u16::MAX as usize, prefix_length) as u16, ))? .queue(terminal::Clear(terminal::ClearType::UntilNewLine))? .flush()?; let printable_input_start = if input.len() > usable_space { let start_index = input.len() - usable_space; // Find a word boundary, otherwise slice the current word in half if let Some((index, _)) = input .chars() .enumerate() .skip(start_index) .find(|(_, ch)| ch.is_whitespace()) { index } else { start_index } } else { 0 }; let printable_input = &input[printable_input_start..]; let mut iter = printable_input.split_whitespace().peekable(); while let Some(word) = iter.next() { if wordlist.contains(word) { terminal.queue(PrintStyledContent(word.green()))?; } else { terminal.queue(PrintStyledContent(word.red()))?; } if iter.peek().is_some() || printable_input .chars() .next_back() .is_some_and(char::is_whitespace) { terminal.queue(Print(" "))?; } } terminal.flush()?; } Ok(input) } fn prompt_validated_passphrase( &mut self, prompt: &str, retries: u8, validator_fn: F, ) -> Result where F: Fn(String) -> Result, E: std::error::Error, { let mut last_error = None; for _ in 0..retries { let s = self.prompt_passphrase(prompt)?; match validator_fn(s) { Ok(v) => return Ok(v), Err(e) => { self.prompt_message(&Message::Text(format!( "Error validating passphrase: {e}" )))?; let _ = last_error.insert(e); } } } Err(Error::Validation( retries, last_error .map(|e| e.to_string()) .unwrap_or_else(|| "Unknown".to_string()), )) } // TODO: return secrecy::Secret fn prompt_passphrase(&mut self, prompt: &str) -> Result { let mut terminal = self.lock().alternate_screen()?.raw_mode()?; terminal .queue(terminal::Clear(terminal::ClearType::All))? .queue(cursor::MoveTo(0, 0))?; let mut lines = prompt.lines().peekable(); let mut prefix_length = 0; while let Some(line) = lines.next() { prefix_length = line.len(); terminal.queue(Print(line))?; if lines.peek().is_some() { terminal .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } } terminal.flush()?; let (mut cols, mut _rows) = terminal.size()?; let mut passphrase = String::new(); loop { match read()? { Event::Resize(new_cols, new_rows) => { cols = new_cols; _rows = new_rows; } Event::Key(k) => match k.code { KeyCode::Enter => { break; } KeyCode::Backspace => { let passphrase_len = passphrase.len(); if passphrase.pop().is_some() && prefix_length + passphrase_len < cols as usize { terminal .queue(cursor::MoveLeft(1))? .queue(Print(" "))? .queue(cursor::MoveLeft(1))? .flush()?; } } KeyCode::Char(c) => { if prefix_length + passphrase.len() < (cols - 1) as usize { terminal.queue(Print("*"))?.flush()?; } passphrase.push(c); } _ => (), }, _ => (), } } Ok(passphrase) } fn prompt_message(&mut self, prompt: impl Borrow) -> Result<()> { let mut terminal = self.lock().alternate_screen()?.raw_mode()?; loop { let (cols, rows) = terminal.size()?; terminal .queue(terminal::Clear(terminal::ClearType::All))? .queue(cursor::MoveTo(0, 0))?; match prompt.borrow() { Message::Text(text) => { for line in text.lines() { let mut written_chars = 0; for word in line.split_whitespace() { #[allow(clippy::cast_possible_truncation)] let len = std::cmp::min(u16::MAX as usize, word.len()) as u16; written_chars += len + 1; if written_chars > cols { terminal .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; written_chars = len + 1; } terminal.queue(Print(word))?.queue(Print(" "))?; } terminal .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } } Message::Data(data) => { let count = data.lines().count(); // NOTE: GE to allow a MoveDown(1) if count >= rows as usize { let msg = format!( "{} {count} {} {rows} {}", "Could not print data", "lines long (screen is", "lines long)" ); terminal .queue(Print(msg))? .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } else { for line in data.lines() { terminal .queue(Print(line))? .queue(cursor::MoveDown(1))? .queue(cursor::MoveToColumn(0))?; } } } } terminal .queue(cursor::DisableBlinking)? .queue(PrintStyledContent(" OK ".negative()))? .flush()?; #[allow(clippy::single_match)] match read()? { Event::Key(k) => match k.code { KeyCode::Enter | KeyCode::Char(' ' | 'q') => break, _ => (), }, _ => (), } } terminal.queue(cursor::EnableBlinking)?.flush()?; Ok(()) } } pub type DefaultTerminal = Terminal; pub fn default_terminal() -> Result { Terminal::new(stdin(), stderr()) }