better scrollbar in codeblocks + enter anim for delegates

This commit is contained in:
2026-08-27 01:47:17 +02:00
parent c33dbc147f
commit 293bf67e09
16 changed files with 458 additions and 229 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ FunctionsSpacing=true
IndentWidth=4 IndentWidth=4
MaxColumnWidth=-1 MaxColumnWidth=-1
NewlineType=native NewlineType=native
GroupAttributesTogether=true GroupAttributesTogether=false
ObjectsSpacing=true ObjectsSpacing=true
SemicolonRule=always SemicolonRule=always
SingleLineEmptyObjects=true SingleLineEmptyObjects=true
+1
View File
@@ -4,6 +4,7 @@ MouseArea {
property int scrollAccumulatedY: 0 property int scrollAccumulatedY: 0
function onWheel(event: WheelEvent): void { function onWheel(event: WheelEvent): void {
event.accepted = false;
} }
onWheel: event => { onWheel: event => {
+14 -12
View File
@@ -24,13 +24,8 @@ ScrollBar {
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
enabled: !Visibilities.getForActive().isDrawing enabled: !Visibilities.getForActive().isDrawing
parent: flickable.parent implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
anchors.left: isHorizontal ? flickable.left : undefined implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
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
contentItem: Item {} contentItem: Item {}
Behavior on position { Behavior on position {
@@ -59,6 +54,7 @@ ScrollBar {
Loader { Loader {
anchors.fill: parent anchors.fill: parent
active: root.size < 1
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
} }
@@ -116,6 +112,17 @@ ScrollBar {
return visualPos * root.travelScale; 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 anchors.fill: parent
cursorShape: undefined cursorShape: undefined
hoverEnabled: true hoverEnabled: true
@@ -136,10 +143,5 @@ ScrollBar {
updateFromEvent(event); 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;
}
} }
} }
+1 -1
View File
@@ -55,7 +55,7 @@ CustomWindow {
property color surfaceColor: Colors.tPalette.m3surface property color surfaceColor: Colors.tPalette.m3surface
WlrLayershell.exclusionMode: ExclusionMode.Ignore 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 WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top
color: "transparent" color: "transparent"
contentItem.focus: true contentItem.focus: true
@@ -12,6 +12,78 @@ Item {
property ChatSession chatData property ChatSession chatData
property bool following: true 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 signal requestClose
@@ -27,6 +99,21 @@ Item {
input.text = ""; 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 { RowLayout {
id: header id: header
@@ -68,9 +155,11 @@ Item {
id: list id: list
property bool userScrolledUp: false property bool userScrolledUp: false
property real lastCHeight: 0.0
function scrollToBottom(): void { function scrollToBottom(): void {
Qt.callLater(() => positionViewAtBeginning()); scrollAnim.to = 0;
scrollAnim.start();
} }
cacheBuffer: height * 20 cacheBuffer: height * 20
@@ -80,47 +169,67 @@ Item {
model: root.chatData.messagesModel model: root.chatData.messagesModel
spacing: 0 spacing: 0
rotation: 180 rotation: 180
add: Transition {
Anim {
from: -100
property: "y"
}
}
// add: Transition { CustomScrollBar.vertical: CustomScrollBar {
// Anim { id: scrollBar
// from: 10
// property: "y" 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 { delegate: MessageDelegate {
rotation: 180 rotation: 180
} }
// displaced: Transition { displaced: Transition {
// Anim { Anim {
// property: "y" property: "y"
// } }
// } }
// move: Transition { move: Transition {
// Anim { Anim {
// property: "y" property: "y"
// } }
// } }
Component.onCompleted: { Component.onCompleted: {
positionViewAtBeginning(); positionViewAtBeginning();
forceActiveFocus();
} }
onAtYEndChanged: { onAtYBeginningChanged: {
if (atYEnd) if (atYBeginning)
userScrolledUp = false; userScrolledUp = false;
} }
onContentHeightChanged: { onContentHeightChanged: {
if (!userScrolledUp && atYEnd) if (userScrolledUp && Chat.busy) {
scrollToBottom(); const delta = contentHeight - lastCHeight;
} contentY += delta;
onCountChanged: { }
if (!userScrolledUp)
scrollToBottom(); lastCHeight = contentHeight;
} }
onMovingChanged: { onMovingChanged: {
if (moving) if (moving)
userScrolledUp = !atYEnd; userScrolledUp = !atYBeginning;
} }
Anim { Anim {
@@ -179,7 +288,7 @@ Item {
type: IconButton.Tonal type: IconButton.Tonal
onClicked: { onClicked: {
listLoader.item.positionViewAtBeginning(); listLoader.item.scrollToBottom();
} }
} }
} }
@@ -194,11 +303,13 @@ Item {
bg.color: Colors.tPalette.m3surfaceContainerLowest bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true
placeholderText: qsTr("Send a message") placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward" sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput()
Keys.onPressed: e => { Keys.onPressed: e => {
if (e.key == Qt.Key_Return) { if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) { if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -31,7 +31,7 @@ TextAreaBase {
enabled: !root.activeFocus enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed manualPressOverride: tapHandler.pressed
onClicked: root.focus = true onClicked: root.forceActiveFocus()
} }
} }
@@ -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
}
}
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Effects
import QtQuick.Shapes import QtQuick.Shapes
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
@@ -7,7 +8,7 @@ import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
CustomClippingRect { CustomRect {
id: root id: root
required property string language required property string language
@@ -15,16 +16,11 @@ CustomClippingRect {
property bool copied: false property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline 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 var codeSpans: []
property int highlightToken: 0 property int highlightToken: 0
function refresh() { function refresh() {
const token = ++root.highlightToken; const token = ++root.highlightToken;
codeSpans = [];
CodeHighlighter.highlight(root.code, root.language, root, token); CodeHighlighter.highlight(root.code, root.language, root, token);
} }
@@ -84,10 +80,14 @@ CustomClippingRect {
let pos = 0; let pos = 0;
for (let i = 0; i < spans.length; i++) { for (let i = 0; i < spans.length; i++) {
const span = spans[i]; const span = spans[i];
if (span.start > pos) const start = Math.min(span.start, code.length);
out += escapeHtml(code.slice(pos, span.start)); const end = Math.min(span.start + span.length, code.length);
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>"; if (end <= pos)
pos = span.start + span.length; continue;
if (start > pos)
out += escapeHtml(code.slice(pos, start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
pos = end;
} }
if (pos < code.length) if (pos < code.length)
out += escapeHtml(code.slice(pos)); out += escapeHtml(code.slice(pos));
@@ -100,7 +100,10 @@ CustomClippingRect {
color: root.codeBackgroundColor color: root.codeBackgroundColor
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
onLanguageChanged: refresh() onLanguageChanged: {
codeSpans = [];
refresh();
}
onCodeChanged: refresh() onCodeChanged: refresh()
RowLayout { RowLayout {
@@ -212,7 +215,7 @@ CustomClippingRect {
} }
} }
CustomRect { CustomClippingRect {
id: codeRect id: codeRect
anchors.left: parent.left anchors.left: parent.left
@@ -226,6 +229,21 @@ CustomClippingRect {
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg 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 { Flickable {
id: codeFlick id: codeFlick
@@ -234,15 +252,23 @@ CustomClippingRect {
CustomScrollBar.horizontal: CustomScrollBar { CustomScrollBar.horizontal: CustomScrollBar {
flickable: codeFlick flickable: codeFlick
parent: codeFlick.parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
} }
TextAreaBase.flickable: TextAreaBase { TextAreaBase.flickable: TextAreaBase {
id: codeText id: codeText
color: CodeColors.active.normal color: CodeColors.active.normal
layer.enabled: true
leftInset: Tokens.padding.small
clip: false
font.family: Config.appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
textFormat: Text.RichText textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans) text: code.text
readOnly: true readOnly: true
} }
} }
@@ -24,7 +24,7 @@ Item {
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
CustomRect { CustomClippingRect {
id: bubble id: bubble
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
@@ -33,16 +33,21 @@ Item {
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined anchors.right: root.isUser ? parent.right : undefined
Behavior on implicitHeight { // Behavior on implicitHeight {
// enabled: root.segment.running // enabled: !root.segment.running
//
Anim {} // Anim {
} // type: Anim.DefaultEffects
// }
// }
// User messages stay a plain editable text field. // User messages stay a plain editable text field.
TextEditBase { TextEditBase {
id: msgText id: msgText
property string cachedText: root.segment.text
property bool cancelled: false
visible: root.isUser visible: root.isUser
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
@@ -65,27 +70,29 @@ Item {
event.accepted = true; event.accepted = true;
} }
} else if (event.key == Qt.Key_Escape) { } else if (event.key == Qt.Key_Escape) {
text = root.segment.text; cancelled = true;
readOnly = true; readOnly = true;
event.accepted = true; event.accepted = true;
} }
} }
onEditingFinished: { onEditingFinished: {
const old = root.segment.text;
if (old !== text)
root.edit(text);
readOnly = true; readOnly = true;
} }
onReadOnlyChanged: { onReadOnlyChanged: {
if (readOnly) { if (readOnly) {
animateCursor = false; animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText; textFormat = CustomText.MarkdownText;
if (cancelled) {
text = cachedText;
cancelled = false;
return;
}
root.edit(text);
} else { } else {
var raw = root.segment.text;
textFormat = CustomText.PlainText; textFormat = CustomText.PlainText;
text = raw; text = cachedText;
forceActiveFocus(); forceActiveFocus();
cursorPosition = text.length; cursorPosition = text.length;
animateCursor = true; animateCursor = true;
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
@@ -7,7 +8,6 @@ import qs.Services
Column { Column {
id: root id: root
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
required property var blocks required property var blocks
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
@@ -15,8 +15,10 @@ Column {
Repeater { Repeater {
id: blockRep id: blockRep
model: root.blocks model: ScriptModel {
values: root.blocks
objectProp: "id"
}
delegate: DelegateChooser { delegate: DelegateChooser {
role: "type" role: "type"
@@ -50,7 +52,7 @@ Column {
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Heading roleValue: LlmMarkdown.Type.Heading
delegate: CustomText { delegate: TextEditBase {
required property var modelData required property var modelData
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
@@ -59,10 +61,11 @@ Column {
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
text: modelData.text text: modelData.text
textFormat: Text.MarkdownText textFormat: Text.MarkdownText
readOnly: true
font.bold: true font.bold: true
font.pointSize: { font.pointSize: {
if (modelData.level <= 1) if (modelData.level <= 1)
return Tokens.font.size.larger; return Tokens.font.size.large;
if (modelData.level === 2) if (modelData.level === 2)
return Tokens.font.size.normal; return Tokens.font.size.normal;
return Tokens.font.size.smaller; return Tokens.font.size.smaller;
@@ -74,17 +77,13 @@ Column {
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Text roleValue: LlmMarkdown.Type.Text
delegate: CustomText { delegate: BubbleEdit {
required property var modelData required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
text: modelData.text text: modelData.text
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
} }
} }
} }
@@ -83,8 +83,9 @@ MouseArea {
Anim { Anim {
target: root target: root
property: "x" property: "x"
to: changeAnim.next ? -root.width / 2 : root.width / 2 to: changeAnim.next ? root.width / 4 : -root.width / 4
from: 0 from: 0
type: Anim.FastEffects
} }
Anim { Anim {
@@ -92,6 +93,7 @@ MouseArea {
property: "opacity" property: "opacity"
from: 1 from: 1
to: 0 to: 0
type: Anim.FastEffects
} }
} }
@@ -101,7 +103,8 @@ MouseArea {
Anim { Anim {
target: root target: root
property: "x" 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 to: 0
} }
@@ -109,30 +112,21 @@ MouseArea {
target: root target: root
property: "opacity" property: "opacity"
from: 0 from: 0
type: Anim.FastEffects
to: 1 to: 1
} }
} }
} }
} }
// Behavior on implicitHeight { Binding {
// enabled: !root.isUser && root.current.streaming property: "contentY"
// restoreMode: Binding.RestoreNone
// Anim {} 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)
onCurrentChanged: {
console.log(modelData.generations.indexOf(current));
} }
// 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 { Anim {
id: restoreAnim id: restoreAnim
@@ -148,20 +142,6 @@ MouseArea {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
// onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight)
// add: Transition {
// Anim {
// from: 10
// property: "y"
// }
// }
// move: Transition {
// Anim {
// property: "y"
// }
// }
Repeater { Repeater {
id: segmentRep id: segmentRep
@@ -10,6 +10,7 @@ DialogRowButton {
required property var model required property var model
property var selectedItem property var selectedItem
property var initialSelect
function keyFor(item: var): string { function keyFor(item: var): string {
return item.id; return item.id;
@@ -91,7 +92,11 @@ DialogRowButton {
} }
onOpenChanged: { onOpenChanged: {
if (open) if (open) {
selectedItem = null; if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
}
} }
} }
@@ -84,6 +84,8 @@ PageBase {
model: root.schemes model: root.schemes
rootParent: root.flickable rootParent: root.flickable
initialSelect: Config.llm.appearance.scheme
onAccepted: { onAccepted: {
if (!selectedItem) if (!selectedItem)
return; return;
+151 -114
View File
@@ -21,7 +21,6 @@ namespace ZShell::llm {
namespace hl { namespace hl {
// Role ids; 0 means "no color".
enum Role : uint8_t { enum Role : uint8_t {
None = 0, None = 0,
Comment, Comment,
@@ -43,29 +42,43 @@ enum Role : uint8_t {
const char* roleName(Role role) { const char* roleName(Role role) {
switch (role) { switch (role) {
case Comment: return "comment"; case Comment:
case String: return "string"; return "comment";
case StringKey: return "string.key"; case String:
case Number: return "number"; return "string";
case Constant: return "constant"; case StringKey:
case Keyword: return "keyword"; return "string.key";
case Type: return "type"; case Number:
case Function: return "function"; return "number";
case Method: return "method"; case Constant:
case Macro: return "macro"; return "constant";
case Preproc: return "preproc"; case Keyword:
case Operator: return "operator"; return "keyword";
case Property: return "property"; case Type:
case Label: return "label"; return "type";
case Attribute: return "attribute"; case Function:
default: return ""; 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* (*)(); using LanguageFn = const TSLanguage* (*)();
// Grammar registry generated by CMake from the installed grammars and
// their highlight queries (see CMakeLists.txt).
const QHash<QString, CodeHighlighter::Grammar>& grammars() { const QHash<QString, CodeHighlighter::Grammar>& grammars() {
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] { static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
QHash<QString, CodeHighlighter::Grammar> map; QHash<QString, CodeHighlighter::Grammar> map;
@@ -89,8 +102,6 @@ const QHash<QString, CodeHighlighter::Grammar>& grammars() {
CodeHighlighter* CodeHighlighter::s_instance = nullptr; CodeHighlighter* CodeHighlighter::s_instance = nullptr;
const QHash<QString, QString>& CodeHighlighter::aliases() { const QHash<QString, QString>& CodeHighlighter::aliases() {
// Language tags as written in code fences (and common variants) to
// grammar id.
static const QHash<QString, QString> aliases = [] { static const QHash<QString, QString> aliases = [] {
QHash<QString, QString> map; QHash<QString, QString> map;
map.insert("c", "c"); map.insert("c", "c");
@@ -145,49 +156,38 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
const QString n = QString::fromUtf8(name, length); const QString n = QString::fromUtf8(name, length);
if (n == "comment") if (n == "comment") return hl::Role::Comment;
return hl::Role::Comment;
if (n.startsWith("string")) if (n.startsWith("string"))
return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; return n == "string.special.key" ? hl::Role::StringKey
if (n == "escape" || n == "regexp") : hl::Role::String;
return hl::Role::String; if (n == "escape" || n == "regexp") return hl::Role::String;
if (n.startsWith("number")) if (n.startsWith("number")) return hl::Role::Number;
return hl::Role::Number; if (n.startsWith("constant") || n == "boolean" || n == "bool" ||
if (n.startsWith("constant") || n == "boolean" || n == "bool" n.startsWith("character"))
|| n.startsWith("character"))
return hl::Role::Constant; return hl::Role::Constant;
if (n.startsWith("keyword")) if (n.startsWith("keyword")) return hl::Role::Keyword;
return hl::Role::Keyword; if (n == "type" || n.startsWith("type.")) return hl::Role::Type;
if (n == "type" || n.startsWith("type.")) if (n.startsWith("namespace") || n.startsWith("module") ||
n == "support.type" || n == "support.namespace")
return hl::Role::Type; return hl::Role::Type;
if (n.startsWith("namespace") || n.startsWith("module") if (n.startsWith("function") || n == "constructor" ||
|| n == "support.type" || n == "support.namespace") n.startsWith("support.function"))
return hl::Role::Type;
if (n.startsWith("function") || n == "constructor"
|| n.startsWith("support.function"))
return hl::Role::Function; return hl::Role::Function;
if (n == "method" || n == "method.builtin") if (n == "method" || n == "method.builtin") return hl::Role::Method;
return hl::Role::Method; if (n.startsWith("macro")) return hl::Role::Macro;
if (n.startsWith("macro")) if (n.startsWith("preproc")) return hl::Role::Preproc;
return hl::Role::Macro; if (n == "operator" || n == "punctuation.operator" ||
if (n.startsWith("preproc")) n.startsWith("operator.") || n.startsWith("punctuation"))
return hl::Role::Preproc;
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")
|| n.startsWith("punctuation"))
return hl::Role::Operator; return hl::Role::Operator;
if (n == "property" || n == "field" || n.startsWith("property.")) if (n == "property" || n == "field" || n.startsWith("property."))
return hl::Role::Property; return hl::Role::Property;
if (n == "label") if (n == "label") return hl::Role::Label;
return hl::Role::Label;
if (n.startsWith("attribute") || n == "annotation") if (n.startsWith("attribute") || n == "annotation")
return hl::Role::Attribute; return hl::Role::Attribute;
// HTML tag names, CSS variables and friends.
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter")) if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
return hl::Role::Keyword; return hl::Role::Keyword;
if (n.startsWith("variable")) if (n.startsWith("variable")) return hl::Role::Constant;
return hl::Role::Constant; if (n.startsWith("support")) return hl::Role::Function;
if (n.startsWith("support"))
return hl::Role::Function;
return hl::Role::None; return hl::Role::None;
} }
@@ -195,68 +195,116 @@ const char* CodeHighlighter::roleName(uint8_t role) {
return hl::roleName(static_cast<hl::Role>(role)); return hl::roleName(static_cast<hl::Role>(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<int>(code.toUtf8().size());
QMutexLocker locker(&m_cacheMutex);
auto it = m_spanCache.find(key);
if (it != m_spanCache.end()) {
m_spanCacheBytes -= static_cast<int>(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<int>(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( void CodeHighlighter::highlight(
const QString& code, const QString& language, QObject* target, int token) { const QString& code, const QString& language, QObject* target, int token) {
QThreadPool::globalInstance()->start([this, target, token, code, language]() { QPointer<QObject> targetGuard(target);
const QVariantList spans = doHighlight(code, language); const QVariantList cached = lookupSpans(code, language);
// The target item may be long gone by now (delegates are if (!cached.isEmpty()) {
// 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<QObject> guard(target);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
QCoreApplication::instance(), targetGuard,
[guard, token, spans]() { "onHighlightSpans",
if (!guard) Qt::DirectConnection,
return; Q_ARG(QVariant, token),
// QML functions are only invokable by their generic Q_ARG(QVariant, cached));
// QVariant overload, so pass untyped arguments. return;
}
QThreadPool::globalInstance()->start(
[this, target, token, code, language]() {
const QVariantList spans = doHighlight(code, language);
storeSpans(code, language, spans);
QPointer<QObject> guard(target);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
guard, "onHighlightSpans", QCoreApplication::instance(),
Q_ARG(QVariant, token), Q_ARG(QVariant, spans)); [guard, token, spans]() {
}, if (!guard) return;
Qt::QueuedConnection); QMetaObject::invokeMethod(
}); guard,
"onHighlightSpans",
Q_ARG(QVariant, token),
Q_ARG(QVariant, spans));
},
Qt::QueuedConnection);
});
} }
QVariantList CodeHighlighter::doHighlight( QVariantList CodeHighlighter::doHighlight(
const QString& code, const QString& language) const { const QString& code, const QString& language) const {
QVariantList spans; QVariantList spans;
if (code.isEmpty()) if (code.isEmpty()) return spans;
return spans;
const QString tag = language.trimmed().toLower(); const QString id = resolveId(language);
const QString id = [&] {
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
}();
const Grammar& grammar = hl::grammars().value(id); const Grammar& grammar = hl::grammars().value(id);
if (grammar.libs.empty()) if (grammar.libs.empty()) return spans;
return spans;
// Guard against pathological blocks; highlighting is best-effort.
static constexpr size_t kMaxBytes = 512 * 1024; static constexpr size_t kMaxBytes = 512 * 1024;
const QByteArray utf8 = code.toUtf8(); const QByteArray utf8 = code.toUtf8();
if (static_cast<size_t>(utf8.size()) > kMaxBytes) if (static_cast<size_t>(utf8.size()) > kMaxBytes) return spans;
return spans;
const TSLanguage* lang = nullptr; const TSLanguage* lang = nullptr;
TSQuery* query = nullptr; TSQuery* query = nullptr;
{ {
QMutexLocker locker(&m_stateMutex); QMutexLocker locker(&m_stateMutex);
auto& state = m_states[id]; 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)) { if (!state || (!state->lang && !state->bad)) {
std::shared_ptr<State> fresh = std::make_shared<State>(); std::shared_ptr<State> fresh = std::make_shared<State>();
bool abiMismatch = false; bool abiMismatch = false;
for (size_t i = 0; i < grammar.libs.size(); ++i) { for (size_t i = 0; i < grammar.libs.size(); ++i) {
void* lib = dlopen(grammar.libs[i].c_str(), void* lib =
RTLD_NOW | RTLD_LOCAL); dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL);
if (!lib) if (!lib) continue;
continue;
auto* symbol = reinterpret_cast<hl::LanguageFn>( auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlsym(lib, grammar.symbols[i].c_str())); dlsym(lib, grammar.symbols[i].c_str()));
if (!symbol) { if (!symbol) {
@@ -266,7 +314,7 @@ QVariantList CodeHighlighter::doHighlight(
const TSLanguage* candidate = symbol(); const TSLanguage* candidate = symbol();
const uint32_t version = ts_language_abi_version(candidate); const uint32_t version = ts_language_abi_version(candidate);
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
version > TREE_SITTER_LANGUAGE_VERSION) { version > TREE_SITTER_LANGUAGE_VERSION) {
dlclose(lib); dlclose(lib);
abiMismatch = true; abiMismatch = true;
continue; continue;
@@ -276,7 +324,6 @@ QVariantList CodeHighlighter::doHighlight(
break; break;
} }
if (fresh->lang) { if (fresh->lang) {
// Candidates in priority order; first that compiles wins.
for (const char* source : grammar.queries) { for (const char* source : grammar.queries) {
TSQueryError errorType = TSQueryErrorNone; TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0; uint32_t errorOffset = 0;
@@ -286,21 +333,17 @@ QVariantList CodeHighlighter::doHighlight(
static_cast<uint32_t>(std::strlen(source)), static_cast<uint32_t>(std::strlen(source)),
&errorOffset, &errorOffset,
&errorType); &errorType);
if (!candidate) if (!candidate) continue;
continue;
fresh->query = candidate; fresh->query = candidate;
break; break;
} }
if (!fresh->query) if (!fresh->query) fresh->bad = true;
fresh->bad = true;
} else if (abiMismatch) { } else if (abiMismatch) {
fresh->bad = true; fresh->bad = true;
} }
if (fresh->lang || fresh->bad) if (fresh->lang || fresh->bad) state = std::move(fresh);
state = std::move(fresh);
} }
if (!state || state->bad || !state->lang) if (!state || state->bad || !state->lang) return spans;
return spans;
lang = static_cast<const TSLanguage*>(state->lang); lang = static_cast<const TSLanguage*>(state->lang);
query = static_cast<TSQuery*>(state->query); query = static_cast<TSQuery*>(state->query);
} }
@@ -317,13 +360,9 @@ QVariantList CodeHighlighter::doHighlight(
TSQueryCursor* cursor = ts_query_cursor_new(); TSQueryCursor* cursor = ts_query_cursor_new();
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); 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<uint32_t>(utf8.size()); const uint32_t size = static_cast<uint32_t>(utf8.size());
std::vector<uint8_t> kinds(size, 0); std::vector<uint8_t> 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<uint32_t> cu(size + 1, 0); std::vector<uint32_t> cu(size + 1, 0);
for (uint32_t b = 0; b < size; ++b) { for (uint32_t b = 0; b < size; ++b) {
cu[b + 1] = cu[b]; cu[b + 1] = cu[b];
@@ -333,9 +372,9 @@ QVariantList CodeHighlighter::doHighlight(
else if (c < 0xC0) else if (c < 0xC0)
; // continuation byte ; // continuation byte
else if (c < 0xF0) else if (c < 0xF0)
cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit cu[b + 1] += 1;
else else
cu[b + 1] += 2; // 4-byte lead -> surrogate pair cu[b + 1] += 2;
} }
TSQueryMatch match; TSQueryMatch match;
@@ -343,14 +382,13 @@ QVariantList CodeHighlighter::doHighlight(
while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) {
const TSQueryCapture& capture = match.captures[captureIndex]; const TSQueryCapture& capture = match.captures[captureIndex];
uint32_t nameLength = 0; 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); const uint8_t role = roleFor(name, nameLength);
if (role == 0) if (role == 0) continue;
continue;
const uint32_t start = ts_node_start_byte(capture.node); const uint32_t start = ts_node_start_byte(capture.node);
const uint32_t end = ts_node_end_byte(capture.node); const uint32_t end = ts_node_end_byte(capture.node);
if (end <= start || end > size) if (end <= start || end > size) continue;
continue;
std::fill(kinds.begin() + start, kinds.begin() + end, role); std::fill(kinds.begin() + start, kinds.begin() + end, role);
} }
@@ -378,8 +416,7 @@ QVariantList CodeHighlighter::doHighlight(
} }
CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new CodeHighlighter();
s_instance = new CodeHighlighter();
return s_instance; return s_instance;
} }
+22
View File
@@ -43,6 +43,10 @@ namespace ZShell::llm {
// means "no highlighting" (unknown language or grammar not installed). // means "no highlighting" (unknown language or grammar not installed).
// token is passed back unchanged so the caller can drop results for // token is passed back unchanged so the caller can drop results for
// superseded code; a destroyed target is simply skipped. // 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 { class CodeHighlighter : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -76,13 +80,31 @@ class CodeHighlighter : public QObject {
// Maps a tree-sitter capture name to a role index (0 = unstyled). // 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 uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role); [[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 // The parsing work; runs on worker threads, so the per-language
// state must be initialized under m_stateMutex and is shared as an // state must be initialized under m_stateMutex and is shared as an
// immutable object afterwards. // immutable object afterwards.
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; [[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<QString, std::shared_ptr<const State>> m_states; mutable QHash<QString, std::shared_ptr<const State>> m_states;
mutable QMutex m_stateMutex; mutable QMutex m_stateMutex;
mutable QHash<QString, SpanCacheEntry> m_spanCache;
mutable QStringList m_spanCacheOrder; // LRU order, oldest first
mutable int m_spanCacheBytes = 0;
mutable QMutex m_cacheMutex;
static CodeHighlighter* s_instance; static CodeHighlighter* s_instance;
}; };
+10 -1
View File
@@ -42,9 +42,18 @@ QVariantList MarkdownParser::parse(const QString& source) {
QStringLiteral("\\$\\$(.+?)\\$\\$"), QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption); QRegularExpression::DotMatchesEverythingOption);
auto makeBlock = [](LlmMarkdown::Type type) { auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block; QVariantMap block;
block.insert("type", static_cast<int>(type)); block.insert("type", static_cast<int>(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<int>(blocks.size()))
+ QLatin1Char(':')
+ QString::number(static_cast<int>(type)));
return block; return block;
}; };