Files
dots-hyprland/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageTextBlock.qml
T

193 lines
7.0 KiB
QML

pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
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 string segmentContent: parent?.segmentContent ?? ({})
property var messageData: parent?.messageData ?? {}
property bool done: parent?.done ?? true
property list<string> renderedLatexHashes: []
property string renderedSegmentContent: ""
property string shownText: ""
property bool forceDisableChunkSplitting: parent?.forceDisableChunkSplitting ?? false
property bool fadeChunkSplitting: !forceDisableChunkSplitting && !editing && !/\n\|/.test(shownText) && Config.options.sidebar.ai.textFadeIn
Layout.fillWidth: true
Timer {
id: renderTimer
interval: 1000
repeat: false
onTriggered: {
renderLatex()
for (const hash of renderedLatexHashes) {
handleRenderedLatex(hash, true);
}
}
}
function renderLatex() {
// Regex for $...$, $$...$$, \[...\]
// Note: This is a simple approach and may need refinement for edge cases
let regex = /(\$\$([\s\S]+?)\$\$)|(\$([^\$]+?)\$)|(\\\[((?:.|\n)+?)\\\])|(\\\(([\s\S]+?)\\\))/g;
let match;
while ((match = regex.exec(segmentContent)) !== null) {
let expression = match[1] || match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8];
if (expression) {
Qt.callLater(() => {
const [renderHash, isNew] = LatexRenderer.requestRender(expression.trim());
if (!renderedLatexHashes.includes(renderHash)) {
renderedLatexHashes.push(renderHash);
}
});
}
}
}
function handleRenderedLatex(hash, force = false) {
if (renderedLatexHashes.includes(hash) || force) {
const imagePath = LatexRenderer.renderedImagePaths[hash];
const markdownImage = `![latex](${imagePath})`;
const expression = LatexRenderer.processedExpressions[hash];
renderedSegmentContent = renderedSegmentContent.replace(expression, markdownImage);
}
}
onDoneChanged: {
renderTimer.restart();
}
onEditingChanged: {
if (!editing) {
renderLatex()
} else {
// console.log("Editing mode enabled", segmentContent)
root.shownText = segmentContent
}
}
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) {
root.shownText = 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);
}
}
spacing: 0
Repeater {
id: textLinesRepeater
property list<real> textLineOpacities: []
model: ScriptModel {
// Split by either double newlines or single newlines in a list
values: root.fadeChunkSplitting ? root.shownText.split(/\n\n(?= {0,2})|\n(?= {0,2}[-\*])/g).filter(line => line.trim() !== "") : [root.shownText]
onValuesChanged: {
while (textLinesRepeater.textLineOpacities.length < values.length) {
textLinesRepeater.textLineOpacities.push(root.messageData.done ? 1 : 0);
}
}
}
delegate: TextArea {
id: textArea
required property int index
required property string modelData
// Fade in animation
visible: opacity > 0
opacity: fadeChunkSplitting ? (textLinesRepeater.textLineOpacities[index] ?? (root.messageData.done ? 1 : 0)) : 1
Connections {
target: root.messageData
function onDoneChanged() {
if (root.messageData.done) {
textLinesRepeater.textLineOpacities[textArea.index] = 1
}
}
}
Connections {
target: textLinesRepeater.model
function onValuesChanged() {
if (textLinesRepeater.model.values.length > textArea.index + 1) {
textLinesRepeater.textLineOpacities[textArea.index] = 1
}
}
}
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
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.colors.colSecondaryContainer
wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
textFormat: renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
text: modelData
onTextChanged: {
if (!root.editing) return
segmentContent = text
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
GlobalStates.sidebarLeftOpen = false
}
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
}
// Rectangle {
// anchors.fill: parent
// color: "#22786378"
// border.width: 1
// border.color: "#7E7E7E"
// }
}
}
}