From 52d184626d7a5b656d2817e2234f41e8f10c29a0 Mon Sep 17 00:00:00 2001 From: newt Date: Sat, 2 Nov 2024 14:42:50 +0000 Subject: [PATCH] feat: add exit dialogue and auth fields --- Cargo.lock | 124 ++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 9 ++-- src/helper.rs | 1 + src/helper/tui.rs | 20 ++++++++ src/lib.rs | 1 - src/main.rs | 127 ++++++++++++++++++++++++++++++++++++++++++---- src/prelude.rs | 7 +++ src/views.rs | 30 +++++++++++ src/views/auth.rs | 66 ++++++++++++++++++++++++ 9 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 src/helper.rs create mode 100644 src/helper/tui.rs delete mode 100644 src/lib.rs create mode 100644 src/prelude.rs create mode 100644 src/views.rs create mode 100644 src/views/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 8ecf22f..de7bdeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e56b118..4cadb76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/helper.rs b/src/helper.rs new file mode 100644 index 0000000..10925a2 --- /dev/null +++ b/src/helper.rs @@ -0,0 +1 @@ +pub mod tui; diff --git a/src/helper/tui.rs b/src/helper/tui.rs new file mode 100644 index 0000000..83f35ad --- /dev/null +++ b/src/helper/tui.rs @@ -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) +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index ec17946..0000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub type Terminal = ratatui::Terminal>; diff --git a/src/main.rs b/src/main.rs index e13785c..a78abe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, +} + +impl Default for State { + fn default() -> Self { + let (exit_tx, exit_rx) = mpsc::channel::(); + + 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>) -> 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::(); + 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); + } + _ => {} } } } diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..a2e6a08 --- /dev/null +++ b/src/prelude.rs @@ -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; diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..0a71205 --- /dev/null +++ b/src/views.rs @@ -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>; + +pub enum ViewCommand { + Change(ViewContainer), +} diff --git a/src/views/auth.rs b/src/views/auth.rs new file mode 100644 index 0000000..5b0b568 --- /dev/null +++ b/src/views/auth.rs @@ -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(()) + } +}