diff options
Diffstat (limited to 'src/tui/ui.rs')
| -rw-r--r-- | src/tui/ui.rs | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..a38834a --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,252 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use super::app::AppState; + +// ── Colour palette ──────────────────────────────────────────────────────────── +// Dark terminal aesthetic: near-black background, cool grey chrome, +// amber accent for our own nick, cyan for others, muted green for system. + +const BG: Color = Color::Rgb(16, 16, 16); +const ORANGE: Color = Color::Rgb(251, 84, 43); // border / panel bg +const FG: Color = Color::Rgb(204, 204, 204); // main foreground +const GRAY: Color = Color::Rgb(74, 74, 74); // other nicks +const LIGHT_ORANGE: Color = Color::Rgb(255, 122, 89); // system messages + +pub fn draw(f: &mut Frame, state: &AppState) { + let area = f.area(); + + // Fill background + f.render_widget(Block::default().style(Style::default().bg(BG)), area); + + // ── Outer layout: title bar + body + status bar ─────────────────────── + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(0), // body + Constraint::Length(1), // status bar + ]) + .split(area); + + draw_titlebar(f, outer[0], state); + draw_body(f, outer[1], state); + draw_statusbar(f, outer[2], state); +} + +fn draw_titlebar(f: &mut Frame, area: Rect, state: &AppState) { + let title = Line::from(vec![ + Span::styled( + " ", + Style::default().fg(ORANGE).add_modifier(Modifier::BOLD), + ), + Span::styled( + "speakez", + Style::default().fg(FG).add_modifier(Modifier::BOLD), + ), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled( + &state.channel, + Style::default().fg(FG).add_modifier(Modifier::BOLD), + ), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled(&state.nick, Style::default().fg(ORANGE)), + ]); + + f.render_widget(Paragraph::new(title).style(Style::default()), area); +} + +fn draw_body(f: &mut Frame, area: Rect, state: &AppState) { + // Body: [chat (fill)] | [members (18)] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), // centre: chat log + input + Constraint::Length(18), // right: member list + ]) + .split(area); + + draw_center(f, cols[0], state); + draw_members_panel(f, cols[1], state); +} + +fn draw_center(f: &mut Frame, area: Rect, state: &AppState) { + // Centre column: chat log on top, input box on bottom + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // chat log + Constraint::Length(3), // input box + ]) + .split(area); + + draw_chat_log(f, rows[0], state); + draw_input(f, rows[1], state); +} + +fn draw_chat_log(f: &mut Frame, area: Rect, state: &AppState) { + let inner_height = area.height.saturating_sub(2) as usize; // subtract borders + + // Build all rendered lines first so we can scroll from the bottom + let lines: Vec<Line> = state + .messages + .iter() + .map(|msg| render_chat_line(msg)) + .collect(); + + // Apply scroll offset + // scroll=0 means pinned to bottom (newest). scroll=N means N lines back from bottom. + let total = lines.len(); + let max_scroll = total.saturating_sub(inner_height); + let scroll = state.scroll.min(max_scroll); + let visible_start = total.saturating_sub(inner_height + scroll); + let visible: Vec<Line> = lines + .into_iter() + .skip(visible_start) + .take(inner_height) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .style(Style::default().bg(BG)); + + f.render_widget( + Paragraph::new(Text::from(visible)) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_chat_line(msg: &super::app::ChatLine) -> Line<'static> { + if msg.is_system { + return Line::from(Span::styled( + format!(" ∙ {}", msg.text), + Style::default() + .fg(LIGHT_ORANGE) + .add_modifier(Modifier::DIM), + )); + } + + let nick_style = if msg.is_self { + Style::default().fg(ORANGE).add_modifier(Modifier::BOLD) + } else if msg.is_notice { + Style::default().fg(ORANGE) + } else { + Style::default().fg(GRAY) + }; + + let nick = format!("{}", msg.nick); + + Line::from(vec![ + Span::styled(nick, nick_style), + Span::styled(" ", Style::default()), + Span::styled(msg.text.clone(), Style::default().fg(FG)), + ]) +} + +fn draw_input(f: &mut Frame, area: Rect, state: &AppState) { + // Show a blinking cursor indicator at the cursor position + let before = &state.input[..state.cursor]; + let after = &state.input[state.cursor..]; + + let cursor_char = if after.is_empty() { + " " + } else { + &after[..after.chars().next().map(|c| c.len_utf8()).unwrap_or(1)] + }; + let after_cursor = if after.is_empty() { + "" + } else { + &after[cursor_char.len()..] + }; + + let line = Line::from(vec![ + Span::styled(before.to_string(), Style::default().fg(FG)), + Span::styled( + cursor_char.to_string(), + Style::default().bg(FG).fg(BG).add_modifier(Modifier::BOLD), + ), + Span::styled(after_cursor.to_string(), Style::default().fg(FG)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .title(Span::styled(" send ", Style::default().fg(FG))); + + f.render_widget(Paragraph::new(line).block(block), area); +} + +fn draw_members_panel(f: &mut Frame, area: Rect, state: &AppState) { + let items: Vec<ListItem> = state + .members + .iter() + .map(|nick| { + // Highlight ops (@) differently + let (sigil, rest) = if nick.starts_with('@') { + ("@", &nick[1..]) + } else if nick.starts_with('+') { + ("+", &nick[1..]) + } else { + ("", nick.as_str()) + }; + + let sigil_style = if sigil == "@" { + Style::default().fg(ORANGE) + } else { + Style::default().fg(FG) + }; + + ListItem::new(Line::from(vec![ + Span::styled(sigil.to_string(), sigil_style), + Span::styled(rest.to_string(), Style::default().fg(FG)), + ])) + }) + .collect(); + + let title = format!(" users ({}) ", state.members.len()); + let block = panel_block(&title); + + f.render_widget(List::new(items).block(block), area); +} + +fn draw_statusbar(f: &mut Frame, area: Rect, state: &AppState) { + let (status_text, status_style) = if state.connected { + ("● connected", Style::default().fg(LIGHT_ORANGE)) + } else { + ("○ connecting…", Style::default().fg(GRAY)) + }; + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(status_text, status_style), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled(&state.status, Style::default().fg(FG)), + Span::styled(" │ ", Style::default().fg(FG)), + Span::styled("PgUp/Dn scroll Ctrl-C quit", Style::default().fg(FG)), + ]); + + f.render_widget(Paragraph::new(line).style(Style::default()), area); +} + +/// Consistent panel block style +fn panel_block(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(ORANGE)) + .title(Span::styled( + format!(" {} ", title), + Style::default().fg(FG), + )) + .style(Style::default().bg(BG)) +} |
