async parsing + tex caching

This commit is contained in:
2026-08-26 02:06:10 +02:00
parent e18875a752
commit 3946541f87
32 changed files with 2730 additions and 489 deletions
+56 -74
View File
@@ -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;
}
+56
View File
@@ -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
}
}
}
+86
View File
@@ -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
}
}
}
+56
View File
@@ -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
}
}
}
+9 -13
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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, "&nbsp;"))
.replace(/\n/g, "<br>");
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;")).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
+12 -7
View File
@@ -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 {
+29 -46
View File
@@ -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)
}
}
}
+15 -3
View File
@@ -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
+246 -8
View File
@@ -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
+232 -83
View File
@@ -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() {
+8
View File
@@ -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;
};
+131 -129
View File
@@ -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));
+36 -16
View File
@@ -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
View File
@@ -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
+12 -2
View File
@@ -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
+8 -1
View File
@@ -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();
+36 -6
View File
@@ -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
+4 -1
View File
@@ -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
+32 -4
View File
@@ -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();
}
+12 -1
View File
@@ -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;
};
+25
View File
@@ -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
+572
View File
@@ -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]))