//! ratatui rendering of the whole application (connecting, pairing and main screens). //! //! Rendering is a pure function of `&App`: nothing here mutates application state. use std::fmt::Display; use std::ops::RangeInclusive; use chrono::{DateTime, Local, TimeZone}; use qrcode::QrCode; use qrcode::render::unicode::Dense1x2; use qrcode::types::QrError; use ratatui::Frame; use ratatui::buffer::CellWidth; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph}; use unicode_segmentation::UnicodeSegmentation; use crate::app::{App, Focus, ListMode, Screen}; use crate::history::{ChatSummary, Contact, StoredMessage}; /// Width of the chat/contact list pane, as a percentage of the terminal width. const LIST_PANE_PERCENT: u16 = 30; /// Height of the input box (two border rows plus one text row). const INPUT_PANE_HEIGHT: u16 = 3; /// Height of the bottom status/help line. const FOOTER_HEIGHT: u16 = 1; /// Title of the list pane while showing chats. const TITLE_CHATS: &str = " Chat [F2: Contatti] "; /// Title of the list pane while showing contacts. const TITLE_CONTACTS: &str = " Contatti [F2: Chat] "; /// Title of the messages pane when no chat is open. const TITLE_NO_CHAT: &str = " Nessuna chat aperta "; /// Title of the input pane. const TITLE_INPUT: &str = " Messaggio "; /// Separator between the chat name and the AI state in the messages pane title. const TITLE_SEPARATOR: &str = "──"; /// Label of the autoreply state in the messages pane title. const AI_LABEL: &str = "AI"; /// Autoreply enabled value shown in the messages pane title. const AI_ON: &str = "ON"; /// Autoreply disabled value shown in the messages pane title. const AI_OFF: &str = "OFF"; /// Marker appended to chat rows whose autoreply is on. const AI_ROW_MARKER: &str = " ●AI"; /// Marker appended to the chats list title when autoreply is on for every chat. const AI_GLOBAL_MARKER: &str = "●AI tutte "; /// Label shown in the list pane title in front of the active search text. const FILTER_LABEL: &str = "Cerca: "; /// Symbol drawn in front of the selected list row. const HIGHLIGHT_SYMBOL: &str = "> "; /// Prompt shown in front of the text being typed. const INPUT_PROMPT: &str = "> "; /// Key help shown before the global autoreply shortcut. const KEY_HELP_BEFORE_GLOBAL: &str = "Tab:focus Enter:apri/invia F2:chat/contatti Canc:elimina F3:AI chat "; /// Key help of the global autoreply shortcut, highlighted when it is on. const KEY_HELP_GLOBAL: &str = "F4:AI tutte"; /// Key help shown after the global autoreply shortcut. const KEY_HELP_AFTER_GLOBAL: &str = " F8:nuovo QR F10:esci"; /// Gap between the status text and the key help. const FOOTER_GAP: &str = " "; /// Leading space of the footer line (also trailing space after the build version). const FOOTER_MARGIN: &str = " "; /// Text shown in front of the build version at the bottom right. const VERSION_PREFIX: &str = "v"; /// Text of the connecting screen. const TEXT_CONNECTING: &str = "Connessione a WhatsApp in corso..."; /// Title of the pairing screen. const TEXT_PAIRING_TITLE: &str = "Collega WhatsApp: scansiona il codice QR"; /// Pairing instructions. const TEXT_PAIRING_STEPS: &str = "WhatsApp > Impostazioni > Dispositivi collegati > Collega un dispositivo"; /// Shown on the pairing screen while no QR code has been received yet. const TEXT_QR_WAITING: &str = "In attesa del codice QR..."; /// Shown instead of the QR code when the terminal is too small to display it. const TEXT_QR_TOO_SMALL: &str = "Terminale troppo piccolo per il QR: ingrandisci la finestra"; /// Prefix of the QR encoding error message. const TEXT_QR_ERROR: &str = "Errore generazione QR"; /// Key help of the connecting and pairing screens. const TEXT_SCREEN_KEYS: &str = "F8: nuovo QR F10: esci"; /// Terminal rows used by the pairing screen besides the QR (title, 2 blanks, steps, status, keys). const PAIRING_TEXT_ROWS: usize = 6; /// Sender label of messages sent from this account. const SENDER_ME: &str = "Tu"; /// Prefix of messages generated by the AI. const AI_PREFIX: &str = "[AI] "; /// Separator between the user part and the server part of a jid. const JID_SEPARATOR: char = '@'; /// `chrono` format of the message time. const CLOCK_FORMAT: &str = "%H:%M"; /// Time shown when a timestamp is out of range. const CLOCK_PLACEHOLDER: &str = "--:--"; /// Border color of the focused pane. const FOCUS_COLOR: Color = Color::Yellow; /// Color of the autoreply marker. const AI_MARKER_COLOR: Color = Color::Green; /// Color of messages written by the AI. const AI_MESSAGE_COLOR: Color = Color::Cyan; /// Color of messages sent by the user. const OWN_MESSAGE_COLOR: Color = Color::Green; /// Zero width joiner, used to glue several emoji into one grapheme. const ZERO_WIDTH_JOINER: char = '\u{200D}'; /// Variation selector 15 (text presentation). const VARIATION_SELECTOR_TEXT: char = '\u{FE0E}'; /// Variation selector 16 (emoji presentation). const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}'; /// Combining enclosing keycap, used by keycap emoji sequences. const COMBINING_KEYCAP: char = '\u{20E3}'; /// Emoji skin tone modifiers (Fitzpatrick types 1-2 to 6). const SKIN_TONE_MODIFIERS: RangeInclusive = '\u{1F3FB}'..='\u{1F3FF}'; /// Tag characters, used by subdivision flag sequences. const TAG_CHARACTERS: RangeInclusive = '\u{E0020}'..='\u{E007F}'; /// Regional indicator symbols, used in pairs by flag emoji. const REGIONAL_INDICATORS: RangeInclusive = '\u{1F1E6}'..='\u{1F1FF}'; /// Purpose: draw the whole UI for the current application state. /// Inputs: the ratatui `frame` to draw on and the read-only `app` state. /// Output: none (the frame buffer is filled). /// Side effects: writes to the frame buffer; may set the terminal cursor position. pub fn render(frame: &mut Frame, app: &App) { let area = frame.area(); match app.screen { Screen::Connecting => render_connecting(frame, area, app), Screen::Pairing => render_pairing(frame, area, app), Screen::Main => render_main(frame, area, app), } } /// Purpose: draw the "connecting" screen. /// Inputs: frame, the area to fill and the app (status text). /// Output: none. /// Side effects: writes to the frame buffer. fn render_connecting(frame: &mut Frame, area: Rect, app: &App) { let lines = vec![ Line::styled(TEXT_CONNECTING, Style::new().add_modifier(Modifier::BOLD)), Line::raw(""), Line::raw(sanitize_for_terminal(&app.status)), Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM)), ]; render_centered(frame, area, lines); } /// Purpose: draw the QR pairing screen. /// Inputs: frame, the area to fill and the app (QR payload and status). /// Output: none. /// Side effects: writes to the frame buffer. fn render_pairing(frame: &mut Frame, area: Rect, app: &App) { let max_rows = usize::from(area.height).saturating_sub(PAIRING_TEXT_ROWS); let qr_lines = qr_block_lines(app.qr_code.as_deref(), max_rows, usize::from(area.width)); let mut lines = vec![ Line::styled(TEXT_PAIRING_TITLE, Style::new().add_modifier(Modifier::BOLD)), Line::raw(""), ]; lines.extend(qr_lines); lines.push(Line::raw("")); lines.push(Line::raw(TEXT_PAIRING_STEPS)); lines.push(Line::raw(sanitize_for_terminal(&app.status))); lines.push(Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM))); render_centered(frame, area, lines); } /// Purpose: render centered text lines in the middle of an area. /// Inputs: frame, the area and the lines to show. /// Output: none. /// Side effects: writes to the frame buffer. fn render_centered(frame: &mut Frame, area: Rect, lines: Vec>) { let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); let [_, middle, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(height), Constraint::Fill(1), ]) .areas(area); frame.render_widget( Paragraph::new(Text::from(lines)).alignment(Alignment::Center), middle, ); } /// Purpose: build the lines showing the QR code, or a message explaining why it is missing. /// Inputs: the QR `payload` (if any) and the rows/columns available for the QR. /// Output: the QR rows, or a single informative line (waiting, too small, encoding error). /// Side effects: none. fn qr_block_lines(payload: Option<&str>, max_rows: usize, max_cols: usize) -> Vec> { let Some(payload) = payload else { return vec![Line::raw(TEXT_QR_WAITING)]; }; match qr_to_text(payload) { Err(error) => vec![Line::raw(format!("{TEXT_QR_ERROR}: {error}"))], Ok(text) => { let cols = text.lines().next().map_or(0, |row| row.chars().count()); if text.lines().count() > max_rows || cols > max_cols { return vec![Line::raw(TEXT_QR_TOO_SMALL)]; } text.lines().map(|row| Line::raw(row.to_string())).collect() } } } /// Purpose: encode a payload as a QR code drawn with half-block characters. /// Inputs: the `payload` to encode. /// Output: the multi-line text (with quiet zone), or the encoding error. Dark modules are /// drawn as empty cells and light modules as filled blocks so it scans on a dark terminal. /// Side effects: none. pub fn qr_to_text(payload: &str) -> Result { let code = QrCode::new(payload.as_bytes())?; Ok(code .render::() .dark_color(Dense1x2::Light) .light_color(Dense1x2::Dark) .quiet_zone(true) .build()) } /// Purpose: draw the main screen (list pane, messages, input, footer). /// Inputs: frame, the area to fill and the app. /// Output: none. /// Side effects: writes to the frame buffer; may set the cursor position. fn render_main(frame: &mut Frame, area: Rect, app: &App) { let [body, footer] = Layout::vertical([Constraint::Fill(1), Constraint::Length(FOOTER_HEIGHT)]).areas(area); let [list, right] = Layout::horizontal([ Constraint::Percentage(LIST_PANE_PERCENT), Constraint::Fill(1), ]) .areas(body); let [messages, input] = Layout::vertical([Constraint::Fill(1), Constraint::Length(INPUT_PANE_HEIGHT)]).areas(right); render_list_pane(frame, list, app); render_messages_pane(frame, messages, app); render_input_pane(frame, input, app); render_footer(frame, footer, app); } /// Purpose: build a bordered block whose border is highlighted when focused. /// Inputs: the `title` and whether the pane has the focus. /// Output: the block. /// Side effects: none. fn pane_block(title: String, focused: bool) -> Block<'static> { let border = if focused { Style::new().fg(FOCUS_COLOR) } else { Style::new() }; Block::bordered().title(title).border_style(border) } /// Purpose: draw the chats/contacts list with the selection highlighted. /// Inputs: frame, the pane area and the app. /// Output: none. /// Side effects: writes to the frame buffer. fn render_list_pane(frame: &mut Frame, area: Rect, app: &App) { let (title, items): (String, Vec) = match app.list_mode { ListMode::Chats => ( list_title(TITLE_CHATS, app.global_autoreply, &app.filter), app.visible_chats().into_iter().map(chat_item).collect(), ), ListMode::Contacts => ( list_title(TITLE_CONTACTS, false, &app.filter), app.visible_contacts().into_iter().map(contact_item).collect(), ), }; let len = items.len(); let list = List::new(items) .block(pane_block(title, app.focus == Focus::List)) .highlight_style(Style::new().add_modifier(Modifier::REVERSED)) .highlight_symbol(HIGHLIGHT_SYMBOL); let mut state = ListState::default().with_selected(selected_index(len, app.selected)); frame.render_stateful_widget(list, area, &mut state); } /// Purpose: build the list pane title, appending the global autoreply marker and the search text. /// Inputs: the `base` title, whether the `global_autoreply` is enabled and the active `filter`. /// Output: `base`, followed by the global marker when enabled, followed by the filter label and /// text when `filter` is non-empty. /// Side effects: none. fn list_title(base: &str, global_autoreply: bool, filter: &str) -> String { let mut title = base.to_string(); if global_autoreply { title.push_str(AI_GLOBAL_MARKER); } if !filter.is_empty() { title.push_str(&format!("{FILTER_LABEL}{} ", sanitize_for_terminal(filter))); } title } /// Purpose: compute the row to highlight in a list. /// Inputs: the list length `len` and the requested `selected` row. /// Output: `selected` clamped to the last row, or `None` if the list is empty. /// Side effects: none. fn selected_index(len: usize, selected: usize) -> Option { len.checked_sub(1).map(|last| selected.min(last)) } /// Purpose: build the list row of a chat (name plus AI marker when autoreply is on). /// Inputs: the chat. /// Output: the list item. /// Side effects: none. fn chat_item(chat: &ChatSummary) -> ListItem<'static> { let mut spans = vec![Span::raw(sanitize_for_terminal(&chat.name))]; if chat.autoreply { spans.push(Span::styled(AI_ROW_MARKER, Style::new().fg(AI_MARKER_COLOR))); } ListItem::new(Line::from(spans)) } /// Purpose: build the list row of a contact. /// Inputs: the contact. /// Output: the list item. /// Side effects: none. fn contact_item(contact: &Contact) -> ListItem<'static> { ListItem::new(Line::raw(sanitize_for_terminal(&contact.name))) } /// Purpose: build the title of the messages pane. /// Inputs: the `open_chat` identifier (if any), its display `name` and its `autoreply` state. /// Output: chat name with "AI: ON/OFF", or "Nessuna chat aperta" when no chat is open. /// Side effects: none. fn messages_title(open_chat: Option<&str>, name: &str, autoreply: bool) -> String { if open_chat.is_none() { return TITLE_NO_CHAT.to_string(); } let state = if autoreply { AI_ON } else { AI_OFF }; let name = sanitize_for_terminal(name); format!(" {name} {TITLE_SEPARATOR} {AI_LABEL}: {state} ") } /// Purpose: draw the messages of the open chat, keeping the latest lines visible. /// Inputs: frame, the pane area and the app. /// Output: none. /// Side effects: writes to the frame buffer. fn render_messages_pane(frame: &mut Frame, area: Rect, app: &App) { let autoreply = app.open_chat_autoreply || app.global_autoreply; let title = messages_title(app.open_chat.as_deref(), &app.open_chat_name, autoreply); let block = pane_block(title, false); let inner = block.inner(area); let lines = visible_message_lines(&app.messages, inner.width, inner.height); frame.render_widget(Paragraph::new(Text::from(lines)).block(block), area); } /// Purpose: wrap all messages to a width and keep only the last rows that fit. /// Inputs: the messages (oldest first) and the `width`/`height` of the text area in cells. /// Output: at most `height` lines, being the most recent ones. /// Side effects: none. fn visible_message_lines(messages: &[StoredMessage], width: u16, height: u16) -> Vec> { let mut lines: Vec> = messages .iter() .flat_map(|message| message_lines(message, usize::from(width))) .collect(); let excess = lines.len().saturating_sub(usize::from(height)); lines.drain(..excess); lines } /// Purpose: format, wrap and style a single message. /// Inputs: the message and the wrap `width` in cells. /// Output: the styled lines of the message; the text is sanitized by `wrap_text`, which cleans /// each paragraph after splitting on newlines so that explicit line breaks survive. /// Side effects: none. fn message_lines(message: &StoredMessage, width: usize) -> Vec> { let style = message_style(message); wrap_text(&format_message_line(message), width) .into_iter() .map(|row| Line::styled(row, style)) .collect() } /// Purpose: pick the text style of a message. /// Inputs: the message. /// Output: cyan for AI messages, green for own messages, default otherwise. /// Side effects: none. fn message_style(message: &StoredMessage) -> Style { match (message.from_me, message.is_ai) { (true, true) => Style::new().fg(AI_MESSAGE_COLOR), (true, false) => Style::new().fg(OWN_MESSAGE_COLOR), (false, _) => Style::new(), } } /// Purpose: format a message as "HH:MM : text" using the local time zone. /// Inputs: the message. /// Output: the single-line (before wrapping) text. /// Side effects: reads the system time zone. pub fn format_message_line(message: &StoredMessage) -> String { format_message_line_in(message, &Local) } /// Purpose: format a message as "HH:MM : text" in a given time zone. /// Inputs: the message and the time `zone` used for the clock. /// Output: the text; sender is "Tu" (prefixed by "[AI] " for AI messages) for own messages, /// else the sender name, else the user part of the sender jid. /// Side effects: none. fn format_message_line_in(message: &StoredMessage, zone: &Tz) -> String where Tz::Offset: Display, { let time = format_clock_time(message.timestamp, zone); format!("{time} {}: {}", sender_label(message), message.text) } /// Purpose: format a unix timestamp as a clock time. /// Inputs: `timestamp` in seconds and the time `zone`. /// Output: "HH:MM", or "--:--" if the timestamp is out of range. /// Side effects: none. fn format_clock_time(timestamp: i64, zone: &Tz) -> String where Tz::Offset: Display, { DateTime::from_timestamp(timestamp, 0).map_or_else( || CLOCK_PLACEHOLDER.to_string(), |utc| utc.with_timezone(zone).format(CLOCK_FORMAT).to_string(), ) } /// Purpose: choose the sender label of a message. /// Inputs: the message. /// Output: "Tu" / "[AI] Tu" for own messages, else the non-blank sender name, else the user /// part of the sender jid. /// Side effects: none. fn sender_label(message: &StoredMessage) -> String { if message.from_me { let prefix = if message.is_ai { AI_PREFIX } else { "" }; return format!("{prefix}{SENDER_ME}"); } message .sender_name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) .unwrap_or_else(|| jid_user_part(&message.sender_jid)) .to_string() } /// Purpose: extract the user part of a jid (text before `@`). /// Inputs: `jid`. /// Output: the user part, or the whole jid if it has no separator. /// Side effects: none. fn jid_user_part(jid: &str) -> &str { jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user) } /// Purpose: measure the display width of a string in terminal cells, exactly as the ratatui /// renderer does (sum of the cell width of each grapheme). /// Inputs: the text. /// Output: the width in cells. /// Side effects: none. fn text_width(text: &str) -> usize { text.graphemes(true).map(|grapheme| usize::from(grapheme.cell_width())).sum() } /// Purpose: tell whether a character takes part in composing emoji graphemes (joiners, /// variation selectors, skin tones, keycaps, tags, regional indicators). /// Inputs: the character. /// Output: `true` if the character is an emoji composition character. /// Side effects: none. fn is_emoji_composition(ch: char) -> bool { matches!( ch, ZERO_WIDTH_JOINER | VARIATION_SELECTOR_TEXT | VARIATION_SELECTOR_EMOJI | COMBINING_KEYCAP ) || SKIN_TONE_MODIFIERS.contains(&ch) || TAG_CHARACTERS.contains(&ch) || REGIONAL_INDICATORS.contains(&ch) } /// Purpose: simplify a single grapheme to what terminals draw with a predictable width. /// Inputs: one extended grapheme cluster. /// Output: `None` for graphemes with control characters or starting with an emoji composition /// character (regional indicators, joiners, selectors, skin tones, keycaps, tags: dropping them /// stops them from re-attaching to the previous grapheme after a re-segmentation); the first /// character only for composed emoji; the grapheme unchanged otherwise. No emitted grapheme /// starts with a composition character. /// Side effects: none. fn sanitize_grapheme(grapheme: &str) -> Option<&str> { let first = grapheme.chars().next()?; if grapheme.chars().any(char::is_control) || is_emoji_composition(first) { return None; } let is_composed_emoji = grapheme.chars().count() > 1 && grapheme.chars().any(is_emoji_composition); Some(if is_composed_emoji { &grapheme[..first.len_utf8()] } else { grapheme }) } /// Purpose: make externally sourced text safe to draw: Windows consoles and `unicode-width` /// disagree on the width of composed emoji, which leaves stale cells on screen. /// Inputs: the `text` to clean. /// Output: the text without control characters, flags and graphemes starting with a composition /// character (lone joiners, selectors, skin tones, keycaps, tags, regional indicators), with each /// composed emoji reduced to its first character. Idempotent: sanitizing the output is a no-op. /// Side effects: none. fn sanitize_for_terminal(text: &str) -> String { text.graphemes(true).filter_map(sanitize_grapheme).collect() } /// Purpose: wrap text to a width, honoring explicit newlines. /// Inputs: the `text` and the maximum `width` in cells (at least 1 is used). /// Output: the rows; control characters are dropped and emoji are simplified. /// Side effects: none. fn wrap_text(text: &str, width: usize) -> Vec { let width = width.max(1); let mut rows = Vec::new(); for paragraph in text.split('\n') { wrap_paragraph(paragraph, width, &mut rows); } rows } /// Purpose: wrap a single paragraph (no newlines) by graphemes, never splitting an emoji. /// Inputs: the `paragraph`, the `width` in cells and the output `rows` to append to. /// Output: none. /// Side effects: appends at least one row to `rows`. fn wrap_paragraph(paragraph: &str, width: usize, rows: &mut Vec) { let clean = sanitize_for_terminal(paragraph); let mut row = String::new(); let mut used = 0; for grapheme in clean.graphemes(true) { let grapheme_width = text_width(grapheme); if used + grapheme_width > width && !row.is_empty() { rows.push(std::mem::take(&mut row)); used = 0; } row.push_str(grapheme); used += grapheme_width; } rows.push(row); } /// Purpose: draw the input box and place the cursor when the input is focused. /// Inputs: frame, the pane area and the app. /// Output: none. /// Side effects: writes to the frame buffer; sets the cursor position if focus is Input. fn render_input_pane(frame: &mut Frame, area: Rect, app: &App) { let focused = app.focus == Focus::Input; let block = pane_block(TITLE_INPUT.to_string(), focused); let inner = block.inner(area); let typed = sanitize_for_terminal(&format!("{INPUT_PROMPT}{}", app.input)); let visible = tail_fitting(&typed, usize::from(inner.width).saturating_sub(1)); let cursor_offset = u16::try_from(text_width(visible)).unwrap_or(u16::MAX); frame.render_widget(Paragraph::new(visible).block(block), area); if focused { frame.set_cursor_position((inner.x.saturating_add(cursor_offset), inner.y)); } } /// Purpose: keep the end of a text that fits a width (horizontal scroll of the input). /// Inputs: the `text` and the `max_width` in cells. /// Output: the longest suffix of whole graphemes whose width is at most `max_width`. /// Side effects: none. fn tail_fitting(text: &str, max_width: usize) -> &str { let mut used = 0; let mut start = text.len(); for (index, grapheme) in text.grapheme_indices(true).rev() { used += text_width(grapheme); if used > max_width { break; } start = index; } &text[start..] } /// Purpose: build the key help spans, highlighting the global autoreply shortcut when it is on. /// Inputs: whether the autoreply is on for every chat. /// Output: the spans before, of and after the global shortcut, in this order. /// Side effects: none. fn key_help_spans(global_autoreply: bool) -> Vec> { let dim = Style::new().add_modifier(Modifier::DIM); let global_style = if global_autoreply { Style::new().fg(AI_MARKER_COLOR) } else { dim }; vec![ Span::styled(KEY_HELP_BEFORE_GLOBAL, dim), Span::styled(KEY_HELP_GLOBAL, global_style), Span::styled(KEY_HELP_AFTER_GLOBAL, dim), ] } /// Purpose: draw the bottom line with the status and the key help on the left and the build /// version on the right. /// Inputs: frame, the footer area and the app. /// Output: none. /// Side effects: writes to the frame buffer. fn render_footer(frame: &mut Frame, area: Rect, app: &App) { let version = version_label(); let version_width = u16::try_from(version.chars().count()).unwrap_or(u16::MAX); let [left, right] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(version_width)]).areas(area); let mut spans = vec![ Span::raw(FOOTER_MARGIN), Span::styled(sanitize_for_terminal(&app.status), Style::new().add_modifier(Modifier::BOLD)), Span::raw(FOOTER_GAP), ]; spans.extend(key_help_spans(app.global_autoreply)); frame.render_widget(Paragraph::new(Line::from(spans)), left); let dim = Style::new().add_modifier(Modifier::DIM); frame.render_widget(Paragraph::new(Span::styled(version, dim)), right); } /// Purpose: build the build-version text shown at the bottom right. /// Inputs: none. /// Output: `VERSION_PREFIX`, `BUILD_VERSION` and `FOOTER_MARGIN`. /// Side effects: none. fn version_label() -> String { format!("{VERSION_PREFIX}{}{FOOTER_MARGIN}", crate::BUILD_VERSION) } #[cfg(test)] mod tests { use chrono::{FixedOffset, Utc}; use super::*; /// Timestamp of 2024-01-01 10:02:00 UTC. const SAMPLE_TIMESTAMP: i64 = 1_704_103_320; /// Offset of UTC+1 in seconds. const PLUS_ONE_HOUR: i32 = 3600; /// A plausible WhatsApp pairing payload. const SAMPLE_QR_PAYLOAD: &str = "2@abcdEFGH1234,xyz+/=,pubkey==,adv=="; /// Builds an incoming message with the sample timestamp. fn incoming(text: &str) -> StoredMessage { StoredMessage { message_id: "m1".to_string(), chat_jid: "393331111111@s.whatsapp.net".to_string(), sender_jid: "393331111111@s.whatsapp.net".to_string(), sender_name: Some("Mario".to_string()), text: text.to_string(), timestamp: SAMPLE_TIMESTAMP, from_me: false, is_ai: false, } } #[test] fn key_help_spans_highlights_global_shortcut_only_when_on() { let on = key_help_spans(true); let off = key_help_spans(false); let text: String = on.iter().map(|span| span.content.as_ref()).collect(); assert_eq!( text, format!("{KEY_HELP_BEFORE_GLOBAL}{KEY_HELP_GLOBAL}{KEY_HELP_AFTER_GLOBAL}") ); assert!(text.contains("F10:esci")); assert!(text.contains("F3:AI chat")); assert!(text.contains("F4:AI tutte")); assert!(!text.contains("Ctrl")); let global_on = on.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap(); assert_eq!(global_on.style.fg, Some(AI_MARKER_COLOR)); let global_off = off.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap(); assert_eq!(global_off.style.fg, None); assert!(global_off.style.add_modifier.contains(Modifier::DIM)); } #[test] fn version_label_shows_build_version_with_prefix_and_margin() { assert_eq!(version_label(), format!("v{} ", crate::BUILD_VERSION)); } #[test] fn incoming_message_uses_sender_name() { let line = format_message_line_in(&incoming("ciao"), &Utc); assert_eq!(line, "10:02 Mario: ciao"); } #[test] fn sender_name_falls_back_to_jid_user_part() { let mut message = incoming("ciao"); message.sender_name = None; assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao"); message.sender_name = Some(" ".to_string()); assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao"); } #[test] fn own_and_ai_messages_are_labelled() { let mut message = incoming("ok"); message.from_me = true; assert_eq!(format_message_line_in(&message, &Utc), "10:02 Tu: ok"); message.is_ai = true; assert_eq!(format_message_line_in(&message, &Utc), "10:02 [AI] Tu: ok"); } #[test] fn clock_time_honors_zone_and_range() { let zone = FixedOffset::east_opt(PLUS_ONE_HOUR).unwrap(); assert_eq!(format_clock_time(SAMPLE_TIMESTAMP, &zone), "11:02"); assert_eq!(format_clock_time(i64::MAX, &Utc), CLOCK_PLACEHOLDER); } #[test] fn qr_text_is_not_empty_and_multiline() { let text = qr_to_text(SAMPLE_QR_PAYLOAD).unwrap(); assert!(!text.is_empty()); assert!(text.lines().count() > 1); } #[test] fn qr_block_reports_errors_and_missing_code() { let oversized = "x".repeat(10_000); let error_line = qr_block_lines(Some(&oversized), 100, 200); assert_eq!(error_line.len(), 1); assert!(error_line[0].to_string().starts_with(TEXT_QR_ERROR)); assert_eq!(qr_block_lines(None, 100, 200)[0].to_string(), TEXT_QR_WAITING); assert_eq!( qr_block_lines(Some(SAMPLE_QR_PAYLOAD), 1, 1)[0].to_string(), TEXT_QR_TOO_SMALL ); } #[test] fn wrap_splits_long_text_and_newlines() { assert_eq!(wrap_text("abcdef", 4), ["abcd", "ef"]); assert_eq!(wrap_text("ab\ncd", 10), ["ab", "cd"]); assert_eq!(wrap_text("", 10), [""]); } #[test] fn sanitize_reduces_zwj_sequences_to_the_first_emoji() { assert_eq!(sanitize_for_terminal("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"), "\u{1F468}"); } #[test] fn sanitize_drops_variation_selectors_skin_tones_and_keycaps() { assert_eq!(sanitize_for_terminal("\u{2764}\u{FE0F}"), "\u{2764}"); assert_eq!(sanitize_for_terminal("\u{1F44D}\u{1F3FD}"), "\u{1F44D}"); assert_eq!(sanitize_for_terminal("1\u{FE0F}\u{20E3}"), "1"); } #[test] fn sanitize_removes_flags_and_lone_regional_indicators() { assert_eq!(sanitize_for_terminal("a\u{1F1EE}\u{1F1F9}b"), "ab"); assert_eq!(sanitize_for_terminal("a\u{1F1EE}b"), "ab"); } #[test] fn sanitize_keeps_plain_text_and_combining_accents() { assert_eq!(sanitize_for_terminal("Ciao mondo \u{1F600}"), "Ciao mondo \u{1F600}"); assert_eq!(sanitize_for_terminal("e\u{301}"), "e\u{301}"); } #[test] fn sanitize_drops_control_characters() { assert_eq!(sanitize_for_terminal("a\tb\u{7}c"), "abc"); } #[test] fn sanitize_does_not_recompose_after_dropping_a_control_grapheme() { assert_eq!(sanitize_for_terminal("\u{1F468}\u{7}\u{200D}\u{1F469}"), "\u{1F468}\u{1F469}"); assert_eq!(sanitize_for_terminal("\u{1F44D}\r\u{1F3FD}"), "\u{1F44D}"); } #[test] fn sanitize_drops_leading_composition_characters() { assert_eq!(sanitize_for_terminal("\u{FE0F}a"), "a"); assert_eq!(sanitize_for_terminal("\u{2764}\u{FE0E}"), "\u{2764}"); assert_eq!(sanitize_for_terminal("\u{1F3F4}\u{E0067}\u{E0062}\u{E007F}"), "\u{1F3F4}"); assert_eq!(sanitize_for_terminal(""), ""); } #[test] fn sanitize_is_idempotent() { let inputs = [ "\u{1F468}\u{7}\u{200D}\u{1F469}", "\u{1F44D}\r\u{1F3FD}", "\u{FE0F}a", "\u{2764}\u{FE0E}", "\u{1F3F4}\u{E0067}\u{E0062}\u{E007F}", "", ]; for input in inputs { let once = sanitize_for_terminal(input); assert_eq!(sanitize_for_terminal(&once), once); } } #[test] fn text_width_matches_the_renderer_cell_width() { assert_eq!(text_width("\u{644}\u{627}"), 2); assert_eq!(text_width("\u{FF76}\u{FF9E}"), 2); } #[test] fn wrap_never_splits_a_wide_emoji() { assert_eq!(wrap_text("a\u{1F600}b", 2), ["a", "\u{1F600}", "b"]); assert_eq!(wrap_text("\u{1F468}\u{200D}\u{1F469}", 2), ["\u{1F468}"]); } #[test] fn tail_fitting_keeps_whole_graphemes() { assert_eq!(tail_fitting("ab\u{1F600}", 1), ""); assert_eq!(tail_fitting("ab\u{1F600}", 2), "\u{1F600}"); assert_eq!(tail_fitting("ae\u{301}", 1), "e\u{301}"); } #[test] fn visible_lines_keep_the_latest() { let messages = [incoming("one"), incoming("two"), incoming("three")]; let lines = visible_message_lines(&messages, 80, 2); assert_eq!(lines.len(), 2); assert!(lines[1].to_string().ends_with("three")); assert!(lines[0].to_string().ends_with("two")); } #[test] fn tail_fitting_keeps_the_end() { assert_eq!(tail_fitting("> hello", 3), "llo"); assert_eq!(tail_fitting("> hi", 10), "> hi"); assert_eq!(tail_fitting("abc", 0), ""); } #[test] fn messages_title_shows_name_and_ai_state() { assert_eq!(messages_title(None, "", false), TITLE_NO_CHAT); let off = messages_title(Some("a@s.whatsapp.net"), "Mario", false); assert!(off.contains("Mario")); assert!(off.contains("AI: OFF")); let on = messages_title(Some("a@s.whatsapp.net"), "Mario", true); assert!(on.contains("AI: ON")); } #[test] fn list_title_marks_global_autoreply() { let on = list_title(TITLE_CHATS, true, ""); assert!(on.starts_with(TITLE_CHATS)); assert!(on.contains(AI_GLOBAL_MARKER)); assert_eq!(list_title(TITLE_CHATS, false, ""), TITLE_CHATS); } #[test] fn list_title_shows_filter() { let filtered = list_title(TITLE_CHATS, false, "ab"); assert!(filtered.starts_with(TITLE_CHATS)); assert!(filtered.contains("Cerca: ab")); assert!(!filtered.contains(AI_GLOBAL_MARKER)); let both = list_title(TITLE_CHATS, true, "ab"); assert!(both.contains(AI_GLOBAL_MARKER)); assert!(both.contains("Cerca: ab")); } #[test] fn selected_index_is_clamped() { assert_eq!(selected_index(0, 0), None); assert_eq!(selected_index(0, 9), None); assert_eq!(selected_index(2, 0), Some(0)); assert_eq!(selected_index(2, 9), Some(1)); } }