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:
michel
2026-07-20 20:14:17 +02:00
parent aaeb62efa4
commit d913c5cc71
3 changed files with 533 additions and 30 deletions
+424
View File
@@ -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");
}
}