From f9e3b8ba7da220746151179ed10a90c2ac215ffc Mon Sep 17 00:00:00 2001 From: frosty Date: Mon, 17 Aug 2026 16:32:52 -0400 Subject: Begin implementing codeblocks feature --- .../code_block_widget.vala | 224 +++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 main/src/ui/conversation_content_view/code_block_widget.vala (limited to 'main/src/ui/conversation_content_view/code_block_widget.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 parse(string text, out bool has_blocks) { + var parts = new ArrayList(); + 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(); + } +} + +} -- cgit v1.3