//! 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. // // Two levels of splitting: // 1. Split body on '\n' → logical lines (explicit newlines from // the sender, e.g. multi-line paste or IRC messages with // embedded newlines). // 2. Wrap each logical line to fit the available width → display // lines. This is the fix for "extremely long lines from IRC // don't wrap and text is lost if resolution is small" — the // previous code only did step 1 and then truncated each // logical line at the right margin, dropping any text past // the visible width. // // Walk newest-to-oldest, accumulating at most `vc` display 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); // Wrap width: each display line starts at `area.x + indent` (where // indent = timestamp width = 11 chars for "[HH:MM:SS] "). The // primary line ALSO has a sender prefix that takes additional // space, but for the purposes of wrap-width calculation we use // the indent-only width — this means the primary line's first // wrapped chunk may still get slightly truncated by render_body // if the sender prefix is long, but the wrap will continue onto // the next line(s) so the full text is visible. This is a major // improvement over the old "everything past the right edge is lost". let ts_indent = Self::format_timestamp(&chrono::Utc::now()).len() as u16; let wrap_width = (area.width as usize).saturating_sub(ts_indent as usize).max(1); for i in (0..=newest_idx).rev() { if display_lines.len() >= vc { break; } let msg = &self.messages[i]; // Step 1: split on explicit newlines. let body_lines: Vec<&str> = msg.body.split('\n').collect(); // Step 2: wrap each logical line to fit `wrap_width` columns. // Collect into a flat list of (is_primary, text) pairs. // The very first sub-line of the first logical line is the // primary display line (gets the timestamp + sender prefix). // Everything else is a continuation (indented to the timestamp). let mut sub_lines: Vec<(bool, String)> = Vec::new(); for (j, line) in body_lines.iter().enumerate() { let wrapped = wrap_text(line, wrap_width); if wrapped.is_empty() { // Empty line — preserve as a blank display line. sub_lines.push((j == 0 && sub_lines.is_empty(), String::new())); } else { for (k, sub) in wrapped.into_iter().enumerate() { let is_primary = j == 0 && k == 0 && sub_lines.is_empty(); sub_lines.push((is_primary, sub)); } } } // Push sub-lines in reverse so the primary line ends up at the // bottom of this message's block (matches the existing layout: // newest message at the bottom of the visible area). for (is_primary, text) in sub_lines.into_iter().rev() { if display_lines.len() >= vc { break; } if is_primary { display_lines.push(DispLine { msg, cont: None }); } else { display_lines.push(DispLine { msg, cont: Some(text) }); } } } // 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); } } } } /// Word-wrap a single line of text to fit within `max_cols` display columns. /// /// Breaks on whitespace when possible (word-wrap); falls back to hard /// character breaks for words longer than `max_cols` (e.g. long URLs). /// Returns a list of wrapped sub-lines, none longer than `max_cols` /// characters. Empty input returns a single empty string (so the caller /// still allocates a display line for blank lines). /// /// This is the fix for the "extremely long lines from IRC don't wrap and /// text is lost if resolution is small" bug — the previous renderer /// truncated each body line at the right margin, dropping everything past /// the visible width. With wrapping, long lines continue onto subsequent /// display lines so the full text is always readable, even on an 80-col /// terminal receiving a 500-char IRC message. fn wrap_text(text: &str, max_cols: usize) -> Vec { if max_cols == 0 { return vec![text.to_string()]; } if text.is_empty() { return vec![String::new()]; } let mut result: Vec = Vec::new(); let mut current = String::new(); for word in text.split(' ') { if current.is_empty() { // First word on this wrapped line. if word.chars().count() <= max_cols { current.push_str(word); } else { // Word itself is longer than max_cols — hard-break it. let mut remaining: String = word.to_string(); while remaining.chars().count() > max_cols { let take: String = remaining.chars().take(max_cols).collect(); result.push(take); remaining = remaining.chars().skip(max_cols).collect(); } if !remaining.is_empty() { current.push_str(&remaining); } } } else { let candidate_len = current.chars().count() + 1 + word.chars().count(); if candidate_len <= max_cols { current.push(' '); current.push_str(word); } else { // Doesn't fit — flush current, start new line with word. result.push(std::mem::take(&mut current)); if word.chars().count() <= max_cols { current.push_str(word); } else { // Long word — hard-break. let mut remaining: String = word.to_string(); while remaining.chars().count() > max_cols { let take: String = remaining.chars().take(max_cols).collect(); result.push(take); remaining = remaining.chars().skip(max_cols).collect(); } if !remaining.is_empty() { current.push_str(&remaining); } } } } } if !current.is_empty() { result.push(current); } if result.is_empty() { result.push(String::new()); } result } #[cfg(test)] mod wrap_tests { use super::wrap_text; #[test] fn short_text_fits_one_line() { let lines = wrap_text("hello world", 80); assert_eq!(lines, vec!["hello world"]); } #[test] fn wraps_at_word_boundary() { let lines = wrap_text("one two three four", 10); // "one two" (7) + " three" would be 13 > 10, so wrap after "two" assert_eq!(lines, vec!["one two", "three four"]); } #[test] fn hard_breaks_long_words() { // A 20-char "word" with no spaces, max_cols=10 → two 10-char lines + remainder. let long = "abcdefghijklmnopqrstuvwxyz"; let lines = wrap_text(long, 10); assert_eq!(lines.len(), 3); assert_eq!(lines[0].chars().count(), 10); assert_eq!(lines[1].chars().count(), 10); assert_eq!(lines[2], "uvwxyz"); } #[test] fn empty_input_returns_one_empty_line() { let lines = wrap_text("", 80); assert_eq!(lines, vec![""]); } #[test] fn long_url_is_hard_broken() { let url = "https://example.com/very/long/path/that/exceeds/width"; let lines = wrap_text(url, 20); // Every line should be <= 20 chars. for line in &lines { assert!(line.chars().count() <= 20, "line '{}' is {} chars (> 20)", line, line.chars().count()); } // Concatenated, they should reconstruct the original. assert_eq!(lines.join(""), url); } } // ─── 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. /// /// URLs (http://, https://, ftp://, www.) are automatically underlined and /// rendered in the accent color so they're visually distinct. This is the /// foundation for the inline-photo and external-video features — when a /// URL points to an image, a future version of this renderer will replace /// the URL text with the inline image (if the terminal supports it); when /// it points to a video, a placeholder like `[video: URL]` is shown and /// the user can launch it externally via `/video ` or a key binding. /// /// 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; } // Within this segment, scan for URLs and underline them. The // non-URL portions use seg_style as-is; URL portions get an // underline + a distinctive color (Cyan, the traditional naim // link color). let url_spans = crate::tui::media::detect_urls(&text); if url_spans.is_empty() { // Fast path: no URLs, render the whole segment at once. 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; } continue; } // Slow path: walk the segment, splitting on URL boundaries. let url_style = seg_style .add_modifier(Modifier::UNDERLINED) .fg(ratatui::style::Color::Cyan); let mut last_end = 0; for span in &url_spans { // Render the non-URL text before this span. if span.byte_start > last_end { let before = &text[last_end..span.byte_start]; let chars: Vec = before.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; } if remaining_cols == 0 { break; } } // Render the URL itself with the URL style. let url_text = &text[span.byte_start..span.byte_end]; let chars: Vec = url_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, url_style); cur_x += take as u16; remaining_cols -= take; } if remaining_cols == 0 { break; } last_end = span.byte_end; } // Render any trailing non-URL text after the last URL. if remaining_cols > 0 && last_end < text.len() { let after = &text[last_end..]; let chars: Vec = after.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