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;
};