From 293bf67e099a496e6d5e39364b49292c5bb2fb3c Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 27 Aug 2026 01:47:17 +0200 Subject: [PATCH] better scrollbar in codeblocks + enter anim for delegates --- .qmlformat.ini | 2 +- Components/CustomMouseArea.qml | 1 + Components/CustomScrollBar.qml | 26 +- Drawers/Windows.qml | 2 +- .../Sidebar/Chat/ChatContent.qml | 167 +++++++++-- .../Notifications/Sidebar/Chat/ChatInput.qml | 2 +- .../Sidebar/Chat/Content/BubbleEdit.qml | 28 ++ .../Sidebar/Chat/Content/CodeBlockView.qml | 52 +++- .../Sidebar/Chat/Content/ContentBubble.qml | 35 ++- .../Sidebar/Chat/Content/MarkdownBlocks.qml | 19 +- .../Sidebar/Chat/Content/MessageDelegate.qml | 44 +-- .../Settings/Common/DialogSelectButton.qml | 9 +- .../Pages/Panels/Sidebar/SidebarLlm.qml | 2 + Plugins/ZShell/Llm/codehighlighter.cpp | 265 ++++++++++-------- Plugins/ZShell/Llm/codehighlighter.hpp | 22 ++ Plugins/ZShell/Llm/markdownparser.cpp | 11 +- 16 files changed, 458 insertions(+), 229 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml diff --git a/.qmlformat.ini b/.qmlformat.ini index 3956296..828d0c9 100644 --- a/.qmlformat.ini +++ b/.qmlformat.ini @@ -3,7 +3,7 @@ FunctionsSpacing=true IndentWidth=4 MaxColumnWidth=-1 NewlineType=native -GroupAttributesTogether=true +GroupAttributesTogether=false ObjectsSpacing=true SemicolonRule=always SingleLineEmptyObjects=true diff --git a/Components/CustomMouseArea.qml b/Components/CustomMouseArea.qml index 6d52d16..0737476 100644 --- a/Components/CustomMouseArea.qml +++ b/Components/CustomMouseArea.qml @@ -4,6 +4,7 @@ MouseArea { property int scrollAccumulatedY: 0 function onWheel(event: WheelEvent): void { + event.accepted = false; } onWheel: event => { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 581cb11..810dd2e 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -24,13 +24,8 @@ ScrollBar { readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 enabled: !Visibilities.getForActive().isDrawing - parent: flickable.parent - anchors.left: isHorizontal ? flickable.left : undefined - anchors.right: flickable.right - anchors.top: isHorizontal ? undefined : flickable.top - anchors.bottom: flickable.bottom - implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2 - implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0 + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 contentItem: Item {} Behavior on position { @@ -59,6 +54,7 @@ ScrollBar { Loader { anchors.fill: parent + active: root.size < 1 sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack } @@ -116,6 +112,17 @@ ScrollBar { return visualPos * root.travelScale; } + function onWheel(event: WheelEvent): void { + if (root.horizontal) { + event.accepted = false; + return; + } + + var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; + var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); + root.position = newPos; + } + anchors.fill: parent cursorShape: undefined hoverEnabled: true @@ -136,10 +143,5 @@ ScrollBar { updateFromEvent(event); } - onWheel: event => { - var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1; - var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); - root.position = newPos; - } } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 779f623..b2ecfb6 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -55,7 +55,7 @@ CustomWindow { property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index dfb647a..2413981 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -12,6 +12,78 @@ Item { property ChatSession chatData property bool following: true + // qmlformat off + readonly property var keys: [ + Qt.Key_A, + Qt.Key_B, + Qt.Key_C, + Qt.Key_D, + Qt.Key_E, + Qt.Key_F, + Qt.Key_G, + Qt.Key_H, + Qt.Key_I, + Qt.Key_J, + Qt.Key_K, + Qt.Key_L, + Qt.Key_M, + Qt.Key_N, + Qt.Key_O, + Qt.Key_P, + Qt.Key_Q, + Qt.Key_R, + Qt.Key_S, + Qt.Key_T, + Qt.Key_U, + Qt.Key_V, + Qt.Key_W, + Qt.Key_X, + Qt.Key_Y, + Qt.Key_Z, + + Qt.Key_Agrave, + Qt.Key_Aacute, + Qt.Key_Acircumflex, + Qt.Key_Atilde, + Qt.Key_Adiaeresis, + Qt.Key_Aring, + Qt.Key_AE, + + Qt.Key_Ccedilla, + + Qt.Key_Egrave, + Qt.Key_Eacute, + Qt.Key_Ecircumflex, + Qt.Key_Ediaeresis, + + Qt.Key_Igrave, + Qt.Key_Iacute, + Qt.Key_Icircumflex, + Qt.Key_Idiaeresis, + + Qt.Key_ETH, + + Qt.Key_Ntilde, + + Qt.Key_Ograve, + Qt.Key_Oacute, + Qt.Key_Ocircumflex, + Qt.Key_Otilde, + Qt.Key_Odiaeresis, + Qt.Key_Ooblique, + + Qt.Key_Ugrave, + Qt.Key_Uacute, + Qt.Key_Ucircumflex, + Qt.Key_Udiaeresis, + + Qt.Key_Yacute, + Qt.Key_ydiaeresis, + + Qt.Key_THORN, + Qt.Key_ssharp + ] + // qmlformat on signal requestClose @@ -27,6 +99,21 @@ Item { input.text = ""; } + function focusInput(): void { + Qt.callLater(() => input.forceActiveFocus()); + } + + onChatDataChanged: { + if (chatData) + focusInput(); + } + Keys.onPressed: e => { + if (root.keys.includes(e.key)) { + input.insert(input.length, e.text); + focusInput(); + } + } + RowLayout { id: header @@ -68,9 +155,11 @@ Item { id: list property bool userScrolledUp: false + property real lastCHeight: 0.0 function scrollToBottom(): void { - Qt.callLater(() => positionViewAtBeginning()); + scrollAnim.to = 0; + scrollAnim.start(); } cacheBuffer: height * 20 @@ -80,47 +169,67 @@ Item { model: root.chatData.messagesModel spacing: 0 rotation: 180 + add: Transition { + Anim { + from: -100 + property: "y" + } + } - // add: Transition { - // Anim { - // from: 10 - // property: "y" - // } - // } + CustomScrollBar.vertical: CustomScrollBar { + id: scrollBar + + flickable: list + parent: list.parent + anchors.top: list.top + anchors.right: list.right + anchors.bottom: list.bottom + + transform: Rotation { + origin.y: list.height / 2 + origin.x: scrollBar.width / 2 + angle: 180 + + axis { + y: 0 + x: 1 + z: 0 + } + } + } delegate: MessageDelegate { rotation: 180 } - // displaced: Transition { - // Anim { - // property: "y" - // } - // } - // move: Transition { - // Anim { - // property: "y" - // } - // } + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } + } Component.onCompleted: { positionViewAtBeginning(); - forceActiveFocus(); } - onAtYEndChanged: { - if (atYEnd) + onAtYBeginningChanged: { + if (atYBeginning) userScrolledUp = false; } onContentHeightChanged: { - if (!userScrolledUp && atYEnd) - scrollToBottom(); - } - onCountChanged: { - if (!userScrolledUp) - scrollToBottom(); + if (userScrolledUp && Chat.busy) { + const delta = contentHeight - lastCHeight; + contentY += delta; + } + + lastCHeight = contentHeight; } onMovingChanged: { if (moving) - userScrolledUp = !atYEnd; + userScrolledUp = !atYBeginning; } Anim { @@ -179,7 +288,7 @@ Item { type: IconButton.Tonal onClicked: { - listLoader.item.positionViewAtBeginning(); + listLoader.item.scrollToBottom(); } } } @@ -194,11 +303,13 @@ Item { bg.color: Colors.tPalette.m3surfaceContainerLowest font.pointSize: Tokens.font.size.normal implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + focus: true placeholderText: qsTr("Send a message") sendIcon.font.pointSize: Tokens.font.size.large sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall + Component.onCompleted: root.focusInput() Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml index 4d7216d..058ac31 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatInput.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -31,7 +31,7 @@ TextAreaBase { enabled: !root.activeFocus manualPressOverride: tapHandler.pressed - onClicked: root.focus = true + onClicked: root.forceActiveFocus() } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml new file mode 100644 index 0000000..e335cf5 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -0,0 +1,28 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextEditBase { + id: root + + color: Colors.palette.m3onSurface + readOnly: true + anchors.margins: Tokens.padding.medium + textFormat: Text.MarkdownText + font.pointSize: Tokens.font.size.smaller + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onLinkActivated: link => { + Qt.openUrlExternally(link); + } + + CustomMouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor + preventStealing: false + hoverEnabled: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index e9f007e..e264c85 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Effects import QtQuick.Shapes import QtQuick.Layouts import Quickshell @@ -7,7 +8,7 @@ import ZShell.Llm import qs.Components import qs.Services -CustomClippingRect { +CustomRect { id: root required property string language @@ -15,16 +16,11 @@ CustomClippingRect { property bool copied: false property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeHeaderColor: Colors.palette.m3outline - - // Highlighter spans for the current code; refreshed when the code or - // its language changes. Highlighting runs off the GUI thread; the - // token drops results that arrive after the code already changed. property var codeSpans: [] property int highlightToken: 0 function refresh() { const token = ++root.highlightToken; - codeSpans = []; CodeHighlighter.highlight(root.code, root.language, root, token); } @@ -84,10 +80,14 @@ CustomClippingRect { let pos = 0; for (let i = 0; i < spans.length; i++) { const span = spans[i]; - if (span.start > pos) - out += escapeHtml(code.slice(pos, span.start)); - out += `` + escapeHtml(code.slice(span.start, span.start + span.length)) + ""; - pos = span.start + span.length; + const start = Math.min(span.start, code.length); + const end = Math.min(span.start + span.length, code.length); + if (end <= pos) + continue; + if (start > pos) + out += escapeHtml(code.slice(pos, start)); + out += `` + escapeHtml(code.slice(start, end)) + ""; + pos = end; } if (pos < code.length) out += escapeHtml(code.slice(pos)); @@ -100,7 +100,10 @@ CustomClippingRect { color: root.codeBackgroundColor radius: Tokens.rounding.medium - onLanguageChanged: refresh() + onLanguageChanged: { + codeSpans = []; + refresh(); + } onCodeChanged: refresh() RowLayout { @@ -212,7 +215,7 @@ CustomClippingRect { } } - CustomRect { + CustomClippingRect { id: codeRect anchors.left: parent.left @@ -226,6 +229,21 @@ CustomClippingRect { implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 color: CodeColors.active.bg + CustomText { + id: code + anchors.top: parent.top + anchors.bottom: parent.bottom + x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small + anchors.margins: Tokens.padding.small + text: root.highlightedHtml(root.code, root.codeSpans) + textFormat: Text.RichText + color: CodeColors.active.normal + layer.enabled: true + clip: false + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + } + Flickable { id: codeFlick @@ -234,15 +252,23 @@ CustomClippingRect { CustomScrollBar.horizontal: CustomScrollBar { flickable: codeFlick + parent: codeFlick.parent + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right } + TextAreaBase.flickable: TextAreaBase { id: codeText color: CodeColors.active.normal + layer.enabled: true + leftInset: Tokens.padding.small + clip: false font.family: Config.appearance.font.family.mono font.pointSize: Tokens.font.size.small textFormat: Text.RichText - text: root.highlightedHtml(root.code, root.codeSpans) + text: code.text readOnly: true } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 5e93159..75666af 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -24,7 +24,7 @@ Item { implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin - CustomRect { + CustomClippingRect { id: bubble radius: Tokens.rounding.medium @@ -33,16 +33,21 @@ Item { implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined - Behavior on implicitHeight { - // enabled: root.segment.running - - Anim {} - } + // Behavior on implicitHeight { + // enabled: !root.segment.running + // + // Anim { + // type: Anim.DefaultEffects + // } + // } // User messages stay a plain editable text field. TextEditBase { id: msgText + property string cachedText: root.segment.text + property bool cancelled: false + visible: root.isUser anchors.left: parent.left anchors.margins: Tokens.padding.medium @@ -65,27 +70,29 @@ Item { event.accepted = true; } } else if (event.key == Qt.Key_Escape) { - text = root.segment.text; + cancelled = true; readOnly = true; event.accepted = true; } } onEditingFinished: { - const old = root.segment.text; - if (old !== text) - root.edit(text); - readOnly = true; } onReadOnlyChanged: { if (readOnly) { animateCursor = false; - root.forceActiveFocus(); textFormat = CustomText.MarkdownText; + + if (cancelled) { + text = cachedText; + cancelled = false; + return; + } + + root.edit(text); } else { - var raw = root.segment.text; textFormat = CustomText.PlainText; - text = raw; + text = cachedText; forceActiveFocus(); cursorPosition = text.length; animateCursor = true; diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index 834b66d..32377ad 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import ZShell.Config import ZShell.Llm import qs.Components @@ -7,7 +8,6 @@ import qs.Services Column { id: root - // Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown). required property var blocks spacing: Tokens.spacing.small @@ -15,8 +15,10 @@ Column { Repeater { id: blockRep - model: root.blocks - + model: ScriptModel { + values: root.blocks + objectProp: "id" + } delegate: DelegateChooser { role: "type" @@ -50,7 +52,7 @@ Column { DelegateChoice { roleValue: LlmMarkdown.Type.Heading - delegate: CustomText { + delegate: TextEditBase { required property var modelData color: Colors.palette.m3onSurface @@ -59,10 +61,11 @@ Column { anchors.margins: Tokens.padding.medium text: modelData.text textFormat: Text.MarkdownText + readOnly: true font.bold: true font.pointSize: { if (modelData.level <= 1) - return Tokens.font.size.larger; + return Tokens.font.size.large; if (modelData.level === 2) return Tokens.font.size.normal; return Tokens.font.size.smaller; @@ -74,17 +77,13 @@ Column { DelegateChoice { roleValue: LlmMarkdown.Type.Text - delegate: CustomText { + delegate: BubbleEdit { required property var modelData - color: Colors.palette.m3onSurface anchors.right: parent.right anchors.left: parent.left anchors.margins: Tokens.padding.medium text: modelData.text - textFormat: Text.MarkdownText - font.pointSize: Tokens.font.size.smaller - wrapMode: Text.WrapAtWordBoundaryOrAnywhere } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index d69f109..036c061 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -83,8 +83,9 @@ MouseArea { Anim { target: root property: "x" - to: changeAnim.next ? -root.width / 2 : root.width / 2 + to: changeAnim.next ? root.width / 4 : -root.width / 4 from: 0 + type: Anim.FastEffects } Anim { @@ -92,6 +93,7 @@ MouseArea { property: "opacity" from: 1 to: 0 + type: Anim.FastEffects } } @@ -101,7 +103,8 @@ MouseArea { Anim { target: root property: "x" - from: changeAnim.next ? root.width / 2 : -root.width / 2 + from: changeAnim.next ? -root.width / 4 : root.width / 4 + type: Anim.FastEffects to: 0 } @@ -109,30 +112,21 @@ MouseArea { target: root property: "opacity" from: 0 + type: Anim.FastEffects to: 1 } } } } - // Behavior on implicitHeight { - // enabled: !root.isUser && root.current.streaming - // - // Anim {} - // } - - onCurrentChanged: { - console.log(modelData.generations.indexOf(current)); + Binding { + property: "contentY" + restoreMode: Binding.RestoreNone + target: root.ListView.view + value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height + when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height) } - // Binding { - // property: "contentY" - // restoreMode: Binding.RestoreNone - // target: root.ListView.view - // value: root.y - Tokens.padding.large * 2 - // when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) - // } - Anim { id: restoreAnim @@ -148,20 +142,6 @@ MouseArea { anchors.left: parent.left anchors.right: parent.right - // onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight) - - // add: Transition { - // Anim { - // from: 10 - // property: "y" - // } - // } - // move: Transition { - // Anim { - // property: "y" - // } - // } - Repeater { id: segmentRep diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..87c93bd 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -10,6 +10,7 @@ DialogRowButton { required property var model property var selectedItem + property var initialSelect function keyFor(item: var): string { return item.id; @@ -91,7 +92,11 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } } } diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml index 2c91863..ebb3445 100644 --- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -84,6 +84,8 @@ PageBase { model: root.schemes rootParent: root.flickable + initialSelect: Config.llm.appearance.scheme + onAccepted: { if (!selectedItem) return; diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index ea99e54..c926c57 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -21,7 +21,6 @@ namespace ZShell::llm { namespace hl { -// Role ids; 0 means "no color". enum Role : uint8_t { None = 0, Comment, @@ -43,29 +42,43 @@ enum Role : uint8_t { const char* roleName(Role role) { switch (role) { - case Comment: return "comment"; - case String: return "string"; - case StringKey: return "string.key"; - case Number: return "number"; - case Constant: return "constant"; - case Keyword: return "keyword"; - case Type: return "type"; - case Function: return "function"; - case Method: return "method"; - case Macro: return "macro"; - case Preproc: return "preproc"; - case Operator: return "operator"; - case Property: return "property"; - case Label: return "label"; - case Attribute: return "attribute"; - default: return ""; + case Comment: + return "comment"; + case String: + return "string"; + case StringKey: + return "string.key"; + case Number: + return "number"; + case Constant: + return "constant"; + case Keyword: + return "keyword"; + case Type: + return "type"; + case Function: + return "function"; + case Method: + return "method"; + case Macro: + return "macro"; + case Preproc: + return "preproc"; + case Operator: + return "operator"; + case Property: + return "property"; + case Label: + return "label"; + case Attribute: + return "attribute"; + default: + return ""; } } using LanguageFn = const TSLanguage* (*)(); -// Grammar registry generated by CMake from the installed grammars and -// their highlight queries (see CMakeLists.txt). const QHash& grammars() { static const QHash grammars = [] { QHash map; @@ -89,8 +102,6 @@ const QHash& grammars() { CodeHighlighter* CodeHighlighter::s_instance = nullptr; const QHash& CodeHighlighter::aliases() { - // Language tags as written in code fences (and common variants) to - // grammar id. static const QHash aliases = [] { QHash map; map.insert("c", "c"); @@ -145,49 +156,38 @@ const QHash& CodeHighlighter::aliases() { uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { const QString n = QString::fromUtf8(name, length); - if (n == "comment") - return hl::Role::Comment; + if (n == "comment") return hl::Role::Comment; if (n.startsWith("string")) - return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; - if (n == "escape" || n == "regexp") - return hl::Role::String; - if (n.startsWith("number")) - return hl::Role::Number; - if (n.startsWith("constant") || n == "boolean" || n == "bool" - || n.startsWith("character")) + return n == "string.special.key" ? hl::Role::StringKey + : hl::Role::String; + if (n == "escape" || n == "regexp") return hl::Role::String; + if (n.startsWith("number")) return hl::Role::Number; + if (n.startsWith("constant") || n == "boolean" || n == "bool" || + n.startsWith("character")) return hl::Role::Constant; - if (n.startsWith("keyword")) - return hl::Role::Keyword; - if (n == "type" || n.startsWith("type.")) + if (n.startsWith("keyword")) return hl::Role::Keyword; + if (n == "type" || n.startsWith("type.")) return hl::Role::Type; + if (n.startsWith("namespace") || n.startsWith("module") || + n == "support.type" || n == "support.namespace") return hl::Role::Type; - if (n.startsWith("namespace") || n.startsWith("module") - || n == "support.type" || n == "support.namespace") - return hl::Role::Type; - if (n.startsWith("function") || n == "constructor" - || n.startsWith("support.function")) + if (n.startsWith("function") || n == "constructor" || + n.startsWith("support.function")) return hl::Role::Function; - if (n == "method" || n == "method.builtin") - return hl::Role::Method; - if (n.startsWith("macro")) - return hl::Role::Macro; - if (n.startsWith("preproc")) - return hl::Role::Preproc; - if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.") - || n.startsWith("punctuation")) + if (n == "method" || n == "method.builtin") return hl::Role::Method; + if (n.startsWith("macro")) return hl::Role::Macro; + if (n.startsWith("preproc")) return hl::Role::Preproc; + if (n == "operator" || n == "punctuation.operator" || + n.startsWith("operator.") || n.startsWith("punctuation")) return hl::Role::Operator; if (n == "property" || n == "field" || n.startsWith("property.")) return hl::Role::Property; - if (n == "label") - return hl::Role::Label; + if (n == "label") return hl::Role::Label; if (n.startsWith("attribute") || n == "annotation") return hl::Role::Attribute; - // HTML tag names, CSS variables and friends. if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter")) return hl::Role::Keyword; - if (n.startsWith("variable")) - return hl::Role::Constant; - if (n.startsWith("support")) - return hl::Role::Function; + if (n.startsWith("variable")) return hl::Role::Constant; + if (n.startsWith("support")) return hl::Role::Function; return hl::Role::None; } @@ -195,68 +195,116 @@ const char* CodeHighlighter::roleName(uint8_t role) { return hl::roleName(static_cast(role)); } +QString CodeHighlighter::resolveId(const QString& language) { + const QString tag = language.trimmed().toLower(); + const QString alias = aliases().value(tag); + return alias.isEmpty() ? tag : alias; // unknown tags = grammar id +} + +QString CodeHighlighter::cacheKey(const QString& id, const QString& code) { + return id + QLatin1Char('\x01') + QString::number(code.size()) + + QLatin1Char('\x01') + QString::number(qHash(code)); +} + +QVariantList CodeHighlighter::lookupSpans( + const QString& code, const QString& language) const { + if (code.isEmpty()) return {}; + const QString key = cacheKey(resolveId(language), code); + QMutexLocker locker(&m_cacheMutex); + const auto it = m_spanCache.constFind(key); + if (it == m_spanCache.constEnd() || it->code != code) return {}; + // Most recently used; eviction drops the oldest entries first. + const qsizetype pos = m_spanCacheOrder.indexOf(key); + if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); + return it->spans; +} + +void CodeHighlighter::storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const { + if (spans.isEmpty() || code.isEmpty()) return; + static constexpr int kMaxEntries = 32; + static constexpr int kMaxBytes = 1024 * 1024; + const QString key = cacheKey(resolveId(language), code); + const int bytes = static_cast(code.toUtf8().size()); + QMutexLocker locker(&m_cacheMutex); + auto it = m_spanCache.find(key); + if (it != m_spanCache.end()) { + m_spanCacheBytes -= static_cast(it->code.toUtf8().size()); + m_spanCache.erase(it); + m_spanCacheOrder.removeAll(key); + } + while (m_spanCacheOrder.size() >= kMaxEntries || + m_spanCacheBytes + bytes > kMaxBytes) { + if (m_spanCacheOrder.isEmpty()) break; + const QString oldest = m_spanCacheOrder.takeFirst(); + m_spanCacheBytes -= + static_cast(m_spanCache.value(oldest).code.toUtf8().size()); + m_spanCache.remove(oldest); + } + m_spanCache.insert(key, SpanCacheEntry{code, spans}); + m_spanCacheOrder.append(key); + m_spanCacheBytes += bytes; +} + void CodeHighlighter::highlight( const QString& code, const QString& language, QObject* target, int token) { - QThreadPool::globalInstance()->start([this, target, token, code, language]() { - const QVariantList spans = doHighlight(code, language); - // The target item may be long gone by now (delegates are - // recreated constantly while chats load); a destroyed target is - // simply skipped. Deliver through the app instance (never - // destroyed) and re-check there: posting to `target` from the - // pool thread would race with its destruction. - QPointer guard(target); + QPointer targetGuard(target); + const QVariantList cached = lookupSpans(code, language); + if (!cached.isEmpty()) { QMetaObject::invokeMethod( - QCoreApplication::instance(), - [guard, token, spans]() { - if (!guard) - return; - // QML functions are only invokable by their generic - // QVariant overload, so pass untyped arguments. + targetGuard, + "onHighlightSpans", + Qt::DirectConnection, + Q_ARG(QVariant, token), + Q_ARG(QVariant, cached)); + return; + } + QThreadPool::globalInstance()->start( + [this, target, token, code, language]() { + const QVariantList spans = doHighlight(code, language); + storeSpans(code, language, spans); + QPointer guard(target); QMetaObject::invokeMethod( - guard, "onHighlightSpans", - Q_ARG(QVariant, token), Q_ARG(QVariant, spans)); - }, - Qt::QueuedConnection); - }); + QCoreApplication::instance(), + [guard, token, spans]() { + if (!guard) return; + QMetaObject::invokeMethod( + guard, + "onHighlightSpans", + Q_ARG(QVariant, token), + Q_ARG(QVariant, spans)); + }, + Qt::QueuedConnection); + }); } QVariantList CodeHighlighter::doHighlight( const QString& code, const QString& language) const { QVariantList spans; - if (code.isEmpty()) - return spans; + if (code.isEmpty()) return spans; - const QString tag = language.trimmed().toLower(); - const QString id = [&] { - const QString alias = aliases().value(tag); - return alias.isEmpty() ? tag : alias; // unknown tags = grammar id - }(); + const QString id = resolveId(language); const Grammar& grammar = hl::grammars().value(id); - if (grammar.libs.empty()) - return spans; + if (grammar.libs.empty()) return spans; - // Guard against pathological blocks; highlighting is best-effort. static constexpr size_t kMaxBytes = 512 * 1024; const QByteArray utf8 = code.toUtf8(); - if (static_cast(utf8.size()) > kMaxBytes) - return spans; + if (static_cast(utf8.size()) > kMaxBytes) return spans; const TSLanguage* lang = nullptr; TSQuery* query = nullptr; { QMutexLocker locker(&m_stateMutex); auto& state = m_states[id]; - // A missing library is retriable (it may be installed while the - // shell runs); an ABI mismatch on every candidate is not. Cache - // successes and permanent failures; leave retriable misses out. if (!state || (!state->lang && !state->bad)) { std::shared_ptr fresh = std::make_shared(); bool abiMismatch = false; for (size_t i = 0; i < grammar.libs.size(); ++i) { - void* lib = dlopen(grammar.libs[i].c_str(), - RTLD_NOW | RTLD_LOCAL); - if (!lib) - continue; + void* lib = + dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL); + if (!lib) continue; auto* symbol = reinterpret_cast( dlsym(lib, grammar.symbols[i].c_str())); if (!symbol) { @@ -266,7 +314,7 @@ QVariantList CodeHighlighter::doHighlight( const TSLanguage* candidate = symbol(); const uint32_t version = ts_language_abi_version(candidate); if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || - version > TREE_SITTER_LANGUAGE_VERSION) { + version > TREE_SITTER_LANGUAGE_VERSION) { dlclose(lib); abiMismatch = true; continue; @@ -276,7 +324,6 @@ QVariantList CodeHighlighter::doHighlight( break; } if (fresh->lang) { - // Candidates in priority order; first that compiles wins. for (const char* source : grammar.queries) { TSQueryError errorType = TSQueryErrorNone; uint32_t errorOffset = 0; @@ -286,21 +333,17 @@ QVariantList CodeHighlighter::doHighlight( static_cast(std::strlen(source)), &errorOffset, &errorType); - if (!candidate) - continue; + if (!candidate) continue; fresh->query = candidate; break; } - if (!fresh->query) - fresh->bad = true; + if (!fresh->query) fresh->bad = true; } else if (abiMismatch) { fresh->bad = true; } - if (fresh->lang || fresh->bad) - state = std::move(fresh); + if (fresh->lang || fresh->bad) state = std::move(fresh); } - if (!state || state->bad || !state->lang) - return spans; + if (!state || state->bad || !state->lang) return spans; lang = static_cast(state->lang); query = static_cast(state->query); } @@ -317,13 +360,9 @@ QVariantList CodeHighlighter::doHighlight( TSQueryCursor* cursor = ts_query_cursor_new(); ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); - // Per-byte winner table: captures arrive in document order and later - // captures overwrite earlier ones (tree-sitter highlight convention). const uint32_t size = static_cast(utf8.size()); std::vector kinds(size, 0); - // QML slices the code by UTF-16 code unit, so spans must be in code - // units, not bytes. cu[b] = code units before byte b. std::vector cu(size + 1, 0); for (uint32_t b = 0; b < size; ++b) { cu[b + 1] = cu[b]; @@ -333,9 +372,9 @@ QVariantList CodeHighlighter::doHighlight( else if (c < 0xC0) ; // continuation byte else if (c < 0xF0) - cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit + cu[b + 1] += 1; else - cu[b + 1] += 2; // 4-byte lead -> surrogate pair + cu[b + 1] += 2; } TSQueryMatch match; @@ -343,14 +382,13 @@ QVariantList CodeHighlighter::doHighlight( while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { const TSQueryCapture& capture = match.captures[captureIndex]; uint32_t nameLength = 0; - const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength); + const char* name = + ts_query_capture_name_for_id(query, capture.index, &nameLength); const uint8_t role = roleFor(name, nameLength); - if (role == 0) - continue; + if (role == 0) continue; const uint32_t start = ts_node_start_byte(capture.node); const uint32_t end = ts_node_end_byte(capture.node); - if (end <= start || end > size) - continue; + if (end <= start || end > size) continue; std::fill(kinds.begin() + start, kinds.begin() + end, role); } @@ -378,8 +416,7 @@ QVariantList CodeHighlighter::doHighlight( } CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) - s_instance = new CodeHighlighter(); + if (!s_instance) s_instance = new CodeHighlighter(); return s_instance; } diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 8fb321d..3987379 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -43,6 +43,10 @@ namespace ZShell::llm { // means "no highlighting" (unknown language or grammar not installed). // token is passed back unchanged so the caller can drop results for // superseded code; a destroyed target is simply skipped. +// +// Successful results are cached by (language, code). A request for +// unchanged code delivers the cached spans directly, without re-parsing +// — while a segment streams, only the grown tail is ever re-parsed. class CodeHighlighter : public QObject { Q_OBJECT QML_ELEMENT @@ -76,13 +80,31 @@ class CodeHighlighter : public QObject { // Maps a tree-sitter capture name to a role index (0 = unstyled). [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static const char* roleName(uint8_t role); + // Maps a fence language tag to the grammar id (see aliases()). + [[nodiscard]] static QString resolveId(const QString& language); + [[nodiscard]] static QString cacheKey(const QString& id, const QString& code); // The parsing work; runs on worker threads, so the per-language // state must be initialized under m_stateMutex and is shared as an // immutable object afterwards. [[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; + // Exact-match span cache. lookupSpans() runs on the GUI thread, + // storeSpans() on worker threads; both take m_cacheMutex. + [[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; + void storeSpans(const QString& code, const QString& language, + const QVariantList& spans) const; + + struct SpanCacheEntry { + QString code; // re-compared on lookup; a hash collision can + // never deliver the wrong spans + QVariantList spans; + }; mutable QHash> m_states; mutable QMutex m_stateMutex; + mutable QHash m_spanCache; + mutable QStringList m_spanCacheOrder; // LRU order, oldest first + mutable int m_spanCacheBytes = 0; + mutable QMutex m_cacheMutex; static CodeHighlighter* s_instance; }; diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 1203d72..3b9ab7d 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -42,9 +42,18 @@ QVariantList MarkdownParser::parse(const QString& source) { QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); - auto makeBlock = [](LlmMarkdown::Type type) { + auto makeBlock = [&](LlmMarkdown::Type type) { QVariantMap block; block.insert("type", static_cast(type)); + // Stable per-position identity ("index:type") for the QML + // ScriptModel: blocks that survive a re-parse keep their id, so + // their delegates are updated in place instead of recreated + // (which would drop code highlights mid-stream). The type is + // part of the id so a block that changes type is rebuilt. + block.insert("id", + QString::number(static_cast(blocks.size())) + + QLatin1Char(':') + + QString::number(static_cast(type))); return block; };