//! Chat view rendering — naim-style message formatting. //! //! Timestamps use `[HH:MM:SS] ` (24-hour, trailing space), colored bold yellow. //! Message prefixes follow naim conventions, modernized to Unicode where the //! classic ASCII markers were purely decorative (system/error stars, file //! transfer tag). IRC-protocol prefixes (``, `nick:`, `* nick`, `-nick-`) //! are preserved verbatim because they are conventions other IRC clients and //! log parsers expect to recognize. //! //! Unicode modernization: //! - System/notice prefix: `***` → `※ ` (U+203B REFERENCE MARK, used as a //! footnote / annotation marker in CJK typography — same semantic role as //! naim's `***` but no longer collides with the C comment delimiter or shell //! glob). //! - Error prefix: `*** Error: ` → `✗ Error: ` (U+2717 BALLOT X) — keeps the //! visual weight of three stars but uses a single Unicode glyph that reads //! unambiguously as "error / rejected". //! - File transfer prefix: `[FILE]` → `⇄ ` (U+21C4 RIGHTWARDS ARROW OVER //! LEFTWARDS ARROW) — evokes bidirectional transfer more directly than the //! bracketed tag, and stays a single cell wide. //! - IRC-protocol prefixes preserved: `` (channel), `Nick:` (query/own), //! `* Nick` (action), `-Nick-` (notice) — these are RFC 1459 / ircII //! conventions and changing them would break copy-paste of logs into other //! tools. //! //! All rendering uses `buf.set_string()` with explicit coordinates for //! character-level control. //! //! ## A4: HTML-like markup (0.1.2) //! //! Message bodies may contain simple HTML-like markup tags that affect rendering: //! - `...` — bold //! - `...` — italic (rendered as dim/underline in terminals that lack italics) //! - `...` — underline //! - `...` — reverse video //! - `...` — colored foreground (case-insensitive; //! color names from `NaimColor::from_name`, or `#RRGGBB` mapped to nearest //! 8-color, or "bold"/"dim" attribute tags) //! //! Tags can nest but cannot overlap. Unknown tags are stripped (their content //! is rendered with the parent style). Malformed tags are rendered literally. use crate::core::message::{ChatMessage, MessageKind}; use crate::core::protocol::ProtocolType; use crate::tui::foundation::{NaimColor, NaimPalette, NaimStyle, Theme}; use chrono::Timelike; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::widgets::Widget; use std::collections::HashSet; const MAX_VISIBLE: usize = 500; // ─── ChatView widget ──────────────────────────────────────────────────────── pub struct ChatView { messages: Vec, palette: NaimPalette, highlight_nicks: HashSet, scroll_offset: usize, } impl ChatView { /// Create from a `Theme` (alternate `Theme` API). pub fn new( messages: &[ChatMessage], theme: &Theme, highlight_nicks: &HashSet, scroll_offset: usize, ) -> Self { let palette = NaimPalette::from_theme(theme); Self::with_palette(messages, &palette, highlight_nicks, scroll_offset) } /// Create with the naim `NaimPalette`. pub fn with_palette( messages: &[ChatMessage], palette: &NaimPalette, highlight_nicks: &HashSet, scroll_offset: usize, ) -> Self { let visible = if messages.len() > MAX_VISIBLE { messages[messages.len() - MAX_VISIBLE..].to_vec() } else { messages.to_vec() }; Self { messages: visible, palette: palette.clone(), highlight_nicks: highlight_nicks.clone(), scroll_offset, } } /// Format timestamp as `[HH:MM:SS] ` (naim default). fn format_timestamp(t: &chrono::DateTime) -> String { format!( "[{:02}:{:02}:{:02}] ", t.hour(), t.minute(), t.second() ) } /// Format a remote (server-provided) timestamp distinctively using /// parentheses: `(HH:MM:SS) `. This gives an immediate visual cue that the /// time was confirmed by the server, not the local clock. fn format_remote_timestamp(t: &chrono::DateTime) -> String { format!( "({:02}:{:02}:{:02}) ", t.hour(), t.minute(), t.second() ) } /// Check if a message contains a highlighted nick. fn is_highlighted(&self, msg: &ChatMessage) -> bool { if msg.is_own { return false; } self.highlight_nicks .iter() .any(|n| msg.body.to_lowercase().contains(&n.to_lowercase())) } /// Check if the message source looks like a channel (starts with # or !). fn is_channel(msg: &ChatMessage) -> bool { msg.source.starts_with('#') || msg.source.starts_with('!') } /// Render a single message at the given y coordinate. fn render_message(&self, msg: &ChatMessage, y: u16, area: Rect, buf: &mut Buffer) { // ── Timestamp ─────────────────────────────────────────────── // D2: timestamp color shifts by protocol — subtle per-protocol visual // identity so switching tabs gives a color-shift cue. IRC keeps the // `event_fg` (yellow) default. // // C-3.3: Server-provided timestamps (IRCv3 server-time, Matrix // origin_server_ts) are rendered with parentheses instead of brackets // and a dimmer style to visually distinguish them from local-clock // timestamps. let (ts, ts_style) = if msg.remote_ts { let ts_str = Self::format_remote_timestamp(&msg.timestamp); // Use dimmed ratatui Color values for remote timestamps — these // use the terminal's "bright" counterpart (indices 8–15) to // provide a subtle but distinct appearance. use ratatui::style::Color; // Protocol-to-dim-color lookup. Explicit match — no discriminant coupling. let dim_color = match msg.protocol { ProtocolType::Irc => Color::DarkGray, ProtocolType::Matrix => Color::Magenta, ProtocolType::Adc => Color::Blue, ProtocolType::BitChat => Color::Green, ProtocolType::Discord => Color::Gray, ProtocolType::Stout => Color::Yellow, ProtocolType::Spacebar => Color::Red, ProtocolType::Nerimity => Color::Magenta, }; (ts_str, ratatui::style::Style::default().fg(dim_color)) } else { let ts_str = Self::format_timestamp(&msg.timestamp); // Protocol-to-timestamp-color lookup. defaults to event_fg for unknown. let ts_color = match msg.protocol { ProtocolType::Irc => self.palette.event_fg, ProtocolType::Matrix => NaimColor::Magenta, ProtocolType::Adc => NaimColor::Blue, ProtocolType::BitChat => NaimColor::Green, ProtocolType::Discord => NaimColor::White, ProtocolType::Stout => NaimColor::Yellow, ProtocolType::Spacebar => NaimColor::Red, ProtocolType::Nerimity => NaimColor::BrightMagenta, }; (ts_str, NaimStyle::bold(ts_color)) }; buf.set_string(area.x, y, &ts, ts_style); let mut x = area.x + ts.len() as u16; if x >= area.x + area.width { return; } let max_x = area.x + area.width; match &msg.kind { // ── Text messages ─────────────────────────────────────── MessageKind::Text => { if msg.is_own { // [HH:MM:SS] Name: body let name_style = NaimStyle::bold(self.palette.self_fg); let name = format!("{}: ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } else if Self::is_channel(msg) && self.is_highlighted(msg) { // [HH:MM:SS] body (highlighted) let name_style = NaimStyle::bold(self.palette.buddy_waiting_fg); let name = format!("<{}> ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } else if Self::is_channel(msg) { // [HH:MM:SS] body let name_style = NaimStyle::bold(self.palette.buddy_fg); let name = format!("<{}> ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } else { // PM/query: [HH:MM:SS] Name: body let name_style = NaimStyle::bold(self.palette.buddy_fg); let name = format!("{}: ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } // Body let body_style = NaimStyle::fg(self.palette.text_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } // ── Action (/me) ──────────────────────────────────────── MessageKind::Action => { // [HH:MM:SS] * Name body let prefix_style = NaimStyle::fg(self.palette.buddy_fg); buf.set_string(x, y, "* ", prefix_style); x += 2; let name_style = NaimStyle::bold(self.palette.buddy_fg); let name = format!("{} ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; let body_style = NaimStyle::fg(self.palette.text_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } // ── Notice ────────────────────────────────────────────── MessageKind::Notice => { if msg.sender.is_empty() { // System/Connection notice: [HH:MM:SS] ※ body // (U+203B REFERENCE MARK — modernized from `*** `) let star_style = NaimStyle::bold(self.palette.event_alt_fg); buf.set_string(x, y, "\u{203B} ", star_style); x += 2; // "※ " is two display cells (1 char + 1 space) let body_style = NaimStyle::bold(self.palette.event_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } else { // User notice: [HH:MM:SS] -Name- body (IRC convention, preserved) let notice_style = NaimStyle::fg(self.palette.event_fg); let prefix = format!("-{}- ", msg.sender); buf.set_string(x, y, &prefix, notice_style); x += prefix.len() as u16; let body_style = NaimStyle::fg(self.palette.event_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } } // ── Private message ───────────────────────────────────── MessageKind::Private => { if msg.is_own { let name_style = NaimStyle::bold(self.palette.self_fg); let name = format!("{}: ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } else { let name_style = NaimStyle::bold(self.palette.buddy_fg); let name = format!("{}: ", msg.sender); buf.set_string(x, y, &name, name_style); x += name.len() as u16; } let body_style = NaimStyle::fg(self.palette.text_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } // ── Error ─────────────────────────────────────────────── MessageKind::Error => { // [HH:MM:SS] ✗ Error: body // (U+2717 BALLOT X — modernized from `*** Error: `) let star_style = NaimStyle::bold(self.palette.event_alt_fg); buf.set_string(x, y, "\u{2717} ", star_style); x += 2; // "✗ " is two display cells let err_style = NaimStyle::bold(self.palette.event_fg); buf.set_string(x, y, "Error: ", err_style); x += "Error: ".len() as u16; let body_style = NaimStyle::bold(self.palette.event_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } // ── File transfer ─────────────────────────────────────── MessageKind::FileTransfer { filename, size_bytes, .. } => { let sz = if *size_bytes > 1_048_576 { format!("{:.1} MB", *size_bytes as f64 / 1_048_576.0) } else if *size_bytes > 1024 { format!("{:.1} KB", *size_bytes as f64 / 1024.0) } else { format!("{} B", size_bytes) }; // Modernized prefix: "⇄ filename (size): " — U+21C4 evokes // bidirectional transfer more directly than the [FILE] // bracketed tag, and stays a single cell wide. let prefix = format!("\u{21C4} {} ({}): ", filename, sz); let prefix_style = NaimStyle::fg(self.palette.buddy_fg); buf.set_string(x, y, &prefix, prefix_style); x += prefix.chars().count() as u16; let body_style = NaimStyle::fg(self.palette.buddy_fg); render_body(buf, x, y, max_x, &msg.body, body_style); } } } } impl Widget for ChatView { fn render(self, area: Rect, buf: &mut Buffer) { let vc = area.height as usize; let mc = self.messages.len(); if vc == 0 || mc == 0 { return; } // Expand each message into one or more display lines (split on '\n'). // Walk newest-to-oldest, accumulating at most `vc` lines. // Then render top-to-bottom (oldest visible at top, newest at bottom). struct DispLine<'a> { msg: &'a ChatMessage, cont: Option, // None = primary line, Some = continuation } let mut display_lines: Vec = Vec::with_capacity(vc); let scroll = self.scroll_offset.min(mc.saturating_sub(1)); let newest_idx = mc.saturating_sub(1).saturating_sub(scroll); for i in (0..=newest_idx).rev() { if display_lines.len() >= vc { break; } let msg = &self.messages[i]; let body_lines: Vec<&str> = msg.body.split('\n').collect(); // Push continuation lines first (in reverse) so the primary line // (j == 0) ends up at the bottom of this message's block. for (j, line) in body_lines.iter().enumerate().rev() { if display_lines.len() >= vc { break; } if j == 0 { display_lines.push(DispLine { msg, cont: None }); } else { display_lines.push(DispLine { msg, cont: Some(line.to_string()) }); } } } // display_lines is newest-first. Render so that the LAST element in // the vector appears at the BOTTOM of the visible area. let total = display_lines.len(); for (k, dl) in display_lines.iter().enumerate() { // k=0 is newest → goes at the bottom (y = area.y + vc - 1) // k=total-1 is oldest visible → goes at the top (y = area.y + vc - total) let y = area.y + (vc.saturating_sub(total) + (total - 1 - k)) as u16; if y >= area.y + area.height { break; } if let Some(cont_body) = &dl.cont { // Continuation line: render just the body (no timestamp/sender). let body_style = NaimStyle::fg(self.palette.text_fg); let indent = Self::format_timestamp(&dl.msg.timestamp).len() as u16; let max_x = area.x + area.width; let start_x = area.x + indent; if start_x < max_x { render_body(buf, start_x, y, max_x, cont_body, body_style); } } else { self.render_message(dl.msg, y, area, buf); } } } } // ─── Helper: render body text with A4 HTML-like markup, truncating to fit ─── /// Render a message body that may contain ``, ``, ``, ``, and /// `` markup tags. Each segment is rendered with the /// appropriate `Style` derived from the parent `style` plus the tag's modifier. /// /// Tags are parsed left-to-right; unknown tags are stripped (their content is /// rendered with the inherited style). Malformed tags (e.g. missing `>`) are /// rendered literally as text. #[inline] fn render_body(buf: &mut Buffer, x: u16, y: u16, max_x: u16, body: &str, style: Style) { if x >= max_x { return; } let remaining = (max_x - x) as usize; let segments = parse_markup(body, style); let mut cur_x = x; let mut remaining_cols = remaining; for (text, seg_style) in segments { if remaining_cols == 0 { break; } let chars: Vec = text.chars().collect(); let take = chars.len().min(remaining_cols); if take > 0 { let truncated: String = chars.iter().take(take).collect(); buf.set_string(cur_x, y, &truncated, seg_style); cur_x += take as u16; remaining_cols -= take; } } } /// A parsed segment of markup: a piece of text plus the style to render it with. type MarkupSegment = (String, Style); /// Parse a string containing HTML-like markup tags into a list of (text, style) /// segments. The `base_style` is the style applied to text outside any tag. /// /// Supported tags (case-insensitive): /// - ``, `` — bold /// - ``, `` — italic (rendered with `add_modifier(Modifier::ITALIC)`) /// - ``, `` — underline /// - ``, `` — reverse video /// - ``, `` — set foreground color /// /// Nesting is supported (e.g. `bold both`). Closing tags pop the /// most recent matching open tag. Mismatched closes (e.g. `` when no `` /// is open) are ignored. Unknown tags (e.g. ``) are treated as no-ops /// (their content is rendered with the inherited style). pub fn parse_markup(input: &str, base_style: Style) -> Vec { let mut segments: Vec = Vec::new(); let mut stack: Vec