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 = ``;
+
+ 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