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;
pub fn render(app: &mut App, frame: &mut Frame) {
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);
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;
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((
chunks[1].x + ((app.input.visual_cursor()).max(scroll) - scroll) as u16 + 1 + 1,
chunks[1].y + 1,
));
app.confirm_button = None; } 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;
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; } else {
app.pop_up = None;
}
if let Some(position) = cursor_position {
frame.set_cursor_position(position);
}
}