| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890 |
- //! 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<char> = '\u{1F3FB}'..='\u{1F3FF}';
- /// Tag characters, used by subdivision flag sequences.
- const TAG_CHARACTERS: RangeInclusive<char> = '\u{E0020}'..='\u{E007F}';
- /// Regional indicator symbols, used in pairs by flag emoji.
- const REGIONAL_INDICATORS: RangeInclusive<char> = '\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<Line<'static>>) {
- 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<Line<'static>> {
- 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<String, QrError> {
- let code = QrCode::new(payload.as_bytes())?;
- Ok(code
- .render::<Dense1x2>()
- .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<ListItem>) = 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<usize> {
- 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<Line<'static>> {
- let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
- 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 <sender>: 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 <sender>: 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<Tz: TimeZone>(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<Tz: TimeZone>(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<String> {
- 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<String>) {
- 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<Span<'static>> {
- 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));
- }
- }
|