ui.rs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. //! ratatui rendering of the whole application (connecting, pairing and main screens).
  2. //!
  3. //! Rendering is a pure function of `&App`: nothing here mutates application state.
  4. use std::fmt::Display;
  5. use std::ops::RangeInclusive;
  6. use chrono::{DateTime, Local, TimeZone};
  7. use qrcode::QrCode;
  8. use qrcode::render::unicode::Dense1x2;
  9. use qrcode::types::QrError;
  10. use ratatui::Frame;
  11. use ratatui::buffer::CellWidth;
  12. use ratatui::layout::{Alignment, Constraint, Layout, Rect};
  13. use ratatui::style::{Color, Modifier, Style};
  14. use ratatui::text::{Line, Span, Text};
  15. use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph};
  16. use unicode_segmentation::UnicodeSegmentation;
  17. use crate::app::{App, Focus, ListMode, Screen};
  18. use crate::history::{ChatSummary, Contact, StoredMessage};
  19. /// Width of the chat/contact list pane, as a percentage of the terminal width.
  20. const LIST_PANE_PERCENT: u16 = 30;
  21. /// Height of the input box (two border rows plus one text row).
  22. const INPUT_PANE_HEIGHT: u16 = 3;
  23. /// Height of the bottom status/help line.
  24. const FOOTER_HEIGHT: u16 = 1;
  25. /// Title of the list pane while showing chats.
  26. const TITLE_CHATS: &str = " Chat [F2: Contatti] ";
  27. /// Title of the list pane while showing contacts.
  28. const TITLE_CONTACTS: &str = " Contatti [F2: Chat] ";
  29. /// Title of the messages pane when no chat is open.
  30. const TITLE_NO_CHAT: &str = " Nessuna chat aperta ";
  31. /// Title of the input pane.
  32. const TITLE_INPUT: &str = " Messaggio ";
  33. /// Separator between the chat name and the AI state in the messages pane title.
  34. const TITLE_SEPARATOR: &str = "──";
  35. /// Label of the autoreply state in the messages pane title.
  36. const AI_LABEL: &str = "AI";
  37. /// Autoreply enabled value shown in the messages pane title.
  38. const AI_ON: &str = "ON";
  39. /// Autoreply disabled value shown in the messages pane title.
  40. const AI_OFF: &str = "OFF";
  41. /// Marker appended to chat rows whose autoreply is on.
  42. const AI_ROW_MARKER: &str = " ●AI";
  43. /// Marker appended to the chats list title when autoreply is on for every chat.
  44. const AI_GLOBAL_MARKER: &str = "●AI tutte ";
  45. /// Label shown in the list pane title in front of the active search text.
  46. const FILTER_LABEL: &str = "Cerca: ";
  47. /// Symbol drawn in front of the selected list row.
  48. const HIGHLIGHT_SYMBOL: &str = "> ";
  49. /// Prompt shown in front of the text being typed.
  50. const INPUT_PROMPT: &str = "> ";
  51. /// Key help shown before the global autoreply shortcut.
  52. const KEY_HELP_BEFORE_GLOBAL: &str =
  53. "Tab:focus Enter:apri/invia F2:chat/contatti Canc:elimina F3:AI chat ";
  54. /// Key help of the global autoreply shortcut, highlighted when it is on.
  55. const KEY_HELP_GLOBAL: &str = "F4:AI tutte";
  56. /// Key help shown after the global autoreply shortcut.
  57. const KEY_HELP_AFTER_GLOBAL: &str = " F8:nuovo QR F10:esci";
  58. /// Gap between the status text and the key help.
  59. const FOOTER_GAP: &str = " ";
  60. /// Leading space of the footer line (also trailing space after the build version).
  61. const FOOTER_MARGIN: &str = " ";
  62. /// Text shown in front of the build version at the bottom right.
  63. const VERSION_PREFIX: &str = "v";
  64. /// Text of the connecting screen.
  65. const TEXT_CONNECTING: &str = "Connessione a WhatsApp in corso...";
  66. /// Title of the pairing screen.
  67. const TEXT_PAIRING_TITLE: &str = "Collega WhatsApp: scansiona il codice QR";
  68. /// Pairing instructions.
  69. const TEXT_PAIRING_STEPS: &str =
  70. "WhatsApp > Impostazioni > Dispositivi collegati > Collega un dispositivo";
  71. /// Shown on the pairing screen while no QR code has been received yet.
  72. const TEXT_QR_WAITING: &str = "In attesa del codice QR...";
  73. /// Shown instead of the QR code when the terminal is too small to display it.
  74. const TEXT_QR_TOO_SMALL: &str = "Terminale troppo piccolo per il QR: ingrandisci la finestra";
  75. /// Prefix of the QR encoding error message.
  76. const TEXT_QR_ERROR: &str = "Errore generazione QR";
  77. /// Key help of the connecting and pairing screens.
  78. const TEXT_SCREEN_KEYS: &str = "F8: nuovo QR F10: esci";
  79. /// Terminal rows used by the pairing screen besides the QR (title, 2 blanks, steps, status, keys).
  80. const PAIRING_TEXT_ROWS: usize = 6;
  81. /// Sender label of messages sent from this account.
  82. const SENDER_ME: &str = "Tu";
  83. /// Prefix of messages generated by the AI.
  84. const AI_PREFIX: &str = "[AI] ";
  85. /// Separator between the user part and the server part of a jid.
  86. const JID_SEPARATOR: char = '@';
  87. /// `chrono` format of the message time.
  88. const CLOCK_FORMAT: &str = "%H:%M";
  89. /// Time shown when a timestamp is out of range.
  90. const CLOCK_PLACEHOLDER: &str = "--:--";
  91. /// Border color of the focused pane.
  92. const FOCUS_COLOR: Color = Color::Yellow;
  93. /// Color of the autoreply marker.
  94. const AI_MARKER_COLOR: Color = Color::Green;
  95. /// Color of messages written by the AI.
  96. const AI_MESSAGE_COLOR: Color = Color::Cyan;
  97. /// Color of messages sent by the user.
  98. const OWN_MESSAGE_COLOR: Color = Color::Green;
  99. /// Zero width joiner, used to glue several emoji into one grapheme.
  100. const ZERO_WIDTH_JOINER: char = '\u{200D}';
  101. /// Variation selector 15 (text presentation).
  102. const VARIATION_SELECTOR_TEXT: char = '\u{FE0E}';
  103. /// Variation selector 16 (emoji presentation).
  104. const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}';
  105. /// Combining enclosing keycap, used by keycap emoji sequences.
  106. const COMBINING_KEYCAP: char = '\u{20E3}';
  107. /// Emoji skin tone modifiers (Fitzpatrick types 1-2 to 6).
  108. const SKIN_TONE_MODIFIERS: RangeInclusive<char> = '\u{1F3FB}'..='\u{1F3FF}';
  109. /// Tag characters, used by subdivision flag sequences.
  110. const TAG_CHARACTERS: RangeInclusive<char> = '\u{E0020}'..='\u{E007F}';
  111. /// Regional indicator symbols, used in pairs by flag emoji.
  112. const REGIONAL_INDICATORS: RangeInclusive<char> = '\u{1F1E6}'..='\u{1F1FF}';
  113. /// Purpose: draw the whole UI for the current application state.
  114. /// Inputs: the ratatui `frame` to draw on and the read-only `app` state.
  115. /// Output: none (the frame buffer is filled).
  116. /// Side effects: writes to the frame buffer; may set the terminal cursor position.
  117. pub fn render(frame: &mut Frame, app: &App) {
  118. let area = frame.area();
  119. match app.screen {
  120. Screen::Connecting => render_connecting(frame, area, app),
  121. Screen::Pairing => render_pairing(frame, area, app),
  122. Screen::Main => render_main(frame, area, app),
  123. }
  124. }
  125. /// Purpose: draw the "connecting" screen.
  126. /// Inputs: frame, the area to fill and the app (status text).
  127. /// Output: none.
  128. /// Side effects: writes to the frame buffer.
  129. fn render_connecting(frame: &mut Frame, area: Rect, app: &App) {
  130. let lines = vec![
  131. Line::styled(TEXT_CONNECTING, Style::new().add_modifier(Modifier::BOLD)),
  132. Line::raw(""),
  133. Line::raw(sanitize_for_terminal(&app.status)),
  134. Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM)),
  135. ];
  136. render_centered(frame, area, lines);
  137. }
  138. /// Purpose: draw the QR pairing screen.
  139. /// Inputs: frame, the area to fill and the app (QR payload and status).
  140. /// Output: none.
  141. /// Side effects: writes to the frame buffer.
  142. fn render_pairing(frame: &mut Frame, area: Rect, app: &App) {
  143. let max_rows = usize::from(area.height).saturating_sub(PAIRING_TEXT_ROWS);
  144. let qr_lines = qr_block_lines(app.qr_code.as_deref(), max_rows, usize::from(area.width));
  145. let mut lines = vec![
  146. Line::styled(TEXT_PAIRING_TITLE, Style::new().add_modifier(Modifier::BOLD)),
  147. Line::raw(""),
  148. ];
  149. lines.extend(qr_lines);
  150. lines.push(Line::raw(""));
  151. lines.push(Line::raw(TEXT_PAIRING_STEPS));
  152. lines.push(Line::raw(sanitize_for_terminal(&app.status)));
  153. lines.push(Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM)));
  154. render_centered(frame, area, lines);
  155. }
  156. /// Purpose: render centered text lines in the middle of an area.
  157. /// Inputs: frame, the area and the lines to show.
  158. /// Output: none.
  159. /// Side effects: writes to the frame buffer.
  160. fn render_centered(frame: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
  161. let height = u16::try_from(lines.len()).unwrap_or(u16::MAX);
  162. let [_, middle, _] = Layout::vertical([
  163. Constraint::Fill(1),
  164. Constraint::Length(height),
  165. Constraint::Fill(1),
  166. ])
  167. .areas(area);
  168. frame.render_widget(
  169. Paragraph::new(Text::from(lines)).alignment(Alignment::Center),
  170. middle,
  171. );
  172. }
  173. /// Purpose: build the lines showing the QR code, or a message explaining why it is missing.
  174. /// Inputs: the QR `payload` (if any) and the rows/columns available for the QR.
  175. /// Output: the QR rows, or a single informative line (waiting, too small, encoding error).
  176. /// Side effects: none.
  177. fn qr_block_lines(payload: Option<&str>, max_rows: usize, max_cols: usize) -> Vec<Line<'static>> {
  178. let Some(payload) = payload else {
  179. return vec![Line::raw(TEXT_QR_WAITING)];
  180. };
  181. match qr_to_text(payload) {
  182. Err(error) => vec![Line::raw(format!("{TEXT_QR_ERROR}: {error}"))],
  183. Ok(text) => {
  184. let cols = text.lines().next().map_or(0, |row| row.chars().count());
  185. if text.lines().count() > max_rows || cols > max_cols {
  186. return vec![Line::raw(TEXT_QR_TOO_SMALL)];
  187. }
  188. text.lines().map(|row| Line::raw(row.to_string())).collect()
  189. }
  190. }
  191. }
  192. /// Purpose: encode a payload as a QR code drawn with half-block characters.
  193. /// Inputs: the `payload` to encode.
  194. /// Output: the multi-line text (with quiet zone), or the encoding error. Dark modules are
  195. /// drawn as empty cells and light modules as filled blocks so it scans on a dark terminal.
  196. /// Side effects: none.
  197. pub fn qr_to_text(payload: &str) -> Result<String, QrError> {
  198. let code = QrCode::new(payload.as_bytes())?;
  199. Ok(code
  200. .render::<Dense1x2>()
  201. .dark_color(Dense1x2::Light)
  202. .light_color(Dense1x2::Dark)
  203. .quiet_zone(true)
  204. .build())
  205. }
  206. /// Purpose: draw the main screen (list pane, messages, input, footer).
  207. /// Inputs: frame, the area to fill and the app.
  208. /// Output: none.
  209. /// Side effects: writes to the frame buffer; may set the cursor position.
  210. fn render_main(frame: &mut Frame, area: Rect, app: &App) {
  211. let [body, footer] =
  212. Layout::vertical([Constraint::Fill(1), Constraint::Length(FOOTER_HEIGHT)]).areas(area);
  213. let [list, right] = Layout::horizontal([
  214. Constraint::Percentage(LIST_PANE_PERCENT),
  215. Constraint::Fill(1),
  216. ])
  217. .areas(body);
  218. let [messages, input] =
  219. Layout::vertical([Constraint::Fill(1), Constraint::Length(INPUT_PANE_HEIGHT)]).areas(right);
  220. render_list_pane(frame, list, app);
  221. render_messages_pane(frame, messages, app);
  222. render_input_pane(frame, input, app);
  223. render_footer(frame, footer, app);
  224. }
  225. /// Purpose: build a bordered block whose border is highlighted when focused.
  226. /// Inputs: the `title` and whether the pane has the focus.
  227. /// Output: the block.
  228. /// Side effects: none.
  229. fn pane_block(title: String, focused: bool) -> Block<'static> {
  230. let border = if focused {
  231. Style::new().fg(FOCUS_COLOR)
  232. } else {
  233. Style::new()
  234. };
  235. Block::bordered().title(title).border_style(border)
  236. }
  237. /// Purpose: draw the chats/contacts list with the selection highlighted.
  238. /// Inputs: frame, the pane area and the app.
  239. /// Output: none.
  240. /// Side effects: writes to the frame buffer.
  241. fn render_list_pane(frame: &mut Frame, area: Rect, app: &App) {
  242. let (title, items): (String, Vec<ListItem>) = match app.list_mode {
  243. ListMode::Chats => (
  244. list_title(TITLE_CHATS, app.global_autoreply, &app.filter),
  245. app.visible_chats().into_iter().map(chat_item).collect(),
  246. ),
  247. ListMode::Contacts => (
  248. list_title(TITLE_CONTACTS, false, &app.filter),
  249. app.visible_contacts().into_iter().map(contact_item).collect(),
  250. ),
  251. };
  252. let len = items.len();
  253. let list = List::new(items)
  254. .block(pane_block(title, app.focus == Focus::List))
  255. .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
  256. .highlight_symbol(HIGHLIGHT_SYMBOL);
  257. let mut state = ListState::default().with_selected(selected_index(len, app.selected));
  258. frame.render_stateful_widget(list, area, &mut state);
  259. }
  260. /// Purpose: build the list pane title, appending the global autoreply marker and the search text.
  261. /// Inputs: the `base` title, whether the `global_autoreply` is enabled and the active `filter`.
  262. /// Output: `base`, followed by the global marker when enabled, followed by the filter label and
  263. /// text when `filter` is non-empty.
  264. /// Side effects: none.
  265. fn list_title(base: &str, global_autoreply: bool, filter: &str) -> String {
  266. let mut title = base.to_string();
  267. if global_autoreply {
  268. title.push_str(AI_GLOBAL_MARKER);
  269. }
  270. if !filter.is_empty() {
  271. title.push_str(&format!("{FILTER_LABEL}{} ", sanitize_for_terminal(filter)));
  272. }
  273. title
  274. }
  275. /// Purpose: compute the row to highlight in a list.
  276. /// Inputs: the list length `len` and the requested `selected` row.
  277. /// Output: `selected` clamped to the last row, or `None` if the list is empty.
  278. /// Side effects: none.
  279. fn selected_index(len: usize, selected: usize) -> Option<usize> {
  280. len.checked_sub(1).map(|last| selected.min(last))
  281. }
  282. /// Purpose: build the list row of a chat (name plus AI marker when autoreply is on).
  283. /// Inputs: the chat.
  284. /// Output: the list item.
  285. /// Side effects: none.
  286. fn chat_item(chat: &ChatSummary) -> ListItem<'static> {
  287. let mut spans = vec![Span::raw(sanitize_for_terminal(&chat.name))];
  288. if chat.autoreply {
  289. spans.push(Span::styled(AI_ROW_MARKER, Style::new().fg(AI_MARKER_COLOR)));
  290. }
  291. ListItem::new(Line::from(spans))
  292. }
  293. /// Purpose: build the list row of a contact.
  294. /// Inputs: the contact.
  295. /// Output: the list item.
  296. /// Side effects: none.
  297. fn contact_item(contact: &Contact) -> ListItem<'static> {
  298. ListItem::new(Line::raw(sanitize_for_terminal(&contact.name)))
  299. }
  300. /// Purpose: build the title of the messages pane.
  301. /// Inputs: the `open_chat` identifier (if any), its display `name` and its `autoreply` state.
  302. /// Output: chat name with "AI: ON/OFF", or "Nessuna chat aperta" when no chat is open.
  303. /// Side effects: none.
  304. fn messages_title(open_chat: Option<&str>, name: &str, autoreply: bool) -> String {
  305. if open_chat.is_none() {
  306. return TITLE_NO_CHAT.to_string();
  307. }
  308. let state = if autoreply { AI_ON } else { AI_OFF };
  309. let name = sanitize_for_terminal(name);
  310. format!(" {name} {TITLE_SEPARATOR} {AI_LABEL}: {state} ")
  311. }
  312. /// Purpose: draw the messages of the open chat, keeping the latest lines visible.
  313. /// Inputs: frame, the pane area and the app.
  314. /// Output: none.
  315. /// Side effects: writes to the frame buffer.
  316. fn render_messages_pane(frame: &mut Frame, area: Rect, app: &App) {
  317. let autoreply = app.open_chat_autoreply || app.global_autoreply;
  318. let title = messages_title(app.open_chat.as_deref(), &app.open_chat_name, autoreply);
  319. let block = pane_block(title, false);
  320. let inner = block.inner(area);
  321. let lines = visible_message_lines(&app.messages, inner.width, inner.height);
  322. frame.render_widget(Paragraph::new(Text::from(lines)).block(block), area);
  323. }
  324. /// Purpose: wrap all messages to a width and keep only the last rows that fit.
  325. /// Inputs: the messages (oldest first) and the `width`/`height` of the text area in cells.
  326. /// Output: at most `height` lines, being the most recent ones.
  327. /// Side effects: none.
  328. fn visible_message_lines(messages: &[StoredMessage], width: u16, height: u16) -> Vec<Line<'static>> {
  329. let mut lines: Vec<Line<'static>> = messages
  330. .iter()
  331. .flat_map(|message| message_lines(message, usize::from(width)))
  332. .collect();
  333. let excess = lines.len().saturating_sub(usize::from(height));
  334. lines.drain(..excess);
  335. lines
  336. }
  337. /// Purpose: format, wrap and style a single message.
  338. /// Inputs: the message and the wrap `width` in cells.
  339. /// Output: the styled lines of the message; the text is sanitized by `wrap_text`, which cleans
  340. /// each paragraph after splitting on newlines so that explicit line breaks survive.
  341. /// Side effects: none.
  342. fn message_lines(message: &StoredMessage, width: usize) -> Vec<Line<'static>> {
  343. let style = message_style(message);
  344. wrap_text(&format_message_line(message), width)
  345. .into_iter()
  346. .map(|row| Line::styled(row, style))
  347. .collect()
  348. }
  349. /// Purpose: pick the text style of a message.
  350. /// Inputs: the message.
  351. /// Output: cyan for AI messages, green for own messages, default otherwise.
  352. /// Side effects: none.
  353. fn message_style(message: &StoredMessage) -> Style {
  354. match (message.from_me, message.is_ai) {
  355. (true, true) => Style::new().fg(AI_MESSAGE_COLOR),
  356. (true, false) => Style::new().fg(OWN_MESSAGE_COLOR),
  357. (false, _) => Style::new(),
  358. }
  359. }
  360. /// Purpose: format a message as "HH:MM <sender>: text" using the local time zone.
  361. /// Inputs: the message.
  362. /// Output: the single-line (before wrapping) text.
  363. /// Side effects: reads the system time zone.
  364. pub fn format_message_line(message: &StoredMessage) -> String {
  365. format_message_line_in(message, &Local)
  366. }
  367. /// Purpose: format a message as "HH:MM <sender>: text" in a given time zone.
  368. /// Inputs: the message and the time `zone` used for the clock.
  369. /// Output: the text; sender is "Tu" (prefixed by "[AI] " for AI messages) for own messages,
  370. /// else the sender name, else the user part of the sender jid.
  371. /// Side effects: none.
  372. fn format_message_line_in<Tz: TimeZone>(message: &StoredMessage, zone: &Tz) -> String
  373. where
  374. Tz::Offset: Display,
  375. {
  376. let time = format_clock_time(message.timestamp, zone);
  377. format!("{time} {}: {}", sender_label(message), message.text)
  378. }
  379. /// Purpose: format a unix timestamp as a clock time.
  380. /// Inputs: `timestamp` in seconds and the time `zone`.
  381. /// Output: "HH:MM", or "--:--" if the timestamp is out of range.
  382. /// Side effects: none.
  383. fn format_clock_time<Tz: TimeZone>(timestamp: i64, zone: &Tz) -> String
  384. where
  385. Tz::Offset: Display,
  386. {
  387. DateTime::from_timestamp(timestamp, 0).map_or_else(
  388. || CLOCK_PLACEHOLDER.to_string(),
  389. |utc| utc.with_timezone(zone).format(CLOCK_FORMAT).to_string(),
  390. )
  391. }
  392. /// Purpose: choose the sender label of a message.
  393. /// Inputs: the message.
  394. /// Output: "Tu" / "[AI] Tu" for own messages, else the non-blank sender name, else the user
  395. /// part of the sender jid.
  396. /// Side effects: none.
  397. fn sender_label(message: &StoredMessage) -> String {
  398. if message.from_me {
  399. let prefix = if message.is_ai { AI_PREFIX } else { "" };
  400. return format!("{prefix}{SENDER_ME}");
  401. }
  402. message
  403. .sender_name
  404. .as_deref()
  405. .map(str::trim)
  406. .filter(|name| !name.is_empty())
  407. .unwrap_or_else(|| jid_user_part(&message.sender_jid))
  408. .to_string()
  409. }
  410. /// Purpose: extract the user part of a jid (text before `@`).
  411. /// Inputs: `jid`.
  412. /// Output: the user part, or the whole jid if it has no separator.
  413. /// Side effects: none.
  414. fn jid_user_part(jid: &str) -> &str {
  415. jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
  416. }
  417. /// Purpose: measure the display width of a string in terminal cells, exactly as the ratatui
  418. /// renderer does (sum of the cell width of each grapheme).
  419. /// Inputs: the text.
  420. /// Output: the width in cells.
  421. /// Side effects: none.
  422. fn text_width(text: &str) -> usize {
  423. text.graphemes(true).map(|grapheme| usize::from(grapheme.cell_width())).sum()
  424. }
  425. /// Purpose: tell whether a character takes part in composing emoji graphemes (joiners,
  426. /// variation selectors, skin tones, keycaps, tags, regional indicators).
  427. /// Inputs: the character.
  428. /// Output: `true` if the character is an emoji composition character.
  429. /// Side effects: none.
  430. fn is_emoji_composition(ch: char) -> bool {
  431. matches!(
  432. ch,
  433. ZERO_WIDTH_JOINER | VARIATION_SELECTOR_TEXT | VARIATION_SELECTOR_EMOJI | COMBINING_KEYCAP
  434. ) || SKIN_TONE_MODIFIERS.contains(&ch)
  435. || TAG_CHARACTERS.contains(&ch)
  436. || REGIONAL_INDICATORS.contains(&ch)
  437. }
  438. /// Purpose: simplify a single grapheme to what terminals draw with a predictable width.
  439. /// Inputs: one extended grapheme cluster.
  440. /// Output: `None` for graphemes with control characters or starting with an emoji composition
  441. /// character (regional indicators, joiners, selectors, skin tones, keycaps, tags: dropping them
  442. /// stops them from re-attaching to the previous grapheme after a re-segmentation); the first
  443. /// character only for composed emoji; the grapheme unchanged otherwise. No emitted grapheme
  444. /// starts with a composition character.
  445. /// Side effects: none.
  446. fn sanitize_grapheme(grapheme: &str) -> Option<&str> {
  447. let first = grapheme.chars().next()?;
  448. if grapheme.chars().any(char::is_control) || is_emoji_composition(first) {
  449. return None;
  450. }
  451. let is_composed_emoji = grapheme.chars().count() > 1 && grapheme.chars().any(is_emoji_composition);
  452. Some(if is_composed_emoji { &grapheme[..first.len_utf8()] } else { grapheme })
  453. }
  454. /// Purpose: make externally sourced text safe to draw: Windows consoles and `unicode-width`
  455. /// disagree on the width of composed emoji, which leaves stale cells on screen.
  456. /// Inputs: the `text` to clean.
  457. /// Output: the text without control characters, flags and graphemes starting with a composition
  458. /// character (lone joiners, selectors, skin tones, keycaps, tags, regional indicators), with each
  459. /// composed emoji reduced to its first character. Idempotent: sanitizing the output is a no-op.
  460. /// Side effects: none.
  461. fn sanitize_for_terminal(text: &str) -> String {
  462. text.graphemes(true).filter_map(sanitize_grapheme).collect()
  463. }
  464. /// Purpose: wrap text to a width, honoring explicit newlines.
  465. /// Inputs: the `text` and the maximum `width` in cells (at least 1 is used).
  466. /// Output: the rows; control characters are dropped and emoji are simplified.
  467. /// Side effects: none.
  468. fn wrap_text(text: &str, width: usize) -> Vec<String> {
  469. let width = width.max(1);
  470. let mut rows = Vec::new();
  471. for paragraph in text.split('\n') {
  472. wrap_paragraph(paragraph, width, &mut rows);
  473. }
  474. rows
  475. }
  476. /// Purpose: wrap a single paragraph (no newlines) by graphemes, never splitting an emoji.
  477. /// Inputs: the `paragraph`, the `width` in cells and the output `rows` to append to.
  478. /// Output: none.
  479. /// Side effects: appends at least one row to `rows`.
  480. fn wrap_paragraph(paragraph: &str, width: usize, rows: &mut Vec<String>) {
  481. let clean = sanitize_for_terminal(paragraph);
  482. let mut row = String::new();
  483. let mut used = 0;
  484. for grapheme in clean.graphemes(true) {
  485. let grapheme_width = text_width(grapheme);
  486. if used + grapheme_width > width && !row.is_empty() {
  487. rows.push(std::mem::take(&mut row));
  488. used = 0;
  489. }
  490. row.push_str(grapheme);
  491. used += grapheme_width;
  492. }
  493. rows.push(row);
  494. }
  495. /// Purpose: draw the input box and place the cursor when the input is focused.
  496. /// Inputs: frame, the pane area and the app.
  497. /// Output: none.
  498. /// Side effects: writes to the frame buffer; sets the cursor position if focus is Input.
  499. fn render_input_pane(frame: &mut Frame, area: Rect, app: &App) {
  500. let focused = app.focus == Focus::Input;
  501. let block = pane_block(TITLE_INPUT.to_string(), focused);
  502. let inner = block.inner(area);
  503. let typed = sanitize_for_terminal(&format!("{INPUT_PROMPT}{}", app.input));
  504. let visible = tail_fitting(&typed, usize::from(inner.width).saturating_sub(1));
  505. let cursor_offset = u16::try_from(text_width(visible)).unwrap_or(u16::MAX);
  506. frame.render_widget(Paragraph::new(visible).block(block), area);
  507. if focused {
  508. frame.set_cursor_position((inner.x.saturating_add(cursor_offset), inner.y));
  509. }
  510. }
  511. /// Purpose: keep the end of a text that fits a width (horizontal scroll of the input).
  512. /// Inputs: the `text` and the `max_width` in cells.
  513. /// Output: the longest suffix of whole graphemes whose width is at most `max_width`.
  514. /// Side effects: none.
  515. fn tail_fitting(text: &str, max_width: usize) -> &str {
  516. let mut used = 0;
  517. let mut start = text.len();
  518. for (index, grapheme) in text.grapheme_indices(true).rev() {
  519. used += text_width(grapheme);
  520. if used > max_width {
  521. break;
  522. }
  523. start = index;
  524. }
  525. &text[start..]
  526. }
  527. /// Purpose: build the key help spans, highlighting the global autoreply shortcut when it is on.
  528. /// Inputs: whether the autoreply is on for every chat.
  529. /// Output: the spans before, of and after the global shortcut, in this order.
  530. /// Side effects: none.
  531. fn key_help_spans(global_autoreply: bool) -> Vec<Span<'static>> {
  532. let dim = Style::new().add_modifier(Modifier::DIM);
  533. let global_style = if global_autoreply {
  534. Style::new().fg(AI_MARKER_COLOR)
  535. } else {
  536. dim
  537. };
  538. vec![
  539. Span::styled(KEY_HELP_BEFORE_GLOBAL, dim),
  540. Span::styled(KEY_HELP_GLOBAL, global_style),
  541. Span::styled(KEY_HELP_AFTER_GLOBAL, dim),
  542. ]
  543. }
  544. /// Purpose: draw the bottom line with the status and the key help on the left and the build
  545. /// version on the right.
  546. /// Inputs: frame, the footer area and the app.
  547. /// Output: none.
  548. /// Side effects: writes to the frame buffer.
  549. fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
  550. let version = version_label();
  551. let version_width = u16::try_from(version.chars().count()).unwrap_or(u16::MAX);
  552. let [left, right] =
  553. Layout::horizontal([Constraint::Fill(1), Constraint::Length(version_width)]).areas(area);
  554. let mut spans = vec![
  555. Span::raw(FOOTER_MARGIN),
  556. Span::styled(sanitize_for_terminal(&app.status), Style::new().add_modifier(Modifier::BOLD)),
  557. Span::raw(FOOTER_GAP),
  558. ];
  559. spans.extend(key_help_spans(app.global_autoreply));
  560. frame.render_widget(Paragraph::new(Line::from(spans)), left);
  561. let dim = Style::new().add_modifier(Modifier::DIM);
  562. frame.render_widget(Paragraph::new(Span::styled(version, dim)), right);
  563. }
  564. /// Purpose: build the build-version text shown at the bottom right.
  565. /// Inputs: none.
  566. /// Output: `VERSION_PREFIX`, `BUILD_VERSION` and `FOOTER_MARGIN`.
  567. /// Side effects: none.
  568. fn version_label() -> String {
  569. format!("{VERSION_PREFIX}{}{FOOTER_MARGIN}", crate::BUILD_VERSION)
  570. }
  571. #[cfg(test)]
  572. mod tests {
  573. use chrono::{FixedOffset, Utc};
  574. use super::*;
  575. /// Timestamp of 2024-01-01 10:02:00 UTC.
  576. const SAMPLE_TIMESTAMP: i64 = 1_704_103_320;
  577. /// Offset of UTC+1 in seconds.
  578. const PLUS_ONE_HOUR: i32 = 3600;
  579. /// A plausible WhatsApp pairing payload.
  580. const SAMPLE_QR_PAYLOAD: &str = "2@abcdEFGH1234,xyz+/=,pubkey==,adv==";
  581. /// Builds an incoming message with the sample timestamp.
  582. fn incoming(text: &str) -> StoredMessage {
  583. StoredMessage {
  584. message_id: "m1".to_string(),
  585. chat_jid: "393331111111@s.whatsapp.net".to_string(),
  586. sender_jid: "393331111111@s.whatsapp.net".to_string(),
  587. sender_name: Some("Mario".to_string()),
  588. text: text.to_string(),
  589. timestamp: SAMPLE_TIMESTAMP,
  590. from_me: false,
  591. is_ai: false,
  592. }
  593. }
  594. #[test]
  595. fn key_help_spans_highlights_global_shortcut_only_when_on() {
  596. let on = key_help_spans(true);
  597. let off = key_help_spans(false);
  598. let text: String = on.iter().map(|span| span.content.as_ref()).collect();
  599. assert_eq!(
  600. text,
  601. format!("{KEY_HELP_BEFORE_GLOBAL}{KEY_HELP_GLOBAL}{KEY_HELP_AFTER_GLOBAL}")
  602. );
  603. assert!(text.contains("F10:esci"));
  604. assert!(text.contains("F3:AI chat"));
  605. assert!(text.contains("F4:AI tutte"));
  606. assert!(!text.contains("Ctrl"));
  607. let global_on = on.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap();
  608. assert_eq!(global_on.style.fg, Some(AI_MARKER_COLOR));
  609. let global_off = off.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap();
  610. assert_eq!(global_off.style.fg, None);
  611. assert!(global_off.style.add_modifier.contains(Modifier::DIM));
  612. }
  613. #[test]
  614. fn version_label_shows_build_version_with_prefix_and_margin() {
  615. assert_eq!(version_label(), format!("v{} ", crate::BUILD_VERSION));
  616. }
  617. #[test]
  618. fn incoming_message_uses_sender_name() {
  619. let line = format_message_line_in(&incoming("ciao"), &Utc);
  620. assert_eq!(line, "10:02 Mario: ciao");
  621. }
  622. #[test]
  623. fn sender_name_falls_back_to_jid_user_part() {
  624. let mut message = incoming("ciao");
  625. message.sender_name = None;
  626. assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao");
  627. message.sender_name = Some(" ".to_string());
  628. assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao");
  629. }
  630. #[test]
  631. fn own_and_ai_messages_are_labelled() {
  632. let mut message = incoming("ok");
  633. message.from_me = true;
  634. assert_eq!(format_message_line_in(&message, &Utc), "10:02 Tu: ok");
  635. message.is_ai = true;
  636. assert_eq!(format_message_line_in(&message, &Utc), "10:02 [AI] Tu: ok");
  637. }
  638. #[test]
  639. fn clock_time_honors_zone_and_range() {
  640. let zone = FixedOffset::east_opt(PLUS_ONE_HOUR).unwrap();
  641. assert_eq!(format_clock_time(SAMPLE_TIMESTAMP, &zone), "11:02");
  642. assert_eq!(format_clock_time(i64::MAX, &Utc), CLOCK_PLACEHOLDER);
  643. }
  644. #[test]
  645. fn qr_text_is_not_empty_and_multiline() {
  646. let text = qr_to_text(SAMPLE_QR_PAYLOAD).unwrap();
  647. assert!(!text.is_empty());
  648. assert!(text.lines().count() > 1);
  649. }
  650. #[test]
  651. fn qr_block_reports_errors_and_missing_code() {
  652. let oversized = "x".repeat(10_000);
  653. let error_line = qr_block_lines(Some(&oversized), 100, 200);
  654. assert_eq!(error_line.len(), 1);
  655. assert!(error_line[0].to_string().starts_with(TEXT_QR_ERROR));
  656. assert_eq!(qr_block_lines(None, 100, 200)[0].to_string(), TEXT_QR_WAITING);
  657. assert_eq!(
  658. qr_block_lines(Some(SAMPLE_QR_PAYLOAD), 1, 1)[0].to_string(),
  659. TEXT_QR_TOO_SMALL
  660. );
  661. }
  662. #[test]
  663. fn wrap_splits_long_text_and_newlines() {
  664. assert_eq!(wrap_text("abcdef", 4), ["abcd", "ef"]);
  665. assert_eq!(wrap_text("ab\ncd", 10), ["ab", "cd"]);
  666. assert_eq!(wrap_text("", 10), [""]);
  667. }
  668. #[test]
  669. fn sanitize_reduces_zwj_sequences_to_the_first_emoji() {
  670. assert_eq!(sanitize_for_terminal("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"), "\u{1F468}");
  671. }
  672. #[test]
  673. fn sanitize_drops_variation_selectors_skin_tones_and_keycaps() {
  674. assert_eq!(sanitize_for_terminal("\u{2764}\u{FE0F}"), "\u{2764}");
  675. assert_eq!(sanitize_for_terminal("\u{1F44D}\u{1F3FD}"), "\u{1F44D}");
  676. assert_eq!(sanitize_for_terminal("1\u{FE0F}\u{20E3}"), "1");
  677. }
  678. #[test]
  679. fn sanitize_removes_flags_and_lone_regional_indicators() {
  680. assert_eq!(sanitize_for_terminal("a\u{1F1EE}\u{1F1F9}b"), "ab");
  681. assert_eq!(sanitize_for_terminal("a\u{1F1EE}b"), "ab");
  682. }
  683. #[test]
  684. fn sanitize_keeps_plain_text_and_combining_accents() {
  685. assert_eq!(sanitize_for_terminal("Ciao mondo \u{1F600}"), "Ciao mondo \u{1F600}");
  686. assert_eq!(sanitize_for_terminal("e\u{301}"), "e\u{301}");
  687. }
  688. #[test]
  689. fn sanitize_drops_control_characters() {
  690. assert_eq!(sanitize_for_terminal("a\tb\u{7}c"), "abc");
  691. }
  692. #[test]
  693. fn sanitize_does_not_recompose_after_dropping_a_control_grapheme() {
  694. assert_eq!(sanitize_for_terminal("\u{1F468}\u{7}\u{200D}\u{1F469}"), "\u{1F468}\u{1F469}");
  695. assert_eq!(sanitize_for_terminal("\u{1F44D}\r\u{1F3FD}"), "\u{1F44D}");
  696. }
  697. #[test]
  698. fn sanitize_drops_leading_composition_characters() {
  699. assert_eq!(sanitize_for_terminal("\u{FE0F}a"), "a");
  700. assert_eq!(sanitize_for_terminal("\u{2764}\u{FE0E}"), "\u{2764}");
  701. assert_eq!(sanitize_for_terminal("\u{1F3F4}\u{E0067}\u{E0062}\u{E007F}"), "\u{1F3F4}");
  702. assert_eq!(sanitize_for_terminal(""), "");
  703. }
  704. #[test]
  705. fn sanitize_is_idempotent() {
  706. let inputs = [
  707. "\u{1F468}\u{7}\u{200D}\u{1F469}",
  708. "\u{1F44D}\r\u{1F3FD}",
  709. "\u{FE0F}a",
  710. "\u{2764}\u{FE0E}",
  711. "\u{1F3F4}\u{E0067}\u{E0062}\u{E007F}",
  712. "",
  713. ];
  714. for input in inputs {
  715. let once = sanitize_for_terminal(input);
  716. assert_eq!(sanitize_for_terminal(&once), once);
  717. }
  718. }
  719. #[test]
  720. fn text_width_matches_the_renderer_cell_width() {
  721. assert_eq!(text_width("\u{644}\u{627}"), 2);
  722. assert_eq!(text_width("\u{FF76}\u{FF9E}"), 2);
  723. }
  724. #[test]
  725. fn wrap_never_splits_a_wide_emoji() {
  726. assert_eq!(wrap_text("a\u{1F600}b", 2), ["a", "\u{1F600}", "b"]);
  727. assert_eq!(wrap_text("\u{1F468}\u{200D}\u{1F469}", 2), ["\u{1F468}"]);
  728. }
  729. #[test]
  730. fn tail_fitting_keeps_whole_graphemes() {
  731. assert_eq!(tail_fitting("ab\u{1F600}", 1), "");
  732. assert_eq!(tail_fitting("ab\u{1F600}", 2), "\u{1F600}");
  733. assert_eq!(tail_fitting("ae\u{301}", 1), "e\u{301}");
  734. }
  735. #[test]
  736. fn visible_lines_keep_the_latest() {
  737. let messages = [incoming("one"), incoming("two"), incoming("three")];
  738. let lines = visible_message_lines(&messages, 80, 2);
  739. assert_eq!(lines.len(), 2);
  740. assert!(lines[1].to_string().ends_with("three"));
  741. assert!(lines[0].to_string().ends_with("two"));
  742. }
  743. #[test]
  744. fn tail_fitting_keeps_the_end() {
  745. assert_eq!(tail_fitting("> hello", 3), "llo");
  746. assert_eq!(tail_fitting("> hi", 10), "> hi");
  747. assert_eq!(tail_fitting("abc", 0), "");
  748. }
  749. #[test]
  750. fn messages_title_shows_name_and_ai_state() {
  751. assert_eq!(messages_title(None, "", false), TITLE_NO_CHAT);
  752. let off = messages_title(Some("a@s.whatsapp.net"), "Mario", false);
  753. assert!(off.contains("Mario"));
  754. assert!(off.contains("AI: OFF"));
  755. let on = messages_title(Some("a@s.whatsapp.net"), "Mario", true);
  756. assert!(on.contains("AI: ON"));
  757. }
  758. #[test]
  759. fn list_title_marks_global_autoreply() {
  760. let on = list_title(TITLE_CHATS, true, "");
  761. assert!(on.starts_with(TITLE_CHATS));
  762. assert!(on.contains(AI_GLOBAL_MARKER));
  763. assert_eq!(list_title(TITLE_CHATS, false, ""), TITLE_CHATS);
  764. }
  765. #[test]
  766. fn list_title_shows_filter() {
  767. let filtered = list_title(TITLE_CHATS, false, "ab");
  768. assert!(filtered.starts_with(TITLE_CHATS));
  769. assert!(filtered.contains("Cerca: ab"));
  770. assert!(!filtered.contains(AI_GLOBAL_MARKER));
  771. let both = list_title(TITLE_CHATS, true, "ab");
  772. assert!(both.contains(AI_GLOBAL_MARKER));
  773. assert!(both.contains("Cerca: ab"));
  774. }
  775. #[test]
  776. fn selected_index_is_clamped() {
  777. assert_eq!(selected_index(0, 0), None);
  778. assert_eq!(selected_index(0, 9), None);
  779. assert_eq!(selected_index(2, 0), Some(0));
  780. assert_eq!(selected_index(2, 9), Some(1));
  781. }
  782. }