async parsing + tex caching
This commit is contained in:
@@ -1,32 +1,42 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Templates
|
||||
import qs.Helpers
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
ScrollBar {
|
||||
id: root
|
||||
|
||||
required property Flickable flickable
|
||||
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY
|
||||
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||
required property Flickable flickable
|
||||
readonly property real nonAnimHeight: flickable.height / flickable.contentHeight
|
||||
readonly property real nonAnimY: flickable.contentY / flickable.contentHeight
|
||||
readonly property real nonAnimHeight: root.axisSize / root.axisContentSize
|
||||
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
|
||||
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
||||
readonly property bool reversed: flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop
|
||||
readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
|
||||
property bool shouldBeActive
|
||||
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
||||
|
||||
enabled: !Visibilities.getForActive().isDrawing
|
||||
implicitWidth: Tokens.padding.extraSmall * 2
|
||||
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
|
||||
|
||||
contentItem: Item {
|
||||
}
|
||||
contentItem: Item {}
|
||||
Behavior on position {
|
||||
enabled: !fullMouse.pressed
|
||||
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onHoveredChanged: {
|
||||
@@ -47,54 +57,26 @@ ScrollBar {
|
||||
target: root.flickable
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitWidth: handle.implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: handle
|
||||
Component {
|
||||
id: verticalTrack
|
||||
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3secondary
|
||||
implicitHeight: root.height * root.effectiveSize
|
||||
implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||
opacity: {
|
||||
if (!root.enabled)
|
||||
return 0;
|
||||
if (root.size === 1)
|
||||
return 0;
|
||||
if (fullMouse.pressed)
|
||||
return 1;
|
||||
if (fullMouse.containsMouse)
|
||||
return 0.8;
|
||||
if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
|
||||
return 0.6;
|
||||
return 0;
|
||||
}
|
||||
radius: Tokens.rounding.full
|
||||
y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale
|
||||
VerticalScrollBarTrack {
|
||||
scrollBar: root
|
||||
mouseArea: fullMouse
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: horizontalTrack
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
|
||||
acceptedButtons: Qt.NoButton
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
}
|
||||
HorizontalScrollBarTrack {
|
||||
scrollBar: root
|
||||
mouseArea: fullMouse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,21 +93,25 @@ ScrollBar {
|
||||
|
||||
property real pressOffset: 0
|
||||
|
||||
function contentYFromThumbTop(thumbTop) {
|
||||
var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0;
|
||||
|
||||
return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight;
|
||||
function contentPosFromThumbStart(thumbStart) {
|
||||
var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0;
|
||||
return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize;
|
||||
}
|
||||
|
||||
function updateFromEvent(event) {
|
||||
var posInTrack = event.y / root.height;
|
||||
var thumbTop = posInTrack - pressOffset;
|
||||
thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop));
|
||||
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||
var posInTrack = eventPos / root.axisLength;
|
||||
var thumbStart = posInTrack - pressOffset;
|
||||
thumbStart = Math.max(0, Math.min(root.effectiveTravel, thumbStart));
|
||||
|
||||
root.flickable.contentY = contentYFromThumbTop(thumbTop);
|
||||
var newPos = contentPosFromThumbStart(thumbStart);
|
||||
if (root.isHorizontal)
|
||||
root.flickable.contentX = newPos;
|
||||
else
|
||||
root.flickable.contentY = newPos;
|
||||
}
|
||||
|
||||
function visualThumbTop() {
|
||||
function visualThumbStart() {
|
||||
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
||||
return visualPos * root.travelScale;
|
||||
}
|
||||
@@ -140,22 +126,18 @@ ScrollBar {
|
||||
updateFromEvent(event);
|
||||
}
|
||||
onPressed: event => {
|
||||
var currentTop = visualThumbTop();
|
||||
var currentBottom = currentTop + root.effectiveSize;
|
||||
var clickPos = event.y / root.height;
|
||||
var currentStart = visualThumbStart();
|
||||
var currentEnd = currentStart + root.effectiveSize;
|
||||
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||
var clickPos = eventPos / root.axisLength;
|
||||
|
||||
var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom;
|
||||
|
||||
if (clickedInsideThumb) {
|
||||
pressOffset = clickPos - currentTop;
|
||||
} else {
|
||||
pressOffset = root.effectiveSize / 2;
|
||||
}
|
||||
var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd;
|
||||
pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2;
|
||||
|
||||
updateFromEvent(event);
|
||||
}
|
||||
onWheel: event => {
|
||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
id: track
|
||||
|
||||
required property var scrollBar
|
||||
required property var mouseArea
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
implicitHeight: handle.implicitHeight
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
CustomRect {
|
||||
id: handle
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
color: Colors.palette.m3secondary
|
||||
implicitWidth: track.scrollBar.width * track.scrollBar.effectiveSize
|
||||
implicitHeight: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||
opacity: {
|
||||
if (!track.scrollBar.enabled)
|
||||
return 0;
|
||||
if (track.scrollBar.size === 1)
|
||||
return 0;
|
||||
if (track.mouseArea.pressed)
|
||||
return 1;
|
||||
if (track.mouseArea.containsMouse)
|
||||
return 0.8;
|
||||
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||
return 0.6;
|
||||
return 0;
|
||||
}
|
||||
radius: Tokens.rounding.full
|
||||
x: track.scrollBar.reversed ? track.scrollBar.width * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.width * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
acceptedButtons: Qt.NoButton
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import QtQuick
|
||||
import QtQuick.Templates
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
TextArea {
|
||||
id: root
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
cursorVisible: !readOnly
|
||||
font.pointSize: Tokens.font.size.small
|
||||
implicitHeight: contentHeight + topPadding + bottomPadding
|
||||
implicitWidth: contentWidth + leftPadding + rightPadding
|
||||
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
||||
renderType: TextArea.NativeRendering
|
||||
selectedTextColor: color
|
||||
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
CAnim {}
|
||||
}
|
||||
Behavior on selectionColor {
|
||||
CAnim {}
|
||||
}
|
||||
cursorDelegate: Item {}
|
||||
|
||||
CustomRect {
|
||||
id: cursor
|
||||
|
||||
property bool disableBlink
|
||||
|
||||
color: Colors.palette.m3primary
|
||||
implicitHeight: root.cursorRectangle.height
|
||||
implicitWidth: 1.5
|
||||
radius: Tokens.rounding.largeIncreased
|
||||
x: root.cursorRectangle.x
|
||||
y: root.cursorRectangle.y
|
||||
|
||||
Behavior on x {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.expressiveFastEffects
|
||||
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.StandardSmall
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onCursorPositionChanged(): void {
|
||||
if (root.activeFocus && root.cursorVisible) {
|
||||
cursor.opacity = 1;
|
||||
cursor.disableBlink = true;
|
||||
enableBlink.restart();
|
||||
}
|
||||
}
|
||||
|
||||
target: root
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: enableBlink
|
||||
|
||||
interval: 500
|
||||
|
||||
onTriggered: cursor.disableBlink = false
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 500
|
||||
repeat: true
|
||||
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
|
||||
triggeredOnStart: true
|
||||
|
||||
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
|
||||
}
|
||||
|
||||
Binding {
|
||||
cursor.opacity: 0
|
||||
when: !root.activeFocus || !root.cursorVisible
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
id: track
|
||||
|
||||
required property var scrollBar
|
||||
required property var mouseArea
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitWidth: handle.implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
CustomRect {
|
||||
id: handle
|
||||
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3secondary
|
||||
implicitHeight: track.scrollBar.height * track.scrollBar.effectiveSize
|
||||
implicitWidth: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||
opacity: {
|
||||
if (!track.scrollBar.enabled)
|
||||
return 0;
|
||||
if (track.scrollBar.size === 1)
|
||||
return 0;
|
||||
if (track.mouseArea.pressed)
|
||||
return 1;
|
||||
if (track.mouseArea.containsMouse)
|
||||
return 0.8;
|
||||
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||
return 0.6;
|
||||
return 0;
|
||||
}
|
||||
radius: Tokens.rounding.full
|
||||
y: track.scrollBar.reversed ? track.scrollBar.height * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.height * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||
|
||||
Behavior on implicitWidth {
|
||||
Anim {}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
acceptedButtons: Qt.NoButton
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ WidgetBase {
|
||||
}
|
||||
required property GridLayout loader
|
||||
required property Wrapper popouts
|
||||
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2
|
||||
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2
|
||||
required property PersistentProperties visibilities
|
||||
|
||||
color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer
|
||||
@@ -57,14 +57,13 @@ WidgetBase {
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
height: implicitHeight
|
||||
text: Time.dateStr
|
||||
visible: root.horizontal
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +83,11 @@ WidgetBase {
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: Qt.formatDateTime(Time.date, modelData)
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +112,7 @@ WidgetBase {
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: {
|
||||
if (modelData.includes("h"))
|
||||
return Time.hourStr;
|
||||
@@ -126,8 +124,7 @@ WidgetBase {
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,13 +132,12 @@ WidgetBase {
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: Qt.formatDateTime(Time.date, "AP")
|
||||
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,14 @@ Item {
|
||||
sendIcon.icon: "arrow_upward"
|
||||
sendIcon.padding: Tokens.padding.extraSmall
|
||||
|
||||
onAccepted: root.send(text)
|
||||
Keys.onPressed: e => {
|
||||
if (e.key == Qt.Key_Return) {
|
||||
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||
root.send(text);
|
||||
e.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSendPressed: root.send(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TextFieldBase {
|
||||
TextAreaBase {
|
||||
id: root
|
||||
|
||||
readonly property alias bg: bg
|
||||
@@ -15,7 +15,7 @@ TextFieldBase {
|
||||
leftPadding: Tokens.padding.extraLarge
|
||||
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
|
||||
topPadding: Tokens.padding.large
|
||||
wrapMode: TextFieldBase.Wrap
|
||||
wrapMode: TextAreaBase.Wrap
|
||||
|
||||
background: CustomRect {
|
||||
id: bg
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
@@ -12,57 +13,69 @@ CustomClippingRect {
|
||||
required property string language
|
||||
required property string code
|
||||
property bool copied: false
|
||||
property color codeColor: Colors.palette.m3onSurfaceVariant
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
||||
property color codeHeaderColor: Colors.palette.m3outline
|
||||
property color codeAccentColor: Colors.palette.m3primary
|
||||
|
||||
// Highlighter spans for the current code; refreshed when the code or
|
||||
// its language changes.
|
||||
// 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() {
|
||||
codeSpans = CodeHighlighter.highlight(root.code, root.language);
|
||||
const token = ++root.highlightToken;
|
||||
codeSpans = [];
|
||||
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||
}
|
||||
|
||||
function onHighlightSpans(token, spans) {
|
||||
if (token !== root.highlightToken)
|
||||
return;
|
||||
codeSpans = spans;
|
||||
}
|
||||
|
||||
function roleColor(kind) {
|
||||
var s = CodeColors.active;
|
||||
switch (kind) {
|
||||
case "comment":
|
||||
return Colors.palette.m3outline;
|
||||
return s.comment;
|
||||
case "string":
|
||||
return Colors.palette.m3tertiary;
|
||||
return s.string;
|
||||
case "string.key":
|
||||
return Colors.palette.m3secondary;
|
||||
return s.stringKey;
|
||||
case "number":
|
||||
case "constant":
|
||||
return Colors.palette.m3tertiaryFixed;
|
||||
return s.number;
|
||||
case "keyword":
|
||||
return root.codeAccentColor;
|
||||
return s.keyword;
|
||||
case "type":
|
||||
return Colors.palette.m3secondary;
|
||||
return s.type;
|
||||
case "function":
|
||||
return s.functions;
|
||||
case "method":
|
||||
return Colors.palette.m3onSurface;
|
||||
return s.method ?? s.functions;
|
||||
case "macro":
|
||||
return s.macro;
|
||||
case "preproc":
|
||||
return Colors.palette.m3secondaryContainer;
|
||||
return s.preproc ?? s.macro;
|
||||
case "operator":
|
||||
return s.operator ?? s.normal;
|
||||
case "property":
|
||||
return root.codeColor;
|
||||
return s.property ?? s.normal;
|
||||
case "label":
|
||||
return s.label;
|
||||
case "attribute":
|
||||
return Colors.palette.m3tertiaryFixedDim;
|
||||
return s.attribute ?? s.label;
|
||||
default:
|
||||
return root.codeColor;
|
||||
return s.normal;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
function escapeHtml(text): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Wraps the code in <font color> tags following the highlighter spans.
|
||||
function highlightedHtml(code, spans) {
|
||||
function highlightedHtml(code, spans): string {
|
||||
let out;
|
||||
if (!spans.length) {
|
||||
out = escapeHtml(code);
|
||||
@@ -79,37 +92,17 @@ CustomClippingRect {
|
||||
if (pos < code.length)
|
||||
out += escapeHtml(code.slice(pos));
|
||||
}
|
||||
// RichText collapses HTML whitespace: break newlines, and protect
|
||||
// line-leading indentation with nbsp (internal spaces stay normal
|
||||
// so long lines can still wrap).
|
||||
return out
|
||||
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " "))
|
||||
.replace(/\n/g, "<br>");
|
||||
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
||||
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2
|
||||
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
|
||||
color: root.codeBackgroundColor
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onLanguageChanged: refresh()
|
||||
onCodeChanged: refresh()
|
||||
|
||||
CustomText {
|
||||
id: codeText
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: headerRow.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.small
|
||||
color: root.codeColor
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
|
||||
@@ -121,11 +114,74 @@ CustomClippingRect {
|
||||
anchors.rightMargin: Tokens.padding.small
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
Item {
|
||||
id: iconItem
|
||||
|
||||
readonly property string iconPath: CodeIcons.path(root.language)
|
||||
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: height
|
||||
|
||||
Shape {
|
||||
id: shape
|
||||
|
||||
anchors.centerIn: parent
|
||||
visible: iconItem.iconPath !== ""
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||
|
||||
ShapePath {
|
||||
strokeColor: "transparent"
|
||||
fillColor: Colors.palette.m3tertiary
|
||||
fillRule: ShapePath.WindingFill
|
||||
|
||||
PathSvg {
|
||||
path: iconItem.iconPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Shape {
|
||||
id: fallbackShape
|
||||
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: -1
|
||||
visible: iconItem.iconPath === ""
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||
|
||||
ShapePath {
|
||||
strokeColor: Colors.palette.m3tertiary
|
||||
strokeWidth: 16
|
||||
capStyle: ShapePath.RoundCap
|
||||
joinStyle: ShapePath.RoundJoin
|
||||
fillColor: "transparent"
|
||||
|
||||
PathSvg {
|
||||
path: "M 40 64 L 112 128 L 40 192"
|
||||
}
|
||||
}
|
||||
|
||||
ShapePath {
|
||||
strokeColor: Colors.palette.m3tertiary
|
||||
strokeWidth: 16
|
||||
capStyle: ShapePath.RoundCap
|
||||
fillColor: "transparent"
|
||||
|
||||
PathSvg {
|
||||
path: "M 120 192 L 216 192"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: langText
|
||||
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: root.codeHeaderColor
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.language
|
||||
text: CodeIcons.name(root.language)
|
||||
visible: root.language.length > 0
|
||||
}
|
||||
|
||||
@@ -137,6 +193,7 @@ CustomClippingRect {
|
||||
icon: root.copied ? "check" : "content_copy"
|
||||
inactiveColor: "transparent"
|
||||
inactiveOnColor: root.codeHeaderColor
|
||||
label.animate: true
|
||||
type: IconButton.Text
|
||||
|
||||
onClicked: {
|
||||
@@ -154,4 +211,40 @@ CustomClippingRect {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: codeRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: headerRow.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Tokens.padding.small
|
||||
radius: root.radius - anchors.margins
|
||||
anchors.margins: Tokens.padding.extraSmall
|
||||
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
|
||||
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
||||
color: CodeColors.active.bg
|
||||
|
||||
Flickable {
|
||||
id: codeFlick
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
|
||||
CustomScrollBar.horizontal: CustomScrollBar {
|
||||
flickable: codeFlick
|
||||
}
|
||||
TextAreaBase.flickable: TextAreaBase {
|
||||
id: codeText
|
||||
|
||||
color: CodeColors.active.normal
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property JsonObject schemes: JsonObject {
|
||||
readonly property Scheme oneDark: Scheme {
|
||||
comment: "#5c6370"
|
||||
string: "#98c379"
|
||||
stringKey: "#e06c75"
|
||||
number: "#d19a66"
|
||||
keyword: "#c678dd"
|
||||
type: "#e5c07b"
|
||||
functions: "#61afef"
|
||||
method: "#61afef"
|
||||
macro: "#98c379"
|
||||
preproc: "#abb2bf"
|
||||
operator: "#abb2bf"
|
||||
property: "#abb2bf"
|
||||
label: "#e06c75"
|
||||
attribute: "#d19a66"
|
||||
bg: "#282c34"
|
||||
normal: "#abb2bf"
|
||||
}
|
||||
readonly property Scheme nord: Scheme {
|
||||
comment: "#4c566a"
|
||||
string: "#a3be8c"
|
||||
stringKey: "#ebcb8b"
|
||||
number: "#b48ead"
|
||||
keyword: "#81a1c1"
|
||||
type: "#8fbcbb"
|
||||
functions: "#88c0d0"
|
||||
method: "#88c0d0"
|
||||
macro: "#5e81ac"
|
||||
preproc: "#5e81ac"
|
||||
operator: "#81a1c1"
|
||||
property: "#d8dee9"
|
||||
label: "#d08770"
|
||||
attribute: "#d8dee9"
|
||||
bg: "#2e3440"
|
||||
normal: "#d8dee9"
|
||||
}
|
||||
readonly property Scheme dracula: Scheme {
|
||||
comment: "#6272a4"
|
||||
string: "#f1fa8c"
|
||||
stringKey: "#f8f8f2"
|
||||
number: "#ffb86c"
|
||||
keyword: "#ff79c6"
|
||||
type: "#8be9fd"
|
||||
functions: "#50fa7b"
|
||||
method: "#50fa7b"
|
||||
macro: "#ff79c6"
|
||||
preproc: "#ff79c6"
|
||||
operator: "#f8f8f2"
|
||||
property: "#f8f8f2"
|
||||
label: "#6272a4"
|
||||
attribute: "#8be9fd"
|
||||
bg: "#282a36"
|
||||
normal: "#f8f8f2"
|
||||
}
|
||||
readonly property Scheme githubDark: Scheme {
|
||||
comment: "#8b949e"
|
||||
string: "#a5d6ff"
|
||||
stringKey: "#79c0ff"
|
||||
number: "#79c0ff"
|
||||
keyword: "#ff7b72"
|
||||
type: "#ffa657"
|
||||
functions: "#d2a8ff"
|
||||
method: "#d2a8ff"
|
||||
macro: "#ff7b72"
|
||||
preproc: "#79c0ff"
|
||||
operator: "#ff7b72"
|
||||
property: "#79c0ff"
|
||||
label: "#7ee787"
|
||||
attribute: "#7ee787"
|
||||
bg: "#0d1117"
|
||||
normal: "#c9d1d9"
|
||||
}
|
||||
readonly property Scheme solarizedDark: Scheme {
|
||||
comment: "#586e75"
|
||||
string: "#2aa198"
|
||||
stringKey: "#268bd2"
|
||||
number: "#2aa198"
|
||||
keyword: "#859900"
|
||||
type: "#b58900"
|
||||
functions: "#268bd2"
|
||||
method: "#268bd2"
|
||||
macro: "#cb4b16"
|
||||
preproc: "#cb4b16"
|
||||
operator: "#859900"
|
||||
property: "#268bd2"
|
||||
label: "#6c71c4"
|
||||
attribute: "#657b83"
|
||||
bg: "#002b36"
|
||||
normal: "#839496"
|
||||
}
|
||||
readonly property Scheme monokai: Scheme {
|
||||
comment: "#75715e"
|
||||
string: "#e6db74"
|
||||
stringKey: "#f8f8f2"
|
||||
number: "#ae81ff"
|
||||
keyword: "#f92672"
|
||||
type: "#a6e22e"
|
||||
functions: "#a6e22e"
|
||||
method: "#a6e22e"
|
||||
macro: "#a6e22e"
|
||||
preproc: "#f92672"
|
||||
operator: "#f92672"
|
||||
property: "#fda5ff"
|
||||
label: "#f92672"
|
||||
attribute: "#a6e22e"
|
||||
bg: "#272822"
|
||||
normal: "#f8f8f2"
|
||||
}
|
||||
readonly property Scheme gruvboxDark: Scheme {
|
||||
comment: "#928374"
|
||||
string: "#b8bb26"
|
||||
stringKey: "#ebdbb2"
|
||||
number: "#d3869b"
|
||||
keyword: "#fb4934"
|
||||
type: "#fabd2f"
|
||||
functions: "#b8bb26"
|
||||
method: "#b8bb26"
|
||||
macro: "#8ec07c"
|
||||
preproc: "#8ec07c"
|
||||
operator: "#ebdbb2"
|
||||
property: "#83a598"
|
||||
label: "#fb4934"
|
||||
attribute: "#8ec07c"
|
||||
bg: "#1d2021"
|
||||
normal: "#ebdbb2"
|
||||
}
|
||||
readonly property Scheme catppuccinMocha: Scheme {
|
||||
comment: "#9399b2"
|
||||
string: "#a6e3a1"
|
||||
stringKey: "#b4befe"
|
||||
number: "#fab387"
|
||||
keyword: "#cba6f7"
|
||||
type: "#f9e2af"
|
||||
functions: "#89b4fa"
|
||||
method: "#89b4fa"
|
||||
macro: "#cba6f7"
|
||||
preproc: "#f5c2e7"
|
||||
operator: "#89dceb"
|
||||
property: "#b4befe"
|
||||
label: "#74c7ec"
|
||||
attribute: "#f9e2af"
|
||||
bg: "#1e1e2e"
|
||||
normal: "#cdd6f4"
|
||||
}
|
||||
readonly property Scheme tokyoNight: Scheme {
|
||||
comment: "#565f89"
|
||||
string: "#9ece6a"
|
||||
stringKey: "#73daca"
|
||||
number: "#ff9e64"
|
||||
keyword: "#9d7cd8"
|
||||
type: "#2ac3de"
|
||||
functions: "#7aa2f7"
|
||||
method: "#7aa2f7"
|
||||
macro: "#7dcfff"
|
||||
preproc: "#7dcfff"
|
||||
operator: "#89ddff"
|
||||
property: "#73daca"
|
||||
label: "#7aa2f7"
|
||||
attribute: "#73daca"
|
||||
bg: "#1a1b26"
|
||||
normal: "#c0caf5"
|
||||
}
|
||||
readonly property Scheme ayuDark: Scheme {
|
||||
comment: "#5a6673"
|
||||
string: "#aad94c"
|
||||
stringKey: "#aad94c"
|
||||
number: "#d2a6ff"
|
||||
keyword: "#ff8f40"
|
||||
type: "#59c2ff"
|
||||
functions: "#ffb454"
|
||||
method: "#ffb454"
|
||||
macro: "#59c2ff"
|
||||
preproc: "#ff8f40"
|
||||
operator: "#f29668"
|
||||
property: "#f07178"
|
||||
label: "#59c2ff"
|
||||
attribute: "#ffb454"
|
||||
bg: "#0a0e14"
|
||||
normal: "#bfbdb6"
|
||||
}
|
||||
readonly property Scheme palenight: Scheme {
|
||||
comment: "#697098"
|
||||
string: "#c3e88d"
|
||||
stringKey: "#82b1ff"
|
||||
number: "#f78c6c"
|
||||
keyword: "#ff5370"
|
||||
type: "#ffcb6b"
|
||||
functions: "#82b1ff"
|
||||
method: "#82b1ff"
|
||||
macro: "#c792ea"
|
||||
preproc: "#ffcb6b"
|
||||
operator: "#89ddff"
|
||||
property: "#c3e88d"
|
||||
label: "#c792ea"
|
||||
attribute: "#ffcb6b"
|
||||
bg: "#292d3e"
|
||||
normal: "#bfc7d5"
|
||||
}
|
||||
}
|
||||
readonly property var active: schemes[Config.llm.appearance.scheme]
|
||||
|
||||
component Scheme: JsonObject {
|
||||
property color comment: "#697098"
|
||||
property color string: "#c3e88d"
|
||||
property color stringKey: "#82b1ff"
|
||||
property color number: "#f78c6c"
|
||||
property color keyword: "#ff5370"
|
||||
property color type: "#ffcb6b"
|
||||
property color functions: "#82b1ff"
|
||||
property color method: "#82b1ff"
|
||||
property color macro: "#c792ea"
|
||||
property color preproc: "#ffcb6b"
|
||||
property color operator: "#89ddff"
|
||||
property color property: "#c3e88d"
|
||||
property color label: "#c792ea"
|
||||
property color attribute: "#ffcb6b"
|
||||
property color bg: "#292d3e"
|
||||
property color normal: "#bfc7d5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@ Item {
|
||||
radius: Tokens.rounding.medium
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
@@ -92,10 +92,10 @@ Item {
|
||||
id: blocks
|
||||
|
||||
visible: !root.isUser
|
||||
anchors.topMargin: Tokens.padding.medium
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
blocks: root.segment.markdown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ Column {
|
||||
language: modelData.language
|
||||
code: modelData.code
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.spacing.small
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
@@ -42,6 +43,7 @@ Column {
|
||||
latex: modelData.latex
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ Column {
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.bold: true
|
||||
@@ -77,6 +80,7 @@ Column {
|
||||
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
|
||||
|
||||
@@ -17,11 +17,14 @@ Item {
|
||||
property int horizontalContentMargin
|
||||
required property string icon
|
||||
required property string label
|
||||
property string subtext: ""
|
||||
property bool open
|
||||
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
||||
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
||||
required property Item rootParent
|
||||
property bool separateContent
|
||||
property bool first: false
|
||||
property bool last: false
|
||||
|
||||
signal accepted
|
||||
signal cancelled
|
||||
@@ -44,8 +47,7 @@ Item {
|
||||
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +133,13 @@ Item {
|
||||
id: dialogBg
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
deformScale: 0
|
||||
group: blobGroup
|
||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||
radius: Tokens.rounding.extraSmall
|
||||
}
|
||||
|
||||
RowButton {
|
||||
@@ -145,9 +148,11 @@ Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: "transparent"
|
||||
height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim
|
||||
height: Math.min(implicitHeight, parent.height)
|
||||
icon: root.icon
|
||||
last: true
|
||||
subtext: root.subtext
|
||||
last: root.last
|
||||
first: root.first
|
||||
text: root.label
|
||||
|
||||
transform: Matrix4x4 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio
|
||||
import qs.Modules.Settings.Pages.Apps
|
||||
import qs.Modules.Settings.Pages.Panels
|
||||
import qs.Modules.Settings.Pages.Panels.Bar
|
||||
import qs.Modules.Settings.Pages.Panels.Sidebar
|
||||
import qs.Modules.Settings.Pages.Services
|
||||
import qs.Services
|
||||
|
||||
@@ -23,13 +24,11 @@ QtObject {
|
||||
// Wallpaper & style
|
||||
StackPage {
|
||||
Component {
|
||||
WallpaperPage {
|
||||
}
|
||||
WallpaperPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
WallpaperSelect {
|
||||
}
|
||||
WallpaperSelect {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -37,32 +36,27 @@ QtObject {
|
||||
// Screenshot
|
||||
StackPage {
|
||||
Component {
|
||||
ScreenshotPage {
|
||||
}
|
||||
ScreenshotPage {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Connectivity
|
||||
Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
},
|
||||
Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
},
|
||||
Component {
|
||||
// Audio
|
||||
StackPage {
|
||||
Component {
|
||||
AudioPage {
|
||||
}
|
||||
AudioPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AppVolumes {
|
||||
}
|
||||
AppVolumes {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,49 +65,45 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
PanelsPage {
|
||||
}
|
||||
PanelsPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarPanel {
|
||||
}
|
||||
BarPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
DashboardPanel {
|
||||
}
|
||||
DashboardPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
ResourcesPanel {
|
||||
}
|
||||
ResourcesPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
LauncherPanel {
|
||||
}
|
||||
LauncherPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
SidebarPanel {
|
||||
}
|
||||
SidebarPanel {}
|
||||
}
|
||||
|
||||
// Bar sub pages
|
||||
Component {
|
||||
BarTray {
|
||||
}
|
||||
BarTray {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarStatusIcons {
|
||||
}
|
||||
BarStatusIcons {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarClock {
|
||||
}
|
||||
BarClock {}
|
||||
}
|
||||
|
||||
// Sidebar sub pages
|
||||
Component {
|
||||
SidebarLlm {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -122,18 +112,15 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
AppsPage {
|
||||
}
|
||||
AppsPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AllApps {
|
||||
}
|
||||
AllApps {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AppInfo {
|
||||
}
|
||||
AppInfo {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -142,13 +129,11 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
ServicesPage {
|
||||
}
|
||||
ServicesPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
NotificationsPage {
|
||||
}
|
||||
NotificationsPage {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -157,15 +142,13 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
AboutPage {
|
||||
}
|
||||
AboutPage {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
readonly property Component placeholderComp: Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
}
|
||||
|
||||
component PlaceholderComp: Item {
|
||||
|
||||
@@ -55,6 +55,7 @@ PageBase {
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
last: true
|
||||
label: qsTr("Add entry")
|
||||
model: {
|
||||
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Modules.Settings.Common
|
||||
|
||||
PageBase {
|
||||
id: root
|
||||
|
||||
readonly property var schemes: [
|
||||
{
|
||||
id: "oneDark",
|
||||
label: qsTr("One Dark")
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
label: qsTr("Nord")
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
label: qsTr("Dracula")
|
||||
},
|
||||
{
|
||||
id: "githubDark",
|
||||
label: qsTr("Github Dark")
|
||||
},
|
||||
{
|
||||
id: "solarizedDark",
|
||||
label: qsTr("Solarized Dark")
|
||||
},
|
||||
{
|
||||
id: "monokai",
|
||||
label: qsTr("Monokai")
|
||||
},
|
||||
{
|
||||
id: "gruvboxDark",
|
||||
label: qsTr("Gruvbox Dark")
|
||||
},
|
||||
{
|
||||
id: "catppuccinMocha",
|
||||
label: qsTr("Catppuccin Mocha")
|
||||
},
|
||||
{
|
||||
id: "tokyoNight",
|
||||
label: qsTr("Tokyo Night")
|
||||
},
|
||||
{
|
||||
id: "ayuDark",
|
||||
label: qsTr("Ayu Dark")
|
||||
},
|
||||
{
|
||||
id: "palenight",
|
||||
label: qsTr("Pale Night")
|
||||
}
|
||||
]
|
||||
|
||||
isSubPage: true
|
||||
title: qsTr("Sidebar")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: Tokens.spacing.extraSmall / 2
|
||||
width: root.cappedWidth
|
||||
|
||||
SectionHeader {
|
||||
first: true
|
||||
text: qsTr("Appearance")
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
id: selectScheme
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Select scheme")
|
||||
first: true
|
||||
last: true
|
||||
icon: "add"
|
||||
label: qsTr("Color scheme")
|
||||
subtext: qsTr("Select the color scheme used for code blocks")
|
||||
model: root.schemes
|
||||
rootParent: root.flickable
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.llm.appearance.scheme = selectedItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ PageBase {
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
label: qsTr("Add entry")
|
||||
last: true
|
||||
model: {
|
||||
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
||||
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
||||
@@ -108,5 +109,18 @@ PageBase {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
text: qsTr("AI chat")
|
||||
}
|
||||
|
||||
NavRow {
|
||||
text: qsTr("AI chat")
|
||||
icon: "robot_2"
|
||||
first: true
|
||||
last: true
|
||||
|
||||
onClicked: root.sState.openSubPage(9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
#pragma once
|
||||
#include "configobject.hpp"
|
||||
#include <qhashfunctions.h>
|
||||
#include <qobject.h>
|
||||
#include <qqmlintegration.h>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
class LlmAppearance : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
|
||||
|
||||
public:
|
||||
explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Llm : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
@@ -11,12 +23,12 @@ class Llm : public ConfigObject {
|
||||
CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
|
||||
CFG_PROPERTY(QString, model, "")
|
||||
CFG_PROPERTY(double, temperature, 0.7)
|
||||
// Whether tools are offered to the model. Models without a tool-calling
|
||||
// template should run with this off.
|
||||
CFG_PROPERTY(bool, tools, true)
|
||||
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||
|
||||
public:
|
||||
explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
explicit Llm(QObject* parent = nullptr)
|
||||
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
|
||||
@@ -14,15 +14,253 @@ find_path(JKQTPlotter6_CMAKE_DIR
|
||||
DOC "Directory containing the JKQtPlotter cmake package files")
|
||||
find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}")
|
||||
|
||||
# Embed the vendored highlight queries as C++ string literals.
|
||||
set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql)
|
||||
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
|
||||
set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n")
|
||||
foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS)
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src)
|
||||
string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n")
|
||||
# --- tree-sitter grammar discovery (configure time) ---
|
||||
#
|
||||
# Discover installed tree-sitter grammars — system packages
|
||||
# (libtree-sitter-<lang>.so) and the parsers Neovim's nvim-treesitter
|
||||
# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with
|
||||
# highlight queries. Query sources, in priority order:
|
||||
# 1. vendored highlight-queries/*.scm (version-pinned; see the
|
||||
# per-file source headers, MIT),
|
||||
# 2. Neovim's own queries (version-matched to its parsers),
|
||||
# 3. tree-sitter/highlighting from GitHub (cached in the build dir).
|
||||
# The result is embedded as highlight-queries.hpp. Re-run cmake to pick
|
||||
# up grammars installed later.
|
||||
#
|
||||
# Candidate entry points are read from the .so with `nm` rather than
|
||||
# assumed to be tree_sitter_<id>; a missing entry point just makes that
|
||||
# candidate fail at runtime.
|
||||
function(_ts_entry_point out file)
|
||||
# OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so
|
||||
# capture through a temp file.
|
||||
get_filename_component(_ts_nm_base "${file}" NAME)
|
||||
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
|
||||
execute_process(
|
||||
COMMAND nm -D --defined-only "${file}"
|
||||
RESULT_VARIABLE _ts_nm_rc
|
||||
OUTPUT_FILE "${_ts_nm_file}"
|
||||
ERROR_FILE "${_ts_nm_file}.err")
|
||||
set(_ts_sym "tree_sitter_missing")
|
||||
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
|
||||
file(READ "${_ts_nm_file}" _ts_nm_out)
|
||||
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
|
||||
# The library also exports the external scanner functions; the
|
||||
# entry point is the one without the _external suffix.
|
||||
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
|
||||
foreach(_ts_s IN LISTS _ts_syms)
|
||||
string(REPLACE " T " "" _ts_s "${_ts_s}")
|
||||
if(NOT _ts_s MATCHES "_external")
|
||||
set(_ts_sym "${_ts_s}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
set(${out} "${_ts_sym}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# Append a query text to <files> (PARENT_SCOPE) unless it duplicates a
|
||||
# hash in <hashes>. The text is written to the build tree right away:
|
||||
# the s-expression `;` comments would split CMake list items and the
|
||||
# query `(` `)` break bracket arguments, so query text only ever lives
|
||||
# in variables and files, never in lists.
|
||||
# Expand Neovim's "; inherits:" directives by appending the inherited
|
||||
# query set's highlights file (recursively; a visited set prevents
|
||||
# cycles). Several languages are stubs that inherit the real query
|
||||
# (html inherits html_tags, qmljs inherits ecma, ...).
|
||||
function(_ts_expand_inherits query_dir text outVar)
|
||||
set(result "${text}")
|
||||
set(_visited "")
|
||||
set(_depth 0)
|
||||
while(_depth LESS 8)
|
||||
# Do not match the leading `;`: a MATCHALL result that itself
|
||||
# contains a semicolon is re-split into list items.
|
||||
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
|
||||
if(NOT _inh)
|
||||
break()
|
||||
endif()
|
||||
set(_added FALSE)
|
||||
foreach(_entry IN LISTS _inh)
|
||||
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
|
||||
set(_file "${query_dir}/${_name}/highlights.scm")
|
||||
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
|
||||
continue()
|
||||
endif()
|
||||
list(APPEND _visited "${_file}")
|
||||
file(READ "${_file}" _inh_text)
|
||||
string(APPEND result "\n${_inh_text}")
|
||||
set(_added TRUE)
|
||||
endforeach()
|
||||
if(NOT _added)
|
||||
break()
|
||||
endif()
|
||||
math(EXPR _depth "${_depth} + 1")
|
||||
endwhile()
|
||||
set(${outVar} "${result}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_ts_add_query sid text filesVar hashesVar)
|
||||
# filesVar/hashesVar hold the caller's variable names.
|
||||
set(files "${${filesVar}}")
|
||||
set(hashes "${${hashesVar}}")
|
||||
string(SHA256 _ts_qh "${text}")
|
||||
if(_ts_qh IN_LIST hashes)
|
||||
return()
|
||||
endif()
|
||||
list(APPEND hashes "${_ts_qh}")
|
||||
list(LENGTH files _ts_qi)
|
||||
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
|
||||
file(WRITE "${_ts_qfile}" "${text}")
|
||||
list(APPEND files "${_ts_qfile}")
|
||||
set(${filesVar} "${files}" PARENT_SCOPE)
|
||||
set(${hashesVar} "${hashes}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
set(_ts_candidates "") # entries: <id>|<cmake-safe id>|<lib>|<symbol>
|
||||
file(GLOB _ts_sys_files
|
||||
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
|
||||
foreach(_ts_file IN LISTS _ts_sys_files)
|
||||
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||
list(APPEND _ts_candidates
|
||||
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
|
||||
endforeach()
|
||||
string(APPEND _hl_header "}\n")
|
||||
set(_ts_nvim_parser_dirs
|
||||
"$ENV{HOME}/.local/share/nvim/site/parser"
|
||||
"$ENV{HOME}/.local/share/nvim/runtime/parser"
|
||||
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
|
||||
set(_ts_nvim_query_dirs
|
||||
"$ENV{HOME}/.local/share/nvim/site/queries"
|
||||
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
|
||||
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
|
||||
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
|
||||
foreach(_ts_file IN LISTS _ts_dir_files)
|
||||
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||
list(APPEND _ts_candidates
|
||||
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
set(_ts_ids "")
|
||||
foreach(_ts_c IN LISTS _ts_candidates)
|
||||
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||
list(GET _ts_parts 0 _ts_id)
|
||||
if(NOT _ts_id IN_LIST _ts_ids)
|
||||
list(APPEND _ts_ids "${_ts_id}")
|
||||
endif()
|
||||
endforeach()
|
||||
list(SORT _ts_ids)
|
||||
|
||||
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
|
||||
set(_hl_header
|
||||
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
|
||||
set(_ts_grammar_rows "")
|
||||
set(_ts_vendored
|
||||
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
|
||||
foreach(_ts_id IN LISTS _ts_ids)
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
|
||||
# Query candidates in priority order (deduped; identical texts are
|
||||
# skipped, the nvim site and plugin copies are usually the same
|
||||
# file).
|
||||
set(_ts_qfiles "")
|
||||
set(_ts_qhashes "")
|
||||
if(_ts_id STREQUAL "cpp")
|
||||
# The C++ grammar is a superset of C and its query only covers
|
||||
# the C++ delta; base C coverage comes from the C query.
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
|
||||
# The TS grammars reuse the JS node names; the JS query usually
|
||||
# compiles against them and gives full coverage.
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
elseif(_ts_id IN_LIST _ts_vendored)
|
||||
file(READ
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
|
||||
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
|
||||
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
|
||||
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _ts_qfiles)
|
||||
# Last resort: fetch from the tree-sitter/highlighting repo.
|
||||
# Version-skewed against locally installed grammars, so only
|
||||
# used when nothing local exists. Cached; offline builds simply
|
||||
# drop the language.
|
||||
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
|
||||
if(NOT EXISTS "${_ts_dl}")
|
||||
file(DOWNLOAD
|
||||
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
|
||||
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
|
||||
list(GET _ts_dl_status 0 _ts_dl_rc)
|
||||
if(NOT _ts_dl_rc EQUAL 0)
|
||||
file(REMOVE "${_ts_dl}")
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${_ts_dl}")
|
||||
file(READ "${_ts_dl}" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
endif()
|
||||
list(LENGTH _ts_qfiles _ts_nq)
|
||||
if(_ts_nq EQUAL 0)
|
||||
continue()
|
||||
endif()
|
||||
|
||||
# Emit query sources.
|
||||
set(_ts_qn 0)
|
||||
set(_ts_q_ptrs "")
|
||||
foreach(_ts_qfile IN LISTS _ts_qfiles)
|
||||
file(READ "${_ts_qfile}" _ts_q)
|
||||
string(APPEND _hl_header
|
||||
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
|
||||
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
|
||||
math(EXPR _ts_qn "${_ts_qn} + 1")
|
||||
endforeach()
|
||||
string(APPEND _hl_header
|
||||
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
|
||||
|
||||
# Emit library candidates (system package first, then Neovim).
|
||||
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
|
||||
set(_ts_nc 0)
|
||||
foreach(_ts_c IN LISTS _ts_candidates)
|
||||
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||
list(GET _ts_parts 0 _ts_cid)
|
||||
if(_ts_cid STREQUAL _ts_id)
|
||||
list(GET _ts_parts 2 _ts_lib)
|
||||
list(GET _ts_parts 3 _ts_sym)
|
||||
string(APPEND _hl_header
|
||||
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
|
||||
math(EXPR _ts_nc "${_ts_nc} + 1")
|
||||
endif()
|
||||
endforeach()
|
||||
string(APPEND _hl_header "};\n")
|
||||
if(_ts_nc EQUAL 0)
|
||||
# Queries but no library: pointless, drop the language.
|
||||
string(APPEND _hl_header "") # (arrays stay; grammar row is skipped)
|
||||
continue()
|
||||
endif()
|
||||
|
||||
list(APPEND _ts_grammar_rows
|
||||
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
|
||||
endforeach()
|
||||
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
|
||||
foreach(_ts_row IN LISTS _ts_grammar_rows)
|
||||
string(APPEND _hl_header " ${_ts_row}\n")
|
||||
endforeach()
|
||||
string(APPEND _hl_header "};\n}\n")
|
||||
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
|
||||
|
||||
# Embed the vendored Latin Modern fonts (GUST Font License; provenance
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
#include "message.hpp"
|
||||
#include "segment.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QPointer>
|
||||
#include <QStandardPaths>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QThreadPool>
|
||||
#include <QVector>
|
||||
#include <QUuid>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -48,6 +52,33 @@ QString sqlText(const QString& value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Plain data for one session's messages, fetched on a worker thread
|
||||
// and turned into the QObject tree on the GUI thread. Messages are
|
||||
// ordered as the model displays them (newest first).
|
||||
struct SegmentRow {
|
||||
QString type;
|
||||
QString text;
|
||||
QString name;
|
||||
QString toolCallId;
|
||||
QString arguments;
|
||||
QString result;
|
||||
int status = 0;
|
||||
qint64 elapsedMs = 0;
|
||||
qint64 timestamp = 0;
|
||||
};
|
||||
|
||||
struct GenerationRow {
|
||||
qint64 timestamp = 0;
|
||||
bool active = false;
|
||||
QVector<SegmentRow> segments;
|
||||
};
|
||||
|
||||
struct MessageRow {
|
||||
bool user = false;
|
||||
qint64 timestamp = 0;
|
||||
QVector<GenerationRow> generations;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatStore::ChatStore(QObject* parent)
|
||||
@@ -69,16 +100,16 @@ QSqlDatabase ChatStore::db() const {
|
||||
}
|
||||
|
||||
void ChatStore::openDb() {
|
||||
const QString path =
|
||||
m_dbPath =
|
||||
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
|
||||
QStringLiteral("/zshell/chats.sqlite");
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
|
||||
|
||||
QSqlDatabase db =
|
||||
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
db.setDatabaseName(path);
|
||||
db.setDatabaseName(m_dbPath);
|
||||
if (!db.open()) {
|
||||
qWarning() << "ChatStore: failed to open database" << path << ":"
|
||||
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
|
||||
<< db.lastError().text();
|
||||
return;
|
||||
}
|
||||
@@ -250,8 +281,13 @@ void ChatStore::setLlmClient(LlmClient* client) {
|
||||
void ChatStore::persist(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
if (!session->isLoaded()) {
|
||||
// Saving now would persist an incomplete model and wipe the
|
||||
// stored history; run it again when the load lands.
|
||||
m_pendingPersists.insert(session);
|
||||
return;
|
||||
}
|
||||
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||
session->ensureLoaded();
|
||||
if (!saveSession(session))
|
||||
return;
|
||||
sortAndNotify();
|
||||
@@ -386,90 +422,203 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
// Newest first so the model receives rows in display order.
|
||||
QSqlQuery query(db());
|
||||
query.prepare(
|
||||
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
|
||||
"ORDER BY rowid DESC");
|
||||
query.bindValue(":id", session->id());
|
||||
if (!query.exec()) {
|
||||
qWarning() << "ChatStore: failed to load messages for" << session->id()
|
||||
<< ":" << query.lastError().text();
|
||||
if (!session)
|
||||
return;
|
||||
}
|
||||
auto* model = session->messagesModel();
|
||||
QList<ChatMessage*> messages;
|
||||
while (query.next()) {
|
||||
const int messageId = query.value(0).toInt();
|
||||
auto* message = model->createMessage(
|
||||
query.value(1).toString() == QLatin1String("user")
|
||||
? ChatMessage::Role::User
|
||||
: ChatMessage::Role::Assistant,
|
||||
query.value(2).toLongLong());
|
||||
QSqlQuery generationQuery(db());
|
||||
generationQuery.prepare(
|
||||
"SELECT id, timestamp, is_active FROM generations "
|
||||
"WHERE message_id = :mid ORDER BY rowid");
|
||||
generationQuery.bindValue(":mid", messageId);
|
||||
int activeIndex = 0;
|
||||
if (generationQuery.exec()) {
|
||||
int index = 0;
|
||||
while (generationQuery.next()) {
|
||||
auto* generation = message->addGeneration(
|
||||
generationQuery.value(1).toLongLong());
|
||||
QSqlQuery segmentQuery(db());
|
||||
segmentQuery.prepare(
|
||||
"SELECT type, text, name, tool_call_id, arguments, "
|
||||
"result, status, elapsed_ms, timestamp FROM segments "
|
||||
"WHERE generation_id = :gid ORDER BY rowid");
|
||||
segmentQuery.bindValue(
|
||||
":gid", generationQuery.value(0).toInt());
|
||||
if (segmentQuery.exec()) {
|
||||
while (segmentQuery.next()) {
|
||||
auto* segment = new LlmSegment(
|
||||
segmentTypeFromName(
|
||||
segmentQuery.value(0).toString()),
|
||||
segmentQuery.value(8).toLongLong(),
|
||||
generation);
|
||||
segment->setText(
|
||||
segmentQuery.value(1).toString());
|
||||
segment->setName(
|
||||
segmentQuery.value(2).toString());
|
||||
segment->setToolCallId(
|
||||
segmentQuery.value(3).toString());
|
||||
segment->appendArguments(
|
||||
segmentQuery.value(4).toString());
|
||||
segment->setResult(
|
||||
segmentQuery.value(5).toString());
|
||||
segment->setStatus(
|
||||
static_cast<LlmSegment::Status>(
|
||||
segmentQuery.value(6).toInt()));
|
||||
segment->restore(
|
||||
segmentQuery.value(7).toLongLong());
|
||||
generation->addSegment(segment);
|
||||
const QString sessionId = session->id();
|
||||
const QString path = m_dbPath;
|
||||
|
||||
// SQL on a worker thread (its own connection; QSqlDatabase objects
|
||||
// are thread-affine). Rows come back as plain data.
|
||||
QThreadPool::globalInstance()->start(
|
||||
[store = QPointer<ChatStore>(this),
|
||||
session = QPointer<ChatSession>(session), sessionId, path]() {
|
||||
QList<MessageRow> rows;
|
||||
const QString connName = QUuid::createUuid().toString();
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(
|
||||
QStringLiteral("QSQLITE"), connName);
|
||||
db.setDatabaseName(path);
|
||||
if (db.open()) {
|
||||
// Tolerate the GUI thread writing while we read.
|
||||
QSqlQuery busy(db);
|
||||
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
|
||||
// Newest first so the model receives rows in
|
||||
// display order.
|
||||
QSqlQuery query(db);
|
||||
query.prepare(
|
||||
"SELECT id, role, timestamp FROM messages "
|
||||
"WHERE session_id = :id ORDER BY rowid DESC");
|
||||
query.bindValue(":id", sessionId);
|
||||
if (!query.exec()) {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load messages for"
|
||||
<< sessionId << ":"
|
||||
<< query.lastError().text();
|
||||
} else {
|
||||
while (query.next()) {
|
||||
const int messageId = query.value(0).toInt();
|
||||
MessageRow message;
|
||||
message.user =
|
||||
query.value(1).toString() ==
|
||||
QLatin1String("user");
|
||||
message.timestamp = query.value(2).toLongLong();
|
||||
QSqlQuery generationQuery(db);
|
||||
generationQuery.prepare(
|
||||
"SELECT id, timestamp, is_active FROM "
|
||||
"generations WHERE message_id = :mid "
|
||||
"ORDER BY rowid");
|
||||
generationQuery.bindValue(":mid", messageId);
|
||||
if (generationQuery.exec()) {
|
||||
while (generationQuery.next()) {
|
||||
GenerationRow generation;
|
||||
generation.timestamp =
|
||||
generationQuery.value(1).toLongLong();
|
||||
generation.active =
|
||||
generationQuery.value(2).toInt() != 0;
|
||||
QSqlQuery segmentQuery(db);
|
||||
segmentQuery.prepare(
|
||||
"SELECT type, text, name, tool_call_id, "
|
||||
"arguments, result, status, elapsed_ms, "
|
||||
"timestamp FROM segments WHERE "
|
||||
"generation_id = :gid ORDER BY rowid");
|
||||
segmentQuery.bindValue(
|
||||
":gid",
|
||||
generationQuery.value(0).toInt());
|
||||
if (segmentQuery.exec()) {
|
||||
while (segmentQuery.next()) {
|
||||
SegmentRow segment;
|
||||
segment.type =
|
||||
segmentQuery.value(0).toString();
|
||||
segment.text =
|
||||
segmentQuery.value(1).toString();
|
||||
segment.name =
|
||||
segmentQuery.value(2).toString();
|
||||
segment.toolCallId =
|
||||
segmentQuery.value(3).toString();
|
||||
segment.arguments =
|
||||
segmentQuery.value(4).toString();
|
||||
segment.result =
|
||||
segmentQuery.value(5).toString();
|
||||
segment.status =
|
||||
segmentQuery.value(6).toInt();
|
||||
segment.elapsedMs =
|
||||
segmentQuery.value(7).toLongLong();
|
||||
segment.timestamp =
|
||||
segmentQuery.value(8).toLongLong();
|
||||
generation.segments.append(segment);
|
||||
}
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load "
|
||||
"segments for generation"
|
||||
<< generationQuery.value(0).toInt()
|
||||
<< ":"
|
||||
<< segmentQuery.lastError().text();
|
||||
}
|
||||
message.generations.append(generation);
|
||||
}
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load generations "
|
||||
"for message"
|
||||
<< messageId << ":"
|
||||
<< generationQuery.lastError().text();
|
||||
}
|
||||
rows.append(message);
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
} else {
|
||||
qWarning() << "ChatStore: failed to load segments for "
|
||||
<< "generation"
|
||||
<< generationQuery.value(0).toInt()
|
||||
<< ":"
|
||||
<< segmentQuery.lastError().text();
|
||||
qWarning()
|
||||
<< "ChatStore: failed to open database for load:"
|
||||
<< db.lastError().text();
|
||||
}
|
||||
if (generationQuery.value(2).toInt() != 0)
|
||||
activeIndex = index;
|
||||
++index;
|
||||
}
|
||||
} else {
|
||||
qWarning() << "ChatStore: failed to load generations for message"
|
||||
<< messageId << ":"
|
||||
<< generationQuery.lastError().text();
|
||||
}
|
||||
message->setActiveGeneration(activeIndex);
|
||||
messages.append(message);
|
||||
}
|
||||
session->adoptMessages(messages);
|
||||
// Remove only once every QSqlDatabase copy and query is gone;
|
||||
// while any reference is alive Qt refuses the removal and the
|
||||
// connection is left dangling in a broken state.
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
|
||||
// Build the object tree on the GUI thread. Deliver through
|
||||
// the app instance (never destroyed) and re-check the
|
||||
// pointers there: posting to `store` from the pool thread
|
||||
// would race with its destruction.
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[store, session, rows = std::move(rows)]() mutable {
|
||||
ChatStore* st = store;
|
||||
ChatSession* s = session;
|
||||
if (!st || !s)
|
||||
return;
|
||||
|
||||
// Rows fetched from disk; newest first.
|
||||
auto* model = s->model();
|
||||
if (!model)
|
||||
return;
|
||||
QList<ChatMessage*> messages;
|
||||
for (const MessageRow& row : rows) {
|
||||
auto* message = model->createMessage(
|
||||
row.user ? ChatMessage::Role::User
|
||||
: ChatMessage::Role::Assistant,
|
||||
row.timestamp);
|
||||
int activeIndex = 0;
|
||||
for (int i = 0; i < row.generations.size(); ++i) {
|
||||
const GenerationRow& generationRow =
|
||||
row.generations.at(i);
|
||||
auto* generation =
|
||||
message->addGeneration(generationRow.timestamp);
|
||||
for (const SegmentRow& segmentRow :
|
||||
generationRow.segments) {
|
||||
auto* segment = new LlmSegment(
|
||||
segmentTypeFromName(segmentRow.type),
|
||||
segmentRow.timestamp,
|
||||
generation);
|
||||
segment->setText(segmentRow.text);
|
||||
segment->setName(segmentRow.name);
|
||||
segment->setToolCallId(segmentRow.toolCallId);
|
||||
segment->appendArguments(segmentRow.arguments);
|
||||
segment->setResult(segmentRow.result);
|
||||
segment->setStatus(
|
||||
static_cast<LlmSegment::Status>(
|
||||
segmentRow.status));
|
||||
segment->restore(segmentRow.elapsedMs);
|
||||
generation->addSegment(segment);
|
||||
}
|
||||
if (generationRow.active)
|
||||
activeIndex = i;
|
||||
}
|
||||
message->setActiveGeneration(activeIndex);
|
||||
messages.append(message);
|
||||
}
|
||||
// Rows added live while the load was in flight are
|
||||
// newer than anything on disk; keep them in front.
|
||||
if (model->rowCount() > 0) {
|
||||
QList<ChatMessage*> live = messages;
|
||||
for (int r = 0; r < model->rowCount(); ++r)
|
||||
live.prepend(model->at(r));
|
||||
messages = live;
|
||||
}
|
||||
if (!messages.isEmpty() || model->rowCount() > 0)
|
||||
s->adoptMessages(messages);
|
||||
|
||||
// Mark loaded only once the model holds both the
|
||||
// fetched history and the rows added live while the
|
||||
// load ran, so a deferred startGeneration (triggered
|
||||
// by loaded()) builds its context from the complete
|
||||
// conversation.
|
||||
s->markLoaded();
|
||||
|
||||
if (s->takeClearPending()) {
|
||||
// Cleared while the load was in flight; drop
|
||||
// everything now that the model is populated.
|
||||
s->clear();
|
||||
} else if (st->m_pendingPersists.remove(s)) {
|
||||
st->persist(s);
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void ChatStore::load() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QSqlDatabase>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
@@ -38,6 +39,9 @@ class ChatStore : public QObject {
|
||||
|
||||
void persist(ChatSession* session);
|
||||
void saveMeta(ChatSession* session);
|
||||
// Loads the session's messages from the database. The SQL runs on a
|
||||
// worker thread; the object tree is built and the model updated on
|
||||
// the GUI thread when it arrives (ChatSession::loaded).
|
||||
void loadMessagesInto(ChatSession* session);
|
||||
|
||||
Q_SIGNALS:
|
||||
@@ -56,6 +60,10 @@ class ChatStore : public QObject {
|
||||
QList<ChatSession*> m_sessions;
|
||||
LlmClient* m_llmClient = nullptr;
|
||||
QString m_connectionName;
|
||||
QString m_dbPath;
|
||||
// Sessions whose persist() ran before their messages finished
|
||||
// loading; persisted once the load lands.
|
||||
QSet<ChatSession*> m_pendingPersists;
|
||||
|
||||
[[nodiscard]] QSqlDatabase db() const;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,17 @@
|
||||
|
||||
#include <tree_sitter/api.h>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QMutexLocker>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
#include <QThreadPool>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
|
||||
namespace ZShell::llm {
|
||||
@@ -57,77 +64,21 @@ const char* roleName(Role role) {
|
||||
|
||||
using LanguageFn = const TSLanguage* (*)();
|
||||
|
||||
// Query sources, indexed by Grammar::queries.
|
||||
struct QuerySources {
|
||||
std::vector<std::string> sources;
|
||||
int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended;
|
||||
};
|
||||
|
||||
const QuerySources& querySources() {
|
||||
static const QuerySources sources = [] {
|
||||
QuerySources qs;
|
||||
// javascript + typescript concatenated: the TS grammar reuses the
|
||||
// JS node names, so the JS query usually compiles against it and
|
||||
// gives full coverage; the TS-only query is the fallback.
|
||||
const std::string tsExtended =
|
||||
std::string(hq::javascript) + "\n" + std::string(hq::typescript);
|
||||
// Same for cpp: the C++ grammar is a superset of C, and the C++
|
||||
// query only covers the C++-specific delta. Base C coverage
|
||||
// (keywords, types, calls, strings, comments) comes from the C
|
||||
// query.
|
||||
const std::string cppExtended =
|
||||
std::string(hq::c) + "\n" + std::string(hq::cpp);
|
||||
qs.sources.reserve(14);
|
||||
qs.sources.push_back(hq::c);
|
||||
qs.sources.push_back(hq::cpp);
|
||||
qs.sources.push_back(hq::python);
|
||||
qs.sources.push_back(hq::javascript);
|
||||
qs.sources.push_back(tsExtended);
|
||||
qs.sources.push_back(hq::typescript);
|
||||
qs.sources.push_back(hq::bash);
|
||||
qs.sources.push_back(hq::json);
|
||||
qs.sources.push_back(hq::rust);
|
||||
qs.sources.push_back(hq::go);
|
||||
qs.sources.push_back(hq::yaml);
|
||||
qs.sources.push_back(hq::toml);
|
||||
qs.sources.push_back(hq::sql);
|
||||
qs.sources.push_back(cppExtended);
|
||||
qs.c = 0;
|
||||
qs.cpp = 1;
|
||||
qs.python = 2;
|
||||
qs.javascript = 3;
|
||||
qs.typescriptExtended = 4;
|
||||
qs.typescript = 5;
|
||||
qs.bash = 6;
|
||||
qs.json = 7;
|
||||
qs.rust = 8;
|
||||
qs.go = 9;
|
||||
qs.yaml = 10;
|
||||
qs.toml = 11;
|
||||
qs.sql = 12;
|
||||
qs.cppExtended = 13;
|
||||
return qs;
|
||||
}();
|
||||
return sources;
|
||||
}
|
||||
|
||||
// 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 = [] {
|
||||
const auto& qs = querySources();
|
||||
QHash<QString, CodeHighlighter::Grammar> map;
|
||||
map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}});
|
||||
map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}});
|
||||
map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}});
|
||||
map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}});
|
||||
map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}});
|
||||
map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}});
|
||||
map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}});
|
||||
map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}});
|
||||
map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}});
|
||||
map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}});
|
||||
map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}});
|
||||
map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}});
|
||||
map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}});
|
||||
for (const auto& g : hq::grammars) {
|
||||
CodeHighlighter::Grammar grammar;
|
||||
for (int i = 0; i < g.nCandidates; ++i) {
|
||||
grammar.libs.push_back(g.candidates[i].lib);
|
||||
grammar.symbols.push_back(g.candidates[i].symbol);
|
||||
}
|
||||
for (int i = 0; i < g.nQueries; ++i)
|
||||
grammar.queries.push_back(g.queries[i]);
|
||||
map.insert(g.id, std::move(grammar));
|
||||
}
|
||||
return map;
|
||||
}();
|
||||
return grammars;
|
||||
@@ -170,6 +121,8 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
|
||||
map.insert("shell-session", "bash");
|
||||
map.insert("zsh", "bash");
|
||||
map.insert("console", "bash");
|
||||
map.insert("qml", "qmljs");
|
||||
map.insert("qmljs", "qmljs");
|
||||
map.insert("json", "json");
|
||||
map.insert("jsonc", "json");
|
||||
map.insert("rust", "rust");
|
||||
@@ -190,10 +143,6 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& CodeHighlighter::querySources() const {
|
||||
return hl::querySources().sources;
|
||||
}
|
||||
|
||||
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
|
||||
const QString n = QString::fromUtf8(name, length);
|
||||
if (n == "comment")
|
||||
@@ -204,15 +153,18 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
|
||||
return hl::Role::String;
|
||||
if (n.startsWith("number"))
|
||||
return hl::Role::Number;
|
||||
if (n.startsWith("constant"))
|
||||
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."))
|
||||
return hl::Role::Type;
|
||||
if (n == "namespace" || n == "module")
|
||||
if (n.startsWith("namespace") || n.startsWith("module")
|
||||
|| n == "support.type" || n == "support.namespace")
|
||||
return hl::Role::Type;
|
||||
if (n.startsWith("function") || n == "constructor")
|
||||
if (n.startsWith("function") || n == "constructor"
|
||||
|| n.startsWith("support.function"))
|
||||
return hl::Role::Function;
|
||||
if (n == "method" || n == "method.builtin")
|
||||
return hl::Role::Method;
|
||||
@@ -220,7 +172,8 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
|
||||
return hl::Role::Macro;
|
||||
if (n.startsWith("preproc"))
|
||||
return hl::Role::Preproc;
|
||||
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator."))
|
||||
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;
|
||||
@@ -228,6 +181,13 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
|
||||
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;
|
||||
return hl::Role::None;
|
||||
}
|
||||
|
||||
@@ -235,15 +195,45 @@ const char* CodeHighlighter::roleName(uint8_t role) {
|
||||
return hl::roleName(static_cast<hl::Role>(role));
|
||||
}
|
||||
|
||||
QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const {
|
||||
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);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, token, spans]() {
|
||||
if (!guard)
|
||||
return;
|
||||
// QML functions are only invokable by their generic
|
||||
// QVariant overload, so pass untyped arguments.
|
||||
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;
|
||||
|
||||
const QString id = aliases().value(language.trimmed().toLower());
|
||||
if (id.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 Grammar& grammar = hl::grammars().value(id);
|
||||
if (grammar.libs.empty())
|
||||
return spans;
|
||||
|
||||
// Guard against pathological blocks; highlighting is best-effort.
|
||||
static constexpr size_t kMaxBytes = 512 * 1024;
|
||||
@@ -251,59 +241,72 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
|
||||
if (static_cast<size_t>(utf8.size()) > kMaxBytes)
|
||||
return spans;
|
||||
|
||||
State& state = m_states[id];
|
||||
if (state.bad)
|
||||
return spans;
|
||||
|
||||
if (!state.lang) {
|
||||
// Missing library is retriable (it may be installed while the
|
||||
// shell runs); ABI/query failures below are not.
|
||||
state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL);
|
||||
if (!state.lib)
|
||||
return spans;
|
||||
auto* symbol = reinterpret_cast<hl::LanguageFn>(
|
||||
dlsym(state.lib, grammar.symbol.toUtf8().constData()));
|
||||
if (!symbol) {
|
||||
dlclose(state.lib);
|
||||
state.lib = nullptr;
|
||||
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;
|
||||
auto* symbol = reinterpret_cast<hl::LanguageFn>(
|
||||
dlsym(lib, grammar.symbols[i].c_str()));
|
||||
if (!symbol) {
|
||||
dlclose(lib);
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
dlclose(lib);
|
||||
abiMismatch = true;
|
||||
continue;
|
||||
}
|
||||
fresh->lib = lib;
|
||||
fresh->lang = candidate;
|
||||
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;
|
||||
TSQuery* candidate = ts_query_new(
|
||||
static_cast<const TSLanguage*>(fresh->lang),
|
||||
source,
|
||||
static_cast<uint32_t>(std::strlen(source)),
|
||||
&errorOffset,
|
||||
&errorType);
|
||||
if (!candidate)
|
||||
continue;
|
||||
fresh->query = candidate;
|
||||
break;
|
||||
}
|
||||
if (!fresh->query)
|
||||
fresh->bad = true;
|
||||
} else if (abiMismatch) {
|
||||
fresh->bad = true;
|
||||
}
|
||||
if (fresh->lang || fresh->bad)
|
||||
state = std::move(fresh);
|
||||
}
|
||||
const TSLanguage* lang = symbol();
|
||||
const uint32_t version = ts_language_abi_version(lang);
|
||||
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
|
||||
version > TREE_SITTER_LANGUAGE_VERSION) {
|
||||
state.bad = true;
|
||||
if (!state || state->bad || !state->lang)
|
||||
return spans;
|
||||
}
|
||||
state.lang = lang;
|
||||
}
|
||||
|
||||
if (!state.query) {
|
||||
// Candidates in priority order; first that compiles wins.
|
||||
for (const int candidate : grammar.queries) {
|
||||
const std::string& source =
|
||||
querySources()[static_cast<size_t>(candidate)];
|
||||
TSQueryError errorType = TSQueryErrorNone;
|
||||
uint32_t errorOffset = 0;
|
||||
TSQuery* query = ts_query_new(
|
||||
static_cast<const TSLanguage*>(state.lang),
|
||||
source.data(),
|
||||
static_cast<uint32_t>(source.size()),
|
||||
&errorOffset,
|
||||
&errorType);
|
||||
if (!query)
|
||||
continue;
|
||||
state.query = query;
|
||||
break;
|
||||
}
|
||||
if (!state.query) {
|
||||
state.bad = true;
|
||||
return spans;
|
||||
}
|
||||
lang = static_cast<const TSLanguage*>(state->lang);
|
||||
query = static_cast<TSQuery*>(state->query);
|
||||
}
|
||||
|
||||
TSParser* parser = ts_parser_new();
|
||||
ts_parser_set_language(parser, static_cast<const TSLanguage*>(state.lang));
|
||||
ts_parser_set_language(parser, lang);
|
||||
TSTree* tree = ts_parser_parse_string(
|
||||
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
|
||||
if (!tree) {
|
||||
@@ -311,7 +314,6 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
|
||||
return spans;
|
||||
}
|
||||
|
||||
TSQuery* query = static_cast<TSQuery*>(state.query);
|
||||
TSQueryCursor* cursor = ts_query_cursor_new();
|
||||
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
class QQmlEngine;
|
||||
@@ -15,37 +17,51 @@ namespace ZShell::llm {
|
||||
|
||||
// Syntax highlighting for LLM code blocks via tree-sitter.
|
||||
//
|
||||
// The tree-sitter runtime is linked. Grammar libraries
|
||||
// (libtree-sitter-<lang>.so) are dlopen()'d lazily, so a missing
|
||||
// grammar package degrades that language to plain text instead of
|
||||
// breaking the build or the app. Highlight queries are embedded at
|
||||
// build time (highlight-queries/*.scm, vendored from the grammar
|
||||
// repos, MIT).
|
||||
// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d
|
||||
// lazily, so a missing grammar degrades that language to plain text
|
||||
// instead of breaking the build or the app. At configure time CMake
|
||||
// discovers installed grammars (system packages plus the parsers
|
||||
// Neovim's nvim-treesitter installs) and pairs each with highlight
|
||||
// queries (vendored, Neovim's, or fetched from
|
||||
// tree-sitter/highlighting); both are embedded in the generated
|
||||
// highlight-queries.hpp.
|
||||
//
|
||||
// For each grammar the first loadable (ABI-compatible) library wins
|
||||
// and the first query that compiles against it wins, so a version
|
||||
// skew between a library and its query degrades gracefully.
|
||||
//
|
||||
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
|
||||
// to a grammar through an alias table.
|
||||
// to a grammar through an alias table; tags not in the table are used
|
||||
// as grammar ids as-is.
|
||||
//
|
||||
// highlight() returns a list of span maps:
|
||||
// highlight() parses the code off the GUI thread and delivers a list of
|
||||
// span maps by calling target's "onHighlightSpans(token, spans)" method
|
||||
// (on the GUI thread):
|
||||
// { "start": int, "length": int, "kind": QString }
|
||||
// where kind is a semantic role (keyword, string, comment, number,
|
||||
// function, type, ...) that QML maps to theme colors. An empty list
|
||||
// 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.
|
||||
class CodeHighlighter : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
|
||||
public:
|
||||
Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const;
|
||||
Q_INVOKABLE void highlight(
|
||||
const QString& code, const QString& language, QObject* target, int token);
|
||||
|
||||
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
|
||||
|
||||
struct Grammar {
|
||||
QString lib; // soname to dlopen
|
||||
QString symbol; // tree_sitter_<lang>() entry point
|
||||
// Candidate query sources, first wins. Lets typescript reuse
|
||||
// the javascript query when it compiles against the grammar.
|
||||
std::vector<int> queries; // indices into querySources()
|
||||
// Library candidates in priority order (system package, then
|
||||
// Neovim copies); libs[i] pairs with symbols[i].
|
||||
std::vector<std::string> libs;
|
||||
std::vector<std::string> symbols;
|
||||
// Candidate query sources in priority order; first that
|
||||
// compiles against the loaded grammar wins.
|
||||
std::vector<const char*> queries;
|
||||
};
|
||||
|
||||
private:
|
||||
@@ -57,12 +73,16 @@ class CodeHighlighter : public QObject {
|
||||
};
|
||||
|
||||
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
||||
[[nodiscard]] const std::vector<std::string>& querySources() const;
|
||||
// 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);
|
||||
// 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;
|
||||
|
||||
mutable QHash<QString, State> m_states;
|
||||
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
||||
mutable QMutex m_stateMutex;
|
||||
static CodeHighlighter* s_instance;
|
||||
};
|
||||
|
||||
|
||||
+122
-48
@@ -5,9 +5,13 @@
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFontDatabase>
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QThreadPool>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
@@ -15,6 +19,9 @@ namespace {
|
||||
// A few px of breathing room around the equation.
|
||||
constexpr int kRenderMargin = 2;
|
||||
constexpr unsigned int kResolutionDpi = 96;
|
||||
// The render cache is unbounded between clears; cap it so a very long
|
||||
// session with many distinct equations cannot grow it forever.
|
||||
constexpr int kCacheLimit = 512;
|
||||
|
||||
// Registers the embedded Latin Modern faces with the font database
|
||||
// (once per process) and reports which families became available.
|
||||
@@ -65,20 +72,23 @@ const LatinModern& loadLatinModern() {
|
||||
} // namespace
|
||||
|
||||
LlmMathText::LlmMathText(QObject* parent)
|
||||
: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) {
|
||||
// No parent: the renderer is used from pool threads, and a parented
|
||||
// QObject would taint children it creates there.
|
||||
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>(
|
||||
nullptr, /* useFontsForGUI */ true)) {
|
||||
// Latin Modern is the default font of modern LaTeX; use the embedded
|
||||
// faces instead of whatever the system happens to have installed.
|
||||
const LatinModern& fonts = loadLatinModern();
|
||||
if (fonts.roman)
|
||||
m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
if (fonts.math) {
|
||||
// Same pattern as JKQTMathText's useXITS(): the OpenType math
|
||||
// font supplies the math alphabet and operators from its MATH
|
||||
// table.
|
||||
m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer.setFallbackFontSymbols(
|
||||
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer->setFallbackFontSymbols(
|
||||
QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
}
|
||||
@@ -112,7 +122,28 @@ void LlmMathText::setDevicePixelRatio(qreal value) {
|
||||
reRender();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Process-wide render cache; the key is the full render state, so
|
||||
// re-opening a chat reuses already-rendered equations. Accessed on the
|
||||
// GUI thread only.
|
||||
struct MathRender {
|
||||
bool ok = false;
|
||||
QImage image;
|
||||
QUrl url;
|
||||
qreal width = 0;
|
||||
qreal height = 0;
|
||||
};
|
||||
|
||||
QHash<QString, MathRender>& mathCache() {
|
||||
static QHash<QString, MathRender> cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void LlmMathText::reRender() {
|
||||
++m_requestId;
|
||||
if (m_latex.trimmed().isEmpty()) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
@@ -123,54 +154,97 @@ void LlmMathText::reRender() {
|
||||
return;
|
||||
}
|
||||
|
||||
m_renderer.setFontPointSize(m_fontPointSize);
|
||||
m_renderer.setFontColor(m_color);
|
||||
|
||||
const bool ok = m_renderer.parse(
|
||||
m_latex,
|
||||
JKQTMathText::LatexParser,
|
||||
JKQTMathText::DefaultParseOptions);
|
||||
if (!ok) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
m_width = 0;
|
||||
m_height = 0;
|
||||
m_ok = false;
|
||||
const QString key = m_latex
|
||||
+ QLatin1Char(0x1f) + m_color.name()
|
||||
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize)
|
||||
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
|
||||
if (auto it = mathCache().find(key); it != mathCache().end()) {
|
||||
m_image = it->image;
|
||||
m_imageUrl = it->url;
|
||||
m_width = it->width;
|
||||
m_height = it->height;
|
||||
m_ok = it->ok;
|
||||
Q_EMIT changed();
|
||||
return;
|
||||
}
|
||||
|
||||
const QImage image = m_renderer.drawIntoImage(
|
||||
/* drawBoxes */ false,
|
||||
QColor(Qt::transparent),
|
||||
kRenderMargin,
|
||||
m_devicePixelRatio,
|
||||
kResolutionDpi);
|
||||
if (image.isNull()) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
m_width = 0;
|
||||
m_height = 0;
|
||||
m_ok = false;
|
||||
Q_EMIT changed();
|
||||
// A render is already running; it re-renders the latest state when
|
||||
// it completes (id mismatch), so there is nothing to do here.
|
||||
if (m_inFlight)
|
||||
return;
|
||||
}
|
||||
m_inFlight = true;
|
||||
|
||||
m_image = image;
|
||||
QByteArray png;
|
||||
{
|
||||
QBuffer buffer(&png);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
image.save(&buffer, "PNG");
|
||||
}
|
||||
m_imageUrl = QUrl(
|
||||
QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64()));
|
||||
// drawIntoImage renders at devicePixelRatio; convert back to
|
||||
// logical pixels.
|
||||
m_width = image.width() / m_devicePixelRatio;
|
||||
m_height = image.height() / m_devicePixelRatio;
|
||||
m_ok = true;
|
||||
Q_EMIT changed();
|
||||
const QString latex = m_latex;
|
||||
const QColor color = m_color;
|
||||
const double pointSize = m_fontPointSize;
|
||||
const qreal dpr = m_devicePixelRatio;
|
||||
// The worker captures the renderer by value (shared_ptr) so it
|
||||
// stays alive even if this object is destroyed mid-render; it is
|
||||
// only ever used by the single in-flight worker (m_inFlight),
|
||||
// never concurrently.
|
||||
auto renderer = m_renderer;
|
||||
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
|
||||
MathRender render;
|
||||
renderer->setFontPointSize(pointSize);
|
||||
renderer->setFontColor(color);
|
||||
if (renderer->parse(
|
||||
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) {
|
||||
const QImage image = renderer->drawIntoImage(
|
||||
/* drawBoxes */ false,
|
||||
QColor(Qt::transparent),
|
||||
kRenderMargin,
|
||||
dpr,
|
||||
kResolutionDpi);
|
||||
if (!image.isNull()) {
|
||||
QByteArray png;
|
||||
{
|
||||
QBuffer buffer(&png);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
image.save(&buffer, "PNG");
|
||||
}
|
||||
render.image = image;
|
||||
render.url = QUrl(
|
||||
QStringLiteral("data:image/png;base64,")
|
||||
+ QString::fromLatin1(png.toBase64()));
|
||||
// drawIntoImage renders at devicePixelRatio; convert
|
||||
// back to logical pixels.
|
||||
render.width = image.width() / dpr;
|
||||
render.height = image.height() / dpr;
|
||||
render.ok = true;
|
||||
}
|
||||
}
|
||||
// Deliver through the app instance (never destroyed) and
|
||||
// re-check the pointer on the GUI thread: posting to `this`
|
||||
// from the pool thread would race with its destruction.
|
||||
QPointer<LlmMathText> guard(this);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, id, key, render = std::move(render)]() mutable {
|
||||
LlmMathText* self = guard;
|
||||
if (!self)
|
||||
return;
|
||||
self->m_inFlight = false;
|
||||
if (id != self->m_requestId) {
|
||||
// Superseded while the worker ran; render the
|
||||
// latest state.
|
||||
self->reRender();
|
||||
return;
|
||||
}
|
||||
if (render.ok) {
|
||||
auto& cache = mathCache();
|
||||
if (cache.size() >= kCacheLimit)
|
||||
cache.clear();
|
||||
cache.insert(key, render);
|
||||
}
|
||||
self->m_image = render.image;
|
||||
self->m_imageUrl = render.url;
|
||||
self->m_width = render.width;
|
||||
self->m_height = render.height;
|
||||
self->m_ok = render.ok;
|
||||
Q_EMIT self->changed();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include <QUrl>
|
||||
#include <QtQml>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
namespace ZShell::llm {
|
||||
@@ -14,7 +16,9 @@ namespace ZShell::llm {
|
||||
// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer).
|
||||
//
|
||||
// Parses a display-math string and renders it into a transparent
|
||||
// QImage at the given device pixel ratio. QML displays the image
|
||||
// QImage at the given device pixel ratio, off the GUI thread (with a
|
||||
// process-wide cache keyed on the full render state, so re-opening a
|
||||
// chat does not re-render the same equations). QML displays the image
|
||||
// (scaling it to the bubble width when needed) and falls back to the
|
||||
// raw LaTeX when parsing fails.
|
||||
class LlmMathText : public QObject {
|
||||
@@ -57,7 +61,9 @@ class LlmMathText : public QObject {
|
||||
private:
|
||||
void reRender();
|
||||
|
||||
JKQTMathText m_renderer;
|
||||
// Shared so an in-flight worker render keeps the renderer alive if
|
||||
// this object (and its QML item) is destroyed mid-render.
|
||||
std::shared_ptr<JKQTMathText> m_renderer;
|
||||
QString m_latex;
|
||||
QColor m_color;
|
||||
double m_fontPointSize = 12.0;
|
||||
@@ -67,6 +73,10 @@ class LlmMathText : public QObject {
|
||||
qreal m_width = 0;
|
||||
qreal m_height = 0;
|
||||
bool m_ok = false;
|
||||
// Bumps on every reRender; a delivery carrying an older id was
|
||||
// superseded and is dropped.
|
||||
int m_requestId = 0;
|
||||
bool m_inFlight = false;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "session.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent)
|
||||
@@ -98,7 +100,12 @@ void ChatMessageModel::clear() {
|
||||
|
||||
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
|
||||
beginResetModel();
|
||||
qDeleteAll(m_messages);
|
||||
// The new list may share rows with the current one (live rows kept
|
||||
// in front of fetched rows); delete only what is truly gone.
|
||||
for (ChatMessage* message : m_messages)
|
||||
if (std::find(messages.begin(), messages.end(), message)
|
||||
== messages.end())
|
||||
delete message;
|
||||
m_messages = std::move(messages);
|
||||
endResetModel();
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
#include "markdownparser.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QPointer>
|
||||
#include <QThreadPool>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
@@ -19,8 +22,10 @@ LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
|
||||
&m_markdownTimer, &QTimer::timeout, this, [this]() {
|
||||
if (!m_markdownDirty)
|
||||
return;
|
||||
m_markdownDirty = false;
|
||||
parseMarkdown();
|
||||
// A parse may still be running; leave the dirty flag set so
|
||||
// it re-parses the newest text when that one completes.
|
||||
if (parseMarkdown())
|
||||
m_markdownDirty = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +58,8 @@ void LlmSegment::close() {
|
||||
// out the debounce.
|
||||
if (m_type == Type::Content && m_markdownDirty) {
|
||||
m_markdownTimer.stop();
|
||||
parseMarkdown();
|
||||
if (parseMarkdown())
|
||||
m_markdownDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +136,33 @@ void LlmSegment::scheduleMarkdown() {
|
||||
m_markdownTimer.start();
|
||||
}
|
||||
|
||||
void LlmSegment::parseMarkdown() {
|
||||
m_markdown = MarkdownParser::parse(m_text);
|
||||
Q_EMIT markdownChanged();
|
||||
bool LlmSegment::parseMarkdown() {
|
||||
if (m_parseInFlight)
|
||||
return false;
|
||||
m_parseInFlight = true;
|
||||
const QString text = m_text;
|
||||
QThreadPool::globalInstance()->start([this, text]() {
|
||||
const QVariantList blocks = MarkdownParser::parse(text);
|
||||
// Deliver through qApp (never destroyed) and re-check the
|
||||
// pointer on the GUI thread: posting to `this` from the pool
|
||||
// thread would race with its destruction.
|
||||
QPointer<LlmSegment> guard(this);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, blocks]() {
|
||||
LlmSegment* seg = guard;
|
||||
if (!seg)
|
||||
return;
|
||||
seg->m_parseInFlight = false;
|
||||
seg->m_markdown = blocks;
|
||||
Q_EMIT seg->markdownChanged();
|
||||
// Text arrived while the worker was running; parse it.
|
||||
if (seg->m_markdownDirty)
|
||||
seg->parseMarkdown();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -92,7 +92,9 @@ class LlmSegment : public QObject {
|
||||
|
||||
private:
|
||||
void scheduleMarkdown();
|
||||
void parseMarkdown();
|
||||
// Kicks off an off-thread parse; returns false when one is already
|
||||
// in flight (the pending change is picked up when it completes).
|
||||
bool parseMarkdown();
|
||||
|
||||
Type m_type;
|
||||
qint64 m_timestamp;
|
||||
@@ -108,6 +110,7 @@ class LlmSegment : public QObject {
|
||||
QVariantList m_markdown;
|
||||
QTimer m_markdownTimer;
|
||||
bool m_markdownDirty = false;
|
||||
bool m_parseInFlight = false;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -94,8 +94,8 @@ LlmClient* ChatSession::client() const {
|
||||
}
|
||||
|
||||
void ChatSession::ensureLoaded() {
|
||||
if (m_loaded) return;
|
||||
m_loaded = true;
|
||||
if (m_loadRequested) return;
|
||||
m_loadRequested = true;
|
||||
if (auto* store = qobject_cast<ChatStore*>(parent()))
|
||||
store->loadMessagesInto(this);
|
||||
}
|
||||
@@ -105,6 +105,18 @@ ChatMessageModel* ChatSession::messagesModel() {
|
||||
return m_model;
|
||||
}
|
||||
|
||||
void ChatSession::markLoaded() {
|
||||
if (m_loaded) return;
|
||||
m_loaded = true;
|
||||
Q_EMIT loaded();
|
||||
}
|
||||
|
||||
bool ChatSession::takeClearPending() {
|
||||
const bool pending = m_clearPending;
|
||||
m_clearPending = false;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
|
||||
m_model->loadMessages(std::move(messages));
|
||||
}
|
||||
@@ -127,9 +139,20 @@ void ChatSession::clearMessages() {
|
||||
}
|
||||
|
||||
void ChatSession::startGeneration(ChatMessage* target) {
|
||||
if (auto* generation = target->activeGeneration()) {
|
||||
if (auto* clientObject = client())
|
||||
auto* generation = target->activeGeneration();
|
||||
auto* clientObject = client();
|
||||
if (generation && clientObject) {
|
||||
if (isLoaded()) {
|
||||
clientObject->startGeneration(this, generation);
|
||||
} else {
|
||||
// The store load is still in flight; the request needs the
|
||||
// full history, so start once it lands.
|
||||
connect(
|
||||
this, &ChatSession::loaded, clientObject,
|
||||
[this, generation, clientObject]() {
|
||||
clientObject->startGeneration(this, generation);
|
||||
});
|
||||
}
|
||||
}
|
||||
persist();
|
||||
}
|
||||
@@ -209,6 +232,11 @@ void ChatSession::clear() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isLoaded()) {
|
||||
// The load lands shortly; drop everything once it does.
|
||||
m_clearPending = true;
|
||||
return;
|
||||
}
|
||||
m_model->clear();
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -43,10 +43,17 @@ class ChatSession : public QObject {
|
||||
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
|
||||
[[nodiscard]] bool pinned() const { return m_pinned; }
|
||||
[[nodiscard]] int messageCount() const { return m_messageCount; }
|
||||
// Loads the messages from the store on first access.
|
||||
// The messages model; the first access starts the (async) load
|
||||
// from the store.
|
||||
[[nodiscard]] ChatMessageModel* messagesModel();
|
||||
void ensureLoaded();
|
||||
// True once the async load from the store has finished.
|
||||
[[nodiscard]] bool isLoaded() const { return m_loaded; }
|
||||
// The model without triggering a load (ChatStore use during the
|
||||
// load itself).
|
||||
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
|
||||
void markLoaded();
|
||||
[[nodiscard]] bool takeClearPending();
|
||||
|
||||
[[nodiscard]] LlmClient* client() const;
|
||||
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
|
||||
@@ -80,6 +87,8 @@ class ChatSession : public QObject {
|
||||
void updatedAtChanged();
|
||||
void pinnedChanged();
|
||||
void messageCountChanged();
|
||||
// The messages finished loading from the store.
|
||||
void loaded();
|
||||
|
||||
private:
|
||||
void onModelRowsChanged();
|
||||
@@ -96,7 +105,9 @@ class ChatSession : public QObject {
|
||||
bool m_pinned = false;
|
||||
int m_messageCount = 0;
|
||||
ChatMessageModel* m_model = nullptr;
|
||||
bool m_loadRequested = false;
|
||||
bool m_loaded = false;
|
||||
bool m_clearPending = false;
|
||||
int m_lastTokenCount = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Code block color scheme sources
|
||||
|
||||
Canonical sources for the schemes in
|
||||
`Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml`.
|
||||
|
||||
Rules:
|
||||
|
||||
- Verify one scheme at a time, in order.
|
||||
- HSL/RGB values from the specs are converted with a Python script,
|
||||
never by hand:
|
||||
`python3 -c "import colorsys; r,g,b = colorsys.hls_to_rgb(h/360, l/100, s/100); print('#%02x%02x%02x' % tuple(round(c*255) for c in (r,g,b)))"`
|
||||
(HLS in Python is h, l, s — note the order.)
|
||||
- Check the box only after the QML is fixed and verified against the spec.
|
||||
|
||||
- [x] **One Dark** — https://github.com/atom/one-dark-syntax/blob/master/styles/syntax-variables.less
|
||||
- [x] **Nord** — https://github.com/nordtheme/nord/blob/develop/src/nord.css
|
||||
- [x] **Dracula** — https://github.com/dracula/draculatheme.com/blob/main/content/spec.mdx
|
||||
- [x] **Solarized** — https://github.com/altercation/solarized/blob/master/colors/solarized.vim (actual path: vim-colors-solarized/colors/solarized.vim)
|
||||
- [x] **Gruvbox** — https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim
|
||||
- [x] **Monokai** — https://github.com/JetBrains/colorSchemeTool/blob/master/intellijThemes/Monokai.icls
|
||||
- [x] **GitHub Dark** — https://github.com/primer/github-vscode-theme
|
||||
- [x] **Catppuccin (Mocha)** — https://github.com/catppuccin/nvim
|
||||
- [x] **Tokyo Night** — https://github.com/folke/tokyonight.nvim/tree/main/lua/tokyonight/groups
|
||||
- [x] **Ayu (Dark)** — https://github.com/ayu-theme/vscode-ayu
|
||||
- [x] **Palenight** — https://github.com/drewtempelmeyer/palenight.vim
|
||||
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import urlopen
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEVICON_VERSION = "v2.17.0"
|
||||
|
||||
ICONS = {
|
||||
"c": "c",
|
||||
"cpp": "cplusplus",
|
||||
"csharp": "csharp",
|
||||
"python": "python",
|
||||
"rust": "rust",
|
||||
"javascript": "javascript",
|
||||
"typescript": "typescript",
|
||||
"bash": "bash",
|
||||
"zsh": "zsh",
|
||||
"powershell": "powershell",
|
||||
"cmake": "cmake",
|
||||
"lua": "lua",
|
||||
"java": "java",
|
||||
"kotlin": "kotlin",
|
||||
"swift": "swift",
|
||||
"go": "go",
|
||||
"dart": "dart",
|
||||
"php": "php",
|
||||
"ruby": "ruby",
|
||||
"scala": "scala",
|
||||
"haskell": "haskell",
|
||||
"elixir": "elixir",
|
||||
"erlang": "erlang",
|
||||
"clojure": "clojure",
|
||||
"r": "r",
|
||||
"perl": "perl",
|
||||
"zig": "zig",
|
||||
"nim": "nim",
|
||||
"ocaml": "ocaml",
|
||||
"fsharp": "fsharp",
|
||||
"visualbasic": "visualbasic",
|
||||
"fortran": "fortran",
|
||||
"crystal": "crystal",
|
||||
"gleam": "gleam",
|
||||
"julia": "julia",
|
||||
"objectivec": "objectivec",
|
||||
"vala": "vala",
|
||||
"groovy": "groovy",
|
||||
"racket": "racket",
|
||||
"haxe": "haxe",
|
||||
"purescript": "purescript",
|
||||
"delphi": "delphi",
|
||||
"coffeescript": "coffeescript",
|
||||
"elm": "elm",
|
||||
"awk": "awk",
|
||||
"matlab": "matlab",
|
||||
"solidity": "solidity",
|
||||
"wasm": "wasm",
|
||||
"vim": "vim",
|
||||
"sql": "sqlite",
|
||||
"json": "json",
|
||||
"yaml": "yaml",
|
||||
"xml": "xml",
|
||||
"html": "html5",
|
||||
"css": "css3",
|
||||
"sass": "sass",
|
||||
"markdown": "markdown",
|
||||
"docker": "docker",
|
||||
"latex": "latex",
|
||||
"graphql": "graphql",
|
||||
}
|
||||
|
||||
# Markdown / syntax-highlighter aliases -> Devicon icon name.
|
||||
ALIASES = {
|
||||
"cpp": "cpp",
|
||||
"cc": "cpp",
|
||||
"cxx": "cpp",
|
||||
|
||||
"cs": "csharp",
|
||||
|
||||
"js": "javascript",
|
||||
"jsx": "javascript",
|
||||
|
||||
"ts": "typescript",
|
||||
"tsx": "typescript",
|
||||
|
||||
"py": "python",
|
||||
|
||||
"sh": "bash",
|
||||
"shell": "bash",
|
||||
|
||||
"ps1": "powershell",
|
||||
|
||||
"vb": "visualbasic",
|
||||
|
||||
"objective-c": "objectivec",
|
||||
"obj-c": "objectivec",
|
||||
|
||||
"groovyscript": "groovy",
|
||||
|
||||
"pascal": "delphi",
|
||||
|
||||
"coffee": "coffeescript",
|
||||
|
||||
"mysql": "sql",
|
||||
"postgres": "sql",
|
||||
"postgresql": "sql",
|
||||
"sqlite": "sql",
|
||||
|
||||
"yml": "yaml",
|
||||
|
||||
"htm": "html",
|
||||
|
||||
"scss": "sass",
|
||||
|
||||
"md": "markdown",
|
||||
|
||||
"dockerfile": "docker",
|
||||
"docker-compose": "docker",
|
||||
|
||||
"tex": "latex",
|
||||
|
||||
"gql": "graphql",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ICON_VARIANTS = (
|
||||
"plain",
|
||||
"original",
|
||||
)
|
||||
|
||||
|
||||
def fetch_svg(devicon_name: str) -> str:
|
||||
for variant in ICON_VARIANTS:
|
||||
url = (
|
||||
f"https://raw.githubusercontent.com/devicons/devicon/"
|
||||
f"{DEVICON_VERSION}/icons/{devicon_name}/"
|
||||
f"{devicon_name}-{variant}.svg"
|
||||
)
|
||||
|
||||
print(f"Fetching {devicon_name} ({variant}): {url}")
|
||||
|
||||
try:
|
||||
with urlopen(url) as response:
|
||||
return response.read().decode("utf-8")
|
||||
except HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
|
||||
raise RuntimeError(
|
||||
f"No usable SVG variant found for '{devicon_name}'."
|
||||
)
|
||||
|
||||
|
||||
def element_to_path(element) -> str:
|
||||
"""Convert a basic SVG shape element to equivalent path data."""
|
||||
tag = element.tag.rsplit("}", 1)[-1]
|
||||
attrib = element.attrib
|
||||
|
||||
if tag == "path":
|
||||
return attrib.get("d", "")
|
||||
|
||||
if tag == "circle":
|
||||
cx = float(attrib["cx"])
|
||||
cy = float(attrib["cy"])
|
||||
r = float(attrib["r"])
|
||||
return (
|
||||
f"M {cx - r},{cy} "
|
||||
f"a {r},{r} 0 1,0 {2 * r},0 "
|
||||
f"a {r},{r} 0 1,0 {-2 * r},0 Z"
|
||||
)
|
||||
|
||||
if tag == "ellipse":
|
||||
cx = float(attrib["cx"])
|
||||
cy = float(attrib["cy"])
|
||||
rx = float(attrib["rx"])
|
||||
ry = float(attrib["ry"])
|
||||
return (
|
||||
f"M {cx - rx},{cy} "
|
||||
f"a {rx},{ry} 0 1,0 {2 * rx},0 "
|
||||
f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z"
|
||||
)
|
||||
|
||||
if tag == "rect":
|
||||
x = float(attrib["x"])
|
||||
y = float(attrib["y"])
|
||||
width = float(attrib["width"])
|
||||
height = float(attrib["height"])
|
||||
return f"M {x},{y} h {width} v {height} h {-width} Z"
|
||||
|
||||
if tag in ("polygon", "polyline"):
|
||||
points = attrib["points"].split()
|
||||
commands = [
|
||||
f"{float(points[i])},{float(points[i + 1])}"
|
||||
for i in range(0, len(points) - 1, 2)
|
||||
]
|
||||
data = "M " + " L ".join(commands)
|
||||
if tag == "polygon":
|
||||
data += " Z"
|
||||
return data
|
||||
|
||||
if tag == "line":
|
||||
return (
|
||||
f"M {float(attrib['x1'])},{float(attrib['y1'])} "
|
||||
f"L {float(attrib['x2'])},{float(attrib['y2'])}"
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SVG path data normalization
|
||||
#
|
||||
# Devicon path data crams arc flags against the following coordinate
|
||||
# (e.g. "a28.78 28.78 0 00-2.65-7.58"), which Qt's PathSvg parser handles
|
||||
# unreliably. Parse every path and re-emit it with canonical spacing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz"
|
||||
_NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?")
|
||||
|
||||
|
||||
def _skip_separators(data: str, i: int) -> int:
|
||||
while i < len(data) and data[i] in " \t\r\n,":
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _read_number(data: str, i: int) -> tuple[str, int]:
|
||||
i = _skip_separators(data, i)
|
||||
match = _NUMBER.match(data, i)
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"Expected number in path data at {i}: {data[i:i + 24]!r}"
|
||||
)
|
||||
return match.group(0), match.end()
|
||||
|
||||
|
||||
def parse_path(data: str) -> list[tuple[str, list[str]]]:
|
||||
"""Parse SVG path data into (command, parameters) tuples."""
|
||||
commands: list[tuple[str, list[str]]] = []
|
||||
i, length = 0, len(data)
|
||||
|
||||
while i < length:
|
||||
i = _skip_separators(data, i)
|
||||
if i >= length:
|
||||
break
|
||||
|
||||
command = data[i]
|
||||
if command not in PATH_COMMANDS:
|
||||
raise ValueError(
|
||||
f"Unexpected character {command!r} at {i} in {data!r}"
|
||||
)
|
||||
i += 1
|
||||
|
||||
if command in "Zz":
|
||||
commands.append((command, []))
|
||||
continue
|
||||
|
||||
implicit = command
|
||||
while i < length:
|
||||
i = _skip_separators(data, i)
|
||||
if i >= length or data[i] in PATH_COMMANDS:
|
||||
break
|
||||
|
||||
if implicit in "Mm":
|
||||
x, i = _read_number(data, i)
|
||||
y, i = _read_number(data, i)
|
||||
commands.append((implicit, [x, y]))
|
||||
# Subsequent coordinate pairs after a moveto are linetos.
|
||||
implicit = "L" if implicit == "M" else "l"
|
||||
elif implicit in "LlTt":
|
||||
x, i = _read_number(data, i)
|
||||
y, i = _read_number(data, i)
|
||||
commands.append((implicit, [x, y]))
|
||||
elif implicit in "Hh":
|
||||
x, i = _read_number(data, i)
|
||||
commands.append((implicit, [x]))
|
||||
elif implicit in "Vv":
|
||||
y, i = _read_number(data, i)
|
||||
commands.append((implicit, [y]))
|
||||
elif implicit in "Cc":
|
||||
# Cubic bezier: x1 y1 x2 y2 x y
|
||||
p = []
|
||||
for _ in range(6):
|
||||
value, i = _read_number(data, i)
|
||||
p.append(value)
|
||||
commands.append((implicit, p))
|
||||
elif implicit in "SsQq":
|
||||
# Smooth cubic / quadratic: x1 y1 x y
|
||||
p = []
|
||||
for _ in range(4):
|
||||
value, i = _read_number(data, i)
|
||||
p.append(value)
|
||||
commands.append((implicit, p))
|
||||
elif implicit in "Aa":
|
||||
rx, i = _read_number(data, i)
|
||||
ry, i = _read_number(data, i)
|
||||
rotation, i = _read_number(data, i)
|
||||
# Arc flags are single digits, possibly crammed against
|
||||
# the following coordinate, so read them positionally.
|
||||
i = _skip_separators(data, i)
|
||||
large_arc = data[i]
|
||||
i += 1
|
||||
i = _skip_separators(data, i)
|
||||
sweep = data[i]
|
||||
i += 1
|
||||
if large_arc not in "01" or sweep not in "01":
|
||||
raise ValueError(
|
||||
f"Invalid arc flags near {i - 2} in {data!r}"
|
||||
)
|
||||
x, i = _read_number(data, i)
|
||||
y, i = _read_number(data, i)
|
||||
commands.append(
|
||||
(implicit, [rx, ry, rotation, large_arc, sweep, x, y])
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unhandled path command {implicit!r}")
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def normalize_path(data: str) -> str:
|
||||
"""Re-emit path data with canonical spacing (Qt PathSvg friendly)."""
|
||||
return " ".join(
|
||||
command if not params else command + " " + " ".join(params)
|
||||
for command, params in parse_path(data)
|
||||
)
|
||||
|
||||
|
||||
def extract_paths(svg: str) -> str:
|
||||
root = ET.fromstring(svg)
|
||||
|
||||
paths = []
|
||||
|
||||
for element in root.iter():
|
||||
path = element_to_path(element)
|
||||
if not path:
|
||||
continue
|
||||
|
||||
normalized = normalize_path(path)
|
||||
|
||||
# Guard against a tokenizer that changes the path it normalizes.
|
||||
if parse_path(normalized) != parse_path(path):
|
||||
raise RuntimeError(
|
||||
"Path normalization round-trip mismatch:\n"
|
||||
f" in : {path[:80]}\n out: {normalized[:80]}"
|
||||
)
|
||||
|
||||
paths.append(normalized)
|
||||
|
||||
if not paths:
|
||||
raise RuntimeError("SVG contains no drawable shape elements")
|
||||
|
||||
# Multiple path elements are valid SVG path data when concatenated.
|
||||
return " ".join(paths)
|
||||
|
||||
|
||||
def qml_string(value: str) -> str:
|
||||
# JSON string escaping is valid for the string syntax we need in QML.
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def qml_identifier(name: str) -> str:
|
||||
name = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||
|
||||
if name and name[0].isdigit():
|
||||
name = "_" + name
|
||||
|
||||
return name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate(output: Path) -> None:
|
||||
icons = {}
|
||||
|
||||
for name, devicon_name in ICONS.items():
|
||||
svg = fetch_svg(devicon_name)
|
||||
icons[name] = extract_paths(svg)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with output.open("w", encoding="utf-8") as file:
|
||||
file.write(
|
||||
"""pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
"""
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Icon path properties
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
for icon, path in icons.items():
|
||||
identifier = qml_identifier(icon)
|
||||
|
||||
file.write(
|
||||
f" readonly property string {identifier}: "
|
||||
f"{qml_string(path)}\n"
|
||||
)
|
||||
|
||||
file.write("\n")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Language -> icon lookup
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
file.write(
|
||||
""" function path(language) {
|
||||
const key = language.toLowerCase()
|
||||
|
||||
switch (key) {
|
||||
"""
|
||||
)
|
||||
|
||||
for icon in icons:
|
||||
identifier = qml_identifier(icon)
|
||||
|
||||
file.write(
|
||||
f' case "{icon}":\n'
|
||||
f" return {identifier}\n"
|
||||
)
|
||||
|
||||
for alias, icon in ALIASES.items():
|
||||
if icon not in icons:
|
||||
raise RuntimeError(
|
||||
f"Alias '{alias}' points to unavailable icon '{icon}'"
|
||||
)
|
||||
|
||||
identifier = qml_identifier(icon)
|
||||
|
||||
file.write(
|
||||
f' case "{alias}":\n'
|
||||
f" return {identifier}\n"
|
||||
)
|
||||
|
||||
file.write(
|
||||
""" default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function name(language) {
|
||||
switch (language.toLowerCase()) {
|
||||
"""
|
||||
)
|
||||
|
||||
# Human-readable names can initially be handled here.
|
||||
# Add/remove these as your codeblock UI evolves.
|
||||
names = {
|
||||
"c": "C",
|
||||
"cpp": "C++",
|
||||
"csharp": "C#",
|
||||
"python": "Python",
|
||||
"rust": "Rust",
|
||||
"javascript": "JavaScript",
|
||||
"typescript": "TypeScript",
|
||||
"qml": "QML",
|
||||
"bash": "Bash",
|
||||
"zsh": "Zsh",
|
||||
"powershell": "PowerShell",
|
||||
"cmake": "CMake",
|
||||
"lua": "Lua",
|
||||
"java": "Java",
|
||||
"kotlin": "Kotlin",
|
||||
"swift": "Swift",
|
||||
"go": "Go",
|
||||
"dart": "Dart",
|
||||
"php": "PHP",
|
||||
"ruby": "Ruby",
|
||||
"scala": "Scala",
|
||||
"haskell": "Haskell",
|
||||
"elixir": "Elixir",
|
||||
"erlang": "Erlang",
|
||||
"clojure": "Clojure",
|
||||
"r": "R",
|
||||
"perl": "Perl",
|
||||
"zig": "Zig",
|
||||
"nim": "Nim",
|
||||
"ocaml": "OCaml",
|
||||
"fsharp": "F#",
|
||||
"visualbasic": "Visual Basic",
|
||||
"fortran": "Fortran",
|
||||
"crystal": "Crystal",
|
||||
"gleam": "Gleam",
|
||||
"julia": "Julia",
|
||||
"objectivec": "Objective-C",
|
||||
"vala": "Vala",
|
||||
"groovy": "Groovy",
|
||||
"racket": "Racket",
|
||||
"haxe": "Haxe",
|
||||
"purescript": "PureScript",
|
||||
"delphi": "Delphi",
|
||||
"coffeescript": "CoffeeScript",
|
||||
"elm": "Elm",
|
||||
"awk": "AWK",
|
||||
"matlab": "MATLAB",
|
||||
"solidity": "Solidity",
|
||||
"wasm": "Wasm",
|
||||
"vim": "Vim",
|
||||
"sql": "SQL",
|
||||
"json": "JSON",
|
||||
"yaml": "YAML",
|
||||
"xml": "XML",
|
||||
"html": "HTML",
|
||||
"css": "CSS",
|
||||
"sass": "Sass",
|
||||
"markdown": "Markdown",
|
||||
"docker": "Docker",
|
||||
"latex": "LaTeX",
|
||||
"graphql": "GraphQL",
|
||||
}
|
||||
|
||||
for icon in icons:
|
||||
if icon not in names:
|
||||
continue
|
||||
|
||||
name = names[icon]
|
||||
|
||||
file.write(
|
||||
f' case "{icon}":\n'
|
||||
f' return "{name}"\n'
|
||||
)
|
||||
|
||||
for alias, icon in ALIASES.items():
|
||||
if icon not in names:
|
||||
continue
|
||||
|
||||
file.write(
|
||||
f' case "{alias}":\n'
|
||||
f' return "{names[icon]}"\n'
|
||||
)
|
||||
|
||||
file.write(
|
||||
""" default:
|
||||
return language
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
print(f"Generated {output}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
generate(Path(sys.argv[1]))
|
||||
Reference in New Issue
Block a user