diff --git a/docs/example_config.json b/docs/example_config.json index c26b33c..2ba9e90 100644 --- a/docs/example_config.json +++ b/docs/example_config.json @@ -10,6 +10,7 @@ }, "settings": { "log_level": "warn", + "message_shortcode_display": false, "reaction_display": true, "reaction_shortcode_display": false, "read_receipt_send": true, diff --git a/docs/iamb.5.md b/docs/iamb.5.md index e2c6e73..96043d3 100644 --- a/docs/iamb.5.md +++ b/docs/iamb.5.md @@ -53,6 +53,10 @@ overridden as described in *PROFILES*. > Specifies the lowest log level that should be shown. Possible values > are: _trace_, _debug_, _info_, _warn_, and _error_. +**message_shortcode_display** (type: boolean) +> Defines whether or not emoji characters in messages should be replaced by +> their respective shortcodes. + **reaction_display** (type: boolean) > Defines whether or not reactions should be shown. diff --git a/src/config.rs b/src/config.rs index ec634a5..6fb7fba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -469,6 +469,7 @@ impl SortOverrides { #[derive(Clone)] pub struct TunableValues { pub log_level: Level, + pub message_shortcode_display: bool, pub reaction_display: bool, pub reaction_shortcode_display: bool, pub read_receipt_send: bool, @@ -489,6 +490,7 @@ pub struct TunableValues { #[derive(Clone, Default, Deserialize)] pub struct Tunables { pub log_level: Option, + pub message_shortcode_display: Option, pub reaction_display: Option, pub reaction_shortcode_display: Option, pub read_receipt_send: Option, @@ -511,6 +513,9 @@ impl Tunables { fn merge(self, other: Self) -> Self { Tunables { log_level: self.log_level.or(other.log_level), + message_shortcode_display: self + .message_shortcode_display + .or(other.message_shortcode_display), reaction_display: self.reaction_display.or(other.reaction_display), reaction_shortcode_display: self .reaction_shortcode_display @@ -534,6 +539,7 @@ impl Tunables { fn values(self) -> TunableValues { TunableValues { log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), + message_shortcode_display: self.message_shortcode_display.unwrap_or(false), reaction_display: self.reaction_display.unwrap_or(true), reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), read_receipt_send: self.read_receipt_send.unwrap_or(true), diff --git a/src/message/html.rs b/src/message/html.rs index 0e8fd2b..6aecf9d 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -148,7 +148,7 @@ impl Table { } } - fn to_text(&self, width: usize, style: Style) -> Text { + fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { let mut text = Text::default(); let columns = self.columns(); let cell_total = width.saturating_sub(columns).saturating_sub(1); @@ -166,7 +166,8 @@ impl Table { if let Some(caption) = &self.caption { let subw = width.saturating_sub(6); - let mut printer = TextPrinter::new(subw, style, true).align(Alignment::Center); + let mut printer = + TextPrinter::new(subw, style, true, emoji_shortcodes).align(Alignment::Center); caption.print(&mut printer, style); for mut line in printer.finish().lines { @@ -213,7 +214,7 @@ impl Table { CellType::Data => style, }; - cell.to_text(*w, style) + cell.to_text(*w, style, emoji_shortcodes) } else { space_text(*w, style) }; @@ -274,8 +275,8 @@ pub enum StyleTreeNode { } impl StyleTreeNode { - pub fn to_text(&self, width: usize, style: Style) -> Text { - let mut printer = TextPrinter::new(width, style, true); + pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { + let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes); self.print(&mut printer, style); printer.finish() } @@ -428,7 +429,7 @@ impl StyleTreeNode { } }, StyleTreeNode::Table(table) => { - let text = table.to_text(width, style); + let text = table.to_text(width, style, printer.emoji_shortcodes()); printer.push_text(text); }, StyleTreeNode::Break => { @@ -464,8 +465,14 @@ impl StyleTree { return links; } - pub fn to_text(&self, width: usize, style: Style, hide_reply: bool) -> Text<'_> { - let mut printer = TextPrinter::new(width, style, hide_reply); + pub fn to_text( + &self, + width: usize, + style: Style, + hide_reply: bool, + emoji_shortcodes: bool, + ) -> Text<'_> { + let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes); for child in self.children.iter() { child.print(&mut printer, style); @@ -805,6 +812,7 @@ pub mod tests { use super::*; use crate::util::space_span; use pretty_assertions::assert_eq; + use unicode_width::UnicodeWidthStr; #[test] fn test_header() { @@ -812,7 +820,7 @@ pub mod tests { let s = "

Header 1

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled(" ", bold), @@ -824,7 +832,7 @@ pub mod tests { let s = "

Header 2

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -837,7 +845,7 @@ pub mod tests { let s = "

Header 3

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -851,7 +859,7 @@ pub mod tests { let s = "

Header 4

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -866,7 +874,7 @@ pub mod tests { let s = "
Header 5
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -882,7 +890,7 @@ pub mod tests { let s = "
Header 6
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -909,7 +917,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -918,7 +926,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -927,7 +935,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -936,7 +944,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -945,7 +953,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -954,7 +962,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -963,7 +971,7 @@ pub mod tests { let s = "Underline!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Underline", underl), Span::styled("!", underl), @@ -972,7 +980,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -981,7 +989,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false); + let text = tree.to_text(20, Style::default(), false, false); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -993,7 +1001,7 @@ pub mod tests { fn test_paragraph() { let s = "

Hello world!

Content

Goodbye world!

"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false); + let text = tree.to_text(10, Style::default(), false, false); assert_eq!(text.lines.len(), 7); assert_eq!( text.lines[0], @@ -1020,7 +1028,7 @@ pub mod tests { fn test_blockquote() { let s = "
Hello world!
"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false); + let text = tree.to_text(10, Style::default(), false, false); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1036,7 +1044,7 @@ pub mod tests { fn test_list_unordered() { let s = "
  • List Item 1
  • List Item 2
  • List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(8, Style::default(), false); + let text = tree.to_text(8, Style::default(), false, false); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1098,7 +1106,7 @@ pub mod tests { fn test_list_ordered() { let s = "
  1. List Item 1
  2. List Item 2
  3. List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(9, Style::default(), false); + let text = tree.to_text(9, Style::default(), false, false); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1168,7 +1176,7 @@ pub mod tests { abc\ "; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), false); + let text = tree.to_text(15, Style::default(), false, false); let bold = Style::default().add_modifier(StyleModifier::BOLD); assert_eq!(text.lines.len(), 11); @@ -1261,7 +1269,7 @@ pub mod tests { let s = "This was replied toThis is the reply"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false); + let text = tree.to_text(10, Style::default(), false, false); assert_eq!(text.lines.len(), 4); assert_eq!( text.lines[0], @@ -1298,7 +1306,7 @@ pub mod tests { ); let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), true); + let text = tree.to_text(10, Style::default(), true, false); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1325,7 +1333,7 @@ pub mod tests { fn test_self_closing() { let s = "Hello
World
Goodbye"; let tree = parse_matrix_html(s); - let text = tree.to_text(7, Style::default(), true); + let text = tree.to_text(7, Style::default(), true, false); assert_eq!(text.lines.len(), 3); assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),])); assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),])); @@ -1336,7 +1344,7 @@ pub mod tests { fn test_embedded_newline() { let s = "

Hello\nWorld

"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), true); + let text = tree.to_text(15, Style::default(), true, false); assert_eq!(text.lines.len(), 1); assert_eq!( text.lines[0], @@ -1359,7 +1367,7 @@ pub mod tests { "\n" ); let tree = parse_matrix_html(s); - let text = tree.to_text(25, Style::default(), true); + let text = tree.to_text(25, Style::default(), true, false); assert_eq!(text.lines.len(), 5); assert_eq!( text.lines[0], @@ -1420,4 +1428,28 @@ pub mod tests { ]) ); } + + #[test] + fn test_emoji_shortcodes() { + for shortcode in ["exploding_head", "polar_bear", "canada"] { + let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str(); + let emoji_width = UnicodeWidthStr::width(emoji); + let replacement = format!(":{shortcode}:"); + let replacement_width = UnicodeWidthStr::width(replacement.as_str()); + let s = format!("

{emoji}

"); + let tree = parse_matrix_html(s.as_str()); + // Test with emojis_shortcodes set to false + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::raw(emoji), + space_span(20 - emoji_width, Style::default()), + ]),]); + // Test with emojis_shortcodes set to true + let text = tree.to_text(20, Style::default(), false, true); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::raw(replacement.as_str()), + space_span(20 - replacement_width, Style::default()), + ])]); + } + } } diff --git a/src/message/mod.rs b/src/message/mod.rs index c7632c1..c17dbce 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -61,7 +61,7 @@ use crate::{ base::RoomInfo, config::ApplicationSettings, message::html::{parse_matrix_html, StyleTree}, - util::{space, space_span, take_width, wrapped_text}, + util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, }; mod html; @@ -834,7 +834,8 @@ impl Message { if let Some(r) = &reply { let w = width.saturating_sub(2); - let mut replied = r.show_msg(w, style, true); + let mut replied = + r.show_msg(w, style, true, settings.tunables.message_shortcode_display); let mut sender = r.sender_span(info, settings); let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); let trailing = w.saturating_sub(sender_width + 1); @@ -862,7 +863,12 @@ impl Message { } // Now show the message contents, and the inlined reply if we couldn't find it above. - let msg = self.show_msg(width, style, reply.is_some()); + let msg = self.show_msg( + width, + style, + reply.is_some(), + settings.tunables.message_shortcode_display, + ); fmt.push_text(msg, style, &mut text); if text.lines.is_empty() { @@ -871,7 +877,9 @@ impl Message { } if settings.tunables.reaction_display { - let mut emojis = printer::TextPrinter::new(width, style, false); + // Pass false for emoji_shortcodes parameter because we handle shortcodes ourselves + // before pushing to the printer. + let mut emojis = printer::TextPrinter::new(width, style, false, false); let mut reactions = 0; for (key, count) in info.get_reactions(self.event.event_id()).into_iter() { @@ -917,7 +925,9 @@ impl Message { if len > 0 { let style = Style::default(); - let mut threaded = printer::TextPrinter::new(width, style, false).literal(true); + let emoji_shortcodes = settings.tunables.message_shortcode_display; + let mut threaded = + printer::TextPrinter::new(width, style, false, emoji_shortcodes).literal(true); let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD)); threaded.push_str(" \u{2937} ", style); threaded.push_span_nobreak(len); @@ -929,11 +939,20 @@ impl Message { text } - pub fn show_msg(&self, width: usize, style: Style, hide_reply: bool) -> Text { + pub fn show_msg( + &self, + width: usize, + style: Style, + hide_reply: bool, + emoji_shortcodes: bool, + ) -> Text { if let Some(html) = &self.html { - html.to_text(width, style, hide_reply) + html.to_text(width, style, hide_reply, emoji_shortcodes) } else { let mut msg = self.event.body(); + if emoji_shortcodes { + msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); + } if self.downloaded { msg.to_mut().push_str(" \u{2705}"); diff --git a/src/message/printer.rs b/src/message/printer.rs index 71c9981..e981609 100644 --- a/src/message/printer.rs +++ b/src/message/printer.rs @@ -11,7 +11,13 @@ use ratatui::text::{Line, Span, Text}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::util::{space_span, take_width}; +use crate::util::{ + replace_emojis_in_line, + replace_emojis_in_span, + replace_emojis_in_str, + space_span, + take_width, +}; /// Wrap styled text for the current terminal width. pub struct TextPrinter<'a> { @@ -19,6 +25,7 @@ pub struct TextPrinter<'a> { width: usize, base_style: Style, hide_reply: bool, + emoji_shortcodes: bool, alignment: Alignment, curr_spans: Vec>, @@ -28,12 +35,13 @@ pub struct TextPrinter<'a> { impl<'a> TextPrinter<'a> { /// Create a new printer. - pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self { + pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self { TextPrinter { text: Text::default(), width, base_style, hide_reply, + emoji_shortcodes, alignment: Alignment::Left, curr_spans: vec![], @@ -59,6 +67,11 @@ impl<'a> TextPrinter<'a> { self.hide_reply } + /// Indicates whether emojis should be replaced by shortcodes + pub fn emoji_shortcodes(&self) -> bool { + self.emoji_shortcodes + } + /// Indicates the current printer's width. pub fn width(&self) -> usize { self.width @@ -71,6 +84,7 @@ impl<'a> TextPrinter<'a> { width: self.width.saturating_sub(indent), base_style: self.base_style, hide_reply: self.hide_reply, + emoji_shortcodes: self.emoji_shortcodes, alignment: self.alignment, curr_spans: vec![], @@ -164,7 +178,10 @@ impl<'a> TextPrinter<'a> { } /// Push a [Span] that isn't allowed to break across lines. - pub fn push_span_nobreak(&mut self, span: Span<'a>) { + pub fn push_span_nobreak(&mut self, mut span: Span<'a>) { + if self.emoji_shortcodes { + replace_emojis_in_span(&mut span); + } let sw = UnicodeWidthStr::width(span.content.as_ref()); if self.curr_width + sw > self.width { @@ -200,10 +217,15 @@ impl<'a> TextPrinter<'a> { continue; } - let sw = UnicodeWidthStr::width(word); + let cow = if self.emoji_shortcodes { + Cow::Owned(replace_emojis_in_str(word)) + } else { + Cow::Borrowed(word) + }; + let sw = UnicodeWidthStr::width(cow.as_ref()); if sw > self.width { - self.push_str_wrapped(word, style); + self.push_str_wrapped(cow, style); continue; } @@ -211,13 +233,13 @@ impl<'a> TextPrinter<'a> { // Word doesn't fit on this line, so start a new one. self.commit(); - if !self.literal && word.chars().all(char::is_whitespace) { + if !self.literal && cow.chars().all(char::is_whitespace) { // Drop leading whitespace. continue; } } - let span = Span::styled(word, style); + let span = Span::styled(cow, style); self.curr_spans.push(span); self.curr_width += sw; } @@ -229,14 +251,22 @@ impl<'a> TextPrinter<'a> { } /// Push a [Line] into the printer. - pub fn push_line(&mut self, line: Line<'a>) { + pub fn push_line(&mut self, mut line: Line<'a>) { self.commit(); + if self.emoji_shortcodes { + replace_emojis_in_line(&mut line); + } self.text.lines.push(line); } /// Push multiline [Text] into the printer. - pub fn push_text(&mut self, text: Text<'a>) { + pub fn push_text(&mut self, mut text: Text<'a>) { self.commit(); + if self.emoji_shortcodes { + for line in &mut text.lines { + replace_emojis_in_line(line); + } + } self.text.lines.extend(text.lines); } diff --git a/src/tests.rs b/src/tests.rs index 2a14ad3..08802ed 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -183,6 +183,7 @@ pub fn mock_tunables() -> TunableValues { TunableValues { default_room: None, log_level: Level::INFO, + message_shortcode_display: false, reaction_display: true, reaction_shortcode_display: false, read_receipt_send: true, diff --git a/src/util.rs b/src/util.rs index 9b0f96c..a11aedc 100644 --- a/src/util.rs +++ b/src/util.rs @@ -147,6 +147,28 @@ pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: text } +fn replace_emoji_in_grapheme(grapheme: &str) -> String { + emojis::get(grapheme) + .and_then(|emoji| emoji.shortcode()) + .map(|shortcode| format!(":{shortcode}:")) + .unwrap_or_else(|| grapheme.to_owned()) +} + +pub fn replace_emojis_in_str(s: &str) -> String { + let graphemes = s.graphemes(true); + graphemes.map(replace_emoji_in_grapheme).collect() +} + +pub fn replace_emojis_in_span(span: &mut Span) { + span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref())) +} + +pub fn replace_emojis_in_line(line: &mut Line) { + for span in &mut line.spans { + replace_emojis_in_span(span); + } +} + #[cfg(test)] pub mod tests { use super::*;