aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorfrosty <gabriel@bwaaa.monster>2026-08-17 16:32:52 -0400
committerfrosty <gabriel@bwaaa.monster>2026-08-17 16:32:52 -0400
commitf9e3b8ba7da220746151179ed10a90c2ac215ffc (patch)
tree0d18a6617fefc2b41d9db75f5c79335607954874
parent6d0706bcaa6d54354e416174e3d2654a2bff81fe (diff)
Begin implementing codeblocks feature
-rw-r--r--main/data/style.css32
-rw-r--r--main/meson.build2
-rw-r--r--main/src/ui/conversation_content_view/code_block_widget.vala224
-rw-r--r--main/src/ui/conversation_content_view/message_widget.vala235
-rw-r--r--main/src/ui/conversation_content_view/syntax_highlighting.vala136
5 files changed, 555 insertions, 74 deletions
diff --git a/main/data/style.css b/main/data/style.css
index 1700a624..fc5915fb 100644
--- a/main/data/style.css
+++ b/main/data/style.css
@@ -120,6 +120,38 @@ window.dino-main .dino-quote:hover {
background: alpha(@theme_fg_color, 0.08);
}
+window.dino-main .code-block {
+ background: alpha(@theme_fg_color, 0.06);
+ border: 1px solid alpha(@theme_fg_color, 0.12);
+ border-radius: 8px;
+}
+
+window.dino-main .code-block-header {
+ background: alpha(@theme_fg_color, 0.06);
+ border-bottom: 1px solid alpha(@theme_fg_color, 0.1);
+ padding: 4px 6px 4px 12px;
+}
+
+window.dino-main .code-block-language {
+ font-weight: bold;
+}
+
+window.dino-main .code-block-meta {
+ color: alpha(@theme_fg_color, 0.65);
+ font-size: 0.85em;
+}
+
+window.dino-main .code-block-download {
+ min-height: 24px;
+ min-width: 24px;
+ padding: 0;
+}
+
+window.dino-main .code-block-content {
+ font-family: monospace;
+ padding: 10px 12px 12px;
+}
+
picture.avatar {
border-radius: 3px;
}
diff --git a/main/meson.build b/main/meson.build
index 38c1a733..0f0c29f3 100644
--- a/main/meson.build
+++ b/main/meson.build
@@ -42,6 +42,8 @@ sources = files(
'src/ui/contact_details/settings_provider.vala',
'src/ui/conversation_content_view/call_widget.vala',
'src/ui/conversation_content_view/chat_state_populator.vala',
+ 'src/ui/conversation_content_view/syntax_highlighting.vala',
+ 'src/ui/conversation_content_view/code_block_widget.vala',
'src/ui/conversation_content_view/content_populator.vala',
'src/ui/conversation_content_view/conversation_item_skeleton.vala',
'src/ui/conversation_content_view/conversation_view.vala',
diff --git a/main/src/ui/conversation_content_view/code_block_widget.vala b/main/src/ui/conversation_content_view/code_block_widget.vala
new file mode 100644
index 00000000..dc1ecc1f
--- /dev/null
+++ b/main/src/ui/conversation_content_view/code_block_widget.vala
@@ -0,0 +1,224 @@
+using Gee;
+using Gdk;
+using Gtk;
+using Pango;
+
+namespace Dino.Ui.ConversationSummary {
+
+public class CodeBlockPart : Object {
+ public bool is_code;
+ public string text;
+ public string language;
+ public int source_start_char;
+
+ public CodeBlockPart.from_text(string text, int source_start_char) {
+ this.text = text;
+ this.source_start_char = source_start_char;
+ this.language = "";
+ }
+
+ public CodeBlockPart.from_code(string text, string language) {
+ this.is_code = true;
+ this.text = text;
+ this.language = language;
+ }
+}
+
+public class CodeBlockParser : Object {
+ private static Regex? fence_regex;
+
+ public static ArrayList<CodeBlockPart> parse(string text, out bool has_blocks) {
+ var parts = new ArrayList<CodeBlockPart>();
+ Regex regex = get_fence_regex();
+ MatchInfo match_info;
+ int offset = 0;
+ has_blocks = false;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ parts.add(new CodeBlockPart.from_text(text, 0));
+ return parts;
+ }
+ while (match_info.matches()) {
+ int match_start;
+ int match_end;
+ int code_start;
+ int code_end;
+ match_info.fetch_pos(0, out match_start, out match_end);
+ match_info.fetch_pos(2, out code_start, out code_end);
+ int text_end = match_start;
+ if (text_end > offset && text[text_end - 1] == '\n') {
+ text_end--;
+ if (text_end > offset && text[text_end - 1] == '\r') text_end--;
+ }
+ if (text_end > offset) {
+ parts.add(new CodeBlockPart.from_text(text.substring(offset, text_end - offset), text.substring(0, offset).char_count()));
+ }
+ if (code_end > code_start && text[code_end - 1] == '\n') {
+ code_end--;
+ if (code_end > code_start && text[code_end - 1] == '\r') code_end--;
+ }
+ string code = text.substring(code_start, code_end - code_start).replace("\r\n", "\n");
+ parts.add(new CodeBlockPart.from_code(code, match_info.fetch(1).strip()));
+ has_blocks = true;
+ offset = match_end;
+ if (offset >= text.length) break;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ break;
+ }
+ }
+ if (offset < text.length) {
+ parts.add(new CodeBlockPart.from_text(text.substring(offset), text.substring(0, offset).char_count()));
+ }
+ return parts;
+ }
+
+ private static Regex get_fence_regex() {
+ if (fence_regex == null) {
+ try {
+ fence_regex = new Regex("(?ms)^```[\\t ]*([^\\r\\n`]*)[\\t ]*\\r?\\n(.*?)^```[\\t ]*(?:\\r?\\n|$)", RegexCompileFlags.OPTIMIZE);
+ } catch (RegexError error) {
+ GLib.error("Invalid code block expression: %s", error.message);
+ }
+ }
+ return fence_regex;
+ }
+}
+
+public class CodeBlockWidget : Box {
+ private string code;
+ private CodeSyntax.LanguageDefinition? definition;
+ private Label code_label;
+ private ulong realize_id;
+
+ public CodeBlockWidget(string code, string language) {
+ Object(orientation: Orientation.VERTICAL, spacing: 0, halign: Align.START, overflow: Overflow.HIDDEN);
+ this.code = code;
+ definition = CodeSyntax.find(language);
+ add_css_class("code-block");
+
+ var header = new Box(Orientation.HORIZONTAL, 8);
+ header.add_css_class("code-block-header");
+
+ string language_name = definition != null ? definition.name : language.strip();
+ if (language_name == "") language_name = _("Plain text");
+ var language_label = new Label(language_name) { xalign=0, hexpand=true };
+ language_label.add_css_class("code-block-language");
+ header.append(language_label);
+
+ int line_count = code == "" ? 0 : code.split("\n").length;
+ var line_label = new Label(n("%d line", "%d lines", line_count).printf(line_count));
+ line_label.add_css_class("code-block-meta");
+ header.append(line_label);
+
+ var download_button = new Button.from_icon_name("dino-file-download-symbolic") {
+ tooltip_text=_("Download code")
+ };
+ download_button.add_css_class("flat");
+ download_button.add_css_class("code-block-download");
+ download_button.clicked.connect(save);
+ header.append(download_button);
+ append(header);
+
+ code_label = new Label(code) {
+ use_markup=false,
+ selectable=true,
+ xalign=0,
+ yalign=0,
+ wrap=false
+ };
+ code_label.add_css_class("code-block-content");
+ append(code_label);
+
+ apply_highlighting();
+ realize_id = realize.connect(apply_highlighting);
+ }
+
+ private void apply_highlighting() {
+ var attributes = new AttrList();
+ if (definition != null) {
+ foreach (CodeSyntax.HighlightSpan span in definition.highlight(code)) {
+ RGBA color = get_token_color(span.style);
+ Attribute foreground = Pango.attr_foreground_new(
+ (uint16) Math.round(color.red * uint16.MAX),
+ (uint16) Math.round(color.green * uint16.MAX),
+ (uint16) Math.round(color.blue * uint16.MAX)
+ );
+ foreground.start_index = span.start;
+ foreground.end_index = span.end;
+ attributes.insert(foreground.copy());
+ if (color.alpha < 1) {
+ Attribute alpha = Pango.attr_foreground_alpha_new((uint16) Math.round(color.alpha * uint16.MAX));
+ alpha.start_index = span.start;
+ alpha.end_index = span.end;
+ attributes.insert(alpha.copy());
+ }
+ }
+ }
+ code_label.attributes = attributes;
+ }
+
+ private RGBA get_token_color(CodeSyntax.TokenStyle style) {
+ StyleContext context = code_label.get_style_context();
+ RGBA color = context.get_color();
+ string? color_name = null;
+ switch (style) {
+ case CodeSyntax.TokenStyle.COMMENT:
+ color.alpha = 0.62f;
+ break;
+ case CodeSyntax.TokenStyle.CONSTANT:
+ color_name = "error_color";
+ break;
+ case CodeSyntax.TokenStyle.NUMBER:
+ case CodeSyntax.TokenStyle.PREPROCESSOR:
+ color_name = "warning_color";
+ break;
+ case CodeSyntax.TokenStyle.STRING:
+ case CodeSyntax.TokenStyle.TYPE:
+ color_name = "success_color";
+ break;
+ case CodeSyntax.TokenStyle.FUNCTION:
+ case CodeSyntax.TokenStyle.KEYWORD:
+ color_name = "accent_color";
+ break;
+ }
+ if (color_name != null) {
+ RGBA theme_color;
+ if (context.lookup_color(color_name, out theme_color)) color = theme_color;
+ }
+ return color;
+ }
+
+ private void save() {
+ var dialog = new FileChooserNative(_("Save code as…"), get_root() as Gtk.Window, FileChooserAction.SAVE, _("Save"), _("Cancel"));
+ dialog.modal = true;
+ dialog.set_current_name("code." + (definition != null ? definition.extension : "txt"));
+ dialog.response.connect((response_id) => {
+ if (response_id == ResponseType.ACCEPT) {
+ File? target = dialog.get_file();
+ if (target != null) {
+ try {
+ string? etag;
+ target.replace_contents(code.data, null, false, FileCreateFlags.REPLACE_DESTINATION, out etag);
+ } catch (Error error) {
+ warning("Failed to save code: %s", error.message);
+ }
+ }
+ }
+ dialog.destroy();
+ });
+ dialog.show();
+ }
+
+ public override void dispose() {
+ if (realize_id != 0) {
+ disconnect(realize_id);
+ realize_id = 0;
+ }
+ base.dispose();
+ }
+}
+
+}
diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala
index 25d3775b..f8db1a45 100644
--- a/main/src/ui/conversation_content_view/message_widget.vala
+++ b/main/src/ui/conversation_content_view/message_widget.vala
@@ -30,6 +30,7 @@ public class MessageMetaItem : ContentMetaItem {
uint pending_timeout_id = -1;
public Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, hexpand=true, vexpand=true };
+ private Widget? content_widget;
public MessageMetaItem(ContentItem content_item, StreamInteractor stream_interactor) {
base(content_item);
@@ -73,111 +74,159 @@ public class MessageMetaItem : ContentMetaItem {
update_label();
}
- private void generate_markup_text(ContentItem item, Label label) {
- MessageItem message_item = item as MessageItem;
- Conversation conversation = message_item.conversation;
+ private void generate_markup_text(Label label, string text, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
Message message = message_item.message;
+ label.set_attributes(create_text_attributes(text, 0, markups, action_name_length));
- // Get a copy of the markup spans, such that we can modify them
- var markups = new ArrayList<Xep.MessageMarkup.Span>();
- foreach (var markup in message.get_markups()) {
- markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char });
+ bool theme_dependent;
+ string markup_text = parse_text_markup(label, text, out theme_dependent);
+
+ int only_emoji_count = Util.get_only_emoji_count(markup_text);
+ if (only_emoji_count != -1) {
+ string size_str = only_emoji_count < 5 ? "xx-large" : "large";
+ markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>";
}
- string markup_text = message.body;
+ string dim_color = Util.is_dark_theme(label) ? "#BDBDBD" : "#707070";
- var attrs = new AttrList();
- label.set_attributes(attrs);
+ if (message.body == "") {
+ markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted"));
+ theme_dependent = true;
+ }
+ markup_text += get_meta_markup(label, true, ref theme_dependent);
- if (markup_text == null) return; // TODO remove
+ if (theme_dependent && realize_id == -1 && !label.get_realized()) {
+ realize_id = label.realize.connect(update_label);
+ } else if (!theme_dependent && realize_id != -1) {
+ label.disconnect(realize_id);
+ }
+ label.label = markup_text;
+ }
- // Only process messages up to a certain size
- if (markup_text.length > 10000) {
- markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]";
+ private ArrayList<Xep.MessageMarkup.Span> copy_markups(Message message) {
+ var markups = new ArrayList<Xep.MessageMarkup.Span>();
+ foreach (var markup in message.get_markups()) {
+ markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char });
}
+ return markups;
+ }
- bool theme_dependent = false;
+ private string prepare_message(ArrayList<Xep.MessageMarkup.Span> markups, out int action_name_length) {
+ Message message = message_item.message;
+ string text = message.body ?? "";
+ action_name_length = 0;
- markup_text = Util.remove_fallbacks_adjust_markups(markup_text, message.quoted_item_id > 0, message.get_fallbacks(), markups);
+ if (text.char_count() > 10000) {
+ text = text.substring(0, text.index_of_nth_char(10000)) + " [" + _("Message too long") + "]";
+ }
- var bold_attr = Pango.attr_weight_new(Pango.Weight.BOLD);
- var italic_attr = Pango.attr_style_new(Pango.Style.ITALIC);
- var strikethrough_attr = Pango.attr_strikethrough_new(true);
+ text = Util.remove_fallbacks_adjust_markups(text, message.quoted_item_id > 0, message.get_fallbacks(), markups);
- // Prefix message with name instead of /me
- if (markup_text.has_prefix("/me ")) {
- string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from);
- markup_text = display_name + " " + markup_text.substring(4);
+ if (text.has_prefix("/me ")) {
+ string display_name = Util.get_participant_display_name(stream_interactor, message_item.conversation, message.from);
+ text = display_name + " " + text.substring(4);
+ action_name_length = display_name.char_count();
foreach (Xep.MessageMarkup.Span span in markups) {
- int length = display_name.char_count() - 4 + 1;
- span.start_char += length;
- span.end_char += length;
+ int difference = action_name_length - 3;
+ span.start_char += difference;
+ span.end_char += difference;
}
-
- bold_attr.end_index = display_name.length;
- italic_attr.end_index = display_name.length;
- attrs.insert(bold_attr.copy());
- attrs.insert(italic_attr.copy());
}
+ return text;
+ }
+
+ private Label create_text_label() {
+ var text_label = new Label("") {
+ use_markup=true,
+ xalign=0,
+ selectable=true,
+ wrap=true,
+ wrap_mode=Pango.WrapMode.WORD_CHAR,
+ hexpand=true
+ };
+ text_label.activate_link.connect(on_label_activate_link);
+ return text_label;
+ }
+
+ private void add_text_attribute(AttrList attributes, Attribute attribute, string text, int source_start, int span_start, int span_end) {
+ int source_end = source_start + text.char_count();
+ int start = int.max(source_start, span_start);
+ int end = int.min(source_end, span_end);
+ if (start >= end) return;
+
+ attribute.start_index = text.index_of_nth_char(start - source_start);
+ attribute.end_index = text.index_of_nth_char(end - source_start);
+ attributes.insert(attribute.copy());
+ }
+
+ private AttrList create_text_attributes(string text, int source_start, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ var attributes = new AttrList();
foreach (var markup in markups) {
- foreach (var ty in markup.types) {
- Attribute attr = null;
- switch (ty) {
+ foreach (var type in markup.types) {
+ Attribute attribute;
+ switch (type) {
case Xep.MessageMarkup.SpanType.EMPHASIS:
- attr = Pango.attr_style_new(Pango.Style.ITALIC);
+ attribute = Pango.attr_style_new(Pango.Style.ITALIC);
break;
case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS:
- attr = Pango.attr_weight_new(Pango.Weight.BOLD);
+ attribute = Pango.attr_weight_new(Pango.Weight.BOLD);
break;
case Xep.MessageMarkup.SpanType.DELETED:
- attr = Pango.attr_strikethrough_new(true);
+ attribute = Pango.attr_strikethrough_new(true);
break;
+ default:
+ continue;
}
- attr.start_index = markup_text.index_of_nth_char(markup.start_char);
- attr.end_index = markup_text.index_of_nth_char(markup.end_char);
- attrs.insert(attr.copy());
+ add_text_attribute(attributes, (owned) attribute, text, source_start, markup.start_char, markup.end_char);
}
}
- // Work around pango bug
- markup_text = Util.unbreak_space_around_non_spacing_mark((owned) markup_text);
+ if (action_name_length > 0) {
+ add_text_attribute(attributes, Pango.attr_weight_new(Pango.Weight.BOLD), text, source_start, 0, action_name_length);
+ add_text_attribute(attributes, Pango.attr_style_new(Pango.Style.ITALIC), text, source_start, 0, action_name_length);
+ }
+
+ return attributes;
+ }
+ private string parse_text_markup(Label text_label, string text, out bool theme_dependent) {
+ string markup_text = Util.unbreak_space_around_non_spacing_mark(text);
+ theme_dependent = false;
+ Conversation conversation = message_item.conversation;
if (conversation.type_ == Conversation.Type.GROUPCHAT) {
- markup_text = Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent);
- } else {
- markup_text = Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent);
+ return Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent);
}
+ return Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent);
+ }
- int only_emoji_count = Util.get_only_emoji_count(markup_text);
- if (only_emoji_count != -1) {
- string size_str = only_emoji_count < 5 ? "xx-large" : "large";
- markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>";
- }
+ private void format_text_segment(Label text_label, CodeBlockPart part, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ text_label.set_attributes(create_text_attributes(part.text, part.source_start_char, markups, action_name_length));
+ bool theme_dependent;
+ text_label.label = parse_text_markup(text_label, part.text, out theme_dependent);
+ }
- string dim_color = Util.is_dark_theme(this.label) ? "#BDBDBD" : "#707070";
+ private string get_meta_markup(Label meta_label, bool inline, ref bool theme_dependent) {
+ Message message = message_item.message;
+ string dim_color = Util.is_dark_theme(meta_label) ? "#BDBDBD" : "#707070";
+ string separator = inline ? " " : "";
+ string markup = "";
- if (message.body == "") {
- markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted"));
- theme_dependent = true;
- }
if (message.edit_to != null) {
- markup_text += @" <span size='small' color='$dim_color'>(%s)</span>".printf(_("edited"));
+ markup = @"$separator<span size='small' color='$dim_color'>(%s)</span>".printf(_("edited"));
+ separator = " ";
theme_dependent = true;
}
- // Append message status info
additional_info = AdditionalInfo.NONE;
if (message.direction == Message.DIRECTION_SENT && (message.marked == Message.Marked.SENDING || message.marked == Message.Marked.UNSENT)) {
- // Append "pending..." iff message has not been sent yet
if (message.time.compare(new DateTime.now_utc().add_seconds(-10)) < 0) {
- markup_text += @" <span size='small' color='$dim_color'>%s</span>".printf(_("pending…"));
+ markup += @"$separator<span size='small' color='$dim_color'>%s</span>".printf(_("pending…"));
theme_dependent = true;
additional_info = AdditionalInfo.PENDING;
} else {
int time_diff = (- (int) message.time.difference(new DateTime.now_utc()) / 1000);
- if (pending_timeout_id != -1) Source.remove(pending_timeout_id);
pending_timeout_id = Timeout.add(10000 - time_diff, () => {
update_label();
pending_timeout_id = -1;
@@ -185,24 +234,62 @@ public class MessageMetaItem : ContentMetaItem {
});
}
} else if (message.direction == Message.DIRECTION_SENT && message.marked == Message.Marked.ERROR) {
- // Append "delivery failed" if there was a server error
- // TODO: Use a dedicated label, not appended text
- string error_color = Util.rgba_to_hex(Util.get_label_pango_color(label, "error_color"));
- markup_text += " <span size='small' color='%s'>%s</span>".printf(error_color, _("delivery failed"));
+ string error_color = Util.rgba_to_hex(Util.get_label_pango_color(meta_label, "error_color"));
+ markup += @"$separator<span size='small' color='$error_color'>%s</span>".printf(_("delivery failed"));
theme_dependent = true;
additional_info = AdditionalInfo.DELIVERY_FAILED;
}
- if (theme_dependent && realize_id == -1) {
- realize_id = label.realize.connect(update_label);
- } else if (!theme_dependent && realize_id != -1) {
- label.disconnect(realize_id);
+ return markup;
+ }
+
+ private Widget generate_code_widget(ArrayList<CodeBlockPart> parts, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) {
+ var content = new Box(Orientation.VERTICAL, 6) { hexpand=true };
+ foreach (CodeBlockPart part in parts) {
+ if (part.is_code) {
+ content.append(new CodeBlockWidget(part.text, part.language));
+ } else {
+ Label text_label = create_text_label();
+ format_text_segment(text_label, part, markups, action_name_length);
+ content.append(text_label);
+ }
}
- label.label = markup_text;
+
+ Label meta_label = create_text_label();
+ bool theme_dependent = false;
+ string meta_markup = get_meta_markup(meta_label, false, ref theme_dependent);
+ if (meta_markup != "") {
+ meta_label.label = meta_markup;
+ content.append(meta_label);
+ }
+ return content;
}
public void update_label() {
- generate_markup_text(content_item, label);
+ if (pending_timeout_id != -1) {
+ Source.remove(pending_timeout_id);
+ pending_timeout_id = -1;
+ }
+ if (realize_id != -1) {
+ label.disconnect(realize_id);
+ realize_id = -1;
+ }
+
+ var markups = copy_markups(message_item.message);
+ int action_name_length;
+ string text = prepare_message(markups, out action_name_length);
+ bool has_blocks;
+ ArrayList<CodeBlockPart> parts = CodeBlockParser.parse(text, out has_blocks);
+ if (has_blocks) {
+ content_widget = generate_code_widget(parts, markups, action_name_length);
+ } else {
+ generate_markup_text(label, text, markups, action_name_length);
+ content_widget = label;
+ }
+
+ if (outer != null && !in_edit_mode) {
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
+ }
}
public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) {
@@ -210,7 +297,7 @@ public class MessageMetaItem : ContentMetaItem {
this.notify["in-edit-mode"].connect(on_in_edit_mode_changed);
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
if (message_item.message.quoted_item_id > 0) {
var quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(message_item.conversation, message_item.message.quoted_item_id);
@@ -223,7 +310,7 @@ public class MessageMetaItem : ContentMetaItem {
outer.set_widget(quote_widget, Plugins.WidgetType.GTK4, 1);
}
}
- return label;
+ return content_widget;
}
public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) {
@@ -267,7 +354,7 @@ public class MessageMetaItem : ContentMetaItem {
edit_mode.cancelled.connect(() => {
in_edit_mode = false;
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
});
edit_mode.send.connect(() => {
string text = edit_mode.chat_text_view.text_view.buffer.text;
@@ -275,7 +362,7 @@ public class MessageMetaItem : ContentMetaItem {
Dino.send_message(message_item.conversation, text, message_item.message.quoted_item_id, message_item.message, markups);
in_edit_mode = false;
- outer.set_widget(label, Plugins.WidgetType.GTK4, 2);
+ outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2);
});
edit_mode.chat_text_view.set_text(message);
diff --git a/main/src/ui/conversation_content_view/syntax_highlighting.vala b/main/src/ui/conversation_content_view/syntax_highlighting.vala
new file mode 100644
index 00000000..b45ba81c
--- /dev/null
+++ b/main/src/ui/conversation_content_view/syntax_highlighting.vala
@@ -0,0 +1,136 @@
+using Gee;
+
+namespace Dino.Ui.CodeSyntax {
+
+public enum TokenStyle {
+ COMMENT,
+ CONSTANT,
+ FUNCTION,
+ KEYWORD,
+ NUMBER,
+ PREPROCESSOR,
+ STRING,
+ TYPE
+}
+
+public class HighlightSpan : Object {
+ public int start;
+ public int end;
+ public TokenStyle style;
+
+ public HighlightSpan(int start, int end, TokenStyle style) {
+ this.start = start;
+ this.end = end;
+ this.style = style;
+ }
+}
+
+public class HighlightRule : Object {
+ public TokenStyle style;
+ private Regex regex;
+
+ public HighlightRule(TokenStyle style, string pattern) {
+ this.style = style;
+ try {
+ regex = new Regex(pattern, RegexCompileFlags.OPTIMIZE);
+ } catch (RegexError error) {
+ GLib.error("Invalid syntax highlighting rule: %s", error.message);
+ }
+ }
+
+ public bool find(string text, int offset, out int start, out int end) {
+ MatchInfo match_info;
+ try {
+ regex.match_full(text, -1, offset, 0, out match_info);
+ } catch (RegexError error) {
+ start = -1;
+ end = -1;
+ return false;
+ }
+ if (!match_info.matches()) {
+ start = -1;
+ end = -1;
+ return false;
+ }
+ match_info.fetch_pos(0, out start, out end);
+ return true;
+ }
+}
+
+public class LanguageDefinition : Object {
+ public string id;
+ public string name;
+ public string extension;
+ public string[] aliases;
+ public HighlightRule[] rules;
+
+ public LanguageDefinition(string id, string name, string extension, string[] aliases, HighlightRule[] rules) {
+ this.id = id;
+ this.name = name;
+ this.extension = extension;
+ this.aliases = aliases;
+ this.rules = rules;
+ }
+
+ public bool matches(string language) {
+ string normalized = language.down().strip();
+ if (normalized == id) return true;
+ foreach (string alias in aliases) {
+ if (normalized == alias) return true;
+ }
+ return false;
+ }
+
+ public ArrayList<HighlightSpan> highlight(string text) {
+ var spans = new ArrayList<HighlightSpan>();
+ int offset = 0;
+ while (offset < text.length) {
+ HighlightRule? selected_rule = null;
+ int selected_start = text.length;
+ int selected_end = text.length;
+ foreach (HighlightRule rule in rules) {
+ int start;
+ int end;
+ if (rule.find(text, offset, out start, out end) && start < selected_start) {
+ selected_rule = rule;
+ selected_start = start;
+ selected_end = end;
+ }
+ }
+ if (selected_rule == null || selected_end <= selected_start) break;
+ spans.add(new HighlightSpan(selected_start, selected_end, selected_rule.style));
+ offset = selected_end;
+ }
+ return spans;
+ }
+}
+
+private static LanguageDefinition[]? definitions;
+
+public static LanguageDefinition? find(string language) {
+ if (definitions == null) definitions = create_definitions();
+ foreach (LanguageDefinition definition in definitions) {
+ if (definition.matches(language)) return definition;
+ }
+ return null;
+}
+
+private static LanguageDefinition[] create_definitions() {
+ return { create_c_definition() };
+}
+
+private static LanguageDefinition create_c_definition() {
+ HighlightRule[] rules = {
+ new HighlightRule(TokenStyle.COMMENT, "//[^\\r\\n]*|/\\*[\\s\\S]*?\\*/"),
+ new HighlightRule(TokenStyle.STRING, "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'"),
+ new HighlightRule(TokenStyle.PREPROCESSOR, "(?m)^[\\t ]*#[\\t ]*[A-Za-z_][A-Za-z0-9_]*"),
+ new HighlightRule(TokenStyle.KEYWORD, "\\b(?:alignas|alignof|asm|auto|break|case|const|constexpr|continue|default|do|else|enum|extern|for|goto|if|inline|register|restrict|return|sizeof|static|static_assert|struct|switch|thread_local|typedef|typeof|typeof_unqual|union|volatile|while|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\\b"),
+ new HighlightRule(TokenStyle.TYPE, "\\b(?:bool|char|char8_t|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Decimal32|_Decimal64|_Decimal128|_Imaginary|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t|intptr_t|uintptr_t|ptrdiff_t|size_t|ssize_t|wchar_t)\\b"),
+ new HighlightRule(TokenStyle.CONSTANT, "\\b(?:false|NULL|nullptr|true)\\b"),
+ new HighlightRule(TokenStyle.NUMBER, "\\b(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|(?:[0-9]+\\.[0-9]*|[0-9]+)(?:[eEpP][+-]?[0-9]+)?)[fFlLuU]*\\b"),
+ new HighlightRule(TokenStyle.FUNCTION, "\\b[A-Za-z_][A-Za-z0-9_]*(?=[\\t ]*\\()")
+ };
+ return new LanguageDefinition("c", "C", "c", { "h", "c89", "c90", "c99", "c11", "c17", "c18", "c23" }, rules);
+}
+
+}