better scrollbar in codeblocks + enter anim for delegates
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ FunctionsSpacing=true
|
||||
IndentWidth=4
|
||||
MaxColumnWidth=-1
|
||||
NewlineType=native
|
||||
GroupAttributesTogether=true
|
||||
GroupAttributesTogether=false
|
||||
ObjectsSpacing=true
|
||||
SemicolonRule=always
|
||||
SingleLineEmptyObjects=true
|
||||
|
||||
@@ -4,6 +4,7 @@ MouseArea {
|
||||
property int scrollAccumulatedY: 0
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.accepted = false;
|
||||
}
|
||||
|
||||
onWheel: event => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ TextAreaBase {
|
||||
enabled: !root.activeFocus
|
||||
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.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 += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
|
||||
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 += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ PageBase {
|
||||
model: root.schemes
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.llm.appearance.scheme
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
@@ -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<QString, CodeHighlighter::Grammar>& grammars() {
|
||||
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
|
||||
QHash<QString, CodeHighlighter::Grammar> map;
|
||||
@@ -89,8 +102,6 @@ const QHash<QString, CodeHighlighter::Grammar>& grammars() {
|
||||
CodeHighlighter* CodeHighlighter::s_instance = nullptr;
|
||||
|
||||
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 = [] {
|
||||
QHash<QString, QString> map;
|
||||
map.insert("c", "c");
|
||||
@@ -145,49 +156,38 @@ const QHash<QString, QString>& 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<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(
|
||||
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<QObject> guard(target);
|
||||
QPointer<QObject> 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<QObject> 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<size_t>(utf8.size()) > kMaxBytes)
|
||||
return spans;
|
||||
if (static_cast<size_t>(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<State> fresh = std::make_shared<State>();
|
||||
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<hl::LanguageFn>(
|
||||
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<uint32_t>(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<const TSLanguage*>(state->lang);
|
||||
query = static_cast<TSQuery*>(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<uint32_t>(utf8.size());
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<QString, std::shared_ptr<const State>> m_states;
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user