diff --git a/.qmlformat.ini b/.qmlformat.ini index 405a8d1..828d0c9 100644 --- a/.qmlformat.ini +++ b/.qmlformat.ini @@ -3,8 +3,9 @@ FunctionsSpacing=true IndentWidth=4 MaxColumnWidth=-1 NewlineType=native -NormalizeOrder=true +GroupAttributesTogether=false ObjectsSpacing=true SemicolonRule=always +SingleLineEmptyObjects=true SortImports=false UseTabs=true diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..556d439 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -28,6 +28,8 @@ Flickable { interval: 10 running: root.doneFakeFlick - onTriggered: root.doneFakeFlick = false + onTriggered: { + root.doneFakeFlick = false; + } } } diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 0b8d618..84c6aba 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -10,24 +10,27 @@ ListView { maximumFlickVelocity: 3000 rebound: Transition { - onRunningChanged: { - if (!running && !root.doneFakeFlick) { - root.doneFakeFlick = true; - root.flick(1, 1); - root.flick(-1, -1); - Qt.callLater(() => root.cancelFlick()); - } - } + // onRunningChanged: { + // if (!running && !root.doneFakeFlick) { + // root.doneFakeFlick = true; + // root.flick(1, 1); + // root.flick(-1, -1); + // Qt.callLater(() => root.cancelFlick()); + // } + // } Anim { properties: "x,y" + type: Anim.DefaultEffects } } - Timer { - interval: 10 - running: root.doneFakeFlick - - onTriggered: root.doneFakeFlick = false - } + // Timer { + // interval: 10 + // running: root.doneFakeFlick + // + // onTriggered: { + // root.doneFakeFlick = false; + // } + // } } diff --git a/Components/CustomMouseArea.qml b/Components/CustomMouseArea.qml index 6d52d16..0737476 100644 --- a/Components/CustomMouseArea.qml +++ b/Components/CustomMouseArea.qml @@ -4,6 +4,7 @@ MouseArea { property int scrollAccumulatedY: 0 function onWheel(event: WheelEvent): void { + event.accepted = false; } onWheel: event => { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 0159df0..6df6539 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,32 +1,37 @@ +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.originX) : (flickable.contentY - flickable.originY) + 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 + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 - contentItem: Item { - } + contentItem: Item {} Behavior on position { enabled: !fullMouse.pressed - Anim { - } + Anim {} } onHoveredChanged: { @@ -47,54 +52,27 @@ 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 + active: root.size < 1 + 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,25 +89,40 @@ 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 + root.flickable.originX; + else + root.flickable.contentY = newPos + root.flickable.originY; } - function visualThumbTop() { + function visualThumbStart() { const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY; return visualPos * root.travelScale; } + function onWheel(event: WheelEvent): void { + if (root.horizontal) { + event.accepted = false; + return; + } + + var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; + var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); + root.position = newPos; + } + anchors.fill: parent cursorShape: undefined hoverEnabled: true @@ -140,24 +133,15 @@ 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 newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); - root.position = newPos; - } } } diff --git a/Components/HorizontalScrollBarTrack.qml b/Components/HorizontalScrollBarTrack.qml new file mode 100644 index 0000000..92185c4 --- /dev/null +++ b/Components/HorizontalScrollBarTrack.qml @@ -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 + } + } +} diff --git a/Components/TextAreaBase.qml b/Components/TextAreaBase.qml new file mode 100644 index 0000000..a96b9c0 --- /dev/null +++ b/Components/TextAreaBase.qml @@ -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 + } + } +} diff --git a/Components/TextEditBase.qml b/Components/TextEditBase.qml new file mode 100644 index 0000000..8eb49a3 --- /dev/null +++ b/Components/TextEditBase.qml @@ -0,0 +1,100 @@ +import QtQuick +import QtQuick.Templates +import ZShell.Config +import qs.Services + +TextEdit { + id: root + + property bool animateCursor: true + property alias cursor: cursor + + color: Colors.palette.m3onSurface + cursorVisible: !readOnly + font.pointSize: Tokens.font.size.small + font.family: Config.appearance.font.family.sans + renderType: TextField.NativeRendering + selectedTextColor: color + selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) + verticalAlignment: TextInput.AlignVCenter + + Behavior on color { + CAnim { + } + } + cursorDelegate: Item { + } + Behavior on selectionColor { + CAnim { + } + } + + 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 opacity { + Anim { + type: Anim.StandardSmall + } + } + Behavior on x { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + Behavior on y { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + + 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 + } + } +} diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index c563a0b..2430660 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -8,6 +8,7 @@ CustomListView { property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real fadeAmount: 0.1 + property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 @@ -28,11 +29,11 @@ CustomListView { } function marginEnd(): real { - return horizontal ? rightMargin : bottomMargin; + return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold; } function marginStart(): real { - return horizontal ? leftMargin : topMargin; + return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold; } function overshootStart(): real { diff --git a/Components/VerticalScrollBarTrack.qml b/Components/VerticalScrollBarTrack.qml new file mode 100644 index 0000000..62e7b5a --- /dev/null +++ b/Components/VerticalScrollBarTrack.qml @@ -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 + } + } +} diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..8dd15c3 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -140,7 +140,6 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts sidebar: sidebar visibilities: root.visibilities } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index bac1fbc..b2ecfb6 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -55,7 +55,7 @@ CustomWindow { property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true @@ -63,12 +63,10 @@ CustomWindow { name: "Bar" Behavior on fsTransitionProg { - Anim { - } + Anim {} } Behavior on surfaceColor { - CAnim { - } + CAnim {} } contentItem.Keys.onEscapePressed: { @@ -293,8 +291,7 @@ CustomWindow { y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) Behavior on extraExtent { - Anim { - } + Anim {} } } diff --git a/Greeter/Services/Colors.qml b/Greeter/Services/Colors.qml index ee9d678..ac4dce6 100644 --- a/Greeter/Services/Colors.qml +++ b/Greeter/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,8 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; - Config.save(); + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/Helpers/Hyprsunset.qml b/Helpers/Hyprsunset.qml index ca82be2..29857f3 100644 --- a/Helpers/Hyprsunset.qml +++ b/Helpers/Hyprsunset.qml @@ -9,13 +9,13 @@ Singleton { id: root readonly property bool enabled: service.enabled - readonly property int end: Config.general.color.scheduleHyprsunsetEnd + readonly property int end: Config.display.nightlight.scheduleEnd property bool manualToggle: false - readonly property int start: Config.general.color.scheduleHyprsunsetStart - readonly property int temp: Config.general.color.hyprsunsetTemp + readonly property int start: Config.display.nightlight.scheduleStart + readonly property int temp: Config.display.nightlight.temp function checkStartup(): void { - if (!Config.general.color.scheduleHyprsunset) + if (!Config.display.nightlight.schedule) return; service.apply(); @@ -26,12 +26,14 @@ Singleton { service.toggle(); } - HyprsunsetManager { + NightlightManager { id: service - activeAuto: Config.general.color.scheduleHyprsunset + activeAuto: Config.display.nightlight.schedule endTime: root.end startTime: root.start temp: root.temp + fadeDuration: Config.display.nightlight.fadeDuration / 1000 + useNativeNightlight: Config.display.nightlight.useNative } } diff --git a/Helpers/ModeScheduler.qml b/Helpers/ModeScheduler.qml index a45e15a..14b6aa8 100644 --- a/Helpers/ModeScheduler.qml +++ b/Helpers/ModeScheduler.qml @@ -11,14 +11,14 @@ import qs.Services Singleton { id: root - readonly property int darkEnd: Config.general.color.scheduleDarkEnd - readonly property int darkStart: Config.general.color.scheduleDarkStart - readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration + readonly property int darkEnd: Config.colors.scheduleDarkEnd + readonly property int darkStart: Config.colors.scheduleDarkStart + readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen function applyDarkMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); - Config.general.color.mode = "dark"; + Config.colors.mode = "dark"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]); @@ -30,13 +30,13 @@ Singleton { function applyLightMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); - Config.general.color.mode = "light"; + Config.colors.mode = "light"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]); Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]); - if (Config.general.color.neovimColors) + if (Config.colors.neovimColors) Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]); } diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..b87f53e 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -126,33 +126,29 @@ Item { focus: true opacity: 0 - Behavior on opacity { - Anim { - duration: 300 - } - } Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + ExAnim {} + } + Behavior on opacity { + Anim { + duration: 300 } } @@ -352,8 +348,7 @@ Item { y: selectionRect.y - root.realBorderWidth Behavior on border.color { - Anim { - } + Anim {} } } @@ -402,6 +397,5 @@ Item { onUndoRequested: annotations.undo() } - component ExAnim: Anim { - } + component ExAnim: Anim {} } diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index ba4b64c..d02b01a 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -38,7 +38,7 @@ Searcher { previewPath = path; showPreview = true; - if (Config.general.color.schemeGeneration) + if (Config.colors.schemeGen) previewColorsProc.running = true; } @@ -62,8 +62,8 @@ Searcher { WallpaperPath.currentWallpaperPath = path; Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); + if (Config.colors.schemeGen) + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); } function stopPreview(): void { diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e7de27d..dbe05b9 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -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 {} } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml new file mode 100644 index 0000000..85e2a6d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -0,0 +1,323 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import ZShell.Llm +import qs.Modules.Notifications.Sidebar.Chat.Content +import qs.Components +import qs.Services + +Item { + id: root + + property ChatSession chatData + property bool following: true + // qmlformat off + readonly property var keys: [ + Qt.Key_A, + Qt.Key_B, + Qt.Key_C, + Qt.Key_D, + Qt.Key_E, + Qt.Key_F, + Qt.Key_G, + Qt.Key_H, + Qt.Key_I, + Qt.Key_J, + Qt.Key_K, + Qt.Key_L, + Qt.Key_M, + Qt.Key_N, + Qt.Key_O, + Qt.Key_P, + Qt.Key_Q, + Qt.Key_R, + Qt.Key_S, + Qt.Key_T, + Qt.Key_U, + Qt.Key_V, + Qt.Key_W, + Qt.Key_X, + Qt.Key_Y, + Qt.Key_Z, + + Qt.Key_Agrave, + Qt.Key_Aacute, + Qt.Key_Acircumflex, + Qt.Key_Atilde, + Qt.Key_Adiaeresis, + Qt.Key_Aring, + Qt.Key_AE, + + Qt.Key_Ccedilla, + + Qt.Key_Egrave, + Qt.Key_Eacute, + Qt.Key_Ecircumflex, + Qt.Key_Ediaeresis, + + Qt.Key_Igrave, + Qt.Key_Iacute, + Qt.Key_Icircumflex, + Qt.Key_Idiaeresis, + + Qt.Key_ETH, + + Qt.Key_Ntilde, + + Qt.Key_Ograve, + Qt.Key_Oacute, + Qt.Key_Ocircumflex, + Qt.Key_Otilde, + Qt.Key_Odiaeresis, + Qt.Key_Ooblique, + + Qt.Key_Ugrave, + Qt.Key_Uacute, + Qt.Key_Ucircumflex, + Qt.Key_Udiaeresis, + + Qt.Key_Yacute, + Qt.Key_ydiaeresis, + + Qt.Key_THORN, + Qt.Key_ssharp + ] + // qmlformat on + + signal requestClose + + function send(text: string): void { + if (text.trim() === "") + return; + following = true; + chatData.sendMessage(text); + input.text = ""; + } + + function focusInput(): void { + Qt.callLater(() => input.forceActiveFocus()); + } + + onChatDataChanged: { + if (chatData) + focusInput(); + } + Keys.onPressed: e => { + if (root.keys.includes(e.key)) { + input.insert(input.length, e.text); + focusInput(); + } + } + + RowLayout { + id: header + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.large + + IconButton { + icon: "arrow_back" + inactiveColor: Colors.tPalette.m3surfaceContainerHigh + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + type: IconButton.Tonal + + enabled: !ChatState.isWindow + visible: enabled + + onClicked: root.requestClose() + } + + CustomText { + Layout.fillWidth: ChatState.isWindow ? false : true + Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter + elide: Text.ElideRight + font.pointSize: Tokens.font.size.larger + text: qsTr(root.chatData.title) + } + } + + Loader { + id: listLoader + + anchors.bottom: input.top + anchors.bottomMargin: Tokens.spacing.medium + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.medium + active: root.chatData && root.chatData.loaded + + sourceComponent: VerticalFadeListView { + id: list + + property bool userScrolledUp: false + property real lastCHeight: 0.0 + + function scrollToBottom(): void { + scrollAnim.start(); + } + + cacheBuffer: Math.max(height * 20, 0) + clip: true + fadeAmount: 0.05 + fadeThreshold: Tokens.padding.medium + model: root.chatData.messagesModel + spacing: 0 + rotation: 180 + add: Transition { + Anim { + from: -100 + property: "y" + } + } + + CustomScrollBar.vertical: CustomScrollBar { + id: scrollBar + + flickable: list + parent: list.parent + anchors.top: list.top + anchors.right: list.right + anchors.bottom: list.bottom + + transform: Rotation { + origin.y: list.height / 2 + origin.x: scrollBar.width / 2 + angle: 180 + + axis { + y: 0 + x: 1 + z: 0 + } + } + } + delegate: MessageDelegate { + rotation: 180 + } + + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } + } + + Component.onCompleted: { + positionViewAtBeginning(); + } + onAtYBeginningChanged: { + if (atYBeginning) + userScrolledUp = false; + } + onContentHeightChanged: { + if (userScrolledUp && Chat.busy) { + const delta = contentHeight - lastCHeight; + contentY += delta; + } + + lastCHeight = contentHeight; + } + onMovingChanged: { + if (moving) + userScrolledUp = !atYBeginning; + } + + Anim { + id: scrollAnim + + property: "contentY" + target: list + type: Anim.DefaultEffects + to: list.originY + } + + WheelInverter { + target: list + } + } + + // Trigger the load even before we're ready to show the list. + Component.onCompleted: if (root.chatData) + root.chatData.messagesModel + } + + EmptyBackground { + id: emptyState + + anchors.fill: parent + spacing: Tokens.spacing.small + visible: root.chatData.messageCount < 1 + } + + Item { + anchors.bottom: input.top + anchors.bottomMargin: Tokens.spacing.extraLarge + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: scrollToBottom.implicitHeight + implicitWidth: scrollToBottom.implicitWidth + scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 + visible: scale > 0 + + Behavior on scale { + Anim {} + } + + Elevation { + anchors.fill: parent + level: 2 + radius: scrollToBottom.radius + } + + IconButton { + id: scrollToBottom + + anchors.centerIn: parent + font.pointSize: Tokens.font.size.large + icon: "arrow_downward" + isRound: true + padding: Tokens.padding.extraSmall + type: IconButton.Tonal + + onClicked: { + listLoader.item.scrollToBottom(); + } + } + } + + ChatInput { + id: input + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + font.pointSize: Tokens.font.size.normal + implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + focus: true + placeholderText: qsTr("Send a message") + sendIcon.font.pointSize: Tokens.font.size.large + sendIcon.icon: "arrow_upward" + sendIcon.padding: Tokens.padding.extraSmall + + Component.onCompleted: root.focusInput() + Keys.onPressed: e => { + if (e.key == Qt.Key_Return) { + if (!(e.modifiers & Qt.ShiftModifier)) { + root.send(text); + e.accepted = true; + } + } + } + onSendPressed: root.send(text) + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml new file mode 100644 index 0000000..1bcb78f --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -0,0 +1,371 @@ +import QtQuick +import ZShell.Components +import ZShell.Config +import ZShell.Llm +import qs.Effects +import qs.Components +import qs.Services + +CustomClippingRect { + id: root + + property bool expanded: false + property bool highlighted: false + required property ChatSession modelData + + signal clicked(content: ChatSession) + signal remove(content: ChatSession) + + color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2) + implicitHeight: { + let h = 0; + + h += infoContainer.implicitHeight; + + if (expanded) + h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin; + else + h += preview.implicitHeight + preview.topMargin; + + h += Tokens.padding.small; + + const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2; + if (h < icon) + return icon; + return h; + } + radius: Tokens.rounding.large + clip: true + + CustomRect { + id: borderHighlight + + anchors.fill: parent + border.width: root.highlighted ? 2 : 0 + border.color: Colors.palette.m3secondary + radius: root.radius - border.width + 1 + + Behavior on border.width { + Anim {} + } + } + + Behavior on implicitHeight { + enabled: titleText.readOnly + + Anim {} + } + + MaterialIcon { + id: chatIcon + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.top: parent.top + anchors.topMargin: Tokens.padding.large + font.pointSize: Tokens.font.size.extraLarge + text: root.modelData.icon || "check" + color: Colors.palette.m3onSurface + } + + Item { + id: infoContainer + + anchors.left: chatIcon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.top: parent.top + anchors.right: expandBtn.left + anchors.rightMargin: Tokens.spacing.medium + implicitHeight: { + let h = 0; + h += title.implicitHeight + title.anchors.topMargin; + h += timestamp.implicitHeight + timestamp.anchors.topMargin; + if (root.expanded) + h += created.implicitHeight + created.anchors.topMargin; + return h; + } + + CustomRect { + id: title + + anchors.left: parent.left + anchors.topMargin: Tokens.padding.extraSmall + anchors.top: parent.top + color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1) + implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2 + radius: Tokens.rounding.full + implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2 + opacity: titleText.readOnly ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + Item { + id: titleWrapper + + anchors.left: parent.left + anchors.top: parent.top + anchors.right: parent.right + implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: fadeMask + } + + TextFieldBase { + id: titleText + + anchors.left: parent.left + anchors.leftMargin: readOnly ? 0 : Tokens.padding.large + anchors.top: parent.top + anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 + font.pointSize: Tokens.font.size.normal + text: root.modelData.title || qsTr("New chat") + readOnly: true + color: Colors.palette.m3onSurface + + Behavior on anchors.leftMargin { + Anim {} + } + Behavior on anchors.topMargin { + Anim {} + } + + onEditingFinished: root.modelData.title = text + onReadOnlyChanged: { + if (!readOnly) { + this.forceActiveFocus(); + cursorPosition = text.length; + } else { + root.forceActiveFocus(); + } + } + } + } + + CustomRect { + id: fadeMask + + // anchors.top: titleText.top + // anchors.bottom: titleText.bottom + // anchors.left: titleText.left + // anchors.right: parent.right + anchors.fill: titleWrapper + + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0) + position: 0.85 + } + + GradientStop { + color: Qt.rgba(1, 1, 1, 0) + position: 1.0 + } + } + } + + CustomText { + id: created + + anchors.top: title.bottom + anchors.topMargin: Tokens.spacing.extraSmall + anchors.left: parent.left + anchors.right: parent.right + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")) + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: updated + + anchors.top: created.bottom + anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Updated on: ") + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: timestamp + + anchors.left: parent.left + anchors.top: title.bottom + anchors.topMargin: Tokens.spacing.extraSmall + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timestamp + anchors.left: updated.right + anchors.top: undefined + anchors.verticalCenter: updated.verticalCenter + } + } + transitions: Transition { + AnchorAnim {} + } + } + } + + CustomText { + id: preview + + readonly property int topMargin: Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + elide: Text.ElideRight + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + maximumLineCount: 1 + opacity: !root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: body + + readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + animate: true + color: Colors.palette.m3onSurfaceVariant + maximumLineCount: 5 + opacity: root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet") + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + StateLayer { + id: sLayer + + property int startY + + onClicked: { + root.clicked(root.modelData); + } + } + + ButtonRow { + id: actionRow + + anchors.top: body.bottom + anchors.left: chatIcon.right + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.leftMargin: Tokens.spacing.medium + anchors.rightMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + IconButton { + icon: "close" + fillWidth: true + shapeMorph: true + isRound: true + font.pointSize: Tokens.font.size.large + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + + onClicked: root.remove(root.modelData) + } + + IconButton { + icon: titleText.readOnly ? "edit" : "check" + label.animate: true + isToggle: true + fillWidth: true + shapeMorph: true + font.pointSize: Tokens.font.size.large + isRound: true + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + activeColor: Colors.palette.m3secondary + activeOnColor: Colors.palette.m3onSecondary + checked: !titleText.readOnly + + onClicked: titleText.readOnly = !titleText.readOnly + } + } + + CustomRect { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.small + implicitHeight: expandIcon.implicitHeight + implicitWidth: expandIcon.implicitHeight + radius: Tokens.rounding.full + color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + + StateLayer { + onClicked: root.expanded = !root.expanded + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.verticalCenterOffset: root.expanded ? -1 : 1 + rotation: root.expanded ? 180 : 0 + color: Colors.palette.m3onSurface + text: "expand_more" + + Behavior on anchors.verticalCenterOffset { + Anim {} + } + Behavior on rotation { + Anim {} + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml new file mode 100644 index 0000000..058ac31 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -0,0 +1,87 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextAreaBase { + id: root + + readonly property alias bg: bg + readonly property alias sendIcon: sendIcon + + signal sendPressed + + bottomPadding: Tokens.padding.large + leftPadding: Tokens.padding.extraLarge + rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small + topPadding: Tokens.padding.large + wrapMode: TextAreaBase.Wrap + + background: CustomRect { + id: bg + + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.extraLarge + + StateLayer { + id: stateLayer + + cursorShape: Qt.IBeamCursor + enabled: !root.activeFocus + manualPressOverride: tapHandler.pressed + + onClicked: root.forceActiveFocus() + } + } + + onPressed: { + if (stateLayer.enabled) + stateLayer.press(stateLayer.mouseX, stateLayer.mouseY); + } + + CustomText { + id: placeholder + + anchors.left: parent.left + anchors.leftMargin: root.leftPadding + anchors.verticalCenter: parent.verticalCenter + color: root.placeholderTextColor + font: root.font + opacity: root.text ? 0 : 1 + text: root.placeholderText + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + IconButton { + id: sendIcon + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.small + anchors.verticalCenter: parent.verticalCenter + enabled: root.text + icon: "upward_arrow" + opacity: root.text ? 1 : 0 + radius: Tokens.rounding.full + radiusMorph: false + stateLayer.hoverEnabled: enabled + type: IconButton.Text + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.sendPressed() + } + + TapHandler { + id: tapHandler + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml new file mode 100644 index 0000000..541eb14 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -0,0 +1,414 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Services +import qs.Components + +Item { + id: root + + property alias list: list + property alias model: list.model + property bool highlight: false + + signal deleteChatRequest(content: ChatSession) + signal loadChatRequest(content: ChatSession, index: int) + signal newChatRequest + + CustomText { + id: header + + anchors.left: parent.left + anchors.margins: Tokens.padding.extraSmall + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3outline + font.family: "CaskaydiaCove NF" + font.pointSize: 13 + font.weight: 500 + text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "") + } + + CustomListView { + id: list + + anchors.bottom: modelsContainer.top + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.medium + clip: true + spacing: Tokens.spacing.medium + + add: Transition { + Anim { + from: list.width + property: "x" + } + } + remove: Transition { + Anim { + to: list.width + property: "x" + } + + Anim { + to: 0 + property: "opacity" + } + } + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } + } + + delegate: ChatDelegate { + id: chat + + required property int index + + implicitWidth: ListView.view.width + highlighted: root.highlight && ChatState.chatSession === modelData + + onHighlightedChanged: if (highlighted) + list.currentIndex = index + onClicked: content => root.loadChatRequest(content, index) + onRemove: content => root.deleteChatRequest(content) + } + + TapHandler { + onTapped: { + root.focus = true; + ChatState.fabExpanded = false; + modelsContainer.expanded = false; + } + } + } + + CustomClippingRect { + id: modelsContainer + + property bool expanded: false + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: fabRoot.left + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.spacing.small + + implicitHeight: expanded ? 400 : fabRoot.implicitHeight + radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 + + function prettyModelName(): string { + const pathList = Chat.model.split("\/"); + const name = pathList[pathList.length - 1]; + return name; + } + + Behavior on implicitHeight { + Anim {} + } + + Behavior on radius { + Anim {} + } + + Item { + id: modelName + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: fabRoot.implicitHeight + + CustomText { + text: modelsContainer.prettyModelName() + color: Colors.palette.m3onSurfaceVariant + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + } + + MaterialIcon { + color: Colors.palette.m3onSurfaceVariant + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + text: modelsContainer.expanded ? "unfold_less" : "unfold_more" + animate: true + font.pointSize: Tokens.font.size.large + } + + StateLayer { + id: modelLayer + onClicked: { + ChatState.fabExpanded = false; + modelsContainer.expanded = !modelsContainer.expanded; + } + radius: modelsContainer.radius + } + } + + VerticalFadeListView { + id: modelsList + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.top: modelName.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + model: Chat.availableModels + opacity: modelsContainer.expanded ? 1 : 0 + currentIndex: Chat.availableModels.indexOf(Chat.model) + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: modelsList + } + + delegate: CustomRect { + id: model + + required property var modelData + required property int index + readonly property bool selected: model.ListView.view.currentIndex === model.index + + implicitWidth: ListView.view.width + implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + + CustomText { + text: model.modelData + color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + } + + MaterialIcon { + Layout.alignment: Qt.AlignRight + visible: model.selected + color: Colors.palette.m3onTertiaryContainer + font.pointSize: Tokens.font.size.normal + opacity: model.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StateLayer { + id: stateLayer + + onClicked: { + Chat.selectModel(model.modelData); + } + } + } + } + } + + IconButton { + id: fabRoot + + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: Tokens.padding.small + padding: 8 + font.pointSize: Math.round(18 * 1.2) + icon: "add" + isRound: ChatState.fabExpanded + + label.transform: Rotation { + origin.y: fabRoot.label.height / 2 + origin.x: fabRoot.label.width / 2 + angle: ChatState.fabExpanded ? 135 : 0 + + Behavior on angle { + Anim {} + } + } + + onClicked: { + modelsContainer.expanded = false; + ChatState.fabExpanded = !ChatState.fabExpanded; + } + + Elevation { + anchors.fill: parent + level: fabRoot.stateLayer.containsMouse ? 4 : 3 + radius: parent.radius + z: -1 + } + } + + ColumnLayout { + id: fabMenu + + anchors.bottom: fabRoot.top + anchors.right: fabRoot.right + anchors.bottomMargin: Tokens.padding.medium + + Repeater { + id: fabRep + + model: ListModel { + ListElement { + name: "new chat" + icon: "add" + } + ListElement { + name: "pop out in window" + icon: "open_in_new" + } + } + + CustomClippingRect { + id: fabMenuItem + + required property var modelData + required property int index + + Layout.alignment: Qt.AlignRight + implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2 + + radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2 + color: Colors.palette.m3primaryContainer + visible: !(modelData.name === "pop out in window" && ChatState.isWindow) + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + + opacity: 0 + + states: State { + name: "visible" + when: ChatState.fabExpanded + + PropertyChanges { + fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2 + fabMenuItem.opacity: 1 + fabMenuItemInner.opacity: 1 + } + } + + transitions: [ + Transition { + to: "visible" + + SequentialAnimation { + PauseAnimation { + duration: (fabRep.count - 1 - fabMenuItem.index) * 50 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + type: Anim.FastSpatial + } + Anim { + property: "opacity" + duration: Tokens.anim.durations.small + easing: Tokens.anim.standard + } + } + } + }, + Transition { + from: "visible" + + SequentialAnimation { + PauseAnimation { + duration: fabMenuItem.index * Tokens.anim.durations.small / 8 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + type: Anim.FastSpatial + } + Anim { + property: "opacity" + duration: Tokens.anim.durations.small + easing: Tokens.anim.standard + } + } + } + } + ] + + RowLayout { + id: fabMenuItemInner + + anchors.centerIn: parent + spacing: Tokens.spacing.medium + opacity: 0 + + MaterialIcon { + text: fabMenuItem.modelData.icon + color: Colors.palette.m3onPrimaryContainer + font.pointSize: Tokens.font.size.large + fill: 1 + } + + CustomText { + animate: true + text: fabMenuItem.modelData.name + font.capitalization: Font.Capitalize + color: Colors.palette.m3onPrimaryContainer + Layout.preferredWidth: implicitWidth + + Behavior on Layout.preferredWidth { + Anim {} + } + } + } + + StateLayer { + id: sLayer + onClicked: { + const name = fabMenuItem.modelData.name; + + if (name === "new chat") + root.newChatRequest(); + else if (name === "pop out in window") + Detach.create(); + + ChatState.fabExpanded = false; + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml new file mode 100644 index 0000000..6f8c765 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import ZShell.Config +import ZShell.Llm +import QtQuick +import QtQuick.Controls +import Quickshell +import qs.Helpers +import qs.Modules.Notifications.Sidebar +import qs.Components +import qs.Services + +Item { + id: root + + anchors.fill: parent + anchors.margins: Tokens.padding.small + + Component.onCompleted: { + if (ChatState.inChat) { + stack.push(chatList); + stack.push(chatContent, { + "chatData": ChatState.chatSession + }); + } else + stack.push(chatList); + } + + StackView { + id: stack + anchors.fill: parent + } + + Component { + id: chatList + + ChatList { + model: ScriptModel { + values: Chat.chats.values + } + + onDeleteChatRequest: chat => { + Chat.chats.remove(chat); + } + onLoadChatRequest: (chat, index) => { + stack.push(chatContent, { + "chatData": chat + }); + ChatState.inChat = true; + ChatState.chatSession = chat; + } + onNewChatRequest: { + const data = Chat.chats.insert(); + stack.push(chatContent, { + "chatData": data + }); + ChatState.inChat = true; + ChatState.chatSession = data; + } + } + } + + Component { + id: chatContent + + ChatContent { + onRequestClose: { + stack.pop(); + ChatState.inChat = false; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml new file mode 100644 index 0000000..c24e59a --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -0,0 +1,22 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Llm +import qs.Helpers + +Singleton { + id: root + + property ChatSession chatSession + readonly property string title: chatSession ? chatSession.title : "Chat" + property int currentIdx: -1 + property bool inChat: false + property bool fabExpanded: false + property bool isWindow: false + property ShellScreen screen + property bool sidebarVisible: Visibilities.getForActive().sidebar + + onSidebarVisibleChanged: if (!sidebarVisible) + fabExpanded = false +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml new file mode 100644 index 0000000..1d4efdf --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -0,0 +1,115 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + required property TextEdit edit + required property bool hovered + required property bool isUser + required property ChatMessage message + required property LlmSegment segment + + implicitHeight: editButton.implicitHeight + implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small + + ButtonRow { + id: generationTools + + enabled: visible + visible: !root.isUser + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0 + icon: "chevron_left" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex - 1); + } + } + + IconButton { + enabled: false + icon: `${root.message.activeGenerationIndex + 1}` + label.font.family: Config.appearance.font.family.sans + label.font.pointSize: Tokens.font.size.small + type: IconButton.Text + } + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1 + icon: "chevron_right" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex + 1); + } + } + } + + ButtonRow { + id: editTools + + anchors.right: parent.right + opacity: !root.current.streaming && root.hovered ? 1 : 0 + spacing: Tokens.spacing.small + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + IconButton { + icon: "refresh" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + visible: !root.isUser + + Behavior on scale { + Anim {} + } + + onClicked: { + root.message.retry(); + } + } + + IconButton { + icon: "content_copy" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + + Behavior on scale { + Anim {} + } + + onClicked: Quickshell.clipboardText = root.current.content + } + + IconButton { + id: editButton + + icon: root.edit.readOnly ? "edit" : "check" + inactiveColor: Colors.palette.m3tertiary + inactiveOnColor: Colors.palette.m3onTertiary + visible: root.isUser + + onClicked: { + root.edit.readOnly = !root.edit.readOnly; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml new file mode 100644 index 0000000..e335cf5 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -0,0 +1,28 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextEditBase { + id: root + + color: Colors.palette.m3onSurface + readOnly: true + anchors.margins: Tokens.padding.medium + textFormat: Text.MarkdownText + font.pointSize: Tokens.font.size.smaller + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onLinkActivated: link => { + Qt.openUrlExternally(link); + } + + CustomMouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor + preventStealing: false + hoverEnabled: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml new file mode 100644 index 0000000..0a75bb3 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -0,0 +1,132 @@ +import QtQuick +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services +import qs.Modules.Notifications.Sidebar.Chat + +Item { + id: root + + property int animOff + property Item currentItem + property bool loading: false + property int lastIdx: -1 + + readonly property Component conversationComp: ChatContent {} + + function loadConversation(chat): void { + if (currentItem) { + currentItem.destroy(); + currentItem = null; + } + + if (!chat) + return; + + root.loading = true; + + const incubator = root.conversationComp.incubateObject(container, { + chatData: chat + }); + + const attach = () => { + incubator.object.anchors.fill = container; + currentItem = incubator.object; + root.loading = false; + enterAnim.start(); + }; + + if (incubator.status === Component.Ready) + attach(); + else + incubator.onStatusChanged = status => { + if (status === Component.Ready) + attach(); + }; + } + + Item { + id: container + + anchors.fill: parent + layer.enabled: opacity < 1 + objectName: "ConversationContainer" + + Component.onCompleted: { + if (ChatState.chatSession) + root.loadConversation(ChatState.chatSession); + } + } + + LoadingIndicator { + anchors.centerIn: parent + implicitSize: Tokens.font.size.extraLarge * 4 + opacity: root.loading ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + Connections { + function onChatSessionChanged(): void { + exitAnim.complete(); + enterAnim.complete(); + root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); + root.lastIdx = ChatState.currentIdx; + exitAnim.start(); + } + + target: ChatState + } + + SequentialAnimation { + id: exitAnim + + Anim { + property: "opacity" + target: container + to: 0 + type: Anim.DefaultEffects + } + + ScriptAction { + script: root.loadConversation(ChatState.chatSession) + } + } + + SequentialAnimation { + id: enterAnim + + PropertyAction { + property: "topMargin" + target: container.anchors + value: root.animOff + } + + PropertyAction { + property: "bottomMargin" + target: container.anchors + value: -root.animOff + } + + ParallelAnimation { + Anim { + from: 0 + property: "opacity" + target: container + to: 1 + type: Anim.SlowEffects + } + + Anim { + properties: "topMargin,bottomMargin" + target: container.anchors + to: 0 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml new file mode 100644 index 0000000..e264c85 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -0,0 +1,276 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Shapes +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +CustomRect { + id: root + + required property string language + required property string code + property bool copied: false + property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh + property color codeHeaderColor: Colors.palette.m3outline + property var codeSpans: [] + property int highlightToken: 0 + + function refresh() { + const token = ++root.highlightToken; + 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 s.comment; + case "string": + return s.string; + case "string.key": + return s.stringKey; + case "number": + case "constant": + return s.number; + case "keyword": + return s.keyword; + case "type": + return s.type; + case "function": + return s.functions; + case "method": + return s.method ?? s.functions; + case "macro": + return s.macro; + case "preproc": + return s.preproc ?? s.macro; + case "operator": + return s.operator ?? s.normal; + case "property": + return s.property ?? s.normal; + case "label": + return s.label; + case "attribute": + return s.attribute ?? s.label; + default: + return s.normal; + } + } + + function escapeHtml(text): string { + return text.replace(/&/g, "&").replace(//g, ">"); + } + + function highlightedHtml(code, spans): string { + let out; + if (!spans.length) { + out = escapeHtml(code); + } else { + out = ""; + let pos = 0; + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const start = Math.min(span.start, code.length); + const end = Math.min(span.start + span.length, code.length); + if (end <= pos) + continue; + if (start > pos) + out += escapeHtml(code.slice(pos, start)); + out += `` + escapeHtml(code.slice(start, end)) + ""; + pos = end; + } + if (pos < code.length) + out += escapeHtml(code.slice(pos)); + } + return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "
"); + } + + implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin + implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin + color: root.codeBackgroundColor + radius: Tokens.rounding.medium + + onLanguageChanged: { + codeSpans = []; + refresh(); + } + onCodeChanged: refresh() + + RowLayout { + id: headerRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Tokens.padding.small + anchors.leftMargin: Tokens.padding.small + 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: CodeIcons.name(root.language) + visible: root.language.length > 0 + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: root.copied ? "check" : "content_copy" + inactiveColor: "transparent" + inactiveOnColor: root.codeHeaderColor + label.animate: true + type: IconButton.Text + + onClicked: { + Quickshell.clipboardText = root.code; + root.copied = true; + copyResetTimer.restart(); + } + + Timer { + id: copyResetTimer + + interval: 1500 + + onTriggered: root.copied = false + } + } + } + + CustomClippingRect { + 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 + + CustomText { + id: code + anchors.top: parent.top + anchors.bottom: parent.bottom + x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small + anchors.margins: Tokens.padding.small + text: root.highlightedHtml(root.code, root.codeSpans) + textFormat: Text.RichText + color: CodeColors.active.normal + layer.enabled: true + clip: false + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + } + + Flickable { + id: codeFlick + + anchors.fill: parent + anchors.margins: Tokens.padding.small + + CustomScrollBar.horizontal: CustomScrollBar { + flickable: codeFlick + parent: codeFlick.parent + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + } + + TextAreaBase.flickable: TextAreaBase { + id: codeText + + color: CodeColors.active.normal + layer.enabled: true + leftInset: Tokens.padding.small + clip: false + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + textFormat: Text.RichText + text: code.text + readOnly: true + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml new file mode 100644 index 0000000..7f14d8b --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml @@ -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" + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml new file mode 100644 index 0000000..ba81fb4 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml @@ -0,0 +1,443 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + readonly property string c: "M 125 50 c -4 -32 -24 -50 -62 -50 C 29 0 3 24 3 64 c 0 39 24 64 64 64 c 32 0 55 -19 58 -50 H 87 c -2 11 -8 20 -20 20 c -21 0 -24 -16 -24 -33 c 0 -23 8 -35 22 -35 c 13 0 20 7 22 20 z" + readonly property string cpp: "M 63.443 0 c -1.782 0 -3.564 .39 -4.916 1.172 L 11.594 28.27 C 8.89 29.828 6.68 33.66 6.68 36.78 v 54.197 c 0 1.562 .55 3.298 1.441 4.841 l -.002 .002 c .89 1.543 2.123 2.89 3.475 3.672 l 46.931 27.094 c 2.703 1.562 7.13 1.562 9.832 0 h .002 l 46.934 -27.094 c 1.352 -.78 2.582 -2.129 3.473 -3.672 c .89 -1.543 1.441 -3.28 1.441 -4.843 V 36.779 c 0 -1.557 -.55 -3.295 -1.441 -4.838 v -.002 c -.891 -1.545 -2.121 -2.893 -3.473 -3.67 L 68.359 1.173 C 67.008 .39 65.226 0 63.443 0 z m .002 26.033 c 13.465 0 26.02 7.246 32.77 18.91 l -16.38 9.479 c -3.372 -5.836 -9.66 -9.467 -16.39 -9.467 c -10.432 0 -18.922 8.49 -18.922 18.924 S 53.013 82.8 63.445 82.8 c 6.735 0 13.015 -3.625 16.395 -9.465 l 16.375 9.477 c -6.746 11.662 -19.305 18.91 -32.77 18.91 c -20.867 0 -37.843 -16.977 -37.843 -37.844 s 16.976 -37.844 37.843 -37.844 v -.002 z M 92.881 57.57 h 4.201 v 4.207 h 4.203 v 4.203 h -4.203 v 4.207 h -4.201 V 65.98 h -4.207 v -4.203 h 4.207 V 57.57 z m 15.765 0 h 4.208 v 4.207 h 4.203 v 4.203 h -4.203 v 4.207 h -4.208 V 65.98 h -4.205 v -4.203 h 4.205 V 57.57 z" + readonly property string csharp: "M 117.5 33.5 l .3 -.2 c -.6 -1.1 -1.5 -2.1 -2.4 -2.6 L 67.1 2.9 c -.8 -.5 -1.9 -.7 -3.1 -.7 c -1.2 0 -2.3 .3 -3.1 .7 l -48 27.9 c -1.7 1 -2.9 3.5 -2.9 5.4 v 55.7 c 0 1.1 .2 2.3 .9 3.4 l -.2 .1 c .5 .8 1.2 1.5 1.9 1.9 l 48.2 27.9 c .8 .5 1.9 .7 3.1 .7 c 1.2 0 2.3 -.3 3.1 -.7 l 48 -27.9 c 1.7 -1 2.9 -3.5 2.9 -5.4 V 36.1 c .1 -.8 0 -1.7 -.4 -2.6 z m -53.5 70 c -21.8 0 -39.5 -17.7 -39.5 -39.5 S 42.2 24.5 64 24.5 c 14.7 0 27.5 8.1 34.3 20 l -13 7.5 C 81.1 44.5 73.1 39.5 64 39.5 c -13.5 0 -24.5 11 -24.5 24.5 s 11 24.5 24.5 24.5 c 9.1 0 17.1 -5 21.3 -12.4 l 12.9 7.6 c -6.8 11.8 -19.6 19.8 -34.2 19.8 z M 115 62 h -3.2 l -.9 4 h 4.1 v 5 h -5 l -1.2 6 h -4.9 l 1.2 -6 h -3.8 l -1.2 6 h -4.8 l 1.2 -6 H 94 v -5 h 3.5 l .9 -4 H 94 v -5 h 5.3 l 1.2 -6 h 4.9 l -1.2 6 h 3.8 l 1.2 -6 h 4.8 l -1.2 6 h 2.2 v 5 z m -12.7 4 h 3.8 l .9 -4 h -3.8 z" + readonly property string python: "M 49.33 62 h 29.159 C 86.606 62 93 55.132 93 46.981 V 19.183 c 0 -7.912 -6.632 -13.856 -14.555 -15.176 c -5.014 -.835 -10.195 -1.215 -15.187 -1.191 c -4.99 .023 -9.612 .448 -13.805 1.191 C 37.098 6.188 35 10.758 35 19.183 V 30 h 29 v 4 H 23.776 c -8.484 0 -15.914 5.108 -18.237 14.811 c -2.681 11.12 -2.8 17.919 0 29.53 C 7.614 86.983 12.569 93 21.054 93 H 31 V 79.952 C 31 70.315 39.428 62 49.33 62 z m -1.838 -39.11 c -3.026 0 -5.478 -2.479 -5.478 -5.545 c 0 -3.079 2.451 -5.581 5.478 -5.581 c 3.015 0 5.479 2.502 5.479 5.581 c -.001 3.066 -2.465 5.545 -5.479 5.545 z m 74.789 25.921 C 120.183 40.363 116.178 34 107.682 34 H 97 v 12.981 C 97 57.031 88.206 65 78.489 65 H 49.33 C 41.342 65 35 72.326 35 80.326 v 27.8 c 0 7.91 6.745 12.564 14.462 14.834 c 9.242 2.717 17.994 3.208 29.051 0 C 85.862 120.831 93 116.549 93 108.126 V 97 H 64 v -4 h 43.682 c 8.484 0 11.647 -5.776 14.599 -14.66 c 3.047 -9.145 2.916 -17.799 0 -29.529 z m -41.955 55.606 c 3.027 0 5.479 2.479 5.479 5.547 c 0 3.076 -2.451 5.579 -5.479 5.579 c -3.015 0 -5.478 -2.502 -5.478 -5.579 c 0 -3.068 2.463 -5.547 5.478 -5.547 z" + readonly property string rust: "M 62.96 .242 c -.232 .135 -1.203 1.528 -2.16 3.097 c -2.4 3.94 -2.426 3.942 -5.65 .55 c -2.098 -2.208 -2.605 -2.612 -3.28 -2.607 c -.44 .002 -.995 .152 -1.235 .332 c -.24 .18 -.916 1.612 -1.504 3.183 c -1.346 3.6 -1.41 3.715 -2.156 3.86 c -.46 .086 -1.343 -.407 -3.463 -1.929 c -1.565 -1.125 -3.1 -2.045 -3.411 -2.045 c -1.291 0 -1.655 .706 -2.27 4.4 c -.78 4.697 -.754 4.681 -4.988 2.758 c -1.71 -.776 -3.33 -1.41 -3.603 -1.41 c -.274 0 -.792 .293 -1.15 .652 c -.652 .652 -.653 .655 -.475 4.246 l .178 3.595 l -.68 .364 c -.602 .322 -1.017 .283 -3.684 -.348 c -3.48 -.822 -4.216 -.8 -4.92 .15 l -.516 .693 l .692 2.964 c .38 1.63 .745 3.2 .814 3.487 c .067 .287 -.05 .746 -.26 1.02 c -.348 .448 -.717 .49 -3.94 .44 c -5.452 -.086 -5.761 .382 -3.51 5.3 c .718 1.56 1.305 2.98 1.305 3.15 c 0 .898 -.717 1.224 -3.794 1.727 c -1.722 .28 -3.218 .51 -3.326 .51 c -.107 0 -.43 .235 -.717 .522 c -.937 .936 -.671 1.816 1.453 4.814 c 2.646 3.735 2.642 3.75 -1.73 5.421 c -4.971 1.902 -5.072 2.37 -1.287 5.96 c 3.525 3.344 3.53 3.295 -.461 5.804 C .208 62.8 .162 62.846 .085 63.876 c -.093 1.253 -.071 1.275 3.538 3.48 c 3.57 2.18 3.57 2.246 .067 5.56 C -.078 76.48 .038 77 5.013 78.877 c 4.347 1.64 4.353 1.66 1.702 5.394 c -1.502 2.117 -1.981 3 -1.981 3.653 c 0 1.223 .637 1.535 4.44 2.174 c 3.206 .54 3.92 .857 3.92 1.741 c 0 .182 -.588 1.612 -1.307 3.177 c -2.236 4.87 -1.981 5.275 3.31 5.275 c 4.93 0 4.799 -.15 3.737 4.294 c -.8 3.35 -.813 3.992 -.088 4.715 c .554 .556 1.6 .494 4.87 -.289 c 2.499 -.596 2.937 -.637 3.516 -.328 l .66 .354 l -.177 3.594 c -.178 3.593 -.177 3.595 .475 4.248 c .358 .36 .884 .652 1.165 .652 c .282 0 1.903 -.63 3.604 -1.404 c 4.22 -1.916 4.194 -1.932 4.973 2.75 c .617 3.711 .977 4.4 2.294 4.4 c .327 0 1.83 -.88 3.34 -1.958 c 2.654 -1.893 3.342 -2.19 4.049 -1.74 c .182 .115 .89 1.67 1.572 3.455 c 1.003 2.625 1.37 3.31 1.929 3.576 c 1.062 .51 1.72 .1 4.218 -2.62 c 3.016 -3.286 3.14 -3.27 5.602 .72 c 2.72 4.406 3.424 4.396 6.212 -.089 c 2.402 -3.864 2.374 -3.862 5.621 -.47 c 2.157 2.25 2.616 2.61 3.343 2.61 c .464 0 1.019 -.175 1.23 -.388 c .214 -.213 .92 -1.786 1.568 -3.496 c .649 -1.71 1.321 -3.2 1.495 -3.31 c .687 -.436 1.398 -.13 4.048 1.752 c 1.56 1.108 3.028 1.96 3.377 1.96 c 1.296 0 1.764 -.92 2.302 -4.535 c .46 -3.082 .554 -3.378 1.16 -3.685 c .596 -.302 .954 -.2 3.75 1.07 c 1.701 .77 3.323 1.402 3.604 1.402 c .282 0 .816 -.302 1.184 -.672 l .672 -.67 l -.184 -3.448 c -.177 -3.29 -.16 -3.468 .364 -3.943 c .54 -.488 .596 -.486 3.615 .204 c 3.656 .835 4.338 .857 5.025 .17 c .671 -.67 .664 -.818 -.254 -4.69 c -1.03 -4.346 -1.168 -4.19 3.78 -4.19 c 3.374 0 3.75 -.049 4.18 -.523 c .718 -.793 .547 -1.702 -.896 -4.779 c -.729 -1.55 -1.32 -2.96 -1.315 -3.135 c .024 -.914 .743 -1.227 4.065 -1.767 c 2.033 -.329 3.553 -.71 3.829 -.96 c .923 -.833 .584 -1.918 -1.523 -4.873 c -2.642 -3.703 -2.63 -3.738 1.599 -5.297 c 5.064 -1.866 5.209 -2.488 1.419 -6.09 c -3.51 -3.335 -3.512 -3.317 .333 -5.677 c 4.648 -2.853 4.655 -3.496 .082 -6.335 c -3.933 -2.44 -3.93 -2.406 -.405 -5.753 c 3.78 -3.593 3.678 -4.063 -1.295 -5.965 c -4.388 -1.679 -4.402 -1.72 -1.735 -5.38 c 1.588 -2.18 1.982 -2.903 1.982 -3.65 c 0 -1.306 -.586 -1.598 -4.436 -2.22 c -3.216 -.52 -3.924 -.835 -3.924 -1.75 c 0 -.174 .588 -1.574 1.307 -3.113 c 1.406 -3.013 1.604 -4.22 .808 -4.94 c -.428 -.387 -1 -.443 -4.067 -.392 c -3.208 .054 -3.618 .008 -4.063 -.439 c -.486 -.488 -.48 -.557 .278 -3.725 c .931 -3.88 .935 -3.975 .17 -4.694 c -.777 -.73 -1.262 -.718 -4.826 .121 c -2.597 .612 -3.027 .653 -3.617 .337 l -.67 -.36 l .185 -3.582 l .186 -3.58 l -.67 -.67 c -.369 -.37 -.891 -.67 -1.163 -.67 c -.27 0 -1.884 .64 -3.583 1.421 c -2.838 1.306 -3.143 1.393 -3.757 1.072 c -.612 -.32 -.714 -.637 -1.237 -3.829 c -.603 -3.693 -.977 -4.412 -2.288 -4.412 c -.311 0 -1.853 .925 -3.426 2.055 c -2.584 1.856 -2.93 2.032 -3.574 1.807 c -.533 -.186 -.843 -.59 -1.221 -1.599 c -.28 -.742 -.817 -2.172 -1.194 -3.177 c -.762 -2.028 -1.187 -2.482 -2.328 -2.482 c -.637 0 -1.213 .458 -3.28 2.604 c -3.25 3.375 -3.261 3.374 -5.65 -.545 C 66.073 1.78 65.075 .382 64.81 .24 c -.597 -.32 -1.3 -.32 -1.85 .002 m 2.96 11.798 c 2.83 2.014 1.326 6.75 -2.144 6.75 c -3.368 0 -5.064 -4.057 -2.66 -6.36 c 1.358 -1.3 3.304 -1.459 4.805 -.39 m -3.558 12.507 c 1.855 .705 2.616 .282 6.852 -3.8 l 3.182 -3.07 l 1.347 .18 c 4.225 .56 12.627 4.25 17.455 7.666 c 4.436 3.14 10.332 9.534 12.845 13.93 l .537 .942 l -2.38 5.364 c -1.31 2.95 -2.382 5.673 -2.382 6.053 c 0 .878 .576 2.267 1.13 2.726 c .234 .195 2.457 1.265 4.939 2.378 l 4.51 2.025 l .178 1.148 c .23 1.495 .26 5.167 .052 6.21 l -.163 .816 h -2.575 c -2.987 0 -2.756 -.267 -2.918 3.396 c -.118 2.656 -.76 4.124 -2.22 5.075 c -2.377 1.551 -6.304 1.27 -7.97 -.57 c -.255 -.284 -.752 -1.705 -1.105 -3.16 c -1.03 -4.254 -2.413 -6.64 -5.193 -8.965 c -.878 -.733 -1.595 -1.418 -1.595 -1.522 c 0 -.102 .965 -.915 2.145 -1.803 c 4.298 -3.24 6.77 -7.012 7.04 -10.747 c .519 -7.126 -5.158 -13.767 -13.602 -15.92 c -2.002 -.51 -2.857 -.526 -27.624 -.526 c -14.057 0 -25.56 -.092 -25.56 -.204 c 0 -.263 3.125 -3.295 4.965 -4.816 c 5.054 -4.178 11.618 -7.465 18.417 -9.22 l 2.35 -.61 l 3.34 3.387 c 1.839 1.863 3.64 3.5 4.003 3.637 M 20.3 46.34 c 1.539 1.008 2.17 3.54 1.26 5.062 c -1.405 2.356 -4.966 2.455 -6.373 .178 c -2.046 -3.309 1.895 -7.349 5.113 -5.24 m 90.672 .13 c 4.026 2.454 .906 8.493 -3.404 6.586 c -2.877 -1.273 -2.97 -5.206 -.155 -6.64 c 1.174 -.6 2.523 -.579 3.56 .053 M 32.163 61.5 v 15.02 h -13.28 l -.526 -2.285 c -1.036 -4.5 -1.472 -9.156 -1.211 -12.969 l .182 -2.679 l 4.565 -2.047 c 2.864 -1.283 4.706 -2.262 4.943 -2.625 c 1.038 -1.584 .94 -2.715 -.518 -5.933 l -.68 -1.502 h 6.523 V 61.5 M 70.39 47.132 c 2.843 .74 4.345 2.245 4.349 4.355 c .002 1.55 -.765 2.52 -2.67 3.38 c -1.348 .61 -1.562 .625 -10.063 .708 l -8.686 .084 v -8.92 h 7.782 c 6.078 0 8.112 .086 9.288 .393 m -2.934 21.554 c 1.41 .392 3.076 1.616 3.93 2.888 c .898 1.337 1.423 3.076 2.667 8.836 c 1.05 4.87 1.727 6.46 3.62 8.532 c 2.345 2.566 1.8 2.466 13.514 2.466 c 5.61 0 10.198 .09 10.198 .2 c 0 .197 -3.863 4.764 -4.03 4.764 c -.048 0 -2.066 -.422 -4.484 -.939 c -6.829 -1.458 -7.075 -1.287 -8.642 6.032 l -1.008 4.702 l -.91 .448 c -1.518 .75 -6.453 2.292 -9.01 2.82 c -4.228 .87 -8.828 1.162 -12.871 .821 c -6.893 -.585 -16.02 -3.259 -16.377 -4.8 c -.075 -.327 -.535 -2.443 -1.018 -4.704 c -.485 -2.26 -1.074 -4.404 -1.31 -4.764 c -1.13 -1.724 -2.318 -1.83 -7.547 -.674 c -1.98 .44 -3.708 .796 -3.84 .796 c -.248 0 -3.923 -4.249 -3.923 -4.535 c 0 -.09 8.728 -.194 19.396 -.23 l 19.395 -.066 l .07 -6.89 c .05 -4.865 -.018 -6.997 -.23 -7.25 c -.234 -.284 -1.485 -.358 -6.011 -.358 H 53.32 v -8.36 l 6.597 .001 c 3.626 .002 7.02 .12 7.539 .264 M 37.57 100.02 c 3.084 1.88 1.605 6.804 -2.043 6.8 c -3.74 0 -5.127 -4.88 -1.94 -6.826 c 1.055 -.643 2.908 -.63 3.983 .026 m 56.48 .206 c 1.512 1.108 2.015 3.413 1.079 4.95 c -2.46 4.034 -8.612 .827 -6.557 -3.419 c 1.01 -2.085 3.695 -2.837 5.478 -1.53" + readonly property string javascript: "M 2 1 v 125 h 125 V 1 H 2 z m 66.119 106.513 c -1.845 3.749 -5.367 6.212 -9.448 7.401 c -6.271 1.44 -12.269 .619 -16.731 -2.059 c -2.986 -1.832 -5.318 -4.652 -6.901 -7.901 l 9.52 -5.83 c .083 .035 .333 .487 .667 1.071 c 1.214 2.034 2.261 3.474 4.319 4.485 c 2.022 .69 6.461 1.131 8.175 -2.427 c 1.047 -1.81 .714 -7.628 .714 -14.065 C 58.433 78.073 58.48 68 58.48 58 h 11.709 c 0 11 .06 21.418 0 32.152 c .025 6.58 .596 12.446 -2.07 17.361 z m 48.574 -3.308 c -4.07 13.922 -26.762 14.374 -35.83 5.176 c -1.916 -2.165 -3.117 -3.296 -4.26 -5.795 c 4.819 -2.772 4.819 -2.772 9.508 -5.485 c 2.547 3.915 4.902 6.068 9.139 6.949 c 5.748 .702 11.531 -1.273 10.234 -7.378 c -1.333 -4.986 -11.77 -6.199 -18.873 -11.531 c -7.211 -4.843 -8.901 -16.611 -2.975 -23.335 c 1.975 -2.487 5.343 -4.343 8.877 -5.235 l 3.688 -.477 c 7.081 -.143 11.507 1.727 14.756 5.355 c .904 .916 1.642 1.904 3.022 4.045 c -3.772 2.404 -3.76 2.381 -9.163 5.879 c -1.154 -2.486 -3.069 -4.046 -5.093 -4.724 c -3.142 -.952 -7.104 .083 -7.926 3.403 c -.285 1.023 -.226 1.975 .227 3.665 c 1.273 2.903 5.545 4.165 9.377 5.926 c 11.031 4.474 14.756 9.271 15.672 14.981 c .882 4.916 -.213 8.105 -.38 8.581 z" + readonly property string typescript: "M 2 63.91 v 62.5 h 125 v -125 H 2 z m 100.73 -5 a 15.56 15.56 0 0 1 7.82 4.5 a 20.58 20.58 0 0 1 3 4 c 0 .16 -5.4 3.81 -8.69 5.85 c -.12 .08 -.6 -.44 -1.13 -1.23 a 7.09 7.09 0 0 0 -5.87 -3.53 c -3.79 -.26 -6.23 1.73 -6.21 5 a 4.58 4.58 0 0 0 .54 2.34 c .83 1.73 2.38 2.76 7.24 4.86 c 8.95 3.85 12.78 6.39 15.16 10 c 2.66 4 3.25 10.46 1.45 15.24 c -2 5.2 -6.9 8.73 -13.83 9.9 a 38.32 38.32 0 0 1 -9.52 -.1 A 23 23 0 0 1 80 109.19 c -1.15 -1.27 -3.39 -4.58 -3.25 -4.82 a 9.34 9.34 0 0 1 1.15 -.73 l 4.6 -2.64 l 3.59 -2.08 l .75 1.11 a 16.78 16.78 0 0 0 4.74 4.54 c 4 2.1 9.46 1.81 12.16 -.62 a 5.43 5.43 0 0 0 .69 -6.92 c -1 -1.39 -3 -2.56 -8.59 -5 c -6.45 -2.78 -9.23 -4.5 -11.77 -7.24 a 16.48 16.48 0 0 1 -3.43 -6.25 a 25 25 0 0 1 -.22 -8 c 1.33 -6.23 6 -10.58 12.82 -11.87 a 31.66 31.66 0 0 1 9.49 .26 z m -29.34 5.24 v 5.12 H 57.16 v 46.23 H 45.65 V 69.26 H 29.38 v -5 a 49.19 49.19 0 0 1 .14 -5.16 c .06 -.08 10 -.12 22 -.1 h 21.81 z" + readonly property string bash: "M 112.205 26.129 L 71.8 2.142 A 15.326 15.326 0 0 0 64.005 0 c -2.688 0 -5.386 .717 -7.796 2.152 L 15.795 26.14 C 10.976 28.999 8 34.289 8 40.018 v 47.975 c 0 5.729 2.967 11.019 7.796 13.878 L 56.2 125.858 A 15.193 15.193 0 0 0 63.995 128 a 15.32 15.32 0 0 0 7.796 -2.142 l 40.414 -23.987 c 4.819 -2.86 7.796 -8.16 7.796 -13.878 V 40.007 c 0 -5.718 -2.967 -11.019 -7.796 -13.878 z m -31.29 74.907 l .063 3.448 c 0 .418 -.267 .889 -.588 1.06 l -2.046 1.178 c -.321 .16 -.6 -.032 -.6 -.45 l -.032 -3.394 c -1.745 .728 -3.523 .9 -4.647 .45 c -.214 -.086 -.31 -.397 -.225 -.761 l .739 -3.116 c .064 -.246 .193 -.493 .364 -.643 a .725 .725 0 0 1 .193 -.139 c .117 -.064 .235 -.075 .332 -.032 c 1.22 .407 2.773 .214 4.272 -.535 c 1.907 -.964 3.18 -2.913 3.16 -4.84 c -.022 -1.757 -.964 -2.474 -3.267 -2.496 c -2.934 .01 -5.675 -.567 -5.718 -4.894 c -.032 -3.555 1.81 -7.26 4.744 -9.595 l -.032 -3.48 c 0 -.428 .257 -.9 .589 -1.07 l 1.98 -1.264 c .322 -.161 .6 .043 .6 .46 l .033 3.48 c 1.456 -.578 2.72 -.738 3.865 -.47 c .247 .064 .364 .406 .257 .802 l -.77 3.084 a 1.372 1.372 0 0 1 -.354 .622 a .825 .825 0 0 1 -.203 .15 c -.108 .053 -.204 .064 -.3 .053 c -.525 -.118 -1.767 -.385 -3.727 .6 c -2.056 1.038 -2.773 2.827 -2.763 4.155 c .022 1.585 .825 2.066 3.63 2.11 c 3.738 .064 5.344 1.691 5.387 5.45 c .053 3.684 -1.917 7.657 -4.937 10.077 z m 21.18 -5.794 c 0 .322 -.042 .621 -.31 .771 l -10.216 6.211 c -.267 .161 -.482 .022 -.482 -.3 V 99.29 c 0 -.321 .193 -.492 .46 -.653 l 10.067 -6.018 c .268 -.16 .482 -.022 .482 .3 z m 7.026 -58.993 L 70.89 59.86 c -4.765 2.784 -8.278 5.911 -8.288 11.662 v 47.107 c 0 3.437 1.392 5.665 3.523 6.318 a 12.81 12.81 0 0 1 -2.12 .204 c -2.239 0 -4.445 -.61 -6.383 -1.757 L 17.219 99.408 c -3.951 -2.345 -6.403 -6.725 -6.403 -11.426 V 40.007 c 0 -4.7 2.452 -9.08 6.403 -11.426 L 57.634 4.594 a 12.555 12.555 0 0 1 6.382 -1.756 c 2.238 0 4.444 .61 6.382 1.756 l 40.415 23.987 c 3.33 1.981 5.579 5.397 6.21 9.242 c -1.36 -2.86 -4.38 -3.63 -7.902 -1.574 z" + readonly property string zsh: "M 19.264 3.822 C 8.664 3.822 0 12.486 0 23.086 v 81.771 c 0 10.6 8.664 19.264 19.264 19.264 h 43.955 a 3.24 3.24 0 0 0 3.24 -3.24 a 3.24 3.24 0 0 0 -3.24 -3.24 H 19.264 a 12.736 12.736 0 0 1 -12.785 -12.784 v -72.26 h 115.043 v 68.471 a .713 .713 0 0 1 -.131 .186 a .716 .716 0 0 1 -.506 .21 h -3.965 a .716 .716 0 0 1 -.506 -.21 a .715 .715 0 0 1 -.209 -.506 V 88.84 l -.002 -.035 l .002 -.037 a 3.208 3.208 0 0 0 -3.207 -3.207 a 3.207 3.207 0 0 0 -2.267 .939 a 3.206 3.206 0 0 0 -.94 2.268 l .002 .037 l -.002 .035 v 32.13 c 0 .851 .338 1.667 .94 2.268 a 3.206 3.206 0 0 0 4.535 0 a 3.208 3.208 0 0 0 .94 -2.267 v -12.735 c 0 -.19 .074 -.371 .208 -.505 a .715 .715 0 0 1 .506 -.21 h 3.965 c .19 0 .372 .076 .506 .21 a .716 .716 0 0 1 .13 .185 v 12.965 a 3.24 3.24 0 0 0 .034 .12 c .003 .267 .033 .535 .101 .794 a 3.187 3.187 0 0 0 .64 1.23 a 3.2 3.2 0 0 0 1.104 .844 a 3.189 3.189 0 0 0 1.356 .297 l .014 -.002 l .011 .002 a 3.18 3.18 0 0 0 2.94 -1.965 c .009 -.02 .013 -.042 .021 -.062 a 3.24 3.24 0 0 0 .03 -.078 a 3.18 3.18 0 0 0 .095 -.313 a 3.24 3.24 0 0 0 .133 -.867 V 23.086 c 0 -10.6 -8.662 -19.264 -19.262 -19.264 H 19.264 z m 0 6.479 h 89.474 a 12.736 12.736 0 0 1 12.783 12.785 v 3.033 H 6.478 v -3.033 a 12.737 12.737 0 0 1 12.786 -12.785 z m 14.658 28.515 a 1.138 1.138 0 0 0 -.774 .4 l -21.14 25.178 a 1.138 1.138 0 0 0 .14 1.604 a 1.138 1.138 0 0 0 1.602 -.139 l 21.143 -25.177 a 1.138 1.138 0 0 0 -.141 -1.604 a 1.138 1.138 0 0 0 -.83 -.262 z m -15.68 .637 c -3.37 0 -6.14 2.772 -6.14 6.143 s 2.77 6.14 6.14 6.14 c 3.37 0 6.14 -2.77 6.14 -6.14 c 0 -3.371 -2.77 -6.143 -6.14 -6.143 z m 0 3.557 c 1.45 0 2.586 1.137 2.586 2.586 s -1.137 2.586 -2.586 2.586 a 2.56 2.56 0 0 1 -2.586 -2.586 a 2.56 2.56 0 0 1 2.586 -2.586 z m 10.344 10.344 c -3.37 0 -6.143 2.77 -6.143 6.14 c 0 3.37 2.772 6.14 6.143 6.14 c 3.37 0 6.14 -2.77 6.14 -6.14 s -2.77 -6.14 -6.14 -6.14 z m 0 3.554 a 2.56 2.56 0 0 1 2.586 2.586 a 2.56 2.56 0 0 1 -2.586 2.586 A 2.56 2.56 0 0 1 26 59.494 a 2.56 2.56 0 0 1 2.586 -2.586 z m 14.18 6.451 a 1.138 1.138 0 0 0 -1.139 1.137 a 1.138 1.138 0 0 0 1.139 1.139 H 57.86 A 1.138 1.138 0 0 0 59 64.496 a 1.138 1.138 0 0 0 -1.139 -1.137 H 42.766 z m 30.767 22.186 a 3.253 3.253 0 0 0 -1.244 .248 a 3.265 3.265 0 0 0 -1.057 .705 a 3.251 3.251 0 0 0 -.703 1.055 a 3.25 3.25 0 0 0 -.248 1.244 v .002 a 3.24 3.24 0 0 0 .951 2.299 a 3.246 3.246 0 0 0 1.057 .703 a 3.241 3.241 0 0 0 1.244 .248 c .051 0 .1 -.006 .15 -.008 h 5.99 a .404 .404 0 0 1 .335 .176 a .41 .41 0 0 1 .066 .183 a .408 .408 0 0 1 -.027 .194 l -9.51 23.748 a 3.58 3.58 0 0 0 -.256 1.33 v 2.912 c 0 .948 .377 1.857 1.047 2.527 a 3.58 3.58 0 0 0 2.53 1.05 h 11.146 a 3.334 3.334 0 0 0 1.275 -.255 a 3.343 3.343 0 0 0 1.082 -.722 a 3.327 3.327 0 0 0 .723 -1.082 a 3.326 3.326 0 0 0 .254 -1.276 v -.002 a 3.338 3.338 0 0 0 -.977 -2.36 a 3.338 3.338 0 0 0 -2.359 -.976 c -.026 0 -.05 .003 -.076 .004 h -7.498 a .387 .387 0 0 1 -.182 -.045 a .38 .38 0 0 1 -.137 -.125 a .39 .39 0 0 1 -.066 -.176 a .382 .382 0 0 1 .027 -.183 l 10.967 -27.344 a 2.964 2.964 0 0 0 .195 -1.418 a 2.955 2.955 0 0 0 -.49 -1.344 a 2.97 2.97 0 0 0 -1.066 -.957 a 2.961 2.961 0 0 0 -1.389 -.345 H 73.728 c -.065 -.004 -.128 -.01 -.195 -.01 z m 26.192 .18 c -1.523 0 -3.266 .147 -4.315 .441 a 5.042 5.042 0 0 0 -2.486 1.424 c -.609 .687 -1.05 1.619 -1.32 2.797 c -.272 1.145 -.405 2.602 -.405 4.369 c 0 1.701 .015 3.076 .049 4.123 a 19.145 19.145 0 0 0 .305 2.553 a 4.957 4.957 0 0 0 .66 1.57 a 6.3 6.3 0 0 0 1.015 1.129 a 10.337 10.337 0 0 0 1.725 1.228 c .677 .393 1.372 .767 2.082 1.127 a 29.432 29.432 0 0 1 2.03 1.13 a 8.647 8.647 0 0 1 1.675 1.228 c .2 .217 .386 .447 .557 .687 c .123 .146 .22 .311 .283 .49 c .059 .16 .11 .325 .148 .49 c .056 .26 .082 .522 .079 .786 c .033 .392 .048 .934 .048 1.62 a 20.153 20.153 0 0 1 -.15 2.798 a 3.99 3.99 0 0 1 -.406 1.521 a 1.397 1.397 0 0 1 -.813 .639 c -.338 .098 -1.508 .146 -2.015 .146 a 4.42 4.42 0 0 1 -1.219 -.146 a 1.676 1.676 0 0 1 -.76 -.688 a 5.713 5.713 0 0 1 -.406 -1.472 a 23.667 23.667 0 0 1 -.104 -2.45 c 0 -.09 -.004 -.182 -.013 -.273 h -.02 a 2.728 2.728 0 0 0 -.918 -1.678 a 2.757 2.757 0 0 0 -1.8 -.668 a 2.747 2.747 0 0 0 -1.798 .668 a 2.728 2.728 0 0 0 -.917 1.678 h -.018 a 2.6 2.6 0 0 0 -.014 .301 v .123 c .009 .645 .018 1.223 .018 1.514 c 0 1.93 .136 3.501 .406 4.713 c .305 1.177 .78 2.11 1.422 2.796 a 4.887 4.887 0 0 0 2.436 1.327 a 14.194 14.194 0 0 0 3.603 .392 c 1.625 0 3.724 -.147 4.807 -.441 a 4.78 4.78 0 0 0 2.588 -1.668 c .676 -.818 1.15 -1.949 1.422 -3.389 c .304 -1.472 .457 -3.354 .457 -5.644 c 0 -1.67 -.051 -3.011 -.153 -4.026 a 8.816 8.816 0 0 0 -.457 -2.504 a 4.07 4.07 0 0 0 -.965 -1.57 a 7.968 7.968 0 0 0 -1.47 -1.178 a 14.43 14.43 0 0 0 -1.524 -.834 a 23.424 23.424 0 0 0 -1.826 -.933 a 44.885 44.885 0 0 1 -1.828 -.98 a 15.28 15.28 0 0 1 -1.47 -1.032 a 5 5 0 0 1 -.61 -.59 a 2.125 2.125 0 0 1 -.305 -.736 a 7.721 7.721 0 0 1 -.152 -1.13 V 95.64 a 17.363 17.363 0 0 1 .101 -2.112 a 3.367 3.367 0 0 1 .356 -1.226 a 1.222 1.222 0 0 1 .76 -.54 c .304 -.098 1.153 -.148 1.627 -.148 c 1.082 0 1.758 .312 2.029 .934 c .214 .393 .335 .828 .353 1.275 c .012 .358 .017 .824 .018 1.31 h .02 v .02 c 0 .726 .289 1.421 .804 1.934 a 2.753 2.753 0 0 0 1.944 .8 c .728 0 1.428 -.287 1.943 -.8 a 2.728 2.728 0 0 0 .805 -1.934 l -.002 -.02 h .002 v -1.31 a 16.978 16.978 0 0 0 -.356 -3.779 c -.203 -1.047 -.61 -1.88 -1.219 -2.502 c -.575 -.655 -1.387 -1.113 -2.435 -1.375 c -1.015 -.294 -2.317 -.441 -3.906 -.441 h -.002 z" + readonly property string powershell: "M 124.912 19.358 c -.962 -1.199 -2.422 -1.858 -4.111 -1.858 h -92.61 c -3.397 0 -6.665 2.642 -7.444 6.015 L 2.162 104.022 c -.396 1.711 -.058 3.394 .926 4.619 c .963 1.199 2.423 1.858 4.111 1.858 v .001 H 99.81 c 3.396 0 6.665 -2.643 7.443 -6.016 l 18.586 -80.508 c .395 -1.711 .057 -3.395 -.927 -4.618 z m -98.589 77.17 c -1.743 -2.397 -1.323 -5.673 .94 -7.318 l 37.379 -27.067 v -.556 L 41.157 36.603 c -1.916 -2.038 -1.716 -5.333 .445 -7.361 c 2.162 -2.027 5.466 -2.019 7.382 .019 l 28.18 29.979 c 1.6 1.702 1.718 4.279 .457 6.264 c -.384 .774 -1.182 1.628 -2.593 2.618 l -41.45 29.769 c -2.263 1.644 -5.512 1.034 -7.255 -1.363 z m 59.543 .538 H 63.532 c -2.597 0 -4.702 -2.082 -4.702 -4.65 s 2.105 -4.65 4.702 -4.65 h 22.333 c 2.597 0 4.702 2.082 4.702 4.65 s -2.104 4.65 -4.701 4.65 z" + readonly property string cmake: "M 62.8 .4 L .3 123.8 l 68.1 -57.9 z m 61 127.3 l -84 -33.9 L 0 127.7 z m 4.2 -1.1 L 65.6 2.5 l 9.2 102.6 z M 71.9 104 l -3.1 -34.9 L 42 92 z" + readonly property string lua: "M 61.7 0 c -1.9 0 -3.8 .2 -5.6 .4 l .2 1.5 c 1.8 -.2 3.6 -.4 5.5 -.4 L 61.7 0 z m 5.6 0 l -.1 1.5 c 1.8 .1 3.6 .3 5.4 .5 l .3 -1.5 C 71 .3 69.2 .1 67.3 0 z m 45.7 .8 c -7.9 0 -14.4 6.3 -14.4 14.3 S 105 29.4 113 29.4 s 14.3 -6.4 14.3 -14.3 S 120.9 .8 113 .8 z m -62.4 .6 c -1.8 .4 -3.6 .9 -5.4 1.4 l .4 1.4 c 1.7 -.5 3.5 -1 5.3 -1.4 l -.3 -1.4 z m 27.6 .3 l -.3 1.4 c 1.8 .4 3.6 .8 5.3 1.4 l .4 -1.3 c -1.8 -.6 -3.6 -1.1 -5.4 -1.5 z m -38.3 3 c -1.7 .7 -3.4 1.5 -5.1 2.3 l .7 1.3 c 1.6 -.8 3.3 -1.6 5 -2.3 l -.6 -1.3 z m 49 .3 l -.5 1.4 c 1.6 .7 3.3 1.5 4.9 2.3 l .6 -1.3 c -1.6 -.9 -3.3 -1.7 -5 -2.4 z M 30 9.7 c -1.6 1 -3.1 2.1 -4.6 3.2 l .9 1.2 c 1.4 -1.1 2.9 -2.1 4.5 -3.2 L 30 9.7 z m 34 5.4 c -27 0 -49 21.9 -49 49 s 21.9 49 49 49 s 49 -21.9 49 -49 s -22 -49 -49 -49 z m -42.9 1.4 c -1.4 1.2 -2.7 2.5 -4 3.9 l 1.1 1 c 1.2 -1.3 2.5 -2.6 3.9 -3.8 l -1 -1.1 z m -7.6 8.2 c -1.1 1.4 -2.2 2.9 -3.2 4.5 l 1.2 .8 c 1 -1.5 2 -3 3.2 -4.4 l -1.2 -.9 z m 70.8 4.7 c 7.9 0 14.3 6.4 14.3 14.3 S 92.2 58 84.3 58 S 70 51.6 70 43.7 s 6.4 -14.3 14.3 -14.3 z M 7.4 34.1 c -.9 1.6 -1.7 3.3 -2.4 5 l 1.4 .5 c .7 -1.6 1.5 -3.3 2.3 -4.8 l -1.3 -.7 z m 113.6 .8 l -1.3 .7 c .9 1.6 1.6 3.3 2.3 5 l 1.3 -.6 c -.7 -1.7 -1.5 -3.4 -2.3 -5.1 z M 3.1 44.3 c -.6 1.8 -1.1 3.6 -1.5 5.4 L 3 50 c .4 -1.8 .9 -3.5 1.5 -5.2 l -1.4 -.5 z m 122.1 1 l -1.4 .4 c .6 1.7 1 3.5 1.4 5.3 l 1.4 -.3 c -.4 -1.8 -.9 -3.6 -1.4 -5.4 z M .5 55.1 C .3 57 .1 58.8 0 60.7 l 1.5 .1 c .1 -1.8 .3 -3.6 .5 -5.4 l -1.5 -.3 z m 127.1 1.1 l -1.5 .2 c .2 1.8 .3 3.6 .4 5.4 h 1.5 c 0 -1.9 -.2 -3.8 -.4 -5.6 z m -96.9 .2 h 4.1 v 28.5 h 15.9 v 3.6 h -20 V 56.4 z m 57.7 8.3 c 5.7 0 8.7 2.2 8.7 6.3 v 13.6 c 0 1.1 .7 1.8 2 1.8 c .2 0 .4 0 .8 -.1 l -.1 2.8 c -1.2 .3 -1.8 .4 -2.5 .4 c -2.4 0 -3.5 -1.1 -3.8 -3.4 c -2.6 2.4 -4.9 3.4 -7.8 3.4 c -4.7 0 -7.6 -2.6 -7.6 -6.8 c 0 -3 1.4 -5.1 4.1 -6.2 c 1.4 -.6 2.2 -.7 7.4 -1.4 c 2.9 -.4 3.8 -1 3.8 -2.6 v -1 c 0 -2.2 -1.9 -3.4 -5.2 -3.4 c -3.4 0 -5.1 1.3 -5.4 4.1 h -3.7 c .1 -2.3 .5 -3.6 1.6 -4.8 c 1.5 -1.7 4.3 -2.7 7.7 -2.7 z m -33.8 .7 h 3.7 v 16.3 c 0 2.8 1.9 4.5 4.8 4.5 c 3.8 0 6.3 -3.1 6.3 -7.8 v -13 H 73 v 23.1 h -3.3 v -3.2 c -2.2 3 -4.3 4.2 -7.7 4.2 c -4.5 0 -7.4 -2.5 -7.4 -6.3 V 65.4 z m -53.1 .8 l -1.5 .1 c .1 1.9 .2 3.7 .5 5.6 l 1.4 -.3 c -.2 -1.8 -.3 -3.6 -.4 -5.4 z m 124.9 1.1 c -.1 1.8 -.3 3.6 -.5 5.4 l 1.5 .2 c .3 -1.8 .5 -3.7 .5 -5.5 l -1.5 -.1 z M 2.8 77.1 l -1.4 .3 c .4 1.8 .9 3.6 1.4 5.4 l 1.4 -.4 c -.6 -1.8 -1 -3.5 -1.4 -5.3 z m 90.6 0 c -1.2 .6 -2 .7 -5.9 1.3 c -3.9 .5 -5.6 1.8 -5.6 4.2 c 0 2.3 1.7 3.7 4.5 3.7 c 2.2 0 4 -.7 5.5 -2.1 c 1.1 -1 1.5 -1.8 1.5 -3 v -4.1 z m 31.6 .9 c -.5 1.8 -.9 3.6 -1.5 5.3 l 1.4 .5 c .6 -1.8 1.1 -3.6 1.5 -5.5 L 125 78 z M 6 87.5 l -1.3 .5 c .7 1.7 1.5 3.4 2.3 5.1 l 1.3 -.6 c -.9 -1.7 -1.6 -3.3 -2.3 -5 z m 115.7 1 c -.8 1.6 -1.5 3.3 -2.4 4.9 l 1.3 .7 L 123 89 l -1.3 -.5 z M 10.9 97.2 l -1.2 .8 c 1 1.6 2.1 3.1 3.2 4.6 l 1.1 -.9 c -1.1 -1.5 -2.1 -3 -3.1 -4.5 z m 105.6 .9 c -1 1.5 -2.1 3 -3.2 4.4 l 1.2 .9 c 1.1 -1.4 2.2 -3 3.2 -4.5 l -1.2 -.8 z m -98.9 7.8 l -1.1 1 c 1.2 1.4 2.5 2.7 3.9 4 l 1 -1.1 c -1.3 -1.2 -2.6 -2.6 -3.8 -3.9 z m 92.2 .8 c -1.2 1.3 -2.6 2.6 -3.9 3.8 l 1 1.1 c 1.3 -1.3 2.7 -2.6 4 -3.9 l -1.1 -1 z m -84.2 6.6 l -.9 1.2 c 1.4 1.1 2.9 2.2 4.5 3.2 l .8 -1.2 c -1.5 -1 -3 -2.1 -4.4 -3.2 z m 76.1 .7 c -1.5 1.1 -3 2.1 -4.5 3.1 l .8 1.2 c 1.5 -1 3.1 -2 4.6 -3.1 l -.9 -1.2 z m -67 5.3 l -.7 1.3 c 1.6 .9 3.3 1.7 5 2.4 l .6 -1.3 c -1.6 -.8 -3.3 -1.5 -4.9 -2.4 z m 57.7 .4 c -1.7 .9 -3.3 1.6 -5 2.3 l .6 1.4 c 1.7 -.7 3.4 -1.5 5.1 -2.4 l -.7 -1.3 z m -47.7 3.8 l -.5 1.4 c 1.8 .6 3.6 1.1 5.4 1.5 l .4 -1.4 c -1.8 -.5 -3.6 -.9 -5.3 -1.5 z m 37.6 .3 c -1.8 .6 -3.5 1 -5.3 1.4 l .3 1.4 c 1.9 -.3 3.7 -.8 5.4 -1.4 l -.4 -1.4 z m -27 2.2 l -.2 1.5 c 1.9 .2 3.7 .4 5.6 .5 v -1.5 c -1.8 -.1 -3.6 -.3 -5.4 -.5 z m 16.3 .1 c -1.8 .2 -3.6 .3 -5.4 .4 l .1 1.5 c 1.8 -.1 3.7 -.2 5.5 -.4 l -.2 -1.5 z" + readonly property string java: "M 47.617 98.12 c -19.192 5.362 11.677 16.439 36.115 5.969 c -4.003 -1.556 -6.874 -3.351 -6.874 -3.351 c -10.897 2.06 -15.952 2.222 -25.844 1.092 c -8.164 -.935 -3.397 -3.71 -3.397 -3.71 z m 33.189 -10.46 c -14.444 2.779 -22.787 2.69 -33.354 1.6 c -8.171 -.845 -2.822 -4.805 -2.822 -4.805 c -21.137 7.016 11.767 14.977 41.309 6.336 c -3.14 -1.106 -5.133 -3.131 -5.133 -3.131 z m 11.319 -60.575 c .001 0 -42.731 10.669 -22.323 34.187 c 6.024 6.935 -1.58 13.17 -1.58 13.17 s 15.289 -7.891 8.269 -17.777 c -6.559 -9.215 -11.587 -13.793 15.634 -29.58 z m 9.998 81.144 s 3.529 2.91 -3.888 5.159 c -14.102 4.272 -58.706 5.56 -71.095 .171 c -4.45 -1.938 3.899 -4.625 6.526 -5.192 c 2.739 -.593 4.303 -.485 4.303 -.485 c -4.952 -3.487 -32.013 6.85 -13.742 9.815 c 49.821 8.076 90.817 -3.637 77.896 -9.468 z M 85 77.896 c 2.395 -1.634 5.703 -3.053 5.703 -3.053 s -9.424 1.685 -18.813 2.474 c -11.494 .964 -23.823 1.154 -30.012 .326 c -14.652 -1.959 8.033 -7.348 8.033 -7.348 s -8.812 -.596 -19.644 4.644 C 17.455 81.134 61.958 83.958 85 77.896 z m 5.609 15.145 c -.108 .29 -.468 .616 -.468 .616 c 31.273 -8.221 19.775 -28.979 4.822 -23.725 c -1.312 .464 -2 1.543 -2 1.543 s .829 -.334 2.678 -.72 c 7.559 -1.575 18.389 10.119 -5.032 22.286 z M 64.181 70.069 c -4.614 -10.429 -20.26 -19.553 .007 -35.559 C 89.459 14.563 76.492 1.587 76.492 1.587 c 5.23 20.608 -18.451 26.833 -26.999 39.667 c -5.821 8.745 2.857 18.142 14.688 28.815 z m 27.274 51.748 c -19.187 3.612 -42.854 3.191 -56.887 .874 c 0 0 2.874 2.38 17.646 3.331 c 22.476 1.437 57 -.8 57.816 -11.436 c .001 0 -1.57 4.032 -18.575 7.231 z" + readonly property string kotlin: "M 112.484 112.484 H 15.516 V 15.516 h 96.968 L 64 64 Z m 0 0" + readonly property string swift: "M 125.54 26.23 a 28.78 28.78 0 0 0 -2.65 -7.58 a 28.84 28.84 0 0 0 -4.76 -6.32 a 23.42 23.42 0 0 0 -6.62 -4.55 a 27.27 27.27 0 0 0 -7.68 -2.53 c -2.65 -.51 -5.56 -.51 -8.21 -.76 H 30.25 a 45.46 45.46 0 0 0 -6.09 .51 a 21.81 21.81 0 0 0 -5.82 1.52 c -.53 .25 -1.32 .51 -1.85 .76 a 33.82 33.82 0 0 0 -5 3.28 c -.53 .51 -1.06 .76 -1.59 1.26 a 22.41 22.41 0 0 0 -4.76 6.32 a 23.61 23.61 0 0 0 -2.65 7.58 a 78.47 78.47 0 0 0 -.79 7.83 v 60.39 a 39.32 39.32 0 0 0 .79 7.83 a 28.78 28.78 0 0 0 2.65 7.58 a 28.84 28.84 0 0 0 4.76 6.32 a 23.42 23.42 0 0 0 6.62 4.55 a 27.27 27.27 0 0 0 7.68 2.53 c 2.65 .51 5.56 .51 8.21 .76 h 63.22 a 45.08 45.08 0 0 0 8.21 -.76 a 27.27 27.27 0 0 0 7.68 -2.53 a 30.13 30.13 0 0 0 6.62 -4.55 a 22.41 22.41 0 0 0 4.76 -6.32 a 23.61 23.61 0 0 0 2.65 -7.58 a 78.47 78.47 0 0 0 .79 -7.83 V 34.06 a 39.32 39.32 0 0 0 -.8 -7.83 z m -18.79 75.54 C 101 91 90.37 94.33 85 96.5 c -11.11 6.13 -26.38 6.76 -41.75 .47 A 64.53 64.53 0 0 1 13.84 73 a 50 50 0 0 0 10.85 6.32 c 15.87 7.1 31.73 6.61 42.9 0 c -15.9 -11.66 -29.4 -26.82 -39.46 -39.2 a 43.47 43.47 0 0 1 -5.29 -6.82 c 12.16 10.61 31.5 24 38.38 27.79 a 271.77 271.77 0 0 1 -27 -32.34 a 266.8 266.8 0 0 0 44.47 34.87 c .71 .38 1.26 .7 1.7 1 a 32.71 32.71 0 0 0 1.21 -3.51 c 3.71 -12.89 -.53 -27.54 -9.79 -39.67 C 93.25 33.81 106 57.05 100.66 76.51 c -.14 .53 -.29 1 -.45 1.55 l .19 .22 c 10.6 12.63 7.67 26.02 6.35 23.49 z" + readonly property string go: "M 108.2 64.8 c -.1 -.1 -.2 -.2 -.4 -.2 l -.1 -.1 c -.1 -.1 -.2 -.1 -.2 -.2 l -.1 -.1 c -.1 0 -.2 -.1 -.2 -.1 l -.2 -.1 c -.1 0 -.2 -.1 -.2 -.1 l -.2 -.1 c -.1 0 -.2 -.1 -.2 -.1 c -.1 0 -.1 0 -.2 -.1 l -.3 -.1 c -.1 0 -.1 0 -.2 -.1 l -.3 -.1 h -.1 l -.4 -.1 h -.2 c -.1 0 -.2 0 -.3 -.1 h -2.3 c -.6 -13.3 .6 -26.8 -2.8 -39.6 c 12.9 -4.6 2.8 -22.3 -8.4 -14.4 c -7.4 -6.4 -17.6 -7.8 -28.3 -7.8 c -10.5 .7 -20.4 2.9 -27.4 8.4 c -2.8 -1.4 -5.5 -1.8 -7.9 -1.1 v .1 c -.1 0 -.3 .1 -.4 .2 c -.1 0 -.3 .1 -.4 .2 h -.1 c -.1 0 -.2 .1 -.4 .2 h -.1 l -.3 .2 h -.1 l -.3 .2 h -.1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 l -.1 .1 c -.1 .1 -.2 .1 -.2 .2 l -.1 .1 l -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 c .4 .3 .4 .4 .4 .4 v .1 l -.1 .3 v .1 c 0 .1 0 .2 -.1 .3 v 3.1 c 0 .1 0 .2 .1 .3 v .1 l .1 .3 v .1 l .1 .3 s 0 .1 .1 .1 l .1 .3 s 0 .1 .1 .1 l .1 .3 s 0 .1 .1 .1 l .2 .3 s 0 .1 .1 .1 l .2 .3 s 0 .1 .1 .1 l .2 .3 l .1 .1 l .3 .3 l .3 .3 h .1 c 1 .9 2 1.6 4 2.2 v -.2 C 23 37.3 26.5 50 26.7 63 c -.6 0 -.7 .4 -1.7 .5 h -.5 c -.1 0 -.3 0 -.5 .1 c -.1 0 -.3 0 -.4 .1 l -.4 .1 h -.1 l -.4 .1 h -.1 l -.3 .1 h -.1 l -.3 .1 s -.1 0 -.1 .1 l -.3 .1 l -.2 .1 c -.1 0 -.2 .1 -.2 .1 l -.2 .1 l -.2 .1 c -.1 0 -.2 .1 -.2 .1 l -.2 .1 l -.4 .3 c -.1 .1 -.2 .2 -.3 .2 l -.4 .4 l -.1 .1 c -.1 .2 -.3 .4 -.4 .5 l -.2 .3 l -.3 .6 l -.1 .3 v .3 c 0 .5 .2 .9 .9 1.2 c .2 3.7 3.9 2 5.6 .8 l .1 -.1 c .2 -.2 .5 -.3 .6 -.3 h .1 l .2 -.1 c .1 0 .1 0 .2 -.1 c .2 -.1 .4 -.1 .5 -.2 c .1 0 .1 -.1 .1 -.2 l .1 -.1 c .1 -.2 .2 -.6 .2 -1.2 l .1 -1.3 v 1.8 c -.5 13.1 -4 30.7 3.3 42.5 c 1.3 2.1 2.9 3.9 4.7 5.4 h -.5 c -.2 .2 -.5 .4 -.8 .6 l -.9 .6 l -.3 .2 l -.6 .4 l -.9 .7 l -1.1 1 c -.2 .2 -.3 .4 -.4 .5 l -.4 .6 l -.2 .3 c -.1 .2 -.2 .4 -.2 .6 l -.1 .3 c -.2 .8 0 1.7 .6 2.7 l .4 .4 h .2 c .1 0 .2 0 .4 .1 c .2 .4 1.2 2.5 3.9 .9 c 2.8 -1.5 4.7 -4.6 8.1 -5.1 l -.5 -.6 c 5.9 2.8 12.8 4 19 4.2 c 8.7 .3 18.6 -.9 26.5 -5.2 c 2.2 .7 3.9 3.9 5.8 5.4 l .1 .1 l .1 .1 l .1 .1 l .1 .1 s .1 0 .1 .1 c 0 0 .1 0 .1 .1 c 0 0 .1 0 .1 .1 h 2.1 s .1 0 .1 -.1 h .1 s .1 0 .1 -.1 h .1 s .1 0 .1 -.1 c 0 0 .1 0 .1 -.1 l .1 -.1 s .1 0 .1 -.1 l .1 -.1 h .1 l .2 -.2 l .2 -.1 h .1 l .1 -.1 h .1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 v -.1 s 0 -.1 .1 -.1 v -.1 s 0 -.1 .1 -.1 v -.1 s 0 -.1 .1 -.1 v -1.4 s -.3 0 -.3 -.1 l -.3 -.1 v -.1 l .3 -.1 s .2 0 .2 -.1 l .1 -.1 v -2.1 s 0 -.1 -.1 -.1 v -.1 s 0 -.1 -.1 -.1 v -.1 s 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 v -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 c 2 -1.9 3.8 -4.2 5.1 -6.9 c 5.9 -11.8 4.9 -26.2 4.1 -39.2 h .1 c .1 0 .2 .1 .2 .1 h .3 s .1 0 .1 .1 h .1 s .1 0 .1 .1 l .2 .1 c 1.7 1.2 5.4 2.9 5.6 -.8 c 1.6 .6 -.3 -1.8 -1.3 -2.5 z M 36 23 C 32.8 7 58.4 4 59.3 19.6 c .8 13 -20 16.3 -23.3 3.4 z m 36.1 15 c -1.3 1.4 -2.7 1.2 -4.1 .7 c 0 1.9 .4 3.9 .1 5.9 c -.5 .9 -1.5 1 -2.3 1.4 c -1.2 -.2 -2.1 -.9 -2.6 -2 l -.2 -.1 c -3.9 5.2 -6.3 -1.1 -5.2 -5 c -1.2 .1 -2.2 -.2 -3 -1.5 c -1.4 -2.6 .7 -5.8 3.4 -6.3 c .7 3 8.7 2.6 10.1 -.2 c 3.1 1.5 6.5 4.3 3.8 7.1 z m -7 -17.5 c -.9 -13.8 20.3 -17.5 23.4 -4 c 3.5 15 -20.8 18.9 -23.4 4 z M 41.7 17 c -1.9 0 -3.5 1.7 -3.5 3.8 c 0 2.1 1.6 3.8 3.5 3.8 s 3.5 -1.7 3.5 -3.8 c 0 -2.1 -1.5 -3.8 -3.5 -3.8 z m 1.6 5.7 c -.5 0 -.8 -.4 -.8 -1 c 0 -.5 .4 -1 .8 -1 c .5 0 .8 .4 .8 1 c 0 .5 -.3 1 -.8 1 z m 27.8 -6.6 c -1.9 0 -3.4 1.7 -3.4 3.8 c 0 2.1 1.5 3.8 3.4 3.8 s 3.4 -1.7 3.4 -3.8 c 0 -2.1 -1.5 -3.8 -3.4 -3.8 z m 1.6 5.6 c -.4 0 -.8 -.4 -.8 -1 c 0 -.5 .4 -1 .8 -1 s .8 .4 .8 1 s -.4 1 -.8 1 z" + readonly property string dart: "M 86.6 25 l 3 .1 c 1.1 .1 2.2 .3 3.4 .5 l -2.5 -7.4 L 75.7 3.5 c -3.4 -3.4 -8 -4.4 -10.4 -2.3 L 29.2 25.1 l 57.4 -.1 z m 6.1 3.6 c -1.2 -.2 -2.3 -.4 -3.3 -.5 l -2.9 -.1 l -56 .1 l 78.6 78.6 l 6.1 -13.8 l -22.5 -64.3 z M 28.9 92.2 l 64.3 22.7 l 13.8 -6.1 l -78.6 -78.6 v 56.1 l .1 2.7 c 0 .9 .1 2 .4 3.2 z M 106.9 34.3 c -2.6 -2.6 -7 -5.1 -11.3 -6.5 L 118.4 93 l -6.9 15.7 l 15.8 -5.2 V 54.8 l -20.4 -20.5 z m -13.5 83.8 l -65 -22.9 c 1.4 4.3 3.8 8.7 6.5 11.4 l 21.3 21.2 l 47.6 .1 l 5.3 -16.7 l -15.7 6.9 z m -67.9 -29 l -.1 -2.7 V 28.9 L 1.7 65.1 C -.4 67.3 .7 72 4 75.5 l 14.7 14.8 l 7.3 2.6 c -.3 -1.3 -.5 -2.5 -.5 -3.8 z" + readonly property string php: "M 64 30.332 C 28.654 30.332 0 45.407 0 64 s 28.654 33.668 64 33.668 c 35.345 0 64 -15.075 64 -33.668 S 99.346 30.332 64 30.332 z m -5.982 9.81 h 7.293 v .003 l -1.745 8.968 h 6.496 c 4.087 0 6.908 .714 8.458 2.139 c 1.553 1.427 2.017 3.737 1.398 6.93 l -3.053 15.7 h -7.408 l 2.902 -14.929 c .33 -1.698 .208 -2.855 -.365 -3.473 c -.573 -.617 -1.793 -.925 -3.658 -.925 h -5.828 L 58.752 73.88 h -7.291 l 6.557 -33.738 z M 26.73 49.114 h 14.133 c 4.252 0 7.355 1.116 9.305 3.348 c 1.95 2.232 2.536 5.346 1.758 9.346 c -.32 1.649 -.863 3.154 -1.625 4.52 c -.763 1.364 -1.76 2.613 -2.99 3.745 c -1.468 1.373 -3.098 2.353 -4.891 2.936 c -1.794 .585 -4.08 .875 -6.858 .875 h -6.294 l -1.745 8.97 h -7.35 l 6.557 -33.74 z m 57.366 0 h 14.13 c 4.252 0 7.353 1.116 9.303 3.348 h .002 c 1.95 2.232 2.538 5.346 1.76 9.346 c -.32 1.649 -.861 3.154 -1.623 4.52 c -.763 1.364 -1.76 2.613 -2.992 3.745 c -1.467 1.373 -3.098 2.353 -4.893 2.936 c -1.794 .585 -4.077 .875 -6.855 .875 h -6.295 l -1.744 8.97 h -7.35 l 6.557 -33.74 z m -51.051 5.325 l -2.742 14.12 h 4.468 c 2.963 0 5.172 -.556 6.622 -1.673 c 1.45 -1.116 2.428 -2.981 2.937 -5.592 c .485 -2.507 .264 -4.279 -.666 -5.309 c -.93 -1.032 -2.79 -1.547 -5.584 -1.547 h -5.035 z m 57.363 0 l -2.744 14.12 h 4.47 c 2.965 0 5.17 -.556 6.622 -1.673 c 1.449 -1.116 2.427 -2.981 2.935 -5.592 c .487 -2.507 .266 -4.279 -.664 -5.309 c -.93 -1.032 -2.792 -1.547 -5.584 -1.547 h -5.035 z" + readonly property string ruby: "m 35.971 111.33 l 81.958 11.188 c -9.374 -15.606 -18.507 -30.813 -27.713 -46.144 Z m 89.71 -86.383 L 93.513 73.339 c -.462 .696 -1.061 1.248 -.41 2.321 c 8.016 13.237 15.969 26.513 23.942 39.777 c 1.258 2.095 2.53 4.182 4.157 6.192 l 4.834 -96.58 z M 16.252 66.22 c .375 .355 1.311 .562 1.747 .347 c 7.689 -3.779 15.427 -7.474 22.948 -11.564 c 2.453 -1.333 4.339 -3.723 6.452 -5.661 c 6.997 -6.417 13.983 -12.847 20.966 -19.278 c .427 -.395 .933 -.777 1.188 -1.275 c 2.508 -4.902 4.973 -9.829 7.525 -14.898 c -3.043 -1.144 -5.928 -2.263 -8.849 -3.281 c -.396 -.138 -1.02 .136 -1.449 .375 c -6.761 3.777 -13.649 7.353 -20.195 11.472 c -3.275 2.061 -5.943 5.098 -8.843 7.743 c -4.674 4.266 -9.342 8.542 -13.948 12.882 a 24.011 24.011 0 0 0 -3.288 3.854 c -3.15 4.587 -6.206 9.24 -9.402 14.025 c 1.786 1.847 3.41 3.613 5.148 5.259 z m 28.102 -6.271 l -11.556 48.823 l 54.3 -34.987 z m 76.631 -34.846 l -46.15 7.71 l 15.662 38.096 z M 44.996 56.644 l 41.892 13.6 c -5.25 -12.79 -10.32 -25.133 -15.495 -37.737 Z M 16.831 75.643 L 2.169 110.691 l 27.925 -.825 Z m 13.593 26.096 l .346 -.076 c 3.353 -13.941 6.754 -27.786 10.177 -42.272 L 18.544 71.035 c 3.819 9.926 7.891 20.397 11.88 30.704 z m 84.927 -78.897 c -4.459 -1.181 -8.918 -2.366 -13.379 -3.539 c -6.412 -1.686 -12.829 -3.351 -19.237 -5.052 c -.801 -.213 -1.38 -.352 -1.851 .613 c -2.265 4.64 -4.6 9.245 -6.901 13.868 c -.071 .143 -.056 .328 -.111 .687 l 41.47 -6.285 z M 89.482 12.288 l 36.343 10.054 l -6.005 -17.11 l -30.285 6.715 Z M 33.505 114.007 c -4.501 -.519 -9.122 -.042 -13.687 .037 c -3.75 .063 -7.5 .206 -11.25 .323 c -.386 .012 -.771 .09 -1.156 .506 c 31.003 2.866 62.005 5.732 93.007 8.6 l .063 -.414 l -29.815 -4.07 c -12.384 -1.691 -24.747 -3.551 -37.162 -4.982 Z M 2.782 99.994 c 3.995 -9.27 7.973 -18.546 11.984 -27.809 c .401 -.929 .37 -1.56 -.415 -2.308 c -1.678 -1.597 -3.237 -3.318 -5.071 -5.226 c -2.479 12.24 -4.897 24.177 -7.317 36.113 l .271 .127 c .185 -.297 .411 -.578 .548 -.897 z m 78.74 -90.153 c 6.737 -1.738 13.572 -3.097 20.367 -4.613 c .44 -.099 .87 -.244 1.303 -.368 l -.067 -.332 l -29.194 3.928 c 2.741 1.197 4.853 2.091 7.591 1.385 z" + readonly property string scala: "M 25.411 110.572 V 95.077 l 11.842 -.474 c 12.315 -.473 21.45 -1.488 34.847 -3.789 c 15.225 -2.639 30.246 -7.375 31.803 -10.082 c .406 -.677 .676 4.534 .676 14.616 v 15.698 l -1.76 1.353 c -1.894 1.489 -9.202 3.993 -17.524 6.09 C 72.303 121.737 40.568 126 29.742 126 h -4.33 z M 25.411 71.327 V 55.83 l 11.842 -.406 c 13.127 -.541 23.344 -1.691 36.877 -4.195 c 15.157 -2.842 28.96 -7.443 29.976 -9.947 c .203 -.473 .406 6.09 .406 14.616 c .067 13.533 -.068 15.698 -1.083 16.78 c -2.368 2.64 -20.638 7.376 -39.449 10.286 c -11.435 1.76 -30.381 3.79 -35.66 3.79 h -2.909 z M 25.411 32.352 V 17.195 l 2.098 -.406 c 1.15 -.203 3.992 -.406 6.293 -.406 c 11.367 0 38.366 -3.722 51.628 -7.105 c 9.27 -2.436 15.698 -4.872 17.931 -6.902 c 1.15 -1.015 1.218 -.406 1.218 14.48 c 0 14.548 -.067 15.63 -1.285 16.714 c -1.827 1.691 -14.345 5.548 -24.09 7.51 c -15.765 3.113 -41.951 6.429 -50.883 6.429 h -2.91 z" + readonly property string haskell: "M 0 110.2 L 30.1 65 L 0 19.9 h 22.6 L 52.7 65 l -30.1 45.1 H 0 z M 30.1 110.2 L 60.2 65 L 30.1 19.9 h 22.6 l 60.2 90.3 H 90.4 L 71.5 81.9 l -18.8 28.2 H 30.1 z M 102.9 83.8 l -10 -15.1 H 128 v 15.1 h -25.1 z M 87.8 61.3 l -10 -15.1 H 128 v 15.1 H 87.8 z" + readonly property string elixir: "M 90.1 80 c .8 -.5 1.7 -1 2.5 -1.5 c 5.6 -4.8 3.6 -13.5 -5.9 -25.7 c .2 6.1 1.2 12.4 2.2 19.1 c .4 2.6 .8 5.3 1.2 8.1 z M 36 103.2 c 3.2 13.7 11.3 21.1 20.5 24.2 c 5.1 .9 10.2 .7 15.3 -.4 c .8 -.4 1.5 -.9 2.2 -1.4 c 2 -1.3 3.7 -2.8 5.3 -4.3 c -6.5 -2.8 -13.1 -8.2 -19.3 -14.6 c -4.8 -.3 -9.5 -.8 -13.6 -1.4 c -4 -.4 -7.4 -1.1 -10.4 -2.1 z m -1.4 -64.5 l -.2 1.4 c -1.4 7.9 -1.5 15 -.4 21.2 c 1.1 -7.4 2.6 -15 4.7 -22.3 c .3 -1.1 .6 -2.3 1 -3.5 h .1 c 1.6 -5 3.4 -9.8 5.5 -14.3 c -4 5.3 -7.5 11.2 -10.7 17.5 z M 33.9 114 c 3.5 4.4 7.5 7.6 11.9 9.9 c -5.6 -4.6 -10 -11.5 -12 -21.4 c -3.8 -1.4 -6.8 -3.3 -9.1 -5.7 c 1.6 6.4 4.6 12.3 9.2 17.2 z m -.6 -47 c -1.8 -5.9 -2.4 -12.7 -1.8 -20.4 c -1.1 2.3 -2.1 4.8 -3.2 7.3 c -4.5 13.1 -6.5 26.7 -4.6 38.7 c 2.1 3.2 5.2 5.8 9.6 7.6 c -1.4 -7.7 -1.5 -19.9 0 -33.2 z m 2.2 33.8 c 0 .1 0 .1 0 0 c 3.2 1.2 6.8 2 11.1 2.5 c 3.5 .6 7.3 1 11.3 1.3 c -8.3 -9.1 -15.6 -19.8 -20.3 -28.6 c -1.1 -1.6 -2 -3.3 -2.8 -5 c -1.1 12.2 -.8 23.1 .7 29.8 z m 51.4 -28.7 c -1.1 -7.5 -2.3 -15.1 -2.3 -22.3 C 71.9 38 59.6 25.4 60.1 5.1 c -.4 .3 -.8 .6 -1.1 .9 c -2.2 1.9 -4.4 4 -6.5 6.3 c -4.5 6.4 -8.1 14.6 -10.9 23.5 c -1.6 11.6 9.6 34.3 24.5 51.7 c 7.2 -.3 14.8 -2.5 22.1 -6.5 c -.4 -2.9 -.8 -5.9 -1.3 -8.9 z m 10 39.7 c -2.6 -.5 -5.2 -1.4 -7.8 -2.6 c -1.3 4.1 -3.4 7.9 -6.4 11.4 c 1.1 .3 2.1 .6 3.1 .7 c 4.2 -2.5 8 -5.8 11.1 -9.5 z M 62.1 3.6 C 61 23.1 72.2 35.5 84.4 46.8 C 73.7 32.6 67.5 24.1 67.5 .3 c -1.7 .9 -3.5 2 -5.4 3.3 z m 21.1 102.2 c -5.3 .8 -10.8 1.1 -16.2 1.1 c -1.4 0 -2.9 0 -4.3 -.1 c 5.8 5.8 12 10.6 18 13 c 3.2 -3.5 5.3 -7.4 6.6 -11.6 c -1.4 -.7 -2.8 -1.5 -4.1 -2.4 z m -2.4 -1.6 c -2 -1.4 -3.8 -2.9 -5.5 -4.4 c -3.5 -3.1 -6.9 -6.5 -10.1 -10.2 c -7.1 .1 -13.7 -1.6 -19.1 -5.1 c -.8 -.5 -1.6 -1.1 -2.3 -1.7 c 4.6 7.3 10.5 15.3 16.9 22 c 6.5 .3 13.5 .2 20.1 -.6 z m 9.6 .2 c -.2 1 -.4 1.9 -.7 2.9 c 2.8 1.4 5.7 2.4 8.6 2.7 c 2.5 -3.4 4.5 -7.2 5.7 -11.3 c -3.7 2.5 -8.4 4.4 -13.6 5.7 z M 47.2 82.8 c 4.7 3 10.3 4.6 16.3 4.8 c -12.3 -14.6 -21.8 -32.4 -23.7 -45 c -2.3 8.9 -3.1 14.4 -4.4 23.6 v .2 c .9 2.3 2.1 5.1 3.7 8.1 c 2.1 3.4 4.8 6.2 8.1 8.3 z m 41.3 .4 c -6.8 3.7 -13.9 5.8 -20.7 6.3 c 2.9 3.2 5.8 6.2 8.8 8.8 c 2.1 1.9 4.5 3.8 7.1 5.5 c 1.7 -.3 3.3 -.6 4.9 -.9 c 1 -6.3 .7 -12.9 -.1 -19.7 z m 2.2 19.1 c 5.6 -1.5 10.5 -3.6 14.1 -6.6 c 1.5 -6.9 1 -14.4 -2.3 -22.4 c -2.9 -4.9 -5.7 -9.2 -8.3 -13 c 4.8 8.8 4.7 15.2 -.4 19.6 l -.1 .1 c -1.1 .7 -2.2 1.4 -3.4 2.1 c .9 6.9 1.3 13.8 .4 20.2 z" + readonly property string erlang: "M 18.2 24.1 L 1 24 v 80 h 19.7 v -.1 C 11 93.6 5.2 79.2 5.3 62.1 C 5.2 47 10 33.9 18.2 24.1 z m 92.9 79.8 z M 127 24 h -16.4 c 6.2 9 9.6 19.3 9.1 32.1 c .1 1.2 .1 1.9 0 4.9 H 46.3 c 0 22 7.7 38.3 27.3 38.4 c 13.5 -.1 23.2 -10.1 29.9 -20.9 l 19 9.5 c -3.4 6.1 -7.2 11 -11.4 16 H 127 V 24 z m -16.5 .1 z m -45.4 1.5 c -9 0 -16.8 7.4 -17.6 16.4 H 81 c -.3 -9 -6.8 -16.4 -15.9 -16.4 z" + readonly property string clojure: "M 64 0 C 28.712 0 0 28.6 0 63.751 c 0 35.155 28.712 63.753 64 63.753 s 64 -28.598 64 -63.753 C 128 28.6 99.288 0 64 0 M 61.659 64.898 a 265.825 265.825 0 0 0 -1.867 4.12 c -2.322 5.241 -4.894 11.62 -5.834 15.706 c -.337 1.455 -.546 3.258 -.542 5.258 c 0 .79 .043 1.622 .11 2.469 a 30.74 30.74 0 0 0 10.533 1.87 a 30.796 30.796 0 0 0 9.642 -1.566 a 18.09 18.09 0 0 1 -2.011 -2.12 c -4.11 -5.221 -6.403 -12.872 -10.031 -25.737 M 46.485 38.96 c -7.85 5.51 -12.986 14.6 -13.005 24.9 c .019 10.145 5.001 19.116 12.653 24.65 c 1.877 -7.789 6.582 -14.92 13.637 -29.214 a 114.691 114.691 0 0 0 -1.43 -3.72 c -1.955 -4.884 -4.776 -10.556 -7.294 -13.124 c -1.283 -1.342 -2.84 -2.502 -4.561 -3.492 M 90.697 98.798 c -4.05 -.506 -7.392 -1.116 -10.317 -2.144 a 36.708 36.708 0 0 1 -16.32 3.807 c -20.293 0 -36.742 -16.383 -36.745 -36.602 c 0 -10.97 4.852 -20.805 12.528 -27.512 c -2.053 -.495 -4.194 -.783 -6.38 -.779 c -10.782 .101 -22.162 6.044 -26.9 22.095 c -.443 2.337 -.337 4.103 -.337 6.197 c 0 31.818 25.895 57.613 57.835 57.613 c 19.561 0 36.841 -9.682 47.305 -24.489 c -5.66 1.405 -11.103 2.077 -15.763 2.091 c -1.747 0 -3.387 -.093 -4.906 -.277 M 79.829 87.634 c .357 .176 1.167 .464 2.293 .783 c 7.579 -5.542 12.504 -14.469 12.523 -24.558 h -.003 c -.028 -16.82 -13.693 -30.43 -30.582 -30.462 a 30.765 30.765 0 0 0 -9.602 1.554 c 6.21 7.05 9.196 17.127 12.084 28.148 l .005 .013 c .005 .009 .924 3.06 2.501 7.11 c 1.566 4.042 3.797 9.048 6.23 12.696 c 1.597 2.444 3.354 4.2 4.551 4.716 M 17.057 30.311 c 5.463 -3.408 11.04 -4.637 15.908 -4.593 c 6.722 .02 12.008 2.096 14.544 3.516 c .612 .352 1.194 .73 1.764 1.12 a 36.714 36.714 0 0 1 14.786 -3.096 c 20.295 .003 36.747 16.386 36.75 36.601 c -.003 10.192 -4.188 19.408 -10.934 26.044 a 45.3 45.3 0 0 0 5.225 .29 c 6.406 .004 13.329 -1.404 18.52 -5.753 c 3.384 -2.84 6.22 -6.998 7.792 -13.233 c .307 -2.408 .484 -4.856 .484 -7.347 c 0 -31.817 -25.892 -57.614 -57.835 -57.614 c -19.372 0 -36.508 9.5 -47.004 24.065 z" + readonly property string r: "M 64 14.6465 v .002 c -35.346 0 -64 19.1902 -64 42.8632 c 0 20.764 22.0464 38.0766 51.3164 42.0176 v -12.83 c -15.55 -4.89 -26.166 -14.6943 -26.166 -25.9923 c 0 -16.183 21.7795 -29.3027 48.6465 -29.3027 c 26.866 0 46.6914 8.9748 46.6914 29.3027 c 0 10.486 -5.2715 17.9507 -14.0645 22.7207 c 1.204 .908 2.2184 2.073 2.9024 3.42 l .3886 .6543 C 121.0248 79.772 128 69.1888 128 57.5098 c 0 -23.672 -28.654 -42.8633 -64 -42.8633 z M 52.7363 41.2637 v 72.084 l 21.834 -.0098 l -.0039 -28.2188 h 5.8613 c 1.199 0 1.7167 .3481 2.9297 1.3301 c 1.454 1.177 3.8164 5.2383 3.8164 5.2383 l 11.5371 21.666 l 24.6739 -.0097 l -15.2657 -25.7403 a 8.388 8.388 0 0 0 -1.4199 -2.041 c -.974 -1.036 -2.3255 -1.8227 -3.1055 -2.2188 c -2.249 -1.1375 -6.12 -2.3076 -6.123 -2.3085 c 0 0 19.08 -1.4151 19.08 -20.4141 c 0 -18.999 -19.9706 -19.3574 -19.9706 -19.3574 H 52.7363 z m 22.0176 15.627 l 13.2188 .0077 s 6.123 -.3302 6.123 6.0098 c 0 6.216 -6.123 6.2344 -6.123 6.2344 l -13.2247 .0039 l .006 -12.2559 z m 9.3457 32.6366 c -2.612 .257 -5.3213 .411 -8.1133 .463 l .002 9.6288 a 88.362 88.362 0 0 0 12.4746 -2.4902 l -.502 -.9414 c -.68 -1.268 -1.3472 -2.5426 -2.0332 -3.8066 a 41.01 41.01 0 0 0 -1.828 -2.8516 v -.002 z" + readonly property string perl: "M 53.343 127.515 c -13.912 -2.458 -25.845 -8.812 -35.707 -19.004 C 9.252 99.845 3.48 88.926 .851 76.764 C -.284 71.51 -.284 56.477 .85 51.222 c 1.776 -8.219 5.228 -16.388 9.927 -23.509 c 3.112 -4.71 12.227 -13.825 16.938 -16.937 c 7.121 -4.698 15.292 -8.15 23.511 -9.925 c 5.256 -1.135 20.29 -1.135 25.546 0 c 12.809 2.769 23.454 8.553 32.638 17.736 c 9.188 9.187 14.969 19.827 17.738 32.635 c 1.135 5.255 1.135 20.287 0 25.542 c -2.769 12.808 -8.55 23.452 -17.738 32.635 c -9.043 9.042 -19.55 14.81 -32.146 17.652 c -4.469 1.005 -19.24 1.295 -23.922 .464 z m 11.565 -12.772 c 0 -4.194 -.06 -4.496 -.908 -4.496 c -.84 0 -.904 .29 -.868 3.899 c .04 4.262 .34 5.574 1.207 5.284 c .404 -.134 .57 -1.494 .57 -4.687 z m -6.758 1.445 c 1.196 -1.194 1.543 -1.917 1.543 -3.209 c 0 -1.315 -.162 -1.634 -.763 -1.517 c -.416 .08 -.92 .759 -1.114 1.505 c -.198 .751 -1.002 1.906 -1.785 2.572 c -1.417 1.194 -1.47 2.191 -.121 2.191 c .384 0 1.393 -.694 2.24 -1.542 z m 14.945 1.05 c .166 -.271 -.339 -1.037 -1.126 -1.699 c -.783 -.666 -1.587 -1.821 -1.784 -2.572 c -.194 -.746 -.699 -1.425 -1.115 -1.505 c -.601 -.117 -.763 .202 -.763 1.517 c 0 2.608 3.747 5.942 4.788 4.259 z m -20.66 -8.146 c 0 -.262 -.635 -.823 -1.41 -1.247 c -5.058 -2.769 -10.984 -7.177 -14.282 -10.612 c -6.435 -6.704 -9.33 -13.385 -9.402 -21.676 c -.044 -5.542 .67 -8.432 3.367 -13.607 c 2.608 -5 5.631 -8.779 13.947 -17.42 c 9.29 -9.648 11.429 -12.195 13.043 -15.53 c 1.147 -2.369 1.296 -3.232 1.458 -8.238 c .197 -6.216 -.182 -10.506 -.929 -10.506 c -.339 0 -.403 1.614 -.21 5.235 c .622 11.593 -1.53 15.19 -14.892 24.88 c -9.2 6.677 -13.422 10.302 -16.612 14.261 c -4.517 5.615 -6.52 10.471 -7.02 17.054 c -1.207 15.868 8.85 29.628 26.591 36.385 c 3.916 1.49 6.35 1.881 6.35 1.021 z m 30.696 -1.287 c 6.1 -2.539 10.738 -5.611 15.11 -10.007 c 6.665 -6.7 9.442 -12.965 9.858 -22.24 c .363 -8.134 -1.405 -13.515 -6.439 -19.61 c -3.447 -4.173 -7.161 -7.16 -17.173 -13.812 c -13.47 -8.95 -16.632 -12.513 -16.632 -18.746 c 0 -1.659 .299 -4.004 .662 -5.219 c .622 -2.066 .606 -3.491 -.02 -1.857 c -.593 1.546 -1.946 .836 -2.676 -1.408 l -.703 -2.156 l .267 2.043 c .94 7.241 1.061 10.272 .641 16.614 c -.56 8.565 -1.614 14.426 -4.505 25.074 c -2.87 10.572 -3.387 14.402 -3.031 22.475 c .298 6.826 1.255 11.932 3.475 18.592 c 2.06 6.188 2.443 6.656 6.23 7.625 c 2.086 .533 4.06 1.433 5.63 2.567 c 1.474 1.066 2.952 1.76 3.78 1.776 c .75 .012 3.237 -.759 5.526 -1.711 z m -1.369 -3.076 c -.565 -.565 -.302 -1.046 1.91 -3.492 c 6.972 -7.697 10.096 -15.645 10.185 -25.906 c .06 -6.995 -1.482 -11.625 -6.197 -18.592 c -2.135 -3.152 -9.636 -11.011 -13.265 -13.893 c -2.664 -2.115 -5.397 -5.72 -5.886 -7.762 c -.496 -2.067 .888 -1.522 2.495 .985 c .787 1.227 2.495 3.027 3.79 4 c 1.297 .977 5.132 3.834 8.523 6.357 c 11.666 8.67 16.858 16.065 18.024 25.668 c .679 5.558 -.395 11.302 -3.108 16.634 c -2.81 5.526 -7.937 11.545 -12.325 14.479 c -2.7 1.8 -3.552 2.115 -4.146 1.522 z m -22.836 .585 c .133 -.343 -1.034 -2.535 -2.592 -4.872 c -4.13 -6.192 -5.926 -9.61 -7.602 -14.454 c -1.413 -4.09 -1.49 -4.646 -1.501 -10.887 c -.016 -9.433 1.005 -12.424 8.49 -24.848 c 7.056 -11.722 8.013 -16.259 7.217 -34.286 c -.286 -6.462 -.61 -11.839 -.718 -11.948 c -.747 -.746 -.904 1.167 -.63 7.665 c .549 12.941 -.287 20.15 -3.016 26.064 c -1.857 4.024 -3.936 7.076 -9.53 14.002 c -7.788 9.64 -9.984 14.75 -9.944 23.125 c .029 5.744 .808 9.276 3.129 14.188 c 2.51 5.316 7.133 10.685 12.926 15.012 c 2.669 1.99 3.391 2.228 3.77 1.239 z" + readonly property string zig: "M 38.484 23.843 l -15.06 18.405 l -7.529 -11.712 z M 38.484 23.843 l -10.876 9.203 l -4.183 9.202 h -5.02 v 42.667 h 7.53 l -9.203 4.183 l -6.693 14.222 H 0 V 23.843 z M 25.935 84.915 L 10.039 103.32 l -6.693 -9.202 z M 46.85 23.843 l 5.02 11.713 l -20.916 6.692 z M 46.85 23.843 h 46.013 v 18.405 H 30.954 L 46.85 32.21 z M 97.046 84.915 L 81.15 103.32 l -5.856 -10.875 z M 97.046 84.915 l -13.386 7.53 l -2.51 10.875 H 35.137 V 84.915 z M 125.49 5.438 L 43.503 103.32 L 2.51 122.562 l 81.987 -98.719 z M 117.96 23.843 l -.836 15.06 l -15.059 4.182 z M 128 23.843 v 79.477 H 88.68 l 11.712 -10.039 l 4.183 -8.366 h 5.02 v -41.83 h -7.53 l 8.366 -7.53 l 7.53 -11.712 z M 104.575 84.915 l 4.183 12.55 l -20.078 5.855 z" + readonly property string nim: "M 64.508 20.135 v .004 s -4.905 3.873 -9.906 7.726 a 70.222 70.222 0 0 0 -20.696 2.975 c -5.028 -3.2 -9.463 -6.715 -9.463 -6.715 s -3.78 6.505 -6.158 10.322 a 52.032 52.032 0 0 0 -10.22 6.776 C 4.393 39.773 .136 37.989 0 37.943 c 4.86 9.806 8.129 19.622 17.016 25.524 c 14.171 -22.35 79.908 -20.294 94.35 -.13 c 9.32 -4.881 12.977 -15.335 16.634 -25.026 c -.402 .132 -5.398 1.804 -8.635 3.039 a 52.521 52.521 0 0 0 -9.08 -6.903 c -2.455 -4.498 -6.03 -10.574 -6.03 -10.574 s -4.237 3.151 -9.142 6.584 a 97.211 97.211 0 0 0 -21.398 -2.342 c -4.572 -3.776 -9.207 -7.98 -9.207 -7.98 z m 59.373 38.468 a 61.161 61.161 0 0 1 -21.028 17.686 a 55.85 55.85 0 0 1 -13.636 3.625 L 64.232 66.97 L 39.09 79.654 a 71.675 71.675 0 0 1 -13.637 -3.492 a 64.347 64.347 0 0 1 -20.424 -17.4 l 11.674 28.275 c 20.274 26.743 72.042 28.603 94.63 .516 c 5.338 -12.037 12.548 -28.95 12.548 -28.95 z" + readonly property string ocaml: "M 65.004 115.355 c -.461 -.894 -1.004 -2.796 -1.356 -3.601 c -.378 -.711 -1.46 -2.692 -1.984 -3.332 c -1.164 -1.332 -1.437 -1.438 -1.809 -3.23 c -.628 -3.067 -2.148 -8.462 -4.042 -12.227 c -1.004 -2 -2.626 -3.606 -4.067 -5.07 c -1.246 -1.247 -4.121 -3.31 -4.668 -3.227 c -4.766 .894 -6.226 5.586 -8.457 9.27 c -1.27 2.062 -2.516 3.769 -3.52 5.937 c -.898 1.98 -.812 4.23 -2.331 5.938 a 15.44 15.44 0 0 0 -3.333 5.855 c -.195 .453 -.546 4.957 -1.003 6.016 l 7.02 -.438 c 6.585 .461 4.687 2.961 14.858 2.438 l 16.098 -.54 a 24.864 24.864 0 0 0 -1.433 -3.792 z M 111.793 8.254 H 16.207 C 7.312 8.23 .086 15.457 .086 24.352 v 35.105 c 2.352 -.812 5.578 -5.75 6.668 -6.934 c 1.789 -2.062 2.16 -4.77 3.059 -6.378 c 2.062 -3.793 2.433 -6.477 7.101 -6.477 c 2.164 0 3.063 .516 4.5 2.516 c .996 1.332 2.79 3.957 3.602 5.668 c 1.004 1.98 2.523 4.582 3.254 5.125 c .515 .351 .972 .722 1.433 .894 c .707 .27 1.356 -.27 1.902 -.629 c .622 -.539 .895 -1.52 1.52 -2.953 c .895 -2.086 1.813 -4.418 2.332 -5.312 c .914 -1.461 1.273 -3.254 2.25 -4.067 c 1.461 -1.246 3.441 -1.355 3.957 -1.437 c 2.98 -.625 4.336 1.437 5.777 2.707 c .973 .894 2.243 2.605 3.246 4.851 c .708 1.793 1.606 3.52 2.067 4.5 c .351 .98 1.266 2.606 1.789 4.582 c .543 1.711 1.809 3.067 2.352 3.961 c 0 0 .812 2.164 5.476 4.145 a 34.992 34.992 0 0 0 4.336 1.52 c 2.066 .734 4.047 .644 6.563 .374 c 1.789 0 2.793 -2.625 3.601 -4.683 c .438 -1.254 .98 -4.774 1.25 -5.758 c .27 -.996 -.437 -1.707 .192 -2.625 c .722 -.977 1.164 -1.082 1.519 -2.332 c .914 -2.793 5.957 -2.875 8.832 -2.875 c 2.414 0 2.063 2.332 6.125 1.52 c 2.336 -.434 4.586 .273 7.023 .995 c 2.063 .543 4.043 1.168 5.204 2.524 c .73 .898 2.629 5.312 .73 5.476 c .164 .188 .36 .645 .625 .817 c -.46 1.707 -2.25 .46 -3.332 .27 c -1.355 -.27 -2.332 0 -3.684 .624 c -2.335 .996 -5.668 .918 -7.726 2.625 c -1.715 1.438 -1.715 4.582 -2.543 6.371 c 0 0 -2.254 5.696 -6.996 9.192 c -1.278 .914 -3.715 3.058 -8.918 3.871 c -2.356 .355 -4.586 .355 -7.024 .27 c -1.164 -.079 -2.332 -.079 -3.52 -.079 c -.706 0 -3.062 -.109 -2.96 .164 l -.27 .645 c .024 .29 .063 .602 .164 .895 c .102 .515 .102 .976 .192 1.437 c 0 .98 -.086 2.063 0 3.066 c .082 2.063 .894 3.957 1.004 6.102 c .078 2.355 1.246 4.875 2.414 6.77 c .46 .707 1.086 .789 1.355 1.71 c .352 .98 0 2.141 .188 3.227 c .625 4.227 1.875 8.73 3.773 12.61 v .078 c 2.332 -.352 4.77 -1.247 7.836 -1.684 c 5.664 -.832 13.5 -.461 18.54 -.914 c 12.796 -1.168 19.706 5.226 31.148 2.601 V 24.336 c -.063 -8.895 -7.293 -16.102 -16.207 -16.102 z M 64.086 83.855 c 0 -.187 0 -.187 0 0 z m -34.457 14.75 c .894 -1.98 1.433 -4.125 2.144 -6.101 c .73 -1.899 1.813 -4.61 3.684 -5.582 c -.246 -.274 -3.957 -.375 -4.934 -.461 c -1.082 -.086 -2.171 -.273 -3.25 -.438 a 135.241 135.241 0 0 1 -6.125 -1.265 c -1.168 -.274 -5.21 -1.715 -6.02 -2.067 c -2.085 -.894 -3.421 -3.52 -4.96 -3.246 c -.977 .188 -1.98 .54 -2.605 1.54 c -.543 .812 -.731 2.242 -1.083 3.226 c -.437 1.086 -1.168 2.164 -1.707 3.25 c -1.277 1.875 -3.332 3.582 -4.23 5.484 c -.191 .457 -.27 .895 -.457 1.356 v 21.683 c 1.082 .188 2.16 .371 3.328 .73 c 8.996 2.438 11.164 2.606 19.98 1.63 l .813 -.11 c .625 -1.437 1.188 -6.207 1.629 -7.644 c .352 -1.164 .812 -2.063 .996 -3.14 c .164 -1.09 0 -2.173 -.102 -3.15 c -.171 -2.628 1.895 -3.519 2.899 -5.69 z m 0 0" + readonly property string fsharp: "M 0 64.5 L 60.7 3.8 v 30.4 L 30.4 64.5 l 30.4 30.4 v 30.4 L 0 64.5 z m 39.1 0 l 21.7 -21.7 v 43.4 L 39.1 64.5 z m 88.9 0 L 65.1 3.8 v 30.4 l 30.4 30.4 l -30.4 30.3 v 30.4 L 128 64.5 z" + readonly property string visualbasic: "M 64 0 A 64 64 0 0 0 0 64 a 64 64 0 0 0 64 64 a 64 64 0 0 0 64 -64 A 64 64 0 0 0 64 0 z m -3.76 38.7 l 6.34 .1 L 48 89.202 h -6.58 L 23.14 38.8 h 6.579 l 14 40 a 23.74 23.74 0 0 1 1.02 4.46 h .141 a 22 22 0 0 1 1.119 -4.56 z m 13.6 .1 h 14.34 A 15.68 15.68 0 0 1 98.54 42 a 10.34 10.34 0 0 1 3.84 8.34 a 12.26 12.26 0 0 1 -2.38 7.44 a 12.52 12.52 0 0 1 -6.4 4.501 v .139 a 12.82 12.82 0 0 1 8.16 3.84 a 11.84 11.84 0 0 1 3.06 8.461 a 13.18 13.18 0 0 1 -4.64 10.48 a 17.28 17.28 0 0 1 -11.7 4 H 73.84 z m 12.7 5.26 l -6.7 .06 V 60.4 h 5.999 a 11.48 11.48 0 0 0 7.58 -2.4 a 8.14 8.14 0 0 0 2.781 -6.6 c 0 -4.893 -3.22 -7.34 -9.66 -7.34 z m -6.7 21.641 v 18.14 h 8 a 12 12 0 0 0 8 -2.46 a 8.42 8.42 0 0 0 2.86 -6.74 c 0 -5.947 -4.053 -8.92 -12.16 -8.92 z" + readonly property string fortran: "M 18.969 0 C 13.25 0 0 11 0 18.66 v 90.453 c 0 5.692 11.21 18.903 18.781 18.903 l 90.551 -.032 c 6.738 -.004 18.688 -9.683 18.688 -18.601 V 18.84 c 0 -6.078 -10.61 -18.832 -18.43 -18.832 L 18.969 0 z m -1.395 13.66 h 93.367 v 41.711 l -10.992 -.164 c -.101 -.098 -.402 -3.047 -.605 -5.758 C 98.19 36.7 95.328 29.363 89.809 26.5 c -2.914 -1.504 -7.457 -1.95 -22.02 -1.953 l -13.57 .004 v 31.273 h 2.41 c 4.066 -.05 9.234 -1.004 10.941 -2.058 c 2.211 -1.356 4.067 -5.27 4.72 -9.989 c .491 -3.445 .87 -6.023 .87 -6.023 h 10.676 v 49.691 H 72.793 v -1.957 c 0 -3.21 -1.508 -10.691 -2.563 -12.949 c -1.656 -3.465 -4.464 -4.668 -12.449 -5.422 l -3.664 -.351 l .203 16.113 c .149 15.308 .25 16.164 1.203 17.469 c 1.207 1.605 2.512 1.906 10.493 2.507 l 5.355 .258 l -.035 10.938 H 17.574 v -10.942 l 4.922 -.304 c 9.988 -.653 9.887 -.602 10.39 -8.43 c .45 -7.43 -.116 -65.598 -.452 -66.762 c -.551 -1.922 -2.618 -3.027 -8.786 -3.023 l -6.074 -.04 V 13.66 z" + readonly property string crystal: "m 127.806 81.328 l -46.325 45.987 c -.185 .185 -.464 .276 -.65 .185 l -63.283 -16.863 c -.279 -.095 -.464 -.28 -.464 -.464 L .035 47.317 c -.09 -.275 0 -.46 .186 -.645 L 46.55 .685 c .184 -.185 .463 -.276 .649 -.185 l 63.28 16.863 c .278 .095 .463 .28 .463 .464 L 127.9 80.682 c .185 .275 .09 .46 -.094 .645 z M 65.726 31.28 L 3.557 47.778 c -.095 0 -.185 .185 -.095 .28 l 45.495 45.156 c .09 .095 .28 .095 .28 -.09 l 16.675 -61.748 c .095 0 -.09 -.185 -.184 -.094 z m 0 0" + readonly property string gleam: "M 56.61 .014 c -.41 .021 -.818 .067 -1.221 .138 v .002 c -3.23 .57 -6.182 2.714 -7.405 6.178 l -9.523 26.98 a 9.599 9.599 0 0 1 -5.553 5.745 L 6.244 49.504 C -.598 52.184 -1.923 61.596 3.91 66.05 l 22.746 17.37 a 9.582 9.582 0 0 1 3.75 7.047 l 1.696 28.553 c .434 7.326 8.978 11.493 15.033 7.323 v -.002 l 23.58 -16.244 a 9.602 9.602 0 0 1 7.87 -1.389 l 27.714 7.2 c 7.116 1.848 13.72 -4.99 11.623 -12.022 l -8.17 -27.412 a 9.579 9.579 0 0 1 1.113 -7.905 l 15.432 -24.103 c 3.958 -6.182 -.503 -14.573 -7.85 -14.752 l -28.63 -.695 a 9.596 9.596 0 0 1 -7.182 -3.499 L 64.459 3.426 C 62.418 .944 59.477 -.137 56.609 .014 z m 30.435 55.513 a 5.082 5.082 0 1 1 .222 10.161 a 5.082 5.082 0 0 1 -.222 -10.16 z M 46.1 62.747 a 5.083 5.083 0 0 1 .992 10.085 a 5.082 5.082 0 1 1 -.992 -10.086 z m 26.283 7.382 a 2.59 2.59 0 0 1 1.812 .799 a 2.59 2.59 0 0 1 .713 1.847 a 6.723 6.723 0 0 1 -2.088 4.721 h -.002 a 6.733 6.733 0 0 1 -2.22 1.41 h -.002 a 6.75 6.75 0 0 1 -2.59 .451 h -.002 a 6.74 6.74 0 0 1 -2.567 -.576 h -.001 a 6.758 6.758 0 0 1 -3.563 -3.736 a 2.594 2.594 0 0 1 .63 -2.805 a 2.59 2.59 0 0 1 1.847 -.715 a 2.595 2.595 0 0 1 2.35 1.655 c .075 .192 .186 .368 .328 .517 v .002 c .143 .149 .312 .266 .5 .35 v .002 h .002 a 1.576 1.576 0 0 0 1.201 .03 l .002 -.003 a 1.552 1.552 0 0 0 .868 -.828 h .002 v -.002 c .083 -.188 .127 -.389 .132 -.594 a 2.593 2.593 0 0 1 1.653 -2.353 c .317 -.122 .654 -.18 .994 -.172 z" + readonly property string julia: "M 0.0 94.2 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z M 69.80000000000001 94.2 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z M 34.9 33.8 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z" + readonly property string objectivec: "M 63.877 125.392 c -32.671 0 -60.37 -27.594 -60.627 -60.469 a 59.94 59.94 0 0 1 17.506 -42.759 a 60.939 60.939 0 0 1 43.279 -18.36 a 60.081 60.081 0 0 1 42.647 17.71 a 60.145 60.145 0 0 1 18.157 42.522 c .151 33.604 -26.864 61.021 -60.469 61.363 h -.493 z m .19 -118.406 a 57.774 57.774 0 0 0 -41.01 17.427 a 56.775 56.775 0 0 0 -16.63 40.484 c .236 31.159 26.495 57.286 57.43 57.286 h .414 c 31.863 -.29 57.504 -26.266 57.385 -58.128 a 56.97 56.97 0 0 0 -17.217 -40.273 A 56.7 56.7 0 0 0 64.068 6.986 z M 16.89 82.383 V 46.865 h 8.64 v 3.183 h -4.583 v 29.218 h 4.584 v 3.183 l -8.642 -.066 z M 46.213 64.272 c 0 6.478 -3.933 10.167 -9.26 10.167 s -8.877 -4.156 -8.877 -9.831 c 0 -5.939 3.722 -10.121 9.167 -10.121 s 8.97 4.36 8.97 9.785 z m -14.415 .29 c 0 3.932 1.973 7.05 5.36 7.05 s 5.333 -3.183 5.333 -7.195 c 0 -3.643 -1.796 -7.083 -5.334 -7.083 s -5.392 3.328 -5.392 7.307 l .033 -.08 z M 49.205 55.158 c 1.69 -.29 3.407 -.434 5.123 -.428 a 9.17 9.17 0 0 1 5.537 1.223 a 4.062 4.062 0 0 1 2.006 3.61 a 4.48 4.48 0 0 1 -3.183 4.183 c 2.269 .46 3.9 2.46 3.9 4.775 a 5.016 5.016 0 0 1 -1.861 3.978 c -1.368 1.21 -3.643 1.796 -7.162 1.796 a 33.966 33.966 0 0 1 -4.327 -.257 l -.033 -18.88 z m 3.499 7.622 h 1.795 c 2.433 0 3.801 -1.145 3.801 -2.782 c 0 -1.638 -1.368 -2.644 -3.61 -2.644 a 9.779 9.779 0 0 0 -2.006 .145 l .02 5.28 z m 0 8.878 c .618 .065 1.243 .092 1.86 .078 c 2.263 0 4.262 -.861 4.262 -3.182 s -1.94 -3.183 -4.373 -3.183 h -1.75 v 6.287 z M 69.54 54.901 h 3.517 v 12.554 c 0 5.334 -2.577 7.116 -6.365 7.116 a 9.313 9.313 0 0 1 -2.973 -.507 l .428 -2.834 c .703 .224 1.44 .335 2.183 .349 c 2.006 0 3.183 -.921 3.183 -4.262 l .026 -12.416 z M 83.067 65.357 v 2.434 h -7.32 v -2.434 h 7.32 z M 100.158 73.63 c -1.585 .632 -3.281 .921 -4.978 .862 c -6.129 0 -9.851 -3.834 -9.851 -9.707 c -.283 -5.353 3.827 -9.923 9.18 -10.206 c .375 -.02 .757 -.02 1.131 .006 a 11.112 11.112 0 0 1 4.775 .862 l -.783 2.801 a 9.476 9.476 0 0 0 -3.788 -.75 c -3.932 0 -6.76 2.467 -6.76 7.116 c 0 4.235 2.499 6.971 6.734 6.971 a 9.806 9.806 0 0 0 3.834 -.717 l .506 2.762 z M 111.2 46.766 v 35.61 h -8.641 v -3.182 h 4.583 V 49.949 h -4.583 v -3.183 h 8.64 z" + readonly property string vala: "m 62.959 32.051 c -7.551 0 -14.777 2.4271 -21.676 7.2832 c -3.0202 2.1912 -5.4913 4.7082 -7.416 7.5508 c -1.8951 2.813 -2.8438 5.745 -2.8438 8.7949 c 0 1.6581 0.20849 3.0486 0.62305 4.1738 c 1.214 3.1091 4.2474 4.6641 9.1035 4.6641 c 0 -0.17764 -0.072563 -0.38417 -0.2207 -0.62109 c -0.68102 -1.2437 -1.0215 -3.0951 -1.0215 -5.5527 c 1e-6 -5.0633 1.1839 -9.2979 3.5527 -12.703 c 2.3984 -3.4052 5.7302 -5.907 9.9941 -7.5059 l 1.4648 62.137 h 13.102 l 25.273 -67.777 h -6.3516 l -19.143 55.697 l -0.80078 -55.963 c -1.1844 -0.11892 -2.3969 -0.17774 -3.6406 -0.17774 z" + readonly property string groovy: "M 57.27 43.147 c -6.273 10.408 -6.633 10.955 -7.504 11.382 c -.78 .383 -.97 .407 -1.287 .164 c -1.296 -.996 -3.031 -.705 -4.248 .712 l -.676 .787 l -.143 -.843 c -.223 -1.318 -.299 -1.505 -.842 -2.092 c -.506 -.545 -.508 -.564 -.214 -2.21 c .364 -2.04 .385 -3.53 .071 -5.01 c -.613 -2.894 -2.139 -4.224 -4.845 -4.224 c -2.341 0 -5.13 1.864 -8.696 5.81 c -2.148 2.378 -5.401 6.847 -6.1 8.382 l -.272 .597 l -11.193 -.141 c -6.967 -.088 -11.117 -.066 -10.99 .059 c .111 .11 4.892 1.949 10.624 4.087 l 10.423 3.887 l .389 .909 c .463 1.081 1.665 2.462 2.696 3.099 l .742 .459 l -.866 .388 c -.644 .288 -.984 .63 -1.325 1.331 c -.673 1.385 -.451 2.176 1.102 3.925 c .698 .787 1.662 2.185 2.141 3.107 c .58 1.114 1.099 1.815 1.548 2.088 c .724 .442 2.059 .544 2.691 .206 c .713 -.382 4.905 -1.438 4.762 -1.201 c -.077 .129 -2.522 3.971 -5.433 8.537 c -2.911 4.566 -5.247 8.347 -5.192 8.403 c .056 .055 8.933 -3.328 19.727 -7.52 l 19.626 -7.62 L 83.553 88.2 c 10.762 4.178 19.648 7.569 19.747 7.536 c .098 -.033 -1.301 -2.376 -3.109 -5.207 l -3.288 -5.147 l 1.135 -.228 c 2.552 -.512 5.431 -2.527 6.98 -4.884 c 2.26 -3.438 2.587 -7.399 1.084 -13.136 c -.302 -1.151 -.499 -2.142 -.438 -2.202 c .06 -.06 4.99 -1.933 10.956 -4.161 c 5.966 -2.229 10.931 -4.135 11.034 -4.236 c .104 -.102 -5.137 -.129 -11.888 -.059 c -10.269 .106 -12.088 .08 -12.169 -.176 a 12.474 12.474 0 0 1 -.215 -.853 c -.141 -.65 -1.085 -1.654 -1.816 -1.933 c -.282 -.108 -1.21 -.21 -2.062 -.227 c -1.377 -.029 -1.631 .031 -2.298 .54 c -.413 .315 -.811 .765 -.886 1 c -.181 .571 -.402 .537 -.751 -.114 c -.812 -1.518 -3.259 -1.842 -4.504 -.596 l -.629 .628 l -1.245 -.617 c -1.536 -.761 -3.42 -.983 -4.504 -.53 c -.53 .221 -.906 .255 -1.279 .114 c -.888 -.337 -2.307 -.065 -2.969 .569 l -.595 .57 l -.976 -.659 c -.537 -.362 -1.246 -.753 -1.576 -.869 c -.496 -.174 -1.676 -2.002 -6.881 -10.66 c -3.455 -5.747 -6.342 -10.45 -6.416 -10.45 c -.074 0 -3.1 4.92 -6.725 10.934 m -18.682 1.788 c 1.109 .776 1.382 2.983 .769 6.212 c -.671 3.539 -1.702 5.813 -3.553 7.838 c -1.213 1.327 -2.574 2.061 -3.858 2.081 c -2.946 .044 -3.694 -2.859 -1.755 -6.813 c .706 -1.438 2.499 -3.906 2.839 -3.906 c .167 0 .677 2.003 .677 2.66 c 0 .224 -.403 1.073 -.895 1.888 c -.717 1.187 -.894 1.681 -.894 2.494 c 0 3.113 2.902 2.729 4.473 -.593 c 1.586 -3.353 2.474 -9.821 1.496 -10.905 c -.522 -.579 -.832 -.566 -2.253 .096 c -2.246 1.045 -6.923 6.488 -8.534 9.931 c -1.583 3.384 -1.245 6.914 .844 8.801 c 2.073 1.872 5.366 1.519 7.788 -.835 c 1.85 -1.799 3.249 -4.447 3.827 -7.244 c .348 -1.68 .86 -1.854 1.037 -.352 c .069 .58 .605 2.529 1.192 4.33 c 1.451 4.455 1.732 5.652 1.73 7.359 c -.003 1.889 -.619 3.24 -2.111 4.629 c -1.427 1.328 -3.429 2.232 -7.167 3.237 c -1.643 .441 -3.359 .933 -3.813 1.093 c -1.106 .389 -1.178 .376 -1.416 -.248 c -.352 -.926 -1.71 -2.943 -2.608 -3.872 l -.86 -.891 l .867 -.233 c 2.86 -.77 7.084 -2.305 9.12 -3.315 c 2.446 -1.212 4.011 -2.464 4.718 -3.773 c .561 -1.039 .526 -3.601 -.067 -4.871 l -.447 -.96 l -.249 .64 c -1.385 3.563 -4.003 6.201 -7.143 7.198 c -1.894 .602 -4.369 .435 -5.898 -.396 c -2.676 -1.457 -3.167 -4.382 -1.37 -8.167 c 2.085 -4.395 8.022 -11.31 10.979 -12.787 c 1.541 -.77 1.846 -.81 2.535 -.326 M 76.54 56.23 c 2.405 1.199 3.94 3.79 3.97 6.703 c .023 2.183 -.502 3.639 -1.768 4.905 c -1.196 1.196 -2.454 1.619 -4.202 1.414 c -2.996 -.352 -4.711 -2.078 -4.944 -4.974 c -.24 -2.992 1.276 -7.128 2.998 -8.178 c .997 -.608 2.57 -.556 3.946 .13 m -10.644 .499 c .364 .259 .951 1.082 1.348 1.887 c .66 1.341 .703 1.564 .703 3.676 c 0 2.11 -.044 2.337 -.7 3.671 c -1.305 2.652 -3.664 4.17 -6.158 3.963 c -1.429 -.118 -2.156 -.616 -2.814 -1.929 c -.925 -1.843 -.586 -5.413 .797 -8.384 c .669 -1.438 2.511 -3.436 3.269 -3.545 c 1.007 -.146 2.921 .21 3.555 .661 m 34.495 -.459 c .127 .086 .641 2.029 1.142 4.317 c .501 2.288 1.255 5.456 1.677 7.04 c .663 2.49 .768 3.212 .776 5.333 c .012 3.058 -.363 4.391 -1.75 6.233 c -2.407 3.196 -5.96 4.412 -11.737 4.015 c -1.324 -.091 -2.474 -.231 -2.554 -.312 c -.08 -.08 .164 -.387 .542 -.683 c 1.093 -.856 1.787 -2.241 1.914 -3.821 c .061 -.758 .152 -1.379 .203 -1.379 c .051 0 1.069 .25 2.264 .554 c 4.753 1.213 7.851 .145 9.344 -3.22 c .605 -1.362 .733 -3.696 .329 -5.974 l -.302 -1.706 l -.03 1.28 c -.016 .704 -.165 1.917 -.33 2.697 c -.566 2.669 -1.612 4.023 -3.109 4.023 c -1.706 0 -2.664 -1.871 -3.303 -6.451 c -1.025 -7.345 -1.366 -8.804 -2.365 -10.122 c -.598 -.788 -.617 -.913 -.22 -1.456 c .275 -.376 .33 -.332 .825 .653 c .292 .581 .938 2.303 1.435 3.828 c 1.344 4.122 1.929 5.129 3.276 5.641 c .736 .28 1.854 -.172 2.367 -.954 c 1.145 -1.748 .933 -4.101 -.659 -7.306 a 257.362 257.362 0 0 1 -1.102 -2.233 c -.094 -.206 1.063 -.203 1.367 .003 m -28.051 .662 c -.669 .94 -.895 1.708 -.902 3.063 c -.016 3.327 3.216 5.348 6.296 3.937 c 2.416 -1.105 2.693 -2.965 .811 -5.44 c -1.151 -1.514 -3.444 -2.585 -4.197 -1.96 c -.55 .456 -.358 1.016 .631 1.842 c 1.139 .953 1.313 1.319 .811 1.7 c -1.219 .925 -3.257 -.351 -3.257 -2.04 c 0 -.489 .1 -1.074 .221 -1.301 c .33 -.617 .082 -.498 -.414 .199 m 15.435 -.311 c 2.714 1.134 4.77 5.84 4.535 10.381 c -.124 2.407 -.676 3.92 -1.784 4.893 c -1.496 1.313 -3.662 .444 -4.89 -1.962 c -.569 -1.116 -1.076 -2.882 -1.915 -6.678 c -.634 -2.868 -1.195 -4.517 -1.799 -5.285 c -.37 -.469 -.378 -.561 -.086 -.977 c .177 -.253 .404 -.46 .504 -.46 c .327 0 1.038 1.771 1.587 3.957 c .622 2.477 .971 3.384 1.619 4.208 c 1.021 1.297 3.093 1.66 4.109 .718 c 1.286 -1.192 1.601 -2.947 .905 -5.042 c -.621 -1.87 -2.84 -3.752 -3.567 -3.025 c -.164 .163 -.129 .383 .122 .766 c .193 .295 .352 .728 .352 .961 c 0 .597 -.448 1.511 -.741 1.511 c -.302 0 -1.067 -.698 -1.514 -1.379 c -.402 -.614 -.456 -2.13 -.091 -2.569 c .336 -.405 1.705 -.414 2.654 -.018 m -24.628 .552 c -.616 .616 -.543 .892 .559 2.118 c .975 1.084 .982 1.102 .587 1.541 c -.22 .244 -.612 .525 -.872 .624 c -.597 .229 -1.706 -.142 -1.986 -.664 c -.307 -.576 -.253 -2.245 .101 -3.092 c .447 -1.07 .002 -.909 -.746 .27 c -1.182 1.863 -1.369 4.031 -.494 5.717 c 1.345 2.592 4.606 2.303 6.705 -.594 c .983 -1.357 .782 -3.022 -.579 -4.806 c -1.096 -1.437 -2.481 -1.908 -3.275 -1.114 M 47.202 58.78 c .263 .803 .591 1.496 .73 1.539 c .329 .104 1.765 -1.2 2.519 -2.289 c .326 -.471 .674 -.857 .773 -.857 c .098 0 .329 .292 .514 .649 c .185 .358 .592 .747 .905 .866 c .515 .196 .65 .129 1.429 -.703 l .861 -.918 l .066 .679 c .125 1.288 -1.479 3.458 -2.902 3.928 c -.653 .216 -.862 .172 -2.008 -.419 c -.966 -.498 -1.374 -.609 -1.667 -.453 c -.572 .307 -.684 1.227 -.319 2.622 c .412 1.574 .843 2.425 1.669 3.296 c .813 .857 1.703 .909 3.244 .191 l 1.07 -.498 l .011 2.413 l .01 2.413 l -.818 .327 c -1.031 .413 -2.359 .419 -3.139 .016 c -.894 -.462 -1.441 -1.869 -2.055 -5.284 c -.711 -3.954 -1.229 -5.526 -2.137 -6.489 c -.398 -.422 -.661 -.876 -.583 -1.008 c .339 -.578 1.198 -1.632 1.27 -1.56 c .044 .043 .295 .736 .557 1.539 M 93.653 73.6 c 0 .117 -.149 .213 -.332 .213 c -.183 0 -.274 -.096 -.201 -.213 c .073 -.117 .222 -.213 .333 -.213 c .11 0 .2 .096 .2 .213" + readonly property string racket: "M 64 .5 a 64.386 64.386 0 0 0 -21.89 3.81 c 25.982 13.525 60.352 57.646 72.246 98.881 C 122.904 92.4 128 78.792 128 64 C 128 28.93 99.347 .5 64 .5 Z M 21.828 16.236 C 8.45 27.876 0 44.961 0 64 c 0 16.01 5.975 30.637 15.83 41.804 c 8.688 -25.896 25.018 -50.855 39.006 -64.799 c -9.985 -10.672 -21.164 -19.146 -33.008 -24.769 Z m 44.8 39.17 C 52.77 70.22 38.963 96.155 33.968 120.085 c 8.957 4.728 19.176 7.415 30.031 7.415 c 11.161 0 21.65 -2.835 30.786 -7.817 c -5.268 -24.14 -15.204 -46.245 -28.157 -64.277 Z m 0 0" + readonly property string haxe: "M 0 0 h 32.3 L 64 16 L 96.3 0 H 128 v 32.6 L 111.8 64 L 128 95.8 V 128 H 95.2 L 64 112.1 L 32.6 128 H 0 V 94.7 L 15.7 64 L 0 31.8 z" + readonly property string purescript: "M 47.397 90.262 h 43.211 l -9.84 -9.14 h -43.21 Z m 43.211 -32.035 h -43.21 l -9.84 9.136 h 43.21 z M 47.397 44.465 h 43.207 l -9.836 -9.14 H 37.561 Z M 33.01 53.512 l -6.5 -6.465 L 2.409 71.012 a 4.535 4.535 0 0 0 -1.34 3.23 c 0 1.223 .477 2.371 1.34 3.23 l 24.101 23.97 l 6.5 -6.462 l -20.847 -20.738 z m 92.747 -5.395 l -24.094 -23.972 l -6.496 6.46 l 20.84 20.739 l -20.84 20.738 l 6.496 6.461 l 24.094 -23.961 a 4.558 4.558 0 0 0 1.347 -3.238 a 4.56 4.56 0 0 0 -1.347 -3.227" + readonly property string delphi: "M 64 0 C 28.656 0 0 28.656 0 64 s 28.656 64 64 64 s 64 -28.656 64 -64 S 99.344 0 64 0 z m 1.512 10.883 c 1.153 .012 2.309 .11 3.449 .234 c 4.332 .453 8.578 1.32 12.672 2.832 c 2.304 .855 4.586 1.778 6.734 2.98 a 110.26 110.26 0 0 1 4.414 2.614 c 1.106 .699 2.164 1.477 3.2 2.262 a 40.911 40.911 0 0 1 2.746 2.258 c .554 .512 .765 1.226 .687 1.988 c -.109 .969 -.562 1.722 -1.457 2.176 c -.074 .035 -.219 .015 -.289 -.032 c -.407 -.32 -.785 -.675 -1.207 -.984 c -1.262 -.918 -2.512 -1.856 -3.813 -2.723 a 46.178 46.178 0 0 0 -3.765 -2.281 c -1.308 -.699 -2.672 -1.301 -4.028 -1.906 a 31.344 31.344 0 0 0 -2.668 -1.074 a 93.06 93.06 0 0 0 -4.523 -1.422 c -1.903 -.543 -3.852 -.876 -5.809 -1.149 c -1.597 -.22 -3.199 -.406 -4.812 -.43 c -.938 -.016 -1.875 -.078 -2.813 -.046 a 90.33 90.33 0 0 0 -4.48 .25 c -1.113 .093 -2.21 .27 -3.32 .425 a 31.675 31.675 0 0 0 -4.543 .977 c -1.253 .367 -2.503 .75 -3.75 1.156 a 27.383 27.383 0 0 0 -2.25 .848 c -1.312 .57 -2.628 1.141 -3.91 1.777 a 54.43 54.43 0 0 0 -3.75 2.051 c -.964 .573 -1.883 1.242 -2.801 1.89 a 38.79 38.79 0 0 0 -2.102 1.579 a 62.68 62.68 0 0 0 -5.125 4.605 c -.613 .618 -1.168 1.282 -1.738 1.938 c -.586 .679 -1.18 1.355 -1.734 2.062 a 44.754 44.754 0 0 0 -1.766 2.407 a 53.159 53.159 0 0 0 -1.719 2.687 c -.516 .875 -.999 1.78 -1.469 2.68 c -.468 .902 -.937 1.793 -1.347 2.718 c -.418 .938 -.78 1.907 -1.153 2.875 c -.293 .77 -.585 1.543 -.835 2.333 c -.3 .968 -.606 1.937 -.82 2.925 c -.39 1.782 -.793 3.575 -1.063 5.38 c -.242 1.632 -.349 3.28 -.442 4.925 c -.07 1.289 -.05 2.582 -.02 3.871 c .032 1.203 .083 2.41 .208 3.61 c .148 1.425 .388 2.843 .586 4.288 c -1.144 .008 -2.129 -.335 -2.73 -1.363 c -.228 -.379 -.334 -.844 -.419 -1.285 a 39.903 39.903 0 0 1 -.449 -2.828 a 66.652 66.652 0 0 1 -.371 -3.734 c -.083 -1.282 -.145 -2.579 -.113 -3.86 c .039 -1.942 .14 -3.88 .383 -5.812 a 62.118 62.118 0 0 1 1.124 -6.344 a 44.585 44.585 0 0 1 1.93 -6.14 c .414 -1.05 .81 -2.114 1.29 -3.141 a 87.998 87.998 0 0 1 2.206 -4.317 a 37.195 37.195 0 0 1 1.844 -2.988 c .773 -1.144 1.598 -2.242 2.426 -3.344 a 40.166 40.166 0 0 1 1.687 -2.105 c .563 -.653 1.16 -1.262 1.75 -1.883 a 53.5 53.5 0 0 1 1.399 -1.418 c .552 -.531 1.101 -1.07 1.68 -1.57 a 89.046 89.046 0 0 1 2.902 -2.43 c .773 -.605 1.594 -1.148 2.406 -1.707 c .688 -.473 1.367 -.957 2.082 -1.387 c .91 -.55 1.843 -1.062 2.773 -1.57 c .8 -.438 1.602 -.859 2.418 -1.254 c .79 -.375 1.59 -.707 2.387 -1.043 c .62 -.269 1.235 -.547 1.871 -.758 c 1.317 -.449 2.646 -.875 3.973 -1.281 c .735 -.227 1.469 -.445 2.219 -.602 c 1.593 -.324 3.187 -.648 4.797 -.898 a 39.693 39.693 0 0 1 3.96 -.418 a 82.44 82.44 0 0 1 5.2 -.074 z m -.06 6.498 c .833 .011 1.665 .041 2.493 .1 c 1.825 .132 3.637 .374 5.45 .632 c .968 .137 1.925 .38 2.878 .625 c 1.532 .4 3.08 .783 4.582 1.274 c 1.313 .434 2.596 .976 3.856 1.539 c 1.384 .617 2.74 1.304 4.09 1.992 c .575 .289 1.118 .633 1.66 .977 c 1.04 .66 2.09 1.312 3.09 2.03 c 1.005 .712 1.957 1.5 2.93 2.255 c .081 .062 .151 .14 .261 .238 c -.875 .582 -1.722 1.125 -2.562 1.7 c -.891 .612 -1.774 1.25 -2.668 1.874 c -.555 .395 -1.117 .778 -1.676 1.172 c -.688 .492 -1.375 .992 -2.063 1.484 c -.793 .58 -1.597 1.141 -2.39 1.715 c -.59 .426 -1.164 .866 -1.746 1.301 c -.688 .503 -1.387 1.004 -2.063 1.531 c -1.16 .891 -2.363 .736 -3.426 .016 c -2.89 -1.952 -6.03 -3.399 -9.367 -4.422 a 31.389 31.389 0 0 0 -7.086 -1.309 c -1.015 -.066 -2.03 -.097 -3.047 -.136 a 22.752 22.752 0 0 0 -4.222 .25 c -1.106 .168 -2.207 .363 -3.309 .562 c -1.449 .26 -2.847 .707 -4.23 1.2 a 30.428 30.428 0 0 0 -2.813 1.164 a 37.46 37.46 0 0 0 -3.054 1.562 c -.95 .543 -1.844 1.188 -2.75 1.805 a 20.467 20.467 0 0 0 -1.668 1.238 c -.788 .665 -1.55 1.363 -2.32 2.055 c -1.376 1.25 -2.587 2.663 -3.712 4.144 c -.703 .934 -1.327 1.918 -1.957 2.906 c -1.043 1.625 -1.886 3.363 -2.605 5.153 c -.883 2.19 -1.559 4.441 -1.965 6.773 c -.273 1.602 -.504 3.206 -.52 4.824 c -.01 1.351 -.023 2.7 .02 4.051 c .031 .793 .167 1.585 .277 2.375 c .172 1.343 -.359 2.426 -1.582 2.98 c -.363 .165 -.812 .22 -1.215 .188 c -1.156 -.097 -2.305 -.27 -3.46 -.41 c -.387 -.046 -.782 -.09 -1.176 -.156 c -.086 -.016 -.207 -.133 -.223 -.219 c -.215 -1.328 -.452 -2.652 -.61 -3.984 c -.127 -1.094 -.16 -2.2 -.23 -3.297 c -.035 -.633 -.074 -1.265 -.066 -1.895 a 56.125 56.125 0 0 1 .086 -3.144 a 60.934 60.934 0 0 1 .32 -3.48 a 29.67 29.67 0 0 1 .441 -2.821 c .333 -1.551 .716 -3.086 1.106 -4.617 a 29.343 29.343 0 0 1 1.457 -4.29 c .418 -.991 .793 -2.003 1.25 -2.988 c .48 -1.035 1 -2.054 1.57 -3.046 c .645 -1.122 1.343 -2.207 2.035 -3.297 a 40.938 40.938 0 0 1 1.391 -2.047 c .391 -.546 .824 -1.05 1.242 -1.57 c .383 -.477 .758 -.95 1.157 -1.407 c .428 -.5 .882 -.98 1.335 -1.46 c .657 -.676 1.313 -1.351 1.985 -2.008 a 25.64 25.64 0 0 1 1.437 -1.325 c .922 -.777 1.848 -1.562 2.817 -2.277 a 83.903 83.903 0 0 1 3.718 -2.574 c .981 -.637 1.997 -1.255 3.043 -1.785 c 1.344 -.688 2.735 -1.308 4.122 -1.915 c .843 -.375 1.71 -.71 2.585 -1.011 a 58.079 58.079 0 0 1 3.407 -1.051 c 1.187 -.324 2.383 -.645 3.59 -.863 c 1.484 -.274 2.984 -.46 4.48 -.645 c 1.035 -.124 2.082 -.222 3.117 -.234 c .834 -.008 1.669 -.014 2.502 -.002 z m -6.007 23.412 a 45.502 45.502 0 0 1 4.317 .227 a 28.014 28.014 0 0 1 7.215 1.722 c 1.648 .625 3.25 1.352 4.78 2.238 a 35.797 35.797 0 0 1 4.907 3.458 c 1.11 .925 2.18 1.895 3.11 3 a 85.415 85.415 0 0 1 3.038 3.769 c .302 .398 .43 .93 .594 1.418 a 129.996 129.996 0 0 1 1.114 3.43 c .457 1.457 .885 2.934 1.343 4.394 c .343 1.106 .707 2.199 1.051 3.309 c .387 1.234 .75 2.472 1.129 3.71 c .258 .852 .53 1.696 .8 2.544 c .294 .945 .583 1.894 .876 2.843 c .27 .872 .55 1.74 .82 2.606 c .211 .688 .406 1.39 .625 2.078 c .191 .628 .4 1.26 .598 1.89 c .52 1.653 1.03 3.3 1.558 4.954 c .48 1.535 .97 3.066 1.461 4.605 c .055 .188 .125 .367 .188 .555 c .113 .352 -.082 .605 -.406 .48 a 16.18 16.18 0 0 1 -1.977 -.89 c -.16 -.09 -.203 -.406 -.281 -.625 c -.375 -1.06 -.735 -2.11 -1.098 -3.156 c -.414 -1.184 -.82 -2.364 -1.23 -3.547 c -.407 -1.157 -.821 -2.313 -1.227 -3.473 c -.492 -1.426 -.987 -2.843 -1.48 -4.27 c -.351 -1 -.715 -2.007 -1.07 -3.007 a 61.587 61.587 0 0 0 -.598 -1.618 c -.34 -.855 -.957 -1.52 -1.579 -2.175 c -.53 -.551 -1.062 -1.105 -1.625 -1.625 a 93.16 93.16 0 0 0 -3.222 -2.864 c -.788 -.66 -1.781 -.89 -2.781 -1.046 c -1.071 -.172 -2.145 -.344 -3.215 -.52 c -1.313 -.218 -2.625 -.449 -3.938 -.668 c -1.183 -.203 -2.364 -.401 -3.543 -.602 c -1.825 -.303 -3.644 -.613 -5.469 -.917 c -1.487 -.258 -2.968 -.528 -4.457 -.778 c -1.043 -.172 -2.093 -.398 -3.148 -.472 c -.582 -.04 -1.2 .136 -1.781 .3 c -.805 .22 -1.508 .68 -2.106 1.254 a 5.903 5.903 0 0 0 -1.445 2.137 c -.645 1.633 -.543 3.234 .039 4.844 c .375 1.05 .91 2.007 1.68 2.82 c .758 .793 1.636 1.367 2.738 1.625 c 1.324 .305 2.633 .687 3.938 1.043 c 2.35 .641 4.7 1.285 7.05 1.934 c 1.492 .41 2.989 .829 4.489 1.242 c 1.492 .419 2.984 .833 4.48 1.25 c 1.41 .395 2.555 1.195 3.305 2.449 c .382 .633 .625 1.352 .902 2.039 c .324 .833 .629 1.68 .93 2.523 c .418 1.2 .832 2.402 1.242 3.602 c .344 1.004 .694 2.012 1.031 3.02 c .296 .867 .578 1.734 .871 2.605 c .293 .848 .597 1.691 .887 2.543 c .344 .993 .668 2 1.012 2.992 c .488 1.452 .988 2.904 1.48 4.352 c .06 .157 .121 .312 .156 .469 c .051 .218 -.079 .352 -.28 .367 c -.892 .11 -1.786 .195 -2.68 .289 c -.45 .05 -.9 .125 -1.352 .176 c -.941 .105 -1.887 .2 -2.828 .304 c -1.008 .121 -2.016 .25 -3.02 .364 c -1.289 .156 -2.58 .3 -3.87 .437 c -.21 .023 -.438 .05 -.641 .031 a .453 .453 0 0 1 -.309 -.199 c -.363 -.676 -.688 -1.371 -1.063 -2.039 a 70.039 70.039 0 0 0 -1.773 -3.094 a 64.824 64.824 0 0 0 -2.133 -3.281 a 47.53 47.53 0 0 0 -2.27 -2.988 c -.96 -1.172 -1.95 -2.328 -3.003 -3.422 a 37.598 37.598 0 0 0 -3.168 -2.945 a 28.744 28.744 0 0 0 -3.344 -2.407 c -1.187 -.726 -2.414 -1.413 -3.75 -1.844 c -1.539 -.489 -3.032 -1.117 -4.582 -1.539 c -1.531 -.41 -3.125 -.626 -4.684 -.949 c -1.472 -.302 -2.972 -.293 -4.468 -.379 c -.66 -.039 -1.336 .015 -2.004 .055 a 44.18 44.18 0 0 0 -2.281 .164 c -.445 .043 -.883 .125 -1.32 .203 c -.844 .164 -1.692 .34 -2.614 .52 c -.273 -.848 -.574 -1.699 -.816 -2.563 a 27.57 27.57 0 0 1 -.625 -2.531 c -.188 -.973 -.313 -1.957 -.446 -2.941 c -.23 -1.821 -.242 -3.653 -.113 -5.477 c .175 -2.55 .602 -5.07 1.375 -7.52 c .508 -1.597 1.062 -3.187 1.832 -4.687 c .512 -.992 1.02 -1.992 1.59 -2.957 c .43 -.723 .949 -1.406 1.449 -2.094 a 26.073 26.073 0 0 1 1.191 -1.578 c .375 -.453 .778 -.883 1.188 -1.3 c .687 -.689 1.375 -1.385 2.09 -2.04 c .488 -.45 1.012 -.848 1.535 -1.25 a 33.322 33.322 0 0 1 1.715 -1.238 c .78 -.51 1.586 -.992 2.394 -1.461 c .72 -.414 1.438 -.836 2.188 -1.176 a 64.286 64.286 0 0 1 3.422 -1.363 c .672 -.25 1.35 -.484 2.047 -.637 c 1.312 -.293 2.628 -.562 3.96 -.754 c 1.117 -.164 2.251 -.277 3.383 -.277 z m -2.742 22.162 c .187 .009 .376 .03 .567 .065 c 1.71 .324 3.438 .598 5.156 .898 c 1.212 .214 2.418 .438 3.629 .645 c 1.425 .237 2.859 .457 4.28 .687 c 1.033 .176 2.06 .363 3.083 .54 c 1.195 .198 2.387 .385 3.582 .585 c 1.039 .18 2.07 .387 3.105 .55 c .676 .098 1.332 .224 1.934 .536 c .285 .157 .586 .32 .828 .539 a 93.038 93.038 0 0 1 2.578 2.383 a 56.576 56.576 0 0 1 2.203 2.234 c .563 .594 .927 1.301 1.157 2.09 c .175 .617 .406 1.211 .62 1.816 c .302 .832 .598 1.672 .895 2.508 c .375 1.055 .755 2.114 1.125 3.168 c .336 .95 .657 1.903 .985 2.844 c .312 .887 .632 1.77 .945 2.652 c .336 .97 .664 1.93 1 2.899 l .492 1.426 c -.019 .015 -.04 .03 -.062 .05 c -.938 -.652 -1.887 -1.289 -2.813 -1.953 c -.422 -.305 -.63 -.785 -.816 -1.262 c -.394 -.972 -.794 -1.957 -1.184 -2.937 c -.379 -.938 -.75 -1.886 -1.125 -2.824 c -.367 -.913 -.75 -1.82 -1.117 -2.73 c -.383 -.938 -.73 -1.895 -1.133 -2.825 c -.469 -1.09 -.75 -2.277 -1.492 -3.234 c -.48 -.625 -1 -1.22 -1.54 -1.793 a 14.223 14.223 0 0 0 -2.647 -2.207 c -.75 -.473 -1.586 -.63 -2.438 -.785 c -2.395 -.434 -4.773 -.883 -7.168 -1.313 c -2.433 -.445 -4.863 -.875 -7.293 -1.312 c -2.07 -.383 -4.152 -.73 -6.207 -1.165 c -1.375 -.285 -2.637 .864 -2.762 2.165 c -.125 1.312 .886 2.43 1.992 2.71 c 2.989 .75 5.96 1.563 8.946 2.352 c 3.523 .938 7.055 1.848 10.566 2.813 c 1.168 .324 2.219 .918 3.184 1.668 c 1.172 .906 2.136 2 2.816 3.304 c .457 .871 .813 1.79 1.184 2.695 a 75.718 75.718 0 0 1 1.074 2.844 c .492 1.426 .97 2.863 1.441 4.293 c .29 .875 .579 1.75 .852 2.625 c .395 1.262 .781 2.53 1.168 3.793 c .261 .87 .527 1.746 .781 2.621 c .34 1.125 .656 2.262 .989 3.395 c .25 .875 .52 1.742 .761 2.617 c .371 1.293 .746 2.586 1.09 3.887 c .172 .676 -.352 1.433 -1.062 1.531 c -1.465 .199 -2.923 .375 -4.383 .559 c -.97 .108 -1.936 .222 -2.906 .328 c -1.175 .125 -2.352 .246 -3.524 .37 c -1.484 .157 -2.968 .325 -4.453 .481 c -.628 .063 -1.26 .164 -1.89 .149 c -1.384 -.016 -2.615 -.453 -3.673 -1.387 c -.766 -.676 -1.187 -1.563 -1.68 -2.406 c -.417 -.72 -.812 -1.442 -1.257 -2.145 c -.687 -1.086 -1.375 -2.168 -2.117 -3.21 c -.711 -.993 -1.492 -1.946 -2.258 -2.895 a 26.025 26.025 0 0 0 -1.219 -1.418 c -1.254 -1.395 -2.578 -2.731 -4.031 -3.926 c -1.11 -.906 -2.234 -1.8 -3.418 -2.598 c -2.875 -1.945 -5.98 -3.414 -9.348 -4.277 c -2.329 -.598 -4.683 -1.035 -7.11 -1 c -1.218 .027 -2.417 .121 -3.608 .375 c -.852 .184 -1.645 .113 -2.352 -.473 c -.387 -.327 -.555 -.77 -.773 -1.187 c -.095 -.176 -.153 -.363 -.235 -.582 c .375 -.106 .758 -.227 1.145 -.313 c .402 -.093 .809 -.176 1.215 -.226 c 1.036 -.117 2.074 -.282 3.109 -.305 c 1.5 -.039 3.008 -.051 4.5 .063 c 1.394 .105 2.789 .324 4.164 .605 a 43.492 43.492 0 0 1 4.32 1.105 c 1.274 .407 2.516 .933 3.75 1.457 c 2.063 .886 3.875 2.168 5.598 3.586 c 1.145 .938 2.23 1.926 3.215 3.028 c 1.199 1.344 2.39 2.691 3.515 4.097 c .801 .997 1.508 2.08 2.235 3.133 c 1.219 1.77 2.292 3.634 3.312 5.524 c .25 .476 .493 .957 .7 1.453 c .343 .812 1.062 1.062 1.816 .972 c 1.019 -.117 2.03 -.25 3.05 -.367 c 1.247 -.145 2.49 -.293 3.731 -.425 c 1.438 -.157 2.875 -.293 4.309 -.458 a 95.975 95.975 0 0 0 3.332 -.425 c .626 -.094 1.074 -.7 1.11 -1.348 c .03 -.574 -.243 -1.062 -.411 -1.582 c -.477 -1.449 -.98 -2.887 -1.477 -4.332 c -.472 -1.398 -.94 -2.801 -1.41 -4.191 c -.496 -1.47 -.976 -2.937 -1.476 -4.407 c -.343 -1 -.699 -1.996 -1.051 -2.996 c -.285 -.832 -.552 -1.668 -.848 -2.492 c -.36 -.988 -.734 -1.977 -1.11 -2.957 c -.198 -.508 -.39 -1.02 -.636 -1.512 c -.574 -1.168 -1.449 -2.062 -2.574 -2.738 c -1.055 -.637 -2.257 -.844 -3.414 -1.168 c -3.405 -.964 -6.816 -1.902 -10.23 -2.844 c -1.46 -.406 -2.93 -.82 -4.395 -1.226 c -1.168 -.325 -2.324 -.689 -3.504 -.95 c -1.375 -.3 -2.309 -1.125 -3.039 -2.269 a 7.07 7.07 0 0 1 -1.074 -3.106 a 4.638 4.638 0 0 1 .636 -2.914 c .571 -.949 1.364 -1.609 2.352 -2.035 a 3.65 3.65 0 0 1 1.621 -.283 z" + readonly property string coffeescript: "M 50.3 29.6 c 11.7 -1 15 -8.5 28.7 -9.8 c 6.7 -.6 11 .8 11.4 3.1 c .4 2.2 -2.9 3.7 -7 4 c -5.6 .6 -8 -1.5 -8.4 -3.4 c -4.1 .4 -4.8 2.2 -4.6 3.5 c .4 2.4 5.5 4.7 14.1 3.9 c 9.8 -.8 13 -4.6 12.2 -8.5 c -1 -5 -8.5 -9.2 -22 -8 c -17.3 1.6 -17.2 9.5 -28.9 10.5 c -4.8 .4 -7.5 -.7 -8 -2.6 c -.3 -1.9 2 -2.8 4.8 -3 c 2.6 -.2 5.7 .2 7.2 1 c 1.1 -.6 1.5 -1.1 1.3 -1.8 c -.4 -1.8 -4 -2.6 -8.5 -2.2 c -8.7 .8 -8.7 4.7 -8.4 6.4 c 1.1 4.7 7.8 7.7 16.1 6.9 z m 58.6 19.8 c -10.8 2.5 -24.6 4.1 -41.2 4.1 c -16.9 0 -30.7 -1.8 -41.5 -4.1 c -9.6 -2.5 -14.8 -5.2 -16.6 -8 c .9 6.3 2.5 12.4 4.6 18.2 c -2.4 1.5 -4.7 3.5 -6.7 6 C 3.7 70.4 2 76 2.3 81.5 c .3 5.5 3 10 7.3 13.5 c 4.5 3.5 9.3 4.5 14.8 3.5 c 2.1 -.3 4.5 -1.5 6.6 -2.1 c -4.5 0 -8.3 -1.5 -12.1 -4.5 c -4.1 -3 -7 -7.3 -7.6 -12.4 c -1 -4.8 0 -9.3 2.7 -13.2 c .6 -.8 1.2 -1.4 1.9 -2 c 1.5 3.8 3.3 7.4 5.2 10.9 c 4.1 6.3 8.3 11.8 12.4 17.7 c 1.8 3.5 3 7 3.8 10.4 c 2.7 3.8 6.6 6.5 11.4 7.9 c 5.9 2.1 12.1 2.9 18.4 2.9 h .7 c 6.3 0 12.9 -1 19 -3 c 4.5 -1.5 8.3 -4 11.1 -8 h .3 c .7 -3 1.8 -6.8 3.5 -10.3 c 4.1 -5.9 8.3 -11.4 12.4 -17.7 c 5.5 -10 9.3 -21.4 11.4 -33.6 c -2.1 3 -7.3 5.8 -16.6 7.9 z m -82.7 -8.7 c 10.8 2.7 24.6 4.1 41.2 4.1 c 16.9 0 30.4 -1.5 41.2 -4.1 c 11.4 -2.7 16.9 -6.3 16.9 -9.6 c 0 -2.5 -2.5 -4.8 -7 -6.6 c 1 .7 1.8 1.8 1.8 3 c 0 3.5 -5.2 6.3 -15.6 8.6 c -9.6 2.1 -22 3.6 -37 3.6 c -14.5 0 -27.4 -1.5 -36.7 -3.5 c -10 -2.5 -15.3 -5.2 -15.3 -8.6 c 0 -1.5 .7 -2.7 2.7 -4.1 c -6.3 2.5 -9.6 4.5 -9.6 7.6 c .3 3.5 5.9 7 17.4 9.6 z" + readonly property string elm: "M 64 60.74 l 25.65 -25.65 h -51.3 L 64 60.74 z M 7.91 4.65 l 25.83 25.84 h 56.17 L 64.07 4.65 H 7.91 z M 67.263 63.993 l 28.08 -28.08 l 27.951 27.953 l -28.08 28.079 z M 123.35 57.42 V 4.65 H 70.58 l 52.77 52.77 z M 60.74 64 L 4.65 7.91 V 120.1 L 60.74 64 z M 98.47 95.21 l 24.88 24.89 V 70.33 L 98.47 95.21 z M 64 67.26 L 7.91 123.35 h 112.18 L 64 67.26 z" + readonly property string awk: "M 74.351 122.47 c .73 -2.936 -2.013 -5.706 -3.838 -3.88 c -2.244 2.264 -3.773 1.341 -3.124 -1.864 c .376 -1.932 .188 -3.021 -.547 -3.021 c -1.933 0 -1.235 -2.83 1.11 -4.488 c 1.235 -.859 4.3 -1.573 6.818 -1.594 c 2.517 0 5.26 -.483 6.081 -1.09 c 1.17 -.816 .902 -.944 -1.18 -.483 c -1.482 .338 -3.94 .129 -5.422 -.44 c -2.04 -.773 -3.462 -.644 -5.77 .505 c -3.838 1.905 -3.774 1.932 -3.774 -.42 c 0 -1.341 -.821 -2.055 -2.603 -2.264 l -2.577 -.338 l 2.201 -2.346 c 2.351 -2.518 4.906 -2.577 11.81 -.354 c 4.508 1.449 4.127 .021 -.715 -2.706 c -4.573 -2.56 -7.697 -6.103 -19.313 -21.847 c -12.373 -16.736 -13.253 -18.894 -13.795 -33.027 l -.461 -11.825 l -6.42 -7.525 c -3.521 -4.133 -6.87 -7.805 -7.44 -8.16 c -.569 -.332 -3.317 -.9 -6.103 -1.234 c -5.727 -.67 -6.081 -1.492 -2.936 -6.817 c 2.47 -4.235 5.743 -4.718 10.483 -1.572 c 1.68 1.11 3.714 1.97 4.552 1.948 c .945 -.037 .644 -.483 -.752 -1.17 c -1.261 -.59 -2.27 -1.406 -2.27 -1.804 c 0 -1.132 13.296 -2.012 17.177 -1.11 c 8.051 1.84 10.628 4.67 11.99 13.123 c .924 5.722 1.53 6.876 5.68 10.778 c 2.539 2.41 7.214 5.62 10.38 7.129 c 3.168 1.508 7.59 4.235 9.856 6.038 c 5.325 4.235 13.672 16.436 16.522 24.112 c 1.235 3.334 2.748 6.646 3.355 7.402 c 1.658 1.992 -1.004 3.355 -5.304 2.684 a 74.022 74.022 0 0 0 -8.577 -.628 c -2.791 -.043 -5.663 -.569 -6.393 -1.154 c -1.868 -1.508 -6.796 -12.684 -7.697 -17.402 c -1.573 -8.39 -4.59 -11.487 -15.201 -15.62 c -7.612 -2.957 -8.175 -3.419 -12.265 -9.796 c -3.758 -5.85 -4.466 -10.252 -1.61 -10.038 c 2.93 .226 2.136 -3.693 -1.031 -5.207 c -2.163 -1.046 -3.586 -1.09 -6.19 -.225 c -2.243 .73 -4.755 .794 -7.546 .161 c -2.287 -.52 -4.17 -.708 -4.17 -.413 c 0 .268 2.345 3.06 5.222 6.183 c 8.384 9.184 9.055 10.671 8.298 18.578 c -.44 4.574 -.268 7.257 .526 8.218 c .65 .779 1.09 1.804 .988 2.265 c -.107 .484 .564 2.56 1.465 4.633 c .924 2.056 1.68 4.444 1.68 5.287 c 0 .859 .71 1.551 1.59 1.551 c 1.18 0 1.384 .59 .858 2.265 c -.606 1.885 -.483 2.072 .714 1.069 c 1.181 -.967 1.68 -.59 2.748 2.034 c .714 1.76 1.74 4.278 2.266 5.599 c .526 1.299 1.825 2.598 2.898 2.871 c 1.085 .269 2.201 1.74 2.555 3.29 c .333 1.53 1.45 3.694 2.453 4.778 c 1.004 1.095 2.158 2.963 2.534 4.154 c .945 2.958 5.496 5.996 9.689 6.48 c 2.64 .316 3.5 .88 3.5 2.372 c 0 1.068 .525 1.84 1.153 1.717 c .65 -.123 1.718 .462 2.416 1.256 c .665 .822 1.696 1.493 2.281 1.493 c .59 0 0 -1.133 -1.299 -2.518 c -1.299 -1.385 -2.093 -2.807 -1.739 -3.167 c .354 -.333 1.847 .8 3.312 2.56 c 2.936 3.479 6.312 5.658 7.3 4.67 c .354 -.332 -1.406 -2.361 -3.88 -4.524 c -2.475 -2.137 -4.155 -4.236 -3.758 -4.654 c .424 -.419 1.89 .333 3.274 1.674 c 6.667 6.458 12.077 10.005 15.325 10.005 c 1.53 0 1.514 -.187 -.107 -1.975 c -.961 -1.068 -1.487 -2.388 -1.149 -2.952 a 1.165 1.165 0 0 0 -.376 -1.616 c -.547 -.338 -3.102 -4.696 -5.684 -9.688 c -2.577 -4.992 -5.534 -9.517 -6.586 -10.086 c -3.752 -2.013 -1.74 -2.641 7.826 -2.453 l 9.747 .214 l 8.385 8.927 c 4.616 4.927 8.223 9.103 8.014 9.313 c -.193 .21 -1.283 -.108 -2.416 -.714 c -2.79 -1.508 -3.06 -1.406 -2.453 .923 c .59 2.244 -.692 2.663 -2.512 .816 c -.67 -.644 -1.556 -.837 -1.975 -.419 c -.419 .44 1.932 3.253 5.244 6.248 c 3.543 3.232 5.62 5.808 5.035 6.313 c -1.326 1.127 -22.797 -.295 -25.438 -1.718 c -1.176 -.633 -3.983 -1.133 -6.248 -1.133 c -3.42 0 -4.106 .333 -4.106 1.986 c 0 4.053 -3.296 10.048 -7.698 14.074 c -2.496 2.266 -4.76 4.134 -5.013 4.134 c -.252 0 -.188 -1.074 .15 -2.351 z m 4.842 -6.565 c -.44 -1.342 -.333 -1.4 .44 -.29 c .816 1.112 1.299 .918 2.56 -1.03 c .838 -1.32 1.675 -3.312 1.863 -4.402 c .274 -1.717 -.145 -1.97 -2.705 -1.68 c -3.602 .419 -6.436 1.804 -8.825 4.278 c -2.163 2.265 -2.222 2.936 -.23 2.936 c .837 0 2.286 .902 3.21 1.992 c 1.969 2.286 4.589 1.009 3.687 -1.804 z m -3.521 -3.247 c 2.517 -2.094 4.17 -2.539 4.17 -1.133 c 0 .816 -4.444 3.226 -5.85 3.167 c -.376 -.021 .359 -.944 1.68 -2.04 z m 34.97 -14.09 c -.354 -.57 -1.09 -1.01 -1.631 -1.01 c -.57 0 -.736 .44 -.376 1.01 c .333 .542 1.068 1.004 1.61 1.004 c .57 0 .736 -.462 .397 -1.004 z M 107.52 85.84 c 2.748 -1.052 1.766 -2.416 -1.3 -1.782 c -1.653 .311 -3.204 .08 -3.585 -.548 c -.945 -1.508 -2.872 -1.406 -2.346 .124 c .231 .697 1.509 1.556 2.85 1.932 c 1.364 .376 2.54 .735 2.62 .773 c .107 .064 .902 -.161 1.76 -.5 z m -9.501 -5.346 c 0 -.483 -.709 -1.181 -1.59 -1.514 c -2.474 -.94 -2.806 -.73 -1.427 .902 c 1.385 1.68 3.022 2.013 3.022 .612 z M 85.082 68.058 c -.73 -.736 -1.192 -.795 -1.192 -.145 c 0 1.342 1.197 2.533 1.868 1.862 c .29 -.29 -.021 -1.068 -.67 -1.717 z m 2.727 -3.275 c -1.74 -1.717 -8.299 -5.593 -8.74 -5.131 c -.316 .29 -.375 .692 -.15 .859 c .231 .166 1.74 1.32 3.334 2.539 c 2.394 1.798 7.112 3.29 5.555 1.739 z M 95 62.228 c 0 -1.213 -3.709 -3.607 -7.085 -4.546 c -2.265 -.655 -2.158 -.424 1.025 2.367 c 3.543 3.124 6.066 4.026 6.066 2.18 z m -9.914 -16.37 c -.735 -.736 -1.197 -.8 -1.197 -.172 c 0 1.363 1.197 2.539 1.868 1.868 c .29 -.269 -.021 -1.047 -.67 -1.702 z M 77.83 40.57 c 0 -.252 -.462 -.735 -1.004 -1.073 c -.569 -.333 -1.009 -.14 -1.009 .445 c 0 .59 .44 1.074 1.01 1.074 c .541 0 1.003 -.194 1.003 -.446 z m -13.564 -4.133 c -2.077 -1.551 -3.231 -1.299 -2.12 .462 c .36 .612 1.428 1.073 2.372 1.052 c 1.487 -.022 1.466 -.215 -.252 -1.508 z m -22.77 -26.71 a 1.053 1.053 0 0 0 -1.073 -1.025 c -.586 0 -.795 .456 -.462 1.025 c .36 .548 .843 1.004 1.073 1.004 c .253 0 .462 -.456 .462 -1.004 z m -19.19 -1.025 c 0 -.548 -1.025 -.988 -2.281 -.988 c -1.825 .022 -1.976 .21 -.757 .988 c 1.97 1.277 3.038 1.277 3.038 0 z M 79.51 90.828 c -.36 -.376 -.671 -1.342 -.671 -2.158 c 0 -1.175 .29 -1.218 1.503 -.215 c .843 .698 1.535 1.664 1.535 2.147 c 0 1.047 -1.406 1.17 -2.367 .226 z m -13.843 -15.39 c -1.905 -1.61 -3.102 -3.311 -2.662 -3.773 c .461 -.461 1.304 -.043 1.868 .967 c .547 1.003 2.093 2.222 3.435 2.742 c 1.342 .505 2.458 1.385 2.458 1.954 c 0 1.718 -1.406 1.192 -5.099 -1.889 z M 8.447 11.403 L 5.2 10.232 l 2.555 -3.296 c 1.889 -2.41 3.397 -3.269 5.62 -3.269 c 1.68 0 2.855 .338 2.646 .751 c -.215 .425 -1.154 2.459 -2.12 4.53 c -.945 2.1 -1.825 3.758 -1.97 3.715 a 453.21 453.21 0 0 1 -3.484 -1.261 z" + readonly property string matlab: "M 123.965 91.902 c -7.246 -18.297 -13.262 -37.058 -20.184 -55.476 c -3.054 -7.84 -6.047 -15.746 -10.215 -23.082 c -1.656 -2.633 -3.238 -5.528 -5.953 -7.215 a 4.013 4.013 0 0 0 -2.222 -.606 c -1.27 .028 -2.536 .594 -3.504 1.415 c -3.645 2.886 -5.805 7.082 -8.227 10.949 c -4.277 7.172 -8.789 14.687 -15.941 19.347 c -3.36 2.371 -7.762 2.63 -11 5.172 c -4.43 3.34 -7.442 8.078 -11.074 12.184 c -.829 .988 -2.11 1.383 -3.227 1.918 C 21.578 60.93 10.738 65.336 0 69.98 c 9.09 7.032 18.777 13.29 28.05 20.079 c 2.544 -.504 5.098 -1.547 7.72 -1.082 c 4.16 1.3 6.597 5.285 8.503 8.93 c 3.875 7.94 6.676 16.323 9.813 24.57 c 5.246 -.375 9.969 -3.079 14.027 -6.258 c 7.809 -6.324 13.758 -14.5 20.305 -22.047 c 3.14 -3.3 6.34 -7.23 11.05 -8.149 c 4.762 -1.152 9.864 .555 13.395 3.836 c 4.957 4.43 9.344 9.551 15.137 12.942 c -.777 -3.836 -2.645 -7.278 -4.035 -10.899 z M 42.96 79.012 c -4.57 2.703 -9.426 4.93 -14.176 7.289 c -7.457 -4.996 -14.723 -10.29 -22.05 -15.465 c 9.878 -4.328 19.91 -8.348 29.917 -12.387 c 4.746 3.703 9.637 7.223 14.383 10.926 c -2.23 3.563 -4.914 6.871 -8.074 9.637 z m 10.168 -12.414 C 48.414 63.058 43.64 59.609 39 55.977 c 2.977 -4.055 6.238 -7.977 10.14 -11.172 c 2.587 -1.657 5.743 -2.117 8.426 -3.61 c 6.368 -3.18 10.711 -9.011 14.86 -14.582 c -5.317 13.805 -10.992 27.664 -19.297 39.985 z m 0 0" + readonly property string solidity: "M 43.322 0 L 22.756 36.576 l 20.566 36.561 l 20.564 -36.561 h 41.143 L 84.465 0 H 43.322 z m 41.342 54.863 L 64.1 91.424 H 22.955 L 43.519 128 h 41.145 l 20.58 -36.576 l -20.58 -36.561 z" + readonly property string wasm: "M .223 .222 v 127.555 h 127.555 V .222 H 78.594 c .014 .227 .036 .455 .036 .686 c 0 8.08 -6.55 14.626 -14.63 14.626 c -8.078 0 -14.625 -6.546 -14.625 -14.626 c 0 -.231 .022 -.459 .031 -.686 z m 29.595 68.746 h 8.445 l 5.782 30.738 h .107 l 6.968 -30.738 h 7.908 l 6.265 31.119 h .106 l 6.597 -31.119 h 8.284 l -10.765 45.156 H 61.12 l -6.213 -30.738 H 54.8 l -6.7 30.738 h -8.557 z m 59.994 0 h 13.334 l 13.284 45.156 h -8.77 l -2.879 -10.051 H 89.59 l -2.212 10.05 h -8.5 Z M 94.895 80.1 l -3.684 16.57 h 11.473 L 98.448 80.1 Z" + readonly property string vim: "M 72.6 80.5 c .2 .2 .6 .5 .9 .5 h 5.3 c .3 0 .7 -.3 .9 -.5 l 1.4 -1.5 c .2 -.2 .3 -.4 .3 -.6 l 1.5 -5.1 c .1 -.5 0 -1 -.3 -1.3 l -1.1 -.9 c -.2 -.2 -.6 -.1 -.9 -.1 h -4.8 l -.2 -.2 l -.1 -.1 c -.2 0 -.4 -.1 -.6 .1 L 73 72 c -.2 0 -.3 .5 -.4 .7 L 71 77.6 c -.2 .5 -.1 1.1 .3 1.5 l 1.3 1.4 z m .8 26.4 l -.4 .1 h -1.2 L 79 85.9 c .2 -.7 -.1 -1.5 -.8 -1.7 l -.4 -.1 H 65.7 c -.5 .1 -.9 .5 -1 1 l -.7 2.5 c -.2 .7 .3 1.3 1 1.5 l .3 -.1 h 1.8 l -7.3 20.9 c -.2 .7 .1 1.6 .8 1.9 l .4 .3 h 11.2 c .6 0 1.1 -.5 1.3 -1.1 l .7 -2.4 c .3 -.7 -.1 -1.5 -.8 -1.7 z m 53.1 -19.7 l -1.9 -2.5 v -.1 c -.3 -.3 -.6 -.6 -1 -.6 h -7.2 c -.4 0 -.7 .4 -1 .6 l -2 2.4 h -3.1 l -2.1 -2.4 v -.1 c -.2 -.3 -.6 -.5 -1 -.5 h -4 l 20.2 -20.2 l -22.6 -22.4 L 121 20.6 v -9 L 118.2 8 H 77.3 L 74 11.5 v 2.9 L 62.7 3 L 55 10.5 L 52.6 8 H 12.2 L 9 11.7 v 9.4 l 3 2.9 h 3 v 26.1 l -14 14 l 14 14 v 32 l 5.2 2.9 h 11.6 l 9.1 -9.5 l 21.6 21.6 L 77 110.6 c .1 .4 .4 .5 .9 .7 l .4 -.2 h 9.4 c .6 0 1.1 -.1 1.2 -.6 l .7 -2 c .2 -.7 -.1 -1.3 -.8 -1.5 l -.4 .1 H 88 l 3.4 -10.7 l 2.3 -2.3 h 5 l -5 15.9 c -.2 .7 .2 1.1 .9 1.4 l .4 -.2 h 9.1 c .5 0 1 -.1 1.2 -.6 l .8 -1.8 c .3 -.7 -.1 -1.3 -.7 -1.6 c -.1 -.1 -.3 0 -.5 0 h -.4 l 4.2 -13 h 6.1 l -5.1 15.9 c -.2 .7 .2 1.1 .9 1.3 l .4 -.3 h 10 c .5 0 1 -.1 1.2 -.6 l .8 -2 c .3 -.7 -.1 -1.3 -.8 -1.5 c -.1 -.1 -.3 .1 -.5 .1 h -.7 l 5.6 -18.5 c .2 -.5 .1 -1.1 -.1 -1.4 z M 62.7 4.9 L 74 16.2 v 4.7 l 3.4 4.1 H 79 L 50 53 V 25 h 3.3 l 2.7 -4.2 v -8.9 l -.2 -.3 l 6.9 -6.7 z M 2.9 64.1 L 15 52 v 24.2 L 2.9 64.1 z m 38.9 38.3 l 58.4 -60 l 21.4 21.5 l -20.2 20.2 h -.1 c -.3 .1 -.5 .3 -.7 .5 L 98.5 87 h -2.9 l -2.2 -2.4 c -.2 -.3 -.6 -.6 -1 -.6 h -8.8 c -.6 0 -1.1 .4 -1.3 1 l -.8 2.5 c -.2 .7 .1 1.3 .8 1.6 h 1.5 L 77.4 108 l -15.1 15.2 l -20.5 -20.8 z" + readonly property string sql: "M 115.95 2.781 c -5.504 -4.906 -12.16 -2.933 -18.738 2.902 a 47.9 47.9 0 0 0 -2.918 2.856 c -11.246 11.93 -21.684 34.02 -24.926 50.895 c 1.262 2.563 2.25 5.832 2.902 8.328 c .325 1.238 .617 2.488 .875 3.746 c 0 0 -.101 -.379 -.515 -1.578 l -.266 -.777 a 8.12 8.12 0 0 0 -.176 -.426 c -.734 -1.707 -2.761 -5.309 -3.656 -6.875 a 172.299 172.299 0 0 0 -2.008 6.27 c 2.582 4.714 4.149 12.8 4.149 12.8 s -.133 -.527 -.782 -2.355 c -.57 -1.617 -3.437 -6.637 -4.117 -7.809 c -1.16 4.29 -1.62 7.18 -1.207 7.883 c .813 1.363 1.578 3.723 2.25 6.324 c 1.528 5.868 2.586 13.016 2.586 13.016 l .094 1.192 c -.203 4.886 -.102 9.781 .297 14.656 c .508 6.113 1.457 11.359 2.668 14.172 l .824 -.45 c -1.781 -5.535 -2.504 -12.792 -2.184 -21.155 c .477 -12.79 3.422 -28.215 8.856 -44.29 c 9.191 -24.261 21.938 -43.733 33.602 -53.034 c -10.63 9.601 -25.023 40.695 -29.332 52.203 C 79.404 74.162 75.99 86.252 73.93 97.84 c 3.555 -10.863 15.043 -15.527 15.043 -15.527 s 5.637 -6.954 12.223 -16.883 c -3.945 .898 -10.426 2.441 -12.598 3.351 c -3.2 1.34 -4.063 1.797 -4.063 1.797 s 10.371 -6.312 19.27 -9.172 c 12.234 -19.27 25.566 -46.645 12.145 -58.625 M 16.896 5.681 c -5.398 .02 -9.77 4.39 -9.785 9.789 v 88.574 c .016 5.398 4.39 9.765 9.785 9.785 h 50.227 a 122.816 122.816 0 0 1 -.277 -14.438 c -.031 -.332 -.059 -.754 -.086 -1.067 a 143.095 143.095 0 0 0 -2.523 -12.684 c -.645 -2.507 -1.465 -4.789 -1.965 -5.636 c -.621 -1.051 -.524 -1.653 -.52 -2.305 c 0 -.64 .082 -1.305 .2 -2.059 c .316 -1.878 .73 -3.738 1.246 -5.574 l 1.156 -.148 c -.09 -.188 -.074 -.348 -.164 -.516 l -.219 -2.031 c .64 -2.137 1.316 -4.262 2.04 -6.371 l 1.066 -.102 c -.043 -.082 -.055 -.203 -.098 -.28 l -.23 -1.685 c 3.363 -17.496 13.8 -39.699 25.601 -52.219 c .352 -.37 .711 -.683 1.055 -1.035 z" + readonly property string json: "M 45.949 63.71 c 0 22.998 17.853 30.038 18.223 30.175 l .016 .005 a .823 .823 0 0 0 -.05 .021 h -.001 v .001 h .001 c 20.306 6.899 40.668 -10.261 40.668 -41.251 c 0 -17.054 -7.595 -37.361 -27.7 -48.894 C 105.528 10.083 126 34.393 126 63.903 c 0 35.822 -30.182 62.003 -61.873 62.003 c -2.078 0 -37.078 -10.935 -37.216 -51.164 c -.103 -30.18 19.208 -40.406 32.72 -38.442 c .009 .003 -13.682 7.806 -13.682 27.41 z M 82.051 64.29 c 0 -22.998 -17.853 -30.038 -18.223 -30.175 l -.016 -.005 a .823 .823 0 0 0 .05 -.021 h .001 v -.001 h -.001 C 43.556 27.19 23.193 44.35 23.193 75.34 c 0 17.054 7.595 37.361 27.7 48.894 C 22.472 117.917 2 93.607 2 64.097 C 2 28.275 32.182 2.094 63.873 2.094 c 2.078 0 37.078 10.935 37.216 51.164 c .103 30.18 -19.207 40.406 -32.72 38.443 c -.009 -.004 13.682 -7.807 13.682 -27.411 z" + readonly property string yaml: "m 0.5 5.6289 l 21.754 34.15 v 21.646 h 13.959 v -21.646 l 22.775 -34.15 h -15.02 l -13.947 21.988 l -13.832 -21.988 h -15.689 z m 63.994 0.13086 l -23.66 55.797 h 11.189 l 5.1387 -12.408 h 25.266 l 4.252 12.408 h 11.957 l -22.699 -55.797 h -11.443 z m 5.9922 11.773 l 7.7441 20.475 h -16.387 l 8.6426 -20.475 z m 16.195 50.139 v 54.451 h 40.818 v -11.635 v -0.00195 h -28.863 v -42.814 h -11.955 z m -64.428 0.011719 v 54.687 h 11.734 v -37.723 l 12.279 25.355 h 9.2344 l 12.699 -26.246 v 38.602 h 11.256 v -54.676 h -15.369 l -13.637 24.732 l -12.986 -24.732 h -15.211 z" + readonly property string xml: "M 23.25 37.459 c -2.661 .114 -2.363 3.885 -2.875 5.797 c -.837 3.125 -1.608 7.1 -1.853 10.324 c -.246 3.138 -.044 6.046 .353 9.219 c .309 2.467 1.02 4.104 .373 4.767 c -.52 .623 -17.199 15.92 -18.543 16.786 c -1.392 .903 -.428 3.13 .37 4.379 c .841 1.317 1.24 1.999 2.99 1.765 c 1.645 -.222 2.167 -2.182 3.127 -3.248 c 1.146 -1.353 12.917 -12.824 13.957 -12.824 l -.026 -.147 c .421 2.923 1.972 5.705 3.598 8.112 c .798 1.19 1.68 2.352 2.699 3.37 c .936 .937 2.145 1.702 3.145 2.409 c 2.352 .123 4.246 -1.618 1.875 -3.59 c -.905 -.757 -3.935 -5.212 -4.961 -7.676 c -.957 -2.303 -3.074 -5.762 -.782 -7.654 c 7.921 -6.6 19.887 -12.132 22.711 -12.406 c -1.377 5.422 -7.257 16.32 -9.408 21.48 c -.741 1.785 -1.233 3.92 -.982 5.864 c .297 2.324 2.046 4.152 3.144 4.326 c 2.229 -.127 3.74 -2.086 5.06 -3.463 c 1.832 -1.908 3.396 -3.672 5.221 -5.588 c 1.812 -1.907 3.593 -3.918 5.2 -5.734 c .988 -1.12 3.6 -3.829 5.152 -3.829 c -.584 3.732 -3.832 8.33 -4.486 12.094 c -.667 3.85 2.844 8.563 6.52 5.131 c 3.336 -3.117 6.72 -6.056 10 -9.22 c 1.162 -1.112 .935 .2 1.196 1.296 c .263 1.114 .945 4.875 1.473 5.89 c .968 1.857 2.653 3.176 4.799 3.212 c 1.951 .032 3.897 -.862 5.258 -2.219 c 1.015 -1.011 2.757 -3.749 .83 -4.463 c -1.23 -.456 -1.159 -.943 -2.65 -.693 c -.925 .158 -1.94 .395 -2.717 .959 c -1.967 -.008 -2.042 -4.756 -1.737 -6.215 c .36 -1.741 1.107 -3.522 .992 -5.34 c -.135 -2.24 -2.554 -3.707 -3.115 -3.865 c -.832 -.41 -1.343 .117 -2.447 1.133 c -.928 .857 -1.947 1.778 -2.863 2.646 c -1.829 1.733 -4.11 4.36 -5.041 4.36 c 1.634 -3.73 3.8 -14.16 -3.442 -13.24 c -3.01 .384 -5.085 2.02 -7.457 3.806 a 252.59 252.59 0 0 1 -5.014 4.674 c -1.269 1.146 -1.99 2.152 -2.654 2.152 c .707 -3.962 9.02 -16.976 4.74 -20.644 c -.858 -.722 -1.678 -1.742 -2.761 -1.864 c -1.511 .186 -3.002 .552 -4.457 1.016 c -3.023 1.043 -14.846 6.9 -22.399 12.306 c -.701 .508 -1.15 -.451 -1.236 -1.558 c -.472 -5.732 1.06 -11.749 2.654 -17.1 c 1.103 -3.732 -2.992 -6.563 -3.531 -6.693 z m 96.842 4.877 c -.889 1.952 -1.067 4.788 -1.332 7.06 v .008 c -2.02 -.238 -4.646 -.495 -6.668 -.617 c 1.273 1.586 3.698 3.149 5.658 3.88 c -.261 1.958 -2.326 3.584 -3.207 5.476 c 2.448 -.679 3.62 -2.514 5.291 -4.403 c 1.935 1.178 4.227 3.363 6.742 3.41 c -.952 -2.022 -1.792 -3.723 -3.525 -5.183 c 1.44 -.94 3.842 -1.927 4.949 -3.387 c -1.844 -.396 -3.832 -.5 -5.629 -.08 c -1.38 -1.523 -1.605 -4.141 -2.28 -6.164 z m -14.733 .387 c -1.233 .113 -3.28 2.633 -4.453 4.144 c -4.323 5.564 -8.265 16.89 -8.9 18.977 c -1.935 6.398 -2.686 14.285 1.502 19.916 c 2.436 3.284 6.728 3.532 10.935 2.324 c 2.17 -.624 4.344 -2.181 5.086 -3.875 c .615 -1.411 .932 -4.037 -2.166 -2.035 c -1.198 .773 -3.395 1.627 -7.334 1.564 c -1.32 -.024 -3.49 -1.92 -4.072 -3.88 c -1.024 -3.194 -.588 -7.458 -.588 -7.458 c .892 -5.505 3.447 -11.47 5.688 -16.474 c .869 -1.932 3.517 -6.748 4.51 -8.283 c 0 .004 1.27 -1.997 .7 -4.123 l -.01 .017 c -.167 -.624 -.487 -.852 -.898 -.814 z m 16.45 15.935 c -.647 1.417 -.774 3.483 -.965 5.133 h -.01 c -1.464 -.17 -3.38 -.358 -4.836 -.45 c .924 1.152 2.686 2.285 4.106 2.817 c -.18 1.416 -1.682 2.606 -2.325 3.975 c 1.781 -.493 2.639 -1.829 3.844 -3.194 c 1.404 .845 3.074 2.436 4.899 2.471 c -.679 -1.471 -1.294 -2.709 -2.555 -3.764 c 1.039 -.678 2.78 -1.408 3.582 -2.459 c -1.333 -.289 -2.789 -.36 -4.082 -.056 c -1.003 -1.107 -1.167 -3 -1.658 -4.473 z m -6.55 12.95 c -.443 .98 -.533 2.4 -.665 3.546 l -.012 -.004 a 62.34 62.34 0 0 0 -3.344 -.308 c .635 .8 1.853 1.58 2.836 1.949 c -.127 .98 -1.16 1.794 -1.605 2.742 c 1.23 -.345 1.82 -1.264 2.658 -2.213 c .979 .595 2.125 1.689 3.387 1.713 c -.472 -1.015 -.894 -1.863 -1.762 -2.598 c .717 -.472 1.92 -.972 2.479 -1.7 c -.927 -.2 -1.927 -.252 -2.823 -.042 c -.694 -.761 -.811 -2.07 -1.148 -3.086 z" + readonly property string html: "M 9.032 2 l 10.005 112.093 l 44.896 12.401 l 45.02 -12.387 L 118.968 2 H 9.032 z m 89.126 26.539 l -.627 7.172 L 97.255 39 H 44.59 l 1.257 14 h 50.156 l -.336 3.471 l -3.233 36.119 l -.238 2.27 L 64 102.609 v .002 l -.034 .018 l -28.177 -7.423 L 33.876 74 h 13.815 l .979 10.919 L 63.957 89 H 64 v -.546 l 15.355 -3.875 L 80.959 67 H 33.261 l -3.383 -38.117 L 29.549 25 h 68.939 l -.33 3.539 z" + readonly property string css: "M 8.76 1 l 10.055 112.883 l 45.118 12.58 l 45.244 -12.626 L 119.24 1 H 8.76 z m 89.591 25.862 l -3.347 37.605 l .01 .203 l -.014 .467 v -.004 l -2.378 26.294 l -.262 2.336 L 64 101.607 v .001 l -.022 .019 l -28.311 -7.888 L 33.75 72 h 13.883 l .985 11.054 l 15.386 4.17 l -.004 .008 v -.002 l 15.443 -4.229 L 81.075 65 H 48.792 l -.277 -3.043 l -.631 -7.129 L 47.553 51 h 34.749 l 1.264 -14 H 30.64 l -.277 -3.041 l -.63 -7.131 L 29.401 23 h 69.281 l -.331 3.862 z" + readonly property string sass: "M 1.219 56.156 c 0 .703 .207 1.167 .323 1.618 c .756 2.933 2.381 5.45 4.309 7.746 c 2.746 3.272 6.109 5.906 9.554 8.383 c 2.988 2.148 6.037 4.248 9.037 6.38 c .515 .366 1.002 .787 1.561 1.236 c -.481 .26 -.881 .489 -1.297 .7 c -3.959 2.008 -7.768 4.259 -11.279 6.986 c -2.116 1.644 -4.162 3.391 -5.607 5.674 c -2.325 3.672 -3.148 7.584 -1.415 11.761 c .506 1.22 1.278 2.274 2.367 3.053 c .353 .252 .749 .502 1.162 .6 c 1.058 .249 2.136 .412 3.207 .609 l 3.033 -.002 c 3.354 -.299 6.407 -1.448 9.166 -3.352 c 4.312 -2.976 7.217 -6.966 8.466 -12.087 c .908 -3.722 .945 -7.448 -.125 -11.153 a 11.696 11.696 0 0 0 -.354 -1.014 c -.13 -.333 -.283 -.657 -.463 -1.072 l 6.876 -3.954 l .103 .088 c -.125 .409 -.258 .817 -.371 1.23 c -.817 2.984 -1.36 6.02 -1.165 9.117 c .208 3.3 1.129 6.389 3.061 9.146 c 1.562 2.23 5.284 2.313 6.944 .075 c .589 -.795 1.16 -1.626 1.589 -2.513 c 1.121 -2.315 2.159 -4.671 3.23 -7.011 l .187 -.428 c -.077 1.108 -.167 2.081 -.208 3.055 c -.064 1.521 .025 3.033 .545 4.48 c .445 1.238 1.202 2.163 2.62 2.326 c .97 .111 1.743 -.333 2.456 -.896 a 10.384 10.384 0 0 0 2.691 -3.199 c 1.901 -3.491 3.853 -6.961 5.576 -10.54 c 1.864 -3.871 3.494 -7.855 5.225 -11.792 l .286 -.698 c .409 1.607 .694 3.181 1.219 4.671 c .61 1.729 1.365 3.417 2.187 5.058 c .389 .775 .344 1.278 -.195 1.928 c -2.256 2.72 -4.473 5.473 -6.692 8.223 c -.491 .607 -.98 1.225 -1.389 1.888 a 3.701 3.701 0 0 0 -.48 1.364 a 1.737 1.737 0 0 0 1.383 1.971 a 9.661 9.661 0 0 0 2.708 .193 c 3.097 -.228 5.909 -1.315 8.395 -3.157 c 3.221 -2.386 4.255 -5.642 3.475 -9.501 c -.211 -1.047 -.584 -2.065 -.947 -3.074 c -.163 -.455 -.174 -.774 .123 -1.198 c 2.575 -3.677 4.775 -7.578 6.821 -11.569 c .081 -.157 .164 -.314 .306 -.482 c .663 3.45 1.661 6.775 3.449 9.792 c -.912 .879 -1.815 1.676 -2.632 2.554 c -1.799 1.934 -3.359 4.034 -4.173 6.595 c -.35 1.104 -.619 2.226 -.463 3.405 c .242 1.831 1.742 3.021 3.543 2.604 c 3.854 -.892 7.181 -2.708 9.612 -5.925 c 1.636 -2.166 1.785 -4.582 1.1 -7.113 c -.188 -.688 -.411 -1.365 -.651 -2.154 c .951 -.295 1.878 -.649 2.837 -.868 c 4.979 -1.136 9.904 -.938 14.702 .86 c 2.801 1.05 5.064 2.807 6.406 5.571 c 1.639 3.379 .733 6.585 -2.452 8.721 c -.297 .199 -.637 .356 -.883 .605 a .869 .869 0 0 0 -.205 .67 c .021 .123 .346 .277 .533 .275 c 1.047 -.008 1.896 -.557 2.711 -1.121 c 2.042 -1.413 3.532 -3.314 3.853 -5.817 l .063 -.188 l -.077 -1.63 c -.031 -.094 .023 -.187 .016 -.258 c -.434 -3.645 -2.381 -6.472 -5.213 -8.688 c -3.28 -2.565 -7.153 -3.621 -11.249 -3.788 a 25.401 25.401 0 0 0 -9.765 1.503 c -.897 .325 -1.786 .71 -2.688 1.073 c -.121 -.219 -.251 -.429 -.358 -.646 c -.926 -1.896 -2.048 -3.708 -2.296 -5.882 c -.176 -1.544 -.392 -3.086 -.025 -4.613 c .353 -1.469 .813 -2.913 1.246 -4.362 c .223 -.746 .066 -1.164 -.646 -1.5 a 2.854 2.854 0 0 0 -.786 -.258 c -1.75 -.254 -3.476 -.109 -5.171 .384 c -.6 .175 -1.036 .511 -1.169 1.175 c -.076 .381 -.231 .746 -.339 1.122 c -.443 1.563 -.757 3.156 -1.473 4.645 c -1.794 3.735 -3.842 7.329 -5.938 10.897 c -.227 .385 -.466 .763 -.752 1.23 c -.736 -1.54 -1.521 -2.922 -1.759 -4.542 c -.269 -1.832 -.481 -3.661 -.025 -5.479 c .339 -1.356 .782 -2.687 1.19 -4.025 c .193 -.636 .104 -.97 -.472 -1.305 c -.291 -.169 -.62 -.319 -.948 -.368 a 11.643 11.643 0 0 0 -5.354 .438 c -.543 .176 -.828 .527 -.994 1.087 c -.488 1.652 -.904 3.344 -1.589 4.915 c -2.774 6.36 -5.628 12.687 -8.479 19.013 c -.595 1.321 -1.292 2.596 -1.963 3.882 c -.17 .326 -.418 .613 -.63 .919 c -.17 -.201 -.236 -.339 -.235 -.477 c .005 -.813 -.092 -1.65 .063 -2.436 a 172.189 172.189 0 0 1 1.578 -7.099 c .47 -1.946 1.017 -3.874 1.538 -5.807 c .175 -.647 .178 -1.252 -.287 -1.796 c -.781 -.911 -2.413 -1.111 -3.381 -.409 l -.428 .242 l .083 -.69 c .204 -1.479 .245 -2.953 -.161 -4.41 c -.506 -1.816 -1.802 -2.861 -3.686 -2.803 c -.878 .027 -1.8 .177 -2.613 .497 c -3.419 1.34 -6.048 3.713 -8.286 6.568 a 2.592 2.592 0 0 1 -.757 .654 c -2.893 1.604 -5.795 3.188 -8.696 4.778 l -3.229 1.769 c -.866 -.826 -1.653 -1.683 -2.546 -2.41 c -2.727 -2.224 -5.498 -4.393 -8.244 -6.592 c -2.434 -1.949 -4.792 -3.979 -6.596 -6.56 c -1.342 -1.92 -2.207 -4.021 -2.29 -6.395 c -.105 -3.025 .753 -5.789 2.293 -8.362 c 1.97 -3.292 4.657 -5.934 7.611 -8.327 c 3.125 -2.53 6.505 -4.678 10.008 -6.639 c 4.901 -2.743 9.942 -5.171 15.347 -6.774 c 5.542 -1.644 11.165 -2.585 16.965 -1.929 c 2.28 .258 4.494 .78 6.527 1.895 c 1.557 .853 2.834 1.97 3.428 3.716 c .586 1.718 .568 3.459 .162 5.204 c -.825 3.534 -2.76 6.447 -5.195 9.05 c -3.994 4.267 -8.866 7.172 -14.351 9.091 a 39.478 39.478 0 0 1 -9.765 2.083 c -2.729 .229 -5.401 -.013 -7.985 -.962 c -1.711 -.629 -3.201 -1.591 -4.399 -2.987 c -.214 -.25 -.488 -.521 -.887 -.287 c -.391 .23 -.46 .602 -.329 .979 c .219 .626 .421 1.278 .762 1.838 c .857 1.405 2.107 2.424 3.483 3.298 c 2.643 1.681 5.597 2.246 8.66 2.377 c 4.648 .201 9.183 -.493 13.654 -1.74 c 6.383 -1.78 11.933 -4.924 16.384 -9.884 c 3.706 -4.13 6.353 -8.791 6.92 -14.419 c .277 -2.747 -.018 -5.438 -1.304 -7.944 c -1.395 -2.715 -3.613 -4.734 -6.265 -6.125 C 68.756 18.179 64.588 17 60.286 17 h -4.31 c -5.21 0 -10.247 1.493 -15.143 3.274 c -3.706 1.349 -7.34 2.941 -10.868 4.703 c -7.683 3.839 -14.838 8.468 -20.715 14.833 c -2.928 3.171 -5.407 6.67 -6.833 10.79 a 40.494 40.494 0 0 0 -1.111 3.746 m 27.839 36.013 c -.333 4.459 -2.354 8.074 -5.657 11.002 c -1.858 1.646 -3.989 2.818 -6.471 3.23 c -.9 .149 -1.821 .185 -2.694 -.188 c -1.245 -.532 -1.524 -1.637 -1.548 -2.814 c -.037 -1.876 .62 -3.572 1.521 -5.186 c 1.176 -2.104 2.9 -3.708 4.741 -5.206 c 2.9 -2.361 6.046 -4.359 9.268 -6.245 l .243 -.1 c .498 1.84 .735 3.657 .597 5.507 z M 54.303 70.98 c -.235 1.424 -.529 2.849 -.945 4.229 c -1.438 4.777 -3.285 9.406 -5.282 13.973 c -.369 .845 -.906 1.616 -1.373 2.417 a 1.689 1.689 0 0 1 -.283 .334 c -.578 .571 -1.126 .541 -1.418 -.206 c -.34 -.868 -.549 -1.797 -.729 -2.716 c -.121 -.617 -.092 -1.265 -.13 -1.897 c .039 -4.494 1.41 -8.578 3.736 -12.38 c .959 -1.568 2.003 -3.062 3.598 -4.054 a 6.27 6.27 0 0 1 1.595 -.706 c .85 -.239 1.372 .154 1.231 1.006 z m 17.164 21.868 l 6.169 -7.203 c .257 2.675 -4.29 8.015 -6.169 7.203 z m 19.703 -4.847 c -.436 .25 -.911 .43 -1.358 .661 c -.409 .212 -.544 -.002 -.556 -.354 a 2.385 2.385 0 0 1 .093 -.721 c .833 -2.938 2.366 -5.446 4.647 -7.486 l .16 -.082 c 1.085 3.035 -.169 6.368 -2.986 7.982 z" + readonly property string markdown: "M 11.95 24.348 c -5.836 0 -10.618 4.867 -10.618 10.681 v 57.942 c 0 5.814 4.782 10.681 10.617 10.681 h 104.102 c 5.835 0 10.617 -4.867 10.617 -10.681 V 35.03 c 0 -5.814 -4.783 -10.681 -10.617 -10.681 H 14.898 l -.002 -.002 H 11.95 z m -.007 9.543 h 104.108 c .625 0 1.076 .423 1.076 1.14 v 57.94 c 0 .717 -.453 1.14 -1.076 1.14 H 11.949 c -.623 0 -1.076 -.423 -1.076 -1.14 V 35.029 c 0 -.715 .451 -1.135 1.07 -1.138 z M 20.721 84.1 V 43.9 H 32.42 l 11.697 14.78 L 55.81 43.9 h 11.696 v 40.2 H 55.81 V 61.044 l -11.694 14.78 l -11.698 -14.78 V 84.1 H 20.722 z m 73.104 0 L 76.28 64.591 h 11.697 V 43.9 h 11.698 v 20.69 h 11.698 z m 0 0" + readonly property string docker: "M 124.8 52.1 c -4.3 -2.5 -10 -2.8 -14.8 -1.4 c -.6 -5.2 -4 -9.7 -8 -12.9 l -1.6 -1.3 l -1.4 1.6 c -2.7 3.1 -3.5 8.3 -3.1 12.3 c .3 2.9 1.2 5.9 3 8.3 c -1.4 .8 -2.9 1.9 -4.3 2.4 c -2.8 1 -5.9 2 -8.9 2 H 79 V 49 H 66 V 24 H 51 v 12 H 26 v 13 H 13 v 14 H 1.8 l -.2 1.5 c -.5 6.4 .3 12.6 3 18.5 l 1.1 2.2 l .1 .2 c 7.9 13.4 21.7 19 36.8 19 c 29.2 0 53.3 -13.1 64.3 -40.6 c 7.4 .4 15 -1.8 18.6 -8.9 l .9 -1.8 l -1.6 -1 z M 28 39 h 10 v 11 H 28 V 39 z m 13.1 44.2 c 0 1.7 -1.4 3.1 -3.1 3.1 c -1.7 0 -3.1 -1.4 -3.1 -3.1 c 0 -1.7 1.4 -3.1 3.1 -3.1 c 1.7 .1 3.1 1.4 3.1 3.1 z M 28 52 h 10 v 11 H 28 V 52 z m -13 0 h 11 v 11 H 15 V 52 z m 27.7 50.2 c -15.8 -.1 -24.3 -5.4 -31.3 -12.4 c 2.1 .1 4.1 .2 5.9 .2 c 1.6 0 3.2 0 4.7 -.1 c 3.9 -.2 7.3 -.7 10.1 -1.5 c 2.3 5.3 6.5 10.2 14 13.8 h -3.4 z M 51 63 H 40 V 52 h 11 v 11 z m 0 -13 H 40 V 39 h 11 v 11 z m 13 13 H 53 V 52 h 11 v 11 z m 0 -13 H 53 V 39 h 11 v 11 z m 0 -13 H 53 V 26 h 11 v 11 z m 13 26 H 66 V 52 h 11 v 11 z M 38.8 81.2 c -.2 -.1 -.5 -.2 -.8 -.2 c -1.2 0 -2.2 1 -2.2 2.2 c 0 1.2 1 2.2 2.2 2.2 s 2.2 -1 2.2 -2.2 c 0 -.3 -.1 -.6 -.2 -.8 c -.2 .3 -.4 .5 -.8 .5 c -.5 0 -.9 -.4 -.9 -.9 c .1 -.4 .3 -.7 .5 -.8 z" + readonly property string latex: "M 29.2 63 H 28 c -.5 5.1 -1.2 11.3 -10 11.3 h -4 c -2.3 0 -2.4 -.3 -2.4 -2 V 45.8 c 0 -1.7 0 -2.4 4.7 -2.4 h 1.6 v -1.5 c -1.9 .1 -6.3 .1 -8.4 .1 c -1.9 0 -5.8 0 -7.5 -.1 v 1.5 h 1.1 c 3.8 0 3.9 .5 3.9 2.3 v 26.1 c 0 1.8 -.1 2.3 -3.9 2.3 H 2 v 1.5 h 25.8 L 29.2 63 z M 28.3 41.8 c -.2 -.6 -.3 -.8 -.9 -.8 s -.8 .2 -1 .8 l -8 20.3 c -.3 .8 -.9 2.4 -4 2.4 v 1.2 h 7.7 v -1.2 c -1.5 0 -2.5 -.7 -2.5 -1.7 c 0 -.2 0 -.3 .1 -.7 l 1.7 -4.3 h 9.9 l 2 5.1 c .1 .2 .2 .4 .2 .6 c 0 1 -1.9 1 -2.8 1 v 1.2 h 9.8 v -1.2 h -.7 c -2.3 0 -2.6 -.3 -2.9 -1.3 l -8.6 -21.4 z m -1.9 3.6 l 4.4 11.3 h -8.9 l 4.5 -11.3 z M 68.2 42.2 H 37.9 L 37 53.3 h 1.2 c .7 -8 1.4 -9.7 9 -9.7 c .9 0 2.2 0 2.7 .1 c 1 .2 1 .7 1 1.9 v 26.1 c 0 1.7 0 2.4 -5.2 2.4 h -2 v 1.5 c 2 -.1 7.1 -.1 9.4 -.1 s 7.4 0 9.5 .1 v -1.5 h -2 c -5.2 0 -5.2 -.7 -5.2 -2.4 v -26 c 0 -1 0 -1.7 .9 -1.9 c .5 -.1 1.9 -.1 2.8 -.1 c 7.5 0 8.2 1.6 8.9 9.7 h 1.2 l -1 -11.2 z M 94.9 74.2 h -1.2 c -1.2 7.6 -2.4 11.3 -10.9 11.3 h -6.6 c -2.3 0 -2.4 -.3 -2.4 -2 V 70.2 h 4.4 c 4.8 0 5.4 1.6 5.4 5.8 h 1.2 V 62.9 h -1.2 c 0 4.2 -.5 5.8 -5.4 5.8 h -4.4 v -12 c 0 -1.6 .1 -2 2.4 -2 h 6.4 c 7.6 0 8.9 2.7 9.7 9.7 h 1.2 l -1.4 -11.2 H 64.2 v 1.5 h 1.1 c 3.8 0 3.9 .5 3.9 2.3 v 26 c 0 1.8 -.1 2.3 -3.9 2.3 h -1.1 V 87 h 28.6 l 2.1 -12.8 z M 109.9 56.6 l 6.8 -10 c 1 -1.6 2.7 -3.2 7.2 -3.2 v -1.5 H 112 v 1.5 c 2 0 3.1 1.1 3.1 2.3 c 0 .5 -.1 .6 -.4 1.1 l -5.7 8.4 l -6.4 -9.6 c -.1 -.1 -.3 -.5 -.3 -.7 c 0 -.6 1.1 -1.4 3.2 -1.5 v -1.5 c -1.7 .1 -5.3 .1 -7.2 .1 c -1.5 0 -4.6 0 -6.5 -.1 v 1.5 h .9 c 2.7 0 3.7 .3 4.6 1.7 l 9.1 13.8 l -8.1 12 c -.7 1 -2.2 3.3 -7.2 3.3 v 1.5 H 103 v -1.5 c -2.3 0 -3.1 -1.4 -3.1 -2.3 c 0 -.4 .1 -.6 .5 -1.2 l 7 -10.4 l 7.9 11.9 c .1 .2 .2 .4 .2 .5 c 0 .6 -1.1 1.4 -3.2 1.5 v 1.5 c 1.7 -.1 5.4 -.1 7.2 -.1 c 2.1 0 4.4 0 6.5 .1 v -1.5 h -.9 c -2.6 0 -3.6 -.2 -4.7 -1.8 l -10.5 -15.8 z" + readonly property string graphql: "M 18.39 96.852 l -4.6 -2.657 L 65.04 5.434 l 4.597 2.656 z m 0 0 M 12.734 87.105 H 115.23 v 5.31 H 12.734 z m 0 0 M 66.031 119.688 L 14.766 90.09 l 2.656 -4.602 l 51.266 29.602 z m 0 0 M 110.566 42.543 L 59.301 12.941 l 2.656 -4.597 l 51.266 29.597 z m 0 0 M 17.434 42.523 l -2.657 -4.601 l 51.27 -29.598 l 2.656 4.598 z m 0 0 M 109.621 96.852 L 58.375 8.09 l 4.598 -2.656 l 51.25 88.761 z m 0 0 M 16.8 34.398 h 5.313 v 59.204 h -5.312 z m 0 0 M 105.887 34.398 h 5.312 v 59.204 h -5.312 z m 0 0 M 65.129 117.441 l -2.32 -4.02 l 44.586 -25.745 l 2.32 4.02 z m 0 0 M 118.238 95.328 c -3.07 5.344 -9.918 7.168 -15.261 4.098 c -5.344 -3.074 -7.168 -9.922 -4.098 -15.266 c 3.074 -5.344 9.922 -7.168 15.266 -4.097 c 5.375 3.105 7.199 9.921 4.093 15.265 M 29.09 43.84 c -3.074 5.344 -9.922 7.168 -15.266 4.097 c -5.344 -3.074 -7.168 -9.921 -4.097 -15.265 c 3.074 -5.344 9.921 -7.168 15.265 -4.098 c 5.344 3.106 7.168 9.922 4.098 15.266 M 9.762 95.328 c -3.075 -5.344 -1.25 -12.16 4.093 -15.266 c 5.344 -3.07 12.16 -1.246 15.266 4.098 c 3.07 5.344 1.246 12.16 -4.098 15.266 c -5.375 3.07 -12.191 1.246 -15.261 -4.098 M 98.91 43.84 c -3.07 -5.344 -1.246 -12.16 4.098 -15.266 c 5.344 -3.07 12.16 -1.246 15.265 4.098 c 3.07 5.344 1.247 12.16 -4.097 15.266 c -5.344 3.07 -12.192 1.246 -15.266 -4.098 M 64 126.656 a 11.158 11.158 0 0 1 -11.168 -11.168 A 11.158 11.158 0 0 1 64 104.32 a 11.158 11.158 0 0 1 11.168 11.168 c 0 6.145 -4.992 11.168 -11.168 11.168 M 64 23.68 a 11.158 11.158 0 0 1 -11.168 -11.168 A 11.158 11.158 0 0 1 64 1.344 a 11.158 11.158 0 0 1 11.168 11.168 A 11.158 11.158 0 0 1 64 23.68" + + function path(language) { + const key = language.toLowerCase() + + switch (key) { + case "c": + return c + case "cpp": + return cpp + case "csharp": + return csharp + case "python": + return python + case "rust": + return rust + case "javascript": + return javascript + case "typescript": + return typescript + case "bash": + return bash + case "zsh": + return zsh + case "powershell": + return powershell + case "cmake": + return cmake + case "lua": + return lua + case "java": + return java + case "kotlin": + return kotlin + case "swift": + return swift + case "go": + return go + case "dart": + return dart + case "php": + return php + case "ruby": + return ruby + case "scala": + return scala + case "haskell": + return haskell + case "elixir": + return elixir + case "erlang": + return erlang + case "clojure": + return clojure + case "r": + return r + case "perl": + return perl + case "zig": + return zig + case "nim": + return nim + case "ocaml": + return ocaml + case "fsharp": + return fsharp + case "visualbasic": + return visualbasic + case "fortran": + return fortran + case "crystal": + return crystal + case "gleam": + return gleam + case "julia": + return julia + case "objectivec": + return objectivec + case "vala": + return vala + case "groovy": + return groovy + case "racket": + return racket + case "haxe": + return haxe + case "purescript": + return purescript + case "delphi": + return delphi + case "coffeescript": + return coffeescript + case "elm": + return elm + case "awk": + return awk + case "matlab": + return matlab + case "solidity": + return solidity + case "wasm": + return wasm + case "vim": + return vim + case "sql": + return sql + case "json": + return json + case "yaml": + return yaml + case "xml": + return xml + case "html": + return html + case "css": + return css + case "sass": + return sass + case "markdown": + return markdown + case "docker": + return docker + case "latex": + return latex + case "graphql": + return graphql + case "cpp": + return cpp + case "cc": + return cpp + case "cxx": + return cpp + case "cs": + return csharp + case "js": + return javascript + case "jsx": + return javascript + case "ts": + return typescript + case "tsx": + return typescript + case "py": + return python + case "sh": + return bash + case "shell": + return bash + case "ps1": + return powershell + case "vb": + return visualbasic + case "objective-c": + return objectivec + case "obj-c": + return objectivec + case "groovyscript": + return groovy + case "pascal": + return delphi + case "coffee": + return coffeescript + case "mysql": + return sql + case "postgres": + return sql + case "postgresql": + return sql + case "sqlite": + return sql + case "yml": + return yaml + case "htm": + return html + case "scss": + return sass + case "md": + return markdown + case "dockerfile": + return docker + case "docker-compose": + return docker + case "tex": + return latex + case "gql": + return graphql + default: + return "" + } + } + + function name(language) { + switch (language.toLowerCase()) { + case "c": + return "C" + case "cpp": + return "C++" + case "csharp": + return "C#" + case "python": + return "Python" + case "rust": + return "Rust" + case "javascript": + return "JavaScript" + case "typescript": + return "TypeScript" + case "bash": + return "Bash" + case "zsh": + return "Zsh" + case "powershell": + return "PowerShell" + case "cmake": + return "CMake" + case "lua": + return "Lua" + case "java": + return "Java" + case "kotlin": + return "Kotlin" + case "swift": + return "Swift" + case "go": + return "Go" + case "dart": + return "Dart" + case "php": + return "PHP" + case "ruby": + return "Ruby" + case "scala": + return "Scala" + case "haskell": + return "Haskell" + case "elixir": + return "Elixir" + case "erlang": + return "Erlang" + case "clojure": + return "Clojure" + case "r": + return "R" + case "perl": + return "Perl" + case "zig": + return "Zig" + case "nim": + return "Nim" + case "ocaml": + return "OCaml" + case "fsharp": + return "F#" + case "visualbasic": + return "Visual Basic" + case "fortran": + return "Fortran" + case "crystal": + return "Crystal" + case "gleam": + return "Gleam" + case "julia": + return "Julia" + case "objectivec": + return "Objective-C" + case "vala": + return "Vala" + case "groovy": + return "Groovy" + case "racket": + return "Racket" + case "haxe": + return "Haxe" + case "purescript": + return "PureScript" + case "delphi": + return "Delphi" + case "coffeescript": + return "CoffeeScript" + case "elm": + return "Elm" + case "awk": + return "AWK" + case "matlab": + return "MATLAB" + case "solidity": + return "Solidity" + case "wasm": + return "Wasm" + case "vim": + return "Vim" + case "sql": + return "SQL" + case "json": + return "JSON" + case "yaml": + return "YAML" + case "xml": + return "XML" + case "html": + return "HTML" + case "css": + return "CSS" + case "sass": + return "Sass" + case "markdown": + return "Markdown" + case "docker": + return "Docker" + case "latex": + return "LaTeX" + case "graphql": + return "GraphQL" + case "cpp": + return "C++" + case "cc": + return "C++" + case "cxx": + return "C++" + case "cs": + return "C#" + case "js": + return "JavaScript" + case "jsx": + return "JavaScript" + case "ts": + return "TypeScript" + case "tsx": + return "TypeScript" + case "py": + return "Python" + case "sh": + return "Bash" + case "shell": + return "Bash" + case "ps1": + return "PowerShell" + case "vb": + return "Visual Basic" + case "objective-c": + return "Objective-C" + case "obj-c": + return "Objective-C" + case "groovyscript": + return "Groovy" + case "pascal": + return "Delphi" + case "coffee": + return "CoffeeScript" + case "mysql": + return "SQL" + case "postgres": + return "SQL" + case "postgresql": + return "SQL" + case "sqlite": + return "SQL" + case "yml": + return "YAML" + case "htm": + return "HTML" + case "scss": + return "Sass" + case "md": + return "Markdown" + case "dockerfile": + return "Docker" + case "docker-compose": + return "Docker" + case "tex": + return "LaTeX" + case "gql": + return "GraphQL" + default: + return language + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml new file mode 100644 index 0000000..75666af --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property bool isUser + required property LlmSegment segment + required property bool hovered + required property Repeater repeater + required property int index + required property ChatMessage message + required property ChatGeneration current + + // Widest the bubble may grow to; assistant bubbles always use it so + // the markdown blocks have a deterministic width to wrap against. + readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge + + signal edit(text: string) + + implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + + CustomClippingRect { + id: bubble + + 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 + blocks.anchors.topMargin * 2 + anchors.right: root.isUser ? parent.right : undefined + + // Behavior on implicitHeight { + // enabled: !root.segment.running + // + // Anim { + // type: Anim.DefaultEffects + // } + // } + + // User messages stay a plain editable text field. + TextEditBase { + id: msgText + + property string cachedText: root.segment.text + property bool cancelled: false + + visible: root.isUser + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + animateCursor: false + color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary + font.pointSize: Tokens.font.size.smaller + readOnly: true + selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) + text: root.isUser ? root.segment.text : "" + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + + Keys.onPressed: event => { + if (event.key == Qt.Key_Return) { + if (!(event.modifiers & Qt.ShiftModifier)) { + readOnly = true; + event.accepted = true; + } + } else if (event.key == Qt.Key_Escape) { + cancelled = true; + readOnly = true; + event.accepted = true; + } + } + onEditingFinished: { + readOnly = true; + } + onReadOnlyChanged: { + if (readOnly) { + animateCursor = false; + textFormat = CustomText.MarkdownText; + + if (cancelled) { + text = cachedText; + cancelled = false; + return; + } + + root.edit(text); + } else { + textFormat = CustomText.PlainText; + text = cachedText; + forceActiveFocus(); + cursorPosition = text.length; + animateCursor = true; + } + } + } + + // Assistant content: parsed markdown blocks. + MarkdownBlocks { + id: blocks + + visible: !root.isUser + anchors.topMargin: Tokens.padding.medium + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + blocks: root.segment.markdown + } + } + + Actions { + id: actionsRow + + readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1) + + anchors.left: parent.left + anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right + anchors.top: bubble.bottom + opacity: shouldBeActive ? 1 : 0 + visible: opacity > 0 + anchors.topMargin: Tokens.spacing.medium + edit: msgText + hovered: root.hovered + isUser: root.isUser + segment: root.segment + current: root.current + message: root.message + + Behavior on opacity { + Anim {} + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml new file mode 100644 index 0000000..d3370e7 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ColumnLayout { + id: root + + spacing: Tokens.spacing.small + + Item { + Layout.fillHeight: true + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("Send a message to get started") + } + + Item { + Layout.fillHeight: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml new file mode 100644 index 0000000..32377ad --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -0,0 +1,91 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Column { + id: root + + required property var blocks + + spacing: Tokens.spacing.small + + Repeater { + id: blockRep + + model: ScriptModel { + values: root.blocks + objectProp: "id" + } + delegate: DelegateChooser { + role: "type" + + DelegateChoice { + roleValue: LlmMarkdown.Type.Code + + delegate: CodeBlockView { + required property var modelData + + language: modelData.language + code: modelData.code + anchors.right: parent.right + anchors.margins: Tokens.spacing.small + anchors.left: parent.left + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Math + + delegate: MathBlockView { + required property var modelData + + latex: modelData.latex + anchors.right: parent.right + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Heading + + delegate: TextEditBase { + required property var modelData + + color: Colors.palette.m3onSurface + anchors.right: parent.right + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + text: modelData.text + textFormat: Text.MarkdownText + readOnly: true + font.bold: true + font.pointSize: { + if (modelData.level <= 1) + return Tokens.font.size.large; + if (modelData.level === 2) + return Tokens.font.size.normal; + return Tokens.font.size.smaller; + } + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Text + + delegate: BubbleEdit { + required property var modelData + + anchors.right: parent.right + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + text: modelData.text + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml new file mode 100644 index 0000000..0a8727d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -0,0 +1,49 @@ +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property string latex + property color mathColor: Colors.palette.m3onSurface + property real fontSize: Tokens.font.size.large + + implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 + + LlmMathText { + id: math + + latex: root.latex + color: root.mathColor + fontPointSize: root.fontSize + devicePixelRatio: Screen.devicePixelRatio + } + + Image { + id: equationImage + + anchors.centerIn: parent + source: math.imageUrl + asynchronous: true + visible: math.ok + fillMode: Image.PreserveAspectFit + smooth: true + width: Math.min(math.width, root.width) + height: math.width > 0 ? width * (math.height / math.width) : 0 + } + + CustomText { + id: fallbackText + + anchors.centerIn: parent + color: root.mathColor + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + text: root.latex + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + visible: !math.ok + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml new file mode 100644 index 0000000..7f267b1 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -0,0 +1,210 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +MouseArea { + id: root + + property ChatGeneration current: modelData.activeGeneration + required property int index + readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property ChatMessage modelData + property bool reasoningExpanded: false + readonly property var blocks: blockify(current.segments) + property real savedOffset: -1 + property bool stickActive: false + + function handleReasoningToggle(expanded: bool): void { + const view = root.ListView.view; + if (!view) + return; + + if (expanded) { + root.savedOffset = view.contentY - root.y; + root.reasoningExpanded = true; + } else { + root.reasoningExpanded = false; + root.stickActive = false; + settleTimer.stop(); + if (!isNaN(root.savedOffset)) { + restoreAnim.to = root.y + root.savedOffset; + restoreAnim.restart(); + root.savedOffset = NaN; + } + } + } + + function blockify(segs) { + const blocks = []; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (seg.type === LlmSegment.Type.Content) { + blocks.push({ + kind: "content", + id: i, + segments: [seg] + }); + } else { + const last = blocks.length ? blocks[blocks.length - 1] : null; + if (last && last.kind === "process") { + last.segments.push(seg); + } else { + blocks.push({ + kind: "process", + id: i, + segments: [seg] + }); + } + } + } + + return blocks; + } + + implicitWidth: ListView.view.width + hoverEnabled: true + preventStealing: false + implicitHeight: layout.implicitHeight + Tokens.padding.medium + + Timer { + id: settleTimer + interval: 16 + + onTriggered: { + root.stickActive = false; + } + } + + onImplicitHeightChanged: { + if (root.reasoningExpanded) { + root.stickActive = true; + settleTimer.restart(); + + const view = root.ListView.view; + if (!view) + return; + const bottomEdge = root.y + root.height + Tokens.padding.large * 2; + const viewportBottom = view.contentY + view.height; + if (bottomEdge > viewportBottom) + view.contentY = bottomEdge - view.height; + } + } + + Behavior on current { + id: changeAnim + + property var object: targetProperty + readonly property bool next: { + const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue); + return cond; + } + + animation: SequentialAnimation { + ParallelAnimation { + Anim { + target: root + property: "x" + to: changeAnim.next ? root.width / 4 : -root.width / 4 + from: 0 + type: Anim.FastEffects + } + + Anim { + target: root + property: "opacity" + from: 1 + to: 0 + type: Anim.FastEffects + } + } + + PropertyAction {} + + ParallelAnimation { + Anim { + target: root + property: "x" + from: changeAnim.next ? -root.width / 4 : root.width / 4 + type: Anim.FastEffects + to: 0 + } + + Anim { + target: root + property: "opacity" + from: 0 + type: Anim.FastEffects + to: 1 + } + } + } + } + + Anim { + id: restoreAnim + + property: "contentY" + target: root.ListView.view + type: Anim.DefaultEffects + } + + Column { + id: layout + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + Repeater { + id: segmentRep + + model: ScriptModel { + values: root.blocks + objectProp: "id" + } + delegate: DelegateChooser { + role: "kind" + + DelegateChoice { + roleValue: "process" + + delegate: ProcessBlock { + width: root.width + blocks: root.blocks + + onExpandedChanged: root.handleReasoningToggle(expanded) + } + } + + DelegateChoice { + roleValue: "content" + + delegate: ContentBubble { + required property var modelData + + isUser: root.isUser + segment: modelData.segments[0] + width: root.width + repeater: segmentRep + message: root.modelData + current: root.current + hovered: root.containsMouse + + onEdit: text => { + root.modelData.edit(text); + + if (root.isUser) + root.modelData.generate(); + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml new file mode 100644 index 0000000..c681bfb --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -0,0 +1,163 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +CustomClippingRect { + id: root + + required property int index + required property var modelData + required property var blocks + readonly property var segments: modelData.segments + readonly property bool isActive: index === blocks.length - 1 + property bool expanded: false + readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] + readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) + + implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + + RowLayout { + id: layout + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + Item { + implicitHeight: expandBtn.implicitHeight + implicitWidth: expandBtn.implicitWidth + + LoadingIndicator { + id: spinnerReasoning + + implicitSize: collapsedText.implicitHeight + anchors.centerIn: parent + opacity: root.isActive ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + IconButton { + id: expandBtn + + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + anchors.centerIn: parent + opacity: root.isActive ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: root.expanded = !root.expanded + } + } + + CustomText { + id: collapsedText + + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.medium + text: { + if (root.isActive) { + if (root.lastSegment.type === LlmSegment.Type.ToolCall) + return qsTr("Using %1...").arg(root.lastSegment.name); + return qsTr("Thinking..."); + } + return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); + } + + Behavior on opacity { + Anim {} + } + } + } + + CustomRect { + id: expandedRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: layout.bottom + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small + color: Colors.palette.m3surfaceContainerLow + implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 + opacity: root.expanded ? 1 : 0 + radius: Tokens.rounding.medium + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + ListView { + id: expandedContent + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + model: root.segments + spacing: Tokens.spacing.medium + + delegate: Item { + id: segment + + required property LlmSegment modelData + required property int index + + implicitWidth: ListView.view.width + implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin + + RowLayout { + id: header + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.small + + MaterialIcon { + text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build" + fill: 1 + } + + CustomText { + Layout.fillWidth: true + text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1)) + } + } + + CustomText { + id: content + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.small + anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.small + anchors.right: parent.right + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website") + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml new file mode 100644 index 0000000..c0ab82d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -0,0 +1,53 @@ +pragma Singleton + +import Quickshell +import QtQuick +import ZShell.Config +import qs.Helpers +import qs.Services + +Singleton { + id: root + + function create(parent: Item, props: var): void { + chatComp.createObject(parent ?? dummy, props); + Visibilities.getForActive().sidebar = false; + ChatState.isWindow = true; + } + + QtObject { + id: dummy + } + + Component { + id: chatComp + + FloatingWindow { + id: win + + property var props + + color: Colors.tPalette.m3surface + implicitHeight: chat.implicitHeight + implicitWidth: chat.implicitWidth + minimumSize.height: Config.sidebar.sizes.width + minimumSize.width: Config.sidebar.sizes.width + surfaceFormat.opaque: false + title: qsTr("ZShell - %1").arg(ChatState.title) + + Component.onCompleted: ChatState.screen = screen + onVisibleChanged: { + if (!visible) { + destroy(); + ChatState.isWindow = false; + } + } + + SidebarView { + id: chat + + anchors.fill: parent + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml new file mode 100644 index 0000000..1ffdb0c --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -0,0 +1,110 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Modules.Notifications.Sidebar.Chat.Content +import qs.Services + +RowLayout { + id: root + + property int breakpoint: 700 + property alias conversationModel: sidebar.model + + readonly property bool isWide: root.width >= root.breakpoint + property bool narrowShowsSidebar: true + + function openConversation(conv: ChatSession, index: int): void { + } + + ChatList { + id: sidebar + + Layout.fillHeight: true + Layout.margins: Tokens.padding.medium + Layout.rightMargin: Tokens.spacing.medium + Layout.maximumWidth: Config.sidebar.sizes.width + Layout.preferredWidth: root.width / 4 + Layout.minimumWidth: Config.sidebar.sizes.width / 2 + highlight: true + + Behavior on implicitWidth { + Anim {} + } + model: ScriptModel { + values: Chat.chats.values + } + + onLoadChatRequest: (chat, index) => { + ChatState.currentIdx = index; + ChatState.chatSession = chat; + } + + onDeleteChatRequest: chat => { + Chat.chats.remove(chat); + ChatState.currentIdx = -1; + ChatState.chatSession = null; + } + onNewChatRequest: { + const data = Chat.chats.insert(); + ChatState.currentIdx = 0; + ChatState.chatSession = data; + } + } + + Item { + Layout.margins: Tokens.padding.extraLarge + Layout.leftMargin: Tokens.spacing.extraLarge + Layout.topMargin: Tokens.padding.large + Layout.preferredWidth: Config.sidebar.sizes.width * 2 + Layout.fillWidth: true + Layout.fillHeight: true + + ChatHost { + id: convHost + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + implicitWidth: Math.min(parent.width, 800) + clip: true + + onImplicitWidthChanged: console.log(implicitWidth) + } + + ColumnLayout { + id: noChatLayout + anchors.centerIn: parent + opacity: ChatState.chatSession ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + MaterialIcon { + Layout.alignment: Qt.AlignCenter + text: "chat_bubble_off" + font.pointSize: Tokens.font.size.extraLarge + color: Colors.tPalette.m3onSurfaceVariant + } + + CustomText { + Layout.alignment: Qt.AlignCenter + text: qsTr("Start or create a chat") + color: Colors.tPalette.m3onSurfaceVariant + } + } + } + + Component { + id: conversationView + + ChatContent {} + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 06f0c5d..4b76e12 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -1,7 +1,11 @@ -import qs.Components +pragma ComponentBehavior: Bound + import ZShell.Config +import ZShell.Llm import QtQuick import QtQuick.Layouts +import qs.Components +import qs.Modules.Notifications.Sidebar.Chat import qs.Services Item { @@ -10,21 +14,94 @@ Item { required property Props props required property var visibilities + Connections { + function onIsWindowChanged(): void { + if (ChatState.isWindow) + root.props.currentTab = 0; + } + + target: ChatState + } + ColumnLayout { id: layout anchors.fill: parent spacing: Tokens.spacing.small - CustomRect { + Tabs { + Layout.fillWidth: true + Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight + dashState: root.props + nonAnimWidth: layout.width + visible: height > 0 + } + + CustomClippingRect { Layout.fillHeight: true Layout.fillWidth: true - color: Colors.tPalette.m3surfaceContainerLow radius: Tokens.rounding.small - NotifDock { - props: root.props - visibilities: root.visibilities + Item { + id: pages + + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width + opacity: root.props.currentTab === 0 ? 1 : 0 + visible: opacity > 0 + x: root.props.currentTab === 0 ? 0 : -root.width + + Behavior on opacity { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + NotifDock { + props: root.props + visibilities: root.visibilities + } + } + } + + Item { + id: chatPage + + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width + opacity: root.props.currentTab === 1 ? 1 : 0 + visible: opacity > 0 + x: root.props.currentTab === 0 ? root.width : 0 + z: 1 + + Behavior on opacity { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + ChatPanel { + anchors.fill: parent + } + } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml index 21a2541..dcb4eb0 100644 --- a/Modules/Notifications/Sidebar/Props.qml +++ b/Modules/Notifications/Sidebar/Props.qml @@ -1,6 +1,8 @@ import Quickshell +import ZShell.Llm PersistentProperties { + property int currentTab: 0 property list expandedNotifs: [] reloadableId: "sidebar" diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml new file mode 100644 index 0000000..2a9bfb8 --- /dev/null +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Templates +import Quickshell +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + readonly property alias count: bar.count + required property PersistentProperties dashState + required property real nonAnimWidth + + implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + + TabBar { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + background: null + currentIndex: root.dashState.currentTab + implicitHeight: contentHeight + + contentItem: RowLayout { + spacing: 0 + + Repeater { + model: bar.contentModel + } + } + + onCurrentIndexChanged: root.state.currentTab = currentIndex + + Tab { + iconName: "notifications" + text: qsTr("Notifications") + } + + Tab { + iconName: "chat" + text: qsTr("Chat") + } + } + + Item { + id: indicator + + anchors.top: bar.bottom + clip: true + implicitHeight: 3 + implicitWidth: bar.currentItem.implicitWidth + x: { + const tab = bar.currentItem; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + } + + Behavior on implicitWidth { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3primary + implicitHeight: parent.implicitHeight * 2 + radius: Tokens.rounding.full + } + } + + CustomRect { + id: separator + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: indicator.bottom + color: Colors.palette.m3outlineVariant + implicitHeight: 1 + } + + component Tab: TabButton { + id: tab + + readonly property bool current: TabBar.tabBar.currentItem === this + required property string iconName + + Layout.fillWidth: true + Layout.preferredWidth: 1 + background: null + implicitHeight: implicitContentHeight + implicitWidth: implicitContentWidth + + contentItem: Item { + implicitHeight: icon.height + label.height + implicitWidth: Math.max(icon.width, label.width) + + StateLayer { + color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + radius: Tokens.rounding.small + + onClicked: root.dashState.currentTab = tab.TabBar.index + } + + MaterialIcon { + id: icon + + anchors.bottom: label.top + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + font.pointSize: 18 + text: tab.iconName + + Behavior on fill { + Anim { + } + } + } + + CustomText { + id: label + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + text: tab.text + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..4fbf307 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..1bdf03d 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,14 +1,12 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property BarPopouts.Wrapper popouts required property PersistentProperties props required property var visibilities @@ -21,8 +19,7 @@ Item { anchors.fill: parent spacing: 8 - IdleInhibit { - } + IdleInhibit {} Record { props: root.props @@ -32,7 +29,6 @@ Item { Toggles { Layout.fillWidth: true - popouts: root.popouts visibilities: root.visibilities } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..fa10251 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -4,13 +4,11 @@ import QtQuick import Quickshell import ZShell.Config import qs.Components -import qs.Modules.Bar.Popouts as BarPopouts Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts readonly property PersistentProperties props: PersistentProperties { property string recordingConfirmDelete property bool recordingListExpanded: false @@ -18,12 +16,12 @@ Item { reloadableId: "utilities" } - readonly property bool shouldBeActive: visibilities.sidebar + readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive required property Item sidebar required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight + 8 * 2 + implicitHeight: content.implicitHeight + Tokens.padding.small * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) opacity: 1 - offsetScale visible: offsetScale < 1 @@ -45,7 +43,6 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts props: root.props visibilities: root.visibilities } diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..dc92aa7 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -1,7 +1,11 @@ pragma ComponentBehavior: Bound import qs.Components +import qs.Components.Toast +import ZShell import ZShell.Config +import ZShell.Llm +import Quickshell import QtQuick Item { @@ -12,6 +16,7 @@ Item { readonly property Props props: Props { } readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled + readonly property bool chatActive: props.currentTab === 1 required property var visibilities anchors.rightMargin: (-implicitWidth - 5) * offsetScale @@ -26,6 +31,17 @@ Item { } } + Connections { + target: Chat + + function onErrorOccurred(message: string): void { + if (root.shouldBeActive && root.chatActive) + return; + + Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error); + } + } + Loader { id: content diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 1d4d547..805682c 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -15,13 +15,19 @@ Item { required property Component content required property string header property int horizontalContentMargin - required property string icon + property string icon required property string label + property alias iconLabel: openButton.iconLabel + property alias rowButton: openButton + 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 + required property string settingAnchor + property bool first: false + property bool last: false signal accepted signal cancelled @@ -44,8 +50,7 @@ Item { color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer Behavior on color { - CAnim { - } + CAnim {} } } @@ -55,6 +60,7 @@ Item { anchors.fill: parent enabled: false hoverEnabled: enabled + preventStealing: true parent: root.open ? root.rootParent : root onClicked: root.open = false @@ -75,7 +81,8 @@ Item { backdrop.enabled: true dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased - dialogBg.radius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased dialogContent.opacity: 1 dialogWrapper.height: root.openHeight dialogWrapper.width: root.openWidth @@ -103,7 +110,7 @@ Item { } Anim { - properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" type: Anim.DefaultEffects } @@ -131,12 +138,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 +153,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 { @@ -167,6 +177,7 @@ Item { sourceComponent: MouseArea { onWheel: event => event.accepted = true + preventStealing: true ColumnLayout { anchors.bottomMargin: Tokens.padding.largeIncreased @@ -219,6 +230,7 @@ Item { text: qsTr("Cancel") type: TextButton.Text verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true onClicked: { root.cancelled(); @@ -232,6 +244,7 @@ Item { isRound: true text: root.acceptLabel type: TextButton.Text + stateLayer.preventStealing: true verticalPadding: Tokens.padding.small onClicked: { diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..d8cb270 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -10,6 +10,7 @@ DialogRowButton { required property var model property var selectedItem + property var initialSelect function keyFor(item: var): string { return item.id; @@ -91,7 +92,14 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 5d2953d..50d1c3d 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -17,6 +17,7 @@ ConnectedRect { property alias subtext: subLabel.text property alias text: label.text property string trailingIcon + property string activeItem signal clicked(event: MouseEvent) @@ -40,15 +41,16 @@ ConnectedRect { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } MaterialIcon { id: iconLabel color: Colors.palette.m3onSurfaceVariant + visible: root.icon !== "" fill: 1 + text: root.icon font.pointSize: Tokens.font.size.normal } @@ -79,6 +81,26 @@ ConnectedRect { } } + Loader { + active: root.activeItem && root.subtext + asynchronous: true + visible: active + Layout.fillHeight: true + + sourceComponent: CustomRect { + color: Colors.palette.m3secondaryContainer + radius: Tokens.rounding.full + implicitWidth: text.implicitWidth + Tokens.padding.medium * 2 + + CustomText { + id: text + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: root.activeItem + } + } + } + Loader { active: root.trailingIcon asynchronous: true diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml new file mode 100644 index 0000000..193fd1a --- /dev/null +++ b/Modules/Settings/Common/SearchPopup.qml @@ -0,0 +1,128 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +DialogRowButton { + id: root + + required property var model + property var selectedItem + property var initialSelect + + acceptAllowed: !!selectedItem + horizontalContentMargin: -Tokens.padding.extraSmall + separateContent: true + + content: Component { + ColumnLayout { + VerticalFadeListView { + bottomMargin: Tokens.padding.large + Layout.fillHeight: true + Layout.fillWidth: true + model: ScriptModel { + values: { + const s = searchField.text; + var regex = new RegExp(s, "i"); + + return root.model.filter(n => regex.test(n)); + } + } + spacing: 0 + topMargin: Tokens.padding.large + + delegate: CustomRect { + id: item + + required property var modelData + readonly property bool selected: root.selectedItem === modelData + + anchors.left: ListView.view.contentItem.left + anchors.margins: 1 // Gets cut off for some reason without this + anchors.right: ListView.view.contentItem.right + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + implicitHeight: label.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + StateLayer { + id: stateLayer + + onClicked: root.selectedItem = item.modelData + } + + CustomText { + id: label + + anchors.left: parent.left + anchors.margins: Tokens.padding.large + anchors.right: item.selected ? checkIcon.left : parent.right + anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins + anchors.verticalCenter: parent.verticalCenter + color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Tokens.font.size.smaller + text: item.modelData + } + + MaterialIcon { + id: checkIcon + + anchors.margins: Tokens.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: Colors.palette.m3onTertiaryContainer + font.pointSize: Tokens.font.size.normal + opacity: item.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + } + + SearchBar { + id: searchField + + Layout.fillWidth: true + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.padding: Tokens.padding.extraSmall + font.pointSize: Tokens.font.size.small + placeholderText: qsTr("Search...") + searchIcon.anchors.leftMargin: Tokens.padding.large + searchIcon.font.pointSize: Tokens.font.size.large + + Behavior on bg.border.color { + CAnim {} + } + } + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } + } +} diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml new file mode 100644 index 0000000..79d6035 --- /dev/null +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -0,0 +1,246 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Blobs +import qs.Components +import ZShell.Config +import qs.Services + +Item { + id: root + + property bool acceptAllowed: true + required property string acceptLabel + required property Component content + required property string header + property int horizontalContentMargin + property string icon + required property string label + property alias iconLabel: openButton.iconText + property alias rowButton: openButton + property alias contentItem: dialogContent.item + property string subtext: "" + property bool open + property real openHeight: Math.min(rootParent.height * 0.8, 400) + property real openWidth: Math.min(rootParent.width * 0.8, 600) + required property Item rootParent + property bool first: false + property bool last: false + required property string settingAnchor + property color openColor: Colors.palette.m3surfaceContainerHighest + + signal accepted + signal cancelled + + function reparentWrapper(): void { + const newParent = open ? rootParent : root; + const pos = dialogWrapper.mapToItem(newParent, 0, 0); + dialogWrapper.parent = newParent; + dialogWrapper.x = pos.x; + dialogWrapper.y = pos.y; + } + + Layout.fillWidth: true + implicitHeight: openButton.implicitHeight + z: open || dialogTransition.running ? 2 : 0 + + BlobGroup { + id: blobGroup + + color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: dialogWrapper + + height: openButton.implicitHeight + width: root.width + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased + dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogContent.opacity: 1 + dialogWrapper.height: root.openHeight + dialogWrapper.width: root.openWidth + dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2 + dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2 + elevation.opacity: 1 + openButton.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: dialogBg.bottomLeftRadius + bottomRightRadius: dialogBg.bottomRightRadius + level: 4 + opacity: 0 + radius: dialogBg.radius + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + + BlobRect { + id: dialogBg + + anchors.fill: parent + 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) + } + + TimeDialogToggle { + id: openButton + + anchors.left: parent.left + anchors.right: parent.right + color: "transparent" + height: Math.min(implicitHeight, parent.height) + subtext: root.subtext + last: root.last + first: root.first + text: root.label + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + + onClicked: root.open = true + } + + Loader { + id: dialogContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + clip: true + implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2 + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font.pointSize: Tokens.font.size.large + text: root.header + } + + Loader { + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + sourceComponent: root.content + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.cancelled(); + root.open = false; + } + } + + TextButton { + enabled: root.acceptAllowed + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + root.accepted(); + root.open = false; + } + } + } + } + } + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + } +} diff --git a/Modules/Settings/Common/TimeDialogSelect.qml b/Modules/Settings/Common/TimeDialogSelect.qml new file mode 100644 index 0000000..33967dc --- /dev/null +++ b/Modules/Settings/Common/TimeDialogSelect.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Helpers +import qs.Components +import qs.Services + +TimeDialogRow { + id: root + + required property int start + required property int end + property int endCurrent + property int startCurrent + openColor: Colors.palette.m3surfaceContainer + openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0) + openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0) + + function keyFor(item: var): string { + return item.id; + } + + function labelFor(item: var): string { + return item.label; + } + + horizontalContentMargin: -Tokens.padding.extraSmall + + content: Component { + TimeInput { + id: timeContent + start: root.start + end: root.end + + onEndEdit: value => root.endCurrent = value + onStartEdit: value => root.startCurrent = value + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + root.z = 5; + } else { + root.rootParent.interactive = true; + root.z = 1; + } + } +} diff --git a/Modules/Settings/Common/OverlayRow.qml b/Modules/Settings/Common/TimeDialogToggle.qml similarity index 84% rename from Modules/Settings/Common/OverlayRow.qml rename to Modules/Settings/Common/TimeDialogToggle.qml index f7a6dae..0cd1555 100644 --- a/Modules/Settings/Common/OverlayRow.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -10,12 +10,14 @@ ConnectedRect { property alias checked: switchButton.checked property int horizontalPadding: Tokens.padding.largeIncreased - required property Component popup property alias subtext: subtext.text property alias text: text.text + property alias icon: icon + property string iconText: "open_in_new" property int verticalPadding: Tokens.padding.small - signal clicked(checked: bool) + signal clicked(event: MouseEvent) + signal toggled(checked: bool) Layout.fillWidth: true implicitHeight: layout.implicitHeight + verticalPadding * 2 @@ -50,11 +52,11 @@ ConnectedRect { anchors.right: switchButton.left anchors.rightMargin: Tokens.spacing.medium anchors.verticalCenter: parent.verticalCenter - text: "open_in_new" + text: root.iconText } StateLayer { - onClicked: PopupManager.requestOpen(root.popup) + onClicked: e => root.clicked(e) } CustomSwitch { @@ -64,6 +66,6 @@ ConnectedRect { anchors.rightMargin: root.horizontalPadding anchors.verticalCenter: parent.verticalCenter - onClicked: root.clicked(checked) + onClicked: root.toggled(checked) } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index 05e8052..c8bfd2b 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -6,15 +6,13 @@ import qs.Components import qs.Helpers import qs.Services -CustomClippingRect { +ColumnLayout { id: root - required property var object - required property list settings - property bool shouldBeActive: true - - signal applySettings(startTime: int, endTime: int) - signal close + required property int start + required property int end + signal endEdit(time: int) + signal startEdit(time: int) function convertHour(timeValue: int): int { return Math.floor(timeValue / 60); @@ -28,555 +26,459 @@ CustomClippingRect { return hour * 60 + minute; } - color: Colors.palette.m3surfaceContainer - implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin - implicitWidth: column.implicitWidth + column.anchors.margins * 2 - radius: Tokens.rounding.largeIncreased + spacing: Tokens.spacing.medium - ColumnLayout { - id: column + RowLayout { + CustomRect { + id: startHourRect - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: startHourField + + function setConfigText(): string { + var val = root.convertHour(root.start); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (startHourField.text.length >= 2) { + startHourField.text = "0" + startHourField.text[0]; + } else if (startHourField.text.length === 1) { + startHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + startHourField.text = setConfigText(); + startHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + startHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = startHourField.text.length; + + if (textLen >= 2 && startHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startHourField.text + digit); + } else { + val = parseInt(startHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + startHourField.text = "0" + val; + } else { + startHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); + } + } + } CustomText { - text: qsTr("Select time") + id: startSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" } - RowLayout { - CustomRect { - id: startHourRect + CustomRect { + id: startMinuteRect - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: startHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startHourField.text.length >= 2) { - startHourField.text = "0" + startHourField.text[0]; - } else if (startHourField.text.length === 1) { - startHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startHourField.text = setConfigText(root.settings[1]); - startHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - startHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startHourField.text.length; - - if (textLen >= 2 && startHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startHourField.text + digit); - } else { - val = parseInt(startHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - startHourField.text = "0" + val; - } else { - startHourField.text = val.toString(); - } - } - - event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startHourField.text === "") - return; - var val = parseInt(startHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startHourField.text) - startHourField.text = newText; - } - } - } - - CustomText { - id: startSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small CustomRect { - id: startMinuteRect + anchors.fill: parent + border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small + Behavior on border.width { + Anim {} + } + } - CustomRect { - anchors.fill: parent - border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width + TextFieldBase { + id: startMinuteField - Behavior on border.width { - Anim { - } + function setConfigText(): string { + var val = root.convertMinute(root.start); + if (val === 0) { + return "00"; } + return String(val); } - TextFieldBase { - id: startMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startMinuteField.text.length >= 2) { - startMinuteField.text = "0" + startMinuteField.text[0]; - } else if (startMinuteField.text.length === 1) { - startMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startMinuteField.text = setConfigText(root.settings[1]); - startMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - startMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startMinuteField.text.length; - - if (textLen >= 2 && startMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startMinuteField.text + digit); - } else { - val = parseInt(startMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - startMinuteField.text = "0" + val; - } else { - startMinuteField.text = val.toString(); - } - } + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startMinuteField.text === "") - return; - var val = parseInt(startMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startMinuteField.text) - startMinuteField.text = newText; - } - } - } - - Item { - id: spacer - - Layout.preferredWidth: Tokens.spacing.largeIncreased - } - - CustomRect { - id: endHourRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { + if (startMinuteField.text.length >= 2) { + startMinuteField.text = "0" + startMinuteField.text[0]; + } else if (startMinuteField.text.length === 1) { + startMinuteField.text = "0"; } - } - } - - TextFieldBase { - id: endHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endHourField.text.length >= 2) { - endHourField.text = "0" + endHourField.text[0]; - } else if (endHourField.text.length === 1) { - endHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endHourField.text = setConfigText(root.settings[2]); - endHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - endHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endHourField.text.length; - - if (textLen >= 2 && endHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endHourField.text + digit); - } else { - val = parseInt(endHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - endHourField.text = "0" + val; - } else { - endHourField.text = val.toString(); - } - } - + return; + } else if (event.key === Qt.Key_Escape) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endHourField.text === "") - return; - var val = parseInt(endHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endHourField.text) - endHourField.text = newText; - } - } - } - - CustomText { - id: endSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } - - CustomRect { - id: endMinuteRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: endMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); + startMinuteField.text = setConfigText(); + startMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + startMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startHourField.focus = true; } - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endMinuteField.text.length >= 2) { - endMinuteField.text = "0" + endMinuteField.text[0]; - } else if (endMinuteField.text.length === 1) { - endMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endMinuteField.text = setConfigText(root.settings[2]); - endMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - endMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endMinuteField.text.length; - - if (textLen >= 2 && endMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endMinuteField.text + digit); - } else { - val = parseInt(endMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - endMinuteField.text = "0" + val; - } else { - endMinuteField.text = val.toString(); - } - } - + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endMinuteField.text === "") + var digit = event.text; + var textLen = startMinuteField.text.length; + + if (textLen >= 2 && startMinuteField.text[0] !== '0') { return; - var val = parseInt(endMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endMinuteField.text) - endMinuteField.text = newText; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startMinuteField.text + digit); + } else { + val = parseInt(startMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + startMinuteField.text = "0" + val; + } else { + startMinuteField.text = val.toString(); + } } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); } } } - RowLayout { - CustomText { - Layout.preferredWidth: spacer.x + spacer.width - text: qsTr("Start") + Item { + id: spacer + + Layout.preferredWidth: Tokens.spacing.largeIncreased + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } } - CustomText { - Layout.preferredWidth: endMinuteRect.width + endHourRect.width - text: qsTr("End") + TextFieldBase { + id: endHourField + + function setConfigText(): string { + var val = root.convertHour(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endHourField.text.length >= 2) { + endHourField.text = "0" + endHourField.text[0]; + } else if (endHourField.text.length === 1) { + endHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endHourField.text = setConfigText(); + endHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + endHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endHourField.text.length; + + if (textLen >= 2 && endHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endHourField.text + digit); + } else { + val = parseInt(endHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + endHourField.text = "0" + val; + } else { + endHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } + } + } + + CustomText { + id: endSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: endMinuteField + + function setConfigText(): string { + var val = root.convertMinute(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endMinuteField.text.length >= 2) { + endMinuteField.text = "0" + endMinuteField.text[0]; + } else if (endMinuteField.text.length === 1) { + endMinuteField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endMinuteField.text = setConfigText(); + endMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + endMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endHourField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endMinuteField.text.length; + + if (textLen >= 2 && endMinuteField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endMinuteField.text + digit); + } else { + val = parseInt(endMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + endMinuteField.text = "0" + val; + } else { + endMinuteField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } } } } RowLayout { - id: buttonRow - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: column.bottom - anchors.topMargin: Tokens.spacing.medium - - Item { - id: buttonSpacer - - Layout.fillWidth: true + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") } - ButtonRow { - spacing: Tokens.spacing.medium - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "close" - inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: Colors.palette.m3onSurfaceVariant - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Cancel" - - onClicked: root.close() - } - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "check" - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Apply" - - onClicked: { - const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text); - const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text); - root.applySettings(start, end); - } - } + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") } } } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 09f22f5..36fb583 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -12,6 +12,7 @@ CustomSwitch { property alias last: bg.last property string settingAnchor property string subtext + property string iconText function flashHighlight(): void { bg.flashHighlight(); @@ -63,8 +64,7 @@ CustomSwitch { text: root.text Behavior on opacity { - Anim { - } + Anim {} } } @@ -81,10 +81,16 @@ CustomSwitch { visible: root.subtext Behavior on opacity { - Anim { - } + Anim {} } } + + MaterialIcon { + id: icon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.iconText ?? "" + } } } diff --git a/Modules/Settings/Content.qml b/Modules/Settings/Content.qml index 66b616a..75a05c2 100644 --- a/Modules/Settings/Content.qml +++ b/Modules/Settings/Content.qml @@ -24,8 +24,7 @@ CustomClippingRect { radius: Tokens.rounding.largeIncreased + Tokens.padding.small Behavior on blobColor { - CAnim { - } + CAnim {} } TapHandler { @@ -70,8 +69,4 @@ CustomClippingRect { anchors.top: parent.top sState: root.sState } - - PopupOverlay { - anchors.fill: parent - } } diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..ee45aba 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -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,15 @@ QtObject { // Wallpaper & style StackPage { Component { - WallpaperPage { - } + WallpaperPage {} } Component { - WallpaperSelect { - } + WallpaperSelect {} + } + + Component { + ColorsFonts {} } } }, @@ -37,32 +40,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 +69,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 +116,15 @@ QtObject { Component { StackPage { Component { - AppsPage { - } + AppsPage {} } Component { - AllApps { - } + AllApps {} } Component { - AppInfo { - } + AppInfo {} } } }, @@ -142,13 +133,11 @@ QtObject { Component { StackPage { Component { - ServicesPage { - } + ServicesPage {} } Component { - NotificationsPage { - } + NotificationsPage {} } } }, @@ -157,15 +146,13 @@ QtObject { Component { StackPage { Component { - AboutPage { - } + AboutPage {} } } } ] readonly property Component placeholderComp: Component { - PlaceholderComp { - } + PlaceholderComp {} } component PlaceholderComp: Item { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index f853699..4aacc6d 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Components import ZShell.Config +import qs.Services Item { id: root @@ -41,6 +42,18 @@ Item { objectName: "PageContainer" Component.onCompleted: root.loadPage(root.sState.currentPageIdx) + + // CustomRect { + // anchors.fill: parent + // color: Qt.alpha(Colors.palette.m3shadow, 0.3) + // opacity: root.sState.dimmed ? 1 : 0 + // visible: opacity > 0 + // z: 0 + // + // Behavior on opacity { + // Anim {} + // } + // } } Connections { diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index ae17efa..8985fb0 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -55,6 +55,11 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + settingAnchor: "panels-bar-status-icons-add-item" + onOpenChanged: { + root.sState.dimmed = open; + } + last: true label: qsTr("Add entry") model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml new file mode 100644 index 0000000..ce750e5 --- /dev/null +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -0,0 +1,105 @@ +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 + settingAnchor: "panels-sidebar-select-scheme" + separateContent: false + header: qsTr("Select scheme") + first: true + last: true + icon: "format_paint" + iconLabel.font.pointSize: Tokens.font.size.large + rowButton.trailingIcon: "open_in_new" + onOpenChanged: { + root.sState.dimmed = open; + } + rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label + label: qsTr("Color scheme") + subtext: qsTr("Select the color scheme used for code blocks") + model: root.schemes + rootParent: root.flickable + + initialSelect: Config.llm.appearance.scheme + + onAccepted: { + if (!selectedItem) + return; + + Config.llm.appearance.scheme = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index e99a36e..6c8abd3 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -88,7 +88,12 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + onOpenChanged: { + root.sState.dimmed = open; + } + settingAnchor: "panels-sidebar-quick-toggles-add-item" 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 +113,18 @@ PageBase { }); } } + + SectionHeader { + text: qsTr("AI chat") + } + + NavRow { + text: qsTr("AI chat") + icon: "robot_2" + first: true + last: true + + onClicked: root.sState.openSubPage(9) + } } } diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index e179635..8a7ffb7 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -202,12 +202,12 @@ PageBase { } ToggleRow { - checked: Config.general.color.smart + checked: Config.colors.smart settingAnchor: "services-smart-color-scheme" subtext: qsTr("Derive theme mode from the wallpaper") text: qsTr("Smart color scheme") - onToggled: Config.general.color.smart = checked + onToggled: Config.colors.smart = checked } SelectRow { diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml new file mode 100644 index 0000000..be09d14 --- /dev/null +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Paths +import qs.Services +import qs.Modules.Settings.Common + +PageBase { + id: root + + readonly property list fonts: Qt.fontFamilies() + readonly property var schemes: [ + { + id: "onedark", + label: qsTr("One Dark") + }, + { + id: "nord", + label: qsTr("Nord") + }, + { + id: "dracula", + label: qsTr("Dracula") + }, + { + id: "darkgreen", + label: qsTr("Darkgreen") + }, + { + id: "solarized", + label: qsTr("Solarized") + }, + { + id: "rosepine", + label: qsTr("Rosepine") + }, + { + id: "gruvbox", + label: qsTr("Gruvbox") + }, + { + id: "catppuccin", + label: qsTr("Catppuccin") + }, + { + id: "tokyonight", + label: qsTr("Tokyo Night") + }, + { + id: "shadotheme", + label: qsTr("Shadotheme") + } + ] + + isSubPage: true + title: qsTr("Colors & font") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + SectionHeader { + first: true + text: qsTr("Colors") + } + + ToggleRow { + text: qsTr("Dynamic colors") + first: true + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled") + checked: Config.colors.schemeGen + settingAnchor: "colors-fonts-dynamic-colors" + + onToggled: { + Config.colors.schemeGen = checked; + + if (checked) + Colors.setMode(Config.colors.mode); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-scheme" + model: root.schemes + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen + header: qsTr("Select scheme") + rowButton.activeItem: root.schemes.find(s => { + var regex = new RegExp(Config.colors.presets.name, "i"); + return regex.test(s.id); + }).label ?? "" + label: qsTr("Preset scheme") + subtext: qsTr("Select preset color scheme") + rootParent: root.flickable + + FileView { + id: view + path: `${Paths.state}/presets.json` + + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var presets + } + } + + initialSelect: Config.colors.presets.name.toLowerCase() + + onAccepted: { + if (!selectedItem) + return; + + const s = selectedItem.toLowerCase(); + let v = ""; + let a = ""; + const existingVariant = Config.colors.presets.variant; + const existingAccent = Config.colors.presets.accent; + const variantKeys = Object.keys(adapter.presets[s].variants); + + if (existingVariant !== "" && variantKeys.some(v => v === existingVariant)) + v = existingVariant; + + if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else if (v !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-variant" + // qmlformat off + model: { + const v = adapter.presets[Config.colors.presets.name].variants; + const map = Object.keys(v).map(v => ({ + id: v, + label: qsTr("%1%2") + .arg(v.charAt(0).toUpperCase()) + .arg(v.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen && model.length > 1 + header: qsTr("Select variant") + rowButton.activeItem: { + const v = Config.colors.presets.variant ?? ""; + if (v === "") + return ""; + return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase()); + } + label: qsTr("Variant") + subtext: qsTr("Select preset variant") + rootParent: root.flickable + + initialSelect: Config.colors.presets.variant + + onAccepted: { + if (!selectedItem) + return; + + const v = selectedItem.toLowerCase(); + const s = Config.colors.presets.name; + const existingAccent = Config.colors.presets.accent; + let a = ""; + + if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-accent" + // qmlformat off + model: { + const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? []; + + if (a.length === 0) + return a; + + const map = a.map(a => ({ + id: a, + label: qsTr("%1%2") + .arg(a.charAt(0).toUpperCase()) + .arg(a.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen && model.length > 1 + header: qsTr("Select accent") + rowButton.activeItem: { + const a = Config.colors.presets.accent ?? ""; + if (a === "") + return ""; + return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase()); + } + label: qsTr("Accent") + last: true + subtext: qsTr("Select preset accent") + rootParent: root.flickable + + initialSelect: Config.colors.presets.accent + + onAccepted: { + if (!selectedItem) + return; + + const a = selectedItem.toLowerCase(); + const v = Config.colors.presets.variant; + const s = Config.colors.presets.name; + + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + } + } + + SectionHeader { + text: qsTr("Fonts") + } + + SearchPopup { + id: selectSansFont + + settingAnchor: "colors-fonts-sans-font" + model: root.fonts + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + header: qsTr("Select font") + first: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.sans, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Sans font") + subtext: qsTr("Select desired sans font") + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.sans + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.sans = selectedItem; + } + } + + SearchPopup { + id: selectMonoFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + settingAnchor: "colors-fonts-mono-font" + separateContent: false + header: qsTr("Select font") + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.mono, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Mono font") + subtext: qsTr("Select desired mono font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.mono + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.mono = selectedItem; + } + } + + SearchPopup { + id: selectClockFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + settingAnchor: "colors-fonts-clock-font" + header: qsTr("Select font") + last: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.clock, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Clock font") + subtext: qsTr("Select desired clock font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.clock + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.clock = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 40274ad..8c4b524 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -33,18 +33,35 @@ PageBase { } } - IconTextButton { + ButtonRow { Layout.alignment: Qt.AlignHCenter - enabled: Config.background.enabled - horizontalPadding: Tokens.padding.extraLarge - icon: "wallpaper" - isRound: true - shapeMorph: true - text: qsTr("Wallpapers") - type: IconTextButton.Tonal - verticalPadding: Tokens.padding.small + spacing: Tokens.spacing.small - onClicked: root.sState.openSubPage(1) // Wallpaper page + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "wallpaper" + isRound: true + shapeMorph: true + text: qsTr("Wallpapers") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(1) // Wallpaper page + } + + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "palette" + isRound: true + shapeMorph: true + text: qsTr("Colors & font") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(2) // Colors & font page + } } ToggleRow { @@ -76,85 +93,116 @@ PageBase { onToggled: Colors.setMode(checked ? "dark" : "light") } - OverlayRow { - id: darkMode - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkEnd); + d.setMinutes(Config.colors.scheduleDarkEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkStart); + d.setMinutes(Config.colors.scheduleDarkStart); return Qt.formatTime(d, "hh:mm AP"); } - checked: Config.general.color.scheduleDark + rowButton.checked: Config.colors.scheduleDark + rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked + start: Config.colors.scheduleDarkStart + end: Config.colors.scheduleDarkEnd first: true + last: true settingAnchor: "style-schedule-dark-mode" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule dark mode") + label: qsTr("Schedule dark mode") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleDarkStart = start; - Config.general.color.scheduleDarkEnd = end; - ModeScheduler.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleDark = value; + onAccepted: { + Config.colors.scheduleDarkStart = startCurrent; + Config.colors.scheduleDarkEnd = endCurrent; + ModeScheduler.checkStartup(); } } - OverlayRow { - id: hyprsunset - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); + d.setMinutes(Config.display.nightlight.scheduleEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetStart); + d.setMinutes(Config.display.nightlight.scheduleStart); return Qt.formatTime(d, "hh:mm AP"); } + rowButton.checked: Config.display.nightlight.schedule + rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked + start: Config.display.nightlight.scheduleStart + end: Config.display.nightlight.scheduleEnd + first: true + settingAnchor: "style-schedule-nightlight" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") + subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) + label: qsTr("Schedule nightlight") + + onOpenChanged: { + root.sState.dimmed = open; + } + + onAccepted: { + Config.display.nightlight.scheduleStart = startCurrent; + Config.display.nightlight.scheduleEnd = endCurrent; + Hyprsunset.checkStartup(); + } + } + + ToggleRow { Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - checked: Config.general.color.scheduleHyprsunset + + checked: Config.display.nightlight.useNative + settingAnchor: "display-nightlight-useNative" + text: qsTr("Use native") + subtext: qsTr("Use native shader nightlight for more features") + + onToggled: Config.display.nightlight.useNative = checked + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 2000 + settingAnchor: "display-nightlight-temp" + stepSize: 100 + subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)") + text: qsTr("Temperature") + to: 6500 + value: Config.display.nightlight.temp + + onMoved: v => Config.display.nightlight.temp = v + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 0 + settingAnchor: "display-nightlight-fade-duration" + enabled: Config.display.nightlight.useNative last: true - settingAnchor: "style-schedule-hyprsunset" - subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule hyprsunset") + stepSize: 1 + subtext: qsTr("How long it takes for the gradual nightlight to finish (s)") + text: qsTr("Fade duration") + to: 60 * 5 + value: Config.display.nightlight.fadeDuration / 1000 - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleHyprsunsetStart = start; - Config.general.color.scheduleHyprsunsetEnd = end; - Hyprsunset.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } - } - - onClicked: value => { - Config.general.color.scheduleHyprsunset = value; - } + onMoved: v => Config.display.nightlight.fadeDuration = v * 1000 } } } diff --git a/Modules/Settings/PopupManager.qml b/Modules/Settings/PopupManager.qml deleted file mode 100644 index 4b37c02..0000000 --- a/Modules/Settings/PopupManager.qml +++ /dev/null @@ -1,20 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick - -Singleton { - id: root - - property bool closed: true - property Component currentPopup: null - - function requestClose(): void { - closed = true; - } - - function requestOpen(component: Component): void { - currentPopup = component; - closed = false; - } -} diff --git a/Modules/Settings/PopupOverlay.qml b/Modules/Settings/PopupOverlay.qml deleted file mode 100644 index d18440e..0000000 --- a/Modules/Settings/PopupOverlay.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property bool closing: PopupManager.closed - readonly property var currentPopup: PopupManager.currentPopup - property bool shouldBeVisible: loader.status === Loader.Ready - - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: shouldBeVisible && !closing ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - - onVisibleChanged: if (!visible) - PopupManager.currentPopup = null - - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - propagateComposedEvents: false - - onClicked: { - const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; - const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; - if (insideItemHeight && insideItemWidth) - return; - - PopupManager.requestClose(); - } - } - - Loader { - id: loader - - anchors.centerIn: parent - sourceComponent: root.currentPopup - } -} diff --git a/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index 79e80d8..11933e1 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -16,6 +16,7 @@ QtObject { property string searchText property DesktopEntry selectedApp property BluetoothDevice selectedBtDevice + property bool dimmed property string selectedWallpaperCategory property list subPageIdxStack diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..9e7303f 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -79,3 +79,12 @@ add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) +add_subdirectory(Llm) + +pkg_check_modules(HYPRLAND_PROBE hyprland) +if(HYPRLAND_PROBE_FOUND) + add_subdirectory(HyprPlugins) +else() + message(STATUS + "hyprland development headers not found; skipping zshell-nightlight plugin") +endif() diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..5d1cd78 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,10 +1,11 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + wheelinverter.hpp + LIBRARIES + Qt::Quick ) diff --git a/Plugins/ZShell/Components/wheelinverter.hpp b/Plugins/ZShell/Components/wheelinverter.hpp new file mode 100644 index 0000000..38aa599 --- /dev/null +++ b/Plugins/ZShell/Components/wheelinverter.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class WheelInverter : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged) + + public: + using QQuickItem::QQuickItem; + + QQuickItem* target() const { return m_target; } + + void setTarget(QQuickItem* target) { + if (m_target == target) return; + + if (m_target) m_target->removeEventFilter(this); + + m_target = target; + + if (m_target) m_target->installEventFilter(this); + + emit targetChanged(); + } + + protected: + class InvertedWheelEvent : public QWheelEvent { + public: + using QWheelEvent::QWheelEvent; + + bool invertedByWheelInverter = true; + }; + + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched != m_target || event->type() != QEvent::Wheel) + return QQuickItem::eventFilter(watched, event); + + auto* wheel = static_cast(event); + + const bool inverted = dynamic_cast(wheel) != + nullptr; + + if (inverted) return false; + + auto* window = m_target ? m_target->window() : nullptr; + if (!window) { + qInfo() << "[WheelInverter] no window"; + return false; + } + + const QPointF windowPos = + window->mapFromGlobal(wheel->globalPosition()); + + auto* invertedEvent = new InvertedWheelEvent( + windowPos, + wheel->globalPosition(), + -wheel->pixelDelta(), + -wheel->angleDelta(), + wheel->buttons(), + wheel->modifiers(), + wheel->phase(), + wheel->inverted(), + wheel->source(), + wheel->pointingDevice()); + + invertedEvent->setTimestamp(wheel->timestamp()); + + QCoreApplication::postEvent(window, invertedEvent); + + return true; + } + + signals: + void targetChanged(); + + private: + QPointer m_target; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..dfe9d59 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -5,6 +5,7 @@ qml_module(ZShell-config configobject.hpp configobject.cpp configlist.hpp configlist.cpp config.hpp config.cpp + migration.hpp migration.cpp anim.hpp anim.cpp tokens.hpp appearance.hpp @@ -13,9 +14,11 @@ qml_module(ZShell-config clipboard.hpp colors.hpp dashboard.hpp + display.hpp dock.hpp general.hpp launcher.hpp + llm.hpp lock.hpp notifs.hpp osd.hpp diff --git a/Plugins/ZShell/Config/colors.hpp b/Plugins/ZShell/Config/colors.hpp index 0554df6..20f8657 100644 --- a/Plugins/ZShell/Config/colors.hpp +++ b/Plugins/ZShell/Config/colors.hpp @@ -22,6 +22,13 @@ class Colors : public ConfigObject { CONFIG_SUBOBJECT(Presets, presets) CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) + CFG_PROPERTY(bool, scheduleDark, false) + CFG_PROPERTY(int, scheduleDarkEnd, 600) + CFG_PROPERTY(int, scheduleDarkStart, 1140) + CFG_PROPERTY(bool, schemeGen, true) + CFG_PROPERTY(bool, neovimColors, false) + CFG_PROPERTY(QString, mode, QStringLiteral("dark")) + CFG_PROPERTY(bool, smart, false) public: explicit Colors(QObject* parent = nullptr) diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index 268b4c1..d8e3a03 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -6,10 +6,13 @@ #include "clipboard.hpp" #include "colors.hpp" #include "dashboard.hpp" +#include "display.hpp" #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" +#include "llm.hpp" #include "lock.hpp" +#include "migration.hpp" #include "notifs.hpp" #include "osd.hpp" #include "screenshot.hpp" @@ -33,6 +36,8 @@ namespace ZShell::config { +Config* Config::s_instance = nullptr; + Config::Config(QObject* parent) : ConfigObject(parent) , m_appearance(new Appearance(this)) @@ -41,9 +46,11 @@ Config::Config(QObject* parent) , m_clipboard(new Clipboard(this)) , m_colors(new Colors(this)) , m_dashboard(new Dashboard(this)) + , m_display(new Display(this)) , m_dock(new Dock(this)) , m_general(new General(this)) , m_launcher(new Launcher(this)) + , m_llm(new Llm(this)) , m_lock(new Lock(this)) , m_notifs(new Notifs(this)) , m_osd(new Osd(this)) @@ -51,6 +58,7 @@ Config::Config(QObject* parent) , m_services(new Services(this)) , m_sidebar(new Sidebar(this)) , m_utilities(new Utilities(this)) { + s_instance = this; connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); m_saveTimer.setSingleShot(true); @@ -81,8 +89,13 @@ Config::Config(QObject* parent) m_firstLoadDone = true; } +Config* Config::instance() { + return s_instance; +} + Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); + if (!s_instance) s_instance = new Config(); + return s_instance; } QString Config::filePath() const { @@ -100,11 +113,14 @@ void Config::loadSync() { QJsonObject before; bool existed = false; + bool migrated = false; if (f.open(QIODevice::ReadOnly)) { const auto doc = QJsonDocument::fromJson(f.readAll()); if (doc.isObject()) { - before = doc.object(); + const QJsonObject raw = doc.object(); + before = ConfigMigrations::apply(raw); + migrated = before != raw; existed = true; } else { qInfo() << "Config: existing config at" << filePath() @@ -119,7 +135,7 @@ void Config::loadSync() { m_loading = false; const auto after = toJson().toObject(); - if (!existed || after != before) saveNow(); + if (!existed || migrated || after != before) saveNow(); } void Config::updateWatch() { @@ -165,16 +181,18 @@ void Config::loadAsync() { if (f.open(QIODevice::ReadOnly)) { const auto doc = QJsonDocument::fromJson(f.readAll()); const bool valid = doc.isObject(); - const QJsonObject before = valid ? doc.object() : QJsonObject(); + const QJsonObject raw = valid ? doc.object() : QJsonObject(); + const QJsonObject before = ConfigMigrations::apply(raw); + const bool migrated = valid && before != raw; QMetaObject::invokeMethod( this, - [this, valid, before]() { + [this, valid, migrated, before]() { loadFromJson(QJsonValue(before)); m_loading = false; const auto after = toJson().toObject(); - if (!valid || after != before) saveNow(); + if (!valid || migrated || after != before) saveNow(); if (m_reloadPending) m_reloadTimer.start(); }, diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp index e1881c5..2ad92ea 100644 --- a/Plugins/ZShell/Config/config.hpp +++ b/Plugins/ZShell/Config/config.hpp @@ -21,9 +21,11 @@ class Bar; class Clipboard; class Colors; class Dashboard; +class Display; class Dock; class General; class Launcher; +class Llm; class Lock; class Notifs; class Osd; @@ -43,9 +45,11 @@ class Config : public ConfigObject { Q_MOC_INCLUDE("clipboard.hpp") Q_MOC_INCLUDE("colors.hpp") Q_MOC_INCLUDE("dashboard.hpp") + Q_MOC_INCLUDE("display.hpp") Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("launcher.hpp") + Q_MOC_INCLUDE("llm.hpp") Q_MOC_INCLUDE("lock.hpp") Q_MOC_INCLUDE("notifs.hpp") Q_MOC_INCLUDE("osd.hpp") @@ -60,9 +64,11 @@ class Config : public ConfigObject { CONFIG_SUBOBJECT(Clipboard, clipboard) CONFIG_SUBOBJECT(Colors, colors) CONFIG_SUBOBJECT(Dashboard, dashboard) + CONFIG_SUBOBJECT(Display, display) CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Osd, osd) @@ -74,6 +80,7 @@ class Config : public ConfigObject { public: explicit Config(QObject* parent = nullptr); static Config* create(QQmlEngine*, QJSEngine*); + [[nodiscard]] static Config* instance(); Q_INVOKABLE void load(); Q_INVOKABLE void saveNow(); @@ -103,6 +110,8 @@ class Config : public ConfigObject { bool m_loading = false; bool m_firstLoadDone = false; QFuture m_loadFuture; + + static Config* s_instance; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/display.hpp b/Plugins/ZShell/Config/display.hpp new file mode 100644 index 0000000..b22d1e8 --- /dev/null +++ b/Plugins/ZShell/Config/display.hpp @@ -0,0 +1,33 @@ +#pragma once +#include "configobject.hpp" +#include + +namespace ZShell::config { + +class Nightlight : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(bool, schedule, true) + CFG_PROPERTY(int, scheduleStart, 1200) + CFG_PROPERTY(int, scheduleEnd, 570) + CFG_PROPERTY(bool, useNative, true) + CFG_PROPERTY(int, fadeDuration, 2000) + CFG_PROPERTY(int, temp, 2600) + + public: + explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +class Display : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(Nightlight, nightlight) + + public: + explicit Display(QObject* parent = nullptr) + : ConfigObject(parent), m_nightlight(new Nightlight(this)) {} +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9d0f8fd..558ff82 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -91,26 +91,6 @@ class Battery : public ConfigObject { explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} }; -class ColorSettings : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, hyprsunsetTemp, 2600) - CFG_PROPERTY(QString, mode, QStringLiteral("dark")) - CFG_PROPERTY(bool, neovimColors, false) - CFG_PROPERTY(bool, scheduleDark, false) - CFG_PROPERTY(int, scheduleDarkEnd, 600) - CFG_PROPERTY(int, scheduleDarkStart, 1140) - CFG_PROPERTY(bool, scheduleHyprsunset, true) - CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570) - CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200) - CFG_PROPERTY(bool, schemeGeneration, true) - CFG_PROPERTY(bool, smart, false) - - public: - explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - class Idle : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -135,7 +115,6 @@ class General : public ConfigObject { CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Battery, battery) - CONFIG_SUBOBJECT(ColorSettings, color) CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) @@ -151,7 +130,6 @@ class General : public ConfigObject { : ConfigObject(parent) , m_apps(new Apps(this)) , m_battery(new Battery(this)) - , m_color(new ColorSettings(this)) , m_idle(new Idle(this)) {} }; diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp new file mode 100644 index 0000000..b6a1353 --- /dev/null +++ b/Plugins/ZShell/Config/llm.hpp @@ -0,0 +1,34 @@ +#pragma once +#include "configobject.hpp" +#include +#include +#include + +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 + + CFG_PROPERTY(QString, endpoint, "http://localhost:8080") + CFG_PROPERTY(QString, model, "") + CFG_PROPERTY(double, temperature, 0.7) + CFG_PROPERTY(bool, tools, true) + CONFIG_SUBOBJECT(LlmAppearance, appearance) + + public: + explicit Llm(QObject* parent = nullptr) + : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.cpp b/Plugins/ZShell/Config/migration.cpp new file mode 100644 index 0000000..da78206 --- /dev/null +++ b/Plugins/ZShell/Config/migration.cpp @@ -0,0 +1,123 @@ +#include "migration.hpp" + +#include + +namespace ZShell::config { + +namespace { + +// A stored value is "missing" if the path does not resolve to an entry. +// Note: default-constructed QJsonValue is Null, not Undefined. +bool isMissing(const QJsonValue& v) { + return v.isUndefined() || v.isNull(); +} + +QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) { + QJsonObject cur = obj; + + for (int i = 0; i < path.size() - 1; ++i) { + if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined; + cur = cur.value(path.at(i)).toObject(); + } + + const QString key = path.last(); + if (!cur.contains(key)) return QJsonValue::Undefined; + return cur.value(key); +} + +QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) { + const QString head = path.takeFirst(); + + if (path.isEmpty()) { + obj[head] = value; + return obj; + } + + QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject() + : QJsonObject(); + obj[head] = placeAt(child, path, value); + return obj; +} + +// Removes `path` from `obj`. Returns true if a value was removed. +// Parent objects left empty are removed as well. +bool removeAt(QJsonObject& obj, const QStringList& path) { + const QString head = path.first(); + + if (path.size() == 1) { + if (!obj.contains(head)) return false; + obj.remove(head); + return true; + } + + if (!obj.value(head).isObject()) return false; + + QJsonObject child = obj.value(head).toObject(); + const bool removed = removeAt(child, path.mid(1)); + + if (child.isEmpty()) + obj.remove(head); + else + obj[head] = child; + + return removed; +} + +} // namespace + +const QList& ConfigMigrations::rules() { + static const QList s_rules = { + migrate( + "general.color.scheduleHyprsunset", "display.nightlight.schedule"), + migrate( + "general.color.scheduleHyprsunsetStart", + "display.nightlight.scheduleStart"), + migrate( + "general.color.scheduleHyprsunsetEnd", + "display.nightlight.scheduleEnd"), + migrate("general.color.scheduleDark", "colors.scheduleDark"), + migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"), + migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"), + migrate("general.color.schemeGeneration", "colors.schemeGen"), + migrate( + "general.color.useNativeNightlight", + "display.nightlight.useNative"), + migrate( + "general.color.nativeFadeDuration", + "display.nightlight.fadeDuration"), + migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"), + migrate("general.color.neovimColors", "colors.neovimColors"), + migrate("general.color.mode", "colors.mode"), + migrate("general.color.smart", "colors.smart"), + }; + return s_rules; +} + +QJsonObject ConfigMigrations::apply(const QJsonObject& json) { + QJsonObject obj = json; + + for (const auto& rule : rules()) { + const QStringList from = + rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts); + const QStringList to = + rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts); + + if (from.isEmpty() || to.isEmpty()) continue; + + const QJsonValue oldValue = valueAt(obj, from); + if (isMissing(oldValue)) continue; + + const bool targetExists = !isMissing(valueAt(obj, to)); + if (!targetExists) obj = placeAt(obj, to, oldValue); + + if (removeAt(obj, from)) { + qInfo() << "Config migration:" << rule.from << "->" << rule.to + << (targetExists ? "(target already set, old value dropped)" + : "(value moved)"); + } + } + + return obj; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.hpp b/Plugins/ZShell/Config/migration.hpp new file mode 100644 index 0000000..1c03505 --- /dev/null +++ b/Plugins/ZShell/Config/migration.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +// A single migration rule: move the value stored at the dotted `from` path +// to the dotted `to` path, then delete the old key (and any parent objects +// left empty). +// +// Rules are applied to the raw JSON file on every load, before the schema +// loads it. They are idempotent: once the old key is gone the rule never +// fires again, so no version stamping is needed. Old rules can stay in the +// table forever. +// +// WARNING: the target path must exist in the schema (i.e. a ConfigObject +// property somewhere in the tree). If it does not, the migrated value is +// loaded as an unknown key and silently dropped on the next save. +struct ConfigMigrationRule { + QString from; + QString to; +}; + +// Helper for writing rules: migrate("general.color.foo", "display.bar.foo") +inline ConfigMigrationRule migrate(const QString& from, const QString& to) { + return {from, to}; +} + +class ConfigMigrations { + public: + [[nodiscard]] static const QList& rules(); + + // Applies all rules to `json`, returning the migrated object. + [[nodiscard]] static QJsonObject apply(const QJsonObject& json); +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/HyprPlugins/CMakeLists.txt b/Plugins/ZShell/HyprPlugins/CMakeLists.txt new file mode 100644 index 0000000..0316838 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/CMakeLists.txt @@ -0,0 +1,12 @@ +pkg_check_modules(HYPRLAND REQUIRED hyprland) + +add_library(zshell-nightlight SHARED main.cpp) + +target_compile_features(zshell-nightlight PRIVATE cxx_std_23) +target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS}) +target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS}) +target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER}) + +target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique) + +install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins") diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp new file mode 100644 index 0000000..0da6f88 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include + +namespace ZShell::services { + +inline std::array kelvinToGain(int kelvin) { + const double t = std::clamp(kelvin, 1000, 40000) / 100.0; + double r, g, b; + + r = t <= 66.0 + ? 1.0 + : std::clamp( + 1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0); + g = t <= 66.0 + ? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0) + : std::clamp( + 1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0); + b = t >= 66.0 + ? 1.0 + : (t <= 19.0 + ? 0.0 + : std::clamp( + 0.54320678911 * std::log(t - 10.0) - 1.19625408914, + 0.0, + 1.0)); + + return {static_cast(r), static_cast(g), static_cast(b)}; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp new file mode 100644 index 0000000..8e8f2e4 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -0,0 +1,447 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include +#include +#include +#undef private + +#include + +extern "C" { +#include +#include +} + +#include "colortemp.hpp" + +using ZShell::services::kelvinToGain; + +using Render::IFramebuffer; +using Render::GL::CHyprOpenGLImpl; +using Render::GL::g_pHyprOpenGL; + +inline HANDLE PHANDLE = nullptr; + +static std::filesystem::path g_runtimeDir{"/tmp"}; +static std::filesystem::path g_cacheBase{"/tmp/.cache"}; + +static void initEnvPaths() { + if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env; + + if (const char* env = getenv("XDG_CACHE_HOME")) { + if (*env) g_cacheBase = env; + } else if (const char* home = getenv("HOME")) { + g_cacheBase = std::filesystem::path(home) / ".cache"; + } +} + +static std::ofstream& debugLog() { + static std::ofstream log( + g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app); + return log; +} + +static std::mutex g_logMutex; +static std::atomic g_logSeq{0}; + +static void dlog(const std::string& msg) { + std::lock_guard lk(g_logMutex); + auto& log = debugLog(); + log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] " + << msg << std::endl; +} + +static constexpr int BAKED_STEPS = 24; + +static std::string glslFloat(float v) { + std::ostringstream ss; + ss << std::fixed << std::setprecision(6) << v; + return ss.str(); +} + +static std::filesystem::path cacheDir() { + return g_cacheBase / "zshell" / "night-light"; +} + +static std::filesystem::path shaderPath() { + return cacheDir() / "nightlight.frag"; +} + +static std::filesystem::path bakedShaderPath(const std::array& gain) { + return cacheDir() / + ("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) + + "-" + glslFloat(gain[2]) + ".frag"); +} + +static std::string defaultShaderSource() { + return "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "uniform vec3 tint;\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n" + "}\n"; +} + +static std::string bakedShaderSource(const std::array& gain) { + std::ostringstream src; + src << "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "const vec3 GAIN = vec3(" + << glslFloat(gain[0]) << "," << glslFloat(gain[1]) << "," + << glslFloat(gain[2]) + << ");\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n" + "}\n"; + return src.str(); +} + +static void ensureShaderFile( + const std::filesystem::path& path, const std::string& source) { + std::error_code ec; + if (std::filesystem::is_regular_file(path, ec)) return; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + out << source; +} + +static bool g_active = false; +static bool g_uniformMode = false; +static bool g_modeDecided = false; +static std::array g_currentGain{1.f, 1.f, 1.f}; + +static int g_currentKelvin = 6500; +static int g_lastKelvin = 6500; + +struct STransition { + bool active = false; + std::chrono::steady_clock::time_point start; + float durationSec = 0.4f; + std::array fromGain{1.f, 1.f, 1.f}; + std::array toGain{1.f, 1.f, 1.f}; + int lastBakedStep = -1; +}; +static STransition g_transition; + +static void postStateEvent(bool enabled, bool animating); + +static bool gainIsNeutral(const std::array& gain) { + for (float v : gain) + if (std::abs(v - 1.f) > 0.001f) return false; + return true; +} + +static CFunctionHook* g_pBeginHook = nullptr; +using origBegin = void (*)( + CHyprOpenGLImpl*, + PHLMONITOR, + const CRegion&, + SP, + std::optional); + +static CFunctionHook* g_pSaveMirrorHook = nullptr; +using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&); + +static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { + const bool saved = thisptr->m_applyFinalShader; + thisptr->m_applyFinalShader = false; + const bool result = (*reinterpret_cast( + g_pSaveMirrorHook->m_original))(thisptr, box); + thisptr->m_applyFinalShader = saved; + return result; +} + +static void installShader() { + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + ensureShaderFile(shaderPath(), defaultShaderSource()); + gl->applyScreenShader(shaderPath().string()); + + auto* shader = gl->m_finalScreenShader.get(); + if (shader && shader->program() >= 1 && + shader->getUniformLocation(SHADER_TINT) != -1) { + g_uniformMode = true; + } else { + g_uniformMode = false; + if (!g_modeDecided) + dlog( + "shader has no 'tint' uniform, falling back to " + "per-step recompiled shaders"); + } + g_modeDecided = true; +} + +static void setUniformGain(const std::array& gain) { + auto* gl = g_pHyprOpenGL.get(); + auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; + if (!gl || !shader) return; + + gl->useShader(gl->m_finalScreenShader); + shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); +} + +static void hkBegin( + CHyprOpenGLImpl* thisptr, + PHLMONITOR mon, + const CRegion& damage, + SP fb, + std::optional finalDamage) { + (*reinterpret_cast( + g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); + + if (fb) { + auto* renderer = g_pHyprRenderer.get(); + if (renderer) renderer->m_renderData.blockScreenShader = true; + } + + if (!g_active) return; + + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1) + installShader(); + if (!gl->m_finalScreenShader.get() || + gl->m_finalScreenShader->program() < 1) + return; + + if (g_transition.active) { + auto* monPtr = mon.get(); + if (!monPtr) return; + const float elapsed = + std::chrono::duration( + std::chrono::steady_clock::now() - g_transition.start) + .count(); + const float t = + std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); + + for (size_t i = 0; i < 3; ++i) + g_currentGain[i] = + g_transition.fromGain[i] + + (g_transition.toGain[i] - g_transition.fromGain[i]) * t; + + if (g_uniformMode) { + setUniformGain(g_currentGain); + } else { + const int step = static_cast(t * BAKED_STEPS); + if (step != g_transition.lastBakedStep) { + g_transition.lastBakedStep = step; + ensureShaderFile( + bakedShaderPath(g_currentGain), + bakedShaderSource(g_currentGain)); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } + } + + if (t >= 1.f) { + g_transition.active = false; + if (gainIsNeutral(g_currentGain)) { + g_active = false; + gl->applyScreenShader(""); + } + postStateEvent(g_active, false); + } else { + monPtr->m_forceFullFrames = 2; + monPtr->scheduleFrame(); + } + } else if (g_uniformMode) { + setUniformGain(g_currentGain); + } +} + +static constexpr int KELVIN_MIN = 2000; +static constexpr int KELVIN_MAX = 6500; + +static int luaNightlightSet(lua_State* L) { + const int rawKelvin = static_cast(luaL_checknumber(L, 1)); + const float durationSec = + lua_gettop(L) >= 2 ? static_cast(luaL_checknumber(L, 2)) : 0.4f; + + const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX); + if (targetKelvin != rawKelvin) + dlog( + "clamped " + std::to_string(rawKelvin) + "K to " + + std::to_string(targetKelvin) + "K"); + + g_transition.fromGain = g_currentGain; + g_transition.toGain = kelvinToGain(targetKelvin); + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + g_active = true; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = targetKelvin; + + postStateEvent(true, true); + + lua_pushinteger(L, targetKelvin); + return 1; +} + +static int luaNightlightDisable(lua_State* L) { + if (!g_active) return 0; + + const float durationSec = + lua_gettop(L) >= 1 ? static_cast(luaL_checknumber(L, 1)) : 0.4f; + + g_transition.fromGain = g_currentGain; + g_transition.toGain = {1.f, 1.f, 1.f}; + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = 6500; + + postStateEvent(false, true); + + return 0; +} + +static int luaNightlightState(lua_State* L) { + lua_pushstring( + L, + std::format( + "{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}", + g_active ? "true" : "false", + g_transition.active ? "true" : "false", + g_lastKelvin, + g_currentKelvin) + .c_str()); + return 1; +} + +static void postStateEvent(bool enabled, bool animating) { + if (!g_pEventManager) return; + g_pEventManager->postEvent( + SHyprIPCEvent{ + .event = "zshell-nightlight", + .data = std::format( + "{},{},{},{}", + enabled ? 1 : 0, + animating ? 1 : 0, + g_lastKelvin, + g_currentKelvin)}); +} + +static const SFunctionMatch* findExactMethod( + const std::vector& methods, + const std::string& classAndMethod) { + for (const auto& m : methods) { + if (m.signature.find(classAndMethod) != std::string::npos) return &m; + } + return nullptr; +} + +static std::string manglePrefix( + const std::string& className, const std::string& methodName) { + return std::to_string(className.size()) + className + + std::to_string(methodName.size()) + methodName; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + initEnvPaths(); + dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ==="); + + const std::string serverHash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (serverHash != clientHash) { + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] Mismatched headers! Can't proceed.", + CHyprColor{1.0f, 0.2f, 0.2f, 1.0f}, + 5000); + throw std::runtime_error("[zshell-nightlight] version mismatch"); + } + + static const auto SAVEMIRROR_METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror"); + const SFunctionMatch* saveMirrorTarget = findExactMethod( + SAVEMIRROR_METHODS, + manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror")); + + if (saveMirrorTarget) { + g_pSaveMirrorHook = HyprlandAPI::createFunctionHook( + PHANDLE, + saveMirrorTarget->address, + reinterpret_cast(&hkSaveBufferForMirror)); + if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook()) + dlog( + "saveBufferForMirror hook installed: " + + saveMirrorTarget->signature); + } + + if (!g_pSaveMirrorHook) + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — " + "captures may still show the nightlight", + CHyprColor{1.f, 0.6f, 0.2f, 1.f}, + 8000); + + static const auto METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); + const SFunctionMatch* target = + findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin")); + if (!target) + throw std::runtime_error( + "[zshell-nightlight] couldn't uniquely resolve " + "CHyprOpenGLImpl::begin"); + + g_pBeginHook = HyprlandAPI::createFunctionHook( + PHANDLE, target->address, reinterpret_cast(&hkBegin)); + if (!g_pBeginHook || !g_pBeginHook->hook()) + throw std::runtime_error("[zshell-nightlight] failed to hook begin()"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlSet", luaNightlightSet)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlDisable", luaNightlightDisable)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlState", luaNightlightState)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + return { + "zshell-nightlight", + "Animated nightlight, excluded from screencopy", + "ZShell", + "1.2"}; +} + + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + + +APICALL EXPORT void PLUGIN_EXIT() {} diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt new file mode 100644 index 0000000..bca7ec0 --- /dev/null +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -0,0 +1,314 @@ +# cmark-gfm: markdown -> block structure (pkg-config; no CMake config +# package is installed). +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) + +# tree-sitter runtime for code block highlighting (grammars are +# dlopen()'d at runtime and optional). +pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter) + +# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live +# in a shared JKQTPlotter6 directory, not one named after the package. +find_path(JKQTPlotter6_CMAKE_DIR + NAMES JKQTMathText6Config.cmake + HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6 + DOC "Directory containing the JKQtPlotter cmake package files") +find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") + +# --- tree-sitter grammar discovery (configure time) --- +# +# Discover installed tree-sitter grammars — system packages +# (libtree-sitter-.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_; 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 (PARENT_SCOPE) unless it duplicates a +# hash in . 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: ||| +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() +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 +# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays. +set(LM_FONTS + lmroman10-regular + lmroman10-italic + lmroman10-bold + lmroman10-bolditalic + latinmodern-math) +set(LM_FONTS_HPP "${CMAKE_CURRENT_BINARY_DIR}/latinmodern-fonts.hpp") +set(_lm_header "#pragma once\n\n// Vendored Latin Modern fonts (GUST Font License; see\n// fonts/latinmodern/GUST-FONT-LICENSE.txt).\nnamespace ZShell::llm::lmfont {\n") +foreach(_lm_font IN LISTS LM_FONTS) + string(REPLACE "-" "_" _lm_sym "${_lm_font}") + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/fonts/latinmodern/${_lm_font}.otf" _lm_hex HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _lm_bytes "${_lm_hex}") + string(APPEND _lm_header "inline const unsigned char ${_lm_sym}[] = { ${_lm_bytes} };\n") +endforeach() +string(APPEND _lm_header "}\n") +file(WRITE "${LM_FONTS_HPP}" "${_lm_header}") + +qml_module(ZShell-llm + URI ZShell.Llm + SOURCES + chat.hpp chat.cpp + chatstore.hpp chatstore.cpp + codehighlighter.hpp codehighlighter.cpp + generation.hpp generation.cpp + llmclient.hpp llmclient.cpp + markdownblock.hpp + markdownparser.hpp markdownparser.cpp + mathtext.hpp mathtext.cpp + message.hpp message.cpp + messagemodel.hpp messagemodel.cpp + segment.hpp segment.cpp + session.hpp session.cpp + tool.hpp tool.cpp + webfetchtool.hpp webfetchtool.cpp + LIBRARIES + Qt::Network + Qt::Sql + Qt::Gui + Qt::Widgets + ZShell-config + JKQTPlotter::JKQTMathText + PkgConfig::CMARK_GFM + PkgConfig::TREE_SITTER +) + +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp new file mode 100644 index 0000000..b7adb9b --- /dev/null +++ b/Plugins/ZShell/Llm/chat.cpp @@ -0,0 +1,167 @@ +#include "chat.hpp" + +#include "config.hpp" +#include "llm.hpp" +#include "llmclient.hpp" +#include "webfetchtool.hpp" + +#include + +namespace ZShell::llm { + +Chat::Chat(QObject* parent) + : QObject(parent) + , m_store(new ChatStore(this)) + , m_client(new LlmClient(this)) { + if (!config::Config::instance()) new config::Config(); + + m_store->setLlmClient(m_client); + m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); + + const auto* llm = config::Config::instance()->llm(); + m_client->setEndpoint(llm->endpoint()); + m_client->setModel(llm->model()); + m_client->setTemperature(llm->temperature()); + m_client->setToolsEnabled(llm->tools()); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + m_client->setEndpoint(llm->endpoint()); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + m_client->setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_client->setTemperature(llm->temperature()); + }); + connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { + m_client->setToolsEnabled(llm->tools()); + }); + + connect(m_client, &LlmClient::busyChanged, this, [this]() { + // A fresh run supersedes the previous error. + if (m_client->busy() && !m_lastError.isEmpty()) { + m_lastError.clear(); + Q_EMIT lastErrorChanged(); + } + Q_EMIT busyChanged(); + }); + connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); + connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); + connect( + m_client, + &LlmClient::availableModelsChanged, + this, + &Chat::availableModelsChanged); + connect( + m_client, + &LlmClient::contextSizeChanged, + this, + &Chat::contextSizeChanged); + connect( + m_client, + &LlmClient::streamingChatIdChanged, + this, + &Chat::streamingChatIdChanged); + connect( + m_client, + &LlmClient::toolsEnabledChanged, + this, + &Chat::toolsEnabledChanged); + connect( + m_client, + &LlmClient::errorOccurred, + this, + [this](const QString& message) { + m_lastError = message; + Q_EMIT lastErrorChanged(); + Q_EMIT errorOccurred(message); + }); + connect( + m_store, + &ChatStore::sessionRemoved, + this, + [this](ChatSession* session) { m_client->sessionRemoved(session); }); + connect( + m_client, + &LlmClient::titleSuggested, + this, + [this](ChatSession* session, const QString& title) { + qInfo() << "Chat: applying generated title" << session->id() + << title << "(was" << session->title() << ")"; + session->setTitle(title); + m_store->saveMeta(session); + }); + connect( + m_client, + &LlmClient::iconSuggested, + this, + [this](ChatSession* session, const QString& icon) { + qInfo() << "Chat: applying generated icon" << session->id() << icon + << "(was" << session->icon() << ")"; + session->setIcon(icon); + m_store->saveMeta(session); + }); +} + +bool Chat::busy() const { + return m_client->busy(); +} + +QString Chat::endpoint() const { + return m_client->endpoint(); +} + +QString Chat::model() const { + return m_client->model(); +} + +QStringList Chat::availableModels() const { + return m_client->availableModels(); +} + +int Chat::contextSize() const { + return m_client->contextSize(); +} + +bool Chat::toolsEnabled() const { + return m_client->toolsEnabled(); +} + +void Chat::setToolsEnabled(bool value) { + m_client->setToolsEnabled(value); + if (auto* config = config::Config::instance()) + config->llm()->set_tools(value); +} + +QString Chat::streamingChatId() const { + return m_client->streamingChatId(); +} + +Chat* Chat::s_instance = nullptr; + +Chat* Chat::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) s_instance = new Chat(); + return s_instance; +} + +void Chat::stop() { + m_client->stop(); +} + +void Chat::dismissError() { + if (m_lastError.isEmpty()) return; + m_lastError.clear(); + Q_EMIT lastErrorChanged(); +} + +void Chat::refreshModels() { + m_client->refreshModels(); +} + +void Chat::selectModel(const QString& id) { + if (id.isEmpty()) return; + m_client->setModel(id); + if (auto* config = config::Config::instance()) config->llm()->set_model(id); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp new file mode 100644 index 0000000..276b4a3 --- /dev/null +++ b/Plugins/ZShell/Llm/chat.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +#include "chatstore.hpp" + +class QQmlEngine; +class QJSEngine; + +namespace ZShell::llm { + +class LlmClient; + +// QML-facing facade. Persistence lives in ChatStore, network streaming in +// LlmClient; this class only wires them together and exposes the +// application-wide state. +class Chat : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) + Q_PROPERTY(QString model READ model NOTIFY modelChanged) + Q_PROPERTY( + QStringList availableModels READ availableModels NOTIFY + availableModelsChanged) + Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY( + bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY + toolsEnabledChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) + Q_PROPERTY( + QString streamingChatId READ streamingChatId NOTIFY + streamingChatIdChanged) + + public: + explicit Chat(QObject* parent = nullptr); + + [[nodiscard]] bool busy() const; + [[nodiscard]] QString endpoint() const; + [[nodiscard]] QString model() const; + [[nodiscard]] QStringList availableModels() const; + [[nodiscard]] int contextSize() const; + [[nodiscard]] bool toolsEnabled() const; + void setToolsEnabled(bool value); + [[nodiscard]] QString lastError() const { return m_lastError; } + [[nodiscard]] ChatStore* chats() const { return m_store; } + [[nodiscard]] QString streamingChatId() const; + + Q_INVOKABLE void stop(); + Q_INVOKABLE void dismissError(); + Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void selectModel(const QString& id); + + static Chat* create(QQmlEngine*, QJSEngine*); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void toolsEnabledChanged(); + void errorOccurred(const QString& message); + void lastErrorChanged(); + void streamingChatIdChanged(); + + private: + ChatStore* m_store = nullptr; + LlmClient* m_client = nullptr; + QString m_lastError; + + static Chat* s_instance; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp new file mode 100644 index 0000000..3d9056f --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -0,0 +1,615 @@ +#include "chatstore.hpp" + +#include "llmclient.hpp" +#include "message.hpp" +#include "segment.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +namespace { + +QString segmentTypeName(LlmSegment::Type type) { + switch (type) { + case LlmSegment::Type::Reasoning: + return QStringLiteral("reasoning"); + case LlmSegment::Type::ToolCall: + return QStringLiteral("tool_call"); + case LlmSegment::Type::Content: + return QStringLiteral("content"); + } + return QStringLiteral("reasoning"); +} + +LlmSegment::Type segmentTypeFromName(const QString& name) { + if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall; + if (name == QLatin1String("content")) return LlmSegment::Type::Content; + return LlmSegment::Type::Reasoning; +} + +QString sqlText(const QString& value) { + if (value.isNull()) return QStringLiteral(""); + return value; +} + +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 segments; +}; + +struct MessageRow { + bool user = false; + qint64 timestamp = 0; + QVector generations; +}; + +} // namespace + +ChatStore::ChatStore(QObject* parent) + : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { + openDb(); + load(); +} + +ChatStore::~ChatStore() { + if (m_connectionName.isEmpty()) return; + QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); + db.close(); + QSqlDatabase::removeDatabase(m_connectionName); +} + +QSqlDatabase ChatStore::db() const { + return QSqlDatabase::database(m_connectionName); +} + +void ChatStore::openDb() { + m_dbPath = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) + + QStringLiteral("/zshell/chats.sqlite"); + QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); + + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(m_dbPath); + if (!db.open()) { + qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" + << db.lastError().text(); + return; + } + { + QSqlQuery pragma(db); + pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); + } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS sessions (\n" + " id TEXT PRIMARY KEY,\n" + " title TEXT NOT NULL DEFAULT '',\n" + " icon TEXT NOT NULL DEFAULT '',\n" + " created_at INTEGER NOT NULL,\n" + " updated_at INTEGER NOT NULL,\n" + " pinned INTEGER NOT NULL DEFAULT 0\n" + ")")); + } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS messages (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " session_id TEXT NOT NULL REFERENCES sessions (id) " + "ON DELETE CASCADE,\n" + " role TEXT NOT NULL,\n" + " timestamp INTEGER NOT NULL\n" + ")")); + } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS generations (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " timestamp INTEGER NOT NULL,\n" + " is_active INTEGER NOT NULL DEFAULT 1\n" + ")")); + } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS segments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " generation_id INTEGER NOT NULL REFERENCES generations " + "(id) ON DELETE CASCADE,\n" + " type TEXT NOT NULL,\n" + " text TEXT NOT NULL DEFAULT '',\n" + " name TEXT NOT NULL DEFAULT '',\n" + " tool_call_id TEXT NOT NULL DEFAULT '',\n" + " arguments TEXT NOT NULL DEFAULT '',\n" + " result TEXT NOT NULL DEFAULT '',\n" + " status INTEGER NOT NULL DEFAULT 0,\n" + " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " timestamp INTEGER NOT NULL\n" + ")")); + } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_messages_session " + "ON messages (session_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_generations_message " + "ON generations (message_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_segments_generation " + "ON segments (generation_id)")); + } +} + +int ChatStore::count() const { + return static_cast(m_sessions.size()); +} + +QVariantList ChatStore::values() const { + QVariantList vals; + vals.reserve(m_sessions.size()); + for (const auto* session : m_sessions) + vals.append(QVariant::fromValue(session)); + return vals; +} + +ChatSession* ChatStore::at(int index) const { + if (index < 0 || index >= m_sessions.size()) return nullptr; + return m_sessions.at(index); +} + +ChatSession* ChatStore::insert(int index) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const QString id = QUuid::createUuid().toString(); + { + QSqlQuery query(db()); + query.prepare( + "INSERT INTO sessions (id, title, created_at, updated_at) " + "VALUES (:id, '', :created_at, :updated_at)"); + query.bindValue(":id", id); + query.bindValue(":created_at", now); + query.bindValue(":updated_at", now); + if (!query.exec()) + qWarning() << "ChatStore: failed to insert session" << id << ":" + << query.lastError().text(); + } + auto* session = new ChatSession(id, this); + session->setMeta(QString(), now, now, 0); + const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; + m_sessions.insert(pos, session); + Q_EMIT countChanged(); + Q_EMIT valuesChanged(); + return session; +} + +void ChatStore::remove(int index) { + removeSession(at(index)); +} + +void ChatStore::remove(ChatSession* chat) { + removeSession(chat); +} + +void ChatStore::removeSession(ChatSession* session) { + if (!session || !m_sessions.contains(session)) return; + const QList before = m_sessions; + Q_EMIT sessionRemoved(session); + { + QSqlQuery query(db()); + query.prepare("DELETE FROM sessions WHERE id = :id"); + query.bindValue(":id", session->id()); + query.exec(); + } + m_sessions.removeOne(session); + session->deleteLater(); + notify(before); +} + +void ChatStore::move(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to >= m_sessions.size() || from == to) + return; + m_sessions.move(from, to); + Q_EMIT valuesChanged(); +} + +void ChatStore::clear() { + const QList sessions = m_sessions; + for (ChatSession* session : sessions) + removeSession(session); +} + +ChatSession* ChatStore::sessionById(const QString& id) { + for (auto* session : m_sessions) + if (session->id() == id) return session; + return nullptr; +} + +void ChatStore::setLlmClient(LlmClient* client) { + m_llmClient = client; +} + +void ChatStore::persist(ChatSession* session) { + if (!session || !m_sessions.contains(session)) return; + if (!session->isLoaded()) { + m_pendingPersists.insert(session); + return; + } + session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); + if (!saveSession(session)) return; + sortAndNotify(); +} + +void ChatStore::saveMeta(ChatSession* session) { + if (!session || !m_sessions.contains(session)) return; + QSqlQuery query(db()); + query.prepare( + "UPDATE sessions SET title = :title, icon = :icon " + "WHERE id = :id"); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":id", session->id()); + if (!query.exec()) + qWarning() << "ChatStore: failed to save meta for" << session->id() + << ":" << query.lastError().text(); +} + +bool ChatStore::saveSession(ChatSession* session) { + const QString id = session->id(); + QSqlDatabase handle = db(); + if (!handle.transaction()) { + qWarning() << "ChatStore: failed to begin transaction:" + << handle.lastError().text(); + return false; + } + bool ok = true; + { + QSqlQuery query(handle); + query.prepare( + "UPDATE sessions SET title = :title, updated_at = :updated_at " + "WHERE id = :id"); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":id", session->id()); + ok = query.exec(); + } + if (ok) { + QSqlQuery query(handle); + query.prepare("DELETE FROM messages WHERE session_id = :id"); + query.bindValue(":id", session->id()); + ok = query.exec(); + } + if (ok) { + QSqlQuery messageInsert(handle); + ok = messageInsert.prepare( + "INSERT INTO messages (session_id, role, timestamp) " + "VALUES (:id, :role, :timestamp)"); + QSqlQuery generationInsert(handle); + ok = ok && + generationInsert.prepare( + "INSERT INTO generations (message_id, timestamp, is_active) " + "VALUES (:mid, :timestamp, :is_active)"); + QSqlQuery segmentInsert(handle); + ok = ok && + segmentInsert.prepare( + "INSERT INTO segments (generation_id, type, text, name, " + "tool_call_id, arguments, result, status, elapsed_ms, " + "timestamp) VALUES (:gid, :type, :text, :name, " + ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " + ":timestamp)"); + const auto* model = session->messagesModel(); + for (int row = model->rowCount() - 1; ok && row >= 0; --row) { + const auto* message = model->at(row); + messageInsert.bindValue(":id", session->id()); + messageInsert.bindValue( + ":role", + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant")); + messageInsert.bindValue(":timestamp", message->timestamp()); + if (!messageInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "message insert failed:" + << messageInsert.lastError().text(); + break; + } + const int messageId = messageInsert.lastInsertId().toInt(); + for (int i = 0; ok && i < message->generationCount(); ++i) { + const auto* generation = message->generation(i); + generationInsert.bindValue(":mid", messageId); + generationInsert.bindValue( + ":timestamp", generation->timestamp()); + generationInsert.bindValue( + ":is_active", + i == message->activeGenerationIndex() ? 1 : 0); + if (!generationInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "generation insert failed:" + << generationInsert.lastError().text(); + break; + } + const int generationId = + generationInsert.lastInsertId().toInt(); + for (const auto* segment : generation->segments()) { + segmentInsert.bindValue(":gid", generationId); + segmentInsert.bindValue( + ":type", segmentTypeName(segment->type())); + segmentInsert.bindValue(":text", sqlText(segment->text())); + segmentInsert.bindValue(":name", sqlText(segment->name())); + segmentInsert.bindValue( + ":tool_call_id", sqlText(segment->toolCallId())); + segmentInsert.bindValue( + ":arguments", sqlText(segment->arguments())); + segmentInsert.bindValue( + ":result", sqlText(segment->result())); + segmentInsert.bindValue( + ":status", static_cast(segment->status())); + segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); + segmentInsert.bindValue(":timestamp", segment->timestamp()); + if (!segmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "segment insert failed:" + << segmentInsert.lastError().text(); + break; + } + } + } + } + } + if (!ok || !handle.commit()) { + qWarning() << "ChatStore: saveSession" << id + << "commit failed, rolling back"; + handle.rollback(); + ok = false; + qWarning() << "ChatStore: failed to save session" << session->id() + << ":" << handle.lastError().text(); + } + return ok; +} +void ChatStore::loadMessagesInto(ChatSession* session) { + if (!session) return; + const QString sessionId = session->id(); + const QString path = m_dbPath; + + QThreadPool::globalInstance()->start([store = QPointer(this), + session = + QPointer(session), + sessionId, + path]() { + QList rows; + const QString connName = QUuid::createUuid().toString(); + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(path); + if (db.open()) { + QSqlQuery busy(db); + busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); + 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 open database for load:" + << db.lastError().text(); + } + } + QSqlDatabase::removeDatabase(connName); + + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [store, session, rows = std::move(rows)]() mutable { + ChatStore* st = store; + ChatSession* s = session; + if (!st || !s) return; + + auto* model = s->model(); + if (!model) return; + QList 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( + segmentRow.status)); + segment->restore(segmentRow.elapsedMs); + generation->addSegment(segment); + } + if (generationRow.active) activeIndex = i; + } + message->setActiveGeneration(activeIndex); + messages.append(message); + } + if (model->rowCount() > 0) { + QList 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); + + s->markLoaded(); + + if (s->takeClearPending()) { + s->clear(); + } else if (st->m_pendingPersists.remove(s)) { + st->persist(s); + } + }, + Qt::QueuedConnection); + }); +} + +void ChatStore::load() { + QSqlQuery query(db()); + query.exec( + "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " + "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " + "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); + while (query.next()) { + auto* session = new ChatSession(query.value(0).toString(), this); + session->setMeta( + query.value(1).toString(), + query.value(3).toLongLong(), + query.value(4).toLongLong(), + query.value(6).toInt()); + session->setIcon(query.value(2).toString()); + session->setPinned(query.value(5).toInt() != 0); + m_sessions.append(session); + } + sortAndNotify(); +} + +void ChatStore::sortAndNotify() { + const QList before = m_sessions; + std::stable_sort( + m_sessions.begin(), + m_sessions.end(), + [](const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); + return a->updatedAtMs() > b->updatedAtMs(); + }); + notify(before); +} + +void ChatStore::notify(const QList& before) { + if (before.size() != m_sessions.size()) Q_EMIT countChanged(); + bool same = before.size() == m_sessions.size(); + for (int i = 0; same && i < m_sessions.size(); ++i) + if (before.at(i) != m_sessions.at(i)) same = false; + if (!same) Q_EMIT valuesChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp new file mode 100644 index 0000000..75b335b --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "session.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class LlmClient; + +class ChatStore : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + + public: + explicit ChatStore(QObject* parent = nullptr); + ~ChatStore() override; + + [[nodiscard]] int count() const; + [[nodiscard]] QVariantList values() const; + [[nodiscard]] ChatSession* at(int index) const; + + Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); + Q_INVOKABLE void remove(int index); + Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); + Q_INVOKABLE void move(int from, int to); + Q_INVOKABLE void clear(); + + [[nodiscard]] ChatSession* sessionById(const QString& id); + [[nodiscard]] LlmClient* llmClient() const { return m_llmClient; } + void setLlmClient(LlmClient* client); + + void persist(ChatSession* session); + void saveMeta(ChatSession* session); + void loadMessagesInto(ChatSession* session); + + Q_SIGNALS: + void countChanged(); + void valuesChanged(); + void sessionRemoved(ZShell::llm::ChatSession* session); + + private: + void openDb(); + void load(); + bool saveSession(ChatSession* session); + void sortAndNotify(); + void removeSession(ChatSession* session); + void notify(const QList& before); + + QList m_sessions; + LlmClient* m_llmClient = nullptr; + QString m_connectionName; + QString m_dbPath; + QSet m_pendingPersists; + + [[nodiscard]] QSqlDatabase db() const; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp new file mode 100644 index 0000000..86e2f54 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -0,0 +1,422 @@ +#include "codehighlighter.hpp" + +#include "highlight-queries.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ZShell::llm { + +namespace hl { + +enum Role : uint8_t { + None = 0, + Comment, + String, + StringKey, + Number, + Constant, + Keyword, + Type, + Function, + Method, + Macro, + Preproc, + Operator, + Property, + Label, + Attribute, +}; + +const char* roleName(Role role) { + switch (role) { + case Comment: + return "comment"; + case String: + return "string"; + case StringKey: + return "string.key"; + case Number: + return "number"; + case Constant: + return "constant"; + case Keyword: + return "keyword"; + case Type: + return "type"; + case Function: + return "function"; + case Method: + return "method"; + case Macro: + return "macro"; + case Preproc: + return "preproc"; + case Operator: + return "operator"; + case Property: + return "property"; + case Label: + return "label"; + case Attribute: + return "attribute"; + default: + return ""; + } +} + +using LanguageFn = const TSLanguage* (*)(); + +const QHash& grammars() { + static const QHash grammars = [] { + QHash map; + 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; +} + +} // namespace hl + +CodeHighlighter* CodeHighlighter::s_instance = nullptr; + +const QHash& CodeHighlighter::aliases() { + static const QHash aliases = [] { + QHash map; + map.insert("c", "c"); + map.insert("h", "c"); + map.insert("cpp", "cpp"); + map.insert("c++", "cpp"); + map.insert("cc", "cpp"); + map.insert("cxx", "cpp"); + map.insert("h++", "cpp"); + map.insert("hpp", "cpp"); + map.insert("hh", "cpp"); + map.insert("python", "python"); + map.insert("py", "python"); + map.insert("javascript", "javascript"); + map.insert("js", "javascript"); + map.insert("jsx", "javascript"); + map.insert("mjs", "javascript"); + map.insert("cjs", "javascript"); + map.insert("typescript", "typescript"); + map.insert("ts", "typescript"); + map.insert("mts", "typescript"); + map.insert("cts", "typescript"); + map.insert("tsx", "tsx"); + map.insert("bash", "bash"); + map.insert("sh", "bash"); + map.insert("shell", "bash"); + map.insert("shellscript", "bash"); + 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"); + map.insert("rs", "rust"); + map.insert("go", "go"); + map.insert("golang", "go"); + map.insert("yaml", "yaml"); + map.insert("yml", "yaml"); + map.insert("toml", "toml"); + map.insert("sql", "sql"); + map.insert("mysql", "sql"); + map.insert("postgres", "sql"); + map.insert("postgresql", "sql"); + map.insert("sqlite", "sql"); + map.insert("sqlite3", "sql"); + return map; + }(); + return aliases; +} + +uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { + const QString n = QString::fromUtf8(name, length); + if (n == "comment") return hl::Role::Comment; + if (n.startsWith("string")) + return n == "string.special.key" ? hl::Role::StringKey + : hl::Role::String; + if (n == "escape" || n == "regexp") return hl::Role::String; + if (n.startsWith("number")) return hl::Role::Number; + if (n.startsWith("constant") || n == "boolean" || n == "bool" || + n.startsWith("character")) + return hl::Role::Constant; + if (n.startsWith("keyword")) return hl::Role::Keyword; + if (n == "type" || n.startsWith("type.")) return hl::Role::Type; + if (n.startsWith("namespace") || n.startsWith("module") || + n == "support.type" || n == "support.namespace") + return hl::Role::Type; + if (n.startsWith("function") || n == "constructor" || + n.startsWith("support.function")) + return hl::Role::Function; + if (n == "method" || n == "method.builtin") return hl::Role::Method; + if (n.startsWith("macro")) return hl::Role::Macro; + if (n.startsWith("preproc")) return hl::Role::Preproc; + if (n == "operator" || n == "punctuation.operator" || + n.startsWith("operator.") || n.startsWith("punctuation")) + return hl::Role::Operator; + if (n == "property" || n == "field" || n.startsWith("property.")) + return hl::Role::Property; + if (n == "label") return hl::Role::Label; + if (n.startsWith("attribute") || n == "annotation") + return hl::Role::Attribute; + 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; +} + +const char* CodeHighlighter::roleName(uint8_t role) { + return hl::roleName(static_cast(role)); +} + +QString CodeHighlighter::resolveId(const QString& language) { + const QString tag = language.trimmed().toLower(); + const QString alias = aliases().value(tag); + return alias.isEmpty() ? tag : alias; // unknown tags = grammar id +} + +QString CodeHighlighter::cacheKey(const QString& id, const QString& code) { + return id + QLatin1Char('\x01') + QString::number(code.size()) + + QLatin1Char('\x01') + QString::number(qHash(code)); +} + +QVariantList CodeHighlighter::lookupSpans( + const QString& code, const QString& language) const { + if (code.isEmpty()) return {}; + const QString key = cacheKey(resolveId(language), code); + QMutexLocker locker(&m_cacheMutex); + const auto it = m_spanCache.constFind(key); + if (it == m_spanCache.constEnd() || it->code != code) return {}; + const qsizetype pos = m_spanCacheOrder.indexOf(key); + if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); + return it->spans; +} + +void CodeHighlighter::storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const { + if (spans.isEmpty() || code.isEmpty()) return; + static constexpr int kMaxEntries = 32; + static constexpr int kMaxBytes = 1024 * 1024; + const QString key = cacheKey(resolveId(language), code); + const int bytes = static_cast(code.toUtf8().size()); + QMutexLocker locker(&m_cacheMutex); + auto it = m_spanCache.find(key); + if (it != m_spanCache.end()) { + m_spanCacheBytes -= static_cast(it->code.toUtf8().size()); + m_spanCache.erase(it); + m_spanCacheOrder.removeAll(key); + } + while (m_spanCacheOrder.size() >= kMaxEntries || + m_spanCacheBytes + bytes > kMaxBytes) { + if (m_spanCacheOrder.isEmpty()) break; + const QString oldest = m_spanCacheOrder.takeFirst(); + m_spanCacheBytes -= + static_cast(m_spanCache.value(oldest).code.toUtf8().size()); + m_spanCache.remove(oldest); + } + m_spanCache.insert(key, SpanCacheEntry{code, spans}); + m_spanCacheOrder.append(key); + m_spanCacheBytes += bytes; +} + +void CodeHighlighter::highlight( + const QString& code, const QString& language, QObject* target, int token) { + QPointer targetGuard(target); + const QVariantList cached = lookupSpans(code, language); + if (!cached.isEmpty()) { + QMetaObject::invokeMethod( + targetGuard, + "onHighlightSpans", + Qt::DirectConnection, + Q_ARG(QVariant, token), + Q_ARG(QVariant, cached)); + return; + } + QThreadPool::globalInstance()->start( + [this, target, token, code, language]() { + const QVariantList spans = doHighlight(code, language); + storeSpans(code, language, spans); + QPointer guard(target); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, token, spans]() { + if (!guard) return; + QMetaObject::invokeMethod( + guard, + "onHighlightSpans", + Q_ARG(QVariant, token), + Q_ARG(QVariant, spans)); + }, + Qt::QueuedConnection); + }); +} + +QVariantList CodeHighlighter::doHighlight( + const QString& code, const QString& language) const { + QVariantList spans; + if (code.isEmpty()) return spans; + + const QString id = resolveId(language); + const Grammar& grammar = hl::grammars().value(id); + if (grammar.libs.empty()) return spans; + + static constexpr size_t kMaxBytes = 512 * 1024; + const QByteArray utf8 = code.toUtf8(); + if (static_cast(utf8.size()) > kMaxBytes) return spans; + + const TSLanguage* lang = nullptr; + TSQuery* query = nullptr; + { + QMutexLocker locker(&m_stateMutex); + auto& state = m_states[id]; + if (!state || (!state->lang && !state->bad)) { + std::shared_ptr fresh = std::make_shared(); + bool abiMismatch = false; + for (size_t i = 0; i < grammar.libs.size(); ++i) { + void* lib = + dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL); + if (!lib) continue; + auto* symbol = reinterpret_cast( + 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) { + for (const char* source : grammar.queries) { + TSQueryError errorType = TSQueryErrorNone; + uint32_t errorOffset = 0; + TSQuery* candidate = ts_query_new( + static_cast(fresh->lang), + source, + static_cast(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); + } + if (!state || state->bad || !state->lang) return spans; + lang = static_cast(state->lang); + query = static_cast(state->query); + } + + TSParser* parser = ts_parser_new(); + ts_parser_set_language(parser, lang); + TSTree* tree = ts_parser_parse_string( + parser, nullptr, utf8.constData(), static_cast(utf8.size())); + if (!tree) { + ts_parser_delete(parser); + return spans; + } + + TSQueryCursor* cursor = ts_query_cursor_new(); + ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); + + const uint32_t size = static_cast(utf8.size()); + std::vector kinds(size, 0); + + std::vector cu(size + 1, 0); + for (uint32_t b = 0; b < size; ++b) { + cu[b + 1] = cu[b]; + const unsigned char c = static_cast(utf8[b]); + if (c < 0x80) + cu[b + 1] += 1; + else if (c < 0xC0) + ; // continuation byte + else if (c < 0xF0) + cu[b + 1] += 1; + else + cu[b + 1] += 2; + } + + TSQueryMatch match; + uint32_t captureIndex = 0; + while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { + const TSQueryCapture& capture = match.captures[captureIndex]; + uint32_t nameLength = 0; + const char* name = + ts_query_capture_name_for_id(query, capture.index, &nameLength); + const uint8_t role = roleFor(name, nameLength); + if (role == 0) continue; + const uint32_t start = ts_node_start_byte(capture.node); + const uint32_t end = ts_node_end_byte(capture.node); + if (end <= start || end > size) continue; + std::fill(kinds.begin() + start, kinds.begin() + end, role); + } + + uint32_t position = 0; + while (position < size) { + if (kinds[position] == 0) { + ++position; + continue; + } + const uint8_t role = kinds[position]; + const uint32_t start = position; + while (position < size && kinds[position] == role) + ++position; + QVariantMap span; + span.insert("start", static_cast(cu[start])); + span.insert("length", static_cast(cu[position] - cu[start])); + span.insert("kind", roleName(role)); + spans.append(span); + } + + ts_query_cursor_delete(cursor); + ts_tree_delete(tree); + ts_parser_delete(parser); + return spans; +} + +CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) s_instance = new CodeHighlighter(); + return s_instance; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp new file mode 100644 index 0000000..4f0b116 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +class QQmlEngine; +class QJSEngine; + +namespace ZShell::llm { + +class CodeHighlighter : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + Q_INVOKABLE void highlight( + const QString& code, + const QString& language, + QObject* target, + int token); + + static CodeHighlighter* create(QQmlEngine*, QJSEngine*); + + struct Grammar { + std::vector libs; + std::vector symbols; + std::vector queries; + }; + + private: + struct State { + bool bad = false; // permanent failure, do not retry + void* lib = nullptr; + const void* lang = nullptr; // const TSLanguage* + void* query = nullptr; // TSQuery* + }; + + [[nodiscard]] static const QHash& aliases(); + [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); + [[nodiscard]] static const char* roleName(uint8_t role); + [[nodiscard]] static QString resolveId(const QString& language); + [[nodiscard]] static QString cacheKey( + const QString& id, const QString& code); + [[nodiscard]] QVariantList doHighlight( + const QString& code, const QString& language) const; + [[nodiscard]] QVariantList lookupSpans( + const QString& code, const QString& language) const; + void storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const; + + struct SpanCacheEntry { + QString code; // re-compared on lookup; a hash collision can + // never deliver the wrong spans + QVariantList spans; + }; + + mutable QHash> m_states; + mutable QMutex m_stateMutex; + mutable QHash m_spanCache; + mutable QStringList m_spanCacheOrder; // LRU order, oldest first + mutable int m_spanCacheBytes = 0; + mutable QMutex m_cacheMutex; + static CodeHighlighter* s_instance; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt new file mode 100644 index 0000000..d656332 --- /dev/null +++ b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt @@ -0,0 +1,40 @@ +% This is a preliminary version (2006-09-30), barring acceptance from +% the LaTeX Project Team and other feedback, of the GUST Font License. +% (GUST is the Polish TeX Users Group, http://www.gust.org.pl) +% +% For the most recent version of this license see +% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt +% or +% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt +% +% This work may be distributed and/or modified under the conditions +% of the LaTeX Project Public License, either version 1.3c of this +% license or (at your option) any later version. +% +% Please also observe the following clause: +% 1) it is requested, but not legally required, that derived works be +% distributed only after changing the names of the fonts comprising this +% work and given in an accompanying "manifest", and that the +% files comprising the Work, as listed in the manifest, also be given +% new names. Any exceptions to this request are also given in the +% manifest. +% +% We recommend the manifest be given in a separate file named +% MANIFEST-.txt, where is some unique identification +% of the font family. If a separate "readme" file accompanies the Work, +% we recommend a name of the form README-.txt. +% +% The latest version of the LaTeX Project Public License is in +% http://www.latex-project.org/lppl.txt and version 1.3c or later +% is part of all distributions of LaTeX version 2006/05/20 or later. + + +--- + +Provenance: + lmroman10-*.otf: Latin Modern v2.007 (GUST, 31-03-2026) + https://www.gust.org.pl/projects/e-foundry/latin-modern/download + (Latin_Modern-otf-2_007-31_03_2026.zip) + latinmodern-math.otf: Latin Modern Math v1.959 (GUST) + https://www.gust.org.pl/projects/e-foundry/lm-math/download + (latinmodern-math-1959.zip; same release as CTAN fonts/lm-math) diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf b/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf new file mode 100644 index 0000000..0e4642e Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf new file mode 100644 index 0000000..e54ad36 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf new file mode 100644 index 0000000..b7f00bd Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf new file mode 100644 index 0000000..721f973 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf differ diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf new file mode 100644 index 0000000..2d10885 Binary files /dev/null and b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf differ diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp new file mode 100644 index 0000000..129de52 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.cpp @@ -0,0 +1,218 @@ +#include "generation.hpp" + +#include + +namespace ZShell::llm { + +ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) + : QObject(parent), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + bool anyRunning = false; + for (auto* segment : m_segments) { + if (!segment->running()) continue; + anyRunning = true; + segment->elapsedMsChanged(); + } + if (anyRunning) Q_EMIT elapsedMsChanged(); + if (!anyRunning && !m_streaming) m_timer.stop(); + }); +} + +QString ChatGeneration::content() const { + QStringList parts; + for (const auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content || + segment->text().isEmpty()) + continue; + parts.append(segment->text()); + } + return parts.join(QStringLiteral("\n\n")); +} + +QString ChatGeneration::reasoning() const { + QStringList parts; + for (const auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Reasoning || + segment->text().isEmpty()) + continue; + parts.append(segment->text()); + } + return parts.join(QStringLiteral("\n\n")); +} + +bool ChatGeneration::reasoningActive() const { + if (!m_streaming) return false; + if (!content().isEmpty()) return false; + return !hasRunningTool(); +} + +qint64 ChatGeneration::reasoningElapsedMs() const { + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Reasoning) + total += segment->elapsedMs(); + return total; +} + +qint64 ChatGeneration::contentElapsedMs() const { + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Content) + total += segment->elapsedMs(); + return total; +} + +qint64 ChatGeneration::toolsElapsedMs() const { + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) + total += segment->elapsedMs(); + return total; +} + +int ChatGeneration::toolCallCount() const { + int count = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) ++count; + return count; +} + +bool ChatGeneration::hasRunningTool() const { + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) + return true; + return false; +} + +void ChatGeneration::updateReasoningActive() { + const bool active = reasoningActive(); + if (m_reasoningActive == active) return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatGeneration::setContent(const QString& value) { + LlmSegment* first = nullptr; + for (auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content) continue; + if (!first) + first = segment; + else + segment->setText(QString()); + } + if (!first) { + if (value.isEmpty()) return; + first = new LlmSegment( + LlmSegment::Type::Content, + QDateTime::currentMSecsSinceEpoch(), + this); + addSegment(first); + } + first->setText(value); +} + +void ChatGeneration::appendContent(const QString& piece) { + if (piece.isEmpty()) return; + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Reasoning && + segment->running()) + segment->close(); + } + openContentSegment()->appendText(piece); +} + +void ChatGeneration::appendReasoning(const QString& piece) { + if (piece.isEmpty()) return; + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Content && segment->running()) + segment->close(); + } + openReasoningSegment()->appendText(piece); +} + +void ChatGeneration::setStreaming(bool value) { + if (m_streaming == value) return; + m_streaming = value; + Q_EMIT streamingChanged(); + if (value) { + if (!m_timer.isActive()) m_timer.start(); + } else { + closeOpenSegments(); + m_timer.stop(); + } + Q_EMIT elapsedMsChanged(); + updateReasoningActive(); +} + +LlmSegment* ChatGeneration::openContentSegment() { + for (auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Content && segment->running()) + return segment; + auto* segment = new LlmSegment( + LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); + segment->begin(); + addSegment(segment); + return segment; +} + +LlmSegment* ChatGeneration::openReasoningSegment() { + for (auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Reasoning && + segment->running()) + return segment; + auto* segment = new LlmSegment( + LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this); + segment->begin(); + addSegment(segment); + return segment; +} + +LlmSegment* ChatGeneration::beginToolCall( + const QString& name, const QString& toolCallId) { + closeOpenSegments(); + auto* segment = new LlmSegment( + LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); + segment->setName(name); + segment->setToolCallId(toolCallId); + segment->setStatus(LlmSegment::Status::Running); + segment->begin(); + addSegment(segment); + Q_EMIT toolStateChanged(); + return segment; +} + +void ChatGeneration::addSegment(LlmSegment* segment) { + if (!segment || m_segments.contains(segment)) return; + segment->setParent(this); + connect(segment, &LlmSegment::textChanged, this, [this, segment]() { + if (segment->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else if (segment->type() == LlmSegment::Type::Content) + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + connect(segment, &LlmSegment::statusChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::resultChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::runningChanged, this, [this]() { + Q_EMIT elapsedMsChanged(); + Q_EMIT toolStateChanged(); + updateReasoningActive(); + }); + m_segments.append(segment); + Q_EMIT segmentsChanged(); +} + +void ChatGeneration::closeOpenSegments() { + for (auto* segment : m_segments) + if (segment->running()) segment->close(); + updateReasoningActive(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp new file mode 100644 index 0000000..20b4709 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "segment.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatGeneration : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat generations are managed by ChatMessage") + + Q_PROPERTY( + QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY( + qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY + elapsedMsChanged) + Q_PROPERTY( + qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY( + bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY( + QList segments READ segments NOTIFY + segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) + + public: + explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString content() const; + [[nodiscard]] QString reasoning() const; + [[nodiscard]] bool reasoningActive() const; + [[nodiscard]] qint64 reasoningElapsedMs() const; + [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] qint64 toolsElapsedMs() const; + [[nodiscard]] bool streaming() const { return m_streaming; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QList segments() const { return m_segments; } + [[nodiscard]] int toolCallCount() const; + [[nodiscard]] bool hasRunningTool() const; + + void setContent(const QString& value); + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setStreaming(bool value); + + [[nodiscard]] LlmSegment* openContentSegment(); + [[nodiscard]] LlmSegment* openReasoningSegment(); + [[nodiscard]] LlmSegment* beginToolCall( + const QString& name, const QString& toolCallId); + void addSegment(LlmSegment* segment); + void closeOpenSegments(); + + Q_SIGNALS: + void contentChanged(); + void reasoningChanged(); + void reasoningActiveChanged(); + void elapsedMsChanged(); + void streamingChanged(); + void toolStateChanged(); + void segmentsChanged(); + + private: + void updateReasoningActive(); + + QTimer m_timer; + QList m_segments; + bool m_reasoningActive = false; + bool m_streaming = false; + qint64 m_timestamp; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/highlight-queries/bash.scm b/Plugins/ZShell/Llm/highlight-queries/bash.scm new file mode 100644 index 0000000..a45327d --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/bash.scm @@ -0,0 +1,59 @@ +; Vendored from bash (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-bash + +[ + (string) + (raw_string) + (heredoc_body) + (heredoc_start) +] @string + +(command_name) @function + +(variable_name) @property + +[ + "case" + "do" + "done" + "elif" + "else" + "esac" + "export" + "fi" + "for" + "function" + "if" + "in" + "select" + "then" + "unset" + "until" + "while" +] @keyword + +(comment) @comment + +(function_definition name: (word) @function) + +(file_descriptor) @number + +[ + (command_substitution) + (process_substitution) + (expansion) +]@embedded + +[ + "$" + "&&" + ">" + ">>" + "<" + "|" +] @operator + +( + (command (_) @constant) + (#match? @constant "^-") +) diff --git a/Plugins/ZShell/Llm/highlight-queries/c.scm b/Plugins/ZShell/Llm/highlight-queries/c.scm new file mode 100644 index 0000000..c9652e5 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/c.scm @@ -0,0 +1,84 @@ +; Vendored from c (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-c + +(identifier) @variable + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]*$")) + +"break" @keyword +"case" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"do" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"for" @keyword +"if" @keyword +"inline" @keyword +"return" @keyword +"sizeof" @keyword +"static" @keyword +"struct" @keyword +"switch" @keyword +"typedef" @keyword +"union" @keyword +"volatile" @keyword +"while" @keyword + +"#define" @keyword +"#elif" @keyword +"#else" @keyword +"#endif" @keyword +"#if" @keyword +"#ifdef" @keyword +"#ifndef" @keyword +"#include" @keyword +(preproc_directive) @keyword + +"--" @operator +"-" @operator +"-=" @operator +"->" @operator +"=" @operator +"!=" @operator +"*" @operator +"&" @operator +"&&" @operator +"+" @operator +"++" @operator +"+=" @operator +"<" @operator +"==" @operator +">" @operator +"||" @operator + +"." @delimiter +";" @delimiter + +(string_literal) @string +(system_lib_string) @string + +(null) @constant +(number_literal) @number +(char_literal) @number + +(field_identifier) @property +(statement_identifier) @label +(type_identifier) @type +(primitive_type) @type +(sized_type_specifier) @type + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function)) +(function_declarator + declarator: (identifier) @function) +(preproc_function_def + name: (identifier) @function.special) + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/cpp.scm b/Plugins/ZShell/Llm/highlight-queries/cpp.scm new file mode 100644 index 0000000..127c6a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/cpp.scm @@ -0,0 +1,73 @@ +; Vendored from cpp (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-cpp (tag v0.23.4, matching the distro grammar) + +; Functions + +(call_expression + function: (qualified_identifier + name: (identifier) @function)) + +(template_function + name: (identifier) @function) + +(template_method + name: (field_identifier) @function) + +(template_function + name: (identifier) @function) + +(function_declarator + declarator: (qualified_identifier + name: (identifier) @function)) + +(function_declarator + declarator: (field_identifier) @function) + +; Types + +((namespace_identifier) @type + (#match? @type "^[A-Z]")) + +(auto) @type + +; Constants + +(this) @variable.builtin +(null "nullptr" @constant) + +; Keywords + +[ + "catch" + "class" + "co_await" + "co_return" + "co_yield" + "constexpr" + "constinit" + "consteval" + "delete" + "explicit" + "final" + "friend" + "mutable" + "namespace" + "noexcept" + "new" + "override" + "private" + "protected" + "public" + "template" + "throw" + "try" + "typename" + "using" + "concept" + "requires" + "virtual" +] @keyword + +; Strings + +(raw_string_literal) @string diff --git a/Plugins/ZShell/Llm/highlight-queries/go.scm b/Plugins/ZShell/Llm/highlight-queries/go.scm new file mode 100644 index 0000000..481d5cc --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/go.scm @@ -0,0 +1,126 @@ +; Vendored from go (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-go + +; Function calls + +(call_expression + function: (identifier) @function) + +(call_expression + function: (identifier) @function.builtin + (#match? @function.builtin "^(append|cap|close|complex|copy|delete|imag|len|make|new|panic|print|println|real|recover)$")) + +(call_expression + function: (selector_expression + field: (field_identifier) @function.method)) + +; Function definitions + +(function_declaration + name: (identifier) @function) + +(method_declaration + name: (field_identifier) @function.method) + +; Identifiers + +(type_identifier) @type +(field_identifier) @property +(identifier) @variable + +; Operators + +[ + "--" + "-" + "-=" + ":=" + "!" + "!=" + "..." + "*" + "*" + "*=" + "/" + "/=" + "&" + "&&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "++" + "+=" + "<-" + "<" + "<<" + "<<=" + "<=" + "=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "||" + "~" +] @operator + +; Keywords + +[ + "break" + "case" + "chan" + "const" + "continue" + "default" + "defer" + "else" + "fallthrough" + "for" + "func" + "go" + "goto" + "if" + "import" + "interface" + "map" + "package" + "range" + "return" + "select" + "struct" + "switch" + "type" + "var" +] @keyword + +; Literals + +[ + (interpreted_string_literal) + (raw_string_literal) + (rune_literal) +] @string + +(escape_sequence) @escape + +[ + (int_literal) + (float_literal) + (imaginary_literal) +] @number + +[ + (true) + (false) + (nil) + (iota) +] @constant.builtin + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/javascript.scm b/Plugins/ZShell/Llm/highlight-queries/javascript.scm new file mode 100644 index 0000000..92b1a52 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/javascript.scm @@ -0,0 +1,207 @@ +; Vendored from javascript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-javascript + +; Variables +;---------- + +(identifier) @variable + +; Properties +;----------- + +(property_identifier) @property + +; Function and method definitions +;-------------------------------- + +(function_expression + name: (identifier) @function) +(function_declaration + name: (identifier) @function) +(method_definition + name: (property_identifier) @function.method) + +(pair + key: (property_identifier) @function.method + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (member_expression + property: (property_identifier) @function.method) + right: [(function_expression) (arrow_function)]) + +(variable_declarator + name: (identifier) @function + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (identifier) @function + right: [(function_expression) (arrow_function)]) + +; Function and method calls +;-------------------------- + +(call_expression + function: (identifier) @function) + +(call_expression + function: (member_expression + property: (property_identifier) @function.method)) + +; Special identifiers +;-------------------- + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +([ + (identifier) + (shorthand_property_identifier) + (shorthand_property_identifier_pattern) + ] @constant + (#match? @constant "^[A-Z_][A-Z\\d_]+$")) + +((identifier) @variable.builtin + (#match? @variable.builtin "^(arguments|module|console|window|document)$") + (#is-not? local)) + +((identifier) @function.builtin + (#eq? @function.builtin "require") + (#is-not? local)) + +; Literals +;--------- + +(this) @variable.builtin +(super) @variable.builtin + +[ + (true) + (false) + (null) + (undefined) +] @constant.builtin + +(comment) @comment + +[ + (string) + (template_string) +] @string + +(regex) @string.special +(number) @number + +; Tokens +;------- + +[ + ";" + (optional_chain) + "." + "," +] @punctuation.delimiter + +[ + "-" + "--" + "-=" + "+" + "++" + "+=" + "*" + "*=" + "**" + "**=" + "/" + "/=" + "%" + "%=" + "<" + "<=" + "<<" + "<<=" + "=" + "==" + "===" + "!" + "!=" + "!==" + "=>" + ">" + ">=" + ">>" + ">>=" + ">>>" + ">>>=" + "~" + "^" + "&" + "|" + "^=" + "&=" + "|=" + "&&" + "||" + "??" + "&&=" + "||=" + "??=" +] @operator + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +(template_substitution + "${" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "as" + "async" + "await" + "break" + "case" + "catch" + "class" + "const" + "continue" + "debugger" + "default" + "delete" + "do" + "else" + "export" + "extends" + "finally" + "for" + "from" + "function" + "get" + "if" + "import" + "in" + "instanceof" + "let" + "new" + "of" + "return" + "set" + "static" + "switch" + "target" + "throw" + "try" + "typeof" + "var" + "void" + "while" + "with" + "yield" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/json.scm b/Plugins/ZShell/Llm/highlight-queries/json.scm new file mode 100644 index 0000000..ede99aa --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/json.scm @@ -0,0 +1,19 @@ +; Vendored from json (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-json + +(pair + key: (_) @string.special.key) + +(string) @string + +(number) @number + +[ + (null) + (true) + (false) +] @constant.builtin + +(escape_sequence) @escape + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/python.scm b/Plugins/ZShell/Llm/highlight-queries/python.scm new file mode 100644 index 0000000..fd315a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/python.scm @@ -0,0 +1,140 @@ +; Vendored from python (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-python + +; Identifier naming conventions + +(identifier) @variable + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]*$")) + +; Function calls + +(decorator) @function +(decorator + (identifier) @function) + +(call + function: (attribute attribute: (identifier) @function.method)) +(call + function: (identifier) @function) + +; Builtin functions + +((call + function: (identifier) @function.builtin) + (#match? + @function.builtin + "^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$")) + +; Function definitions + +(function_definition + name: (identifier) @function) + +(attribute attribute: (identifier) @property) +(type (identifier) @type) + +; Literals + +[ + (none) + (true) + (false) +] @constant.builtin + +[ + (integer) + (float) +] @number + +(comment) @comment +(string) @string +(escape_sequence) @escape + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "-" + "-=" + "!=" + "*" + "**" + "**=" + "*=" + "/" + "//" + "//=" + "/=" + "&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "->" + "+=" + "<" + "<<" + "<<=" + "<=" + "<>" + "=" + ":=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "~" + "@=" + "and" + "in" + "is" + "not" + "or" + "is not" + "not in" +] @operator + +[ + "as" + "assert" + "async" + "await" + "break" + "class" + "continue" + "def" + "del" + "elif" + "else" + "except" + "exec" + "finally" + "for" + "from" + "global" + "if" + "import" + "lambda" + "nonlocal" + "pass" + "print" + "raise" + "return" + "try" + "while" + "with" + "yield" + "match" + "case" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/rust.scm b/Plugins/ZShell/Llm/highlight-queries/rust.scm new file mode 100644 index 0000000..c1acc9e --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/rust.scm @@ -0,0 +1,164 @@ +; Vendored from rust (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-rust + +; Identifiers + +(type_identifier) @type +(primitive_type) @type.builtin +(field_identifier) @property + +; Identifier conventions + +; Assume all-caps names are constants +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]+$'")) + +; Assume uppercase names are enum constructors +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +; Assume that uppercase names in paths are types +((scoped_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) + +; Assume all qualified names in struct patterns are enum constructors. (They're +; either that, or struct names; highlighting both as constructors seems to be +; the less glaring choice of error, visually.) +(struct_pattern + type: (scoped_type_identifier + name: (type_identifier) @constructor)) + +; Function calls + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function.method)) +(call_expression + function: (scoped_identifier + "::" + name: (identifier) @function)) + +(generic_function + function: (identifier) @function) +(generic_function + function: (scoped_identifier + name: (identifier) @function)) +(generic_function + function: (field_expression + field: (field_identifier) @function.method)) + +(macro_invocation + macro: (identifier) @function.macro + "!" @function.macro) + +; Function definitions + +(function_item (identifier) @function) +(function_signature_item (identifier) @function) + +(line_comment) @comment +(block_comment) @comment + +(line_comment (doc_comment)) @comment.documentation +(block_comment (doc_comment)) @comment.documentation + +"(" @punctuation.bracket +")" @punctuation.bracket +"[" @punctuation.bracket +"]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) +(type_parameters + "<" @punctuation.bracket + ">" @punctuation.bracket) + +"::" @punctuation.delimiter +":" @punctuation.delimiter +"." @punctuation.delimiter +"," @punctuation.delimiter +";" @punctuation.delimiter + +(parameter (identifier) @variable.parameter) + +(lifetime (identifier) @label) + +"as" @keyword +"async" @keyword +"await" @keyword +"break" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"dyn" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"fn" @keyword +"for" @keyword +"gen" @keyword +"if" @keyword +"impl" @keyword +"in" @keyword +"let" @keyword +"loop" @keyword +"macro_rules!" @keyword +"match" @keyword +"mod" @keyword +"move" @keyword +"pub" @keyword +"raw" @keyword +"ref" @keyword +"return" @keyword +"static" @keyword +"struct" @keyword +"trait" @keyword +"type" @keyword +"union" @keyword +"unsafe" @keyword +"use" @keyword +"where" @keyword +"while" @keyword +"yield" @keyword +(crate) @keyword +(mutable_specifier) @keyword +(use_list (self) @keyword) +(scoped_use_list (self) @keyword) +(scoped_identifier (self) @keyword) +(super) @keyword + +(self) @variable.builtin + +(char_literal) @string +(string_literal) @string +(raw_string_literal) @string + +(boolean_literal) @constant.builtin +(integer_literal) @constant.builtin +(float_literal) @constant.builtin + +(escape_sequence) @escape + +(attribute_item) @attribute +(inner_attribute_item) @attribute + +"*" @operator +"&" @operator +"'" @operator diff --git a/Plugins/ZShell/Llm/highlight-queries/sql.scm b/Plugins/ZShell/Llm/highlight-queries/sql.scm new file mode 100644 index 0000000..de1438a --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/sql.scm @@ -0,0 +1,463 @@ +; Vendored from sql (MIT License) +; Source: https://github.com/DerekStride/tree-sitter-sql + +(object_reference + name: (identifier) @type) + +(invocation + (object_reference + name: (identifier) @function.call)) + +[ + (keyword_gist) + (keyword_btree) + (keyword_hash) + (keyword_spgist) + (keyword_gin) + (keyword_brin) + (keyword_array) + (keyword_object_id) +] @function.call + +(relation + alias: (identifier) @variable) + +(field + name: (identifier) @field) + +(term + alias: (identifier) @variable) + +((term + value: (cast + name: (keyword_cast) @function.call + parameter: [(literal)]?))) + +(literal) @string +(comment) @comment @spell +(marginalia) @comment + +((literal) @number + (#match? @number "^[-+]?%d+$")) + +((literal) @float + (#match? @float "^[-+]?%d*\.%d*$")) + +(parameter) @parameter + +[ + (keyword_true) + (keyword_false) +] @boolean + +[ + (keyword_asc) + (keyword_desc) + (keyword_terminated) + (keyword_escaped) + (keyword_unsigned) + (keyword_nulls) + (keyword_last) + (keyword_delimited) + (keyword_replication) + (keyword_auto_increment) + (keyword_default) + (keyword_collate) + (keyword_concurrently) + (keyword_engine) + (keyword_always) + (keyword_generated) + (keyword_preceding) + (keyword_following) + (keyword_first) + (keyword_current_timestamp) + (keyword_immutable) + (keyword_atomic) + (keyword_parallel) + (keyword_leakproof) + (keyword_safe) + (keyword_cost) + (keyword_strict) +] @attribute + +[ + (keyword_materialized) + (keyword_recursive) + (keyword_temp) + (keyword_temporary) + (keyword_unlogged) + (keyword_external) + (keyword_parquet) + (keyword_csv) + (keyword_rcfile) + (keyword_textfile) + (keyword_orc) + (keyword_avro) + (keyword_jsonfile) + (keyword_sequencefile) + (keyword_volatile) +] @storageclass + +[ + (keyword_case) + (keyword_when) + (keyword_then) + (keyword_else) +] @conditional + +[ + (keyword_select) + (keyword_from) + (keyword_where) + (keyword_index) + (keyword_join) + (keyword_primary) + (keyword_delete) + (keyword_create) + (keyword_show) + (keyword_unload) + (keyword_insert) + (keyword_merge) + (keyword_distinct) + (keyword_replace) + (keyword_update) + (keyword_into) + (keyword_overwrite) + (keyword_matched) + (keyword_values) + (keyword_value) + (keyword_attribute) + (keyword_set) + (keyword_left) + (keyword_right) + (keyword_outer) + (keyword_inner) + (keyword_full) + (keyword_order) + (keyword_partition) + (keyword_group) + (keyword_with) + (keyword_without) + (keyword_as) + (keyword_having) + (keyword_limit) + (keyword_offset) + (keyword_table) + (keyword_tables) + (keyword_key) + (keyword_references) + (keyword_foreign) + (keyword_constraint) + (keyword_force) + (keyword_use) + (keyword_include) + (keyword_for) + (keyword_if) + (keyword_exists) + (keyword_column) + (keyword_columns) + (keyword_cross) + (keyword_lateral) + (keyword_natural) + (keyword_alter) + (keyword_drop) + (keyword_add) + (keyword_view) + (keyword_end) + (keyword_is) + (keyword_using) + (keyword_between) + (keyword_window) + (keyword_no) + (keyword_data) + (keyword_type) + (keyword_rename) + (keyword_refresh) + (keyword_to) + (keyword_schema) + (keyword_owner) + (keyword_authorization) + (keyword_all) + (keyword_any) + (keyword_some) + (keyword_returning) + (keyword_begin) + (keyword_commit) + (keyword_rollback) + (keyword_transaction) + (keyword_only) + (keyword_like) + (keyword_rlike) + (keyword_similar) + (keyword_over) + (keyword_change) + (keyword_modify) + (keyword_after) + (keyword_before) + (keyword_range) + (keyword_rows) + (keyword_groups) + (keyword_exclude) + (keyword_current) + (keyword_ties) + (keyword_others) + (keyword_zerofill) + (keyword_format) + (keyword_fields) + (keyword_row) + (keyword_sort) + (keyword_compute) + (keyword_comment) + (keyword_location) + (keyword_cached) + (keyword_uncached) + (keyword_lines) + (keyword_stored) + (keyword_virtual) + (keyword_partitioned) + (keyword_analyze) + (keyword_explain) + (keyword_verbose) + (keyword_truncate) + (keyword_rewrite) + (keyword_optimize) + (keyword_vacuum) + (keyword_cache) + (keyword_language) + (keyword_called) + (keyword_conflict) + (keyword_declare) + (keyword_filter) + (keyword_function) + (keyword_input) + (keyword_name) + (keyword_oid) + (keyword_oids) + (keyword_precision) + (keyword_regclass) + (keyword_regnamespace) + (keyword_regproc) + (keyword_regtype) + (keyword_restricted) + (keyword_return) + (keyword_returns) + (keyword_separator) + (keyword_setof) + (keyword_stable) + (keyword_support) + (keyword_tblproperties) + (keyword_trigger) + (keyword_unsafe) + (keyword_admin) + (keyword_connection) + (keyword_cycle) + (keyword_database) + (keyword_encrypted) + (keyword_increment) + (keyword_logged) + (keyword_none) + (keyword_owned) + (keyword_password) + (keyword_reset) + (keyword_role) + (keyword_current_role) + (keyword_sequence) + (keyword_start) + (keyword_restart) + (keyword_tablespace) + (keyword_split) + (keyword_tablets) + (keyword_until) + (keyword_user) + (keyword_current_user) + (keyword_session_user) + (keyword_valid) + (keyword_action) + (keyword_definer) + (keyword_invoker) + (keyword_enable) + (keyword_disable) + (keyword_security) + (keyword_policy) + (keyword_permissive) + (keyword_restrictive) + (keyword_public) + (keyword_extension) + (keyword_version) + (keyword_out) + (keyword_inout) + (keyword_variadic) + (keyword_ordinality) + (keyword_session) + (keyword_isolation) + (keyword_level) + (keyword_serializable) + (keyword_repeatable) + (keyword_read) + (keyword_write) + (keyword_committed) + (keyword_uncommitted) + (keyword_deferrable) + (keyword_names) + (keyword_zone) + (keyword_immediate) + (keyword_deferred) + (keyword_constraints) + (keyword_snapshot) + (keyword_characteristics) + (keyword_off) + (keyword_follows) + (keyword_precedes) + (keyword_each) + (keyword_instead) + (keyword_of) + (keyword_initially) + (keyword_old) + (keyword_new) + (keyword_referencing) + (keyword_statement) + (keyword_execute) + (keyword_procedure) + (keyword_copy) + (keyword_delimiter) + (keyword_encoding) + (keyword_escape) + (keyword_force_not_null) + (keyword_force_null) + (keyword_force_quote) + (keyword_freeze) + (keyword_header) + (keyword_match) + (keyword_program) + (keyword_quote) + (keyword_stdin) + (keyword_extended) + (keyword_main) + (keyword_plain) + (keyword_storage) + (keyword_compression) + (keyword_duplicate) + (keyword_while) +] @keyword + +[ + (keyword_restrict) + (keyword_unbounded) + (keyword_unique) + (keyword_cascade) + (keyword_delayed) + (keyword_high_priority) + (keyword_low_priority) + (keyword_ignore) + (keyword_nothing) + (keyword_check) + (keyword_option) + (keyword_local) + (keyword_cascaded) + (keyword_wait) + (keyword_nowait) + (keyword_metadata) + (keyword_incremental) + (keyword_bin_pack) + (keyword_noscan) + (keyword_stats) + (keyword_statistics) + (keyword_maxvalue) + (keyword_minvalue) +] @type.qualifier + +[ + (keyword_int) + (keyword_null) + (keyword_boolean) + (keyword_binary) + (keyword_varbinary) + (keyword_image) + (keyword_bit) + (keyword_inet) + (keyword_character) + (keyword_smallserial) + (keyword_serial) + (keyword_bigserial) + (keyword_smallint) + (keyword_mediumint) + (keyword_bigint) + (keyword_tinyint) + (keyword_decimal) + (keyword_float) + (keyword_double) + (keyword_numeric) + (keyword_real) + (double) + (keyword_money) + (keyword_smallmoney) + (keyword_char) + (keyword_nchar) + (keyword_varchar) + (keyword_nvarchar) + (keyword_varying) + (keyword_text) + (keyword_string) + (keyword_uuid) + (keyword_json) + (keyword_jsonb) + (keyword_xml) + (keyword_bytea) + (keyword_enum) + (keyword_date) + (keyword_datetime) + (keyword_time) + (keyword_datetime2) + (keyword_datetimeoffset) + (keyword_smalldatetime) + (keyword_timestamp) + (keyword_timestamptz) + (keyword_geometry) + (keyword_geography) + (keyword_box2d) + (keyword_box3d) + (keyword_interval) +] @type.builtin + +[ + (keyword_in) + (keyword_and) + (keyword_or) + (keyword_not) + (keyword_by) + (keyword_on) + (keyword_do) + (keyword_union) + (keyword_except) + (keyword_intersect) +] @keyword.operator + +[ + "+" + "-" + "*" + "/" + "%" + "^" + ":=" + "=" + "<" + "<=" + "!=" + ">=" + ">" + "<>" + (op_other) + (op_unary_other) +] @operator + +[ + "(" + ")" +] @punctuation.bracket + +[ + ";" + "," + "." +] @punctuation.delimiter diff --git a/Plugins/ZShell/Llm/highlight-queries/toml.scm b/Plugins/ZShell/Llm/highlight-queries/toml.scm new file mode 100644 index 0000000..b26f3a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/toml.scm @@ -0,0 +1,36 @@ +; Vendored from toml (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-toml + +; Properties +;----------- + +(bare_key) @property +(quoted_key) @string + +; Literals +;--------- + +(boolean) @constant.builtin +(comment) @comment +(string) @string +(integer) @number +(float) @number +(offset_date_time) @string.special +(local_date_time) @string.special +(local_date) @string.special +(local_time) @string.special + +; Punctuation +;------------ + +"." @punctuation.delimiter +"," @punctuation.delimiter + +"=" @operator + +"[" @punctuation.bracket +"]" @punctuation.bracket +"[[" @punctuation.bracket +"]]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket diff --git a/Plugins/ZShell/Llm/highlight-queries/typescript.scm b/Plugins/ZShell/Llm/highlight-queries/typescript.scm new file mode 100644 index 0000000..72500ee --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/typescript.scm @@ -0,0 +1,38 @@ +; Vendored from typescript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-typescript + +; Types + +(type_identifier) @type +(predefined_type) @type.builtin + +((identifier) @type + (#match? @type "^[A-Z]")) + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) + +; Variables + +(required_parameter (identifier) @variable.parameter) +(optional_parameter (identifier) @variable.parameter) + +; Keywords + +[ "abstract" + "declare" + "enum" + "export" + "implements" + "interface" + "keyof" + "namespace" + "private" + "protected" + "public" + "type" + "readonly" + "override" + "satisfies" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/yaml.scm b/Plugins/ZShell/Llm/highlight-queries/yaml.scm new file mode 100644 index 0000000..bbfa7e6 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/yaml.scm @@ -0,0 +1,82 @@ +; Vendored from yaml (MIT License) +; Source: https://github.com/tree-sitter-grammars/tree-sitter-yaml + +(boolean_scalar) @boolean + +(null_scalar) @constant.builtin + +[ + (double_quote_scalar) + (single_quote_scalar) + (block_scalar) + (string_scalar) +] @string + +[ + (integer_scalar) + (float_scalar) +] @number + +(comment) @comment + +[ + (anchor_name) + (alias_name) +] @label + +(tag) @type + +[ + (yaml_directive) + (tag_directive) + (reserved_directive) +] @attribute + +(block_mapping_pair + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property)) + +(block_mapping_pair + key: (flow_node + (plain_scalar + (string_scalar) @property))) + +(flow_mapping + (_ + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property))) + +(flow_mapping + (_ + key: (flow_node + (plain_scalar + (string_scalar) @property)))) + +[ + "," + "-" + ":" + ">" + "?" + "|" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "*" + "&" + "---" + "..." +] @punctuation.special diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp new file mode 100644 index 0000000..b29f92b --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -0,0 +1,768 @@ +#include "llmclient.hpp" + +#include "generation.hpp" +#include "message.hpp" +#include "messagemodel.hpp" +#include "segment.hpp" +#include "session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +QString LlmClient::completionsPath( + const QString& endpoint, const QString& subpath) { + QString base = endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + if (!base.endsWith("/v1")) base += "/v1"; + return base + subpath; +} + +QString LlmClient::serverErrorMessage( + const QByteArray& body, const QString& fallback) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + if (obj.contains("error")) { + const QJsonValue errorValue = obj["error"]; + if (errorValue.isObject()) { + const QString message = + errorValue.toObject()["message"].toString(); + if (!message.isEmpty()) return message; + } else if (!errorValue.toString().isEmpty()) { + return errorValue.toString(); + } + } + } + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void LlmClient::setBusy(bool value) { + if (m_busy == value) return; + m_busy = value; + Q_EMIT busyChanged(); +} + +void LlmClient::setStreamingChatId(const QString& id) { + if (m_streamingChatId == id) return; + m_streamingChatId = id; + Q_EMIT streamingChatIdChanged(); +} + +LlmClient::LlmClient(QObject* parent) : QObject(parent) { + m_tools = new ToolRegistry(this); + connect( + m_tools, + &ToolRegistry::enabledChanged, + this, + &LlmClient::toolsEnabledChanged); + probeContextSize(); +} + +LlmClient::~LlmClient() { + if (m_reply) m_reply->abort(); + m_tools->cancelAll(); + endStream(); +} + +void LlmClient::setEndpoint(const QString& value) { + if (m_endpoint == value) return; + m_endpoint = value; + Q_EMIT endpointChanged(); + probeContextSize(); + if (m_model.isEmpty()) refreshModels(); +} + +void LlmClient::setModel(const QString& value) { + if (m_model == value) return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) refreshModels(); +} + +void LlmClient::setTemperature(double value) { + m_temperature = value; +} + +void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target) return; + m_active = session; + m_streaming = target; + m_streaming->setStreaming(true); + setBusy(true); + setStreamingChatId(session->id()); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + const auto* model = session->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(target->parent())); + if (targetRow < 0) { + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); + return; + } + + m_transcript = QJsonArray(); + m_callBuilders.clear(); + m_callResults.clear(); + m_finishReason.clear(); + m_round = 0; + m_contentMark = 0; + m_reasoningMark = 0; + + sendRound(); +} + +void LlmClient::sendRound() { + if (!m_active || !m_streaming) return; + m_finishReason.clear(); + m_callBuilders.clear(); + m_callResults.clear(); + m_roundDone = false; + m_toolPhase = false; + m_pendingCalls = 0; + m_buffer.clear(); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + const auto* model = m_active->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(m_streaming->parent())); + if (targetRow < 0) { + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); + return; + } + + QJsonArray messages = buildContextMessages(m_active, targetRow); + for (const QJsonValue& value : m_transcript) + messages.append(value); + + QJsonObject body; + QJsonObject streamOptions; + streamOptions[QStringLiteral("include_usage")] = true; + body[QStringLiteral("stream_options")] = streamOptions; + body[QStringLiteral("messages")] = messages; + body[QStringLiteral("stream")] = true; + body[QStringLiteral("temperature")] = m_temperature; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; + const QJsonArray toolSpecs = m_tools->specifications(); + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_reply) m_buffer.append(m_reply->readAll()); + drainBuffer(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply* reply = m_reply; + if (!reply) return; + m_reply = nullptr; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + const QByteArray responseBody = reply->readAll(); + m_buffer.append(responseBody); + reply->deleteLater(); + + drainBuffer(); + if (!m_streaming) return; + + if (error == QNetworkReply::NoError) + roundFinished(); + else if (error == QNetworkReply::OperationCanceledError) + finishTurn(); + else + fail(serverErrorMessage(responseBody, errorString)); + }); +} + +QJsonArray LlmClient::buildContextMessages( + ChatSession* session, int stopBeforeRow) const { + const auto* model = session->messagesModel(); + QJsonArray messages; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + const auto* generation = message->activeGeneration(); + if (!generation) continue; + + if (message->role() == ChatMessage::Role::User) { + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = generation->content(); + messages.append(user); + continue; + } + + QList toolSegments; + for (const auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall) + toolSegments.append(segment); + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (toolSegments.isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + else if (!generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + + if (toolSegments.isEmpty()) { + messages.append(assistant); + continue; + } + QJsonArray calls; + for (const auto* segment : toolSegments) { + QJsonObject function; + function[QStringLiteral("name")] = segment->name(); + function[QStringLiteral("arguments")] = segment->arguments(); + QJsonObject call; + call[QStringLiteral("id")] = segment->toolCallId(); + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + messages.append(assistant); + for (const auto* segment : toolSegments) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId(); + toolMessage[QStringLiteral("content")] = segment->result(); + messages.append(toolMessage); + } + } + return messages; +} + +void LlmClient::applyToolCallDelta(const QJsonObject& call) { + if (!m_streaming) return; + const int index = call[QStringLiteral("index")].toInt(-1); + if (index < 0) return; + while (m_callBuilders.size() <= index) + m_callBuilders.append(ToolCallBuilder{}); + ToolCallBuilder& builder = m_callBuilders[index]; + builder.seen = true; + const QString id = call[QStringLiteral("id")].toString(); + if (!id.isEmpty()) builder.id = id; + const QJsonObject function = call[QStringLiteral("function")].toObject(); + const QString name = function[QStringLiteral("name")].toString(); + if (!name.isEmpty()) builder.name = name; + const QString arguments = function[QStringLiteral("arguments")].toString(); + if (!arguments.isEmpty()) builder.arguments += arguments; + + if (!builder.segment) { + m_streaming->closeOpenSegments(); + builder.segment = m_streaming->beginToolCall(builder.name, builder.id); + } + builder.segment->setName(builder.name); + builder.segment->setToolCallId(builder.id); + if (!arguments.isEmpty()) builder.segment->appendArguments(arguments); +} + +void LlmClient::roundFinished() { + if (m_roundDone || !m_streaming) return; + m_roundDone = true; + + bool hasCalls = false; + for (const auto& builder : m_callBuilders) + if (builder.seen) hasCalls = true; + if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { + finishTurn(); + return; + } + if (m_round >= kMaxToolRounds) { + qWarning() << "LlmClient: tool round limit reached, ending turn"; + finishTurn(); + return; + } + + m_streaming->closeOpenSegments(); + + const QString content = m_streaming->content(); + const QString reasoning = m_streaming->reasoning(); + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (content.size() > m_contentMark) { + assistant[QStringLiteral("content")] = content.mid(m_contentMark); + m_contentMark = content.size(); + } + if (reasoning.size() > m_reasoningMark) { + assistant[QStringLiteral("reasoning_content")] = + reasoning.mid(m_reasoningMark); + m_reasoningMark = reasoning.size(); + } + QJsonArray calls; + for (const auto& builder : m_callBuilders) { + if (!builder.seen) continue; + QJsonObject function; + function[QStringLiteral("name")] = builder.name; + function[QStringLiteral("arguments")] = builder.arguments; + QJsonObject call; + call[QStringLiteral("id")] = builder.id; + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + m_transcript.append(assistant); + m_round++; + + executeAllCalls(); +} + +void LlmClient::executeAllCalls() { + m_toolPhase = true; + m_pendingCalls = 0; + m_callResults = QList(m_callBuilders.size()); + + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) continue; + + LlmTool* tool = m_tools->tool(call.name); + QJsonObject args; + QString errorText; + if (!tool) { + errorText = + QStringLiteral("Error: unknown tool '%1'").arg(call.name); + } else if (!call.arguments.isEmpty()) { + const QJsonDocument doc = + QJsonDocument::fromJson(call.arguments.toUtf8()); + if (!doc.isObject()) { + errorText = QStringLiteral( + "Error: tool arguments are not valid " + "JSON: %1") + .arg(call.arguments); + } else { + args = doc.object(); + } + } + if (!errorText.isEmpty()) { + m_callResults[i] = {errorText, false}; + if (LlmSegment* segment = call.segment) + segment->finishTool(errorText, false); + continue; + } + + ++m_pendingCalls; + tool->execute(args, [this, i, call](const QJsonObject& result) { + if (!m_streaming) return; + const bool success = result.contains(QStringLiteral("output")); + const QString content = + success ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = {content, success}; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) flushCallResults(); + }); + } + if (m_pendingCalls == 0) flushCallResults(); +} + +void LlmClient::flushCallResults() { + if (!m_toolPhase) return; + m_toolPhase = false; + if (!m_streaming) return; + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = m_callResults.at(i).content; + m_transcript.append(toolMessage); + } + sendRound(); +} + +void LlmClient::stop() { + if (!m_busy) return; + if (m_toolPhase) { + m_tools->cancelAll(); + if (m_streaming) { + for (auto* segment : m_streaming->segments()) { + if (segment->type() == LlmSegment::Type::ToolCall && + segment->running()) + segment->finishTool(QStringLiteral("Cancelled"), false); + } + } + finishTurn(); + return; + } + if (m_reply) m_reply->abort(); +} + +void LlmClient::endStream() { + if (!m_streaming) return; + auto* generation = m_streaming; + auto* session = m_active; + m_streaming = nullptr; + m_active = nullptr; + generation->setStreaming(false); + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && + generation->toolCallCount() == 0) { + if (auto* message = qobject_cast(generation->parent())) { + if (message->generationCount() <= 1) { + if (session) session->removeMessage(message); + } else { + message->removeGeneration(generation); + } + } + } + setBusy(false); + setStreamingChatId(QString()); +} + +void LlmClient::finishTurn() { + if (!m_streaming) return; + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) session->persist(); +} + +void LlmClient::clearOnFinish(ChatSession* session) { + m_pendingClear = session; +} + +void LlmClient::sessionRemoved(ChatSession* session) { + if (m_pendingClear == session) m_pendingClear.clear(); + if (m_active == session) { + stop(); + endStream(); + } +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + finishTurn(); + Q_EMIT errorOccurred(message); +} + +void LlmClient::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void LlmClient::handleLine(const QByteArray& line) { + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + roundFinished(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail( + message.isEmpty() ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_streaming) continue; + const QJsonObject choice = choiceValue.toObject(); + const QJsonObject delta = choice["delta"].toObject(); + + const QString finishReason = + choice[QStringLiteral("finish_reason")].toString(); + if (!finishReason.isEmpty()) m_finishReason = finishReason; + + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + + for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { + if (!m_streaming) break; + applyToolCallDelta(callValue.toObject()); + } + } +} + +void LlmClient::refreshModels() { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "LlmClient: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void LlmClient::probeContextSize() { + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"] + .toObject()["n_ctx"] + .toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_active || m_contextSize <= 0) return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) return; + const double used = usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) m_active->setLastTokenCount(static_cast(used)); +} + +void LlmClient::shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult) { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) return; + + qInfo() << "LlmClient:" << tag << "request POST" << url.toString() + << "model=" << m_model; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QJsonArray messages; + QJsonObject system; + system[QStringLiteral("role")] = QStringLiteral("system"); + system[QStringLiteral("content")] = systemPrompt; + messages.append(system); + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = userText.simplified().mid(0, 512); + messages.append(user); + + QJsonObject body; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; + body[QStringLiteral("stream")] = false; + body[QStringLiteral("temperature")] = 0.3; + body[QStringLiteral("max_tokens")] = 128; + QJsonObject templateKwargs; + templateKwargs[QStringLiteral("enable_thinking")] = false; + body[QStringLiteral("chat_template_kwargs")] = templateKwargs; + body[QStringLiteral("messages")] = messages; + + auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply, tag, onResult = std::move(onResult)]() { + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + qInfo() << "LlmClient:" << tag << "request finished" + << "error=" << reply->error() << reply->errorString() + << "http=" + << reply + ->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); + + if (reply->error() != QNetworkReply::NoError) return; + + const QJsonDocument doc = QJsonDocument::fromJson(data); + const QJsonArray choices = + doc.object()[QStringLiteral("choices")].toArray(); + if (choices.isEmpty()) return; + const QString result = choices.at(0) + .toObject()[QStringLiteral("message")] + .toObject()[QStringLiteral("content")] + .toString() + .trimmed(); + qInfo() << "LlmClient:" << tag << "raw result" << result; + onResult(result); + }); +} + +void LlmClient::requestTitle(ChatSession* session, const QString& userText) { + shortRequest( + QStringLiteral("title"), + QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."), + userText, + [this, session = QPointer(session)](QString title) { + if (!session) { + qWarning() << "LlmClient: title request: session gone"; + return; + } + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + if (title.size() < 2) { + qWarning() << "LlmClient: title rejected (too short)" << title; + return; + } + if (title.size() > 48) title = title.left(47) + QStringLiteral("…"); + qInfo() << "LlmClient: suggesting title" << title; + Q_EMIT titleSuggested(session, title); + }); +} + +void LlmClient::requestIcon(ChatSession* session, const QString& userText) { + const QStringList icons = { + "chat", "lightbulb", "code", "description", + "article", "school", "work", "build", + "science", "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", "psychology_alt", + "favorite", "savings", "gamepad", "auto_awesome", + }; + const QString prompt = + QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the icon " + "name, exactly as written in the list, and nothing else.") + .arg(icons.join(QStringLiteral(", "))); + shortRequest( + QStringLiteral("icon"), + prompt, + userText, + [this, session = QPointer(session), icons](QString name) { + if (!session) { + qWarning() << "LlmClient: icon request: session gone"; + return; + } + name = name.simplified().toLower(); + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\''); + }; + while (name.size() >= 2 && isQuote(name.at(0)) && + isQuote(name.at(name.size() - 1))) + name = name.mid(1, name.size() - 2).simplified(); + name.replace(QLatin1Char(' '), QLatin1Char('_')); + if (!icons.contains(name)) { + qWarning() << "LlmClient: icon not in list, using default" + << name; + name = QStringLiteral("chat"); + } + qInfo() << "LlmClient: suggesting icon" << name; + Q_EMIT iconSuggested(session, name); + }); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp new file mode 100644 index 0000000..56fdfc4 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include "segment.hpp" +#include "tool.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +class QJsonObject; +class QNetworkReply; + +namespace ZShell::llm { + +class ChatGeneration; +class ChatSession; +class LlmSegment; +class LlmTool; + +class LlmClient : public QObject { + Q_OBJECT + + public: + explicit LlmClient(QObject* parent = nullptr); + ~LlmClient() override; + + [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] double temperature() const { return m_temperature; } + [[nodiscard]] QStringList availableModels() const { + return m_availableModels; + } + [[nodiscard]] int contextSize() const { return m_contextSize; } + [[nodiscard]] ToolRegistry* tools() const { return m_tools; } + void setEndpoint(const QString& value); + void setModel(const QString& value); + void setTemperature(double value); + void setContextSize(int size); + void probeContextSize(); + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } + void setToolsEnabled(bool value) { m_tools->setEnabled(value); } + [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } + [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + void startGeneration(ChatSession* session, ChatGeneration* target); + void stop(); + void endStream(); + void clearOnFinish(ChatSession* session); + void sessionRemoved(ChatSession* session); + + void refreshModels(); + void requestTitle(ChatSession* session, const QString& userText); + void requestIcon(ChatSession* session, const QString& userText); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void toolsEnabledChanged(); + void streamingChatIdChanged(); + void errorOccurred(const QString& message); + void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); + void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); + + private: + struct ToolCallBuilder { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + void sendRound(); + QJsonArray buildContextMessages( + ChatSession* session, int stopBeforeRow) const; + void applyToolCallDelta(const QJsonObject& call); + void roundFinished(); + void executeAllCalls(); + void flushCallResults(); + void finishTurn(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void updateTokenUsage(const QJsonObject& data); + void setBusy(bool value); + void setStreamingChatId(const QString& id); + void shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult); + static QString completionsPath( + const QString& endpoint, const QString& subpath); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + + QNetworkAccessManager m_manager; + ToolRegistry* m_tools = nullptr; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + ChatSession* m_active = nullptr; + ChatGeneration* m_streaming = nullptr; + QPointer m_pendingClear; + bool m_busy = false; + QString m_streamingChatId; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + double m_temperature = 0.7; + int m_contextSize = 0; + + QJsonArray m_transcript; + QList m_callBuilders; + struct ToolCallResult { + QString content; + bool success = false; + }; + QList m_callResults; + QString m_finishReason; + int m_round = 0; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; + bool m_roundDone = false; + bool m_toolPhase = false; + int m_pendingCalls = 0; + static constexpr int kMaxToolRounds = 12; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp new file mode 100644 index 0000000..7242ed3 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +class LlmMarkdown : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Blocks are produced by LlmSegment") + + public: + enum class Type : int { + Text = 0, // Paragraph, list, quote, table; "text" is markdown source + Heading, // "level" + "text" (markdown source of the content) + Code, // "language" + "code" + Math // "latex" (display math, without the $$ delimiters) + }; + Q_ENUM(Type) + + explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp new file mode 100644 index 0000000..756334d --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -0,0 +1,134 @@ +#include "markdownparser.hpp" + +#include "markdownblock.hpp" + +#include +#include + +#include +#include +#include + +namespace ZShell::llm { + +QVariantList MarkdownParser::parse(const QString& source) { + QVariantList blocks; + if (source.trimmed().isEmpty()) return blocks; + + const QStringList lines = source.split('\n'); + + auto sliceSource = [&](int startLine, int endLine) -> QString { + if (startLine < 1 || endLine < startLine) return QString(); + const int from = startLine; + const int to = qMin(endLine, static_cast(lines.size())); + return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); + }; + + const QByteArray utf8 = source.toUtf8(); + cmark_node* doc = cmark_parse_document( + utf8.constData(), + static_cast(utf8.size()), + CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); + if (!doc) return blocks; + + const QRegularExpression mathRe( + QStringLiteral("\\$\\$(.+?)\\$\\$"), + QRegularExpression::DotMatchesEverythingOption); + + auto makeBlock = [&](LlmMarkdown::Type type) { + QVariantMap block; + block.insert("type", static_cast(type)); + block.insert( + "id", + QString::number(static_cast(blocks.size())) + + QLatin1Char(':') + QString::number(static_cast(type))); + return block; + }; + + auto appendText = [&](const QString& text) { + if (text.trimmed().isEmpty()) return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Text); + block.insert("text", text); + blocks.append(block); + }; + + auto appendMath = [&](const QString& latex) { + if (latex.trimmed().isEmpty()) return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Math); + block.insert("latex", latex); + blocks.append(block); + }; + + auto appendCode = [&](const QString& language, const QString& code) { + QVariantMap block = makeBlock(LlmMarkdown::Type::Code); + block.insert("language", language); + block.insert("code", code); + blocks.append(block); + }; + + auto appendHeading = [&](int level, const QString& text) { + if (text.trimmed().isEmpty()) return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); + block.insert("level", level); + block.insert("text", text); + blocks.append(block); + }; + + for (cmark_node* node = cmark_node_first_child(doc); node; + node = cmark_node_next(node)) { + const cmark_node_type type = cmark_node_get_type(node); + const int startLine = cmark_node_get_start_line(node); + const int endLine = cmark_node_get_end_line(node); + + if (type == CMARK_NODE_CODE_BLOCK) { + const char* literal = cmark_node_get_literal(node); + QString code = literal ? QString::fromUtf8(literal) : QString(); + if (code.endsWith('\n')) code.chop(1); + + QString language; + if (const char* info = cmark_node_get_fence_info(node); info) + language = QString::fromUtf8(info) + .section(' ', 0, 0) + .trimmed() + .toLower(); + + appendCode(language, code); + continue; + } + + if (type == CMARK_NODE_HEADING) { + const char* content = cmark_node_get_string_content(node); + appendHeading( + cmark_node_get_heading_level(node), + content ? QString::fromUtf8(content).trimmed() : QString()); + continue; + } + + if (type == CMARK_NODE_PARAGRAPH) { + const QString text = sliceSource(startLine, endLine); + int cursor = 0; + bool anyMath = false; + for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); + it = mathRe.globalMatch(text, cursor)) { + const QRegularExpressionMatch m = it.next(); + anyMath = true; + appendText(text.mid( + cursor, static_cast(m.capturedStart() - cursor))); + appendMath(m.captured(1).trimmed()); + cursor = static_cast(m.capturedEnd()); + } + if (!anyMath) + appendText(text); + else + appendText(text.mid(cursor)); + continue; + } + + appendText(sliceSource(startLine, endLine)); + } + + cmark_node_free(doc); + return blocks; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp new file mode 100644 index 0000000..433e06b --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +class MarkdownParser { + public: + [[nodiscard]] static QVariantList parse(const QString& source); +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp new file mode 100644 index 0000000..8abcede --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -0,0 +1,226 @@ +#include "mathtext.hpp" + +#include "latinmodern-fonts.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { +constexpr int kRenderMargin = 2; +constexpr unsigned int kResolutionDpi = 96; +constexpr int kCacheLimit = 512; + +struct LatinModern { + bool roman = false; + bool math = false; +}; + +const LatinModern& loadLatinModern() { + static const LatinModern fonts = [] { + LatinModern result; + const auto add = [](const unsigned char* data, + size_t size, + const QString& fileName, + const QString& family) { + const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; + { + QFile f(path); + if (!f.open(QIODevice::WriteOnly) || + f.write( + reinterpret_cast(data), + static_cast(size)) != static_cast(size)) + return false; + } + const int key = QFontDatabase::addApplicationFont(path); + if (key < 0) return false; + return QFontDatabase::applicationFontFamilies(key).contains(family); + }; + result.roman = add(lmfont::lmroman10_regular, + sizeof(lmfont::lmroman10_regular), + QStringLiteral("lmroman10-regular.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_italic, + sizeof(lmfont::lmroman10_italic), + QStringLiteral("lmroman10-italic.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bold, + sizeof(lmfont::lmroman10_bold), + QStringLiteral("lmroman10-bold.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bolditalic, + sizeof(lmfont::lmroman10_bolditalic), + QStringLiteral("lmroman10-bolditalic.otf"), + QStringLiteral("LMRoman10")); + result.math = + add(lmfont::latinmodern_math, + sizeof(lmfont::latinmodern_math), + QStringLiteral("latinmodern-math.otf"), + QStringLiteral("Latin Modern Math")); + return result; + }(); + return fonts; +} + +} // namespace + +LlmMathText::LlmMathText(QObject* parent) + : QObject(parent) + , m_renderer( + std::make_shared(nullptr, /* useFontsForGUI */ true)) { + const LatinModern& fonts = loadLatinModern(); + if (fonts.roman) + m_renderer->setFontRomanAndMath( + QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); + if (fonts.math) { + m_renderer->setFontMathRoman( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFallbackFontSymbols( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + } +} + +void LlmMathText::setLatex(const QString& value) { + if (m_latex == value) return; + m_latex = value; + reRender(); +} + +void LlmMathText::setColor(const QColor& value) { + if (m_color == value) return; + m_color = value; + reRender(); +} + +void LlmMathText::setFontPointSize(double value) { + if (qFuzzyCompare(m_fontPointSize, value)) return; + m_fontPointSize = value; + reRender(); +} + +void LlmMathText::setDevicePixelRatio(qreal value) { + if (qFuzzyCompare(m_devicePixelRatio, value)) return; + m_devicePixelRatio = value; + reRender(); +} + +namespace { + +struct MathRender { + bool ok = false; + QImage image; + QUrl url; + qreal width = 0; + qreal height = 0; +}; + +QHash& mathCache() { + static QHash cache; + return cache; +} + +} // namespace + +void LlmMathText::reRender() { + ++m_requestId; + if (m_latex.trimmed().isEmpty()) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + 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; + } + + if (m_inFlight) return; + m_inFlight = true; + + const QString latex = m_latex; + const QColor color = m_color; + const double pointSize = m_fontPointSize; + const qreal dpr = m_devicePixelRatio; + 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())); + render.width = image.width() / dpr; + render.height = image.height() / dpr; + render.ok = true; + } + } + QPointer 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) { + 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 diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp new file mode 100644 index 0000000..3f97a59 --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace ZShell::llm { + +class LlmMathText : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) + Q_PROPERTY( + double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY + changed) + Q_PROPERTY( + qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio + NOTIFY changed) + Q_PROPERTY(QImage image READ image NOTIFY changed) + Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) + Q_PROPERTY(qreal width READ width NOTIFY changed) + Q_PROPERTY(qreal height READ height NOTIFY changed) + Q_PROPERTY(bool ok READ ok NOTIFY changed) + + public: + explicit LlmMathText(QObject* parent = nullptr); + + [[nodiscard]] QString latex() const { return m_latex; } + void setLatex(const QString& value); + [[nodiscard]] QColor color() const { return m_color; } + void setColor(const QColor& value); + [[nodiscard]] double fontPointSize() const { return m_fontPointSize; } + void setFontPointSize(double value); + [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } + void setDevicePixelRatio(qreal value); + [[nodiscard]] QImage image() const { return m_image; } + [[nodiscard]] QUrl imageUrl() const { return m_imageUrl; } + [[nodiscard]] qreal width() const { return m_width; } + [[nodiscard]] qreal height() const { return m_height; } + [[nodiscard]] bool ok() const { return m_ok; } + + Q_SIGNALS: + void changed(); + + private: + void reRender(); + + std::shared_ptr m_renderer; + QString m_latex; + QColor m_color; + double m_fontPointSize = 12.0; + qreal m_devicePixelRatio = 1.0; + QImage m_image; + QUrl m_imageUrl; + qreal m_width = 0; + qreal m_height = 0; + bool m_ok = false; + int m_requestId = 0; + bool m_inFlight = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp new file mode 100644 index 0000000..070b3cb --- /dev/null +++ b/Plugins/ZShell/Llm/message.cpp @@ -0,0 +1,74 @@ +#include "message.hpp" + +#include "messagemodel.hpp" +#include "session.hpp" + +namespace ZShell::llm { + +namespace { + +ChatSession* sessionOf(const ChatMessage* message) { + if (auto* model = qobject_cast(message->parent())) + return model->session(); + return nullptr; +} + +} // namespace + +ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) + : QObject(parent), m_role(role), m_timestamp(timestamp) {} + +ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { + auto* generation = new ChatGeneration(timestamp, this); + m_generations.append(generation); + if (m_active < 0) m_active = static_cast(m_generations.size() - 1); + Q_EMIT generationsChanged(); + return generation; +} + +ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { + auto* generation = addGeneration(timestamp); + setActiveInternal(static_cast(m_generations.size() - 1)); + return generation; +} + +void ChatMessage::removeGeneration(ChatGeneration* generation) { + const int index = static_cast(m_generations.indexOf(generation)); + if (index < 0) return; + const bool wasActive = index == m_active; + m_generations.removeAt(index); + delete generation; + if (m_generations.isEmpty()) { + m_active = -1; + } else if (m_active >= m_generations.size()) { + m_active = static_cast(m_generations.size() - 1); + } + Q_EMIT generationsChanged(); + if (wasActive) Q_EMIT activeGenerationChanged(); +} + +void ChatMessage::setActiveInternal(int index) { + if (index < 0 || index >= m_generations.size() || index == m_active) return; + m_active = index; + Q_EMIT activeGenerationChanged(); +} + +void ChatMessage::setActiveGeneration(int index) { + setActiveInternal(index); +} + +void ChatMessage::edit(const QString& newContent) { + if (auto* generation = activeGeneration()) + generation->setContent(newContent); + if (auto* session = sessionOf(this)) session->persist(); +} + +void ChatMessage::retry() { + if (auto* session = sessionOf(this)) session->retry(this); +} + +void ChatMessage::generate() { + if (auto* session = sessionOf(this)) session->continueFrom(this); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp new file mode 100644 index 0000000..87310ed --- /dev/null +++ b/Plugins/ZShell/Llm/message.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "generation.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatMessage : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat messages are created by the Chat singleton") + + Q_PROPERTY(Role role READ role CONSTANT) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY( + int generationCount READ generationCount NOTIFY generationsChanged) + Q_PROPERTY( + QList generations READ generations NOTIFY + generationsChanged) + Q_PROPERTY( + ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration + NOTIFY activeGenerationChanged) + Q_PROPERTY( + int activeGenerationIndex READ activeGenerationIndex NOTIFY + activeGenerationChanged) + + public: + enum class Role : int { User = 0, Assistant }; + Q_ENUM(Role) + + explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] Role role() const { return m_role; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] int generationCount() const { + return static_cast(m_generations.size()); + } + [[nodiscard]] QList generations() const { + return m_generations; + } + [[nodiscard]] ChatGeneration* activeGeneration() const { + return generation(m_active); + } + [[nodiscard]] int activeGenerationIndex() const { return m_active; } + [[nodiscard]] ChatGeneration* generation(int index) const { + if (index < 0 || index >= m_generations.size()) return nullptr; + return m_generations.at(index); + } + + Q_INVOKABLE void setActiveGeneration(int index); + Q_INVOKABLE void edit(const QString& newContent); + Q_INVOKABLE void retry(); + Q_INVOKABLE void generate(); + + ChatGeneration* addGeneration(qint64 timestamp); + ChatGeneration* appendGeneration(qint64 timestamp); + void removeGeneration(ChatGeneration* generation); + + Q_SIGNALS: + void generationsChanged(); + void activeGenerationChanged(); + + private: + void setActiveInternal(int index); + + Role m_role; + qint64 m_timestamp; + QList m_generations; + int m_active = -1; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp new file mode 100644 index 0000000..9dd50c8 --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -0,0 +1,113 @@ +#include "messagemodel.hpp" + +#include "session.hpp" + +#include + +namespace ZShell::llm { + +ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent) + : QAbstractListModel(parent), m_session(session) {} + +ChatMessageModel::~ChatMessageModel() = default; + +int ChatMessageModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(m_messages.size()); +} + +QVariant ChatMessageModel::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_messages.size()) + return {}; + return QVariant::fromValue(m_messages.at(index.row())); +} + +QHash ChatMessageModel::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} + +ChatMessage* ChatMessageModel::at(int row) const { + if (row < 0 || row >= m_messages.size()) return nullptr; + return m_messages.at(row); +} + +int ChatMessageModel::rowOf(const ChatMessage* message) const { + for (int i = 0; i < m_messages.size(); ++i) + if (m_messages.at(i) == message) return i; + return -1; +} + +ChatMessage* ChatMessageModel::createMessage( + ChatMessage::Role role, qint64 timestamp) { + return new ChatMessage(role, timestamp, this); +} + +ChatMessage* ChatMessageModel::appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + auto* message = new ChatMessage(role, timestamp, this); + auto* generation = message->addGeneration(timestamp); + generation->setContent(content); + + beginInsertRows(QModelIndex(), 0, 0); + m_messages.prepend(message); + endInsertRows(); + + emit lastMessageChanged(); + + return message; +} + +void ChatMessageModel::removeMessage(ChatMessage* message) { + const int row = rowOf(message); + if (row < 0) return; + + const bool wasLastMessage = row == 0; + + beginRemoveRows(QModelIndex(), row, row); + m_messages.removeAt(row); + endRemoveRows(); + + delete message; + + if (wasLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::removeRange(int firstRow, int lastRow) { + if (firstRow < 0 || firstRow > lastRow || lastRow >= m_messages.size()) + return; + + const bool changesLastMessage = firstRow == 0; + + beginRemoveRows(QModelIndex(), firstRow, lastRow); + for (int row = lastRow; row >= firstRow; --row) + delete m_messages.takeAt(row); + endRemoveRows(); + + if (changesLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::clear() { + if (m_messages.isEmpty()) return; + + beginRemoveRows(QModelIndex(), 0, static_cast(m_messages.size() - 1)); + qDeleteAll(m_messages); + m_messages.clear(); + endRemoveRows(); + + emit lastMessageChanged(); +} + +void ChatMessageModel::loadMessages(QList messages) { + beginResetModel(); + for (ChatMessage* message : m_messages) + if (std::find(messages.begin(), messages.end(), message) == + messages.end()) + delete message; + m_messages = std::move(messages); + endResetModel(); + + emit lastMessageChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.hpp b/Plugins/ZShell/Llm/messagemodel.hpp new file mode 100644 index 0000000..016c5a6 --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "message.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class ChatMessageModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat message models are owned by ChatSession") + + Q_PROPERTY( + ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY + lastMessageChanged) + + public: + explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); + ~ChatMessageModel() override; + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] ChatSession* session() const { return m_session; } + [[nodiscard]] QList messages() const { return m_messages; } + [[nodiscard]] ChatMessage* at(int row) const; + [[nodiscard]] int rowOf(const ChatMessage* message) const; + + [[nodiscard]] ChatMessage* lastMessage() const { + return m_messages.isEmpty() ? nullptr : m_messages.first(); + } + + ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp); + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void removeMessage(ChatMessage* message); + void removeRange(int firstRow, int lastRow); + void clear(); + void loadMessages(QList messages); + + signals: + void lastMessageChanged(); + + private: + ChatSession* m_session; + QList m_messages; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp new file mode 100644 index 0000000..e75087c --- /dev/null +++ b/Plugins/ZShell/Llm/segment.cpp @@ -0,0 +1,137 @@ +#include "segment.hpp" + +#include "markdownparser.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { +constexpr int kMarkdownRefreshMs = 150; +} // namespace + +LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) + : QObject(parent), m_type(type), m_timestamp(timestamp) { + m_markdownTimer.setInterval(kMarkdownRefreshMs); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); +} + +qint64 LlmSegment::elapsedMs() const { + if (m_startedAt <= 0) return 0; + const qint64 end = m_endedAt > 0 ? m_endedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_startedAt; +} + +void LlmSegment::begin() { + if (m_running) return; + m_running = true; + m_startedAt = QDateTime::currentMSecsSinceEpoch(); + m_endedAt = 0; + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); +} + +void LlmSegment::close() { + if (m_running) { + m_running = false; + m_endedAt = QDateTime::currentMSecsSinceEpoch(); + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); + } + if (m_type == Type::Content && m_markdownDirty) { + m_markdownTimer.stop(); + if (parseMarkdown()) m_markdownDirty = false; + } +} + +void LlmSegment::appendText(const QString& piece) { + if (piece.isEmpty()) return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setText(const QString& value) { + if (m_text == value) return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setName(const QString& value) { + if (m_name == value) return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmSegment::setToolCallId(const QString& value) { + if (m_toolCallId == value) return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmSegment::appendArguments(const QString& piece) { + if (piece.isEmpty()) return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmSegment::setResult(const QString& value) { + if (m_result == value) return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmSegment::setStatus(Status value) { + if (m_status == value) return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmSegment::finishTool(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + +void LlmSegment::restore(qint64 elapsedMs) { + m_startedAt = m_timestamp; + m_endedAt = m_timestamp + qMax(0, elapsedMs); +} + +void LlmSegment::scheduleMarkdown() { + if (m_type != Type::Content) return; + m_markdownDirty = true; + if (!m_markdownTimer.isActive()) m_markdownTimer.start(); +} + +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); + QPointer 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(); + if (seg->m_markdownDirty) seg->parseMarkdown(); + }, + Qt::QueuedConnection); + }); + return true; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp new file mode 100644 index 0000000..ce125d1 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class LlmSegment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Segments are managed by ChatGeneration") + + Q_PROPERTY(Type type READ type CONSTANT) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) + Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) + Q_PROPERTY(QString result READ result NOTIFY resultChanged) + Q_PROPERTY(Status status READ status NOTIFY statusChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) + + public: + enum class Type : int { Reasoning = 0, ToolCall, Content }; + Q_ENUM(Type) + + enum class Status : int { None = 0, Running, Success, Error }; + Q_ENUM(Status) + + explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QString text() const { return m_text; } + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString toolCallId() const { return m_toolCallId; } + [[nodiscard]] QString arguments() const { return m_arguments; } + [[nodiscard]] QString result() const { return m_result; } + [[nodiscard]] Status status() const { return m_status; } + [[nodiscard]] bool running() const { return m_running; } + [[nodiscard]] qint64 elapsedMs() const; + [[nodiscard]] QVariantList markdown() const { return m_markdown; } + + void begin(); + void close(); + void appendText(const QString& piece); + void setText(const QString& value); + void setName(const QString& value); + void setToolCallId(const QString& value); + void appendArguments(const QString& piece); + void setResult(const QString& value); + void setStatus(Status value); + void finishTool(const QString& resultText, bool success); + void restore(qint64 elapsedMs); + + Q_SIGNALS: + void textChanged(); + void markdownChanged(); + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + void runningChanged(); + void elapsedMsChanged(); + + private: + void scheduleMarkdown(); + bool parseMarkdown(); + + Type m_type; + qint64 m_timestamp; + QString m_text; + QString m_name; + QString m_toolCallId; + QString m_arguments; + QString m_result; + Status m_status = Status::None; + bool m_running = false; + qint64 m_startedAt = 0; + qint64 m_endedAt = 0; + QVariantList m_markdown; + QTimer m_markdownTimer; + bool m_markdownDirty = false; + bool m_parseInFlight = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp new file mode 100644 index 0000000..2f083ba --- /dev/null +++ b/Plugins/ZShell/Llm/session.cpp @@ -0,0 +1,241 @@ +#include "session.hpp" + +#include "chatstore.hpp" +#include "llmclient.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +QString titleFrom(const QString& content) { + const QString flat = content.simplified(); + if (flat.isEmpty()) return QString(); + if (flat.size() <= 48) return flat; + return flat.left(47) + QStringLiteral("…"); +} + +} // namespace + +ChatSession::ChatSession(const QString& id, QObject* parent) + : QObject(parent), m_id(id) { + m_model = new ChatMessageModel(this, this); + connect( + m_model, + &QAbstractListModel::rowsInserted, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::rowsRemoved, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::modelReset, + this, + &ChatSession::onModelRowsChanged); +} + +void ChatSession::onModelRowsChanged() { + setCount(m_model->rowCount()); +} + +void ChatSession::setTitle(const QString& value) { + if (m_title == value) return; + m_title = value; + Q_EMIT titleChanged(); + persist(); +} + +void ChatSession::setIcon(const QString& value) { + if (m_icon == value) return; + m_icon = value; + Q_EMIT iconChanged(); +} + +void ChatSession::setUpdatedAt(qint64 value) { + if (m_updatedAt == value) return; + m_updatedAt = value; + Q_EMIT updatedAtChanged(); +} + +void ChatSession::setPinned(bool value) { + if (m_pinned == value) return; + m_pinned = value; + Q_EMIT pinnedChanged(); +} + +void ChatSession::setCount(int value) { + if (m_messageCount == value) return; + m_messageCount = value; + Q_EMIT messageCountChanged(); +} + +void ChatSession::setMeta( + const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { + m_title = title; + m_createdAt = createdAt; + m_updatedAt = updatedAt; + m_messageCount = messageCount; +} + +void ChatSession::setLastTokenCount(int value) { + m_lastTokenCount = value; +} + +LlmClient* ChatSession::client() const { + if (auto* store = qobject_cast(parent())) + return store->llmClient(); + return nullptr; +} + +void ChatSession::ensureLoaded() { + if (m_loadRequested) return; + m_loadRequested = true; + if (auto* store = qobject_cast(parent())) + store->loadMessagesInto(this); +} + +ChatMessageModel* ChatSession::messagesModel() { + ensureLoaded(); + 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 messages) { + m_model->loadMessages(std::move(messages)); +} + +void ChatSession::persist() { + if (auto* store = qobject_cast(parent())) store->persist(this); +} + +ChatMessage* ChatSession::appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + return m_model->appendNewest(role, content, timestamp); +} + +void ChatSession::removeMessage(ChatMessage* message) { + m_model->removeMessage(message); +} + +void ChatSession::clearMessages() { + m_model->clear(); +} + +void ChatSession::startGeneration(ChatMessage* target) { + auto* generation = target->activeGeneration(); + auto* clientObject = client(); + if (generation && clientObject) { + if (isLoaded()) { + clientObject->startGeneration(this, generation); + } else { + connect( + this, + &ChatSession::loaded, + clientObject, + [this, generation, clientObject]() { + clientObject->startGeneration(this, generation); + }); + } + } + persist(); +} + +void ChatSession::sendMessage(const QString& text) { + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + appendNewest(ChatMessage::Role::User, trimmed, now); + while (m_model->rowCount() > 200) + m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + if (m_title.isEmpty()) { + m_title = titleFrom(trimmed); + Q_EMIT titleChanged(); + qInfo() << "ChatSession:" << m_id << "new conversation," + << "fallback title" << m_title + << "- requesting generated title and icon"; + if (auto* clientObject = client()) { + clientObject->requestTitle(this, trimmed); + clientObject->requestIcon(this, trimmed); + } + } + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); +} + +void ChatSession::retry(ChatMessage* target) { + if (!target || target->role() != ChatMessage::Role::Assistant) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(target); + if (row < 0) return; + + m_model->removeRange(0, row - 1); + if (m_model->rowCount() < 2) return; + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + target->appendGeneration(now); + startGeneration(target); +} + +void ChatSession::continueFrom(ChatMessage* message) { + if (!message) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(message); + if (row < 0) return; + + m_model->removeRange(0, row - 1); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (message->role() == ChatMessage::Role::Assistant) { + if (m_model->rowCount() < 2) return; + message->appendGeneration(now); + startGeneration(message); + } else { + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); + } +} + +void ChatSession::clear() { + if (auto* clientObject = client()) { + if (clientObject->busy() && clientObject->streamingSession() == this) { + clientObject->clearOnFinish(this); + clientObject->stop(); + return; + } + } + if (!isLoaded()) { + m_clearPending = true; + return; + } + m_model->clear(); + persist(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp new file mode 100644 index 0000000..a024322 --- /dev/null +++ b/Plugins/ZShell/Llm/session.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include "messagemodel.hpp" +#include "message.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class LlmClient; + +class ChatSession : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are managed by Chat.chats") + + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) + Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) + Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) + Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) + Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY( + ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) + + public: + explicit ChatSession(const QString& id, QObject* parent = nullptr); + + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString title() const { return m_title; } + [[nodiscard]] QString icon() const { return m_icon; } + [[nodiscard]] QDateTime createdAt() const { + return QDateTime::fromMSecsSinceEpoch(m_createdAt); + } + [[nodiscard]] QDateTime updatedAt() const { + return QDateTime::fromMSecsSinceEpoch(m_updatedAt); + } + [[nodiscard]] qint64 createdAtMs() const { return m_createdAt; } + [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } + [[nodiscard]] bool pinned() const { return m_pinned; } + [[nodiscard]] int messageCount() const { return m_messageCount; } + [[nodiscard]] ChatMessageModel* messagesModel(); + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + [[nodiscard]] ChatMessageModel* model() const { return m_model; } + void markLoaded(); + [[nodiscard]] bool takeClearPending(); + + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + void setLastTokenCount(int value); + + void setTitle(const QString& value); + void setIcon(const QString& value); + void setUpdatedAt(qint64 value); + void setPinned(bool value); + + void setMeta( + const QString& title, + qint64 createdAt, + qint64 updatedAt, + int messageCount); + + void adoptMessages(QList messages); + void persist(); + void removeMessage(ChatMessage* message); + void clearMessages(); + + Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); + Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); + Q_INVOKABLE void clear(); + + Q_SIGNALS: + void titleChanged(); + void iconChanged(); + void updatedAtChanged(); + void pinnedChanged(); + void messageCountChanged(); + void loaded(); + + private: + void onModelRowsChanged(); + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void startGeneration(ChatMessage* target); + void setCount(int value); + + QString m_id; + QString m_title; + QString m_icon; + qint64 m_createdAt = 0; + qint64 m_updatedAt = 0; + 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; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp new file mode 100644 index 0000000..05ddb35 --- /dev/null +++ b/Plugins/ZShell/Llm/tool.cpp @@ -0,0 +1,55 @@ +#include "tool.hpp" + +namespace ZShell::llm { + +LlmTool::LlmTool(QObject* parent) : QObject(parent) {} + +LlmTool::~LlmTool() = default; + +void LlmTool::cancel() {} + +QJsonObject LlmTool::specification() const { + QJsonObject function; + function[QStringLiteral("name")] = name(); + function[QStringLiteral("description")] = description(); + function[QStringLiteral("parameters")] = parameters(); + QJsonObject spec; + spec[QStringLiteral("type")] = QStringLiteral("function"); + spec[QStringLiteral("function")] = function; + return spec; +} + +ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} + +void ToolRegistry::setEnabled(bool value) { + if (m_enabled == value) return; + m_enabled = value; + Q_EMIT enabledChanged(); +} + +void ToolRegistry::registerTool(LlmTool* tool) { + if (!tool || m_tools.contains(tool)) return; + tool->setParent(this); + m_tools.append(tool); +} + +LlmTool* ToolRegistry::tool(const QString& name) const { + for (const auto* tool : m_tools) + if (tool->name() == name) return const_cast(tool); + return nullptr; +} + +QJsonArray ToolRegistry::specifications() const { + if (!m_enabled) return {}; + QJsonArray specs; + for (const auto* tool : m_tools) + specs.append(tool->specification()); + return specs; +} + +void ToolRegistry::cancelAll() { + for (auto* tool : m_tools) + tool->cancel(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp new file mode 100644 index 0000000..f8e12df --- /dev/null +++ b/Plugins/ZShell/Llm/tool.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +class LlmTool : public QObject { + Q_OBJECT + + public: + explicit LlmTool(QObject* parent = nullptr); + ~LlmTool() override; + + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QString description() const = 0; + [[nodiscard]] virtual QJsonObject parameters() const = 0; + + virtual void execute( + const QJsonObject& args, + std::function done) = 0; + virtual void cancel(); + + [[nodiscard]] QJsonObject specification() const; +}; + +class ToolRegistry : public QObject { + Q_OBJECT + + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) + + public: + explicit ToolRegistry(QObject* parent = nullptr); + + [[nodiscard]] bool enabled() const { return m_enabled; } + void setEnabled(bool value); + + void registerTool(LlmTool* tool); + [[nodiscard]] LlmTool* tool(const QString& name) const; + [[nodiscard]] QJsonArray specifications() const; + void cancelAll(); + + Q_SIGNALS: + void enabledChanged(); + + private: + bool m_enabled = true; + QList m_tools; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp new file mode 100644 index 0000000..ff86b8f --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -0,0 +1,406 @@ +#include "webfetchtool.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +const char* kUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; + +QJsonObject makeOutput(const QString& text) { + QJsonObject obj; + obj[QStringLiteral("output")] = text; + return obj; +} + +QJsonObject makeError(const QString& message) { + QJsonObject obj; + obj[QStringLiteral("error")] = message; + return obj; +} + +} // namespace + +WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} + +WebFetchTool::~WebFetchTool() { + for (auto* job : m_jobs) { + if (job->timer) job->timer->stop(); + if (job->reply) job->reply->abort(); + } + qDeleteAll(m_jobs); +} + +QString WebFetchTool::name() const { + return QStringLiteral("webfetch"); +} + +QString WebFetchTool::description() const { + return QStringLiteral( + "Fetch content from an HTTP or HTTPS URL and return it as plain " + "text or raw HTML. HTML pages are reduced to their visible text " + "by default. This tool is read-only."); +} + +QJsonObject WebFetchTool::parameters() const { + QJsonObject url; + url[QStringLiteral("type")] = QStringLiteral("string"); + url[QStringLiteral("description")] = + QStringLiteral("The HTTP or HTTPS URL to fetch content from"); + + QJsonArray formats; + formats.append(QStringLiteral("text")); + formats.append(QStringLiteral("html")); + QJsonObject format; + format[QStringLiteral("type")] = QStringLiteral("string"); + format[QStringLiteral("enum")] = formats; + format[QStringLiteral("description")] = QStringLiteral( + "The format to return the content in. Defaults to " + "text."); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Optional timeout in seconds"); + + QJsonObject properties; + properties[QStringLiteral("url")] = url; + properties[QStringLiteral("format")] = format; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("url")); + schema[QStringLiteral("required")] = required; + return schema; +} + +void WebFetchTool::completeJob(Job* job, QJsonObject result) { + QMetaObject::invokeMethod( + this, + [this, job, result = std::move(result)]() mutable { + if (!m_jobs.contains(job)) return; + auto done = std::move(job->done); + m_jobs.removeAll(job); + job->timer->deleteLater(); + if (job->reply) job->reply->deleteLater(); + delete job; + done(std::move(result)); + }, + Qt::QueuedConnection); +} + +void WebFetchTool::execute( + const QJsonObject& args, std::function done) { + auto* job = new Job; + job->done = std::move(done); + m_jobs.append(job); + + auto fail = [this, job](const QString& message) { + job->timer->stop(); + completeJob(job, makeError(message)); + }; + + const QString urlText = args[QStringLiteral("url")].toString().trimmed(); + const QUrl url = QUrl::fromUserInput(urlText); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid URL: %1").arg(urlText)); + return; + } + if (url.scheme() != QLatin1String("http") && + url.scheme() != QLatin1String("https")) { + fail(QStringLiteral("URL must use http:// or https://")); + return; + } + + job->format = + args[QStringLiteral("format")].toString(QStringLiteral("text")); + if (job->format != QLatin1String("html")) + job->format = QStringLiteral("text"); + + const int timeoutMs = + qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds) * + 1000; + + job->timer = new QTimer(this); + job->timer->setSingleShot(true); + connect(job->timer, &QTimer::timeout, this, [this, job]() { + if (job->reply) job->reply->abort(); + }); + job->timer->start(timeoutMs); + + QNetworkRequest request(url); + request.setRawHeader("User-Agent", QByteArray(kUserAgent)); + request.setRawHeader( + "Accept", + job->format == QLatin1String("html") + ? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" + : "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, " + "*/*;q=0.1"); + request.setRawHeader("Accept-Language", "en-US,en;q=0.9"); + + job->reply = m_manager.get(request); + connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { + if (!job->reply) return; + job->body += job->reply->readAll(); + if (job->body.size() > MaxResponseBytes) { + job->tooLarge = true; + job->reply->abort(); + } + }); + connect(job->reply, &QNetworkReply::finished, this, [this, job]() { + QNetworkReply* reply = job->reply; + job->reply = nullptr; + job->timer->stop(); + if (!reply) return; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + QByteArray body = job->body; + body += reply->readAll(); + job->body.clear(); + const QByteArray contentType = + reply->rawHeader("Content-Type").toLower(); + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (job->tooLarge) { + completeJob( + job, + makeError(QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit"))); + return; + } + if (error != QNetworkReply::NoError) { + completeJob( + job, + makeError( + QStringLiteral("Request failed: %1").arg(errorString))); + return; + } + if (status < 200 || status >= 300) { + completeJob( + job, + makeError( + QStringLiteral("Server returned status %1").arg(status))); + return; + } + + const QString mime = QString::fromLatin1(contentType) + .section(QLatin1Char(';'), 0, 0) + .trimmed(); + if (mime.startsWith(QLatin1String("image/"))) { + completeJob( + job, + makeError( + QStringLiteral("Unsupported fetched image content type: %1") + .arg(mime))); + return; + } + const bool textual = + mime.isEmpty() || mime.startsWith(QLatin1String("text/")) || + mime == QLatin1String("application/json") || + mime.endsWith(QLatin1String("+json")) || + mime == QLatin1String("application/xml") || + mime.endsWith(QLatin1String("+xml")) || + mime.startsWith(QLatin1String("application/javascript")) || + mime.startsWith(QLatin1String("text/javascript")); + if (!textual) { + completeJob( + job, + makeError( + QStringLiteral("Unsupported fetched file content type: %1") + .arg(mime))); + return; + } + + QString content = QString::fromUtf8(body); + if (mime.contains(QLatin1String("text/html")) && + job->format == QLatin1String("text")) + content = extractTextFromHtml(content); + if (content.size() > MaxOutputChars) + content = content.left(MaxOutputChars) + + QStringLiteral("\n[... truncated ...]"); + completeJob(job, makeOutput(content)); + }); +} + +void WebFetchTool::cancel() { + for (auto* job : m_jobs) { + job->timer->stop(); + if (job->reply) job->reply->abort(); + } +} + +QString WebFetchTool::decodeEntities(const QString& text) { + if (!text.contains(QLatin1Char('&'))) return text; + QString out; + out.reserve(text.size()); + for (qsizetype i = 0; i < text.size(); ++i) { + if (text.at(i) != QLatin1Char('&')) { + out += text.at(i); + continue; + } + const qsizetype semi = text.indexOf(QLatin1Char(';'), i); + if (semi < 0 || semi - i > 12) { + out += QLatin1Char('&'); + continue; + } + const QString entity = text.mid(i + 1, semi - i - 1); + QString replacement; + if (entity == QLatin1String("amp")) + replacement = QLatin1Char('&'); + else if (entity == QLatin1String("lt")) + replacement = QLatin1Char('<'); + else if (entity == QLatin1String("gt")) + replacement = QLatin1Char('>'); + else if (entity == QLatin1String("quot")) + replacement = QLatin1Char('"'); + else if (entity == QLatin1String("apos")) + replacement = QLatin1Char('\''); + else if (entity == QLatin1String("nbsp")) + replacement = QLatin1Char(' '); + else { + bool ok = false; + const quint32 codePoint = + entity.startsWith(QLatin1String("#x")) || + entity.startsWith(QLatin1String("#X")) + ? entity.mid(2).toUInt(&ok, 16) + : entity.toUInt(&ok); + if (ok && codePoint != 0) { + const char32_t ucs4[2] = { + static_cast(codePoint), + 0, + }; + replacement = QString::fromUcs4(ucs4); + } + } + if (replacement.isEmpty()) { + out += QLatin1Char('&'); + continue; + } + out += replacement; + i = semi; + } + return out; +} + +QString WebFetchTool::extractTextFromHtml(const QString& html) { + static const QSet kSkipTags = { + QStringLiteral("noscript"), + QStringLiteral("iframe"), + QStringLiteral("object"), + QStringLiteral("head"), + }; + static const QSet kRawTags = { + QStringLiteral("script"), + QStringLiteral("style"), + }; + static const QSet kVoidTags = { + QStringLiteral("area"), + QStringLiteral("base"), + QStringLiteral("br"), + QStringLiteral("col"), + QStringLiteral("embed"), + QStringLiteral("hr"), + QStringLiteral("img"), + QStringLiteral("input"), + QStringLiteral("link"), + QStringLiteral("meta"), + QStringLiteral("source"), + QStringLiteral("track"), + QStringLiteral("wbr"), + }; + + QString text; + text.reserve(html.size() / 2); + int skipDepth = 0; + qsizetype i = 0; + while (i < html.size()) { + const qsizetype open = html.indexOf(QLatin1Char('<'), i); + if (open < 0) { + if (skipDepth == 0) text += html.mid(i); + break; + } + if (skipDepth == 0) text += html.mid(i, open - i); + const qsizetype close = html.indexOf(QLatin1Char('>'), open); + if (close < 0) break; + const QString tag = + html.mid(open + 1, close - open - 1).trimmed().toLower(); + i = close + 1; + + if (tag.startsWith(QLatin1Char('!')) || + tag.startsWith(QLatin1Char('?'))) + continue; + + QString name = tag; + if (name.startsWith(QLatin1Char('/'))) { + if (skipDepth > 0) --skipDepth; + continue; + } + qsizetype j = 0; + while (j < name.size() && (name.at(j).isLetterOrNumber() || + name.at(j) == QLatin1Char(':') || + name.at(j) == QLatin1Char('-'))) + ++j; + name = name.left(j); + + if (kRawTags.contains(name)) { + const qsizetype rawEnd = html.indexOf( + QStringLiteral("'), rawEnd); + if (rawClose < 0) break; + i = rawClose + 1; + continue; + } + if (kVoidTags.contains(name)) continue; + if (skipDepth > 0) { + if (name == QLatin1String("body")) { + skipDepth = 0; + continue; + } + ++skipDepth; + continue; + } + if (kSkipTags.contains(name)) { + ++skipDepth; + continue; + } + text += QLatin1Char(' '); + } + + QString out = decodeEntities(text); + QStringList lines; + for (const QString& line : out.split(QLatin1Char('\n'))) { + const QString flat = line.simplified(); + if (flat.isEmpty()) { + if (!lines.isEmpty() && lines.last().isEmpty()) continue; + lines.append(QString()); + } else { + lines.append(flat); + } + } + while (lines.size() > 1 && lines.first().isEmpty()) + lines.removeFirst(); + while (lines.size() > 1 && lines.last().isEmpty()) + lines.removeLast(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp new file mode 100644 index 0000000..b7bfd6b --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +#include + +class QNetworkReply; +class QTimer; + +namespace ZShell::llm { + +class WebFetchTool : public LlmTool { + Q_OBJECT + + public: + static constexpr int MaxResponseBytes = 5 * 1024 * 1024; + static constexpr int DefaultTimeoutSeconds = 30; + static constexpr int MaxTimeoutSeconds = 120; + static constexpr int MaxOutputChars = 64 * 1024; + + explicit WebFetchTool(QObject* parent = nullptr); + ~WebFetchTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QJsonObject& args, + std::function done) override; + void cancel() override; + + static QString extractTextFromHtml(const QString& html); + static QString decodeEntities(const QString& text); + + private: + struct Job { + QNetworkReply* reply = nullptr; + QTimer* timer = nullptr; + QByteArray body; + bool tooLarge = false; + QString format; + std::function done; + }; + + void completeJob(Job* job, QJsonObject result); + + QNetworkAccessManager m_manager; + QList m_jobs; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 7d1d2ff..2dfe707 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -9,7 +9,7 @@ qml_module(ZShell-services cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp - hyprsunsetmanager.hpp hyprsunsetmanager.cpp + nightlightmanager.hpp nightlightmanager.cpp tickingservice.hpp tickingservice.cpp sensorslib.hpp sensorslib.cpp usagefmt.hpp usagefmt.cpp @@ -25,4 +25,4 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors -) + ) diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp deleted file mode 100644 index 0b33f6f..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "hyprsunsetmanager.hpp" -#include -#include -#include -#include - -namespace ZShell::services { - -HyprsunsetManager::HyprsunsetManager(QObject* parent) : QObject(parent) { - connect(&m_timer, &QTimer::timeout, this, &HyprsunsetManager::apply); - connect(&m_manualTimer, &QTimer::timeout, this, [this] { - m_manualToggle = false; - emit manualToggleChanged(); - apply(); - }); - connect(&m_startCooldown, &QTimer::timeout, this, [this] { - m_startAllowed = true; - apply(); - }); - - m_startCooldown.start(2000); - m_manualTimer.setSingleShot(true); - m_timer.start(60000); - - m_process.setStandardInputFile(QProcess::nullDevice()); - m_process.setStandardOutputFile(QProcess::nullDevice()); -} - -int HyprsunsetManager::startTime() const { - return m_startTime; -} - -int HyprsunsetManager::endTime() const { - return m_endTime; -} - -bool HyprsunsetManager::manualToggle() const { - return m_manualToggle; -} - -bool HyprsunsetManager::enabled() const { - return m_enabled; -} - -bool HyprsunsetManager::activeAuto() const { - return m_activeAuto; -} - -int HyprsunsetManager::temp() const { - return m_temp; -} - -void HyprsunsetManager::setActiveAuto(bool activeAuto) { - if (activeAuto == m_activeAuto) return; - - m_activeAuto = activeAuto; - emit activeAutoChanged(); -} - -void HyprsunsetManager::setManualToggle(bool toggle) { - if (toggle == m_manualToggle) return; - - m_manualToggle = toggle; - emit manualToggleChanged(); - - m_manualTimer.start(60 * 60 * 1000); -} - -void HyprsunsetManager::setEndTime(const int& time) { - if (time == m_endTime) return; - - m_endTime = time; - emit endTimeChanged(); - apply(); -} - -void HyprsunsetManager::setStartTime(const int& time) { - if (time == m_startTime) return; - - m_startTime = time; - emit startTimeChanged(); - apply(); -} - -void HyprsunsetManager::setTemp(const int& temp) { - if (temp == m_temp) return; - - m_temp = temp; - emit tempChanged(); - apply(); -} - -void HyprsunsetManager::toggle() { - if (m_enabled) { - end(); - } else { - start(); - } -} - -void HyprsunsetManager::start() { - if (m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = true; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments( - {"hyprsunset", "temperature", QString::number(m_temp)}); - m_process.startDetached(); -} - -void HyprsunsetManager::end() { - if (!m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = false; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments({"hyprsunset", "identity"}); - m_process.startDetached(); -} - -void HyprsunsetManager::apply() { - if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - - const auto current = QTime::currentTime(); - const auto currentMin = current.hour() * 60 + current.minute(); - bool isDarkTime = false; - - if (m_startTime <= m_endTime) { - isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); - } else { - isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); - } - - if (isDarkTime) { - start(); - } else { - end(); - } -} - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.hpp b/Plugins/ZShell/Services/hyprsunsetmanager.hpp deleted file mode 100644 index 7241c0d..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace ZShell::services { - -class HyprsunsetManager : public QObject { - Q_OBJECT - QML_ELEMENT - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) - Q_PROPERTY( - int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) - Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) - Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) - Q_PROPERTY( - bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY - activeAutoChanged) - Q_PROPERTY( - bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY - manualToggleChanged) - - public: - explicit HyprsunsetManager(QObject* parent = nullptr); - - [[nodiscard]] int startTime() const; - [[nodiscard]] int endTime() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] int temp() const; - [[nodiscard]] bool activeAuto() const; - [[nodiscard]] bool manualToggle() const; - - Q_INVOKABLE void toggle(); - Q_INVOKABLE void apply(); - - void setStartTime(const int& time); - void setEndTime(const int& time); - void setTemp(const int& temp); - void setActiveAuto(bool activeAuto); - void setManualToggle(bool toggle); - - signals: - void enabledChanged(); - void startTimeChanged(); - void activeAutoChanged(); - void endTimeChanged(); - void tempChanged(); - void manualToggleChanged(); - - private: - int m_startTime; - int m_endTime; - bool m_enabled = false; - bool m_manualToggle = false; - bool m_activeAuto; - bool m_startAllowed = false; - bool m_initialized = false; - QTimer m_startCooldown; - int m_temp; - QProcess m_process; - QTimer m_timer; - QTimer m_manualTimer; - void start(); - void end(); -}; - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp new file mode 100644 index 0000000..ea50b1d --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -0,0 +1,442 @@ +#include "nightlightmanager.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace { +constexpr const char* NL_EVENT = "zshell-nightlight"; +constexpr int RECONNECT_MS = 2000; +} // namespace + +NightlightManager::NightlightManager(QObject* parent) : QObject(parent) { + connect(&m_timer, &QTimer::timeout, this, &NightlightManager::apply); + connect(&m_manualTimer, &QTimer::timeout, this, [this] { + m_manualToggle = false; + emit manualToggleChanged(); + apply(); + }); + connect(&m_startCooldown, &QTimer::timeout, this, [this] { + m_startAllowed = true; + apply(); + }); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this] { + if (!m_useNativeNightlight) return; + + connectEventSocket(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + bootstrapState(); + }); + + m_startCooldown.start(2000); + m_manualTimer.setSingleShot(true); + m_reconnectTimer.setSingleShot(true); + m_timer.start(60000); + + m_process.setStandardInputFile(QProcess::nullDevice()); + m_process.setStandardOutputFile(QProcess::nullDevice()); +} + +int NightlightManager::startTime() const { + return m_startTime; +} + +int NightlightManager::endTime() const { + return m_endTime; +} + +bool NightlightManager::manualToggle() const { + return m_manualToggle; +} + +bool NightlightManager::enabled() const { + return m_enabled; +} + +bool NightlightManager::activeAuto() const { + return m_activeAuto; +} + +int NightlightManager::temp() const { + return m_temp; +} + +double NightlightManager::fadeDuration() const { + return m_fadeDuration; +} + +bool NightlightManager::animating() const { + return m_animating; +} + +int NightlightManager::lastTemp() const { + return m_lastTemp; +} + +int NightlightManager::currentTemp() const { + return m_currentTemp; +} + +void NightlightManager::setActiveAuto(bool activeAuto) { + if (activeAuto == m_activeAuto) return; + + m_activeAuto = activeAuto; + emit activeAutoChanged(); +} + +void NightlightManager::setManualToggle(bool toggle) { + if (toggle == m_manualToggle) return; + + m_manualToggle = toggle; + emit manualToggleChanged(); + + m_manualTimer.start(60 * 60 * 1000); +} + +void NightlightManager::setEndTime(const int& time) { + if (time == m_endTime) return; + + m_endTime = time; + emit endTimeChanged(); + apply(); +} + +void NightlightManager::setStartTime(const int& time) { + if (time == m_startTime) return; + + m_startTime = time; + emit startTimeChanged(); + apply(); +} + +void NightlightManager::setTemp(const int& temp) { + if (temp == m_temp) return; + + m_temp = temp; + emit tempChanged(); + apply(); +} + +void NightlightManager::setFadeDuration(double duration) { + if (duration < 0.0) duration = 0.0; + + m_fadeDuration = duration; + emit fadeDurationChanged(); +} + +void NightlightManager::toggle() { + if (m_enabled) { + end(); + } else { + start(); + } +} + +void NightlightManager::start() { + if (m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = true; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlSet(%1, %2)") + .arg(m_temp) + .arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"hyprsunset", "temperature", QString::number(m_temp)}); + m_process.startDetached(); + } +} + +void NightlightManager::end() { + if (!m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = false; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlDisable(%1)").arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments({"hyprsunset", "identity"}); + m_process.startDetached(); + } +} + +void NightlightManager::apply() { + if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; + + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime = false; + + if (m_startTime <= m_endTime) { + isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); + } else { + isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); + } + + if (isDarkTime) { + start(); + } else { + end(); + } +} + +bool NightlightManager::useNativeNightlight() const { + return m_useNativeNightlight; +} + +QString NightlightManager::pluginPath() const { + return m_pluginPath; +} + +void NightlightManager::setPluginPath(const QString& path) { + if (path == m_pluginPath) return; + + m_pluginPath = path; + emit pluginPathChanged(); +} + +void NightlightManager::setUseNativeNightlight(bool native) { + if (native == m_useNativeNightlight) return; + + m_useNativeNightlight = native; + emit useNativeNightlightChanged(); + + if (native) { + disconnect(&m_eventSocket, nullptr, this, nullptr); + connect( + &m_eventSocket, + &QLocalSocket::readyRead, + this, + &NightlightManager::handleEventLines); + connect( + &m_eventSocket, + &QLocalSocket::errorOccurred, + this, + &NightlightManager::scheduleReconnect); + connectEventSocket(); + bootstrapState(); + } else { + m_reconnectTimer.stop(); + disconnect(&m_eventSocket, nullptr, this, nullptr); + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) + m_eventSocket.disconnectFromServer(); + m_socketBuffer.clear(); + m_animating = false; + emit animatingChanged(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + } +} + +QString NightlightManager::hyprlandDir() const { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) return QString(); + + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + auto dir = runtimeDir + "/hypr/" + his; + if (!QFileInfo(dir).isDir()) dir = "/tmp/hypr/" + his; + + if (!QFileInfo(dir).isDir()) return QString(); + + return dir; +} + +QString NightlightManager::eventSocketPath() const { + const auto dir = hyprlandDir(); + return dir.isEmpty() ? QString() : dir + "/.socket2.sock"; +} + +QString NightlightManager::resolvePluginPath() const { + if (!m_pluginPath.isEmpty()) return m_pluginPath; + + const QString name = QStringLiteral("libzshell-nightlight.so"); + const QStringList dirs = { + "/usr/lib/ZShell/plugins", + "/usr/local/lib/ZShell/plugins", + }; + for (const auto& dir : dirs) { + const QString candidate = dir + "/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString home = qEnvironmentVariable("HOME"); + if (!home.isEmpty()) { + const QString candidate = home + "/.local/lib/ZShell/plugins/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + return QString(); +} + +void NightlightManager::tryLoadPlugin() { + if (m_pluginLoadAttempted) return; + + const auto path = resolvePluginPath(); + if (path.isEmpty()) { + qWarning() << "NightlightManager: plugin not found; searched " + "pluginPath and /usr/lib/ZShell/plugins"; + m_pluginLoadAttempted = true; + return; + } + + m_pluginLoadAttempted = true; + m_pluginLoadedByUs = true; + qInfo() << "NightlightManager: plugin not loaded, loading" << path; + QProcess::startDetached("hyprctl", {"plugin", "load", path}); + + QTimer::singleShot(1500, this, [this] { bootstrapState(); }); +} + +void NightlightManager::bootstrapState() { + if (!m_useNativeNightlight) return; + + const auto dir = hyprlandDir(); + if (dir.isEmpty()) return; + + auto* sock = new QLocalSocket(this); + auto* watchdog = new QTimer(this); + watchdog->setSingleShot(true); + + const auto finish = [sock, watchdog]() { + watchdog->stop(); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QLocalSocket::connected, sock, [sock] { + sock->write("repl return hl.plugin.zshell.nlState()"); + sock->flush(); + }); + connect(sock, &QLocalSocket::readyRead, sock, [this, sock, finish]() { + const auto raw = sock->readAll(); + const auto doc = QJsonDocument::fromJson(raw); + finish(); + if (!doc.isObject()) { + if (m_pluginLoadAttempted) + qWarning() << "NightlightManager: plugin load failed, state " + << "query still answered:" << raw; + + tryLoadPlugin(); + return; + } + + if (m_pluginLoadedByUs) { + qInfo() << "NightlightManager: plugin was not loaded, manager " + << "loaded it; state:" << raw; + m_pluginLoadedByUs = false; + } else { + qInfo() << "NightlightManager: plugin already loaded, state:" + << raw; + } + + const auto obj = doc.object(); + updateState( + obj.value("enabled").toBool(), + obj.value("animating").toBool(), + obj.value("last").toInt(), + obj.value("current").toInt()); + }); + connect(sock, &QLocalSocket::errorOccurred, sock, finish); + connect(watchdog, &QTimer::timeout, sock, finish); + watchdog->start(2500); + + sock->connectToServer(dir + "/.socket.sock"); +} + +void NightlightManager::connectEventSocket() { + if (!m_useNativeNightlight) return; + + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) return; + + const auto path = eventSocketPath(); + if (path.isEmpty()) { + scheduleReconnect(); + return; + } + + m_socketBuffer.clear(); + m_eventSocket.connectToServer(path, QLocalSocket::ReadOnly); +} + +void NightlightManager::scheduleReconnect() { + if (!m_useNativeNightlight) return; + + m_reconnectTimer.start(RECONNECT_MS); +} + +void NightlightManager::handleEventLines() { + m_socketBuffer.append(m_eventSocket.readAll()); + + qsizetype idx = 0; + while ((idx = m_socketBuffer.indexOf('\n')) >= 0) { + const auto line = m_socketBuffer.left(idx); + m_socketBuffer.remove(0, idx + 1); + handleEventLine(line); + } +} + +void NightlightManager::handleEventLine(const QByteArray& line) { + const qsizetype sep = line.indexOf(">>"); + if (sep <= 0) return; + + if (line.left(sep) != NL_EVENT) return; + + const auto fields = line.mid(sep + 2).split(','); + if (fields.size() < 4) return; + + bool ok = false; + const auto enabled = fields.at(0).toInt(&ok); + if (!ok) return; + const auto animating = fields.at(1).toInt(&ok); + if (!ok) return; + const auto last = fields.at(2).toInt(&ok); + if (!ok) return; + const auto current = fields.at(3).toInt(&ok); + if (!ok) return; + + updateState(enabled == 1, animating == 1, last, current); +} + +void NightlightManager::updateState( + bool enabled, bool animating, int last, int current) { + if (enabled != m_enabled) { + m_enabled = enabled; + emit enabledChanged(); + } + if (animating != m_animating) { + m_animating = animating; + emit animatingChanged(); + } + if (last != m_lastTemp) { + m_lastTemp = last; + emit lastTempChanged(); + } + if (current != m_currentTemp) { + m_currentTemp = current; + emit currentTempChanged(); + } +} + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp new file mode 100644 index 0000000..43b341d --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class NightlightManager : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY( + int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) + Q_PROPERTY( + double fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) + Q_PROPERTY( + bool useNativeNightlight READ useNativeNightlight WRITE + setUseNativeNightlight NOTIFY useNativeNightlightChanged) + Q_PROPERTY( + QString pluginPath READ pluginPath WRITE setPluginPath NOTIFY + pluginPathChanged) + Q_PROPERTY( + bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY + activeAutoChanged) + Q_PROPERTY( + bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY + manualToggleChanged) + Q_PROPERTY(bool animating READ animating NOTIFY animatingChanged) + Q_PROPERTY(int lastTemp READ lastTemp NOTIFY lastTempChanged) + Q_PROPERTY(int currentTemp READ currentTemp NOTIFY currentTempChanged) + + public: + explicit NightlightManager(QObject* parent = nullptr); + + [[nodiscard]] int startTime() const; + [[nodiscard]] int endTime() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] int temp() const; + [[nodiscard]] double fadeDuration() const; + [[nodiscard]] bool useNativeNightlight() const; + [[nodiscard]] QString pluginPath() const; + [[nodiscard]] bool activeAuto() const; + [[nodiscard]] bool manualToggle() const; + [[nodiscard]] bool animating() const; + [[nodiscard]] int lastTemp() const; + [[nodiscard]] int currentTemp() const; + + Q_INVOKABLE void toggle(); + Q_INVOKABLE void apply(); + + void setStartTime(const int& time); + void setEndTime(const int& time); + void setTemp(const int& temp); + void setFadeDuration(double duration); + void setUseNativeNightlight(bool native); + void setPluginPath(const QString& path); + void setActiveAuto(bool activeAuto); + void setManualToggle(bool toggle); + + signals: + void enabledChanged(); + void startTimeChanged(); + void activeAutoChanged(); + void endTimeChanged(); + void tempChanged(); + void fadeDurationChanged(); + void useNativeNightlightChanged(); + void pluginPathChanged(); + void manualToggleChanged(); + void animatingChanged(); + void lastTempChanged(); + void currentTempChanged(); + + private: + int m_startTime; + int m_endTime; + bool m_enabled = false; + bool m_manualToggle = false; + bool m_activeAuto; + bool m_startAllowed = false; + bool m_initialized = false; + QTimer m_startCooldown; + int m_temp; + double m_fadeDuration = 0.4; + bool m_useNativeNightlight = false; + QString m_pluginPath; + bool m_pluginLoadAttempted = false; + bool m_pluginLoadedByUs = false; + bool m_animating = false; + int m_lastTemp = 6500; + int m_currentTemp = 6500; + QProcess m_process; + QTimer m_timer; + QTimer m_manualTimer; + QLocalSocket m_eventSocket; + QByteArray m_socketBuffer; + QTimer m_reconnectTimer; + + void start(); + void end(); + [[nodiscard]] QString hyprlandDir() const; + [[nodiscard]] QString eventSocketPath() const; + [[nodiscard]] QString resolvePluginPath() const; + void tryLoadPlugin(); + void bootstrapState(); + void connectEventSocket(); + void scheduleReconnect(); + void handleEventLines(); + void handleEventLine(const QByteArray& line); + void updateState(bool enabled, bool animating, int last, int current); +}; + +}; // namespace ZShell::services diff --git a/Services/Colors.qml b/Services/Colors.qml index 863c03f..970281d 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,7 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/ci/Dockerfile b/ci/Dockerfile index e080260..d1b3965 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,6 +1,6 @@ FROM archlinux:latest -RUN pacman -Syu --noconfirm +RUN pacman -Syyu --noconfirm # Build toolchain RUN pacman -S --noconfirm \ @@ -10,6 +10,9 @@ RUN pacman -S --noconfirm \ clang \ lld \ nodejs \ + tree-sitter \ + jkqtplotter \ + cmark-gfm \ git # Qt6 diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index ed60e8b..f0c8e72 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -94,9 +94,8 @@ def list_presets( if v.accents: entry["accents"] = sorted(v.accents) entry["default_accent"] = sorted(v.accents)[0] - variants[v.id] = entry - out[meta.name] = { - "id": sid, + variants[v.id.lower()] = entry + out[sid.lower()] = { "variants": variants, } print(json.dumps({"presets": out}, indent=2)) @@ -626,8 +625,8 @@ def generate( scheme_type = config["colors"].get("schemeType", "fruit-salad") scheme = scheme or scheme_type assert isinstance(scheme, str) - config_mode = config["general"]["color"]["mode"] - smart = bool(config["general"]["color"].get("smart", False)) + config_mode = config["colors"]["mode"] + smart = bool(config["colors"].get("smart", False)) scheme_class = get_scheme_class(scheme) p_variant = "default" @@ -670,7 +669,7 @@ def generate( name = palette_obj.scheme flavor = palette_obj.variant - display_name = schemes[p_scheme].name + display_name = schemes[p_scheme].id config["colors"]["presets"] = { "name": display_name, "variant": p_variant, diff --git a/scripts/codeicons.py b/scripts/codeicons.py new file mode 100644 index 0000000..e2a183f --- /dev/null +++ b/scripts/codeicons.py @@ -0,0 +1,502 @@ +#!/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 + +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", +} + +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", +} + + +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 "" + + +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])) + 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": + p = [] + for _ in range(6): + value, i = _read_number(data, i) + p.append(value) + commands.append((implicit, p)) + elif implicit in "SsQq": + 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) + 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) + + 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") + + return " ".join(paths) + + +def qml_string(value: str) -> str: + 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 + + +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 { +""" + ) + + 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") + + 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 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 return {identifier}\n' + ) + + file.write( + """ default: + return "" + } + } + + function name(language) { + switch (language.toLowerCase()) { +""" + ) + + 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 return "{name}"\n') + + for alias, icon in ALIASES.items(): + if icon not in names: + continue + + file.write( + f' case "{alias}":\n return "{names[icon]}"\n' + ) + + file.write( + """ default: + return language + } + } +} +""" + ) + + print(f"Generated {output}") + + +if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) + sys.exit(1) + +generate(Path(sys.argv[1]))