diff options
Diffstat (limited to 'src/tui')
| -rw-r--r-- | src/tui/app.rs | 130 | ||||
| -rw-r--r-- | src/tui/mod.rs | 2 | ||||
| -rw-r--r-- | src/tui/ui.rs | 252 |
3 files changed, 384 insertions, 0 deletions
diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..1e7d021 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,130 @@ +/// Compile-time channel to join on startup +pub const CHANNEL: &str = "#speakez"; + +/// A single chat message in the log +#[derive(Clone)] +pub struct ChatLine { + pub nick: String, + pub text: String, + /// true = server/system message (JOIN, PART, topic, etc.) + pub is_system: bool, + /// true = NOTICE + pub is_notice: bool, + /// true = this is our own message + pub is_self: bool, +} + +/// All mutable state the TUI needs to render and respond to input +pub struct AppState { + /// Our nick + pub nick: String, + /// The active channel name + pub channel: String, + /// Chat log for the active channel + pub messages: Vec<ChatLine>, + /// Member list for the active channel + pub members: Vec<String>, + /// Current contents of the input box + pub input: String, + /// Cursor position within `input` (byte index) + pub cursor: usize, + /// Scroll offset from the bottom (0 = pinned to latest) + pub scroll: usize, + /// Status line text (connection state, errors, etc.) + pub status: String, + /// Whether we've fully registered + pub connected: bool, +} + +impl AppState { + pub fn new(nick: impl Into<String>, channel: impl Into<String>) -> Self { + Self { + nick: nick.into(), + channel: channel.into(), + messages: Vec::new(), + members: Vec::new(), + input: String::new(), + cursor: 0, + scroll: 0, + status: "Connecting...".into(), + connected: false, + } + } + + /// Push a chat message + pub fn push_message(&mut self, nick: &str, text: &str, is_self: bool) { + self.messages.push(ChatLine { + nick: nick.to_string(), + text: text.to_string(), + is_system: false, + is_notice: false, + is_self, + }); + } + + /// Push a system/event line (joins, parts, topic changes) + pub fn push_system(&mut self, text: &str) { + self.messages.push(ChatLine { + nick: String::new(), + text: text.to_string(), + is_system: true, + is_notice: false, + is_self: false, + }); + } + + /// Insert a character at the cursor + pub fn input_insert(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + } + + /// Delete the character before the cursor + pub fn input_backspace(&mut self) { + if self.cursor == 0 { + return; + } + // Find the start of the previous character + let prev = self.input[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap_or(0); + self.input.remove(prev); + self.cursor = prev; + } + + /// Move cursor left one character + pub fn cursor_left(&mut self) { + self.cursor = self.input[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap_or(0); + } + + /// Move cursor right one character + pub fn cursor_right(&mut self) { + if self.cursor < self.input.len() { + let ch = self.input[self.cursor..].chars().next().unwrap(); + self.cursor += ch.len_utf8(); + } + } + + /// Take the current input, clear the box, return the text + pub fn take_input(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.input) + } + + /// Sort and deduplicate the member list + pub fn sort_members(&mut self) { + self.members.sort_by(|a, b| { + // Strip sigils for sorting (@, +, %) + let a = a.trim_start_matches(&['@', '+', '%', '~', '&'][..]); + let b = b.trim_start_matches(&['@', '+', '%', '~', '&'][..]); + a.to_lowercase().cmp(&b.to_lowercase()) + }); + self.members.dedup(); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..3e2facd --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod ui; 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)) +} |
