zero-trust-rps 0.0.5

Online Multiplayer Rock Paper Scissors
Documentation
use ratatui::{
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style, Stylize},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, List, Padding, Paragraph, Row, Table, Wrap},
    Frame,
};
use uuid::Uuid;

use crate::common::{
    hex::{parse_ascii_hex_digit, HEX_DIGITS},
    rps::state::RpsState,
};

use super::app::App;

pub const MIN_ROOM_CODE_LEN: usize = 4;

/// Renders the user interface widgets.
pub fn render(app: &mut App, frame: &mut Frame) {
    // This is where you add new widgets.
    // See the following resources:
    // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html
    // - https://github.com/ratatui/ratatui/tree/master/examples
    //
    // Text input example:
    // - https://github.com/sayanarijit/tui-input/blob/main/examples/ratatui-input/src/main.rs
    let mut cursor_position: Option<(u16, u16)> = None;

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(1)
        .vertical_margin(0)
        .constraints(
            [
                Constraint::Length(2),
                Constraint::Length(3),
                Constraint::Length(
                    app.client_state
                        .room
                        .as_ref()
                        .map(|app| app.users.len() + 2)
                        .unwrap_or(0)
                        .try_into()
                        .expect("too many users"),
                ),
                Constraint::Min(1),
            ]
            .as_ref(),
        )
        .split(frame.area());

    let bold = Style::default().add_modifier(Modifier::BOLD);
    // TODO: Exit x button!!
    let msg = vec![
        Span::raw("Press "),
        Span::styled("Esc", bold),
        Span::raw(" to exit, "),
        Span::styled("Enter", bold),
        Span::raw(" to "),
        Span::raw(match app.client_state.state {
            RpsState::BeforeRoom => "join a room with that numeric id.",
            RpsState::InRoom => "play the typed word.",
            RpsState::Played => "send confirmation of your play.",
            RpsState::Confirmed => "continue to room.",
        }),
        Span::raw(if app.user_tried_to_change_state_already {
            "  loading"
        } else {
            ""
        }),
        Span::raw(if app.is_waiting_for_others() {
            " waiting"
        } else {
            ""
        }),
        Span::raw(if app.client_state.timed_out {
            " OFFLINE"
        } else {
            ""
        }),
    ];
    let mut text = Text::from(Line::from(msg));
    text.push_line(Line::from(vec![
        Span::raw("Room: "),
        Span::styled(
            app.client_state
                .room
                .as_ref()
                .map(|room| format!("{:04}", room.id))
                .unwrap_or_else(|| "?".repeat(MIN_ROOM_CODE_LEN)),
            bold,
        ),
        Span::raw(" User: "),
        Span::styled(app.client_state.user.to_string(), bold),
    ]));
    let help_message = Paragraph::new(text);
    frame.render_widget(help_message, chunks[0]);

    const ACTIVE_COLOUR: Color = Color::Yellow;
    let input_border_colour =
        if app.user_tried_to_change_state_already || app.is_waiting_for_others() {
            Color::Gray
        } else {
            ACTIVE_COLOUR
        };
    if app.text_input_allowed() {
        let width = chunks[0].width.max(3) - 3; // keep 2 for borders and 1 for cursor

        let scroll = app.input.visual_scroll(width as usize);

        let spans = {
            let app = &app;
            let mut bytes = 0;
            app.input.value().chars().map(move |ch| {
                bytes += ch.len_utf8();
                let colour = if bytes > usize::from(app.input_max_length())
                    || app.client_state.state == RpsState::BeforeRoom && !ch.is_ascii_digit()
                {
                    Some(Color::Red)
                } else {
                    None
                };
                let span = if app.hide_inputs {
                    Span::raw("*")
                } else if ch.is_ascii_digit() && !ch.is_ascii_uppercase() {
                    Span::raw(HEX_DIGITS[parse_ascii_hex_digit(ch).expect("is_digit") as usize])
                } else {
                    Span::raw(ch.to_string())
                };
                if let Some(colour) = colour {
                    span.style(Style::default().fg(colour))
                } else {
                    span
                }
            })
        };
        let input = Paragraph::new(Text::from(Line::from_iter(spans)))
            .scroll((0, scroll as u16))
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Input")
                    .fg(input_border_colour)
                    .padding(Padding::left(1)),
            );

        frame.render_widget(input, chunks[1]);

        cursor_position = Some((
            // Put cursor past the end of the input text
            chunks[1].x + ((app.input.visual_cursor()).max(scroll) - scroll) as u16 + 1 + 1,
            // Move one line down, from the border to the input line
            chunks[1].y + 1,
        ));
        app.confirm_button = None; // TODO: maybe button to right of text input??
    } else {
        app.confirm_button = Some(chunks[1]);
        frame.render_widget(
            Paragraph::new(if app.client_state.state == RpsState::Confirmed {
                "Continue"
            } else {
                "Confirm"
            })
            .centered()
            .style(Style::default().fg(input_border_colour))
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .fg(input_border_colour),
            ),
            chunks[1],
        );
    }

    let users: Vec<Row> = app
        .client_state
        .room
        .as_ref()
        .iter()
        .flat_map(|room| room.users.iter().map(|u| (u, room.round.as_ref())))
        .map(|(u, round)| {
            let is_you = u.id == app.client_state.user;
            let is_active = round.is_some_and(|round| round.users.contains(&u.id));
            let flags = if is_you { "" } else { " " };
            use crate::common::message::UserState::*;
            let state = match u.state {
                InRoom => Text::raw(format!("{InRoom:?}")),
                Played(hash) => {
                    let mut text = Text::raw("");
                    hash.get_span_iter().for_each(|s| text.push_span(s));
                    text.push_span(" (");
                    text.push_span(hash.get_algorithm());
                    text.push_span(")");
                    text
                }
                Confirmed(confirmed) => {
                    let mut text = Text::raw("");
                    text.push_span(format!("{:?} ", confirmed.get_data()));
                    confirmed
                        .as_hash()
                        .get_span_iter()
                        .for_each(|s| text.push_span(s));
                    text.push_span(" (");
                    text.push_span(confirmed.as_hash().get_algorithm());
                    text.push_span(")");
                    text
                }
            };
            Row::new([
                Text::styled(
                    format!(" {}", u.id),
                    Style::default().add_modifier(if is_active {
                        Modifier::BOLD
                    } else {
                        Modifier::empty()
                    }),
                ),
                state,
                Text::raw(flags),
            ])
        })
        .collect();
    let table_constraints = [
        Constraint::Fill(1),
        Constraint::Fill(3),
        Constraint::Length(1),
    ];
    let messages = Table::new(users, table_constraints)
        .block(Block::default().borders(Borders::ALL).title("Users"));

    frame.render_widget(messages, chunks[2]);

    let old_plays = List::new(
        app.old_plays
            .iter()
            .enumerate()
            .rev()
            .take(chunks[3].height as usize)
            .map(|(i, round)| {
                use std::fmt::Write as _;
                let mut row = format!("{i}: ");

                let mut buffer = Uuid::encode_buffer();
                let mut round_iter = round.iter().peekable();
                while let Some((id, play)) = round_iter.next() {
                    write!(
                        row,
                        "{}: {:?}",
                        id.as_hyphenated()
                            .encode_lower(&mut buffer)
                            .split('-')
                            .next()
                            .expect("split iterator should have at least one"),
                        play.get_data()
                    )
                    .expect("writing to string should not fail");
                    if round_iter.peek().is_some() {
                        row.push_str(", ");
                    }
                }
                row
            }),
    );

    frame.render_widget(old_plays, chunks[3]);

    if let Some(error) = app.submit_error.as_ref() {
        let block = Block::new()
            .title("Error")
            .borders(Borders::ALL)
            .bg(Color::Indexed(232))
            .border_style(Style::default().fg(Color::Red));

        let paragraph = Paragraph::new(error.as_str())
            .wrap(Wrap { trim: true })
            .centered()
            .block(block);

        let horiz_block_padd = 2u16; //  account for border of block

        let width = frame.area().width.min(
            (error.as_str().len())
                .try_into()
                .unwrap_or(u16::MAX)
                .saturating_add(horiz_block_padd),
        );
        let height = u16::try_from(paragraph.line_count(width.wrapping_sub(horiz_block_padd)))
            .unwrap_or(frame.area().height - 2);

        let area = Layout::default()
            .margin(1)
            .direction(Direction::Vertical)
            .horizontal_margin((frame.area().width - width) / 2)
            .constraints([
                Constraint::Fill(1),
                Constraint::Length(height),
                Constraint::Fill(1),
            ])
            .split(frame.area())[1];

        frame.render_widget(Clear, area);
        frame.render_widget(paragraph, area);

        app.pop_up = Some(area);
        cursor_position = None; // do not show cursor!
    } else {
        app.pop_up = None;
    }

    if let Some(position) = cursor_position {
        // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
        frame.set_cursor_position(position);
    }
}