aboutsummaryrefslogtreecommitdiffstats
path: root/src/tui
diff options
context:
space:
mode:
authorGravatar lancebord 2026-03-06 18:09:52 -0500
committerGravatar lancebord 2026-03-06 18:09:52 -0500
commitffb9c05de1c755dbddd8b67cca1d6023b213115f (patch)
treefa8c375fc6489871b3539e15f39310dad99b3618 /src/tui
initial commit
Diffstat (limited to '')
-rw-r--r--src/tui/app.rs130
-rw-r--r--src/tui/mod.rs2
-rw-r--r--src/tui/ui.rs252
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))
+}