feat: in-TUI login/registration screen
The default TUI path now brings the terminal up first and runs a login screen whenever no working credentials exist: a pure-state-machine form (unit tested) driven by an async connect/login attempt on a background thread. Soulseek registers unknown usernames on login, so the same form covers registration. On success the username is saved to config.toml and a form-entered password to the OS keychain; stored credentials skip the form entirely. Failed logins show the server error and retry instead of aborting.
This commit is contained in:
+108
-30
@@ -45,6 +45,13 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The interactive TUI handles missing credentials itself with a
|
||||||
|
// login/registration screen; only the one-shot subcommands hard-require
|
||||||
|
// them up front.
|
||||||
|
if cli.command.is_none() {
|
||||||
|
return run_default_tui(&cli, &resolved, config_path, &file_config);
|
||||||
|
}
|
||||||
|
|
||||||
let username = resolved.username.clone().ok_or_else(|| {
|
let username = resolved.username.clone().ok_or_else(|| {
|
||||||
color_eyre::eyre::eyre!(
|
color_eyre::eyre::eyre!(
|
||||||
"Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml"
|
"Username required: use --username, set SOULSEEK_USERNAME, or add it to config.toml"
|
||||||
@@ -122,14 +129,9 @@ fn main() -> Result<()> {
|
|||||||
message,
|
message,
|
||||||
listen_secs,
|
listen_secs,
|
||||||
}) => chat_room(&settings, &room, message.as_deref(), listen_secs),
|
}) => chat_room(&settings, &room, message.as_deref(), listen_secs),
|
||||||
// Handled before the credential check above.
|
// Portmap is handled before the credential check; None returns early
|
||||||
Some(Commands::Portmap) => unreachable!(),
|
// into run_default_tui above.
|
||||||
None => run_default_tui(
|
Some(Commands::Portmap) | None => unreachable!(),
|
||||||
settings,
|
|
||||||
resolved.download_dir.clone(),
|
|
||||||
resolved.max_concurrent_downloads,
|
|
||||||
Duration::from_secs(resolved.search_timeout),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +154,15 @@ fn init_logging(cli: &Cli) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect, log in, and run the interactive TUI (the default no-subcommand path).
|
/// Run the interactive TUI (the default no-subcommand path): bring the
|
||||||
|
/// terminal up first, run the login/registration screen (skipped past when
|
||||||
|
/// stored credentials work), persist whatever logged in, then enter the
|
||||||
|
/// main UI.
|
||||||
fn run_default_tui(
|
fn run_default_tui(
|
||||||
settings: ClientSettings,
|
cli: &Cli,
|
||||||
download_dir: String,
|
resolved: &persist::config::Resolved,
|
||||||
max_concurrent_downloads: usize,
|
config_path: Option<std::path::PathBuf>,
|
||||||
search_timeout: Duration,
|
file_config: &persist::config::FileConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use ratatui::crossterm::{
|
use ratatui::crossterm::{
|
||||||
event::EnableMouseCapture,
|
event::EnableMouseCapture,
|
||||||
@@ -165,40 +170,113 @@ fn run_default_tui(
|
|||||||
terminal::{Clear, ClearType},
|
terminal::{Clear, ClearType},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (server_host, server_port) = parse_server_address(&resolved.server)?;
|
||||||
|
|
||||||
|
let shared_directory = match directories::resolve_shared_directory(
|
||||||
|
resolved.shared_dir.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(dir) => dir.map(|path| path.display().to_string()),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("⚠️ Ignoring shared directory: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let secret_store = persist::secret::KeyringStore;
|
||||||
|
let initial_password = persist::secret::resolve_password(
|
||||||
|
cli.password.as_deref(),
|
||||||
|
resolved.username.as_deref(),
|
||||||
|
resolved.password_cmd.as_deref(),
|
||||||
|
&secret_store,
|
||||||
|
);
|
||||||
|
|
||||||
// Enable logger buffering BEFORE connection to prevent log artifacts
|
// Enable logger buffering BEFORE connection to prevent log artifacts
|
||||||
soulseek_rs::utils::logger::enable_buffering();
|
soulseek_rs::utils::logger::enable_buffering();
|
||||||
|
|
||||||
// Best-effort: make ourselves reachable behind a home router so
|
// Best-effort: make ourselves reachable behind a home router so
|
||||||
// firewalled peers can connect back. Kept alive for the session.
|
// firewalled peers can connect back. Kept alive for the session.
|
||||||
let _port_mapper = settings
|
let _port_mapper = (!resolved.disable_listener)
|
||||||
.enable_listen
|
.then(|| port_mapping::PortMapper::spawn(resolved.listener_port));
|
||||||
.then(|| port_mapping::PortMapper::spawn(settings.listen_port));
|
|
||||||
|
|
||||||
let mut client = Client::with_settings(settings);
|
let enable_listen = !resolved.disable_listener;
|
||||||
client
|
let listen_port = resolved.listener_port;
|
||||||
.connect()
|
let make_settings = move |username: String, password: String| {
|
||||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?;
|
ClientSettings {
|
||||||
client
|
username,
|
||||||
.login()
|
password,
|
||||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?;
|
server_address: PeerAddress::new(
|
||||||
|
server_host.clone(),
|
||||||
let client = Arc::new(client);
|
server_port,
|
||||||
|
),
|
||||||
|
enable_listen,
|
||||||
|
listen_port,
|
||||||
|
shared_directory: shared_directory.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Clear screen and enable mouse capture before initializing TUI
|
// Clear screen and enable mouse capture before initializing TUI
|
||||||
let _ =
|
let _ =
|
||||||
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
|
||||||
let terminal = ratatui::init();
|
let outcome = ui::login::run_login_flow(
|
||||||
|
&mut terminal,
|
||||||
|
&make_settings,
|
||||||
|
resolved.username.clone(),
|
||||||
|
initial_password,
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = match outcome {
|
||||||
|
Ok(Some(outcome)) => outcome,
|
||||||
|
Ok(None) => {
|
||||||
|
// User cancelled at the login screen.
|
||||||
|
ratatui::restore();
|
||||||
|
soulseek_rs::utils::logger::disable_buffering();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
ratatui::restore();
|
||||||
|
soulseek_rs::utils::logger::disable_buffering();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
persist_credentials(&outcome, config_path, file_config, &secret_store);
|
||||||
|
|
||||||
launch_main_tui(
|
launch_main_tui(
|
||||||
terminal,
|
terminal,
|
||||||
client,
|
Arc::new(outcome.client),
|
||||||
download_dir,
|
resolved.download_dir.clone(),
|
||||||
max_concurrent_downloads,
|
resolved.max_concurrent_downloads,
|
||||||
search_timeout,
|
Duration::from_secs(resolved.search_timeout),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a successful login, remember the username in config.toml and — when
|
||||||
|
/// it was typed into the form — the password in the OS keychain. Both are
|
||||||
|
/// best-effort: failing to persist must not break a working session.
|
||||||
|
fn persist_credentials(
|
||||||
|
outcome: &ui::login::LoginOutcome,
|
||||||
|
config_path: Option<std::path::PathBuf>,
|
||||||
|
file_config: &persist::config::FileConfig,
|
||||||
|
secret_store: &dyn persist::secret::SecretStore,
|
||||||
|
) {
|
||||||
|
if let Some(path) = config_path
|
||||||
|
&& file_config.username.as_deref() != Some(&outcome.username)
|
||||||
|
{
|
||||||
|
let mut updated = file_config.clone();
|
||||||
|
updated.username = Some(outcome.username.clone());
|
||||||
|
if let Err(e) = updated.save(&path) {
|
||||||
|
eprintln!("⚠️ Could not save config: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outcome.entered_via_form
|
||||||
|
&& let Err(e) = secret_store.set(&outcome.username, &outcome.password)
|
||||||
|
{
|
||||||
|
eprintln!("⚠️ Could not store password in keychain: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> {
|
fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> {
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
//! First-run login/registration screen.
|
||||||
|
//!
|
||||||
|
//! Soulseek auto-registers unknown usernames on login, so a single form
|
||||||
|
//! covers both flows. The form itself ([`LoginForm`]) is a pure state
|
||||||
|
//! machine so it can be tested without a terminal; the IO loop
|
||||||
|
//! ([`run_login_flow`]) drives it against a real terminal and client.
|
||||||
|
|
||||||
|
use color_eyre::Result;
|
||||||
|
use ratatui::{
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
||||||
|
layout::{Constraint, Flex, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
};
|
||||||
|
use soulseek_rs::{Client, ClientSettings};
|
||||||
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LoginField {
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LoginPhase {
|
||||||
|
/// User is typing into the form.
|
||||||
|
Editing,
|
||||||
|
/// A connect+login attempt is in flight.
|
||||||
|
Connecting,
|
||||||
|
/// The last attempt failed; the message is shown until the next key.
|
||||||
|
Failed(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.
|
||||||
|
Submit,
|
||||||
|
/// User pressed Esc — abort the whole program.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub focused: LoginField,
|
||||||
|
pub phase: LoginPhase,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginForm {
|
||||||
|
pub fn new(username: Option<String>) -> Self {
|
||||||
|
let username = username.unwrap_or_default();
|
||||||
|
let focused = if username.is_empty() {
|
||||||
|
LoginField::Username
|
||||||
|
} else {
|
||||||
|
LoginField::Password
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
username,
|
||||||
|
password: String::new(),
|
||||||
|
focused,
|
||||||
|
phase: LoginPhase::Editing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> LoginAction {
|
||||||
|
match self.phase {
|
||||||
|
LoginPhase::Connecting => return LoginAction::None,
|
||||||
|
LoginPhase::Failed(_) => {
|
||||||
|
// Any key acknowledges the error; the password was wrong (or
|
||||||
|
// the name is taken), so make the user retype it.
|
||||||
|
self.phase = LoginPhase::Editing;
|
||||||
|
self.password.clear();
|
||||||
|
self.focused = LoginField::Password;
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
return LoginAction::Cancel;
|
||||||
|
}
|
||||||
|
return LoginAction::None;
|
||||||
|
}
|
||||||
|
LoginPhase::Editing => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => LoginAction::Cancel,
|
||||||
|
KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => {
|
||||||
|
self.focused = match self.focused {
|
||||||
|
LoginField::Username => LoginField::Password,
|
||||||
|
LoginField::Password => LoginField::Username,
|
||||||
|
};
|
||||||
|
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::Backspace => {
|
||||||
|
self.focused_field_mut().pop();
|
||||||
|
LoginAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.focused_field_mut().push(c);
|
||||||
|
LoginAction::None
|
||||||
|
}
|
||||||
|
_ => LoginAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused_field_mut(&mut self) -> &mut String {
|
||||||
|
match self.focused {
|
||||||
|
LoginField::Username => &mut self.username,
|
||||||
|
LoginField::Password => &mut self.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A successful login: the connected client plus the credentials that worked.
|
||||||
|
pub struct LoginOutcome {
|
||||||
|
pub client: Client,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
/// True when the password was typed into the form (as opposed to coming
|
||||||
|
/// from CLI/env/keychain) — only then do we offer to store it.
|
||||||
|
pub entered_via_form: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the login screen until a login succeeds (`Some`) or the user
|
||||||
|
/// cancels with Esc (`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,
|
||||||
|
make_settings: &dyn Fn(String, String) -> ClientSettings,
|
||||||
|
initial_username: Option<String>,
|
||||||
|
initial_password: Option<String>,
|
||||||
|
) -> Result<Option<LoginOutcome>> {
|
||||||
|
let mut form = LoginForm::new(initial_username);
|
||||||
|
let mut entered_via_form = false;
|
||||||
|
let mut attempt: Option<Receiver<Result<Client, String>>> = None;
|
||||||
|
|
||||||
|
if let Some(password) = initial_password
|
||||||
|
&& !form.username.is_empty()
|
||||||
|
{
|
||||||
|
form.password = password;
|
||||||
|
form.phase = LoginPhase::Connecting;
|
||||||
|
attempt = Some(spawn_attempt(make_settings(
|
||||||
|
form.username.clone(),
|
||||||
|
form.password.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| render(frame, &form))?;
|
||||||
|
|
||||||
|
if let Some(rx) = &attempt {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Ok(client)) => {
|
||||||
|
return Ok(Some(LoginOutcome {
|
||||||
|
client,
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
entered_via_form,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Err(message)) => {
|
||||||
|
form.phase = LoginPhase::Failed(message);
|
||||||
|
attempt = None;
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
|
form.phase =
|
||||||
|
LoginPhase::Failed("Connection thread died".into());
|
||||||
|
attempt = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
entered_via_form = true;
|
||||||
|
attempt = Some(spawn_attempt(make_settings(
|
||||||
|
form.username.clone(),
|
||||||
|
form.password.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
LoginAction::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect and log in on a background thread so the UI stays responsive.
|
||||||
|
fn spawn_attempt(
|
||||||
|
settings: ClientSettings,
|
||||||
|
) -> Receiver<Result<Client, String>> {
|
||||||
|
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}")),
|
||||||
|
});
|
||||||
|
let _ = tx.send(result.map(|()| client));
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut Frame, form: &LoginForm) {
|
||||||
|
let area = centered(frame.area(), 52, 12);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Soulseek Login ")
|
||||||
|
.title_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(1), // username
|
||||||
|
Constraint::Length(1), // password
|
||||||
|
Constraint::Length(1), // spacer
|
||||||
|
Constraint::Length(2), // status / error
|
||||||
|
Constraint::Length(1), // spacer
|
||||||
|
Constraint::Length(2), // hints
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let field = |label: &str, value: String, focused: bool| {
|
||||||
|
let marker = if focused { "> " } else { " " };
|
||||||
|
let style = if focused {
|
||||||
|
Style::default().add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(format!("{marker}{label:<10}"), style),
|
||||||
|
Span::raw(value),
|
||||||
|
Span::raw(if focused { "▏" } else { "" }),
|
||||||
|
]))
|
||||||
|
};
|
||||||
|
|
||||||
|
let editing = form.phase == LoginPhase::Editing;
|
||||||
|
frame.render_widget(
|
||||||
|
field(
|
||||||
|
"Username:",
|
||||||
|
form.username.clone(),
|
||||||
|
editing && form.focused == LoginField::Username,
|
||||||
|
),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
field(
|
||||||
|
"Password:",
|
||||||
|
"•".repeat(form.password.chars().count()),
|
||||||
|
editing && form.focused == LoginField::Password,
|
||||||
|
),
|
||||||
|
rows[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = match &form.phase {
|
||||||
|
LoginPhase::Editing => Paragraph::new(""),
|
||||||
|
LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled(
|
||||||
|
"Connecting…",
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
))),
|
||||||
|
LoginPhase::Failed(message) => {
|
||||||
|
Paragraph::new(Line::from(Span::styled(
|
||||||
|
message.clone(),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
)))
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
frame.render_widget(status, rows[3]);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(
|
||||||
|
"New usernames are registered automatically.\n\
|
||||||
|
Tab: switch · Enter: log in · Esc: quit",
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
rows[5],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
||||||
|
let [area] = Layout::horizontal([Constraint::Length(width)])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.areas(area);
|
||||||
|
let [area] = Layout::vertical([Constraint::Length(height)])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.areas(area);
|
||||||
|
area
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::crossterm::event::KeyModifiers;
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(form: &mut LoginForm, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
form.handle_key(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefilled_username_focuses_password() {
|
||||||
|
let form = LoginForm::new(Some("alice".into()));
|
||||||
|
assert_eq!(form.username, "alice");
|
||||||
|
assert_eq!(form.focused, LoginField::Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_form_focuses_username() {
|
||||||
|
let form = LoginForm::new(None);
|
||||||
|
assert_eq!(form.focused, LoginField::Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_fills_the_focused_field_and_tab_switches() {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
type_str(&mut form, "bob");
|
||||||
|
assert_eq!(form.username, "bob");
|
||||||
|
form.handle_key(key(KeyCode::Tab));
|
||||||
|
assert_eq!(form.focused, LoginField::Password);
|
||||||
|
type_str(&mut form, "hunter2");
|
||||||
|
assert_eq!(form.password, "hunter2");
|
||||||
|
form.handle_key(key(KeyCode::Tab));
|
||||||
|
assert_eq!(form.focused, LoginField::Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_deletes_from_focused_field() {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
type_str(&mut form, "bob");
|
||||||
|
form.handle_key(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(form.username, "bo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_username_moves_to_password() {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
type_str(&mut form, "bob");
|
||||||
|
let action = form.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(action, LoginAction::None);
|
||||||
|
assert_eq!(form.focused, LoginField::Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_submits_when_both_fields_filled() {
|
||||||
|
let mut form = LoginForm::new(Some("alice".into()));
|
||||||
|
type_str(&mut form, "hunter2");
|
||||||
|
let action = form.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(action, LoginAction::Submit);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_with_empty_password_does_not_submit() {
|
||||||
|
let mut form = LoginForm::new(Some("alice".into()));
|
||||||
|
let action = form.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(action, LoginAction::None);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels() {
|
||||||
|
let mut form = LoginForm::new(None);
|
||||||
|
assert_eq!(form.handle_key(key(KeyCode::Esc)), LoginAction::Cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
let action = form.handle_key(key(KeyCode::Char('x')));
|
||||||
|
assert_eq!(action, LoginAction::None);
|
||||||
|
assert_eq!(form.phase, LoginPhase::Editing);
|
||||||
|
assert_eq!(form.password, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod download_selector;
|
mod download_selector;
|
||||||
|
pub mod login;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
mod main_tui;
|
mod main_tui;
|
||||||
mod panes;
|
mod panes;
|
||||||
|
|||||||
Reference in New Issue
Block a user