1026 lines
42 KiB
Rust
Executable File
1026 lines
42 KiB
Rust
Executable File
//! 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 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] <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;
|
||
}
|
||
} |