Merge pull request #159 from dyc3/crossterm

Switch to crossterm library for terminal ui stuff
This commit is contained in:
Carson McManus 2022-06-25 11:13:19 -04:00 committed by GitHub
commit c78dd00731
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 255 additions and 127 deletions

58
Cargo.lock generated
View file

@ -302,6 +302,32 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossterm"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2102ea4f781910f8a5b98dd061f4c2023f479ce7bb1236330099ceb5a93cf17"
dependencies = [
"bitflags",
"crossterm_winapi",
"futures-core",
"libc",
"mio",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.2.11" version = "0.2.11"
@ -1715,6 +1741,36 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
[[package]]
name = "signal-hook"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.10" version = "0.3.10"
@ -1871,6 +1927,7 @@ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",
"cookie 0.14.4", "cookie 0.14.4",
"crossterm",
"dirs", "dirs",
"hmac-sha1", "hmac-sha1",
"lazy_static 1.4.0", "lazy_static 1.4.0",
@ -1888,7 +1945,6 @@ dependencies = [
"stderrlog", "stderrlog",
"steamguard", "steamguard",
"tempdir", "tempdir",
"termion",
"text_io", "text_io",
"thiserror", "thiserror",
"uuid", "uuid",

View file

@ -41,13 +41,13 @@ cookie = "0.14"
regex = "1" regex = "1"
lazy_static = "1.4.0" lazy_static = "1.4.0"
uuid = { version = "0.8", features = ["v4"] } uuid = { version = "0.8", features = ["v4"] }
termion = "1.5.6"
steamguard = { version ="^0.4.2", path = "./steamguard" } steamguard = { version ="^0.4.2", path = "./steamguard" }
dirs = "3.0.2" dirs = "3.0.2"
ring = "0.16.20" ring = "0.16.20"
aes = "0.7.4" aes = "0.7.4"
block-modes = "0.8.1" block-modes = "0.8.1"
thiserror = "1.0.26" thiserror = "1.0.26"
crossterm = { version = "0.23.2", features = ["event-stream"] }
[dev-dependencies] [dev-dependencies]
tempdir = "0.3" tempdir = "0.3"

View file

@ -99,6 +99,10 @@ impl FromStr for Verbosity {
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[clap(about = "Debug stuff, not useful for most users.")] #[clap(about = "Debug stuff, not useful for most users.")]
pub(crate) struct ArgsDebug { pub(crate) struct ArgsDebug {
#[clap(long, help = "Show a text prompt.")]
pub demo_prompt: bool,
#[clap(long, help = "Show a character prompt.")]
pub demo_prompt_char: bool,
#[clap(long, help = "Show an example confirmation menu using dummy data.")] #[clap(long, help = "Show an example confirmation menu using dummy data.")]
pub demo_conf_menu: bool, pub demo_conf_menu: bool,
} }

View file

@ -2,6 +2,22 @@ use crate::tui;
use log::*; use log::*;
use steamguard::{Confirmation, ConfirmationType}; use steamguard::{Confirmation, ConfirmationType};
pub fn demo_prompt() {
print!("Prompt: ");
let result = tui::prompt();
println!("Result: {}", result);
}
pub fn demo_prompt_char() {
println!("Showing prompt");
let result = tui::prompt_char("Continue?", "yn");
println!("Result: {}", result);
let result = tui::prompt_char("Continue?", "Yn");
println!("Result: {}", result);
let result = tui::prompt_char("Continue?", "yN");
println!("Result: {}", result);
}
pub fn demo_confirmation_menu() { pub fn demo_confirmation_menu() {
info!("showing demo menu"); info!("showing demo menu");
let (accept, deny) = tui::prompt_confirmation_menu(vec![ let (accept, deny) = tui::prompt_confirmation_menu(vec![
@ -33,6 +49,7 @@ pub fn demo_confirmation_menu() {
creator: 09870987, creator: 09870987,
description: "example confirmation".into(), description: "example confirmation".into(),
}, },
]); ])
.expect("confirmation menu demo failed");
println!("accept: {}, deny: {}", accept.len(), deny.len()); println!("accept: {}, deny: {}", accept.len(), deny.len());
} }

View file

@ -1,5 +1,6 @@
extern crate rpassword; extern crate rpassword;
use clap::{IntoApp, Parser}; use clap::{IntoApp, Parser};
use crossterm::tty::IsTty;
use log::*; use log::*;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use std::{ use std::{
@ -28,7 +29,7 @@ mod cli;
mod demos; mod demos;
mod encryption; mod encryption;
mod errors; mod errors;
mod tui; pub(crate) mod tui;
fn main() { fn main() {
std::process::exit(match run() { std::process::exit(match run() {
@ -329,6 +330,12 @@ fn load_accounts_with_prompts(manifest: &mut accountmanager::Manifest) -> anyhow
} }
fn do_subcmd_debug(args: cli::ArgsDebug) -> anyhow::Result<()> { fn do_subcmd_debug(args: cli::ArgsDebug) -> anyhow::Result<()> {
if args.demo_prompt {
demos::demo_prompt();
}
if args.demo_prompt_char {
demos::demo_prompt_char();
}
if args.demo_conf_menu { if args.demo_conf_menu {
demos::demo_confirmation_menu(); demos::demo_confirmation_menu();
} }
@ -516,8 +523,8 @@ fn do_subcmd_trade(
} }
} }
} else { } else {
if termion::is_tty(&stdout()) { if stdout().is_tty() {
let (accept, deny) = tui::prompt_confirmation_menu(confirmations); let (accept, deny) = tui::prompt_confirmation_menu(confirmations)?;
for conf in &accept { for conf in &accept {
let result = account.accept_confirmation(conf); let result = account.accept_confirmation(conf);
if result.is_err() { if result.is_err() {

View file

@ -1,14 +1,16 @@
use crossterm::{
cursor,
event::{Event, KeyCode, KeyEvent, KeyModifiers},
execute,
style::{Color, Print, PrintStyledContent, SetForegroundColor, Stylize},
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
QueueableCommand,
};
use log::*; use log::*;
use regex::Regex; use regex::Regex;
use std::collections::HashSet; use std::collections::HashSet;
use std::io::{Read, Write}; use std::io::{stdout, Write};
use steamguard::Confirmation; use steamguard::Confirmation;
use termion::{
event::{Event, Key},
input::TermRead,
raw::IntoRawMode,
screen::AlternateScreen,
};
lazy_static! { lazy_static! {
static ref CAPTCHA_VALID_CHARS: Regex = static ref CAPTCHA_VALID_CHARS: Regex =
@ -38,16 +40,26 @@ fn test_validate_captcha_text() {
} }
/// Prompt the user for text input. /// Prompt the user for text input.
pub fn prompt() -> String { pub(crate) fn prompt() -> String {
let mut text = String::new(); stdout().flush().unwrap();
let _ = std::io::stdout().flush();
std::io::stdin() let mut line = String::new();
.read_line(&mut text) while let Event::Key(KeyEvent { code, .. }) = crossterm::event::read().unwrap() {
.expect("Did not enter a correct string"); match code {
return String::from(text.strip_suffix('\n').unwrap()); KeyCode::Enter => {
break;
}
KeyCode::Char(c) => {
line.push(c);
}
_ => {}
}
}
line
} }
pub fn prompt_captcha_text(captcha_gid: &String) -> String { pub(crate) fn prompt_captcha_text(captcha_gid: &String) -> String {
println!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid); println!("Captcha required. Open this link in your web browser: https://steamcommunity.com/public/captcha.php?gid={}", captcha_gid);
let mut captcha_text; let mut captcha_text;
loop { loop {
@ -64,11 +76,21 @@ pub fn prompt_captcha_text(captcha_gid: &String) -> String {
/// Prompt the user for a single character response. Useful for asking yes or no questions. /// Prompt the user for a single character response. Useful for asking yes or no questions.
/// ///
/// `chars` should be all lowercase characters, with at most 1 uppercase character. The uppercase character is the default answer if no answer is provided. /// `chars` should be all lowercase characters, with at most 1 uppercase character. The uppercase character is the default answer if no answer is provided.
pub fn prompt_char(text: &str, chars: &str) -> char { pub(crate) fn prompt_char(text: &str, chars: &str) -> char {
return prompt_char_impl(&mut std::io::stdin(), text, chars); loop {
let _ = stdout().queue(Print(format!("{} [{}] ", text, chars)));
let _ = stdout().flush();
let input = prompt();
if let Ok(c) = prompt_char_impl(input, chars) {
return c;
}
}
} }
fn prompt_char_impl(input: &mut impl Read, text: &str, chars: &str) -> char { fn prompt_char_impl<T>(input: T, chars: &str) -> anyhow::Result<char>
where
T: Into<String>,
{
let uppers = chars.replace(char::is_lowercase, ""); let uppers = chars.replace(char::is_lowercase, "");
if uppers.len() > 1 { if uppers.len() > 1 {
panic!("Invalid chars for prompt_char. Maximum 1 uppercase letter is allowed."); panic!("Invalid chars for prompt_char. Maximum 1 uppercase letter is allowed.");
@ -79,141 +101,162 @@ fn prompt_char_impl(input: &mut impl Read, text: &str, chars: &str) -> char {
None None
}; };
loop { let answer: String = input.into().to_ascii_lowercase();
print!("{} [{}] ", text, chars);
let _ = std::io::stdout().flush();
let answer = input
.read_line()
.expect("Unable to read input")
.unwrap()
.to_ascii_lowercase();
if answer.len() == 0 { if answer.len() == 0 {
if let Some(a) = default_answer { if let Some(a) = default_answer {
return a; return Ok(a);
} else {
bail!("no valid answer")
} }
} else if answer.len() > 1 { } else if answer.len() > 1 {
continue; bail!("answer too long")
} }
let answer_char = answer.chars().collect::<Vec<char>>()[0]; let answer_char = answer.chars().collect::<Vec<char>>()[0];
if chars.to_ascii_lowercase().contains(answer_char) { if chars.to_ascii_lowercase().contains(answer_char) {
return answer_char; return Ok(answer_char);
}
} }
bail!("no valid answer")
} }
/// Returns a tuple of (accepted, denied). Ignored confirmations are not included. /// Returns a tuple of (accepted, denied). Ignored confirmations are not included.
pub fn prompt_confirmation_menu( pub(crate) fn prompt_confirmation_menu(
confirmations: Vec<Confirmation>, confirmations: Vec<Confirmation>,
) -> (Vec<Confirmation>, Vec<Confirmation>) { ) -> anyhow::Result<(Vec<Confirmation>, Vec<Confirmation>)> {
println!("press a key other than enter to show the menu.");
let mut to_accept_idx: HashSet<usize> = HashSet::new(); let mut to_accept_idx: HashSet<usize> = HashSet::new();
let mut to_deny_idx: HashSet<usize> = HashSet::new(); let mut to_deny_idx: HashSet<usize> = HashSet::new();
let mut screen = AlternateScreen::from(std::io::stdout().into_raw_mode().unwrap()); execute!(stdout(), EnterAlternateScreen)?;
let stdin = std::io::stdin(); crossterm::terminal::enable_raw_mode()?;
let mut selected_idx = 0; let mut selected_idx = 0;
for c in stdin.events() { loop {
match c.expect("could not get events") { execute!(
Event::Key(Key::Char('a')) => { stdout(),
Clear(ClearType::All),
cursor::MoveTo(1, 1),
PrintStyledContent(
"arrow keys to select, [a]ccept, [d]eny, [i]gnore, [enter] confirm choices\n\n"
.white()
),
)?;
for i in 0..confirmations.len() {
stdout().queue(Print("\r"))?;
if selected_idx == i {
stdout().queue(SetForegroundColor(Color::Yellow))?;
stdout().queue(Print(" >"))?;
} else {
stdout().queue(SetForegroundColor(Color::White))?;
stdout().queue(Print(" "))?;
}
if to_accept_idx.contains(&i) {
stdout().queue(SetForegroundColor(Color::Green))?;
stdout().queue(Print("[a]"))?;
} else if to_deny_idx.contains(&i) {
stdout().queue(SetForegroundColor(Color::Red))?;
stdout().queue(Print("[d]"))?;
} else {
stdout().queue(Print("[ ]"))?;
}
if selected_idx == i {
stdout().queue(SetForegroundColor(Color::Yellow))?;
}
stdout().queue(Print(format!(" {}\n", confirmations[i].description())))?;
}
stdout().flush()?;
match crossterm::event::read()? {
Event::Resize(_, _) => continue,
Event::Key(KeyEvent {
code: KeyCode::Char('a'),
..
}) => {
to_accept_idx.insert(selected_idx); to_accept_idx.insert(selected_idx);
to_deny_idx.remove(&selected_idx); to_deny_idx.remove(&selected_idx);
} }
Event::Key(Key::Char('d')) => { Event::Key(KeyEvent {
code: KeyCode::Char('d'),
..
}) => {
to_accept_idx.remove(&selected_idx); to_accept_idx.remove(&selected_idx);
to_deny_idx.insert(selected_idx); to_deny_idx.insert(selected_idx);
} }
Event::Key(Key::Char('i')) => { Event::Key(KeyEvent {
code: KeyCode::Char('i'),
..
}) => {
to_accept_idx.remove(&selected_idx); to_accept_idx.remove(&selected_idx);
to_deny_idx.remove(&selected_idx); to_deny_idx.remove(&selected_idx);
} }
Event::Key(Key::Char('A')) => { Event::Key(KeyEvent {
code: KeyCode::Char('A'),
..
}) => {
(0..confirmations.len()).for_each(|i| { (0..confirmations.len()).for_each(|i| {
to_accept_idx.insert(i); to_accept_idx.insert(i);
to_deny_idx.remove(&i); to_deny_idx.remove(&i);
}); });
} }
Event::Key(Key::Char('D')) => { Event::Key(KeyEvent {
code: KeyCode::Char('D'),
..
}) => {
(0..confirmations.len()).for_each(|i| { (0..confirmations.len()).for_each(|i| {
to_accept_idx.remove(&i); to_accept_idx.remove(&i);
to_deny_idx.insert(i); to_deny_idx.insert(i);
}); });
} }
Event::Key(Key::Char('I')) => { Event::Key(KeyEvent {
code: KeyCode::Char('I'),
..
}) => {
(0..confirmations.len()).for_each(|i| { (0..confirmations.len()).for_each(|i| {
to_accept_idx.remove(&i); to_accept_idx.remove(&i);
to_deny_idx.remove(&i); to_deny_idx.remove(&i);
}); });
} }
Event::Key(Key::Up) if selected_idx > 0 => { Event::Key(KeyEvent {
code: KeyCode::Up, ..
}) if selected_idx > 0 => {
selected_idx -= 1; selected_idx -= 1;
} }
Event::Key(Key::Down) if selected_idx < confirmations.len() - 1 => { Event::Key(KeyEvent {
code: KeyCode::Down,
..
}) if selected_idx < confirmations.len() - 1 => {
selected_idx += 1; selected_idx += 1;
} }
Event::Key(Key::Char('\n')) => { Event::Key(KeyEvent {
code: KeyCode::Enter,
..
}) => {
break; break;
} }
Event::Key(Key::Esc) | Event::Key(Key::Ctrl('c')) => { Event::Key(KeyEvent {
return (vec![], vec![]); code: KeyCode::Esc, ..
})
| Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
}) => {
return Ok((vec![], vec![]));
} }
_ => {} _ => {}
} }
write!(
screen,
"{}{}{}arrow keys to select, [a]ccept, [d]eny, [i]gnore, [enter] confirm choices\n\n",
termion::clear::All,
termion::cursor::Goto(1, 1),
termion::color::Fg(termion::color::White)
)
.unwrap();
for i in 0..confirmations.len() {
if selected_idx == i {
write!(
screen,
"\r{} >",
termion::color::Fg(termion::color::LightYellow)
)
.unwrap();
} else {
write!(screen, "\r{} ", termion::color::Fg(termion::color::White)).unwrap();
} }
if to_accept_idx.contains(&i) { execute!(stdout(), LeaveAlternateScreen)?;
write!( crossterm::terminal::disable_raw_mode()?;
screen,
"{}[a]",
termion::color::Fg(termion::color::LightGreen)
)
.unwrap();
} else if to_deny_idx.contains(&i) {
write!(
screen,
"{}[d]",
termion::color::Fg(termion::color::LightRed)
)
.unwrap();
} else {
write!(screen, "[ ]").unwrap();
}
if selected_idx == i { return Ok((
write!(
screen,
"{}",
termion::color::Fg(termion::color::LightYellow)
)
.unwrap();
}
write!(screen, " {}\n", confirmations[i].description()).unwrap();
}
}
return (
to_accept_idx to_accept_idx
.iter() .iter()
.map(|i| confirmations[*i].clone()) .map(|i| confirmations[*i].clone())
@ -222,14 +265,18 @@ pub fn prompt_confirmation_menu(
.iter() .iter()
.map(|i| confirmations[*i].clone()) .map(|i| confirmations[*i].clone())
.collect(), .collect(),
); ));
} }
pub fn pause() { pub(crate) fn pause() {
println!("Press any key to continue..."); println!("Press any key to continue...");
let mut stdout = std::io::stdout().into_raw_mode().unwrap(); let _ = stdout().flush();
stdout.flush().unwrap(); loop {
std::io::stdin().events().next(); match crossterm::event::read().expect("could not read terminal events") {
Event::Key(_) => break,
_ => continue,
}
}
} }
#[cfg(test)] #[cfg(test)]
@ -238,36 +285,33 @@ mod prompt_char_tests {
#[test] #[test]
fn test_gives_answer() { fn test_gives_answer() {
let inputs = ['y', '\n'].iter().collect::<String>(); let answer = prompt_char_impl("y", "yn").unwrap();
let answer = prompt_char_impl(&mut inputs.as_bytes(), "ligma balls", "yn");
assert_eq!(answer, 'y'); assert_eq!(answer, 'y');
} }
#[test] #[test]
fn test_gives_default() { fn test_gives_default() {
let inputs = ['\n'].iter().collect::<String>(); let answer = prompt_char_impl("", "Yn").unwrap();
let answer = prompt_char_impl(&mut inputs.as_bytes(), "ligma balls", "Yn");
assert_eq!(answer, 'y'); assert_eq!(answer, 'y');
} }
#[test] #[test]
fn test_should_not_give_default() { fn test_should_not_give_default() {
let inputs = ['n', '\n'].iter().collect::<String>(); let answer = prompt_char_impl("n", "Yn").unwrap();
let answer = prompt_char_impl(&mut inputs.as_bytes(), "ligma balls", "Yn");
assert_eq!(answer, 'n'); assert_eq!(answer, 'n');
} }
#[test] #[test]
fn test_should_not_give_invalid() { fn test_should_not_give_invalid() {
let inputs = ['g', '\n', 'n', '\n'].iter().collect::<String>(); let answer = prompt_char_impl("g", "yn");
let answer = prompt_char_impl(&mut inputs.as_bytes(), "ligma balls", "yn"); assert!(matches!(answer, Err(_)));
let answer = prompt_char_impl("n", "yn").unwrap();
assert_eq!(answer, 'n'); assert_eq!(answer, 'n');
} }
#[test] #[test]
fn test_should_not_give_multichar() { fn test_should_not_give_multichar() {
let inputs = ['y', 'y', '\n', 'n', '\n'].iter().collect::<String>(); let answer = prompt_char_impl("yy", "yn");
let answer = prompt_char_impl(&mut inputs.as_bytes(), "ligma balls", "yn"); assert!(matches!(answer, Err(_)));
assert_eq!(answer, 'n');
} }
} }