Rearrange for tidier structure (#2212)

This commit is contained in:
clsty
2025-10-16 07:19:55 +08:00
parent 13065d7e5a
commit 8b493e091d
529 changed files with 165 additions and 138 deletions
@@ -0,0 +1,320 @@
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
Rectangle {
id: root
property int messageIndex
property var messageData
property var messageInputField
property real messagePadding: 7
property real contentSpacing: 3
property bool enableMouseSelection: false
property bool renderMarkdown: true
property bool editing: false
property list<var> messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content)
anchors.left: parent?.left
anchors.right: parent?.right
implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2
radius: Appearance.rounding.normal
color: Appearance.colors.colLayer1
function saveMessage() {
if (!root.editing) return;
// Get all Loader children (each represents a segment)
const segments = messageContentColumnLayout.children
.map(child => child.segment)
.filter(segment => (segment));
// 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.editing = false
root.messageData.content = newContent;
}
Keys.onPressed: (event) => {
if ( // Prevent de-select
event.key === Qt.Key_Control ||
event.key == Qt.Key_Shift ||
event.key == Qt.Key_Alt ||
event.key == Qt.Key_Meta
) {
event.accepted = true
}
// Ctrl + S to save
if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) {
root.saveMessage();
event.accepted = true;
}
}
ColumnLayout { // Main layout of the whole thing
id: columnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: messagePadding
spacing: root.contentSpacing
Rectangle {
Layout.fillWidth: true
implicitWidth: headerRowLayout.implicitWidth + 4 * 2
implicitHeight: headerRowLayout.implicitHeight + 4 * 2
color: Appearance.colors.colSecondaryContainer
radius: Appearance.rounding.small
RowLayout { // Header
id: headerRowLayout
anchors {
fill: parent
margins: 4
}
spacing: 18
Item { // Name
id: nameWrapper
implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
RowLayout {
id: nameRowLayout
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
Layout.fillHeight: true
implicitWidth: messageData?.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth
implicitHeight: messageData?.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight
CustomIcon {
id: modelIcon
anchors.centerIn: parent
visible: messageData?.role == 'assistant' && Ai.models[messageData?.model].icon
width: Appearance.font.pixelSize.large
height: Appearance.font.pixelSize.large
source: messageData?.role == 'assistant' ? Ai.models[messageData?.model].icon :
messageData?.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic'
colorize: true
color: Appearance.m3colors.m3onSecondaryContainer
}
MaterialSymbol {
id: roleIcon
anchors.centerIn: parent
visible: !modelIcon.visible
iconSize: Appearance.font.pixelSize.larger
color: Appearance.m3colors.m3onSecondaryContainer
text: messageData?.role == 'user' ? 'person' :
messageData?.role == 'interface' ? 'settings' :
messageData?.role == 'assistant' ? 'neurology' :
'computer'
}
}
StyledText {
id: providerName
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSecondaryContainer
text: messageData?.role == 'assistant' ? Ai.models[messageData?.model].name :
(messageData?.role == 'user' && SystemInfo.username) ? SystemInfo.username :
Translation.tr("Interface")
}
}
}
Button { // Not visible to model
id: modelVisibilityIndicator
visible: messageData?.role == 'interface'
implicitWidth: 16
implicitHeight: 30
Layout.alignment: Qt.AlignVCenter
background: Item
MaterialSymbol {
id: notVisibleToModelText
anchors.centerIn: parent
iconSize: Appearance.font.pixelSize.small
color: Appearance.colors.colSubtext
text: "visibility_off"
}
StyledToolTip {
text: Translation.tr("Not visible to model")
}
}
ButtonGroup {
spacing: 5
AiMessageControlButton {
id: copyButton
buttonIcon: activated ? "inventory" : "content_copy"
onClicked: {
Quickshell.clipboardText = root.messageData?.content
copyButton.activated = true
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyButton.activated = false
}
}
StyledToolTip {
text: Translation.tr("Copy")
}
}
AiMessageControlButton {
id: editButton
activated: root.editing
enabled: root.messageData?.done ?? false
buttonIcon: "edit"
onClicked: {
root.editing = !root.editing
if (!root.editing) { // Save changes
root.saveMessage()
}
}
StyledToolTip {
text: root.editing ? Translation.tr("Save") : Translation.tr("Edit")
}
}
AiMessageControlButton {
id: toggleMarkdownButton
activated: !root.renderMarkdown
buttonIcon: "code"
onClicked: {
root.renderMarkdown = !root.renderMarkdown
}
StyledToolTip {
text: Translation.tr("View Markdown source")
}
}
AiMessageControlButton {
id: deleteButton
buttonIcon: "close"
onClicked: {
Ai.removeMessage(root.messageIndex)
}
StyledToolTip {
text: Translation.tr("Delete")
}
}
}
}
}
Loader {
Layout.fillWidth: true
active: root.messageData?.localFilePath && root.messageData?.localFilePath.length > 0
sourceComponent: AttachedFileIndicator {
filePath: root.messageData?.localFilePath
canRemove: false
}
}
ColumnLayout { // Message content
id: messageContentColumnLayout
spacing: 0
Repeater {
model: root.messageBlocks.length
delegate: Loader {
required property int index
property var thisBlock: root.messageBlocks[index]
Layout.fillWidth: true
// property var segment: thisBlock
property var segmentContent: thisBlock.content
property var segmentLang: thisBlock.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 ?? true
property bool done: root.messageData?.done ?? false
property bool completed: thisBlock.completed ?? false
property bool forceDisableChunkSplitting: root.messageData.content.includes("```")
source: thisBlock.type === "code" ? "MessageCodeBlock.qml" :
thisBlock.type === "think" ? "MessageThinkBlock.qml" :
"MessageTextBlock.qml"
}
}
}
Flow { // Annotations
visible: root.messageData?.annotationSources?.length > 0
spacing: 5
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
Repeater {
model: ScriptModel {
values: root.messageData?.annotationSources || []
}
delegate: AnnotationSourceButton {
required property var modelData
displayText: modelData.text
url: modelData.url
}
}
}
Flow { // Search queries
visible: root.messageData?.searchQueries?.length > 0
spacing: 5
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
Repeater {
model: ScriptModel {
values: root.messageData?.searchQueries || []
}
delegate: SearchQueryButton {
required property var modelData
query: modelData
}
}
}
}
}
@@ -0,0 +1,27 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
GroupButton {
id: button
property string buttonIcon
property bool activated: false
toggled: activated
baseWidth: height
colBackgroundHover: Appearance.colors.colSecondaryContainerHover
colBackgroundActive: Appearance.colors.colSecondaryContainerActive
contentItem: MaterialSymbol {
horizontalAlignment: Text.AlignHCenter
iconSize: Appearance.font.pixelSize.larger
text: buttonIcon
color: button.activated ? Appearance.m3colors.m3onPrimary :
button.enabled ? Appearance.m3colors.m3onSurface :
Appearance.colors.colOnLayer1Inactive
Behavior on color {
animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this)
}
}
}
@@ -0,0 +1,52 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.services
import QtQuick
import QtQuick.Layouts
import Quickshell.Hyprland
RippleButton {
id: root
property string displayText
property string url
property real faviconSize: 20
implicitHeight: 30
leftPadding: (implicitHeight - faviconSize) / 2
rightPadding: 10
buttonRadius: Appearance.rounding.full
colBackground: Appearance.colors.colSurfaceContainerHighest
colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover
colRipple: Appearance.colors.colSurfaceContainerHighestActive
PointingHandInteraction {}
onClicked: {
if (url) {
Qt.openUrlExternally(url)
GlobalStates.sidebarLeftOpen = false
}
}
contentItem: Item {
anchors.centerIn: parent
implicitWidth: rowLayout.implicitWidth
implicitHeight: rowLayout.implicitHeight
RowLayout {
id: rowLayout
anchors.fill: parent
spacing: 5
Favicon {
url: root.url
size: root.faviconSize
displayText: root.displayText
}
StyledText {
id: text
horizontalAlignment: Text.AlignHCenter
text: displayText
color: Appearance.m3colors.m3onSurface
}
}
}
}
@@ -0,0 +1,158 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell.Io
import qs.modules.common
import qs.modules.common.widgets
import qs.services
Rectangle {
id: root
signal remove()
property bool canRemove: true
property string filePath: ""
property string mimeType: ""
property real maxHeight: 200
property real imageWidth: -1
property real imageHeight: -1
property real scale: Math.min(root.maxHeight / imageHeight, root.width / imageWidth)
onFilePathChanged: refresh()
visible: filePath !== ""
function refresh() {
root.mimeType = "";
root.imageWidth = -1;
root.imageHeight = -1;
fileTypeProc.exec(["file", "-b", "--mime-type", filePath]);
}
Process {
id: fileTypeProc
command: ["file", "-b", "--mime-type", filePath]
stdout: StdioCollector {
onStreamFinished: {
root.mimeType = this.text;
if (root.mimeType.startsWith("image/"))
imageSizeProc.exec(["identify", "-format", "%wx%h", filePath]);
}
}
}
Process {
id: imageSizeProc
command: ["identify", "-format", "%wx%h", filePath]
stdout: StdioCollector {
onStreamFinished: {
const dimensions = this.text.split("x");
root.imageWidth = parseInt(dimensions[0]);
root.imageHeight = parseInt(dimensions[1]);
}
}
}
// Styles/widgets
property real horizontalPadding: 10
property real verticalPadding: 10
radius: Appearance.rounding.small - anchors.margins
color: Appearance.colors.colLayer2
implicitHeight: visible ? (contentItem.implicitHeight + verticalPadding * 2) : 0
ColumnLayout {
id: contentItem
anchors {
fill: parent
leftMargin: root.horizontalPadding
rightMargin: root.horizontalPadding
topMargin: root.verticalPadding
bottomMargin: root.verticalPadding
}
RowLayout {
MaterialSymbol {
Layout.alignment: Qt.AlignTop
text: {
if (root.mimeType.startsWith("image/"))
return "image";
if (root.mimeType.startsWith("audio/"))
return "music_note";
if (root.mimeType.startsWith("video/"))
return "movie";
if (root.mimeType === "application/pdf")
return "picture_as_pdf";
if (root.mimeType.startsWith("text/"))
return "description";
return "file_present";
}
iconSize: Appearance.font.pixelSize.hugeass
}
StyledText {
Layout.fillWidth: true
Layout.topMargin: 4
text: root.filePath
font.pixelSize: Appearance.font.pixelSize.smaller
font.family: Appearance.font.family.monospace
wrapMode: Text.Wrap
}
RippleButton {
visible: root.canRemove
Layout.alignment: Qt.AlignTop
buttonRadius: Appearance.rounding.full
colBackground: Appearance.colors.colLayer2
implicitHeight: 28
implicitWidth: 28
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "close"
horizontalAlignment: Text.AlignHCenter
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colOnSurfaceVariant
}
onClicked: root.remove()
}
}
Loader {
id: imagePreviewLoader
visible: (root.imageWidth != -1) && (root.imageHeight != -1)
Layout.alignment: Qt.AlignHCenter
sourceComponent: Item {
implicitHeight: root.imageHeight * root.scale
implicitWidth: imagePreview.implicitWidth
StyledImage {
id: imagePreview
anchors.fill: parent
source: Qt.resolvedUrl(root.filePath)
fillMode: Image.PreserveAspectFit
antialiasing: true
width: root.imageWidth * root.scale
height: root.imageHeight * root.scale
sourceSize.width: root.imageWidth * root.scale
sourceSize.height: root.imageHeight * root.scale
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: imagePreview.width
height: imagePreview.height
radius: Appearance.rounding.normal
}
}
Rectangle {
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: Appearance.colors.colOutlineVariant
radius: Appearance.rounding.normal
}
}
}
}
}
}
@@ -0,0 +1,296 @@
pragma ComponentBehavior: Bound
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 org.kde.syntaxhighlighting
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 segmentContent: parent?.segmentContent ?? ({})
property var segmentLang: parent?.segmentLang ?? "txt"
property bool isCommandRequest: segmentLang === "command"
property var displayLang: (isCommandRequest ? "bash" : segmentLang)
property var messageData: parent?.messageData ?? {}
property real codeBlockBackgroundRounding: Appearance.rounding.small
property real codeBlockHeaderPadding: 3
property real codeBlockComponentSpacing: 2
spacing: codeBlockComponentSpacing
anchors.left: parent.left
anchors.right: parent.right
Rectangle { // Code background
Layout.fillWidth: true
topLeftRadius: codeBlockBackgroundRounding
topRightRadius: codeBlockBackgroundRounding
bottomLeftRadius: Appearance.rounding.unsharpen
bottomRightRadius: Appearance.rounding.unsharpen
color: Appearance.colors.colSurfaceContainerHighest
implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2
RowLayout { // Language and buttons
id: codeBlockTitleBarRowLayout
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: codeBlockHeaderPadding
anchors.rightMargin: codeBlockHeaderPadding
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: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain"
}
Item { Layout.fillWidth: true }
ButtonGroup {
AiMessageControlButton {
id: copyCodeButton
buttonIcon: activated ? "inventory" : "content_copy"
onClicked: {
Quickshell.clipboardText = segmentContent
copyCodeButton.activated = true
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyCodeButton.activated = false
}
}
StyledToolTip {
text: Translation.tr("Copy code")
}
}
AiMessageControlButton {
id: saveCodeButton
buttonIcon: activated ? "check" : "save"
onClicked: {
const downloadPath = FileUtils.trimFileProtocol(Directories.downloads)
Quickshell.execDetached(["bash", "-c",
`echo '${StringUtils.shellSingleQuoteEscape(segmentContent)}' > '${downloadPath}/code.${segmentLang || "txt"}'`
])
Quickshell.execDetached(["notify-send",
Translation.tr("Code saved to file"),
Translation.tr("Saved to %1").arg(`${downloadPath}/code.${segmentLang || "txt"}`),
"-a", "Shell"
])
saveCodeButton.activated = true
saveIconTimer.restart()
}
Timer {
id: saveIconTimer
interval: 1500
repeat: false
onTriggered: {
saveCodeButton.activated = false
}
}
StyledToolTip {
text: Translation.tr("Save to Downloads")
}
}
}
}
}
RowLayout { // Line numbers and code
spacing: codeBlockComponentSpacing
Rectangle { // Line numbers
implicitWidth: 40
implicitHeight: lineNumberColumnLayout.implicitHeight
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
right: parent.right
rightMargin: 5
top: parent.top
topMargin: 6
}
spacing: 0
Repeater {
model: codeTextArea.text.split("\n").length
Text {
required property int index
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
implicitHeight: codeColumnLayout.implicitHeight
ColumnLayout {
id: codeColumnLayout
anchors.fill: parent
spacing: 0
ScrollView {
id: codeScrollView
Layout.fillWidth: true
// Layout.fillHeight: true
implicitWidth: parent.width
implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1
// contentHeight: codeTextArea.contentHeight
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
ScrollBar.horizontal: ScrollBar {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
padding: 5
policy: ScrollBar.AsNeeded
opacity: visualSize == 1 ? 0 : 1
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
contentItem: Rectangle {
implicitHeight: 6
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2Active
}
}
TextArea { // Code
id: codeTextArea
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || 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.colors.colSecondaryContainer
// wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
text: segmentContent
onTextChanged: {
segmentContent = 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_C) && event.modifiers == Qt.ControlModifier) {
codeTextArea.copy();
event.accepted = true;
}
}
SyntaxHighlighter {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(root.displayLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
}
}
Loader {
active: root.isCommandRequest && root.messageData.functionPending
visible: active
Layout.fillWidth: true
Layout.margins: 6
Layout.topMargin: 0
sourceComponent: RowLayout {
Item { Layout.fillWidth: true }
ButtonGroup {
GroupButton {
contentItem: StyledText {
text: Translation.tr("Reject")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer2
}
onClicked: Ai.rejectCommand(root.messageData)
}
GroupButton {
toggled: true
contentItem: StyledText {
text: Translation.tr("Approve")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnPrimary
}
onClicked: Ai.approveCommand(root.messageData)
}
}
}
}
}
// 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
// }
// }
}
}
}
@@ -0,0 +1,192 @@
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"
// }
}
}
}
@@ -0,0 +1,172 @@
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
Item {
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 bool completed: parent?.completed ?? false
property real thinkBlockBackgroundRounding: Appearance.rounding.small
property real thinkBlockHeaderPaddingVertical: 3
property real thinkBlockHeaderPaddingHorizontal: 10
property real thinkBlockComponentSpacing: 2
property var collapseAnimation: messageTextBlock.implicitHeight > 40 ? Appearance.animation.elementMoveEnter : Appearance.animation.elementMoveFast
property bool collapsed: true /* should be root.completed but its kinda buggy rn so nope */
Layout.fillWidth: true
implicitHeight: collapsed ? header.implicitHeight : columnLayout.implicitHeight
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: root.width
height: root.height
radius: thinkBlockBackgroundRounding
}
}
Behavior on implicitHeight {
enabled: root.completed ?? false
NumberAnimation {
duration: collapseAnimation.duration
easing.type: collapseAnimation.type
easing.bezierCurve: collapseAnimation.bezierCurve
}
}
ColumnLayout {
id: columnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 0
Rectangle { // Header background
id: header
color: Appearance.colors.colSurfaceContainerHighest
Layout.fillWidth: true
implicitHeight: thinkBlockTitleBarRowLayout.implicitHeight + thinkBlockHeaderPaddingVertical * 2
MouseArea { // Click to reveal
id: headerMouseArea
enabled: root.completed
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
root.collapsed = !root.collapsed
}
}
RowLayout { // Header content
id: thinkBlockTitleBarRowLayout
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: thinkBlockHeaderPaddingHorizontal
anchors.rightMargin: thinkBlockHeaderPaddingHorizontal
spacing: 10
MaterialSymbol {
Layout.fillWidth: false
Layout.topMargin: 7
Layout.bottomMargin: 7
Layout.leftMargin: 3
text: "linked_services"
}
StyledText {
id: thinkBlockLanguage
Layout.fillWidth: false
Layout.alignment: Qt.AlignLeft
text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4))
}
Item { Layout.fillWidth: true }
RippleButton { // Expand button
id: expandButton
visible: root.completed
implicitWidth: 22
implicitHeight: 22
colBackground: headerMouseArea.containsMouse ? Appearance.colors.colLayer2Hover
: ColorUtils.transparentize(Appearance.colors.colLayer2, 1)
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
onClicked: { root.collapsed = !root.collapsed }
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "keyboard_arrow_down"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
rotation: root.collapsed ? 0 : 180
Behavior on rotation {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}
}
}
Item {
id: content
Layout.fillWidth: true
implicitHeight: collapsed ? 0 : contentBackground.implicitHeight + thinkBlockComponentSpacing
clip: true
Behavior on implicitHeight {
enabled: root.completed ?? false
NumberAnimation {
duration: collapseAnimation.duration
easing.type: collapseAnimation.type
easing.bezierCurve: collapseAnimation.bezierCurve
}
}
Rectangle {
id: contentBackground
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
implicitHeight: messageTextBlock.implicitHeight
color: Appearance.colors.colLayer2
// Load data for the message at the correct scope
property bool editing: root.editing
property bool renderMarkdown: root.renderMarkdown
property bool enableMouseSelection: root.enableMouseSelection
property string segmentContent: root.segmentContent
property var messageData: root.messageData
property bool done: root.done
MessageTextBlock {
id: messageTextBlock
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
}
}
}
}
}
@@ -0,0 +1,53 @@
import qs
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell.Hyprland
RippleButton {
id: root
property string query
implicitHeight: 30
leftPadding: 6
rightPadding: 10
buttonRadius: Appearance.rounding.verysmall
colBackground: Appearance.colors.colSurfaceContainerHighest
colBackgroundHover: Appearance.colors.colSurfaceContainerHighestHover
colRipple: Appearance.colors.colSurfaceContainerHighestActive
PointingHandInteraction {}
onClicked: {
let url = Config.options.search.engineBaseUrl + root.query;
for (let site of (Config?.options?.search.excludedSites ?? [])) {
url += ` -site:${site}`;
}
Qt.openUrlExternally(url);
GlobalStates.sidebarLeftOpen = false;
}
contentItem: Item {
anchors.centerIn: parent
implicitWidth: rowLayout.implicitWidth
implicitHeight: rowLayout.implicitHeight
RowLayout {
id: rowLayout
anchors.centerIn: parent
spacing: 5
MaterialSymbol {
text: "search"
iconSize: 20
color: Appearance.m3colors.m3onSurface
}
StyledText {
id: text
horizontalAlignment: Text.AlignHCenter
text: root.query
color: Appearance.m3colors.m3onSurface
}
}
}
}