feat: add exit dialogue and auth fields

This commit is contained in:
newt 2024-11-02 14:42:50 +00:00
parent 7cf30ab18b
commit 52d184626d
9 changed files with 367 additions and 18 deletions

124
Cargo.lock generated
View file

@ -32,6 +32,17 @@ version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "async-trait"
version = "0.1.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -77,6 +88,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.8.0"
@ -233,24 +250,28 @@ dependencies = [
]
[[package]]
name = "dotenvy"
version = "0.15.7"
name = "dyn-clone"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
[[package]]
name = "echoed"
version = "0.1.0"
dependencies = [
"async-trait",
"color-eyre",
"cookie_store",
"crossterm",
"dotenvy",
"dyn-clone",
"paste",
"ratatui",
"regex",
"reqwest",
"reqwest_cookie_store",
"tokio",
"tui-textarea",
"tui_confirm_dialog",
"url",
]
@ -739,6 +760,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.32.2"
@ -863,6 +893,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.89"
@ -897,6 +936,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ratatui"
version = "0.29.0"
@ -913,6 +982,7 @@ dependencies = [
"lru",
"paste",
"strum",
"time",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
@ -1363,7 +1433,9 @@ checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"time-core",
@ -1515,6 +1587,29 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tui-textarea"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"ratatui",
"unicode-width 0.2.0",
]
[[package]]
name = "tui_confirm_dialog"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117929c8e294f8ceea4f56f4d09a6a4e694f057d175f2cf61433309fb01e9a94"
dependencies = [
"crossterm",
"rand",
"ratatui",
"regex",
]
[[package]]
name = "unicase"
version = "2.8.0"
@ -1832,6 +1927,27 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.1"

View file

@ -4,17 +4,18 @@ publish = false
version = "0.1.0"
edition = "2021"
[features]
dotenvy = ["dep:dotenvy"]
[dependencies]
async-trait = "0.1.83"
color-eyre = "0.6.3"
cookie_store = "0.21.0"
crossterm = "0.28.1"
dotenvy = { version = "0.15.7", optional = true }
dyn-clone = "1.0.17"
paste = "1.0.15"
ratatui = "0.29.0"
regex = "1.11.1"
reqwest = { version = "0.12.9", features = ["cookies", "multipart"] }
reqwest_cookie_store = "0.8.0"
tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] }
tui-textarea = "0.7.0"
tui_confirm_dialog = "0.2.4"
url = "2.5.2"

1
src/helper.rs Normal file
View file

@ -0,0 +1 @@
pub mod tui;

20
src/helper/tui.rs Normal file
View file

@ -0,0 +1,20 @@
use ratatui::{
prelude::*,
widgets::{Block, BorderType, Borders},
};
// Create a block with
pub fn border(frame: &mut Frame, title: Option<&str>) -> Rect {
let outer = frame.area();
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
if let Some(title) = title {
block = block.title(title);
}
frame.render_widget(block.clone(), outer);
block.inner(outer)
}

View file

@ -1 +0,0 @@
pub type Terminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;

View file

@ -1,18 +1,127 @@
use color_eyre::Result;
use crossterm::event::{self, KeyCode, KeyEventKind};
use echoed::Terminal;
use ratatui::widgets::Paragraph;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
prelude::*,
widgets::{BorderType, Borders},
};
use std::{
io::Stdout,
sync::{mpsc, Arc},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use tui_confirm_dialog::{ConfirmDialog, ConfirmDialogState, Listener, PopupMessage};
use views::{ViewCommand, ViewContainer};
#[macro_use]
extern crate async_trait;
mod helper;
pub mod prelude;
mod views;
const EXIT_TIMEOUT: Duration = Duration::from_millis(150);
const POLL_TIMEOUT: Duration = Duration::from_millis(16);
pub struct State {
exit: ConfirmDialogState,
exit_rx: mpsc::Receiver<Listener>,
}
impl Default for State {
fn default() -> Self {
let (exit_tx, exit_rx) = mpsc::channel::<Listener>();
Self {
exit: ConfirmDialogState::default()
.with_title("Exit")
.with_text(Text::raw("Are you sure you would like to exit?"))
.with_listener(Some(exit_tx)),
exit_rx,
// ..Default::default()
}
}
}
async fn run(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
let mut view: ViewContainer = Arc::new(Mutex::new(views::Auth::default()));
view.lock().await.setup();
let mut state = State::default();
let (view_tx, view_rx) = mpsc::channel::<ViewCommand>();
let mut exit_triggered = Instant::now();
exit_triggered -= EXIT_TIMEOUT;
async fn run(mut terminal: Terminal) -> Result<()> {
loop {
// check if the app should exit
if let Ok((_, exit)) = state.exit_rx.try_recv() {
if exit.unwrap_or_default() {
break;
}
}
// check for new view commands
match view_rx.try_recv() {
Ok(ViewCommand::Change(new_view)) => {
view = new_view;
view.lock().await.setup();
}
Err(_) => {}
}
// draw the view
let mut view = view.lock().await;
terminal.draw(|frame| {
let greeting = Paragraph::new("Hello Ratatui! (press 'q' to quit)");
frame.render_widget(greeting, frame.area());
view.draw(frame);
if state.exit.is_opened() {
frame.render_stateful_widget(
ConfirmDialog::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.selected_button_style(Style::default().yellow().underlined().bold()),
frame.area(),
&mut state.exit,
);
}
})?;
if let event::Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
return Ok(());
// handle events
if event::poll(POLL_TIMEOUT)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// handle exit popup keypresses above all else
if state.exit.is_opened() {
if key.code == KeyCode::Esc && exit_triggered.elapsed() < EXIT_TIMEOUT {
continue;
} else {
state.exit.handle(key);
exit_triggered = Instant::now();
}
continue;
}
// then handle view keypresses
view.keypress(key, &view_tx).await?;
// then handle global keypresses
// esc + ctrl+c quit
if (key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)))
&& !state.exit.is_opened()
&& exit_triggered.elapsed() >= EXIT_TIMEOUT
{
state.exit = state.exit.open();
exit_triggered = Instant::now();
}
drop(view);
}
_ => {}
}
}
}

7
src/prelude.rs Normal file
View file

@ -0,0 +1,7 @@
pub use crate::{helper::tui as helper, views::ViewCommand};
pub use color_eyre::Result;
pub use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
pub use ratatui::prelude::*;
pub use std::any::Any;
pub type ViewSender = std::sync::mpsc::Sender<ViewCommand>;

30
src/views.rs Normal file
View file

@ -0,0 +1,30 @@
use crate::prelude::*;
use dyn_clone::DynClone;
use std::sync::Arc;
use tokio::sync::Mutex;
macro_rules! import_view {
($($view:ident),+) => {
paste::paste! {
$(
mod [<$view:lower>];
pub use [<$view:lower>]::$view;
)+
}
};
}
import_view!(Auth);
#[async_trait]
pub trait View: DynClone {
fn setup(&mut self);
fn draw(&self, frame: &mut Frame);
async fn keypress(&mut self, key: KeyEvent, command_tx: &ViewSender) -> Result<()>;
}
pub type ViewContainer = Arc<Mutex<dyn View + Send + Sync>>;
pub enum ViewCommand {
Change(ViewContainer),
}

66
src/views/auth.rs Normal file
View file

@ -0,0 +1,66 @@
use super::View;
use crate::prelude::*;
use tui_textarea::TextArea;
const SHOW_STYLE: Style = Style::new().add_modifier(Modifier::REVERSED);
const HIDE_STYLE: Style = Style::new().bg(Color::Reset);
#[derive(Default, Clone)]
pub struct Auth<'t> {
email: TextArea<'t>,
password: TextArea<'t>,
pass_select: bool,
}
#[async_trait]
impl<'t> View for Auth<'t> {
fn setup(&mut self) {
self.password.set_mask_char('\u{2022}');
self.password.set_cursor_style(HIDE_STYLE);
}
fn draw(&self, frame: &mut Frame) {
let area = helper::border(frame, Some("Auth"));
let field_areas =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
#[allow(unused_mut)]
for ((mut field, label), area) in [(&self.email, "Email"), (&self.password, "Password")]
.iter()
.zip(field_areas.iter())
{
let [label_area, field_area] =
Layout::horizontal([Constraint::Percentage(20), Constraint::Percentage(80)])
.areas(*area);
frame.render_widget(Text::raw(*label), label_area);
frame.render_widget(field, field_area);
}
}
async fn keypress(&mut self, key: KeyEvent, _command_tx: &ViewSender) -> Result<()> {
match key.code {
KeyCode::Up => {
self.pass_select = false;
self.email.set_cursor_style(SHOW_STYLE);
self.password.set_cursor_style(HIDE_STYLE);
}
KeyCode::Down => {
self.pass_select = true;
self.email.set_cursor_style(HIDE_STYLE);
self.password.set_cursor_style(SHOW_STYLE);
}
KeyCode::Char(_) | KeyCode::Backspace => {
let field = if self.pass_select {
&mut self.password
} else {
&mut self.email
};
field.input(key);
}
_ => {}
}
Ok(())
}
}