flatland3 0.2.2

Flatland3 terminal play client and account CLI
//! In-TUI email login / register when no credentials are configured.

use std::io::{self, IsTerminal};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Terminal;

use crate::api::ControlPlaneClient;
use crate::config::{default_api_base, SessionConfig};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
    Login,
    Register,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
    Email,
    Password,
    DisplayName,
}

pub async fn run_if_needed() -> anyhow::Result<()> {
    if crate::auth_context::credentials_available() {
        return Ok(());
    }
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        anyhow::bail!(
            "not logged in; run `flatland3 auth login` or set FLATLAND_API_TOKEN.\n\
             (non-interactive shell — cannot show login UI)"
        );
    }
    run_login_screen().await
}

async fn run_login_screen() -> anyhow::Result<()> {
    crate::terminal::enter()?;
    let _guard = crate::terminal::Guard;

    let backend = CrosstermBackend::new(io::stdout());
    let mut term = Terminal::new(backend)?;

    let mut mode = Mode::Login;
    let mut field = Field::Email;
    let mut email = String::new();
    let mut password = String::new();
    let mut display_name = String::new();
    let mut status = String::from("Sign in to play on the control plane.");
    let mut busy = false;

    loop {
        term.draw(|frame| {
            draw_login(
                frame,
                frame.area(),
                mode,
                field,
                &email,
                &password,
                &display_name,
                &status,
                busy,
            );
        })?;

        if !event::poll(Duration::from_millis(100))? {
            continue;
        }
        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }
        if busy {
            continue;
        }

        match key.code {
            KeyCode::Esc => anyhow::bail!("login cancelled"),
            KeyCode::Tab => {
                field = next_field(mode, field);
            }
            KeyCode::BackTab => {
                field = prev_field(mode, field);
            }
            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                mode = Mode::Register;
                field = Field::Email;
                status = "Create an account, then play.".into();
            }
            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                mode = Mode::Login;
                field = Field::Email;
                status = "Sign in with email and password.".into();
            }
            KeyCode::Enter => {
                busy = true;
                status = "Working…".into();
                term.draw(|frame| {
                    draw_login(
                        frame,
                        frame.area(),
                        mode,
                        field,
                        &email,
                        &password,
                        &display_name,
                        &status,
                        busy,
                    );
                })?;

                let result = match mode {
                    Mode::Login => submit_login(&email, &password).await,
                    Mode::Register => submit_register(&email, &password, &display_name).await,
                };
                busy = false;
                match result {
                    Ok(()) => return Ok(()),
                    Err(err) => status = err.to_string(),
                }
            }
            KeyCode::Char(c) => {
                let target = active_string_mut(mode, field, &mut email, &mut password, &mut display_name);
                target.push(c);
            }
            KeyCode::Backspace => {
                let target = active_string_mut(mode, field, &mut email, &mut password, &mut display_name);
                target.pop();
            }
            _ => {}
        }
    }
}

fn active_string_mut<'a>(
    mode: Mode,
    field: Field,
    email: &'a mut String,
    password: &'a mut String,
    display_name: &'a mut String,
) -> &'a mut String {
    match field {
        Field::Email => email,
        Field::Password => password,
        Field::DisplayName if mode == Mode::Register => display_name,
        Field::DisplayName => email,
    }
}

fn next_field(mode: Mode, field: Field) -> Field {
    match (mode, field) {
        (Mode::Login, Field::Email) => Field::Password,
        (Mode::Login, Field::Password) => Field::Email,
        (Mode::Login, Field::DisplayName) => Field::Email,
        (Mode::Register, Field::Email) => Field::Password,
        (Mode::Register, Field::Password) => Field::DisplayName,
        (Mode::Register, Field::DisplayName) => Field::Email,
    }
}

fn prev_field(mode: Mode, field: Field) -> Field {
    match (mode, field) {
        (Mode::Login, Field::Email) => Field::Password,
        (Mode::Login, Field::Password) => Field::Email,
        (Mode::Login, Field::DisplayName) => Field::Password,
        (Mode::Register, Field::Email) => Field::DisplayName,
        (Mode::Register, Field::Password) => Field::Email,
        (Mode::Register, Field::DisplayName) => Field::Password,
    }
}

fn mask_password(password: &str) -> String {
    if password.is_empty() {
        "(password)".to_string()
    } else {
        "".repeat(password.chars().count())
    }
}

fn draw_login(
    frame: &mut ratatui::Frame,
    area: Rect,
    mode: Mode,
    field: Field,
    email: &str,
    password: &str,
    display_name: &str,
    status: &str,
    busy: bool,
) {
    frame.render_widget(Clear, area);
    let popup = centered_rect(70, 55, area);
    frame.render_widget(Clear, popup);

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(1)
        .constraints([
            Constraint::Length(3),
            Constraint::Length(3),
            Constraint::Length(3),
            Constraint::Length(3),
            Constraint::Min(3),
            Constraint::Length(3),
        ])
        .split(popup);

    let title = match mode {
        Mode::Login => " Flatland3 — Sign in ",
        Mode::Register => " Flatland3 — Register ",
    };
    frame.render_widget(
        Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
        chunks[0],
    );

    let mode_hint = match mode {
        Mode::Login => "Mode: Login  (Ctrl+R register)",
        Mode::Register => "Mode: Register  (Ctrl+L login)",
    };
    frame.render_widget(
        Paragraph::new(mode_hint).style(Style::default().fg(Color::DarkGray)),
        chunks[1],
    );

    render_field(frame, chunks[2], "Email", email, field == Field::Email);
    render_field(
        frame,
        chunks[3],
        "Password",
        &mask_password(password),
        field == Field::Password,
    );

    if mode == Mode::Register {
        render_field(
            frame,
            chunks[4],
            "Display name",
            display_name,
            field == Field::DisplayName,
        );
    } else {
        frame.render_widget(
            Paragraph::new(status)
                .wrap(ratatui::widgets::Wrap { trim: true })
                .style(Style::default().fg(Color::Yellow)),
            chunks[4],
        );
    }

    let footer_idx = if mode == Mode::Register { 5 } else { 5 };
    let footer = if busy {
        "Please wait…"
    } else {
        "Tab: next field  Enter: submit  Esc: quit"
    };
    frame.render_widget(
        Paragraph::new(footer).alignment(Alignment::Center),
        chunks[footer_idx],
    );

    if mode == Mode::Register {
        frame.render_widget(
            Paragraph::new(status)
                .wrap(ratatui::widgets::Wrap { trim: true })
                .style(Style::default().fg(Color::Yellow)),
            Rect {
                y: chunks[4].y + chunks[4].height.saturating_sub(2),
                height: 2,
                ..chunks[4]
            },
        );
    }
}

fn render_field(frame: &mut ratatui::Frame, area: Rect, label: &str, value: &str, focused: bool) {
    let style = if focused {
        Style::default().add_modifier(Modifier::BOLD)
    } else {
        Style::default()
    };
    let display = if value.is_empty() {
        format!("{label}: ")
    } else {
        format!("{label}: {value}")
    };
    frame.render_widget(
        Paragraph::new(Line::from(Span::styled(display, style)))
            .block(Block::default().borders(if focused { Borders::ALL } else { Borders::NONE })),
        area,
    );
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}

async fn submit_login(email: &str, password: &str) -> anyhow::Result<()> {
    if email.trim().is_empty() || password.is_empty() {
        anyhow::bail!("email and password are required");
    }
    let api = default_api_base();
    let client = ControlPlaneClient::new(&api);
    #[derive(serde::Deserialize)]
    struct LoginData {
        session_token: String,
    }
    let data: LoginData = client
        .post(
            "/v1/auth/login",
            &serde_json::json!({ "email": email.trim(), "password": password }),
            None,
        )
        .await?;
    let config = SessionConfig {
        api_base: api.trim_end_matches('/').to_string(),
        session_token: data.session_token,
        last_character_id: None,
    };
    config.save()?;
    Ok(())
}

async fn submit_register(email: &str, password: &str, display_name: &str) -> anyhow::Result<()> {
    if email.trim().is_empty() || password.is_empty() || display_name.trim().is_empty() {
        anyhow::bail!("email, password, and display name are required");
    }
    let api = default_api_base();
    let client = ControlPlaneClient::new(&api);
    let _: serde_json::Value = client
        .post(
            "/v1/auth/register",
            &serde_json::json!({
                "email": email.trim(),
                "password": password,
                "display_name": display_name.trim(),
            }),
            None,
        )
        .await?;
    submit_login(email, password).await
}