diff --git a/.config/quickshell/modules/common/functions/string_utils.js b/.config/quickshell/modules/common/functions/string_utils.js index 9fa704ad..97b830bb 100644 --- a/.config/quickshell/modules/common/functions/string_utils.js +++ b/.config/quickshell/modules/common/functions/string_utils.js @@ -33,3 +33,11 @@ function splitMarkdownBlocks(markdown) { } return result; } + +function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; +} + +function escapeBackslashes(str) { + return str.replace(/\\/g, '\\\\'); +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml index 83623f30..07b6a0d6 100644 --- a/.config/quickshell/modules/sidebarLeft/AiChat.qml +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -83,7 +83,8 @@ Item { ## ✏️ Markdown test ### Formatting -*Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon ### Table @@ -114,7 +115,9 @@ int main(int argc, char* argv[]) { ### LaTeX -Inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ +- Simple inline: $\\frac{1}{2} = \\frac{2}{4}$ +- Complex inline: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ +- Another complex inline: \\\\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\\\] `, Ai.interfaceRole); diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml index d570fff1..77def382 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -89,19 +89,25 @@ Rectangle { RowLayout { // Header spacing: 15 + Layout.fillWidth: true Rectangle { // Name id: nameWrapper color: Appearance.m3colors.m3secondaryContainer + // color: "transparent" radius: Appearance.rounding.small - implicitWidth: nameRowLayout.implicitWidth + 10 * 2 implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30) + Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter RowLayout { id: nameRowLayout - anchors.centerIn: parent - spacing: 5 + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 7 Item { Layout.alignment: Qt.AlignVCenter @@ -141,6 +147,8 @@ Rectangle { StyledText { id: providerName Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + elide: Text.ElideRight font.pixelSize: Appearance.font.pixelSize.normal font.weight: Font.DemiBold color: Appearance.m3colors.m3onSecondaryContainer @@ -172,8 +180,6 @@ Rectangle { } } - Item { Layout.fillWidth: true } - RowLayout { spacing: 5 @@ -240,11 +246,15 @@ Rectangle { } delegate: Loader { Layout.fillWidth: true - property var segment: modelData + // property var segment: modelData + property var segmentContent: modelData.content + property var segmentLang: modelData.lang property var messageData: root.messageData property var editing: root.editing property var renderMarkdown: root.renderMarkdown property var enableMouseSelection: root.enableMouseSelection + property bool thinking: root.messageData.thinking + property bool done: root.messageData.done source: modelData.type === "code" ? "MessageCodeBlock.qml" : "MessageTextBlock.qml" } diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml index 96213e25..2cbf4ae7 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -22,7 +22,8 @@ ColumnLayout { property bool editing: parent?.editing ?? false property bool renderMarkdown: parent?.renderMarkdown ?? true property bool enableMouseSelection: parent?.enableMouseSelection ?? false - property var segment: parent?.segment ?? {} + property var segmentContent: parent?.segmentContent ?? ({}) + property var segmentLang: parent?.segmentLang ?? "plaintext" property var messageData: parent?.messageData ?? {} spacing: codeBlockComponentSpacing @@ -57,7 +58,7 @@ ColumnLayout { font.pixelSize: Appearance.font.pixelSize.small font.weight: Font.DemiBold color: Appearance.colors.colOnLayer2 - text: segment.lang ? Repository.definitionForName(segment.lang).name : "plain" + text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain" } Item { Layout.fillWidth: true } @@ -66,7 +67,7 @@ ColumnLayout { id: copyCodeButton buttonIcon: "content_copy" onClicked: { - Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(segment.content)}'`) + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(segmentContent)}'`) } StyledToolTip { content: qsTr("Copy code") @@ -160,7 +161,7 @@ ColumnLayout { id: codeTextArea Layout.fillWidth: true readOnly: !editing - selectByMouse: enableMouseSelection || editing + // selectByMouse: enableMouseSelection || editing renderType: Text.NativeRendering font.family: Appearance.font.family.monospace font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text @@ -170,9 +171,9 @@ ColumnLayout { // wrapMode: TextEdit.Wrap color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 - text: segment.content + text: segmentContent onTextChanged: { - segment.content = text + segmentContent = text } Keys.onPressed: (event) => { @@ -183,7 +184,7 @@ ColumnLayout { codeTextArea.cursorPosition = cursor + 4; event.accepted = true; } else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { - messageText.copy(); + codeTextArea.copy(); event.accepted = true; } } @@ -192,23 +193,22 @@ ColumnLayout { id: highlighter textEdit: codeTextArea repository: Repository - definition: Repository.definitionForName(segment.lang || "plaintext") - // definition: Repository.definitionForName("cpp") + definition: Repository.definitionForName(segmentLang || "plaintext") theme: Appearance.syntaxHighlightingTheme } } } // MouseArea to block scrolling - MouseArea { - id: codeBlockMouseArea - anchors.fill: parent - acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton - cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor - onWheel: (event) => { - event.accepted = false - } - } + // MouseArea { + // id: codeBlockMouseArea + // anchors.fill: parent + // acceptedButtons: editing ? Qt.NoButton : Qt.LeftButton + // cursorShape: (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + // onWheel: (event) => { + // event.accepted = false + // } + // } } } } \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml b/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml index f750e19f..11303784 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/MessageTextBlock.qml @@ -14,50 +14,129 @@ import Quickshell import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects -TextArea { +ColumnLayout { + id: root // These are needed on the parent loader property bool editing: parent?.editing ?? false property bool renderMarkdown: parent?.renderMarkdown ?? true property bool enableMouseSelection: parent?.enableMouseSelection ?? false - property var segment: parent?.segment ?? {} + property string segmentContent: parent?.segmentContent ?? ({}) property var messageData: parent?.messageData ?? {} + property bool done: parent?.done ?? true + property list renderedLatexHashes: [] + + property string renderedSegmentContent: "" Layout.fillWidth: true - readOnly: !editing - selectByMouse: enableMouseSelection || 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: 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_C) && event.modifiers == Qt.ControlModifier) { - messageText.copy() - event.accepted = true + function renderLatex() { + // Regex for $...$, $$...$$, \[...\] + // Note: This is a simple approach and may need refinement for edge cases + let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])/g; + let match; + while ((match = regex.exec(segmentContent)) !== null) { + let expression = match[1] || match[2] || match[3]; + if (expression) { + // Qt.callLater(() => { + // }); + const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim()); + if (!renderedLatexHashes.includes(renderHash)) { + renderedLatexHashes.push(renderHash); + } + } } } - onLinkActivated: (link) => { - Qt.openUrlExternally(link) - Hyprland.dispatch("global quickshell:sidebarLeftClose") + function handleRenderedLatex(hash, force = false) { + if (renderedLatexHashes.includes(hash) || force) { + const imagePath = LatexRenderer.renderedImagePaths[hash]; + const markdownImage = `![latex](${imagePath})`; + + const expression = StringUtils.escapeBackslashes(LatexRenderer.processedExpressions[hash]); + renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage); + } } - MouseArea { // Pointing hand for links - anchors.fill: parent - acceptedButtons: Qt.NoButton // Only for hover - hoverEnabled: true - cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : - (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + onDoneChanged: { + renderLatex() + for (const hash of renderedLatexHashes) { + handleRenderedLatex(hash, true); + } + } + onEditingChanged: { + if (!editing) { + renderLatex() + } + } + + onSegmentContentChanged: { + // console.log("Segment content changed: " + segmentContent); + renderedSegmentContent = segmentContent; + if (!root.editing && segmentContent) { + root.renderLatex(); + } + } + + onRenderedSegmentContentChanged: { + // console.log("Rendered segment content changed: " + renderedSegmentContent); + if (renderedSegmentContent) { + textArea.text = renderedSegmentContent; + } + } + + // When something finishes rendering + // 1. Check if the hash is in the list + // 2. If it is, replace the expression with the image path + Connections { + target: LatexRenderer + function onRenderFinished(hash, imagePath) { + const expression = LatexRenderer.processedExpressions[hash]; + // console.log("Render finished: " + hash + " " + expression); + handleRenderedLatex(hash); + } + } + + TextArea { + id: textArea + + Layout.fillWidth: true + readOnly: !editing + selectByMouse: enableMouseSelection || 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: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText + text: qsTr("Waiting for response...") + + onTextChanged: { + segmentContent = text + } + + Keys.onPressed: (event) => { + 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 : + (enableMouseSelection || editing) ? Qt.IBeamCursor : Qt.ArrowCursor + } } } diff --git a/.config/quickshell/services/LatexRenderer.qml b/.config/quickshell/services/LatexRenderer.qml new file mode 100644 index 00000000..2c0b7f3d --- /dev/null +++ b/.config/quickshell/services/LatexRenderer.qml @@ -0,0 +1,83 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common" +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Qt.labs.platform + +/** +* Renders LaTeX snippets with MicroTeX. +* For every request: +* 1. Hash it +* 2. Check if the hash is already processed +* 3. If not, render it with MicroTeX and mark as processed +*/ +Singleton { + id: root + + readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images + + property list processedHashes: [] + property var processedExpressions: ({}) + property var renderedImagePaths: ({}) + property string microtexBinaryPath: Qt.resolvedUrl("/opt/MicroTeX/LaTeX") + property string latexOutputPath: StringUtils.trimFileProtocol(`${StandardPaths.standardLocations(StandardPaths.CacheLocation)[0]}/latex`) + + signal renderFinished(string hash, string imagePath) + + Component.onCompleted: { + Hyprland.dispatch(`exec rm -rf ${latexOutputPath} && mkdir -p ${latexOutputPath}`) + } + + /** + * Requests rendering of a LaTeX expression. + * Returns the [hash, isNew] + */ + function requestRender(expression) { + // 1. Hash it and initialize necessary variables + const hash = Qt.md5(expression) + const imagePath = `${latexOutputPath}/${hash}.svg` + + // 2. Check if the hash is already processed + if (processedHashes.includes(hash)) { + // console.log("Already processed: " + hash) + renderFinished(hash, imagePath) + return [hash, false] + } else { + root.processedHashes.push(hash) + root.processedExpressions[hash] = expression + // console.log("Rendering expression: " + expression) + } + + // 3. If not, render it with MicroTeX and mark as processed + const processQml = ` + import Quickshell.Io + Process { + id: microtexProcess${hash} + running: true + command: [ "${microtexBinaryPath}", "-headless", + "-input=${StringUtils.escapeBackslashes(expression)}", + "-output=${imagePath}", + "-textsize=${Appearance.font.pixelSize.normal}", + "-padding=${renderPadding}", + "-foreground=${Appearance.colors.colOnLayer1}", + "-maxwidth=0.85" ] + // stdout: SplitParser { + // onRead: data => { console.log("MicroTeX: " + data) } + // } + onExited: (exitCode, exitStatus) => { + renderedImagePaths["${hash}"] = "${imagePath}" + root.renderFinished("${hash}", "${imagePath}") + microtexProcess${hash}.destroy() + } + } + ` + // console.log("MicroTeX: " + processQml) + Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`) + return [hash, true] + } +} \ No newline at end of file