From 8464f0107c463925129eaa39dd562fdad67eea25 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Wed, 7 May 2025 12:16:20 +0200 Subject: [PATCH] code block syntax highlighting --- .../quickshell/modules/common/Appearance.qml | 2 + .../modules/common/functions/string_utils.js | 36 ++- .../quickshell/modules/sidebarLeft/AiChat.qml | 2 +- .../modules/sidebarLeft/aiChat/AiMessage.qml | 270 +++++++++++++++--- .config/quickshell/services/MaterialTheme.qml | 2 + README.md | 2 +- 6 files changed, 262 insertions(+), 52 deletions(-) diff --git a/.config/quickshell/modules/common/Appearance.qml b/.config/quickshell/modules/common/Appearance.qml index 88dc67d22..e2e9809f0 100644 --- a/.config/quickshell/modules/common/Appearance.qml +++ b/.config/quickshell/modules/common/Appearance.qml @@ -11,6 +11,7 @@ Singleton { property QtObject rounding property QtObject font property QtObject sizes + property string syntaxHighlightingTheme function mix(color1, color2, percentage) { var c1 = Qt.color(color1); @@ -238,4 +239,5 @@ Singleton { property int fabHoveredShadowRadius: 7 } + syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light" } diff --git a/.config/quickshell/modules/common/functions/string_utils.js b/.config/quickshell/modules/common/functions/string_utils.js index c655d93da..4784a56ea 100644 --- a/.config/quickshell/modules/common/functions/string_utils.js +++ b/.config/quickshell/modules/common/functions/string_utils.js @@ -1,17 +1,35 @@ function format(str, ...args) { - return str.replace(/{(\d+)}/g, (match, index) => - typeof args[index] !== 'undefined' ? args[index] : match - ); + return str.replace(/{(\d+)}/g, (match, index) => + typeof args[index] !== 'undefined' ? args[index] : match + ); } function getDomain(url) { - const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); - return match ? match[1] : null; + const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); + return match ? match[1] : null; } function shellSingleQuoteEscape(str) { - // First escape backslashes, then escape single quotes - return String(str) - .replace(/\\/g, '\\\\') - .replace(/'/g, "'\\''"); + // First escape backslashes, then escape single quotes + return String(str) + .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); } + +function splitMarkdownBlocks(markdown) { + const regex = /```(\w+)?\n([\s\S]*?)```/g; + let result = []; + let lastIndex = 0; + let match; + while ((match = regex.exec(markdown)) !== null) { + if (match.index > lastIndex) { + result.push({ type: "text", content: markdown.slice(lastIndex, match.index) }); + } + result.push({ type: "code", lang: match[1] || "", content: match[2] }); + lastIndex = regex.lastIndex; + } + if (lastIndex < markdown.length) { + result.push({ type: "text", content: markdown.slice(lastIndex) }); + } + return result; +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml index 7e8d5c385..f3782d082 100644 --- a/.config/quickshell/modules/sidebarLeft/AiChat.qml +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -83,7 +83,7 @@ Item { + "- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n\n" + "- Table:\n\n" + "| | Quickshell | AGS/Astal |\n" - + "|:-------------------------|:----------------:|:-----------------:|\n" + + "|--------------------------|------------------|-------------------|\n" + "| UI Toolkit | Qt | Gtk3/Gtk4 |\n" + "| Language | QML | Js/Ts/Lua |\n" + "| Reactivity | Implied | Needs declaration |\n" diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml index 296fb03c5..384ca56da 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -13,6 +13,8 @@ import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Hyprland import Qt5Compat.GraphicalEffects +import org.kde.syntaxhighlighting +// import org.kde.kirigami as Kirigami Rectangle { id: root @@ -22,6 +24,8 @@ Rectangle { property real messagePadding: 7 property real contentSpacing: 3 + property real codeBlockBackgroundRounding: Appearance.rounding.small + property real codeBlockComponentSpacing: 2 property bool renderMarkdown: true property bool editing: false @@ -149,7 +153,25 @@ Rectangle { onClicked: { root.editing = !root.editing if (!root.editing) { // Save changes - root.messageData.content = messageText.text + // Get all Loader children (each represents a segment) + const segments = messageContentColumnLayout.children + .map(child => child.segment) + .filter(segment => (segment)); + // console.log("Segments: " + JSON.stringify(segments)) + + // Reconstruct markdown + const newContent = segments.map(segment => { + if (segment.type === "code") { + const lang = segment.lang ? segment.lang : ""; + // Remove trailing newlines + const code = segment.content.replace(/\n+$/, ""); + return "```" + lang + "\n" + code + "\n```"; + } else { + return segment.content; + } + }).join(""); + + root.messageData.content = newContent; } } StyledToolTip { @@ -159,15 +181,12 @@ Rectangle { AiMessageControlButton { id: toggleMarkdownButton activated: !root.renderMarkdown - buttonIcon: root.renderMarkdown ? "wysiwyg" : "code" + buttonIcon: "code" onClicked: { root.renderMarkdown = !root.renderMarkdown - if (root.renderMarkdown && messageData.finished) { - messageText.text = root.messageData.content - } } StyledToolTip { - content: qsTr("Toggle Markdown rendering") + content: qsTr("View Markdown source") } } AiMessageControlButton { @@ -183,45 +202,214 @@ Rectangle { } } - TextEdit { // Message - id: messageText - Layout.fillWidth: true - Layout.margins: messagePadding - readOnly: !root.editing - selectByMouse: true - - renderType: Text.NativeRendering - font.family: Appearance.font.family.reading - font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text - font.pixelSize: Appearance.font.pixelSize.small - selectedTextColor: Appearance.m3colors.m3onSecondaryContainer - selectionColor: Appearance.m3colors.m3secondaryContainer - wrapMode: Text.WordWrap - color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 - textFormat: root.renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText - text: messageData.thinking ? qsTr("Waiting for response...") : root.messageData.content - - Keys.onPressed: (event) => { - if (event.key === Qt.Key_Control) { // Prevent de-select - event.accepted = true + ColumnLayout { + id: messageContentColumnLayout + Repeater { + model: ScriptModel { + values: { + const result = StringUtils.splitMarkdownBlocks(root.messageData.content) + // console.log(JSON.stringify(result)) + return result + } } - if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { - messageText.copy() - event.accepted = true + delegate: Loader { + Layout.fillWidth: true + property var segment: modelData + sourceComponent: modelData.type === "code" ? codeBlockComponent : textBlockComponent } } - - onLinkActivated: (link) => { - Qt.openUrlExternally(link) - Hyprland.dispatch("global quickshell:sidebarLeftClose") - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton // Only for hover - hoverEnabled: true - cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor - } } + + Component { // Text block + id: textBlockComponent + TextArea { + readOnly: !root.editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.reading + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.m3colors.m3secondaryContainer + wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + textFormat: root.renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText + text: messageData.thinking ? qsTr("Waiting for response...") : segment.content + + onTextChanged: { + segment.content = text + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Control || event.key == Qt.Key_Shift || event.key == Qt.Key_Alt || event.key == Qt.Key_Meta) { // Prevent de-select + event.accepted = true + } + if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { + messageText.copy() + event.accepted = true + } + } + + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + Hyprland.dispatch("global quickshell:sidebarLeftClose") + } + + MouseArea { // Pointing hand for links + anchors.fill: parent + acceptedButtons: Qt.NoButton // Only for hover + hoverEnabled: true + cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor + } + } + } + + Component { // Code block + id: codeBlockComponent + ColumnLayout { + spacing: codeBlockComponentSpacing + Layout.fillWidth: true + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: codeBlockBackgroundRounding + topRightRadius: codeBlockBackgroundRounding + bottomLeftRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.m3colors.m3surfaceContainerHighest + implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + + RowLayout { // Language and buttons + id: codeBlockTitleBarRowLayout + spacing: 5 + + StyledText { + id: codeBlockLanguage + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: false + Layout.topMargin: 7 + Layout.bottomMargin: 7 + Layout.leftMargin: 10 + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.DemiBold + color: Appearance.colors.colOnLayer2 + text: segment.lang ? Repository.definitionForName(segment.lang).name : "plain" + } + + Item { Layout.fillWidth: true } + } + } + + RowLayout { // Line numbers and code + spacing: codeBlockComponentSpacing + + Rectangle { // Line numbers + implicitWidth: 40 + Layout.fillHeight: true + Layout.fillWidth: false + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: codeBlockBackgroundRounding + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: Appearance.rounding.unsharpen + color: Appearance.colors.colLayer2 + + ColumnLayout { + id: lineNumberColumnLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Repeater { + model: codeTextArea.text.split("\n").length + Text { + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + font.family: Appearance.font.family.monospace + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + horizontalAlignment: Text.AlignRight + text: index + 1 + } + } + } + } + + Rectangle { // Code background + Layout.fillWidth: true + topLeftRadius: Appearance.rounding.unsharpen + bottomLeftRadius: Appearance.rounding.unsharpen + topRightRadius: Appearance.rounding.unsharpen + bottomRightRadius: codeBlockBackgroundRounding + color: Appearance.colors.colLayer2 + // implicitWidth: codeTextArea.implicitWidth + implicitHeight: codeTextArea.implicitHeight + + ScrollView { + id: codeScrollView + Layout.fillWidth: true + Layout.fillHeight: true + implicitWidth: parent.width + implicitHeight: codeTextArea.contentHeight + contentWidth: codeTextArea.contentWidth + contentHeight: codeTextArea.contentHeight + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + TextArea { // Code + + id: codeTextArea + Layout.fillWidth: true + readOnly: !root.editing + renderType: Text.NativeRendering + font.family: Appearance.font.family.monospace + font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text + font.pixelSize: Appearance.font.pixelSize.small + selectedTextColor: Appearance.m3colors.m3onSecondaryContainer + selectionColor: Appearance.m3colors.m3secondaryContainer + // wrapMode: TextEdit.Wrap + color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 + + text: segment.content + onTextChanged: { + segment.content = text + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Tab) { + // Insert 4 spaces at cursor + const cursor = codeTextArea.cursorPosition; + codeTextArea.insert(cursor, " "); + codeTextArea.cursorPosition = cursor + 4; + event.accepted = true; + } else if ( + event.key === Qt.Key_Control || + event.key == Qt.Key_Shift || + event.key == Qt.Key_Alt || + event.key == Qt.Key_Meta + ) { + event.accepted = true; + } else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { + messageText.copy(); + event.accepted = true; + } + } + + SyntaxHighlighter { + id: highlighter + textEdit: codeTextArea + repository: Repository + definition: Repository.definitionForName(segment.lang || "plaintext") + // definition: Repository.definitionForName("cpp") + theme: Appearance.syntaxHighlightingTheme + } + } + } + } + } + } + } + } } diff --git a/.config/quickshell/services/MaterialTheme.qml b/.config/quickshell/services/MaterialTheme.qml index 94b117b42..c34441a1b 100644 --- a/.config/quickshell/services/MaterialTheme.qml +++ b/.config/quickshell/services/MaterialTheme.qml @@ -25,6 +25,8 @@ Singleton { Appearance.m3colors[m3Key] = json[key] } } + + Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5) } Timer { diff --git a/README.md b/README.md index 77c395dc6..84be4f32b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Not ready, but feel free to try it. It's simple: - **Assumption**: You are already using the AGS illogical-impulse -- **Install Qt packages** (idk which are actually needed so this is everything I have): `qt5-base qt5-declarative qt5-graphicaleffects qt5-imageformats qt5-quickcontrols qt5-quickcontrols2 qt5-svg qt5-translations qt5-wayland qt5-x11extras qt6-5compat qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-positioning qt6-quicktimeline qt6-sensors qt6-svg qt6-tools qt6-translations qt6-virtualkeyboard qt6-wayland qt6-webchannel qt6-webengine qt6-websockets qt6-webview` +- **Install Qt packages** (idk which are actually needed so this is everything I have): `qt5-base qt5-declarative qt5-graphicaleffects qt5-imageformats qt5-quickcontrols qt5-quickcontrols2 qt5-svg qt5-translations qt5-wayland qt5-x11extras qt6-5compat qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-positioning qt6-quicktimeline qt6-sensors qt6-svg qt6-tools qt6-translations qt6-virtualkeyboard qt6-wayland qt6-webchannel qt6-webengine qt6-websockets qt6-webview syntax-highlighting` - **Install quickshell and more stuff**: `yay -S quickshell matugen-bin grimblast` - **Copy** `.config/quickshell` folder and hyprland config files in `.config/hypr/hyprland/` (backing up is your responsibility) - **Run quickshell** with `qs` and see how things are - it's not finished for daily use, but **feedback is very welcome**