use gettextrs::gettext;
use gtk::{pango, prelude::*};
use ruma::html::{
matrix::{MatrixElement, OrderedListData},
Children, NodeRef,
};
use sourceview::prelude::*;
use tracing::debug;
use super::{inline_html::InlineHtmlBuilder, SUPPORTED_BLOCK_ELEMENTS};
use crate::{
components::{AtRoom, LabelWithWidgets},
prelude::*,
session::model::Room,
};
#[derive(Debug, Clone, Copy)]
pub(super) struct HtmlWidgetConfig<'a> {
pub(super) room: &'a Room,
pub(super) detect_at_room: bool,
pub(super) ellipsize: bool,
}
pub(super) fn new_message_label() -> gtk::Label {
gtk::Label::builder()
.wrap(true)
.wrap_mode(pango::WrapMode::WordChar)
.xalign(0.0)
.valign(gtk::Align::Start)
.use_markup(true)
.build()
}
pub(super) fn widget_for_html_nodes<'a>(
nodes: impl IntoIterator<Item = NodeRef<'a>>,
config: HtmlWidgetConfig<'a>,
add_ellipsis: bool,
sender_name: &mut Option<&str>,
) -> Option<gtk::Widget> {
let nodes = nodes.into_iter().collect::<Vec<_>>();
if nodes.is_empty() {
return None;
}
let groups = group_inline_nodes(nodes);
let len = groups.len();
let mut children = Vec::new();
for (i, group) in groups.into_iter().enumerate() {
let is_last = i == (len - 1);
let add_ellipsis = add_ellipsis || (config.ellipsize && !is_last);
match group {
NodeGroup::Inline(inline_nodes) => {
if let Some(widget) =
label_for_inline_html(inline_nodes, config, add_ellipsis, sender_name)
{
children.push(widget);
}
}
NodeGroup::Block(block_node) => {
let Some(widget) =
widget_for_html_block(block_node, config, add_ellipsis, sender_name)
else {
continue;
};
if let Some(sender_name) = sender_name.take() {
let label = new_message_label();
let (text, _) = InlineHtmlBuilder::new(false, false)
.append_emote_with_name(&mut Some(sender_name))
.build();
label.set_label(&text);
children.push(label.upcast());
}
children.push(widget);
}
}
if config.ellipsize {
break;
}
}
if children.is_empty() {
return None;
}
if children.len() == 1 {
return children.into_iter().next();
}
let grid = gtk::Grid::builder()
.row_spacing(6)
.accessible_role(gtk::AccessibleRole::Group)
.build();
for (row, child) in children.into_iter().enumerate() {
grid.attach(&child, 0, row as i32, 1, 1);
}
Some(grid.upcast())
}
enum NodeGroup<'a> {
Inline(Vec<NodeRef<'a>>),
Block(NodeRef<'a>),
}
fn group_inline_nodes(nodes: Vec<NodeRef<'_>>) -> Vec<NodeGroup<'_>> {
let mut result = Vec::new();
let mut inline_group = None;
for node in nodes {
let is_block = node
.as_element()
.is_some_and(|element| SUPPORTED_BLOCK_ELEMENTS.contains(&element.name.local.as_ref()));
if is_block {
if let Some(inline) = inline_group.take() {
result.push(NodeGroup::Inline(inline));
}
result.push(NodeGroup::Block(node));
} else {
let inline = inline_group.get_or_insert_with(Vec::default);
inline.push(node);
}
}
if let Some(inline) = inline_group.take() {
result.push(NodeGroup::Inline(inline));
}
result
}
fn label_for_inline_html<'a>(
nodes: impl IntoIterator<Item = NodeRef<'a>>,
config: HtmlWidgetConfig<'a>,
add_ellipsis: bool,
sender_name: &mut Option<&str>,
) -> Option<gtk::Widget> {
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);
if text.is_empty() {
return None;
}
if let Some(widgets) = widgets {
widgets.iter().for_each(|p| {
if !p.source().is_some_and(|s| s.is::<AtRoom>()) {
p.set_activatable(true);
}
});
let w = LabelWithWidgets::with_label_and_widgets(&text, widgets);
w.set_use_markup(true);
w.set_ellipsize(config.ellipsize);
Some(w.upcast())
} else {
let w = new_message_label();
w.set_markup(&text);
w.set_ellipsize(if config.ellipsize {
pango::EllipsizeMode::End
} else {
pango::EllipsizeMode::None
});
Some(w.upcast())
}
}
fn widget_for_html_block(
node: NodeRef<'_>,
config: HtmlWidgetConfig<'_>,
add_ellipsis: bool,
sender_name: &mut Option<&str>,
) -> Option<gtk::Widget> {
let widget = match node.as_element()?.to_matrix().element {
MatrixElement::H(heading) => {
let w = label_for_inline_html(node.children(), config, add_ellipsis, sender_name)
.unwrap_or_else(|| {
new_message_label().upcast()
});
w.add_css_class(&format!("h{}", heading.level.value()));
w
}
MatrixElement::Blockquote => {
let w = widget_for_html_nodes(node.children(), config, add_ellipsis, &mut None)?;
w.add_css_class("quote");
w
}
MatrixElement::P | MatrixElement::Div(_) | MatrixElement::Li | MatrixElement::Summary => {
widget_for_html_nodes(node.children(), config, add_ellipsis, sender_name)?
}
MatrixElement::Ul => {
widget_for_list(ListType::Unordered, node.children(), config, add_ellipsis)?
}
MatrixElement::Ol(list) => {
widget_for_list(list.into(), node.children(), config, add_ellipsis)?
}
MatrixElement::Hr => gtk::Separator::new(gtk::Orientation::Horizontal).upcast(),
MatrixElement::Pre => {
widget_for_preformatted_text(node.children(), config.ellipsize, add_ellipsis)?
}
MatrixElement::Details => widget_for_details(node.children(), config, add_ellipsis)?,
element => {
debug!("Unexpected HTML block element: {element:?}");
return None;
}
};
Some(widget)
}
fn widget_for_list(
list_type: ListType,
list_items: Children<'_>,
config: HtmlWidgetConfig<'_>,
add_ellipsis: bool,
) -> Option<gtk::Widget> {
let list_items = list_items
.filter(|node| {
node.as_element()
.is_some_and(|element| element.name.local.as_ref() == "li")
})
.collect::<Vec<_>>();
if list_items.is_empty() {
return None;
}
let grid = gtk::Grid::builder()
.row_spacing(6)
.column_spacing(6)
.margin_end(6)
.margin_start(6)
.build();
let len = list_items.len();
for (pos, li) in list_items.into_iter().enumerate() {
let is_last = pos == (len - 1);
let add_ellipsis = add_ellipsis || (config.ellipsize && !is_last);
let w = widget_for_html_nodes(li.children(), config, add_ellipsis, &mut None)
.unwrap_or_else(|| new_message_label().upcast());
let bullet = list_type.bullet(pos);
grid.attach(&bullet, 0, pos as i32, 1, 1);
grid.attach(&w, 1, pos as i32, 1, 1);
if config.ellipsize {
break;
}
}
Some(grid.upcast())
}
#[derive(Debug, Clone, Copy)]
enum ListType {
Unordered,
Ordered {
start: i64,
},
}
impl ListType {
fn bullet(&self, position: usize) -> gtk::Label {
let bullet = gtk::Label::builder().valign(gtk::Align::Baseline).build();
match self {
ListType::Unordered => bullet.set_label("•"),
ListType::Ordered { start } => {
bullet.set_label(&format!("{}.", *start + position as i64))
}
}
bullet
}
}
impl From<OrderedListData> for ListType {
fn from(value: OrderedListData) -> Self {
Self::Ordered {
start: value.start.unwrap_or(1),
}
}
}
fn widget_for_preformatted_text(
children: Children<'_>,
ellipsize: bool,
add_ellipsis: bool,
) -> Option<gtk::Widget> {
let children = children.collect::<Vec<_>>();
if children.is_empty() {
return None;
}
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,
});
let (children, code_language) = if let Some(code) = unique_code_child {
let children = children[0].children().collect::<Vec<_>>();
if children.is_empty() {
return None;
}
(children, code.language)
} else {
(children, None)
};
let text = InlineHtmlBuilder::new(ellipsize, add_ellipsis).build_with_nodes_text(children);
if ellipsize {
let text = format!("<tt>{}</tt>", text.escape_markup());
let label = new_message_label();
label.set_ellipsize(if ellipsize {
pango::EllipsizeMode::End
} else {
pango::EllipsizeMode::None
});
label.set_label(&text);
return Some(label.upcast());
}
let buffer = sourceview::Buffer::builder()
.highlight_matching_brackets(false)
.text(text)
.build();
crate::utils::sourceview::setup_style_scheme(&buffer);
let language = code_language
.and_then(|lang| sourceview::LanguageManager::default().language(lang.as_ref()));
buffer.set_language(language.as_ref());
let view = sourceview::View::builder()
.buffer(&buffer)
.editable(false)
.css_classes(["codeview", "frame"])
.hexpand(true)
.build();
let scrolled = gtk::ScrolledWindow::new();
scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never);
scrolled.set_child(Some(&view));
Some(scrolled.upcast())
}
fn widget_for_details(
children: Children<'_>,
config: HtmlWidgetConfig<'_>,
add_ellipsis: bool,
) -> Option<gtk::Widget> {
let (summary, other_children) = children.partition::<Vec<_>, _>(|node| {
node.as_element()
.is_some_and(|element| element.name.local.as_ref() == "summary")
});
let content = widget_for_html_nodes(other_children, config, add_ellipsis, &mut None);
let summary = summary
.into_iter()
.next()
.and_then(|node| widget_for_details_summary(node.children(), config, add_ellipsis));
if let Some(content) = content {
let summary = summary.unwrap_or_else(|| {
let label = new_message_label();
label.set_label(&gettext("Details"));
label.upcast()
});
let expander = gtk::Expander::builder()
.label_widget(&summary)
.child(&content)
.build();
Some(expander.upcast())
} else {
summary
}
}
fn widget_for_details_summary(
children: Children<'_>,
config: HtmlWidgetConfig<'_>,
add_ellipsis: bool,
) -> Option<gtk::Widget> {
let children = children.collect::<Vec<_>>();
if children.is_empty() {
return None;
}
if children.len() == 1 {
if let Some(node) = children.first().filter(|node| {
node.as_element().is_some_and(|element| {
matches!(
element.name.local.as_ref(),
"h1" | "h2" | "h3" | "h4" | "h5" | "h6"
)
})
}) {
if let Some(widget) = widget_for_html_block(*node, config, add_ellipsis, &mut None) {
return Some(widget);
}
}
}
label_for_inline_html(children, config, add_ellipsis, &mut None)
}