nirc-rs/src/tui/chat_view.rs

795 lines
32 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 (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<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);
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 `<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.
///
/// 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<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;
}
}
}
/// 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;
}
}