aboutsummaryrefslogtreecommitdiff
path: root/main/src/ui/conversation_content_view/code_block_widget.vala
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 /main/src/ui/conversation_content_view/code_block_widget.vala
parent6d0706bcaa6d54354e416174e3d2654a2bff81fe (diff)
Begin implementing codeblocks feature
Diffstat (limited to 'main/src/ui/conversation_content_view/code_block_widget.vala')
-rw-r--r--main/src/ui/conversation_content_view/code_block_widget.vala224
1 files changed, 224 insertions, 0 deletions
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();
+ }
+}
+
+}