keyfork/keyfork-prompt/src/lib.rs

346 lines
12 KiB
Rust
Raw Normal View History

use std::{
io::{stderr, stdin, BufRead, BufReader, Read, Stderr, Stdin, Write},
os::fd::AsRawFd,
};
#[cfg(feature = "mnemonic")]
use keyfork_mnemonic_util::Wordlist;
use crossterm::{
cursor,
2024-01-01 21:57:57 +00:00
event::{read, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyModifiers},
style::{Print, PrintStyledContent, Stylize},
terminal,
tty::IsTty,
QueueableCommand,
};
mod alternate_screen;
mod raw_mode;
2024-01-07 04:23:41 +00:00
use alternate_screen::AlternateScreen;
use raw_mode::RawMode;
#[cfg(feature = "qrencode")]
pub mod qrencode;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("The given handler is not a TTY")]
NotATTY,
#[error("IO Error: {0}")]
IO(#[from] std::io::Error),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub enum Message {
Text(String),
Data(String),
}
pub struct PromptManager<R, W> {
read: BufReader<R>,
write: W,
}
impl<R, W> PromptManager<R, W>
where
R: Read + Sized,
W: Write + AsRawFd + Sized,
{
pub fn new(read_handle: R, write_handle: W) -> Result<Self> {
if !write_handle.is_tty() {
return Err(Error::NotATTY);
}
Ok(Self {
read: BufReader::new(read_handle),
write: write_handle,
})
}
pub fn prompt_input(&mut self, prompt: &str) -> Result<String> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
2023-12-21 20:01:59 +00:00
.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))?;
2023-12-21 20:01:59 +00:00
}
}
terminal.flush()?;
let mut line = String::new();
self.read.read_line(&mut line)?;
Ok(line)
}
2024-01-01 21:57:57 +00:00
// TODO: create a wrapper for bracketed paste similar to RawMode
#[cfg(feature = "mnemonic")]
2024-01-07 04:23:41 +00:00
#[allow(clippy::too_many_lines)]
pub fn prompt_wordlist(&mut self, prompt: &str, wordlist: &Wordlist) -> Result<String> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
let mut terminal = RawMode::new(&mut terminal)?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
2024-01-01 21:57:57 +00:00
.queue(cursor::MoveTo(0, 0))?
.queue(EnableBracketedPaste)?;
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;
}
2024-01-01 21:57:57 +00:00
Event::Paste(mut p) => {
p.retain(|c| c != '\n');
input.push_str(&p);
}
Event::Key(k) => match k.code {
KeyCode::Enter => {
input.push('\n');
break;
}
KeyCode::Backspace => {
input.pop();
}
2023-12-26 18:35:56 +00:00
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(' ');
}
2023-12-26 18:35:56 +00:00
}
KeyCode::Char(' ') => {
2024-01-07 04:23:41 +00:00
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;
2024-01-07 04:23:41 +00:00
#[allow(clippy::cast_possible_truncation)]
terminal
2024-01-07 04:23:41 +00:00
.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;
2024-01-07 04:23:41 +00:00
// Find a word boundary, otherwise slice the current word in half
if let Some((index, _)) = input
.chars()
.enumerate()
.skip(start_index)
2024-01-07 04:23:41 +00:00
.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()
2024-01-07 04:23:41 +00:00
.next_back()
.is_some_and(char::is_whitespace)
{
terminal.queue(Print(" "))?;
}
}
terminal.flush()?;
}
2024-01-01 21:57:57 +00:00
terminal.queue(DisableBracketedPaste)?.flush()?;
Ok(input)
}
// TODO: return secrecy::Secret<String>
pub fn prompt_passphrase(&mut self, prompt: &str) -> Result<String> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
let mut terminal = RawMode::new(&mut terminal)?;
2023-12-21 20:01:59 +00:00
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
2023-12-21 20:01:59 +00:00
.queue(cursor::MoveTo(0, 0))?;
let mut lines = prompt.lines().peekable();
let mut prefix_length = 0;
2023-12-21 20:01:59 +00:00
while let Some(line) = lines.next() {
prefix_length = line.len();
2023-12-21 20:01:59 +00:00
terminal.queue(Print(line))?;
if lines.peek().is_some() {
terminal
.queue(cursor::MoveDown(1))?
.queue(cursor::MoveToColumn(0))?;
2023-12-21 20:01:59 +00:00
}
}
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 => {
passphrase.push('\n');
break;
}
2023-12-21 20:01:59 +00:00
KeyCode::Backspace => {
let passphrase_len = passphrase.len();
if passphrase.pop().is_some()
&& prefix_length + passphrase_len < cols as usize
{
2023-12-21 20:01:59 +00:00
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)
}
2024-01-07 04:23:41 +00:00
pub fn prompt_message(&mut self, prompt: &Message) -> Result<()> {
let mut terminal = AlternateScreen::new(&mut self.write)?;
let mut terminal = RawMode::new(&mut terminal)?;
2023-12-21 20:01:59 +00:00
loop {
let (cols, rows) = terminal::size()?;
terminal
.queue(terminal::Clear(terminal::ClearType::All))?
.queue(cursor::MoveTo(0, 0))?;
match &prompt {
2024-01-07 04:23:41 +00:00
Message::Text(text) => {
for line in text.lines() {
let mut written_chars = 0;
for word in line.split_whitespace() {
2024-01-07 04:23:41 +00:00
#[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))?;
}
}
2024-01-07 04:23:41 +00:00
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))?;
}
}
}
2023-12-21 20:01:59 +00:00
}
terminal
.queue(cursor::DisableBlinking)?
.queue(PrintStyledContent(" OK ".negative()))?
.flush()?;
2023-12-21 20:01:59 +00:00
2024-01-07 04:23:41 +00:00
#[allow(clippy::single_match)]
match read()? {
Event::Key(k) => match k.code {
2024-01-07 04:23:41 +00:00
KeyCode::Enter | KeyCode::Char(' ' | 'q') => break,
_ => (),
},
_ => (),
}
}
terminal.queue(cursor::EnableBlinking)?.flush()?;
Ok(())
}
}
pub type DefaultPromptManager = PromptManager<Stdin, Stderr>;
pub fn default_prompt_manager() -> Result<DefaultPromptManager> {
PromptManager::new(stdin(), stderr())
}