diff --git a/src/session_view/room_history/message_row/text/inline_html.rs b/src/session_view/room_history/message_row/text/inline_html.rs index 8e97f491..f305b75e 100644 --- a/src/session_view/room_history/message_row/text/inline_html.rs +++ b/src/session_view/room_history/message_row/text/inline_html.rs @@ -23,6 +23,8 @@ pub(super) struct InlineHtmlBuilder<'a> { single_line: bool, /// Whether to append an ellipsis at the end of the string. ellipsis: bool, + /// Whether whitespace should be preserved. + preserve_whitespace: bool, /// The mentions detection setting and results. mentions: MentionsMode<'a>, /// The inner string. @@ -39,12 +41,16 @@ impl<'a> InlineHtmlBuilder<'a> { /// If `single_line` is set to `true`, the string will be ellipsized at the /// first line break. /// - /// If `ellipsis` is set to `true`, and ellipsis will be added at the end of + /// If `ellipsis` is set to `true`, an ellipsis will be added at the end of /// the string. - pub(super) fn new(single_line: bool, ellipsis: bool) -> Self { + /// + /// If `preserve_whitespace` is set to `true`, all whitespace will be + /// preserved, otherwise it will be collapsed according to the HTML spec. + pub(super) fn new(single_line: bool, ellipsis: bool, preserve_whitespace: bool) -> Self { Self { single_line, ellipsis, + preserve_whitespace, mentions: MentionsMode::default(), inner: String::new(), truncated: false, @@ -82,7 +88,7 @@ impl<'a> InlineHtmlBuilder<'a> { if ellipsis { inner.append_ellipsis(); - } else { + } else if !self.preserve_whitespace { inner.truncate_end_whitespaces(); } @@ -207,8 +213,11 @@ impl<'a> InlineHtmlBuilder<'a> { if self.single_line { self.truncated = true; } else { - // Remove whitespaces before the newline. - self.inner.truncate_end_whitespaces(); + if !self.preserve_whitespace { + // Remove whitespaces before the newline. + self.inner.truncate_end_whitespaces(); + } + self.inner.push('\n'); } } @@ -226,10 +235,14 @@ impl<'a> InlineHtmlBuilder<'a> { fn append_text_node(&mut self, text: &str, context: NodeContext) { // Collapse whitespaces and remove them at the beginning and end of an HTML // element, and after a newline. - let text = text.collapse_whitespaces( - context.is_first_child || self.inner.ends_with('\n'), - context.is_last_child, - ); + let text = if self.preserve_whitespace { + text.to_owned() + } else { + text.collapse_whitespaces( + context.is_first_child || self.inner.ends_with('\n'), + context.is_last_child, + ) + }; if context.should_linkify { if let MentionsMode::WithMentions { diff --git a/src/session_view/room_history/message_row/text/mod.rs b/src/session_view/room_history/message_row/text/mod.rs index 7b13b289..94dc0a1d 100644 --- a/src/session_view/room_history/message_row/text/mod.rs +++ b/src/session_view/room_history/message_row/text/mod.rs @@ -322,6 +322,7 @@ mod imp { room, detect_at_room, ellipsize, + is_preformatted: false, }, false, &mut sender_name, diff --git a/src/session_view/room_history/message_row/text/tests.rs b/src/session_view/room_history/message_row/text/tests.rs index f14d34f2..4016f14f 100644 --- a/src/session_view/room_history/message_row/text/tests.rs +++ b/src/session_view/room_history/message_row/text/tests.rs @@ -5,7 +5,7 @@ use super::inline_html::InlineHtmlBuilder; #[test] fn text_with_no_markup() { let html = Html::parse("A simple text"); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!(s, "A simple text"); assert!(pills.is_none()); @@ -14,13 +14,13 @@ fn text_with_no_markup() { #[test] fn single_line() { let html = Html::parse("A simple text
on several lines"); - let (s, pills) = InlineHtmlBuilder::new(true, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(true, false, false).build_with_nodes(html.children()); assert_eq!(s, "A simple text…"); assert!(pills.is_none()); let html = Html::parse("\nThis is a paragraph
\n\nThis is another paragraph\n"); - let (s, pills) = InlineHtmlBuilder::new(true, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(true, false, false).build_with_nodes(html.children()); assert_eq!(s, "This is a paragraph…"); assert!(pills.is_none()); @@ -29,7 +29,7 @@ fn single_line() { #[test] fn add_ellipsis() { let html = Html::parse("A simple text"); - let (s, pills) = InlineHtmlBuilder::new(false, true).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, true, false).build_with_nodes(html.children()); assert_eq!(s, "A simple text…"); assert!(pills.is_none()); @@ -38,7 +38,7 @@ fn add_ellipsis() { #[test] fn no_duplicate_ellipsis() { let html = Html::parse("A simple text...
...on several lines"); - let (s, pills) = InlineHtmlBuilder::new(true, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(true, false, false).build_with_nodes(html.children()); assert_eq!(s, "A simple text..."); assert!(pills.is_none()); @@ -47,7 +47,7 @@ fn no_duplicate_ellipsis() { #[test] fn trim_end_spaces() { let html = Html::parse("A high-altitude text 🗻 "); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!(s, "A high-altitude text 🗻"); assert!(pills.is_none()); @@ -55,17 +55,27 @@ fn trim_end_spaces() { #[test] fn collapse_whitespace() { - let html = Html::parse("Hello \nyou! \nYou are my \nfriend."); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let original = "Hello \nyou! \nYou are my \nfriend."; + let html = Html::parse(original); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!(s, "Hello you! You are my friend."); assert!(pills.is_none()); - let html = Html::parse(" Hello \nyou! \n\nYou are \n my \nfriend . "); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, true).build_with_nodes(html.children()); + assert_eq!(s, original); + assert!(pills.is_none()); + + let original = " Hello \nyou! \n\nYou are \n my \nfriend . "; + let html = Html::parse(original); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!(s, "Hello you! You are my friend."); assert!(pills.is_none()); + + let (s, pills) = InlineHtmlBuilder::new(false, false, true).build_with_nodes(html.children()); + assert_eq!(s, original); + assert!(pills.is_none()); } #[test] @@ -73,7 +83,7 @@ fn sanitize_inline_html() { let html = Html::parse( r#"A text with markup"#, ); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!( s, @@ -87,7 +97,7 @@ fn escape_markup() { let html = Html::parse( r#"Go to this & that docs"#, ); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!( s, @@ -101,7 +111,7 @@ fn linkify() { let html = Html::parse( "The homepage is https://gnome.org, and you can contact me at contact@me.local", ); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!( s, @@ -113,7 +123,7 @@ fn linkify() { #[test] fn do_not_linkify_inside_anchor() { let html = Html::parse(r#"The homepage is https://gnome.org"#); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!( s, @@ -125,7 +135,7 @@ fn do_not_linkify_inside_anchor() { #[test] fn do_not_linkify_inside_code() { let html = Html::parse("The homepage is https://gnome.org"); - let (s, pills) = InlineHtmlBuilder::new(false, false).build_with_nodes(html.children()); + let (s, pills) = InlineHtmlBuilder::new(false, false, false).build_with_nodes(html.children()); assert_eq!(s, "The homepage is https://gnome.org"); assert!(pills.is_none()); @@ -134,7 +144,7 @@ fn do_not_linkify_inside_code() { #[test] fn emote_name() { let html = Html::parse("sent a beautiful picture."); - let (s, pills) = InlineHtmlBuilder::new(false, false) + let (s, pills) = InlineHtmlBuilder::new(false, false, false) .append_emote_with_name(&mut Some("Jun")) .build_with_nodes(html.children()); diff --git a/src/session_view/room_history/message_row/text/widgets.rs b/src/session_view/room_history/message_row/text/widgets.rs index ff7e89cf..dcefb8ac 100644 --- a/src/session_view/room_history/message_row/text/widgets.rs +++ b/src/session_view/room_history/message_row/text/widgets.rs @@ -19,9 +19,19 @@ use crate::{ /// The immutable config fields to build a HTML widget tree. #[derive(Debug, Clone, Copy)] pub(super) struct HtmlWidgetConfig<'a> { + /// The room where the message constructed by this widget was sent. + /// + /// Used for generating mentions. pub(super) room: &'a Room, + /// Whether we should try to detect an `@room` mention in the HTML to + /// render. pub(super) detect_at_room: bool, + /// Whether to ellipsize the message. pub(super) ellipsize: bool, + /// Whether this is preformatted text. + /// + /// Whitespaces are untouched in preformatted text. + pub(super) is_preformatted: bool, } /// Construct a new label for displaying a message's content. @@ -85,7 +95,7 @@ pub(super) fn widget_for_html_nodes( // Include sender name before, if the child widget did not handle it. if let Some(sender_name) = sender_name.take() { let label = new_message_label(); - let (text, _) = InlineHtmlBuilder::new(false, false) + let (text, _) = InlineHtmlBuilder::new(false, false, config.is_preformatted) .append_emote_with_name(&mut Some(sender_name)) .build(); label.set_label(&text); @@ -174,10 +184,11 @@ fn label_for_inline_html( add_ellipsis: bool, sender_name: &mut Option<&str>, ) -> Option { - let (text, widgets) = InlineHtmlBuilder::new(config.ellipsize, add_ellipsis) - .detect_mentions(config.room, config.detect_at_room) - .append_emote_with_name(sender_name) - .build_with_nodes(nodes); + let (text, widgets) = + InlineHtmlBuilder::new(config.ellipsize, add_ellipsis, config.is_preformatted) + .detect_mentions(config.room, config.detect_at_room) + .append_emote_with_name(sender_name) + .build_with_nodes(nodes); if text.is_empty() { return None; @@ -242,7 +253,7 @@ fn widget_for_html_block( } MatrixElement::Hr => gtk::Separator::new(gtk::Orientation::Horizontal).upcast(), MatrixElement::Pre => { - widget_for_preformatted_text(node.children(), config.ellipsize, add_ellipsis)? + widget_for_preformatted_text(node.children(), config, add_ellipsis, sender_name)? } MatrixElement::Details => widget_for_details(node.children(), config, add_ellipsis)?, element => { @@ -350,8 +361,9 @@ impl From for ListType { /// Create a widget for preformatted text. fn widget_for_preformatted_text( children: Children, - ellipsize: bool, + config: HtmlWidgetConfig<'_>, add_ellipsis: bool, + sender_name: &mut Option<&str>, ) -> Option { let children = children.collect::>(); @@ -361,37 +373,45 @@ fn widget_for_preformatted_text( let unique_code_child = (children.len() == 1) .then_some(&children[0]) - .and_then(|child| child.as_element()) - .and_then(|element| match element.to_matrix().element { - MatrixElement::Code(code) => Some(code), - _ => None, + .and_then(|child| { + match child + .as_element() + .map(|element| element.to_matrix().element) + { + Some(MatrixElement::Code(code)) => Some((child, code)), + _ => None, + } }); - let (children, code_language) = if let Some(code) = unique_code_child { - let children = children[0].children().collect::>(); + let Some((child, code)) = unique_code_child else { + // This is just preformatted text, we need to construct the children hierarchy. + let config = HtmlWidgetConfig { + is_preformatted: true, + ..config + }; + let widget = widget_for_html_nodes(children, config, add_ellipsis, sender_name)?; - if children.is_empty() { - return None; - } + // We use the monospace font for preformatted text. + widget.add_css_class("monospace"); - (children, code.language) - } else { - (children, None) + return Some(widget); }; - let text = InlineHtmlBuilder::new(ellipsize, add_ellipsis).build_with_nodes_text(children); + let children = child.children().collect::>(); - if ellipsize { - // Present text as inline code. - let text = format!("{}", text.escape_markup()); + if children.is_empty() { + return None; + } + let text = InlineHtmlBuilder::new(config.ellipsize, add_ellipsis, config.is_preformatted) + .build_with_nodes_text(children); + + if config.ellipsize { + // Present text as inline code. let label = new_message_label(); - label.set_ellipsize(if ellipsize { - pango::EllipsizeMode::End - } else { - pango::EllipsizeMode::None - }); - label.set_label(&text); + label.set_ellipsize(pango::EllipsizeMode::End); + label.add_css_class("monospace"); + label.set_label(&text.escape_markup()); return Some(label.upcast()); } @@ -402,7 +422,8 @@ fn widget_for_preformatted_text( .build(); crate::utils::sourceview::setup_style_scheme(&buffer); - let language = code_language + let language = code + .language .and_then(|lang| sourceview::LanguageManager::default().language(lang.as_ref())); buffer.set_language(language.as_ref());