nirc-rs/src/tui/chat_view.rs

1026 lines
42 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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`, `-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: `<Nick>` (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:
//! - `<B>...</B>` — bold
//! - `<I>...</I>` — italic (rendered as dim/underline in terminals that lack italics)
//! - `<U>...</U>` — underline
//! - `<R>...</R>` — reverse video
//! - `<FONT COLOR="red">...</FONT>` — 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<ChatMessage>,
palette: NaimPalette,
highlight_nicks: HashSet<String>,
scroll_offset: usize,
}
impl ChatView {
/// Create from a `Theme` (alternate `Theme` API).
pub fn new(
messages: &[ChatMessage],
theme: &Theme,
highlight_nicks: &HashSet<String>,
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<String>,
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<chrono::Utc>) -> 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<chrono::Utc>) -> 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 815) 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] <Name> 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] <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;
} 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<String>, // None = primary line, Some = continuation
}
let mut display_lines: Vec<DispLine> = 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<String> {
if max_cols == 0 {
return vec![text.to_string()];
}
if text.is_empty() {
return vec![String::new()];
}
let mut result: Vec<String> = 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 `<B>`, `<I>`, `<U>`, `<R>`, and
/// `<FONT COLOR="...">` 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 <url>` 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<char> = 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<char> = 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<char> = 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<char> = 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):
/// - `<B>`, `</B>` — bold
/// - `<I>`, `</I>` — italic (rendered with `add_modifier(Modifier::ITALIC)`)
/// - `<U>`, `</U>` — underline
/// - `<R>`, `</R>` — reverse video
/// - `<FONT COLOR="X">`, `</FONT>` — set foreground color
///
/// Nesting is supported (e.g. `<B>bold <I>both</I></B>`). Closing tags pop the
/// most recent matching open tag. Mismatched closes (e.g. `</I>` when no `<I>`
/// is open) are ignored. Unknown tags (e.g. `<FOO>`) are treated as no-ops
/// (their content is rendered with the inherited style).
pub fn parse_markup(input: &str, base_style: Style) -> Vec<MarkupSegment> {
let mut segments: Vec<MarkupSegment> = Vec::new();
let mut stack: Vec<Style> = vec![base_style];
let mut current_text = String::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'<' {
// Find the matching '>'
if let Some(end) = find_tag_end(input, i) {
// Flush any pending text with the current style
if !current_text.is_empty() {
let style = *stack.last().unwrap();
segments.push((std::mem::take(&mut current_text), style));
}
let tag = &input[i + 1..end];
apply_tag(tag, &mut stack);
i = end + 1;
continue;
}
// No closing '>' — treat '<' as literal text
current_text.push('<');
i += 1;
} else {
// Push the UTF-8 char starting at i
let ch = input[i..].chars().next().unwrap();
current_text.push(ch);
i += ch.len_utf8();
}
}
if !current_text.is_empty() {
let style = *stack.last().unwrap();
segments.push((current_text, style));
}
segments
}
/// Find the index of the `>` that closes a tag starting at `start` (which must
/// be `<`). Returns None if no closing `>` is found on the same line.
fn find_tag_end(input: &str, start: usize) -> Option<usize> {
input[start + 1..].find('>').map(|offset| start + 1 + offset)
}
/// Apply a markup tag to the style stack. `tag` is the text between `<` and `>`,
/// e.g. `B`, `/B`, `FONT COLOR="red"`.
fn apply_tag(tag: &str, stack: &mut Vec<Style>) {
let tag = tag.trim();
let (is_close, body) = if let Some(rest) = tag.strip_prefix('/') {
(true, rest.trim())
} else {
(false, tag)
};
// Extract just the tag name (first word, before any space or attribute).
// For `<FONT COLOR="red">`, body is `FONT COLOR="red"` and tag_name is `FONT`.
let tag_name: String = body.split_whitespace().next().unwrap_or("").to_uppercase();
if is_close {
// Pop the most recent matching open tag. We track open tags via their
// name suffix on the stack — but since we only have Styles on the stack,
// we just pop the topmost entry (matching naim's lenient behavior).
// Only pop if there's more than the base style on the stack.
match tag_name.as_str() {
"B" | "I" | "U" | "R" | "FONT" => {
if stack.len() > 1 {
stack.pop();
}
}
_ => {} // unknown close tag — ignore
}
return;
}
let current = *stack.last().unwrap();
let new_style = match tag_name.as_str() {
"B" => current.add_modifier(Modifier::BOLD),
"I" => current.add_modifier(Modifier::ITALIC),
"U" => current.add_modifier(Modifier::UNDERLINED),
"R" => current.add_modifier(Modifier::REVERSED),
"FONT" => {
// Parse COLOR="..." attribute (case-insensitive) from the full body.
if let Some(color) = parse_font_color(body) {
if let Some(naim_color) = NaimColor::from_name(&color) {
current.fg(naim_color.to_ratatui())
} else if let Some(naim_color) = parse_hex_color(&color) {
current.fg(naim_color.to_ratatui())
} else {
current // unknown color name — leave unchanged
}
} else {
current // no COLOR attribute — no-op
}
}
_ => current, // unknown open tag — no-op
};
stack.push(new_style);
}
/// Extract the `COLOR="..."` value from a FONT tag's content.
/// `name` is the part after `<` and before `>`, e.g. `FONT COLOR="red"`.
/// Returns the color string (e.g. `red`), or None if no COLOR attribute.
fn parse_font_color(name: &str) -> Option<String> {
let lower = name.to_lowercase();
let key = "color=";
let idx = lower.find(key)?;
let after = &name[idx + key.len()..];
let after = after.trim_start();
if after.starts_with('"') {
let end = after[1..].find('"')?;
Some(after[1..1 + end].to_owned())
} else if after.starts_with('\'') {
let end = after[1..].find('\'')?;
Some(after[1..1 + end].to_owned())
} else {
// Unquoted value — take up to next whitespace
let end = after.find(char::is_whitespace).unwrap_or(after.len());
if end == 0 { None } else { Some(after[..end].to_owned()) }
}
}
/// Parse a `#RRGGBB` hex color into the nearest `NaimColor` (8-color approximation).
/// Returns None if the string isn't a valid `#RRGGBB`.
///
/// Uses byte-based hex parsing instead of `&s[0..2]` / `&s[2..4]` / `&s[4..6]`
/// slicing. The old str-slicing approach panicked on multi-byte UTF-8 chars
/// at a slice boundary (e.g. `<FONT COLOR="#aébcd">` where `é` is 2 bytes).
/// Since this runs inside the main draw closure, such a panic would crash the
/// whole TUI app the moment a user's own message (or a paste) contained such
/// a tag.
fn parse_hex_color(s: &str) -> Option<NaimColor> {
let s = s.strip_prefix('#')?;
let bytes = s.as_bytes();
if bytes.len() != 6 { return None; }
let hex_val = |b: u8| -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
};
let r = (hex_val(bytes[0])? << 4) | hex_val(bytes[1])?;
let g = (hex_val(bytes[2])? << 4) | hex_val(bytes[3])?;
let b = (hex_val(bytes[4])? << 4) | hex_val(bytes[5])?;
Some(nearest_8_color(r, g, b))
}
/// Map an RGB triple to the nearest of the 8 terminal colors.
/// Uses simple Euclidean distance in RGB space, with a threshold for "dark =
/// black" and "bright = white" heuristics. Bright variants (R+G+B > 480) map
/// to White; dark variants (R+G+B < 192) map to Clear (Black).
fn nearest_8_color(r: u8, g: u8, b: u8) -> NaimColor {
let sum = r as u32 + g as u32 + b as u32;
if sum < 192 { return NaimColor::Clear; }
if sum > 600 { return NaimColor::White; }
// Standard ANSI 8-color palette (R, G, B)
let palette: [(NaimColor, u8, u8, u8); 8] = [
(NaimColor::Clear, 0, 0, 0),
(NaimColor::Red, 205, 0, 0),
(NaimColor::Green, 0, 205, 0),
(NaimColor::Yellow, 205, 205, 0),
(NaimColor::Blue, 0, 0, 238),
(NaimColor::Magenta, 205, 0, 205),
(NaimColor::Cyan, 0, 205, 205),
(NaimColor::White, 229, 229, 229),
];
let mut best = NaimColor::White;
let mut best_dist = u32::MAX;
for (color, pr, pg, pb) in palette.iter() {
let dr = r as i32 - *pr as i32;
let dg = g as i32 - *pg as i32;
let db = b as i32 - *pb as i32;
let dist = (dr * dr + dg * dg + db * db) as u32;
if dist < best_dist {
best_dist = dist;
best = *color;
}
}
best
}
#[cfg(test)]
mod markup_tests {
use super::*;
fn base() -> Style { Style::default().fg(ratatui::style::Color::White) }
fn has_mod(s: Style, m: Modifier) -> bool { s.add_modifier.contains(m) }
#[test]
fn plain_text_no_tags() {
let segs = parse_markup("hello world", base());
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].0, "hello world");
assert_eq!(segs[0].1, base());
}
#[test]
fn bold_tag() {
let segs = parse_markup("<B>bold</B>", base());
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].0, "bold");
assert!(has_mod(segs[0].1, Modifier::BOLD));
}
#[test]
fn mixed_bold_plain() {
let segs = parse_markup("plain <B>bold</B> plain", base());
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].0, "plain ");
assert!(!has_mod(segs[0].1, Modifier::BOLD));
assert_eq!(segs[1].0, "bold");
assert!(has_mod(segs[1].1, Modifier::BOLD));
assert_eq!(segs[2].0, " plain");
assert!(!has_mod(segs[2].1, Modifier::BOLD));
}
#[test]
fn nested_tags() {
let segs = parse_markup("<B>bold <I>both</I></B>", base());
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].0, "bold ");
assert!(has_mod(segs[0].1, Modifier::BOLD));
assert!(!has_mod(segs[0].1, Modifier::ITALIC));
assert_eq!(segs[1].0, "both");
assert!(has_mod(segs[1].1, Modifier::BOLD));
assert!(has_mod(segs[1].1, Modifier::ITALIC));
}
#[test]
fn underline_tag() {
let segs = parse_markup("<U>under</U>", base());
assert!(has_mod(segs[0].1, Modifier::UNDERLINED));
}
#[test]
fn reverse_tag() {
let segs = parse_markup("<R>rev</R>", base());
assert!(has_mod(segs[0].1, Modifier::REVERSED));
}
#[test]
fn font_color_named() {
let segs = parse_markup(r##"<FONT COLOR="red">red text</FONT>"##, base());
assert_eq!(segs[0].0, "red text");
assert_eq!(segs[0].1.fg, Some(ratatui::style::Color::Red));
}
#[test]
fn font_color_case_insensitive() {
let segs = parse_markup(r##"<font color="CYAN">x</font>"##, base());
assert_eq!(segs[0].1.fg, Some(ratatui::style::Color::Cyan));
}
#[test]
fn font_color_hex_red() {
let segs = parse_markup(r##"<FONT COLOR="#FF0000">x</FONT>"##, base());
assert_eq!(segs[0].1.fg, Some(ratatui::style::Color::Red));
}
#[test]
fn font_color_hex_blue() {
let segs = parse_markup(r##"<FONT COLOR="#0000FF">x</FONT>"##, base());
assert_eq!(segs[0].1.fg, Some(ratatui::style::Color::Blue));
}
#[test]
fn unknown_tag_strips_content_kept() {
let segs = parse_markup("<FOO>kept</FOO>", base());
assert_eq!(segs[0].0, "kept");
assert_eq!(segs[0].1, base()); // no modifier change
}
#[test]
fn unclosed_tag_literal() {
// No closing '>' on the open tag — render literally
let segs = parse_markup("<B no close", base());
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].0, "<B no close");
}
#[test]
fn close_without_open_is_noop() {
let segs = parse_markup("plain </B> text", base());
// 3 segments: "plain ", "" (empty, no modifier), " text"
assert!(segs.len() >= 2);
assert_eq!(segs[0].0, "plain ");
}
#[test]
fn nearest_8_color_thresholds() {
assert_eq!(nearest_8_color(0, 0, 0), NaimColor::Clear); // black
assert_eq!(nearest_8_color(255, 255, 255), NaimColor::White); // white
assert_eq!(nearest_8_color(255, 0, 0), NaimColor::Red);
assert_eq!(nearest_8_color(0, 255, 0), NaimColor::Green);
assert_eq!(nearest_8_color(0, 0, 255), NaimColor::Blue);
assert_eq!(nearest_8_color(255, 255, 0), NaimColor::Yellow);
assert_eq!(nearest_8_color(255, 0, 255), NaimColor::Magenta);
assert_eq!(nearest_8_color(0, 255, 255), NaimColor::Cyan);
}
#[test]
fn parse_hex_color_valid() {
assert_eq!(parse_hex_color("#FF0000"), Some(NaimColor::Red));
assert_eq!(parse_hex_color("#00FF00"), Some(NaimColor::Green));
assert_eq!(parse_hex_color("#0000FF"), Some(NaimColor::Blue));
}
#[test]
fn parse_hex_color_invalid() {
assert_eq!(parse_hex_color("FF0000"), None); // missing #
assert_eq!(parse_hex_color("#FF"), None); // too short
assert_eq!(parse_hex_color("#GGGGGG"), None); // non-hex
}
}
// ─── tab bar (kept for coexistence) ───────────────────────
//
// This function is retained because `main.rs` currently calls it. When the
// main loop is migrated to use `WinlistWidget`, this can be removed.
/// Render a horizontal tab bar (horizontal style, non-naim).
#[allow(deprecated)]
pub fn render_tab_bar(
area: Rect,
buf: &mut Buffer,
tabs: &[crate::core::app::Tab],
active_idx: usize,
theme: &Theme,
) {
if tabs.is_empty() {
return;
}
let avail = (area.width as usize).saturating_sub(2);
let tw = (avail / tabs.len()).max(3).min(20) as u16;
let mut x = area.x;
for (i, tab) in tabs.iter().enumerate() {
let style = if i == active_idx {
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_active_bg)
.bold()
} else {
Style::default()
.fg(theme.tab_inactive_fg)
.bg(theme.bg)
};
let title: String = tab
.title
.chars()
.take((tw as usize).saturating_sub(1))
.collect();
let d: String = if tab.unread_count() > 0 {
format!("{}{}", title, tab.unread_count())
} else {
title
};
let d: String = d.chars().take(tw as usize).collect();
buf.set_string(x, area.y, &d, style);
x += tw;
}
}