diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index 4bf2334..911dece 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,21 +6,25 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, - pane_block, primary_style, title_style, warning_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, get_spinner_char, + info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, + crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, poll, + }, layout::{Constraint, Flex, Layout, Rect}, style::Modifier, text::{Line, Span}, widgets::{Clear, Paragraph}, }; -use soulseek_rs::{Client, ClientSettings}; +use soulseek_rs::{Client, ClientSettings, SoulseekRs}; use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; + +const RESTART_FLOOR: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginField { @@ -36,15 +40,17 @@ pub enum LoginPhase { Connecting, /// The last attempt failed; the message is shown until the next key. Failed(String), + Rejected(String), } /// What the caller should do after feeding a key to the form. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginAction { None, - /// Both fields are filled and the user pressed Enter. + /// Both fields are filled and the user pressed Enter or r. Submit, - /// User pressed Esc — abort the whole program. + Stop, + /// User pressed Esc, q or Ctrl-C — abort the whole program. Cancel, } @@ -72,9 +78,33 @@ impl LoginForm { } pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction { + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return LoginAction::Cancel; + } + match self.phase { - LoginPhase::Connecting => return LoginAction::None, + LoginPhase::Connecting => { + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => LoginAction::Submit, + KeyCode::Esc => { + self.phase = LoginPhase::Editing; + LoginAction::Stop + } + KeyCode::Char('q') => LoginAction::Cancel, + _ => LoginAction::None, + }; + } LoginPhase::Failed(_) => { + self.phase = LoginPhase::Editing; + return match key.code { + KeyCode::Enter | KeyCode::Char('r') => self.submit(), + KeyCode::Esc => LoginAction::Cancel, + _ => LoginAction::None, + }; + } + LoginPhase::Rejected(_) => { // Any key acknowledges the error; the password was wrong (or // the name is taken), so make the user retype it. self.phase = LoginPhase::Editing; @@ -97,20 +127,7 @@ impl LoginForm { }; LoginAction::None } - KeyCode::Enter => { - if !self.username.is_empty() && !self.password.is_empty() { - self.phase = LoginPhase::Connecting; - LoginAction::Submit - } else { - // Move to the first empty field instead of submitting. - self.focused = if self.username.is_empty() { - LoginField::Username - } else { - LoginField::Password - }; - LoginAction::None - } - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.focused_field_mut().pop(); LoginAction::None @@ -123,6 +140,20 @@ impl LoginForm { } } + fn submit(&mut self) -> LoginAction { + if !self.username.is_empty() && !self.password.is_empty() { + self.phase = LoginPhase::Connecting; + return LoginAction::Submit; + } + // Move to the first empty field instead of submitting. + self.focused = if self.username.is_empty() { + LoginField::Username + } else { + LoginField::Password + }; + LoginAction::None + } + const fn focused_field_mut(&mut self) -> &mut String { match self.focused { LoginField::Username => &mut self.username, @@ -142,7 +173,7 @@ pub struct LoginOutcome { } /// Drive the login screen until a login succeeds (`Some`) or the user -/// cancels with Esc (`None`). When both credentials are already known an +/// quits (`None`). When both credentials are already known an /// attempt starts immediately and the form is only shown on failure. pub fn run_login_flow( terminal: &mut DefaultTerminal, @@ -150,27 +181,31 @@ pub fn run_login_flow( initial_username: Option, initial_password: Option, ) -> Result> { - let mut form = LoginForm::new(initial_username); - let mut entered_via_form = false; - let mut attempt: Option>> = - match initial_password { - Some(password) if !form.username.is_empty() => { - form.password = password; - form.phase = LoginPhase::Connecting; - Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))) - } - _ => None, - }; + let start = |form: &LoginForm| { + spawn_attempt(make_settings( + form.username.clone(), + form.password.clone(), + )) + }; + let mut form = LoginForm::new(initial_username.clone()); + let mut attempt = match &initial_password { + Some(password) if !form.username.is_empty() => { + form.password.clone_from(password); + form.phase = LoginPhase::Connecting; + Some(start(&form)) + } + _ => None, + }; + let mut started = Instant::now(); loop { - terminal.draw(|frame| render(frame, &form))?; - if let Some(rx) = &attempt { match rx.try_recv() { Ok(Ok(client)) => { + let entered_via_form = initial_username.as_deref() + != Some(form.username.as_str()) + || initial_password.as_deref() + != Some(form.password.as_str()); return Ok(Some(LoginOutcome { client, username: form.username, @@ -178,8 +213,8 @@ pub fn run_login_flow( entered_via_form, })); } - Ok(Err(message)) => { - form.phase = LoginPhase::Failed(message); + Ok(Err(failure)) => { + form.phase = failure; attempt = None; } Err(std::sync::mpsc::TryRecvError::Empty) => {} @@ -191,19 +226,24 @@ pub fn run_login_flow( } } + let spinner = + get_spinner_char(started.elapsed().subsec_millis() as usize / 100); + terminal.draw(|frame| render(frame, &form, spinner))?; + if poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match form.handle_key(key) { LoginAction::Cancel => return Ok(None), + LoginAction::Submit + if attempt.is_some() + && started.elapsed() < RESTART_FLOOR => {} LoginAction::Submit => { - entered_via_form = true; - attempt = Some(spawn_attempt(make_settings( - form.username.clone(), - form.password.clone(), - ))); + attempt = Some(start(&form)); + started = Instant::now(); } + LoginAction::Stop => attempt = None, LoginAction::None => {} } } @@ -211,27 +251,35 @@ pub fn run_login_flow( } /// Connect and log in on a background thread so the UI stays responsive. -fn spawn_attempt(settings: ClientSettings) -> Receiver> { +fn spawn_attempt( + settings: ClientSettings, +) -> Receiver> { let (tx, rx) = channel(); std::thread::spawn(move || { let mut client = Client::with_settings(settings); let result = client .connect() - .map_err(|e| format!("Failed to connect: {e}")) - .and_then(|()| match client.login() { - Ok(true) => Ok(()), - Ok(false) => Err( - "Login rejected: wrong password, or the username is taken" - .to_string(), - ), - Err(e) => Err(format!("Login failed: {e}")), - }); + .map_err(|e| LoginPhase::Failed(format!("Failed to connect: {e}"))) + .and_then(|()| verdict(client.login())); let _ = tx.send(result.map(|()| client)); }); rx } -fn render(frame: &mut Frame, form: &LoginForm) { +fn verdict(login: soulseek_rs::Result) -> Result<(), LoginPhase> { + match login { + Ok(true) => Ok(()), + Ok(false) | Err(SoulseekRs::AuthenticationFailed) => { + Err(LoginPhase::Rejected( + "Login rejected: wrong password, or the username is taken" + .to_string(), + )) + } + Err(e) => Err(LoginPhase::Failed(format!("Login failed: {e}"))), + } +} + +fn render(frame: &mut Frame, form: &LoginForm, spinner: &str) { let area = centered(frame.area(), 52, 12); frame.render_widget(Clear, area); @@ -290,21 +338,32 @@ fn render(frame: &mut Frame, form: &LoginForm) { let status = match &form.phase { LoginPhase::Editing => Paragraph::new(""), LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( - "Connecting…", + format!("{spinner} Connecting…"), warning_style(), ))), - LoginPhase::Failed(message) => Paragraph::new(Line::from( - Span::styled(message.clone(), error_style()), - )) - .wrap(ratatui::widgets::Wrap { trim: true }), + LoginPhase::Failed(message) | LoginPhase::Rejected(message) => { + Paragraph::new(Line::from(Span::styled( + message.clone(), + error_style(), + ))) + .wrap(ratatui::widgets::Wrap { trim: true }) + } }; frame.render_widget(status, rows[3]); + let keys = match form.phase { + LoginPhase::Connecting => { + "Enter/r: restart · Esc: stop · q/Ctrl-C: quit" + } + LoginPhase::Failed(_) => "Enter/r: retry · Esc: quit", + LoginPhase::Editing | LoginPhase::Rejected(_) => { + "Tab: switch · Enter: log in · Esc: quit" + } + }; frame.render_widget( - Paragraph::new( - "New usernames are registered automatically.\n\ - Tab: switch · Enter: log in · Esc: quit", - ) + Paragraph::new(format!( + "New usernames are registered automatically.\n{keys}" + )) .style(dimmed_style()), rows[5], ); @@ -323,7 +382,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { #[cfg(test)] mod tests { use super::*; - use ratatui::crossterm::event::KeyModifiers; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -335,6 +393,13 @@ mod tests { } } + fn connecting() -> LoginForm { + let mut form = LoginForm::new(Some("alice".into())); + type_str(&mut form, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + form + } + #[test] fn prefilled_username_focuses_password() { let form = LoginForm::new(Some("alice".into())); @@ -402,11 +467,93 @@ mod tests { } #[test] - fn key_after_failure_returns_to_editing_and_clears_password() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "wrong"); - form.handle_key(key(KeyCode::Enter)); - form.phase = LoginPhase::Failed("INVALIDPASS".into()); + fn ctrl_c_quits_from_every_phase_without_typing() { + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + for phase in [ + LoginPhase::Editing, + LoginPhase::Connecting, + LoginPhase::Failed("unreachable".into()), + LoginPhase::Rejected("INVALIDPASS".into()), + ] { + let mut form = LoginForm::new(None); + form.phase = phase.clone(); + assert_eq!( + form.handle_key(ctrl_c), + LoginAction::Cancel, + "{phase:?}" + ); + assert_eq!(form.username, "", "{phase:?}"); + } + } + + #[test] + fn typing_is_ignored_while_connecting() { + let mut form = connecting(); + let action = form.handle_key(key(KeyCode::Char('x'))); + assert_eq!(action, LoginAction::None); + assert_eq!(form.password, "pw"); + assert_eq!(form.phase, LoginPhase::Connecting); + } + + #[test] + fn escape_while_connecting_stops_and_keeps_the_credentials() { + let mut form = connecting(); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Stop); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + assert_eq!(form.handle_key(key(KeyCode::Enter)), LoginAction::Submit); + } + + #[test] + fn enter_or_r_while_connecting_restarts() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn q_while_connecting_quits() { + let mut form = connecting(); + assert_eq!( + form.handle_key(key(KeyCode::Char('q'))), + LoginAction::Cancel + ); + } + + #[test] + fn enter_or_r_after_a_failed_connection_retries_with_the_same_password() { + for code in [KeyCode::Enter, KeyCode::Char('r')] { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(code)), LoginAction::Submit); + assert_eq!(form.phase, LoginPhase::Connecting); + assert_eq!(form.password, "pw"); + } + } + + #[test] + fn other_key_after_a_failed_connection_edits_with_the_password_kept() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Char('x'))), LoginAction::None); + assert_eq!(form.phase, LoginPhase::Editing); + assert_eq!(form.password, "pw"); + } + + #[test] + fn escape_after_a_failed_connection_quits() { + let mut form = connecting(); + form.phase = LoginPhase::Failed("Login failed: timed out".into()); + assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel); + } + + #[test] + fn key_after_rejection_returns_to_editing_and_clears_password() { + let mut form = connecting(); + form.phase = LoginPhase::Rejected("INVALIDPASS".into()); let action = form.handle_key(key(KeyCode::Char('x'))); assert_eq!(action, LoginAction::None); assert_eq!(form.phase, LoginPhase::Editing); @@ -414,13 +561,14 @@ mod tests { } #[test] - fn keys_ignored_while_connecting() { - let mut form = LoginForm::new(Some("alice".into())); - type_str(&mut form, "pw"); - form.handle_key(key(KeyCode::Enter)); - assert_eq!(form.phase, LoginPhase::Connecting); - let action = form.handle_key(key(KeyCode::Char('x'))); - assert_eq!(action, LoginAction::None); - assert_eq!(form.password, "pw"); + fn a_wrong_password_is_rejected_and_an_unanswered_login_failed() { + assert_eq!(verdict(Ok(true)), Ok(())); + for rejected in [Ok(false), Err(SoulseekRs::AuthenticationFailed)] { + assert!(matches!(verdict(rejected), Err(LoginPhase::Rejected(_)))); + } + for failed in [Err(SoulseekRs::Timeout), Err(SoulseekRs::NotConnected)] + { + assert!(matches!(verdict(failed), Err(LoginPhase::Failed(_)))); + } } }