diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..ddd9476 --- /dev/null +++ b/.clangd @@ -0,0 +1,5 @@ +CompileFlags: + Remove: + # GCC-only flags from the system's default spec that clang/clangd cannot + # parse. They appear in compile_commands.json but are not set by the project. + - -mno-direct-extern-access diff --git a/.gitignore b/.gitignore index de6958c..c6ff3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ dist/ network-dev/ **/zshell.build/ **/zshell.dist/ +.opencode/ +run-agent.sh +AGENTS.md diff --git a/.prettierignore b/.prettierignore index b1e8d68..3d59707 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ .venv/ scripts/fzf.js scripts/fuzzysort.js +scripts/settings-indexer.js 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/CMakeLists.txt b/CMakeLists.txt index fc9f50a..b091ec4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,10 +61,12 @@ set(CMAKE_INSTALL_MESSAGE NEVER) add_compile_options( -Wall -Wextra -Wpedantic -Wshadow -Wconversion - -Wold-style-cast -Wnull-dereference -Wdouble-promotion - -Wformat=2 -Wfloat-equal -Woverloaded-virtual + -Wnull-dereference -Wdouble-promotion + -Wformat=2 -Wfloat-equal -Wsign-conversion -Wredundant-decls -Wswitch -Wunreachable-code + $<$:-Wold-style-cast> + $<$:-Woverloaded-virtual> ) @@ -188,4 +190,11 @@ if("m3shapes" IN_LIST ENABLE_MODULES) if(TARGET m3shapesplugin) set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN") endif() + + # Silence warnings that come from m3shapes files we do not manage. + foreach(m3shapes_target m3shapes_core m3shapes_morph m3shapes_shapes m3shapes m3shapesplugin) + if(TARGET ${m3shapes_target}) + target_compile_options(${m3shapes_target} PRIVATE -Wno-shadow -Wno-float-equal) + endif() + endforeach() endif() diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 1c9a97d..cf28beb 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -20,7 +20,7 @@ BusyIndicator { property int animState property color bgColor: Colors.palette.m3secondaryContainer property color fgColor: Colors.palette.m3primary - property real implicitSize: Tokens.font.size.normal * 3 + property real implicitSize: Tokens.font.body.medium.pointSize * 3 property real internalStrokeWidth: strokeWidth readonly property alias progress: manager.progress property real strokeWidth: Tokens.padding.extraSmall * 0.8 diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index a1267f2..1373d1e 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -28,6 +28,9 @@ Flickable { interval: 10 running: root.doneFakeFlick - onTriggered: root.doneFakeFlick = false + onTriggered: { + root.doneFakeFlick = false; + root.returnToBounds(); + } } } diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 2b02e96..97936d3 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/CustomRect.qml b/Components/CustomRect.qml index 2f5da64..b163d99 100644 --- a/Components/CustomRect.qml +++ b/Components/CustomRect.qml @@ -6,7 +6,6 @@ Rectangle { color: "transparent" Behavior on color { - CAnim { - } + CAnim {} } } diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index f889cac..d6690d2 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,33 +1,41 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Templates import qs.Services 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 ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (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 - property bool shouldBeActive + readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop) readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - enabled: !ShellState.forActive().isDrawing - implicitHeight: Tokens.padding.extraSmall * 2 - implicitWidth: Tokens.padding.extraSmall * 2 + property bool shouldBeActive + property bool inverted - contentItem: Item { - } + enabled: !ShellState.forActive().isDrawing + + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 + + contentItem: Item {} Behavior on position { enabled: !fullMouse.pressed - Anim { - } + Anim {} } onHoveredChanged: { @@ -48,54 +56,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 } } @@ -112,25 +93,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 + (root.reversed ? root.flickable.contentWidth : 0)); + else + root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0)); } - 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 + (root.inverted ? -delta : delta))); + root.position = newPos; + } + anchors.fill: parent cursorShape: undefined hoverEnabled: true @@ -141,24 +137,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/GeminiGlow.qml b/Components/GeminiGlow.qml new file mode 100644 index 0000000..443ef07 --- /dev/null +++ b/Components/GeminiGlow.qml @@ -0,0 +1,42 @@ +import QtQuick +import Quickshell + +ShaderEffect { + id: root + + property Item target: parent + + property real glowSize: 40 + property real innerGlow: 0 + property real lineWidth: 2 + property real intensity: 1.0 + property real speed: 1.0 + property int period: 6000 + property bool running: true + + property color color1: "#4285F4" + property color color2: "#9B72CB" + property color color3: "#D96570" + property color color4: "#F2A54A" + + readonly property size itemSize: Qt.size(width, height) + readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) + readonly property real radius: (target as Rectangle).radius ?? 0 + property real phase: 0 + + anchors.fill: target + anchors.margins: -glowSize + z: -1 + + enabled: false + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) + + FrameAnimation { + running: root.running && root.visible + onTriggered: { + const dt = frameTime * 1000; + root.phase = (root.phase + dt * root.speed / root.period) % 1.0; + } + } +} 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/ScreenState.qml b/Components/ScreenState.qml index 9885c51..5fb3dc6 100644 --- a/Components/ScreenState.qml +++ b/Components/ScreenState.qml @@ -19,11 +19,11 @@ PersistentProperties { property bool sidebar // Dashboard state - property int dashboardTab + property int dashboardTab: 0 property date dashboardDate: new Date() // Sidebar state - property int sidebarTab + property int sidebarTab: 0 property list expandedNotifs: [] // Utilities state diff --git a/Components/TextAreaBase.qml b/Components/TextAreaBase.qml new file mode 100644 index 0000000..5408a2d --- /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: Tokens.font.body.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..e0931f3 --- /dev/null +++ b/Components/TextEditBase.qml @@ -0,0 +1,96 @@ +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: Tokens.font.body.small + 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..6fe91b6 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -7,7 +7,9 @@ CustomListView { id: root property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + property bool fadeEnabled: true property real fadeAmount: 0.1 + property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 @@ -16,7 +18,9 @@ CustomListView { } function fadeShouldBeActive(isStart: bool): bool { - // When content is smaller than flickable size, hide fade when rebound starts. + if (!fadeEnabled) + return false; + if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) { return false; } @@ -28,11 +32,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/Windows.qml b/Drawers/Windows.qml index 30888ee..97f5b7b 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -63,12 +63,10 @@ CustomWindow { name: "Bar" Behavior on fsTransitionProg { - Anim { - } + Anim {} } Behavior on surfaceColor { - CAnim { - } + CAnim {} } contentItem.Keys.onEscapePressed: { @@ -288,8 +286,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/Helpers/Picker.qml b/Helpers/Picker.qml index 5c3a08d..9839ff9 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -123,33 +123,29 @@ Item { focus: true opacity: 0 - Behavior on opacity { - Anim { - duration: 300 - } - } Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - Anim { - } + Anim {} } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - Anim { - } + Anim {} } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - Anim { - } + Anim {} } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" + Anim {} + } + Behavior on opacity { Anim { + duration: 300 } } @@ -393,8 +389,7 @@ Item { y: selectionRect.y - root.realBorderWidth Behavior on border.color { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 3f9f3e3..e47f198 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -63,8 +63,7 @@ WidgetBase { visible: root.horizontal Behavior on color { - CAnim { - } + CAnim {} } } @@ -88,8 +87,7 @@ WidgetBase { text: Qt.formatDateTime(Time.date, modelData) Behavior on color { - CAnim { - } + CAnim {} } } } @@ -126,8 +124,7 @@ WidgetBase { } Behavior on color { - CAnim { - } + CAnim {} } } } @@ -140,8 +137,7 @@ WidgetBase { visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 Behavior on color { - CAnim { - } + CAnim {} } } } diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 717edf6..5ff38f6 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -25,6 +25,8 @@ CustomListView { return [0]; case "variant": return SchemeVariants.query(text); + case "files": + return Files.query(text); default: return Apps.search(text); } @@ -33,7 +35,7 @@ CustomListView { function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { - for (const action of ["calc", "scheme", "variant"]) + for (const action of ["calc", "scheme", "variant", "files"]) if (text.startsWith(`${prefix}${action} `)) return action; @@ -163,6 +165,13 @@ CustomListView { PropertyChanges { root.delegate: variantItem } + }, + State { + name: "files" + + PropertyChanges { + root.delegate: filesItem + } } ] transitions: Transition { @@ -264,6 +273,14 @@ CustomListView { } } + Component { + id: filesItem + + FilesItem { + list: root + } + } + Connections { function onTextChanged() { root.syncDisplayText(); diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 4bfaf49..4db1508 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -85,6 +85,8 @@ Item { } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); + else if (text.startsWith(`${Config.launcher.actionPrefix}files `)) + currentItem.onClicked(); else currentItem.modelData.onClicked(list.currentList); } else { diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index 3f8218a..30f0004 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -16,7 +16,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 6303b0f..c93a8e5 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -19,7 +19,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { Apps.launch(root.modelData); diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index e526b94..c52d71a 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -24,7 +24,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.onClicked(); @@ -97,8 +97,7 @@ Item { text: qsTr("Open in calculator") Behavior on opacity { - Anim { - } + Anim {} } } diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index 25caa4f..18aedce 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -15,7 +15,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Services/Files.qml b/Modules/Launcher/Services/Files.qml new file mode 100644 index 0000000..aebbbb0 --- /dev/null +++ b/Modules/Launcher/Services/Files.qml @@ -0,0 +1,194 @@ +// qs/Services/Files.qml +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import ZShell.Config + +Singleton { + id: root + + property list results: [] + + property string _pendingQuery: "" + property int _generation: 0 + + readonly property var _imageExts: ({ + "png": true, + "jpg": true, + "jpeg": true, + "gif": true, + "bmp": true, + "webp": true, + "svg": true, + "tiff": true, + "tif": true, + "ico": true, + "heic": true, + "avif": true + }) + + // freedesktop mimetype-icon names; extend as needed + readonly property var _extIcons: ({ + // images + "png": "image-x-generic", + "jpg": "image-x-generic", + "jpeg": "image-x-generic", + "gif": "image-x-generic", + "bmp": "image-x-generic", + "webp": "image-x-generic", + "svg": "image-x-generic", + "tiff": "image-x-generic", + "tif": "image-x-generic", + "ico": "image-x-generic", + "heic": "image-x-generic", + "avif": "image-x-generic", + // documents + "pdf": "application-pdf", + "doc": "x-office-document", + "docx": "x-office-document", + "odt": "x-office-document", + "txt": "text-x-generic", + "md": "text-x-generic", + "xls": "x-office-spreadsheet", + "xlsx": "x-office-spreadsheet", + "ods": "x-office-spreadsheet", + "ppt": "x-office-presentation", + "pptx": "x-office-presentation", + "odp": "x-office-presentation", + // archives + "zip": "package-x-generic", + "tar": "package-x-generic", + "gz": "package-x-generic", + "xz": "package-x-generic", + "7z": "package-x-generic", + "rar": "package-x-generic", + // audio/video + "mp3": "audio-x-generic", + "flac": "audio-x-generic", + "wav": "audio-x-generic", + "ogg": "audio-x-generic", + "mp4": "video-x-generic", + "mkv": "video-x-generic", + "webm": "video-x-generic", + "avi": "video-x-generic", + "mov": "video-x-generic", + // code + "js": "text-x-script", + "ts": "text-x-script", + "py": "text-x-script", + "sh": "text-x-script", + "qml": "text-x-script", + "cpp": "text-x-c++src", + "c": "text-x-csrc", + "h": "text-x-chdr", + "json": "application-json", + "html": "text-html", + "css": "text-css" + }) + + function showInFolder(path: string): void { + const explorer = Config.general.apps.explorer; + let args = []; + + if (explorer.includes("dolphin")) + args = [...explorer, "--select", path]; + else + args = [...explorer, path]; + + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...args]); + else + Quickshell.execDetached([...args]); + } + + function launch(command: list): void { + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--"].concat(command)); + else + Quickshell.execDetached(command); + } + + function transformSearch(search: string): string { + return search.slice(Config.launcher.actionPrefix.length + "files ".length); + } + + function query(search: string): list { + search = transformSearch(search); + if (search !== _pendingQuery) { + _pendingQuery = search; + debounceTimer.restart(); + } + return results; + } + + function _extOf(name: string): string { + const i = name.lastIndexOf("."); + // no dot, or dotfile with no real extension (".bashrc" -> treat as no ext) + if (i <= 0) + return ""; + return name.slice(i + 1).toLowerCase(); + } + + function _iconFor(name: string, isDir: bool): string { + if (isDir) + return "folder"; + const ext = _extOf(name); + return _extIcons[ext] ?? "text-x-generic"; + } + + function _buildEntry(path: string): var { + const name = path.split("/").pop(); + const ext = _extOf(name); + // trailing slash from plocate's directory output (if -d/dir results ever appear) + const isDir = path.endsWith("/"); + + return { + path: path, + name: name, + icon: _iconFor(name, isDir), + isImage: !isDir && (_imageExts[ext] ?? false) + }; + } + + Timer { + id: debounceTimer + interval: 120 + onTriggered: root._runQuery(root._pendingQuery) + } + + function _runQuery(search: string): void { + _generation++; + + if (!search) { + proc.running = false; + results = []; + return; + } + + if (proc.running) + proc.running = false; + + proc.buffer = []; + proc.myGeneration = _generation; + proc.command = ["plocate", "-i", "-b", "--limit", "50", search]; + proc.running = true; + } + + Process { + id: proc + + property var buffer: [] + property int myGeneration: 0 + + stdout: SplitParser { + onRead: line => proc.buffer.push(root._buildEntry(line)) + } + + onRunningChanged: { + if (!running && myGeneration === root._generation) + root.results = buffer; + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml new file mode 100644 index 0000000..599dee5 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -0,0 +1,581 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +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 + // 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 + + readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4 + + property var pendingAttachments: [] + + function attachFiles(): void { + Files.pickFiles().then(files => { + if (!files || files.length === 0) + return; + const added = chatData.addAttachments(files); + root.pendingAttachments = root.pendingAttachments.concat(added); + }); + } + + function send(text: string): void { + if (text.trim() === "" && root.pendingAttachments.length === 0) + return; + root.scrollToLatest(); + chatData.sendMessage(text, root.pendingAttachments); + input.text = ""; + root.pendingAttachments = []; + } + + function scrollToLatest(): void { + if (!listLoader.item || root.atNewestEnd) + return; + scrollAnim.stop(); + scrollAnim.start(); + } + + 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(); + } + } + + Anim { + id: scrollAnim + + target: listLoader.item + property: "contentY" + to: 0 + type: Anim.DefaultEffects + + onFinished: if (target) + target.contentY = 0 + } + + 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: Tokens.font.title.medium + text: qsTr(root.chatData?.title || ChatState.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: Flickable { + id: flick + + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: list.layoutHeight + + transform: Rotation { + origin.y: flick.height / 2 + origin.x: flick.width / 2 + angle: 180 + } + + WheelInverter { + target: flick + } + + Anim { + id: restoreAnim + + target: flick + type: Anim.DefaultEffects + property: "contentY" + } + + ChatListView { + id: list + + width: flick.width + height: flick.height + yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects + + asynchronous: true + cacheBuffer: 50 + estimatedHeight: 100 + readyDelay: 5 + removeDuration: Tokens.anim.durations.normal + spacing: 0 + + model: root.chatData.messagesModel + + useCustomViewport: true + viewport: { + tWatcher.transform; + return Qt.rect(0, flick.contentY, width, flick.height); + } + onViewportAdjustNeeded: delta => { + restoreAnim.stop(); + flick.contentY += delta; + } + onViewportRestoreRequested: target => { + restoreAnim.to = target; + restoreAnim.start(); + } + + delegate: MessageDelegate { + id: message + + rootParent: flick + chatList: list + ChatListView.preferredHeight: implicitHeight + ChatListView.trackViewport: true + ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight + opacity: ChatListView.removing || ChatListView.adding ? 0 : 1 + transform: Rotation { + origin.y: message.height / 2 + origin.x: message.width / 2 + angle: 180 + } + + Behavior on y { + enabled: message.ChatListView.ready + + Anim { + id: yAnim + type: Anim.DefaultEffects + } + } + + Behavior on x { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + + CustomScrollBar.vertical: CustomScrollBar { + id: scrollBar + + flickable: flick + parent: flick.parent + anchors.top: flick.top + anchors.right: flick.right + anchors.bottom: flick.bottom + inverted: true + + transform: Rotation { + origin.y: flick.height / 2 + origin.x: scrollBar.width / 2 + angle: 180 + + axis { + y: 0 + x: 1 + z: 0 + } + } + } + + TransformWatcher { + id: tWatcher + + a: flick.contentItem + b: 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: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1 + visible: scale > 0 + + Behavior on scale { + Anim {} + } + + Elevation { + anchors.fill: parent + level: 2 + radius: scrollToBottom.radius + } + + IconButton { + id: scrollToBottom + + anchors.centerIn: parent + font: Tokens.font.icon.large + icon: "arrow_downward" + isRound: true + padding: Tokens.padding.extraSmall + type: IconButton.Tonal + + onClicked: { + root.scrollToLatest(); + } + } + } + + GridView { + id: attachments + + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: input.top + anchors.bottomMargin: Tokens.spacing.medium + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + add: Transition { + id: addTrans + Anim { + property: "y" + from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 0 + to: 1 + } + } + remove: Transition { + id: removeTrans + Anim { + property: "y" + to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 1 + to: 0 + } + } + + model: ScriptModel { + objectProp: "path" + values: root.pendingAttachments + } + + delegate: Item { + id: thumb + + required property var modelData + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + fontStyle: Tokens.font.icon.large + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + + MouseArea { + id: hover + + anchors.fill: parent + hoverEnabled: true + cursorShape: undefined + + IconButton { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.medium + + icon: "close" + visible: opacity > 0 + enabled: thumb.opacity > 0 + opacity: hover.containsMouse ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData) + } + } + } + } + } + + ChatInput { + id: input + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + fastGlow: LlmClient.busy + glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow) + font: Tokens.font.body.small + implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + focus: true + placeholderText: qsTr("Send a message") + sendIcon.font: Tokens.font.icon.medium + sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward" + sendIcon.padding: Tokens.padding.extraSmall + sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5 + sendIcon.enabled: text || LlmClient.busy + + Component.onCompleted: { + root.focusInput(); + } + Keys.onPressed: e => { + if (e.key == Qt.Key_Return) { + if (!(e.modifiers & Qt.ShiftModifier)) { + root.send(text); + e.accepted = true; + } + } + + if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) { + if (root.chatData.clipboardHasAttachment()) { + const att = root.chatData.pasteAttachments(); + root.pendingAttachments = [...root.pendingAttachments, ...att]; + e.accepted = true; + } + } + } + onButtonPressed: { + if (LlmClient.busy) + LlmClient.stop(); + else + 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..3480d08 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -0,0 +1,389 @@ +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 + property bool slim: 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; + + if (slim) + return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2; + + 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 + fontStyle: Tokens.font.icon.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; + } + + opacity: root.slim ? 0 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + 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: Tokens.font.body.medium + 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.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: Tokens.font.label.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: Tokens.font.label.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: Tokens.font.label.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: Tokens.font.body.small + maximumLineCount: 1 + opacity: !root.expanded && !root.slim ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.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?.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: Tokens.font.icon.medium + 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: Tokens.font.icon.medium + 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) + + opacity: root.slim ? 0 : 1 + visible: opacity > 0 + enabled: visible + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + 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..ceb4e77 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -0,0 +1,142 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextAreaBase { + id: root + + readonly property alias bg: bg + readonly property alias sendIcon: sendIcon + readonly property alias glow: glow + + property bool fastGlow: false + + signal buttonPressed + + bottomPadding: Tokens.padding.large + leftPadding: Tokens.padding.extraLarge + rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small + topPadding: Tokens.padding.large + wrapMode: TextAreaBase.Wrap + + onFastGlowChanged: { + if (fastGlow) { + resetAnim.stop(); + boostAnim.restart(); + } else { + boostAnim.stop(); + resetAnim.restart(); + } + } + + background: CustomRect { + id: bg + + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.extraLarge + + GeminiGlow { + id: glow + glowSize: 32 + z: 5 + + opacity: root.activeFocus ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + SequentialAnimation { + id: boostAnim + + NumberAnimation { + target: glow + property: "speed" + to: 4.0 + duration: 250 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: glow + property: "speed" + to: 2.25 + duration: 1500 + easing.type: Easing.InOutQuad + } + } + + // Ease back down to normal once fastGlow is false + NumberAnimation { + id: resetAnim + target: glow + property: "speed" + to: 1.0 + duration: 800 + easing.type: Easing.InOutQuad + } + } + + 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.buttonPressed() + } + + 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..adf488c --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -0,0 +1,306 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat.Content +import qs.Services +import qs.Components + +Item { + id: root + + property alias list: list + property alias model: list.model + property bool highlight: false + property bool slim: false + + signal requestExpand + signal deleteChatRequest(content: ChatSession) + signal loadChatRequest(content: ChatSession, index: int) + signal newChatRequest + + onSlimChanged: { + if (slim) + modelsContainer.open = false; + } + + CustomText { + id: chatHeader + + 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: chatHeader.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 + slim: root.slim + + 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; + } + } + } + + ModelsContainer { + id: modelsContainer + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: fabRoot.left + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.spacing.small + + rootParent: root + fabRoot: fabRoot + + opacity: root.slim ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + 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: root.slim ? "left_panel_open" : "add" + isRound: ChatState.fabExpanded + + label.transform: Rotation { + origin.y: fabRoot.label.height / 2 + origin.x: fabRoot.label.width / 2 + angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0 + + Behavior on angle { + Anim {} + } + } + + onClicked: { + if (root.slim && !ChatState.narrowSidebarExpanded) { + root.requestExpand(); + return; + } + + 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 + fontStyle: Tokens.font.icon.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..b7a5959 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -0,0 +1,83 @@ +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.firstOpen) { + ChatState.openDraft(); + stack.push(chatList); + stack.push(chatContent); + ChatState.firstOpen = false; + return; + } + + if (ChatState.inChat) { + stack.push(chatList); + stack.push(chatContent); + } else + stack.push(chatList); + } + + function openChat(): void { + if (stack.depth < 2) + stack.push(chatContent); + } + + StackView { + id: stack + anchors.fill: parent + } + + Component { + id: chatList + + ChatList { + model: LlmClient.chats + + onDeleteChatRequest: chat => { + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + if (wasCurrent) + ChatState.openDraft(); + } + onLoadChatRequest: (chat, index) => { + ChatState.chatSession = chat; + openChat(); + ChatState.inChat = true; + } + onNewChatRequest: { + ChatState.openDraft(); + openChat(); + ChatState.inChat = true; + } + } + } + + Component { + id: chatContent + + ChatContent { + chatData: ChatState.chatSession + + onRequestClose: { + stack.pop(); + ChatState.inChat = false; + ChatState.openDraft(); + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml new file mode 100644 index 0000000..5536f1d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -0,0 +1,33 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Llm +import qs.Services + +Singleton { + id: root + + property ChatSession chatSession: LlmClient.chats.draft() + readonly property string title: chatSession?.title || "Chat" + property int currentIdx: -1 + property bool firstOpen: true + property bool inChat: false + property bool fabExpanded: false + property bool isWindow: false + property bool isWide: false + property bool settledSlim: false + property bool settledWide: false + property bool narrowSidebarExpanded: false + property bool windowIsWide: false + property ShellScreen screen + property bool sidebarVisible: ShellState.forActive().sidebar + + function openDraft(): void { + root.chatSession = LlmClient.chats.draft(); + root.currentIdx = -1; + } + + 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..1795ff3 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -0,0 +1,118 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property TextEdit edit + required property bool hovered + required property bool isUser + required property ChatMessage message + required property bool running + + 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 + isRound: true + + 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: Tokens.font.body.small + type: IconButton.Text + } + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1 + icon: "chevron_right" + type: IconButton.Tonal + isRound: true + + 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.running && 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 + isRound: true + + 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 + isRound: true + + Behavior on scale { + Anim {} + } + + onClicked: Quickshell.clipboardText = root.message.content + } + + IconButton { + id: editButton + + icon: root.edit.readOnly ? "edit" : "check" + inactiveColor: Colors.palette.m3tertiary + inactiveOnColor: Colors.palette.m3onTertiary + visible: root.isUser + isRound: true + + onClicked: { + root.edit.readOnly = !root.edit.readOnly; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml new file mode 100644 index 0000000..c40a051 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -0,0 +1,400 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property ChatGeneration current + + implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 + + CustomRect { + id: approvalBg + + anchors.fill: parent + radius: Tokens.rounding.medium + color: Colors.palette.m3surface + border.width: 1 + border.color: Colors.palette.m3outlineVariant + + ColumnLayout { + id: approvalLayout + anchors.fill: parent + anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small + + CustomText { + id: requestHeader + text: qsTr("Tool call request") + font: Tokens.font.headline.medium + color: Colors.palette.m3onSurface + } + + Item { + id: toolsBg + + property bool open: false + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 + + implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + z: open || dialogTransition.running ? 2 : 0 + Layout.fillWidth: true + + function reparentWrapper(): void { + const newParent = open ? root.rootParent : toolsBg; + const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); + toolsBg.collapsedY = toolsWrapper.y; + toolsWrapper.parent = newParent; + toolsWrapper.x = c.x - toolsWrapper.width / 2; + toolsWrapper.y = c.y - toolsWrapper.height / 2; + } + + onOpenChanged: { + if (open) + root.rootParent.interactive = false; + else + root.rootParent.interactive = true; + } + + BlobGroup { + id: blobGroup + + color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: toolsBg.open ? root.rootParent : toolsBg + + onClicked: toolsBg.open = false + } + + Item { + id: toolsWrapper + + width: toolsBg.width + height: toolBox.implicitHeight + rotation: parent === root.rootParent ? 180 : 0 + z: 1 + + states: State { + name: "open" + when: toolsBg.open + + PropertyChanges { + backdrop.enabled: true + toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased + toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased + toolsContent.opacity: 1 + toolsWrapper.height: toolsBg.openHeight + toolsWrapper.width: toolsBg.openWidth + toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2 + toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium + elevation.opacity: 1 + toolBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: toolsBg.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: toolsBackground.bottomLeftRadius + bottomRightRadius: toolsBackground.bottomRightRadius + level: 4 + opacity: 0 + radius: toolsBackground.radius + + transform: Matrix4x4 { + matrix: toolsBackground.deformMatrix + } + } + + BlobRect { + id: toolsBackground + + anchors.fill: parent + bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + CustomRect { + id: toolBox + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(implicitHeight, parent.height) + implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 + radius: Tokens.rounding.small + + StateLayer { + onClicked: { + toolsBg.open = true; + } + } + + ColumnLayout { + id: toolList + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.medium + + Repeater { + id: repeater + + model: root.current.pendingToolCalls?.slice(0, 4) ?? [] + + delegate: RowLayout { + id: toolText + + required property var modelData + required property int index + + spacing: Tokens.spacing.medium + + function toolTarget(seg: var): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" + fill: 1 + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.modelData.name) + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: "arrow_right_alt" + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) + } + } + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length) + visible: repeater.count < root.current.pendingToolCalls.length + } + } + } + + Loader { + id: toolsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2 + + VerticalFadeListView { + id: innerLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + cacheBuffer: height * 10 + fadeAmount: 0.1 + fadeEnabled: interactive + spacing: 0 + clip: true + interactive: contentHeight > toolsBg.maxHeight + + model: root.current.pendingToolCalls ?? [] + + delegate: Item { + id: toolCall + + required property var modelData + property bool expanded: false + + implicitWidth: CustomListView.view.width + implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins) + + function toolTarget(seg: var): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: collapsedRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + IconButton { + id: toolExpandBtn + + font: Tokens.font.icon.medium + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + rotation: toolCall.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: toolCall.expanded = !toolCall.expanded + } + + CustomText { + id: preview + + Layout.fillWidth: true + + text: toolCall.modelData.name + } + } + + CustomClippingRect { + id: expandedTool + + anchors.top: collapsedRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + + implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + + CustomText { + id: fullToolCall + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + + text: toolCall.toolTarget(toolCall.modelData) + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + } + } + } + } + } + } + } + } + + ButtonRow { + id: confirmationRow + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.medium + + IconTextButton { + icon: "check_circle" + text: qsTr("Allow") + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + isRound: true + shapeMorph: true + onClicked: root.current.approveTools() + } + + IconTextButton { + icon: "cancel" + text: qsTr("Deny") + isRound: true + shapeMorph: true + inactiveColor: Colors.palette.m3error + inactiveOnColor: Colors.palette.m3onError + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + onClicked: root.current.denyTools() + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml new file mode 100644 index 0000000..a3666f3 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -0,0 +1,27 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextEditBase { + id: root + + color: Colors.palette.m3onSurface + readOnly: true + textFormat: Text.MarkdownText + font: Tokens.font.body.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onLinkActivated: link => { + Qt.openUrlExternally(link); + } + + MouseArea { + 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..3ee1c15 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -0,0 +1,147 @@ +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 noAnim: false + property bool loading: false + property int lastIdx: -1 + + readonly property Component conversationComp: ChatContent {} + + signal requestClose + + 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.top = container.top; + incubator.object.anchors.bottom = container.bottom; + incubator.object.anchors.margins = Tokens.padding.medium; + incubator.object.anchors.horizontalCenter = container.horizontalCenter; + incubator.object.requestClose.connect(root.requestClose); + incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width)); + currentItem = incubator.object; + root.loading = false; + if (!noAnim) + 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.icon.extraLarge.pointSize * 4 + opacity: root.loading ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + Connections { + function onChatSessionChanged(): void { + exitAnim.complete(); + enterAnim.complete(); + + if (root.noAnim) { + root.loadConversation(ChatState.chatSession); + return; + } + + 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..993c5e8 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -0,0 +1,296 @@ +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 string html: "" + property bool colored: false + property string requestedCode: "" + property string requestedLanguage: "" + property int highlightToken: 0 + + function refresh() { + requestedCode = root.code; + requestedLanguage = root.language; + const token = ++root.highlightToken; + CodeHighlighter.highlight(root.code, root.language, root, token); + } + + function onHighlightSpans(token, spans) { + if (token !== root.highlightToken) + return; + if (root.code !== root.requestedCode || root.language !== root.requestedLanguage) + return; + html = spans.length ? root.highlightedHtml(root.code, spans) : ""; + colored = root.visible && html.length > 0; + } + + 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, ">"); + } + + // Mirrors the whitespace rules of the rich text rendering below (leading + // whitespace kept, mid-line runs collapsed, tabs as single spaces) so the + // plain and colored layouts are identical. + function plainForDisplay(code): string { + return code.split("\n").map(line => { + const i = line.search(/\S/); + if (i < 0) + return ""; + const lead = line.slice(0, i).replace(/\t/g, " "); + return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, ""); + }).join("\n"); + } + + 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: { + html = ""; + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onCodeChanged: { + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onVisibleChanged: if (root.visible) + refreshTimer.restart() + + Timer { + id: refreshTimer + + interval: 200 + repeat: false + onTriggered: 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: Tokens.font.label.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 + + 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 + clip: false + font: Tokens.font.mono.small + textFormat: root.colored ? Text.RichText : Text.PlainText + text: root.colored ? root.html : root.plainForDisplay(root.code) + 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..572970a --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -0,0 +1,166 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat +import qs.Components +import qs.Services + +Item { + id: root + + required property bool isUser + required property string text + required property var blocks + required property bool running + required property bool hovered + required property Repeater repeater + required property int index + required property ChatMessage message + + readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge + + signal edit(text: string) + + implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0) + + CustomClippingRect { + id: bubble + + property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + property real frozenWidth: 0.0 + property bool layoutFrozen: false + property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + + radius: Tokens.rounding.medium + color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitWidth: layoutFrozen ? frozenWidth : layoutWidth + implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2 + anchors.right: root.isUser ? parent.right : undefined + layer.enabled: false + layer.smooth: true + + transform: Scale { + id: stretchScale + origin.x: root.isUser ? bubble.width : 0 + xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0 + } + + states: [ + State { + name: "toSlim" + when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + }, + State { + name: "toWide" + when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + } + ] + + onLayoutFrozenChanged: { + if (layoutFrozen) + frozenWidth = width; + } + + TextEditBase { + id: msgText + + property string cachedText: root.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: Tokens.font.body.small + readOnly: true + selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) + text: root.isUser ? root.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; + } + } + } + + MarkdownBlocks { + id: markdown + + visible: !root.isUser + anchors.topMargin: Tokens.padding.medium + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + blocks: root.blocks + } + } + + Actions { + id: actionsRow + + readonly property bool shouldBeActive: !root.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 + running: root.running + 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..af0bfef --- /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 + fontStyle: Tokens.font.icon.extraLarge + 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/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml new file mode 100644 index 0000000..9a73c55 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + + signal dismiss + + implicitHeight: 0 + + Component.onCompleted: { + root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2); + enterAnim.start(); + } + + Behavior on implicitHeight { + Anim {} + } + + ParallelAnimation { + id: enterAnim + + Anim { + target: container + property: "y" + from: container.implicitHeight + to: 0 + } + + Anim { + target: container + property: "opacity" + from: 0 + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + target: root + property: "implicitHeight" + from: root.implicitHeight + to: 0 + } + + Anim { + target: container + property: "y" + to: container.implicitHeight + from: 0 + } + + Anim { + target: container + property: "opacity" + to: 0 + from: 1 + } + + onFinished: root.dismiss() + } + + CustomRect { + id: container + + anchors.left: parent.left + anchors.right: parent.right + + radius: Tokens.rounding.medium + color: Colors.palette.m3errorContainer + implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2 + + ColumnLayout { + id: errorInner + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + spacing: Tokens.spacing.small + + CustomText { + id: header + + Layout.fillWidth: true + + font: Tokens.font.headline.small + elide: CustomText.ElideRight + color: Colors.palette.m3onErrorContainer + + text: qsTr("An error occured") + } + + CustomText { + id: body + + Layout.fillWidth: true + + wrapMode: CustomText.WordWrap + color: Colors.palette.m3onErrorContainer + + text: root.current.errorMessage || LlmClient.serverError + } + } + + IconButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + isRound: true + icon: "close" + type: IconButton.Text + font: Tokens.font.icon.medium + + onClicked: exitAnim.start() + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml new file mode 100644 index 0000000..1c1117e --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -0,0 +1,90 @@ +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: MarkdownBlock.Type.Code + + delegate: CodeBlockView { + required property MarkdownBlock modelData + + language: modelData.language + code: modelData.code + anchors.right: parent.right + anchors.margins: Tokens.spacing.small + anchors.left: parent.left + } + } + + DelegateChoice { + roleValue: MarkdownBlock.Type.Math + + delegate: MathBlockView { + required property MarkdownBlock modelData + + latex: modelData.latex + anchors.right: parent.right + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + } + } + + DelegateChoice { + roleValue: MarkdownBlock.Type.Heading + + delegate: TextEditBase { + required property MarkdownBlock 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: { + if (modelData.level <= 1) + return Tokens.font.title.builders.large.weight(Font.Bold).build(); + if (modelData.level === 2) + return Tokens.font.title.builders.medium.weight(Font.Bold).build(); + return Tokens.font.title.builders.small.weight(Font.Bold).build(); + } + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + + DelegateChoice { + roleValue: MarkdownBlock.Type.Text + + delegate: BubbleEdit { + required property MarkdownBlock 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..8680312 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -0,0 +1,47 @@ +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 font font: Tokens.font.headline.medium + + implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 + + LlmMathText { + id: math + + latex: root.latex + color: root.mathColor + fontPointSize: root.font.pointSize + devicePixelRatio: Screen.devicePixelRatio + } + + Image { + id: equationImage + + anchors.centerIn: parent + source: math.imageId.length ? "image://llmmath/" + math.imageId : "" + 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: Tokens.font.mono.medium + 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..20e2533 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -0,0 +1,363 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Components +import ZShell.Config +import ZShell.Llm +import qs.Modules.Notifications.Sidebar.Chat +import qs.Components +import qs.Services + +MouseArea { + id: root + + required property int index + required property Item rootParent + required property ChatMessage modelData + required property ChatListView chatList + + readonly property bool isUser: modelData.role === ChatMessage.Role.User + + readonly property var blocks: root.isUser ? [ + { + kind: "content", + id: 0, + text: root.modelData.content, + markdown: [], + running: false + } + ] : blockify(current.segments, current.error) + + property ChatGeneration current: modelData.activeGeneration + property bool reasoningExpanded: false + property real savedOffset: NaN + property bool anchored: false + + function handleReasoningToggle(expanded: bool, block: Item): void { + if (!root.rootParent) + return; + + if (expanded) { + root.savedOffset = root.rootParent.contentY - root.y; + root.anchored = true; + chatList.anchorItem(block, ChatListView.TopEdge); + settleTimer.start(); + } else { + settleTimer.stop(); + root.anchored = false; + chatList.clearAnchor(); + if (!isNaN(root.savedOffset)) { + chatList.requestRestore(root.y + root.savedOffset); + root.savedOffset = NaN; + } + } + } + + function blockify(segs, errored) { + 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, + text: seg.text, + markdown: seg.markdown, + running: seg.running + }); + } 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] + }); + } + } + } + + if (errored) + blocks.push({ + kind: "error", + id: segs.length + }); + + return blocks; + } + + hoverEnabled: true + preventStealing: false + implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0) + onImplicitHeightChanged: if (root.anchored) + settleTimer.restart() + + Timer { + id: settleTimer + + interval: Tokens.anim.durations.expressiveDefaultEffects + repeat: false + + onTriggered: { + if (root.anchored) { + root.anchored = false; + chatList.clearAnchor(); + } + } + } + + 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 + } + } + } + } + + GridView { + id: attachments + + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + model: ScriptModel { + objectProp: "path" + values: { + const list = [...root.modelData.attachments]; + + if (list.length > 0 && list.length < attachments.columns) { + while (list.length < attachments.columns) + list.unshift(null); + } + + return list; + } + } + + delegate: Item { + id: thumb + + required property var modelData + + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + fontStyle: Tokens.font.icon.extraLarge + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + } + } + } + + Column { + id: layout + + anchors.top: attachments.bottom + anchors.left: parent.left + anchors.right: parent.right + + spacing: Tokens.spacing.medium + + Repeater { + id: segmentRep + + model: ScriptModel { + values: root.blocks + objectProp: "id" + } + delegate: DelegateChooser { + role: "kind" + + DelegateChoice { + roleValue: "error" + + delegate: ErrorBubble { + width: root.width + current: root.current + + onDismiss: root.current.dismissError() + } + } + + DelegateChoice { + roleValue: "process" + + delegate: ProcessBlock { + width: root.width + blocks: root.blocks + current: root.current + rootParent: root.rootParent + + onExpandedChanged: root.handleReasoningToggle(expanded, this) + } + } + + DelegateChoice { + roleValue: "content" + + delegate: ContentBubble { + required property var modelData + + isUser: root.isUser + text: modelData.text + blocks: modelData.markdown + running: modelData.running + width: root.width + repeater: segmentRep + message: root.modelData + hovered: root.containsMouse + + onEdit: text => { + root.modelData.edit(text); + + if (root.isUser) + root.modelData.generate(); + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml new file mode 100644 index 0000000..800e456 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml @@ -0,0 +1,351 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Modules.Notifications.Sidebar.Chat +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property Item fabRoot + + property bool open: false + + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 + + property int horizontalContentMargin + property string header: qsTr("Available models") + property string acceptLabel: qsTr("Accept") + + z: open || dialogTransition.running ? 2 : 0 + Layout.fillWidth: true + + implicitHeight: fabRoot.implicitHeight + + function prettyModelName(): string { + if (modelsContent.item?.modelsList.count < 1) + return qsTr("No models found"); + + const pathList = LlmClient.model.split("\/"); + const name = pathList[pathList.length - 1]; + + if (!name) + return qsTr("No model selected"); + + return name; + } + + Behavior on implicitHeight { + Anim {} + } + + function reparentWrapper(): void { + const newParent = open ? root.rootParent : root; + const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2); + root.collapsedY = modelsWrapper.y; + modelsWrapper.parent = newParent; + modelsWrapper.x = c.x - modelsWrapper.width / 2; + modelsWrapper.y = c.y - modelsWrapper.height / 2; + } + + BlobGroup { + id: blobGroup + + color: root.open ? Colors.palette.m3surfaceContainerHighest : 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: modelsWrapper + + width: root.width + height: modelBox.implicitHeight + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + modelsBackground.radius: Tokens.rounding.largeIncreased + modelsContent.opacity: 1 + modelsWrapper.height: root.openHeight + modelsWrapper.width: root.openWidth + modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2 + modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight + elevation.opacity: 1 + modelBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,radius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + level: 4 + opacity: 0 + radius: modelsBackground.radius + + transform: Matrix4x4 { + matrix: modelsBackground.deformMatrix + } + } + + BlobRect { + id: modelsBackground + + anchors.fill: parent + radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomRect { + id: modelBox + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: root.fabRoot.implicitHeight + radius: modelsBackground.radius + + CustomText { + id: label + text: root.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: "unfold_more" + fontStyle: Tokens.font.icon.large + } + + StateLayer { + id: modelLayer + + onClicked: { + ChatState.fabExpanded = false; + root.open = true; + } + } + } + + Loader { + id: modelsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + readonly property alias modelsList: modelsList + + onWheel: event => event.accepted = true + preventStealing: true + clip: true + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2 + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font: Tokens.font.title.builders.large.weight(Font.Normal).build() + text: root.header + } + + VerticalFadeListView { + id: modelsList + + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + Layout.preferredHeight: 400 + Layout.maximumHeight: 400 + model: LlmClient.availableModels + currentIndex: LlmClient.availableModels.indexOf(LlmClient.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: ListView.view.currentIndex === model.index + + implicitWidth: ListView.view.width + implicitHeight: name.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.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.small + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + + CustomText { + id: name + + text: model.modelData + elide: CustomText.ElideRight + color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + + Layout.fillWidth: true + } + + MaterialIcon { + id: check + + color: Colors.palette.m3onTertiaryContainer + fontStyle: Tokens.font.icon.medium + opacity: model.selected ? 1 : 0 + visible: opacity > 0 + text: "check" + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StateLayer { + id: stateLayer + + onClicked: { + model.ListView.view.currentIndex = model.index; + } + } + } + } + + 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.open = false; + } + } + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + LlmClient.selectModel(modelsList.currentItem.modelData); + root.open = false; + } + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml new file mode 100644 index 0000000..43ad59a --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Blobs +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property int index + required property var modelData + required property var blocks + readonly property var segments: modelData.segments + required property Item rootParent + required property ChatGeneration current + readonly property bool isActive: index === blocks.length - 1 + property bool expanded: false + readonly property var lastSegment: root.segments[root.segments.length - 1] + readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) + readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending) + + implicitHeight: { + const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; + const collapsed = layout.implicitHeight; + const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin; + + if (expanded) + return expand; + else if (pendingApproval) + return approval; + else + return collapsed; + } + + onCurrentChanged: expanded = false + + 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: Tokens.font.icon.medium + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + anchors.centerIn: parent + opacity: root.isActive ? 0 : 1 + enabled: opacity > 0 + 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: Tokens.font.label.small + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.medium + text: { + if (root.isActive) { + if (root.pendingApproval) + return qsTr("Waiting for approval..."); + + 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 {} + } + } + } + + ApprovalPrompt { + id: approvalPrompt + + anchors.top: layout.bottom + anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left + anchors.right: parent.right + visible: root.pendingApproval + + rootParent: root.rootParent + current: root.current + } + + CustomClippingRect { + id: expandedRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: layout.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 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 var 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 + + function toolTarget(seg: var): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + function prettyTool(seg: var): string { + if (seg.name === "webfetch") { + switch (seg.status) { + case LlmToolCall.Status.Running: + return qsTr("Fetching"); + case LlmToolCall.Status.Pending: + return qsTr("Pending fetch"); + case LlmToolCall.Status.Success: + return qsTr("Fetched"); + case LlmToolCall.Status.Error: + return qsTr("Failed to fetch"); + default: + return qsTr("Fetched website"); + } + } else if (seg.name === "readfile") { + switch (seg.status) { + case LlmToolCall.Status.Running: + return qsTr("Reading"); + case LlmToolCall.Status.Pending: + return qsTr("Pending read"); + case LlmToolCall.Status.Success: + return qsTr("Read"); + case LlmToolCall.Status.Error: + return qsTr("Failed to read"); + default: + return qsTr("Read file"); + } + } + } + + 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: Tokens.font.body.small + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData)) + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml new file mode 100644 index 0000000..128202d --- /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); + ShellState.forActive().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..38b0e50 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -0,0 +1,207 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import ZShell.Blobs +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Modules.Notifications.Sidebar.Chat.Content +import qs.Services + +Item { + id: root + + property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3 + property color blobColor: Colors.tPalette.m3surfaceContainerLow + readonly property int iconSize: Math.round(Tokens.font.icon.extraLarge.pointSize * (96 / 72)) + Tokens.padding.large * 2 + property alias conversationModel: sidebar.model + property bool chatOpen: ChatState.chatSession !== null + + readonly property bool isWide: root.width >= root.breakpoint + + readonly property bool showList: isWide || !chatOpen + readonly property bool showContent: !isWide && chatOpen + + function openConversation(conv: ChatSession, index: int): void { + } + + BlobGroup { + id: blobGroup + + color: root.blobColor + smoothing: Tokens.rounding.medium + } + + BlobInvertedRect { + id: invertedRect + anchors.fill: parent + borderBottom: Tokens.padding.small + borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium + borderRight: Tokens.padding.small + borderTop: Tokens.padding.small + group: blobGroup + opacity: root.blobColor.a + radius: Tokens.rounding.largeIncreased + } + + ChatList { + id: sidebar + + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize + highlight: true + slim: !root.isWide && !ChatState.narrowSidebarExpanded + z: 1 + + Behavior on implicitWidth { + Anim { + type: Anim.DefaultEffects + } + } + + model: LlmClient.chats + + onLoadChatRequest: (chat, index) => { + ChatState.currentIdx = index; + ChatState.chatSession = chat; + } + + onDeleteChatRequest: chat => { + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + + if (wasCurrent) + ChatState.openDraft(); + } + onNewChatRequest: ChatState.openDraft() + + onRequestExpand: { + ChatState.narrowSidebarExpanded = true; + } + } + + CustomClippingRect { + anchors.fill: parent + anchors.leftMargin: invertedRect.borderLeft + anchors.topMargin: invertedRect.borderTop + anchors.bottomMargin: invertedRect.borderBottom + anchors.rightMargin: invertedRect.borderRight + radius: invertedRect.radius + + children: [contentArea, dim] + } + + CustomRect { + id: dim + anchors.fill: parent + + color: Colors.palette.m3shadow + opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0 + z: 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Item { + id: contentArea + + property int leftMargin: sidebar.implicitWidth + + z: 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 + + states: State { + name: "wide" + when: !root.isWide + + PropertyChanges { + contentArea.leftMargin: root.iconSize + } + } + + transitions: Transition { + to: "wide" + + Anim { + type: Anim.DefaultEffects + property: "leftMargin" + } + } + + ChatHost { + id: convHost + + anchors.fill: parent + clip: true + + onLoadingChanged: if (!loading) + root.chatOpen = true + } + + MouseArea { + id: dimArea + anchors.fill: parent + preventStealing: true + enabled: !root.isWide && ChatState.narrowSidebarExpanded + cursorShape: undefined + + onClicked: { + const x = mouseX; + const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; + + if (x > invertedRect.borderLeft - margin) { + ChatState.narrowSidebarExpanded = false; + ChatState.fabExpanded = false; + } + } + + HoverHandler { + cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined + enabled: dimArea.enabled + } + } + } + + Binding { + target: ChatState + property: "windowIsWide" + value: root.showList + } + + Binding { + target: ChatState + property: "isWide" + value: root.isWide + } + + Binding { + target: ChatState + value: contentArea.leftMargin === root.iconSize + property: "settledSlim" + } + + Binding { + target: ChatState + value: contentArea.leftMargin === sidebar.implicitWidth + property: "settledWide" + } + + Component { + id: conversationView + + ChatContent {} + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 143c6a5..6167cb7 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 { @@ -9,20 +13,150 @@ Item { required property ScreenState screenState + Connections { + function onIsWindowChanged(): void { + if (ChatState.isWindow) + root.screenState.sidebarTab = 0; + } + + target: ChatState + } + ColumnLayout { id: layout anchors.fill: parent spacing: Tokens.spacing.small - CustomRect { + Tabs { + Layout.fillWidth: true + Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight + screenState: root.screenState + nonAnimWidth: layout.width + visible: height > 0 + } + + Item { Layout.fillHeight: true Layout.fillWidth: true - color: Colors.tPalette.m3surfaceContainerLow - radius: Tokens.rounding.small + clip: true - NotifDock { - screenState: root.screenState + Item { + id: pages + + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width + opacity: root.screenState.sidebarTab === 0 ? 1 : 0 + visible: opacity > 0 + x: root.screenState.sidebarTab === 0 ? 0 : -root.width + + Behavior on opacity { + Anim {} + } + Behavior on x { + Anim {} + } + + CustomRect { + id: bg + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + NotifDock { + screenState: root.screenState + } + } + } + + Loader { + id: chatLoader + + anchors.bottom: parent.bottom + anchors.top: parent.top + active: Config.llm.enabled + width: parent.width + property bool tre: true + + sourceComponent: Item { + id: chatPage + + opacity: root.screenState.sidebarTab === 1 ? 1 : 0 + visible: opacity > 0 + x: root.screenState.sidebarTab === 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.large + + ChatPanel { + anchors.fill: parent + } + + GeminiGlow { + innerGlow: 50 + z: 5 + opacity: LlmClient.busy ? 1.0 : 0.0 + glowSize: 0 + visible: opacity > 0 && !ChatState.isWindow + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: fadeMask + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Rectangle { + id: fadeMask + + readonly property real fadeStartPos: { + const h = Math.max(1, height); + const nh = (h - h / 3) / h; + return nh; + } + + anchors.fill: parent + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.rgba(0, 0, 0, 0) + position: 0 + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 0) + position: fadeMask.fadeStartPos + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 1.0) + position: 1.0 + } + } + } + } + } } } diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml new file mode 100644 index 0000000..3dbd448 --- /dev/null +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -0,0 +1,139 @@ +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 ScreenState screenState + 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.screenState.sidebarTab + 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.screenState.sidebarTab = 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 + fontStyle: Tokens.font.icon.large + 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/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index 2b2d344..e06e927 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -140,8 +140,7 @@ ColumnLayout { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: FileSystemModel { nameFilters: ["recording_*.mp4"] @@ -167,8 +166,7 @@ ColumnLayout { opacity: list.count === 0 ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: Tokens.spacing.small @@ -183,16 +181,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } @@ -208,16 +203,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 26e5343..3aa8206 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,9 +1,9 @@ import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Config import qs.Components import qs.Modules.Notifications.Sidebar.Utils.Cards -import ZShell.Config Item { id: root @@ -19,8 +19,7 @@ Item { anchors.fill: parent spacing: 8 - IdleInhibit { - } + IdleInhibit {} Record { screenState: root.screenState diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 189ad55..c44850e 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -4,7 +4,6 @@ import QtQuick import Quickshell import ZShell.Config import qs.Components -import qs.Modules.Bar.Popouts as BarPopouts Item { id: root @@ -15,7 +14,7 @@ Item { required property Item sidebar 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 diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 6f493da..6934644 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 { @@ -25,6 +29,17 @@ Item { } } + Connections { + target: LlmClient + + function onGenerationFailed(session: ChatSession, 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 fc59f88..f06d808 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -10,24 +10,25 @@ import qs.Services Item { id: root - property bool acceptAllowed: true required property string acceptLabel required property Component content - property bool first: false required property string header + required property string label + required property Item rootParent + required property string settingAnchor + + property bool acceptAllowed: true + property bool first: false property int horizontalContentMargin property string icon property alias iconLabel: openButton.iconLabel - required property string label + property string subtext: "" property bool last: false 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 alias rowButton: openButton property bool separateContent - required property string settingAnchor - property string subtext: "" signal accepted signal cancelled @@ -50,8 +51,7 @@ Item { color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer Behavior on color { - CAnim { - } + CAnim {} } } diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 505f58b..c9bf846 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -8,8 +8,9 @@ import qs.Services DialogRowButton { id: root - property var initialSelect required property var model + + property var initialSelect property var selectedItem function keyFor(item: var): string { diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index de02ceb..991fde1 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -30,7 +30,7 @@ ColumnLayout { function findAnchor(item: Item, anchor: string): Item { if (!item) return null; - if (item.settingAnchor !== undefined && item.settingAnchor === anchor) + if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property return item; const kids = item.children; for (let i = 0; i < kids.length; i++) { @@ -43,8 +43,8 @@ ColumnLayout { function highlightAnchor(anchor: string): void { const row = findAnchor(contentChild, anchor); - if (row && row.flashHighlight !== undefined) - row.flashHighlight(); + if (row && row.flashHighlight !== undefined) // qmllint disable missing-property + row.flashHighlight(); // qmllint disable missing-property } function scrollToAnchor(anchor: string): bool { @@ -61,8 +61,8 @@ ColumnLayout { root.animateScroll = true; flickable.contentY = target; Qt.callLater(() => root.animateScroll = false); - if (row.flashHighlight !== undefined) - row.flashHighlight(); + if (row.flashHighlight !== undefined) // qmllint disable missing-property + row.flashHighlight(); // qmllint disable missing-property return true; } @@ -155,11 +155,20 @@ ColumnLayout { Layout.fillHeight: true Layout.fillWidth: true Layout.topMargin: -topMargin + bottomMargin: Tokens.padding.extraLarge + topMargin: Tokens.padding.large + fadeAmount: 0.1 + contentHeight: root.contentChild?.implicitHeight ?? 0 contentItem.children: [root.contentChild] - fadeAmount: 0.1 - topMargin: Tokens.padding.large + + rebound: Transition { + Anim { + properties: "x,y" + type: Anim.DefaultEffects + } + } Behavior on contentY { enabled: root.animateScroll diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index bbd096b..a73378d 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -81,14 +81,10 @@ VerticalFadeFlickable { topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall - RadiusBehavior on bottomLeftRadius { - } - RadiusBehavior on bottomRightRadius { - } - RadiusBehavior on topLeftRadius { - } - RadiusBehavior on topRightRadius { - } + RadiusBehavior on bottomLeftRadius {} + RadiusBehavior on bottomRightRadius {} + RadiusBehavior on topLeftRadius {} + RadiusBehavior on topRightRadius {} StateLayer { id: stateLayer @@ -264,14 +260,10 @@ VerticalFadeFlickable { topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall width: cardList.width - RadiusBehavior on bottomLeftRadius { - } - RadiusBehavior on bottomRightRadius { - } - RadiusBehavior on topLeftRadius { - } - RadiusBehavior on topRightRadius { - } + RadiusBehavior on bottomLeftRadius {} + RadiusBehavior on bottomRightRadius {} + RadiusBehavior on topLeftRadius {} + RadiusBehavior on topRightRadius {} ColumnLayout { id: resultLayout diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 46a2249..591e7e1 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,11 @@ QtObject { // Wallpaper & style StackPage { Component { - WallpaperPage { - } + WallpaperPage {} } Component { - WallpaperSelect { - } + WallpaperSelect {} } Component { @@ -41,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 {} } } }, @@ -75,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 {} } } }, @@ -126,18 +116,15 @@ QtObject { Component { StackPage { Component { - AppsPage { - } + AppsPage {} } Component { - AllApps { - } + AllApps {} } Component { - AppInfo { - } + AppInfo {} } } }, @@ -146,13 +133,11 @@ QtObject { Component { StackPage { Component { - ServicesPage { - } + ServicesPage {} } Component { - NotificationsPage { - } + NotificationsPage {} } } }, @@ -161,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/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index e6e5394..7b0aea9 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -55,8 +55,8 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" - label: qsTr("Add entry") last: true + label: qsTr("Add entry") model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(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..9eda863 --- /dev/null +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -0,0 +1,112 @@ +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("General") + } + + ToggleRow { + settingAnchor: "panels-sidebar-llm-enabled" + checked: Config.llm.enabled + text: qsTr("Enabled") + first: true + last: true + + onToggled: Config.llm.enabled = checked + } + + SectionHeader { + text: qsTr("Appearance") + } + + DialogSelectButton { + id: selectScheme + + acceptLabel: qsTr("Confirm") + settingAnchor: "panels-sidebar-llm-scheme" + enabled: Object.keys(model).length > 0 + header: qsTr("Select scheme") + first: true + last: true + icon: "add" + label: qsTr("Color scheme") + subtext: qsTr("Select the color scheme used for code blocks") + model: root.schemes + rootParent: root.flickable + + 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 cef7a3f..8de209b 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -93,6 +93,7 @@ PageBase { } 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 => ({ @@ -112,5 +113,19 @@ PageBase { }); } } + + SectionHeader { + text: qsTr("AI chat") + } + + NavRow { + text: qsTr("AI chat") + settingAnchor: "panels-sidebar-llm" + icon: "robot_2" + first: true + last: true + + onClicked: root.sState.openSubPage(9) + } } } diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml index a180086..d49c911 100644 --- a/Modules/Settings/SettingsSearcher.qml +++ b/Modules/Settings/SettingsSearcher.qml @@ -1,66 +1,24 @@ pragma Singleton import "../../scripts/fzf.js" as Fzf +import "../../scripts/settings-indexer.js" as SettingsIndexer import QtQuick import Quickshell import ZShell import ZShell.Config +import qs.Paths Singleton { id: root - property var fzfFinder: null + property var inverted: ({}) + property var ranking: ({}) readonly property var highlightCache: ({ "search": "", "pattern": null }) - property var inverted: ({}) - property var ranking: ({}) - - function highlight(text: string, search: string, colour: color): string { - const escaped = text.replace(/&/g, "&").replace(//g, ">"); - if (search.length === 0) - return escaped; - - const cache = root.highlightCache; - if (search !== cache.search) { - const tokens = tokenize(search); - cache.search = search; - if (tokens.length === 0) - cache.pattern = null; - else { - const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); - cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi"); - } - } - - const pattern = cache.pattern; - if (!pattern) - return escaped; - - pattern.lastIndex = 0; - if (!pattern.test(escaped)) - return escaped; - - pattern.lastIndex = 0; - return escaped.replace(pattern, `$1`); - } - - function lookup(token: string): var { - const result = ({}); - const exact = root.inverted[token] !== undefined; - const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token)); - for (const key of keys) { - const rank = root.ranking[key] ?? ({}); - for (const id of root.inverted[key]) { - const w = rank[id] ?? 0.1; - if (result[id] === undefined || w > result[id]) - result[id] = w; - } - } - - return result; - } + property var fzfFinder: null + readonly property string cachePath: Paths.cache + "/settings-index.json" function query(search: string): list { const tokens = root.tokenize(search); @@ -70,7 +28,7 @@ Singleton { const scores = ({}); const hitCounts = ({}); for (const token of tokens) { - const matches = root.lookup(token); + const matches = root.lookup(token); // { id: weight } for (const id in matches) { scores[id] = (scores[id] ?? 0) + matches[id]; hitCounts[id] = (hitCounts[id] ?? 0) + 1; @@ -103,13 +61,76 @@ Singleton { return out; } + function lookup(token: string): var { + const result = ({}); + const exact = root.inverted[token] !== undefined; + const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token)); + for (const key of keys) { + const rank = root.ranking[key] ?? ({}); + for (const id of root.inverted[key]) { + const w = rank[id] ?? 0.1; + if (result[id] === undefined || w > result[id]) + result[id] = w; + } + } + + return result; + } + function tokenize(text: string): var { return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0); } + function highlight(text: string, search: string, colour: color): string { + const escaped = text.replace(/&/g, "&").replace(//g, ">"); + if (search.length === 0) + return escaped; + + const cache = root.highlightCache; + if (search !== cache.search) { + const tokens = root.tokenize(search); + cache.search = search; + if (tokens.length === 0) { + cache.pattern = null; + } else { + const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi"); + } + } + + const pattern = cache.pattern; + if (!pattern) + return escaped; + + pattern.lastIndex = 0; + if (!pattern.test(escaped)) + return escaped; + + pattern.lastIndex = 0; + return escaped.replace(pattern, `$1`); + } + + function loadIndex(): var { + const revision = ZUtils.gitRevision(); + const cached = ZUtils.readTextFile(cachePath); + if (cached) { + try { + const parsed = JSON.parse(cached); + if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name) + return parsed; + } catch (e) {} + } + const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text)); + data.revision = revision; + data.locale = Qt.locale().name; + ZUtils.writeTextFile(cachePath, JSON.stringify(data)); + console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`); + return data; + } + Component.onCompleted: { try { - const data = JSON.parse(ZUtils.settingsIndex()); + const data = root.loadIndex(); entries.model = data.entries; root.inverted = data.inverted ?? {}; root.ranking = data.ranking ?? {}; @@ -122,6 +143,7 @@ Singleton { limit: 25 }); } catch (e) { + console.warn("SettingsSearcher: failed to build settings index:", e); entries.model = []; root.inverted = {}; root.ranking = {}; @@ -132,22 +154,25 @@ Singleton { Variants { id: entries - SettingEntry { - } + SettingEntry {} } component SettingEntry: QtObject { - readonly property string anchor: modelData.anchor ?? "" + required property var modelData + + readonly property int pageIdx: modelData.pageIdx + readonly property var subPath: modelData.subPath + readonly property var targetSubPath: modelData.targetSubPath ?? [] readonly property var crumbIcons: modelData.crumbIcons readonly property var crumbLabels: modelData.crumbLabels - readonly property bool isToggle: togglePath.length > 0 - required property var modelData - readonly property int pageIdx: modelData.pageIdx - readonly property string section: modelData.section ?? "" - readonly property var subPath: modelData.subPath - readonly property string subtext: modelData.subtext ?? "" readonly property string title: modelData.title + readonly property string section: modelData.section ?? "" + readonly property string subtext: modelData.subtext ?? "" + readonly property string anchor: modelData.anchor ?? "" + readonly property string icon: modelData.icon ?? "" + readonly property string togglePath: modelData.togglePath ?? "" + readonly property bool isToggle: togglePath.length > 0 readonly property bool toggleValue: { if (!isToggle) return false; diff --git a/Plugins/ZShell/Blobs/blobrect.hpp b/Plugins/ZShell/Blobs/blobrect.hpp index 68df1c3..967e339 100644 --- a/Plugins/ZShell/Blobs/blobrect.hpp +++ b/Plugins/ZShell/Blobs/blobrect.hpp @@ -50,9 +50,9 @@ class BlobRect : public BlobShape { [[nodiscard]] qreal damping() const { return m_damping; } - void setDamping(qreal d) { - if (!qFuzzyCompare(m_damping, d)) { - m_damping = d; + void setDamping(qreal value) { + if (!qFuzzyCompare(m_damping, value)) { + m_damping = value; emit dampingChanged(); } } @@ -110,7 +110,6 @@ class BlobRect : public BlobShape { void checkAtRest(float speed); void beginRetire(); - // Physics state QPointF m_prevScenePos; QElapsedTimer m_elapsed; bool m_physicsActive = false; @@ -122,13 +121,10 @@ class BlobRect : public BlobShape { float m_retire01 = 0.0f; float m_retire11 = 1.0f; - // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, - // m11) Rest state is identity: m00=1, m01=0, m11=1 float m_dm00 = 1.0f; float m_dm01 = 0.0f; float m_dm11 = 1.0f; - // Spring velocities for each component float m_dmVel00 = 0.0f; float m_dmVel01 = 0.0f; float m_dmVel11 = 0.0f; diff --git a/Plugins/ZShell/Blobs/blobshape.cpp b/Plugins/ZShell/Blobs/blobshape.cpp index e33b2f3..c73d02c 100644 --- a/Plugins/ZShell/Blobs/blobshape.cpp +++ b/Plugins/ZShell/Blobs/blobshape.cpp @@ -168,10 +168,6 @@ void BlobShape::updatePolish() { if (other->width() <= 0 || other->height() <= 0) continue; - // Excluded shapes stay in the rect data. Exclusion only gates the smooth - // blend (via excludeMask below); ownership must still see every shape, - // otherwise two nodes can both believe they own a pixel and the pixel is - // blended twice. const QPointF otherScene = other->mapToScene(QPointF(0, 0)); bool include = false; @@ -196,7 +192,7 @@ void BlobShape::updatePolish() { const QMatrix4x4& dm = other->m_deformMatrix; const float a = dm(0, 0), b = dm(1, 0); - const float c = dm(0, 1), d = dm(1, 1); + const float c = dm(0, 1), e = dm(1, 1); BlobRectData r; r.cx = static_cast(otherScene.x() + other->width() / 2.0); @@ -207,19 +203,19 @@ void BlobShape::updatePolish() { r.offsetX = dm(0, 3); r.offsetY = dm(1, 3); - const float det = a * d - c * b; + const float det = a * e - c * b; const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; - r.invDeform[0] = d * invDet; + r.invDeform[0] = e * invDet; r.invDeform[1] = -b * invDet; r.invDeform[2] = -c * invDet; r.invDeform[3] = a * invDet; - const float halfTr = 0.5f * (a + d); - const float halfDiff = 0.5f * (a - d); + const float halfTr = 0.5f * (a + e); + const float halfDiff = 0.5f * (a - e); r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; - r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh; + r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh; m_cachedRects.append(r); rectShapes.append(other); diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index f0e1a36..7d1bbaf 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell URI ZShell + INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp @@ -18,7 +19,10 @@ qml_module(ZShell zshell-util ) -target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}") +target_compile_definitions(ZShell PRIVATE + ZSHELL_VERSION="${VERSION}" + GIT_REVISION="${GIT_REVISION}" +) # Node macros expand to Q_OBJECT, automoc can't see it otherwise list(APPEND CMAKE_AUTOMOC_MACRO_NAMES @@ -37,6 +41,7 @@ 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) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index a0b7276..6d21cc0 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -2,6 +2,7 @@ qml_module(ZShell-components URI ZShell.Components SOURCES lazylistview.hpp lazylistview.cpp + chatlistview.hpp chatlistview.cpp wavyline.hpp wavyline.cpp wavylinerenderer.hpp wavylinerenderer.cpp buttonrow.hpp buttonrow.cpp @@ -9,6 +10,7 @@ qml_module(ZShell-components sparklinecontent.hpp sparklinecontent.cpp sparklineitem.hpp sparklineitem.cpp sparklinesceneitem.hpp sparklinesceneitem.cpp + wheelinverter.hpp LIBRARIES Qt::Quick Qt::QuickPrivate diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 0a5aa66..da2842a 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -187,7 +187,7 @@ void CarouselView::setModel(const QVariantList& m) { emit modelChanged(); - const int newCount = m_model.size(); + const int newCount = static_cast(m_model.size()); if (newCount == 0) { m_currentRealIndex = -1; @@ -647,7 +647,7 @@ qreal CarouselView::sampleSize(qreal childLoc) const { } int CarouselView::realIndexOf(int virtualIndex) const { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return -1; @@ -668,7 +668,7 @@ qreal CarouselView::snapTargetX(qreal x) const { } void CarouselView::wrapContentIfNeeded() { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -707,7 +707,7 @@ void CarouselView::wrapContentIfNeeded() { } void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -856,8 +856,8 @@ void CarouselView::positionSlot(const Slot& slot) const { return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing; }; - const auto leftGap = [this, n](int leftIndex) { - return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; + const auto leftGap = [this, n](int gapIndex) { + return gapIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; }; qreal center = 0; @@ -974,7 +974,7 @@ void CarouselView::releaseAllItems() { } void CarouselView::relayout() { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0 || width() <= 0 || m_focalWidth <= 0) { for (Slot& s : m_slots) { @@ -1038,7 +1038,7 @@ void CarouselView::relayout() { m_slots.append(Slot{}); slotUsedThisPass.append(false); - freeSlot = m_slots.size() - 1; + freeSlot = static_cast(m_slots.size()) - 1; } updateSlotContent(m_slots[freeSlot], v); @@ -1092,7 +1092,7 @@ void CarouselView::snapToNearest() { } void CarouselView::goToIndex(int realIndex) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -1126,7 +1126,7 @@ void CarouselView::decrementCurrentIndex() { } void CarouselView::centerInstantlyOnReal(int realIndex) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -1220,7 +1220,7 @@ qreal CarouselView::sampledVelocity() const { const VelocitySample& oldest = m_velocitySamples.first(); const VelocitySample& newest = m_velocitySamples.last(); - const qreal dt = newest.t - oldest.t; + const qreal dt = static_cast(newest.t - oldest.t); if (dt < 8) return 0; diff --git a/Plugins/ZShell/Components/chatlistview.cpp b/Plugins/ZShell/Components/chatlistview.cpp new file mode 100644 index 0000000..90e48bf --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.cpp @@ -0,0 +1,1235 @@ +#include "chatlistview.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; +constexpr int RESTORE_QUIET_MS = 150; +constexpr int MEASURE_BATCH = 4; +constexpr int MEASURE_SETTLE_MS = 100; +constexpr int MEASURE_DEBOUNCE_MS = 150; + +} // namespace + +namespace ZShell::components { + +ChatListViewAttached::ChatListViewAttached(QObject* parent) : QObject(parent) {} + +qreal ChatListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void ChatListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +qreal ChatListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void ChatListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + +bool ChatListViewAttached::ready() const { + return m_ready; +} + +void ChatListViewAttached::setReady(bool ready) { + if (m_ready == ready) return; + m_ready = ready; + emit readyChanged(); +} + +bool ChatListViewAttached::adding() const { + return m_adding; +} + +void ChatListViewAttached::setAdding(bool adding) { + if (m_adding == adding) return; + m_adding = adding; + emit addingChanged(); +} + +bool ChatListViewAttached::removing() const { + return m_removing; +} + +void ChatListViewAttached::setRemoving(bool removing) { + if (m_removing == removing) return; + m_removing = removing; + emit removingChanged(); +} + +bool ChatListViewAttached::trackViewport() const { + return m_trackViewport; +} + +void ChatListViewAttached::setTrackViewport(bool track) { + if (m_trackViewport == track) return; + m_trackViewport = track; + emit trackViewportChanged(); +} + +ChatListView::ChatListView(QQuickItem* parent) : QQuickItem(parent) { + m_measureTimer = new QTimer(this); + m_measureTimer->setSingleShot(true); + m_measureTimer->setInterval(MEASURE_DEBOUNCE_MS); + connect(m_measureTimer, &QTimer::timeout, this, [this] { + buildMeasureQueue(); + polish(); + }); +} + +ChatListViewAttached* ChatListView::qmlAttachedProperties(QObject* object) { + return new ChatListViewAttached(object); +} + +ChatListView::~ChatListView() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); +} + +QAbstractItemModel* ChatListView::model() const { + return m_model; +} + +void ChatListView::setModel(QAbstractItemModel* model) { + if (m_model == model) return; + + if (m_model) disconnectModel(); + + m_model = model; + + if (m_model) connectModel(); + + resetContent(); + emit modelChanged(); +} + +QQmlComponent* ChatListView::delegate() const { + return m_delegate; +} + +void ChatListView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) return; + + m_delegate = delegate; + resetContent(); + emit delegateChanged(); +} + +qreal ChatListView::spacing() const { + return m_spacing; +} + +void ChatListView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) return; + m_spacing = spacing; + emit spacingChanged(); + polish(); +} + +qreal ChatListView::contentHeight() const { + return m_contentHeight; +} + +qreal ChatListView::layoutHeight() const { + return m_layoutHeight; +} + +qreal ChatListView::contentY() const { + return m_contentY; +} + +void ChatListView::setContentY(qreal contentY) { + if (qFuzzyCompare(m_contentY, contentY)) return; + m_contentY = contentY; + emit contentYChanged(); + polish(); +} + +QRectF ChatListView::viewport() const { + return m_viewport; +} + +void ChatListView::setViewport(const QRectF& viewport) { + if (m_viewport == viewport) return; + m_viewport = viewport; + emit viewportChanged(); + if (m_useCustomViewport) polish(); +} + +bool ChatListView::useCustomViewport() const { + return m_useCustomViewport; +} + +void ChatListView::setUseCustomViewport(bool use) { + if (m_useCustomViewport == use) return; + m_useCustomViewport = use; + emit useCustomViewportChanged(); + polish(); +} + +qreal ChatListView::cacheBuffer() const { + return m_cacheBuffer; +} + +void ChatListView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) return; + m_cacheBuffer = buffer; + emit cacheBufferChanged(); + polish(); +} + +qreal ChatListView::estimatedHeight() const { + return m_estimatedHeight; +} + +void ChatListView::setEstimatedHeight(qreal height) { + if (qFuzzyCompare(m_estimatedHeight, height)) return; + m_estimatedHeight = height; + emit estimatedHeightChanged(); + polish(); +} + +bool ChatListView::asynchronous() const { + return m_asynchronous; +} + +void ChatListView::setAsynchronous(bool async) { + if (m_asynchronous == async) return; + m_asynchronous = async; + emit asynchronousChanged(); + polish(); +} + +int ChatListView::removeDuration() const { + return m_removeDuration; +} + +void ChatListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +int ChatListView::readyDelay() const { + return m_readyDelay; +} + +void ChatListView::setReadyDelay(int delay) { + if (m_readyDelay == delay) return; + m_readyDelay = delay; + emit readyDelayChanged(); +} + +int ChatListView::yAnimDuration() const { + return m_yAnimDuration; +} + +void ChatListView::setYAnimDuration(int duration) { + if (m_yAnimDuration == duration) return; + m_yAnimDuration = duration; + emit yAnimDurationChanged(); +} + +int ChatListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +bool ChatListView::event(QEvent* e) { + return QQuickItem::event(e); +} + +void ChatListView::componentComplete() { + QQuickItem::componentComplete(); + m_componentComplete = true; + resetContent(); +} + +void ChatListView::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if (!m_componentComplete) return; + + if (!qFuzzyCompare(newGeometry.width(), oldGeometry.width())) { + for (auto& entry : m_delegates) { + if (entry.item) entry.item->setWidth(newGeometry.width()); + } + m_measureTimer->start(); + } + + polish(); +} + +void ChatListView::updatePolish() { + if (!m_componentComplete || !m_model || !m_delegate) return; + + QList pendingIndices; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (it->pendingInsert && it->item) pendingIndices.append(it.key()); + } + + for (int pendingIdx : pendingIndices) { + auto& entry = m_delegates[pendingIdx]; + + if (m_readyDelay > 0) { + if (!entry.readyDelayStarted) { + entry.readyDelayStarted = true; + auto* item = entry.item; + QTimer::singleShot(m_readyDelay, this, [this, item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto it = m_delegates.find(idx); + if (it == m_delegates.end() || it->item != item || + !it->pendingInsert) + return; + + it->pendingInsert = false; + it->readyDelayStarted = false; + + if (idx >= 0 && idx < static_cast(m_layout.size())) { + qreal visualY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); + ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown + ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visualY += m_spacing; + hasVisItem = true; + } + if (i == idx) break; + if (h > 0) visualY += h; + } + item->setY(visualY); + } + + item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + + m_animateY = true; + polish(); + }); + } + continue; + } + + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + } + + relayout(); + syncDelegates(); + pumpMeasureQueue(); + + for (auto& record : m_layout) + record.isNew = false; + + const bool animate = m_animateY; + m_animateY = false; + const int layoutSize = static_cast(m_layout.size()); + const qint64 now = m_clock.elapsed(); + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= layoutSize) continue; + + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; + + const qreal target = m_layout[idx].targetY; + const qreal cur = entry.item->y(); + + const bool inFlight = entry.hasPropY && qAbs(cur - entry.propY) > 0.5; + const bool cooling = entry.hasPropY && + (now - entry.lastPropTime) < m_yAnimDuration + 50; + + const bool needWrite = inFlight ? qAbs(target - entry.propY) > 0.25 + : qAbs(cur - target) > 0.25; + if (!needWrite) continue; + + if (animate || inFlight || cooling) { + entry.item->setProperty("y", target); + entry.propY = target; + entry.hasPropY = true; + entry.lastPropTime = now; + } else { + entry.item->setY(target); + entry.hasPropY = false; + } + } + + applyAnchor(); +} + +void ChatListView::relayout() { + qreal y = 0; + bool hasLayoutItem = false; + for (auto& record : m_layout) { + const qreal layoutH = record.heightKnown ? record.height + : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) y += m_spacing; + hasLayoutItem = true; + record.targetY = y; + y += layoutH; + } else { + record.targetY = y; + } + } + + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { + m_layoutHeight = y; + emit layoutHeightChanged(); + } + + qreal visY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visY += m_spacing; + hasVisItem = true; + visY += h; + } + } + + for (const auto& dying : std::as_const(m_dyingDelegates)) { + if (!dying.item) continue; + const qreal dyingH = delegateVisibleHeight(dying.item); + if (dyingH > 0) visY = std::max(visY, dying.item->y() + dyingH); + } + + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { + m_contentHeight = visY; + emit contentHeightChanged(); + } +} + +QRectF ChatListView::effectiveViewport() const { + QRectF vp; + if (m_useCustomViewport) + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); + + if (!m_useCustomViewport && m_layoutHeight > 0) { + const qreal top = std::min(vp.y(), m_layoutHeight); + const qreal bottom = std::max(vp.y() + vp.height(), 0.0); + if (bottom > top) vp = QRectF(vp.x(), top, vp.width(), bottom - top); + } + + vp.adjust(0, -m_cacheBuffer, 0, m_cacheBuffer); + + if (m_layoutHeight > 0) { + const qreal top = std::max(vp.y(), 0.0); + const qreal bottom = std::min(vp.y() + vp.height(), m_layoutHeight); + if (top < bottom) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + else + return {}; + } + + return vp; +} + +std::pair ChatListView::computeVisibleRange() const { + if (m_layout.isEmpty()) return {-1, -1}; + + const auto vp = effectiveViewport(); + if (vp.isEmpty()) return {-1, -1}; + + const qreal vpTop = vp.y(); + const qreal vpBottom = vp.y() + vp.height(); + + int lo = 0; + int hi = static_cast(m_layout.size()) - 1; + int first = static_cast(m_layout.size()); + + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + const auto& record = m_layout[mid]; + const qreal itemBottom = + record.targetY + + (record.heightKnown ? record.height : effectiveEstimatedHeight()); + + if (itemBottom >= vpTop) { + first = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (first >= static_cast(m_layout.size())) return {-1, -1}; + + int last = first; + for (int i = first; i < static_cast(m_layout.size()); ++i) { + if (m_layout[i].targetY > vpBottom) break; + last = i; + } + + return {first, last}; +} + +void ChatListView::syncDelegates() { + const auto [first, last] = computeVisibleRange(); + + QSet visibleIndices; + if (first >= 0) { + for (int i = first; i <= last; ++i) + visibleIndices.insert(i); + } + + const auto vp = effectiveViewport(); + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it->measureOnly) continue; + if (visibleIndices.contains(it.key())) continue; + if (!it->item || vp.isEmpty()) { + toRemove.append(it.key()); + continue; + } + const qreal itemTop = it->item->y(); + const qreal itemBottom = itemTop + delegateVisibleHeight(it->item); + if (itemBottom < vp.top() || itemTop > vp.bottom()) + toRemove.append(it.key()); + } + + const int destroyBudget = m_asynchronous + ? ASYNC_BATCH_DESTROY + : static_cast(toRemove.size()); + QVector removedEntries; + removedEntries.reserve( + std::min(destroyBudget, static_cast(toRemove.size()))); + int destroyed = 0; + for (int idx : toRemove) { + if (destroyed >= destroyBudget) break; + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); + ++destroyed; + } + for (auto& entry : removedEntries) { + destroyDelegate(entry); + } + + QList toCreate; + if (first >= 0) { + for (int i = first; i <= last; ++i) { + auto existing = m_delegates.find(i); + if (existing != m_delegates.end()) { + if (existing->measureOnly) { + existing->measureOnly = false; + polish(); + } + continue; + } + toCreate.append(i); + } + } + + const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE + : static_cast(toCreate.size()); + int created = 0; + for (int i : toCreate) { + if (created >= createBudget) break; + + auto entry = createDelegate(i); + if (entry.item) { + entry.pendingInsert = true; + entry.item->setY(m_layout[i].targetY); + m_itemToIndex.insert(entry.item, i); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + if (created > 0 || + (m_asynchronous && (destroyed < static_cast(toRemove.size()) || + created < static_cast(toCreate.size())))) + polish(); +} + +bool ChatListView::isStaleRecord(const ItemRecord& record, qreal viewWidth) { + return record.heightKnown && record.measuredWidth >= 0 && + qAbs(record.measuredWidth - viewWidth) > 1.0; +} + +void ChatListView::buildMeasureQueue() { + m_measureQueue.clear(); + if (m_layout.isEmpty()) return; + + qreal center = 0; + const auto vp = effectiveViewport(); + if (!vp.isEmpty()) + center = vp.center().y(); + else if (m_layoutHeight > 0) + center = m_layoutHeight / 2; + + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + if (!isStaleRecord(m_layout[i], width())) continue; + if (m_delegates.contains(i)) continue; + m_measureQueue.append(i); + } + + const qreal c = center; + std::stable_sort( + m_measureQueue.begin(), m_measureQueue.end(), [this, c](int a, int b) { + return qAbs(m_layout[a].targetY - c) < + qAbs(m_layout[b].targetY - c); + }); +} + +void ChatListView::pumpMeasureQueue() { + const qint64 now = m_clock.elapsed(); + + QList done; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->measureOnly || !it->item) continue; + if (!isDelegateReady(it->item)) continue; + if (now - it->lastHeightChange < MEASURE_SETTLE_MS) continue; + done.append(it.key()); + } + for (int idx : done) { + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + destroyDelegate(entry); + } + + int live = 0; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); ++it) + if (it->measureOnly) ++live; + + bool created = false; + while (live < MEASURE_BATCH && !m_measureQueue.isEmpty()) { + const int idx = m_measureQueue.takeFirst(); + if (idx < 0 || idx >= static_cast(m_layout.size())) continue; + if (!isStaleRecord(m_layout[idx], width())) continue; + if (m_delegates.contains(idx)) continue; + + auto entry = createDelegate(idx); + if (!entry.item) continue; + + entry.measureOnly = true; + entry.pendingInsert = true; + entry.lastHeightChange = now; + entry.item->setY(m_layout[idx].targetY); + + m_itemToIndex.insert(entry.item, idx); + m_delegates.insert(idx, std::move(entry)); + ++live; + created = true; + } + + if (created || live > 0 || !m_measureQueue.isEmpty()) polish(); +} + +ChatListView::DelegateEntry ChatListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) return entry; + + const auto roleNames = m_model->roleNames(); + + auto* compContext = m_delegate->creationContext(); + if (!compContext) compContext = qmlContext(this); + if (!compContext) return entry; + + auto* obj = m_delegate->beginCreate(compContext); + entry.item = qobject_cast(obj); + + if (!entry.item) { + if (obj) m_delegate->completeCreate(); + delete obj; + return entry; + } + + const auto index = m_model->index(modelIndex, 0); + QVariantMap initialProps; + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + initialProps.insert(name, m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + initialProps.insert(QStringLiteral("index"), modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + initialProps.insert( + QStringLiteral("modelData"), m_model->data(index, role)); + } + + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + + if (modelIndex < static_cast(m_layout.size()) && + m_layout[modelIndex].isNew) { + auto* addingAttached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) addingAttached->setAdding(true); + } + + m_delegate->completeCreate(); + + entry.item->setVisible(false); + + auto onHeightChanged = [this, item = entry.item] { + if (!isDelegateReady(item)) return; + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto delegateIt = m_delegates.find(idx); + if (delegateIt == m_delegates.end() || delegateIt->item != item) return; + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && + !qFuzzyCompare(m_layout[idx].height + 1.0, h + 1.0)) { + const qreal oldH = m_layout[idx].height; + const bool wasKnown = m_layout[idx].heightKnown; + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + if (wasKnown) untrackHeight(oldH); + trackHeight(h); + if (delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (wasKnown) { + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att && att->trackViewport() && !m_anchorItem && + m_layout[idx].targetY < m_viewport.y()) { + bool suppressed = false; + if (m_restoreActive && idx == m_restoreIndex) { + if (m_clock.elapsed() < m_restoreQuietUntil) { + suppressed = true; + m_restoreQuietUntil = + m_clock.elapsed() + RESTORE_QUIET_MS; + } else { + m_restoreActive = false; + } + } + if (!suppressed) emit viewportAdjustNeeded(h - oldH); + } + } + + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } + } + }; + + connect( + entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + connect( + attached, + &ChatListViewAttached::preferredHeightChanged, + this, + onHeightChanged); + connect( + attached, + &ChatListViewAttached::visibleHeightChanged, + this, + [this] { polish(); }); + connect( + attached, + &ChatListViewAttached::readyChanged, + this, + [this, item = entry.item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + if (idx >= static_cast(m_layout.size())) return; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (!att || !att->ready()) return; + + const qreal h = delegateHeight(item); + const qreal oldLayoutH = m_layout[idx].heightKnown + ? m_layout[idx].height + : effectiveEstimatedHeight(); + const bool wasKnown = m_layout[idx].heightKnown; + if (wasKnown) untrackHeight(m_layout[idx].height); + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + trackHeight(h); + auto delegateIt = m_delegates.find(idx); + if (delegateIt != m_delegates.end() && delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (att->trackViewport() && !m_anchorItem && + !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0) && + m_layout[idx].targetY < m_viewport.y()) { + emit viewportAdjustNeeded(h - oldLayoutH); + } + + polish(); + }); + } + + return entry; +} + +void ChatListView::destroyDelegate(DelegateEntry& entry) { + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } +} + +void ChatListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model || !entry.item) return; + + const auto roleNames = m_model->roleNames(); + const auto index = m_model->index(entry.modelIndex, 0); + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + entry.item->setProperty( + name.toUtf8().constData(), m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + entry.item->setProperty("modelData", m_model->data(index, role)); + } +} + +void ChatListView::connectModel() { + if (!m_model) return; + + m_modelConnections = { + connect( + m_model, + &QAbstractItemModel::rowsInserted, + this, + &ChatListView::onRowsInserted), + connect( + m_model, + &QAbstractItemModel::rowsAboutToBeRemoved, + this, + &ChatListView::onRowsAboutToBeRemoved), + connect( + m_model, + &QAbstractItemModel::rowsRemoved, + this, + &ChatListView::onRowsRemoved), + connect( + m_model, + &QAbstractItemModel::dataChanged, + this, + &ChatListView::onDataChanged), + connect( + m_model, + &QAbstractItemModel::modelReset, + this, + &ChatListView::onModelReset), + connect( + m_model, + &QAbstractItemModel::layoutChanged, + this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), + connect( + m_model, + &QObject::destroyed, + this, + [this] { + m_model = nullptr; + resetContent(); + emit modelChanged(); + }), + }; +} + +void ChatListView::disconnectModel() { + for (auto& conn : m_modelConnections) + disconnect(conn); + m_modelConnections.clear(); +} + +void ChatListView::resetContent() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + m_delegates.clear(); + m_itemToIndex.clear(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + m_knownHeightSum = 0; + m_knownHeightCount = 0; + + m_layout.clear(); + m_measureQueue.clear(); + if (m_model && m_componentComplete) { + const int rows = m_model->rowCount(); + m_layout.resize(rows); + for (int i = 0; i < rows; ++i) { + m_layout[i].height = 0; + m_layout[i].heightKnown = false; + m_layout[i].measuredWidth = -1; + } + emit countChanged(); + } + + polish(); +} + +void ChatListView::onRowsInserted( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int insertCount = last - first + 1; + m_layout.insert(first, insertCount, ItemRecord{0, 0, false, true}); + for (int& i : m_measureQueue) + if (i >= first) i += insertCount; + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onRowsAboutToBeRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) continue; + + auto entry = m_delegates.take(i); + if (entry.item) m_itemToIndex.remove(entry.item); + entry.pendingRemoval = true; + + if (entry.pendingInsert) { + destroyDelegate(entry); + continue; + } + + if (m_removeDuration > 0 && entry.item) { + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) attached->setRemoving(true); + + auto* item = entry.item; + QTimer::singleShot(m_removeDuration, this, [this, item] { + for (auto it = m_dyingDelegates.begin(); + it != m_dyingDelegates.end(); + ++it) { + if (it->item == item) { + destroyDelegate(*it); + m_dyingDelegates.erase(it); + return; + } + } + }); + m_dyingDelegates.append(std::move(entry)); + } else { + destroyDelegate(entry); + } + } +} + +void ChatListView::onRowsRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int removeCount = last - first + 1; + + for (int i = first; i <= last; ++i) { + if (m_layout[i].heightKnown) untrackHeight(m_layout[i].height); + } + + QVector queue; + queue.reserve(m_measureQueue.size()); + for (int i : m_measureQueue) { + if (i >= first && i <= last) continue; + if (i > last) i -= removeCount; + queue.append(i); + } + m_measureQueue = std::move(queue); + + m_layout.remove(first, removeCount); + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() > last ? it.key() - removeCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles) { + Q_UNUSED(roles) + + if (topLeft.parent().isValid()) return; + + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + if (m_delegates.contains(i)) updateDelegateData(m_delegates[i]); + } +} + +void ChatListView::onModelReset() { + if (!m_model) { + resetContent(); + return; + } + + const int newRows = m_model->rowCount(); + const int oldRows = static_cast(m_layout.size()); + + if (newRows == oldRows) { + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + bool changed = false; + + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->item || it.key() >= newRows) { + changed = true; + break; + } + const auto newData = + m_model->data(m_model->index(it.key(), 0), role); + const auto oldData = it->item->property("modelData"); + if (newData != oldData) { + changed = true; + break; + } + } + + if (!changed) { + for (auto& entry : m_delegates) + updateDelegateData(entry); + return; + } + } + + resetContent(); + if (!qFuzzyIsNull(m_viewport.y())) { + emit viewportAdjustNeeded(-m_viewport.y()); + } +} + +qreal ChatListView::effectiveEstimatedHeight() const { + if (m_estimatedHeight >= 0) return m_estimatedHeight; + if (m_knownHeightCount > 0) return m_knownHeightSum / m_knownHeightCount; + return 40; +} + +void ChatListView::trackHeight(qreal height) { + m_knownHeightSum += height; + ++m_knownHeightCount; +} + +void ChatListView::untrackHeight(qreal height) { + m_knownHeightSum -= height; + --m_knownHeightCount; +} + +qreal ChatListView::delegateHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached && attached->preferredHeight() >= 0) + return attached->preferredHeight(); + + return item->implicitHeight(); +} + +qreal ChatListView::delegateVisibleHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached) { + if (attached->visibleHeight() >= 0) return attached->visibleHeight(); + if (attached->preferredHeight() >= 0) + return attached->preferredHeight(); + } + + return item->implicitHeight(); +} + +bool ChatListView::isDelegateReady(QQuickItem* item) { + if (!item) return false; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + return !att || att->ready(); +} + +void ChatListView::anchorItem(QQuickItem* item, Edge edge) { + if (!item) return; + + if (m_anchorItem == item) { + if (m_anchorEdge != edge) { + m_anchorEdge = edge; + applyAnchor(); + } + return; + } + + m_anchorItem = item; + m_anchorEdge = edge; + connect(item, &QObject::destroyed, this, [this, item] { + if (m_anchorItem == item) clearAnchor(); + }); + applyAnchor(); +} + +void ChatListView::clearAnchor() { + if (!m_anchorItem) return; + for (auto* it = m_anchorItem.data(); it; it = it->parentItem()) { + auto delegateIt = m_itemToIndex.find(it); + if (delegateIt != m_itemToIndex.end()) { + m_restoreIndex = delegateIt.value(); + break; + } + } + m_anchorItem = nullptr; +} + +void ChatListView::requestRestore(qreal targetY) { + m_restoreActive = true; + m_restoreQuietUntil = m_clock.elapsed() + RESTORE_QUIET_MS; + const qreal target = + qBound(0.0, targetY, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(target + 1.0, m_viewport.y() + 1.0)) { + emit viewportRestoreRequested(target); + } +} + +void ChatListView::applyAnchor() { + const auto item = m_anchorItem; + if (!item || m_viewport.isNull()) return; + + const qreal a = item->mapToItem(this, 0, 0).y(); + const qreal b = item->mapToItem(this, 0, item->height()).y(); + const qreal near = std::min(a, b); + const qreal far = std::max(a, b); + + const qreal vpTop = m_viewport.y(); + const qreal vpBottom = m_viewport.y() + m_viewport.height(); + + qreal next = -1; + if (m_anchorEdge == TopEdge) { + if (far > vpBottom) next = far - m_viewport.height(); + } else { + if (near < vpTop) next = near; + } + if (next < 0) return; + + next = qBound(0.0, next, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(next + 1.0, vpTop + 1.0)) { + emit viewportAdjustNeeded(next - vpTop); + } +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/chatlistview.hpp b/Plugins/ZShell/Components/chatlistview.hpp new file mode 100644 index 0000000..40c8f3c --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.hpp @@ -0,0 +1,299 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class ChatListViewAttached : public QObject { + Q_OBJECT + + Q_PROPERTY( + qreal preferredHeight READ preferredHeight WRITE setPreferredHeight + NOTIFY preferredHeightChanged) + Q_PROPERTY( + qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY + visibleHeightChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) + Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) + Q_PROPERTY( + bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY + trackViewportChanged) + + public: + explicit ChatListViewAttached(QObject* parent = nullptr); + + [[nodiscard]] qreal preferredHeight() const; + void setPreferredHeight(qreal height); + + [[nodiscard]] qreal visibleHeight() const; + void setVisibleHeight(qreal height); + + [[nodiscard]] bool ready() const; + void setReady(bool ready); + + [[nodiscard]] bool adding() const; + void setAdding(bool adding); + + [[nodiscard]] bool removing() const; + void setRemoving(bool removing); + + [[nodiscard]] bool trackViewport() const; + void setTrackViewport(bool track); + + signals: + void preferredHeightChanged(); + void visibleHeightChanged(); + void readyChanged(); + void addingChanged(); + void removingChanged(); + void trackViewportChanged(); + + private: + qreal m_preferredHeight = -1; + qreal m_visibleHeight = -1; + bool m_ready = false; + bool m_adding = false; + bool m_removing = false; + bool m_trackViewport = false; +}; + +class ChatListView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + QML_ATTACHED(ChatListViewAttached) + + public: + Q_PROPERTY( + QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged) + + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY( + qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) + Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged) + Q_PROPERTY( + qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged) + + Q_PROPERTY( + QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged) + Q_PROPERTY( + bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport + NOTIFY useCustomViewportChanged) + Q_PROPERTY( + qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY + cacheBufferChanged) + + Q_PROPERTY( + qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight + NOTIFY estimatedHeightChanged) + + Q_PROPERTY( + bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY + asynchronousChanged) + + Q_PROPERTY( + int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY + removeDurationChanged) + Q_PROPERTY( + int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY + readyDelayChanged) + Q_PROPERTY( + int yAnimDuration READ yAnimDuration WRITE setYAnimDuration NOTIFY + yAnimDurationChanged) + + Q_PROPERTY(int count READ count NOTIFY countChanged) + + enum Edge { + TopEdge, + BottomEdge, + }; + Q_ENUM(Edge) + + Q_INVOKABLE void anchorItem(QQuickItem* item, Edge edge); + Q_INVOKABLE void clearAnchor(); + Q_INVOKABLE void requestRestore(qreal targetY); + + explicit ChatListView(QQuickItem* parent = nullptr); + ~ChatListView() override; + + static ChatListViewAttached* qmlAttachedProperties(QObject* object); + + [[nodiscard]] QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] qreal contentHeight() const; + [[nodiscard]] qreal layoutHeight() const; + + [[nodiscard]] qreal contentY() const; + void setContentY(qreal contentY); + + [[nodiscard]] QRectF viewport() const; + void setViewport(const QRectF& viewport); + + [[nodiscard]] bool useCustomViewport() const; + void setUseCustomViewport(bool use); + + [[nodiscard]] qreal cacheBuffer() const; + void setCacheBuffer(qreal buffer); + + [[nodiscard]] qreal estimatedHeight() const; + void setEstimatedHeight(qreal height); + + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool async); + + [[nodiscard]] int removeDuration() const; + void setRemoveDuration(int duration); + + [[nodiscard]] int readyDelay() const; + void setReadyDelay(int delay); + + [[nodiscard]] int yAnimDuration() const; + void setYAnimDuration(int duration); + + [[nodiscard]] int count() const; + + signals: + void modelChanged(); + void delegateChanged(); + void spacingChanged(); + void contentHeightChanged(); + void layoutHeightChanged(); + void contentYChanged(); + void viewportChanged(); + void useCustomViewportChanged(); + void cacheBufferChanged(); + void estimatedHeightChanged(); + void asynchronousChanged(); + void removeDurationChanged(); + void readyDelayChanged(); + void yAnimDurationChanged(); + void countChanged(); + void viewportAdjustNeeded(qreal delta); + void viewportRestoreRequested(qreal targetY); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + bool event(QEvent* e) override; + + private: + struct ItemRecord { + qreal targetY = 0; + qreal height = 0; + bool heightKnown = false; + bool isNew = false; + qreal measuredWidth = -1; + }; + + struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + bool pendingRemoval = false; + bool pendingInsert = false; + bool readyDelayStarted = false; + bool measureOnly = false; + qreal propY = 0; + bool hasPropY = false; + qint64 lastPropTime = 0; + qint64 lastHeightChange = 0; + }; + + void relayout(); + [[nodiscard]] std::pair computeVisibleRange() const; + [[nodiscard]] QRectF effectiveViewport() const; + [[nodiscard]] qreal effectiveEstimatedHeight() const; + [[nodiscard]] static bool isStaleRecord( + const ItemRecord& record, qreal viewWidth); + [[nodiscard]] static qreal delegateHeight(QQuickItem* item); + [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); + [[nodiscard]] static bool isDelegateReady(QQuickItem* item); + void trackHeight(qreal height); + void untrackHeight(qreal height); + + void syncDelegates(); + void pumpMeasureQueue(); + void buildMeasureQueue(); + DelegateEntry createDelegate(int modelIndex); + void destroyDelegate(DelegateEntry& entry); + void updateDelegateData(DelegateEntry& entry); + + void connectModel(); + void disconnectModel(); + void resetContent(); + void onRowsInserted(const QModelIndex& parent, int first, int last); + void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void onRowsRemoved(const QModelIndex& parent, int first, int last); + void onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles); + void onModelReset(); + + void applyAnchor(); + + QAbstractItemModel* m_model = nullptr; + QQmlComponent* m_delegate = nullptr; + + qreal m_spacing = 0; + qreal m_contentHeight = 0; + qreal m_layoutHeight = 0; + qreal m_contentY = 0; + + QRectF m_viewport; + bool m_useCustomViewport = false; + qreal m_cacheBuffer = 0; + + qreal m_estimatedHeight = -1; + qreal m_knownHeightSum = 0; + int m_knownHeightCount = 0; + bool m_asynchronous = false; + + int m_removeDuration = 300; + int m_readyDelay = 0; + int m_yAnimDuration = 300; + QElapsedTimer m_clock; + + QVector m_layout; + QHash m_delegates; + QHash m_itemToIndex; + QVector m_dyingDelegates; + + QVector m_measureQueue; + QTimer* m_measureTimer = nullptr; + + bool m_componentComplete = false; + bool m_relayoutPending = false; + bool m_animateY = false; + + QPointer m_anchorItem; + Edge m_anchorEdge = TopEdge; + int m_restoreIndex = -1; + bool m_restoreActive = false; + qint64 m_restoreQuietUntil = 0; + + QList m_modelConnections; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp index 8d51704..97b4dd5 100644 --- a/Plugins/ZShell/Components/sparklinecontent.cpp +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -50,8 +50,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) { void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { if (!buffer) return; - // New data, or the buffer's own smoothingRadius changed (it re-emits - // valuesChanged in that case too) -> geometry is stale. connect( buffer, &internal::CircularBuffer::valuesChanged, @@ -67,7 +65,7 @@ void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { void SparklineContent::setLine1Color(const QColor& color) { if (m_line1Color == color) return; m_line1Color = color; - update(); // visual-only, no geometry rebuild needed + update(); } void SparklineContent::setLine2Color(const QColor& color) { @@ -173,7 +171,7 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry( if (m_curveSmoothing && points.size() > 2) { const qreal tension = 1.0; const qreal k = tension / 6.0; - const int n = points.size(); + const int n = static_cast(points.size()); for (int i = 0; i < n - 1; ++i) { const QPointF& p0 = points[qMax(i - 1, 0)]; diff --git a/Plugins/ZShell/Components/sparklinesceneitem.cpp b/Plugins/ZShell/Components/sparklinesceneitem.cpp index 42170e3..eedac27 100644 --- a/Plugins/ZShell/Components/sparklinesceneitem.cpp +++ b/Plugins/ZShell/Components/sparklinesceneitem.cpp @@ -84,7 +84,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( points.reserve(len); for (int i = 0; i < len; ++i) { - // Already smoothed once, incrementally, by the buffer itself. const qreal value = qBound(0.0, buffer->smoothedAt(i), m_maxValue); @@ -99,7 +98,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( if (m_curveSmoothing && points.size() > 2) { const qreal tension = 1.0; const qreal k = tension / 6.0; - const int n = points.size(); + const int n = static_cast(points.size()); for (int i = 0; i < n - 1; ++i) { const QPointF& p0 = points[qMax(i - 1, 0)]; const QPointF& p1 = points[i]; @@ -125,9 +124,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( } } - // Flatten the curve to a polyline once, here, on data change only. - // This is CPU math only -- no QPainter draw calls, no image, no - // texture upload happen as a result of this. const auto subpaths = path.toSubpathPolygons(); if (subpaths.isEmpty()) return sample; @@ -139,7 +135,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( void SparklineSceneItem::writeFillGeometry( QSGGeometryNode* node, const LineSample& sample) { - const int n = sample.polyline.size(); + const int n = static_cast(sample.polyline.size()); auto* geometry = node->geometry(); geometry->allocate(n * 2); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); @@ -147,8 +143,10 @@ void SparklineSceneItem::writeFillGeometry( auto* vertices = geometry->vertexDataAsPoint2D(); for (int i = 0; i < n; ++i) { const QPointF& p = sample.polyline[i]; - vertices[i * 2].set(p.x(), p.y()); - vertices[i * 2 + 1].set(p.x(), sample.fillBottom); + vertices[i * 2].set( + static_cast(p.x()), static_cast(p.y())); + vertices[i * 2 + 1].set( + static_cast(p.x()), static_cast(sample.fillBottom)); } node->markDirty(QSGNode::DirtyGeometry); @@ -156,7 +154,7 @@ void SparklineSceneItem::writeFillGeometry( void SparklineSceneItem::writeStrokeGeometry( QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { - const int n = sample.polyline.size(); + const int n = static_cast(sample.polyline.size()); auto* geometry = node->geometry(); geometry->allocate(n * 2); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); @@ -179,8 +177,12 @@ void SparklineSceneItem::writeStrokeGeometry( const QPointF& p = sample.polyline[i]; const QPointF offset = normal * half; - vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y()); - vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y()); + vertices[i * 2].set( + static_cast(p.x() + offset.x()), + static_cast(p.y() + offset.y())); + vertices[i * 2 + 1].set( + static_cast(p.x() - offset.x()), + static_cast(p.y() - offset.y())); } node->markDirty(QSGNode::DirtyGeometry); @@ -243,7 +245,7 @@ QSGNode* SparklineSceneItem::updatePaintNode( } QMatrix4x4 matrix; - matrix.translate(-stepX() * m_slideProgress, 0); + matrix.translate(static_cast(-stepX() * m_slideProgress), 0.0f); transformNode->setMatrix(matrix); const bool needsGeometryWrite = samplesRebuilt || justCreated; diff --git a/Plugins/ZShell/Components/wavyline.hpp b/Plugins/ZShell/Components/wavyline.hpp index 5ce3b8e..4025664 100644 --- a/Plugins/ZShell/Components/wavyline.hpp +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -85,7 +85,7 @@ class WavyLine : public QCanvasPainterItem { [[nodiscard]] qreal value() const; void setValue(qreal value); -signals: + signals: void lineWidthChanged(); void amplitudeMultiplierChanged(); void frequencyChanged(); 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 d17da2a..5b11d2f 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -20,6 +20,7 @@ qml_module(ZShell-config enums.hpp general.hpp launcher.hpp + llm.hpp lock.hpp notifs.hpp osd.hpp diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp new file mode 100644 index 0000000..aabd24e --- /dev/null +++ b/Plugins/ZShell/Config/llm.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include "common.hpp" + +namespace ZShell::config { + +class LlmAppearance : public settings::ObjectNode { + CONFIG_NODE(LlmAppearance, settings::ObjectNode) + + CONFIG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight")) +}; + +class Llm : public settings::ObjectNode { + CONFIG_NODE(Llm, settings::ObjectNode) + + CONFIG_PROPERTY(QString, endpoint, "http://localhost:8080") + CONFIG_PROPERTY(QString, model, "") + CONFIG_PROPERTY(double, temperature, 0.7) + CONFIG_PROPERTY(bool, tools, true) + CONFIG_PROPERTY(bool, sandbox, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_SUBOBJECT(LlmAppearance, appearance) +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp index bbbe3c5..308e90a 100644 --- a/Plugins/ZShell/Config/rootnodes.hpp +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -16,6 +16,7 @@ #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" +#include "llm.hpp" #include "lock.hpp" #include "notifs.hpp" #include "osd.hpp" @@ -42,6 +43,7 @@ class ConfigRoot : public settings::RootNode { 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) diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp index 39953e9..8e8f2e4 100644 --- a/Plugins/ZShell/HyprPlugins/main.cpp +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -37,12 +37,10 @@ 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_RUNTIME_DIR")) g_runtimeDir = env; if (const char* env = getenv("XDG_CACHE_HOME")) { - if (*env) - g_cacheBase = env; + if (*env) g_cacheBase = env; } else if (const char* home = getenv("HOME")) { g_cacheBase = std::filesystem::path(home) / ".cache"; } @@ -50,8 +48,7 @@ static void initEnvPaths() { static std::ofstream& debugLog() { static std::ofstream log( - g_runtimeDir / "zshell-nightlight-debug.log", - std::ios::app); + g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app); return log; } @@ -173,15 +170,14 @@ static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { static void installShader() { auto* gl = g_pHyprOpenGL.get(); - if (!gl) - return; + 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) { + shader->getUniformLocation(SHADER_TINT) != -1) { g_uniformMode = true; } else { g_uniformMode = false; @@ -196,8 +192,7 @@ static void installShader() { static void setUniformGain(const std::array& gain) { auto* gl = g_pHyprOpenGL.get(); auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; - if (!gl || !shader) - return; + if (!gl || !shader) return; gl->useShader(gl->m_finalScreenShader); shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); @@ -214,8 +209,7 @@ static void hkBegin( if (fb) { auto* renderer = g_pHyprRenderer.get(); - if (renderer) - renderer->m_renderData.blockScreenShader = true; + if (renderer) renderer->m_renderData.blockScreenShader = true; } if (!g_active) return; @@ -223,17 +217,15 @@ static void hkBegin( auto* gl = g_pHyprOpenGL.get(); if (!gl) return; - if (gl->m_finalScreenShader.get() && - gl->m_finalScreenShader->program() < 1) + if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1) installShader(); if (!gl->m_finalScreenShader.get() || - gl->m_finalScreenShader->program() < 1) + gl->m_finalScreenShader->program() < 1) return; if (g_transition.active) { auto* monPtr = mon.get(); - if (!monPtr) - return; + if (!monPtr) return; const float elapsed = std::chrono::duration( std::chrono::steady_clock::now() - g_transition.start) @@ -255,9 +247,9 @@ static void hkBegin( ensureShaderFile( bakedShaderPath(g_currentGain), bakedShaderSource(g_currentGain)); - gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } } - } if (t >= 1.f) { g_transition.active = false; diff --git a/Plugins/ZShell/Internal/hyprextras.cpp b/Plugins/ZShell/Internal/hyprextras.cpp index fd28611..3baf4dc 100644 --- a/Plugins/ZShell/Internal/hyprextras.cpp +++ b/Plugins/ZShell/Internal/hyprextras.cpp @@ -182,10 +182,10 @@ static QString buildHlConfigCall(const QString& key, const QVariant& value) { } static QColor colorFromInt(quint32 value) { - const int a = (value >> 24) & 0xFF; - const int r = (value >> 16) & 0xFF; - const int g = (value >> 8) & 0xFF; - const int b = value & 0xFF; + const int a = static_cast(value >> 24) & 0xFF; + const int r = static_cast(value >> 16) & 0xFF; + const int g = static_cast(value >> 8) & 0xFF; + const int b = static_cast(value) & 0xFF; return QColor(r, g, b, a); } diff --git a/Plugins/ZShell/Internal/strokecanvasitem.cpp b/Plugins/ZShell/Internal/strokecanvasitem.cpp index dcb7e47..50bc676 100644 --- a/Plugins/ZShell/Internal/strokecanvasitem.cpp +++ b/Plugins/ZShell/Internal/strokecanvasitem.cpp @@ -72,7 +72,7 @@ static QCanvasPath buildStrokePath( void StrokeCanvasItem::undoLastGroup() { if (m_strokes.isEmpty()) return; const int lastGroup = m_strokes.last().groupId; - for (int i = m_strokes.size() - 1; + for (int i = static_cast(m_strokes.size()) - 1; i >= 0 && m_strokes[i].groupId == lastGroup; --i) m_strokes.remove(i); diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp index c109728..11b2d08 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -154,7 +154,7 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { for (const auto& stroke : canvas->m_strokes) canvasGroupIds.insert(stroke.groupId); - for (int i = m_strokes.size() - 1; i >= 0; --i) { + for (int i = static_cast(m_strokes.size()) - 1; i >= 0; --i) { if (!canvasGroupIds.contains(m_strokes[i].groupId)) { if (m_strokes[i].groupId >= 0) m_pendingGroupRemovals.append(m_strokes[i].groupId); diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt new file mode 100644 index 0000000..5c218f6 --- /dev/null +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -0,0 +1,265 @@ +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) + +pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter) + +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}") + +function(_ts_entry_point out 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") + 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() + +function(_ts_expand_inherits query_dir text outVar) + set(result "${text}") + set(_visited "") + set(_depth 0) + while(_depth LESS 8) + 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) + 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 "") +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}") + + set(_ts_qfiles "") + set(_ts_qhashes "") + if(_ts_id STREQUAL "cpp") + 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") + 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) + 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() + + 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") + + 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) + string(APPEND _hl_header "") + 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}") + +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 + attachment.hpp attachment.cpp + chatstore.hpp chatstore.cpp + clipboard.hpp clipboard.cpp + codehighlighter.hpp codehighlighter.cpp + filetool.hpp filetool.cpp + generation.hpp generation.cpp + llmclient.hpp llmclient.cpp + markdownblock.hpp markdownblock.cpp + markdownparser.hpp markdownparser.cpp + mathtext.hpp mathtext.cpp + message.hpp message.cpp + messagemodel.hpp messagemodel.cpp + prompts.hpp prompts.cpp + segment.hpp segment.cpp + session.hpp session.cpp + shelltool.hpp shelltool.cpp + tool.hpp tool.cpp + turn.hpp turn.cpp + webfetchtool.hpp webfetchtool.cpp + workspace.hpp workspace.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}) diff --git a/Plugins/ZShell/Llm/attachment.cpp b/Plugins/ZShell/Llm/attachment.cpp new file mode 100644 index 0000000..fca66d9 --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.cpp @@ -0,0 +1,19 @@ +#include "attachment.hpp" + +namespace ZShell::llm { + +Attachment::Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent) + : QObject(parent) + , m_name(name) + , m_path(path) + , m_kind(kind) + , m_mimeType(mimeType) + , m_size(size) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/attachment.hpp b/Plugins/ZShell/Llm/attachment.hpp new file mode 100644 index 0000000..e3c02cd --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::llm { + +class Attachment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments") + + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(Kind kind READ kind CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(QString mimeType READ mimeType CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + + public: + enum class Kind : int { Image = 0, File = 1 }; + Q_ENUM(Kind) + + Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent = nullptr); + + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] Kind kind() const { return m_kind; } + [[nodiscard]] bool isImage() const { return m_kind == Kind::Image; } + [[nodiscard]] QString mimeType() const { return m_mimeType; } + [[nodiscard]] qint64 size() const { return m_size; } + + private: + QString m_name; + QString m_path; + Kind m_kind; + QString m_mimeType; + qint64 m_size = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp new file mode 100644 index 0000000..2feac92 --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -0,0 +1,805 @@ +#include "chatstore.hpp" + +#include "attachment.hpp" +#include "llmclient.hpp" +#include "markdownblock.hpp" +#include "message.hpp" +#include "segment.hpp" + +#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 AttachmentRow { + QString name; + QString path; + int kind = 1; + QString mime; + qint64 size = 0; +}; + +struct MessageRow { + bool user = false; + qint64 timestamp = 0; + QString content; + QVector generations; + QVector attachments; +}; + +} // namespace + +ChatStore::ChatStore(LlmClient* client, QObject* parent) + : QAbstractListModel(parent) + , m_client(client) + , m_connectionName(QUuid::createUuid().toString()) { + openDb(); + load(); +} + +ChatStore::~ChatStore() { + if (m_connectionName.isEmpty()) return; + QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false); + handle.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 handle = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + handle.setDatabaseName(m_dbPath); + if (!handle.open()) { + qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" + << handle.lastError().text(); + return; + } + { + QSqlQuery pragma(handle); + pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); + } + { + QSqlQuery query(handle); + 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(handle); + 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" + " content TEXT NOT NULL DEFAULT '',\n" + " timestamp INTEGER NOT NULL\n" + ")")); + if (!query.exec(QStringLiteral( + "ALTER TABLE messages ADD COLUMN content TEXT " + "NOT NULL DEFAULT ''"))) { + } + } + { + QSqlQuery query(handle); + 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(handle); + 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(handle); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS attachments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " name TEXT NOT NULL,\n" + " path TEXT NOT NULL,\n" + " kind INTEGER NOT NULL DEFAULT 1,\n" + " mime TEXT NOT NULL DEFAULT '',\n" + " size INTEGER NOT NULL DEFAULT 0\n" + ")")); + } + { + QSqlQuery query(handle); + 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)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_attachments_message " + "ON attachments (message_id)")); + } +} + +int ChatStore::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(m_sessions.size()); +} + +QVariant ChatStore::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_sessions.size()) + return {}; + return QVariant::fromValue(m_sessions.at(index.row())); +} + +QHash ChatStore::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} + +int ChatStore::rowOf(const ChatSession* session) const { + for (int i = 0; i < m_sessions.size(); ++i) + if (m_sessions.at(i) == session) return i; + return -1; +} + +bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) return a->pinned(); + return a->updatedAtMs() > b->updatedAtMs(); +} + +int ChatStore::sortedRow(const ChatSession* session) const { + int row = 0; + while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row))) + ++row; + return row; +} + +void ChatStore::insertRow(ChatSession* session) { + const int row = sortedRow(session); + beginInsertRows(QModelIndex(), row, row); + m_sessions.insert(row, session); + endInsertRows(); +} + +void ChatStore::moveRow(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to > m_sessions.size() || from == to || to == from + 1) + return; + if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return; + m_sessions.move(from, to > from ? to - 1 : to); + endMoveRows(); +} + +void ChatStore::reposition(ChatSession* session) { + if (m_loading) return; + const int row = rowOf(session); + if (row < 0) return; + const int target = sortedRow(session); + if (target == row) return; + moveRow(row, target); +} + +ChatSession* ChatStore::draft() { + if (m_draft) return m_draft; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + m_draft = new ChatSession(this, QUuid::createUuid().toString(), this); + m_draft->hydrate(QString(), QString(), now, now, 0, false); + return m_draft; +} + +void ChatStore::commitDraft(ChatSession* session) { + if (!session || session->isCommitted()) return; + if (m_draft == session) m_draft = nullptr; + + if (!writeSessionRow(session)) return; + session->setCommitted(true); + insertRow(session); +} + +bool ChatStore::writeSessionRow(ChatSession* session) { + QSqlQuery query(db()); + query.prepare( + "INSERT INTO sessions (id, title, icon, created_at, updated_at, " + "pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, " + ":pinned)"); + query.bindValue(":id", session->id()); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":created_at", session->createdAtMs()); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); + if (!query.exec()) { + qWarning() << "ChatStore: failed to insert session" << session->id() + << ":" << query.lastError().text(); + return false; + } + return true; +} + +void ChatStore::removeRow(ChatSession* session) { + if (!session) return; + const int row = rowOf(session); + + Q_EMIT sessionRemoved(session); + + { + QSqlQuery query(db()); + query.prepare("DELETE FROM sessions WHERE id = :id"); + query.bindValue(":id", session->id()); + query.exec(); + } + + if (row >= 0) { + beginRemoveRows(QModelIndex(), row, row); + m_sessions.removeAt(row); + endRemoveRows(); + } + session->deleteLater(); +} + +void ChatStore::remove(ChatSession* chat) { + if (!chat) return; + m_pendingPersists.remove(chat); + m_pendingClears.remove(chat); + if (m_draft == chat) m_draft = nullptr; + + if (!chat->isCommitted()) { + Q_EMIT sessionRemoved(chat); + chat->deleteLater(); + return; + } + + removeRow(chat); +} + +void ChatStore::clear() { + const QList sessions = m_sessions; + for (ChatSession* session : sessions) + remove(session); +} + +void ChatStore::requestClear(ChatSession* session) { + if (!session) return; + m_pendingClears.insert(session); + if (session->isLoaded() && m_client->streamingSession() != session) + applyPendingClear(session); +} + +void ChatStore::applyPendingClear(ChatSession* session) { + if (!session || !m_pendingClears.remove(session)) return; + if (!session->isLoaded()) return; + session->clearMessages(); + persist(session); +} + +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; + reposition(session); +} + +void ChatStore::saveFlags(ChatSession* session) { + if (!session || !m_sessions.contains(session)) return; + QSqlQuery query(db()); + query.prepare( + "UPDATE sessions SET title = :title, icon = :icon, " + "pinned = :pinned WHERE id = :id"); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":pinned", session->pinned() ? 1 : 0); + query.bindValue(":id", session->id()); + if (!query.exec()) + qWarning() << "ChatStore: failed to save flags for" << session->id() + << ":" << query.lastError().text(); + reposition(session); +} + +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, icon = :icon, " + "updated_at = :updated_at, pinned = :pinned WHERE id = :id"); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); + 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, content, timestamp) " + "VALUES (:id, :role, :content, :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)"); + QSqlQuery attachmentInsert(handle); + ok = ok && attachmentInsert.prepare( + "INSERT INTO attachments (message_id, name, path, kind, " + "mime, size) VALUES (:mid, :name, :path, :kind, :mime, " + ":size)"); + 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(":content", sqlText(message->content())); + 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())); + const auto* text = + qobject_cast(segment); + segmentInsert.bindValue( + ":text", sqlText(text ? text->text() : QString())); + const auto* call = + qobject_cast(segment); + segmentInsert.bindValue( + ":name", sqlText(call ? call->name() : QString())); + segmentInsert.bindValue( + ":tool_call_id", + sqlText(call ? call->toolCallId() : QString())); + segmentInsert.bindValue( + ":arguments", + sqlText(call ? call->arguments() : QString())); + segmentInsert.bindValue( + ":result", sqlText(call ? call->result() : QString())); + segmentInsert.bindValue( + ":status", call ? static_cast(call->status()) : 0); + 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) + for (const auto* attachment : message->attachments()) { + attachmentInsert.bindValue(":mid", messageId); + attachmentInsert.bindValue( + ":name", sqlText(attachment->name())); + attachmentInsert.bindValue( + ":path", sqlText(attachment->path())); + attachmentInsert.bindValue( + ":kind", static_cast(attachment->kind())); + attachmentInsert.bindValue( + ":mime", sqlText(attachment->mimeType())); + attachmentInsert.bindValue(":size", attachment->size()); + if (!attachmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "attachment insert failed:" + << attachmentInsert.lastError().text(); + break; + } + } + } + } + if (!ok || !handle.commit()) { + qWarning() << "ChatStore: saveSession" << id + << "commit failed, rolling back"; + handle.rollback(); + qWarning() << "ChatStore: failed to save session" << session->id() + << ":" << handle.lastError().text(); + return false; + } + return true; +} + +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 handle = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + handle.setDatabaseName(path); + if (handle.open()) { + QSqlQuery busy(handle); + busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); + QSqlQuery query(handle); + query.prepare( + "SELECT id, role, content, 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.content = query.value(2).toString(); + message.timestamp = query.value(3).toLongLong(); + QSqlQuery generationQuery(handle); + 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(handle); + 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(); + } + QSqlQuery attachmentQuery(handle); + attachmentQuery.prepare( + "SELECT name, path, kind, mime, size FROM " + "attachments WHERE message_id = :mid " + "ORDER BY rowid"); + attachmentQuery.bindValue(":mid", messageId); + if (attachmentQuery.exec()) { + while (attachmentQuery.next()) { + AttachmentRow attachment; + attachment.name = + attachmentQuery.value(0).toString(); + attachment.path = + attachmentQuery.value(1).toString(); + attachment.kind = + attachmentQuery.value(2).toInt(); + attachment.mime = + attachmentQuery.value(3).toString(); + attachment.size = + attachmentQuery.value(4).toLongLong(); + message.attachments.append(attachment); + } + } else { + qWarning() + << "ChatStore: failed to load attachments " + "for message" + << messageId << ":" + << attachmentQuery.lastError().text(); + } + rows.append(message); + } + } + handle.close(); + } else { + qWarning() << "ChatStore: failed to open database for load:" + << handle.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->messagesModel(); + if (!model) return; + QList messages; + for (const MessageRow& row : rows) { + auto* message = model->createMessage( + row.user ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + row.timestamp); + + if (!row.attachments.isEmpty()) { + QList attachments; + for (const AttachmentRow& attachmentRow : + row.attachments) { + attachments.append(new Attachment( + attachmentRow.name, + attachmentRow.path, + attachmentRow.kind == 0 + ? Attachment::Kind::Image + : Attachment::Kind::File, + attachmentRow.mime, + attachmentRow.size, + message)); + } + message->setAttachments(attachments); + } + + if (row.user) { + QString content = row.content; + if (content.isEmpty() && !row.generations.isEmpty()) { + for (const SegmentRow& segment : + row.generations.first().segments) + if (segmentTypeFromName(segment.type) == + LlmSegment::Type::Content) { + content = segment.text; + break; + } + } + if (!content.isEmpty()) message->setContent(content); + messages.append(message); + continue; + } + + 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); + if (!generation) continue; + for (const SegmentRow& segmentRow : + generationRow.segments) { + const LlmSegment::Type type = + segmentTypeFromName(segmentRow.type); + LlmSegment* segment = nullptr; + if (type == LlmSegment::Type::ToolCall) { + auto* call = new LlmToolCall( + segmentRow.timestamp, generation); + call->setName(segmentRow.name); + call->setToolCallId(segmentRow.toolCallId); + call->appendArguments(segmentRow.arguments); + call->setResult(segmentRow.result); + call->setStatus( + static_cast( + segmentRow.status)); + call->restore(segmentRow.elapsedMs); + segment = call; + } else { + auto* text = new LlmTextSegment( + type, segmentRow.timestamp, generation); + text->setText(segmentRow.text); + text->restore(segmentRow.elapsedMs); + segment = text; + } + 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(); + + st->applyPendingClear(s); + if (st->m_pendingPersists.remove(s)) st->persist(s); + }, + Qt::QueuedConnection); + }); +} + +void ChatStore::load() { + m_loading = true; + 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(this, query.value(0).toString(), this); + session->hydrate( + query.value(1).toString(), + query.value(2).toString(), + query.value(3).toLongLong(), + query.value(4).toLongLong(), + query.value(6).toInt(), + query.value(5).toInt() != 0); + m_sessions.append(session); + } + m_loading = false; + + beginResetModel(); + endResetModel(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp new file mode 100644 index 0000000..25b901b --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "session.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class LlmClient; + +class ChatStore : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") + + public: + explicit ChatStore(LlmClient* client, QObject* parent = nullptr); + ~ChatStore() 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]] LlmClient* client() const { return m_client; } + + [[nodiscard]] int rowOf(const ChatSession* session) const; + + Q_INVOKABLE ZShell::llm::ChatSession* draft(); + Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); + Q_INVOKABLE void clear(); + + void commitDraft(ZShell::llm::ChatSession* session); + + void persist(ZShell::llm::ChatSession* session); + void saveFlags(ZShell::llm::ChatSession* session); + void loadMessagesInto(ZShell::llm::ChatSession* session); + + void requestClear(ZShell::llm::ChatSession* session); + void applyPendingClear(ZShell::llm::ChatSession* session); + + void reposition(ZShell::llm::ChatSession* session); + + Q_SIGNALS: + void sessionRemoved(ZShell::llm::ChatSession* session); + + private: + void openDb(); + void load(); + [[nodiscard]] int sortedRow(const ChatSession* session) const; + [[nodiscard]] static bool firstAfter( + const ChatSession* a, const ChatSession* b); + void insertRow(ChatSession* session); + void moveRow(int from, int to); + void removeRow(ChatSession* session); + [[nodiscard]] bool saveSession(ChatSession* session); + [[nodiscard]] bool writeSessionRow(ChatSession* session); + [[nodiscard]] QSqlDatabase db() const; + + LlmClient* m_client; + QList m_sessions; + ChatSession* m_draft = nullptr; + QString m_connectionName; + QString m_dbPath; + QSet m_pendingPersists; + QSet m_pendingClears; + bool m_loading = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/clipboard.cpp b/Plugins/ZShell/Llm/clipboard.cpp new file mode 100644 index 0000000..917cd55 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.cpp @@ -0,0 +1,61 @@ +#include "clipboard.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm::clipboard { + +bool hasAttachment() { + QClipboard* clipboard = QGuiApplication::clipboard(); + if (!clipboard) return false; + const QMimeData* data = clipboard->mimeData(); + if (!data) return false; + if (data->hasImage()) return true; + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) return true; + return false; +} + +QStringList attachmentPaths(QString& error) { + error.clear(); + QClipboard* clipboard = QGuiApplication::clipboard(); + const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; + if (!data) { + qWarning() << "clipboard: no clipboard data"; + return {}; + } + + QStringList paths; + if (data->hasImage()) { + const QImage image = clipboard->image(); + if (image.isNull()) { + error = QStringLiteral("Clipboard image could not be decoded"); + return {}; + } + const QString tempPath = + QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") + .arg(QDateTime::currentMSecsSinceEpoch()); + if (!image.save(tempPath)) { + error = QStringLiteral("Could not save the pasted image"); + return {}; + } + paths.append(tempPath); + } else if (data->hasUrls()) { + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) paths.append(url.toLocalFile()); + } + + if (paths.isEmpty() && (data->hasImage() || data->hasUrls())) + error = QStringLiteral( + "Nothing usable on the clipboard (expected an image " + "or copied files)"); + return paths; +} + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/clipboard.hpp b/Plugins/ZShell/Llm/clipboard.hpp new file mode 100644 index 0000000..5a2d168 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::clipboard { + +[[nodiscard]] bool hasAttachment(); + +[[nodiscard]] QStringList attachmentPaths(QString& error); + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp new file mode 100644 index 0000000..7351920 --- /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; +} + +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 = 64; + static constexpr int kMaxBytes = 2 * 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) + ; + 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..7464804 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -0,0 +1,75 @@ +#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; + void* lib = nullptr; + const void* lang = nullptr; + void* query = nullptr; + }; + + [[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; + QVariantList spans; + }; + + mutable QHash> m_states; + mutable QMutex m_stateMutex; + mutable QHash m_spanCache; + mutable QStringList m_spanCacheOrder; + mutable int m_spanCacheBytes = 0; + mutable QMutex m_cacheMutex; + static CodeHighlighter* s_instance; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp new file mode 100644 index 0000000..2e368b9 --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -0,0 +1,136 @@ +#include "filetool.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} + +QString FileReadTool::name() const { + return QStringLiteral("readfile"); +} + +QString FileReadTool::description() const { + return QStringLiteral( + "Read a text file from the local filesystem and return part of " + "its content. At most a limited number of characters is returned " + "per call; use offset and limit to page through larger files. " + "Binary files are not supported. Full output of webfetch calls " + "is saved in the sandbox directory and can be read this way. " + "This tool is read-only."); +} + +QJsonObject FileReadTool::parameters() const { + QJsonObject path; + path[QStringLiteral("type")] = QStringLiteral("string"); + path[QStringLiteral("description")] = + QStringLiteral("Path of the file to read"); + + QJsonObject offset; + offset[QStringLiteral("type")] = QStringLiteral("integer"); + offset[QStringLiteral("minimum")] = 0; + offset[QStringLiteral("description")] = + QStringLiteral("Byte offset to start reading from. Defaults to 0."); + + QJsonObject limit; + limit[QStringLiteral("type")] = QStringLiteral("integer"); + limit[QStringLiteral("minimum")] = 1; + limit[QStringLiteral("description")] = QStringLiteral( + "Maximum number of characters to return. Defaults " + "to a value that fits the model's context window."); + + QJsonObject properties; + properties[QStringLiteral("path")] = path; + properties[QStringLiteral("offset")] = offset; + properties[QStringLiteral("limit")] = limit; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("path")); + schema[QStringLiteral("required")] = required; + return schema; +} + +void FileReadTool::execute( + const ToolContext& context, const QJsonObject& args, ToolDone done) { + const QString path = args[QStringLiteral("path")].toString().trimmed(); + if (path.isEmpty()) { + done({false, QStringLiteral("Missing required argument 'path'")}); + return; + } + const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); + const int budget = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); + const int limit = + qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + const QFileInfo info(path); + if (info.exists() && info.isDir()) { + done( + {false, + QStringLiteral("Path is a directory, not a file: %1") + .arg(path)}); + } else { + done({false, QStringLiteral("Cannot open file: %1").arg(path)}); + } + return; + } + const qint64 fileSize = file.size(); + if (fileSize == 0) { + file.close(); + done({true, QString()}); + return; + } + if (offset >= fileSize) { + file.close(); + done( + {false, + QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)") + .arg(offset) + .arg(fileSize)}); + return; + } + if (!file.seek(offset)) { + file.close(); + done({false, QStringLiteral("Cannot seek in file: %1").arg(path)}); + return; + } + const qint64 wanted = qint64(limit) * 4 + 16; + const QByteArray chunk = file.read(qMin(wanted, fileSize - offset)); + file.close(); + + if (chunk.contains('\0')) { + done( + {false, + QStringLiteral("Binary files are not supported: %1").arg(path)}); + return; + } + const QString content = QString::fromUtf8(chunk); + if (content.size() <= limit) { + done({true, content}); + return; + } + const QString head = content.left(limit); + const int nextOffset = offset + static_cast(head.toUtf8().size()); + const QString output = + head + + QStringLiteral( + "\n\n[... truncated: showing %1 characters (bytes %2-%3 of a " + "%4 byte file). Continue with offset=%5.]") + .arg(limit) + .arg(offset) + .arg(nextOffset) + .arg(fileSize) + .arg(nextOffset); + done({true, output}); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp new file mode 100644 index 0000000..eac43ed --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "tool.hpp" + +namespace ZShell::llm { + +class FileReadTool : public LlmTool { + Q_OBJECT + + public: + explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr); + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const ToolContext& context, + const QJsonObject& args, + ToolDone done) override; +}; + +} // 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..e6a8b8e --- /dev/null +++ b/Plugins/ZShell/Llm/generation.cpp @@ -0,0 +1,287 @@ +#include "generation.hpp" + +#include "llmclient.hpp" +#include "message.hpp" +#include "session.hpp" + +#include + +namespace ZShell::llm { + +ChatGeneration::ChatGeneration( + ChatMessage* message, qint64 timestamp, QObject* parent) + : QObject(parent), m_message(message), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + bool runningText = false; + bool runningTool = false; + for (auto* segment : m_segments) { + if (!segment->running()) continue; + Q_EMIT segment->elapsedMsChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + runningTool = true; + else + runningText = true; + } + if (runningText) { + Q_EMIT reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + } + if (runningTool) Q_EMIT toolsElapsedMsChanged(); + if (!runningText && !runningTool && !m_streaming) m_timer.stop(); + }); +} + +ChatMessage* ChatGeneration::message() const { + return m_message; +} + +QString ChatGeneration::content() const { + QStringList parts; + for (const auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->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) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->text()); + } + return parts.join(QStringLiteral("\n\n")); +} + +bool ChatGeneration::reasoningActive() const { + if (!m_streaming) return false; + if (!content().isEmpty()) return false; + return !hasRunningTool() && !hasPendingTool(); +} + +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; +} + +QList ChatGeneration::toolCalls() const { + QList calls; + for (auto* segment : m_segments) + if (auto* call = qobject_cast(segment)) + calls.append(call); + return calls; +} + +int ChatGeneration::toolCallCount() const { + return static_cast(toolCalls().size()); +} + +bool ChatGeneration::hasRunningTool() const { + for (const auto* call : toolCalls()) + if (call->running()) return true; + return false; +} + +bool ChatGeneration::hasPendingTool() const { + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) return true; + return false; +} + +void ChatGeneration::setApprovalPending(bool value) { + if (m_approvalPending == value) return; + m_approvalPending = value; + Q_EMIT toolApprovalPendingChanged(); + Q_EMIT pendingToolCallsChanged(); +} + +QVariantList ChatGeneration::pendingToolCalls() const { + QVariantList out; + if (!m_approvalPending) return out; + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + out.append(QVariant::fromValue(call)); + return out; +} + +void ChatGeneration::approveTools() { + if (auto* llmClient = client()) llmClient->approveTools(this); +} + +void ChatGeneration::denyTools() { + if (auto* llmClient = client()) llmClient->denyTools(this); +} + +void ChatGeneration::dismissError() { + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); + Q_EMIT errorMessageChanged(); +} + +LlmClient* ChatGeneration::client() const { + if (m_message && m_message->session()) + return m_message->session()->client(); + return nullptr; +} + +void ChatGeneration::updateReasoningActive() { + const bool active = reasoningActive(); + if (m_reasoningActive == active) return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatGeneration::setContent(const QString& value) { + LlmTextSegment* first = nullptr; + for (auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content) continue; + auto* text = qobject_cast(segment); + if (!text) continue; + if (!first) + first = text; + else + text->setText(QString()); + } + if (!first) { + if (value.isEmpty()) return; + first = openTextSegment(LlmSegment::Type::Content); + } + 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(); + } + openTextSegment(LlmSegment::Type::Content)->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(); + } + openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece); +} + +void ChatGeneration::setError(const QString& value) { + const bool hasError = !value.isEmpty(); + if (m_hasError == hasError && m_errorMessage == value) return; + m_hasError = hasError; + m_errorMessage = value; + Q_EMIT errorChanged(); + Q_EMIT errorMessageChanged(); +} + +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 reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + Q_EMIT toolsElapsedMsChanged(); + updateReasoningActive(); +} + +LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) { + for (auto* segment : m_segments) { + if (segment->type() != type) continue; + auto* text = qobject_cast(segment); + if (text && text->running()) return text; + } + auto* text = + new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this); + text->begin(); + addSegment(text); + return text; +} + +LlmToolCall* ChatGeneration::beginToolCall( + const QString& name, const QString& toolCallId) { + closeOpenSegments(); + auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this); + call->setName(name); + call->setToolCallId(toolCallId); + call->setStatus(LlmToolCall::Status::Pending); + call->begin(); + addSegment(call); + Q_EMIT toolCallCountChanged(); + return call; +} + +void ChatGeneration::addSegment(LlmSegment* segment) { + if (!segment || m_segments.contains(segment)) return; + segment->setParent(this); + connect(segment, &LlmSegment::runningChanged, this, [this, segment]() { + Q_EMIT toolCallCountChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + Q_EMIT hasRunningToolChanged(); + updateReasoningActive(); + }); + if (auto* text = qobject_cast(segment)) + connect(text, &LlmTextSegment::textChanged, this, [this, text]() { + if (text->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + if (auto* call = qobject_cast(segment)) { + connect(call, &LlmToolCall::statusChanged, this, [this, call]() { + Q_EMIT hasRunningToolChanged(); + Q_EMIT pendingToolCallsChanged(); + if (call->status() == LlmToolCall::Status::Pending) + Q_EMIT toolApprovalPendingChanged(); + updateReasoningActive(); + }); + } + m_segments.append(segment); + Q_EMIT segmentsChanged(); + Q_EMIT toolCallCountChanged(); +} + +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..b7b732b --- /dev/null +++ b/Plugins/ZShell/Llm/generation.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "segment.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatMessage; +class ChatSession; +class LlmClient; + +class ChatGeneration : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat generations are managed by ChatMessage") + + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(bool error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) + Q_PROPERTY( + qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY + reasoningElapsedMsChanged) + Q_PROPERTY( + qint64 contentElapsedMs READ contentElapsedMs NOTIFY + contentElapsedMsChanged) + Q_PROPERTY( + qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY( + bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY( + bool hasRunningTool READ hasRunningTool NOTIFY hasRunningToolChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY( + QList segments READ segments NOTIFY + segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged) + Q_PROPERTY( + bool toolApprovalPending READ toolApprovalPending NOTIFY + toolApprovalPendingChanged) + Q_PROPERTY( + QVariantList pendingToolCalls READ pendingToolCalls NOTIFY + pendingToolCallsChanged) + + public: + ChatGeneration(ChatMessage* message, qint64 timestamp, QObject* parent); + + [[nodiscard]] ChatMessage* message() const; + + [[nodiscard]] QString content() const; + [[nodiscard]] QString reasoning() const; + [[nodiscard]] bool error() const { return m_hasError; } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } + [[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]] QList toolCalls() const; + [[nodiscard]] int toolCallCount() const; + [[nodiscard]] bool hasRunningTool() const; + [[nodiscard]] bool hasPendingTool() const; + + [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } + [[nodiscard]] QVariantList pendingToolCalls() const; + + Q_INVOKABLE void approveTools(); + Q_INVOKABLE void denyTools(); + Q_INVOKABLE void dismissError(); + + void setContent(const QString& value); + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setStreaming(bool value); + void setError(const QString& value); + void setApprovalPending(bool value); + + [[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type); + [[nodiscard]] LlmToolCall* beginToolCall( + const QString& name, const QString& toolCallId); + void addSegment(LlmSegment* segment); + void closeOpenSegments(); + + Q_SIGNALS: + void contentChanged(); + void reasoningChanged(); + void errorChanged(); + void errorMessageChanged(); + void reasoningActiveChanged(); + void reasoningElapsedMsChanged(); + void contentElapsedMsChanged(); + void toolsElapsedMsChanged(); + void streamingChanged(); + void hasRunningToolChanged(); + void segmentsChanged(); + void toolCallCountChanged(); + void toolApprovalPendingChanged(); + void pendingToolCallsChanged(); + + private: + void updateReasoningActive(); + [[nodiscard]] LlmClient* client() const; + + ChatMessage* m_message; + QTimer m_timer; + QList m_segments; + bool m_reasoningActive = false; + bool m_streaming = false; + bool m_approvalPending = false; + qint64 m_timestamp; + bool m_hasError = false; + QString m_errorMessage; +}; + +} // 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..74aeff4 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -0,0 +1,867 @@ +#include "llmclient.hpp" + +#include "attachment.hpp" +#include "filetool.hpp" +#include "generation.hpp" +#include "message.hpp" +#include "messagemodel.hpp" +#include "prompts.hpp" +#include "session.hpp" +#include "shelltool.hpp" +#include "webfetchtool.hpp" +#include "workspace.hpp" + +#include "config/llm.hpp" +#include "config/rootnodes.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +constexpr int kRefreshIntervalMs = 60'000; +constexpr int kProbeTimeoutMs = 2000; + +int contextSizeFromErrorMessage(const QString& message) { + static const QRegularExpression re( + QLatin1String("available context size \\((\\d+)")); + const auto match = re.match(message); + if (match.hasMatch()) return match.captured(1).toInt(); + return 0; +} + +} // namespace + +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::jsonErrorMessage(const QByteArray& body) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) return QString(); + const QJsonValue errorValue = doc.object().value("error"); + if (errorValue.isObject()) + return errorValue.toObject().value("message").toString(); + if (errorValue.isString()) return errorValue.toString(); + return QString(); +} + +QString LlmClient::serverErrorMessage( + const QByteArray& body, const QString& fallback) { + const QString message = jsonErrorMessage(body); + if (!message.isEmpty()) return message; + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void LlmClient::setServerError(const QString& value) { + if (m_serverError == value) return; + m_serverError = value; + Q_EMIT serverErrorChanged(); +} + +void LlmClient::setBusy(bool value) { + if (m_busy == value) return; + m_busy = value; + Q_EMIT busyChanged(); +} + +void LlmClient::setModel(const QString& value) { + if (m_model == value) return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) refreshModels(); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_tools->contextSize() == size) return; + m_tools->setContextSize(size); + Q_EMIT contextSizeChanged(); +} + +void LlmClient::setToolsEnabled(bool value) { + if (m_tools->enabled() == value) return; + m_tools->setEnabled(value); + Q_EMIT toolsEnabledChanged(); + if (value) refreshAllStates(); +} + +void LlmClient::setSandboxEnabled(bool value) { + if (m_tools->sandboxEnabled() == value) return; + m_tools->setSandboxEnabled(value); + Q_EMIT sandboxEnabledChanged(); +} + +LlmClient::LlmClient(QObject* parent) : QObject(parent) { + m_tools = new ToolRegistry(this); + m_store = new ChatStore(this, this); + + m_tools->registerTool(new WebFetchTool(m_tools, m_tools)); + m_tools->registerTool(new FileReadTool(m_tools, m_tools)); + m_tools->registerTool(new ShellTool(m_tools, m_tools)); + + auto* llm = config::ConfigSingleton::instance()->llm(); + if (llm) { + m_endpoint = llm->endpoint(); + m_model = llm->model(); + m_temperature = llm->temperature(); + m_configEnabled = llm->enabled(); + m_tools->setEnabled(llm->tools()); + m_tools->setSandboxEnabled(llm->sandbox()); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + m_endpoint = llm->endpoint(); + refreshAllStates(); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_temperature = llm->temperature(); + }); + connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { + setToolsEnabled(llm->tools()); + }); + connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { + setSandboxEnabled(llm->sandbox()); + }); + connect(llm, &config::Llm::enabledChanged, this, [this, llm]() { + m_configEnabled = llm->enabled(); + if (m_configEnabled) { + m_refreshTimer.setInterval(kRefreshIntervalMs); + m_refreshTimer.start(); + refreshAllStates(); + } else { + m_refreshTimer.stop(); + } + }); + } + + connect( + m_store, &ChatStore::sessionRemoved, this, [this](ChatSession* session) { + if (m_turn && m_turn->session() == session) { + stop(); + endStream(); + } + }); + + m_refreshTimer.setInterval(kRefreshIntervalMs); + connect( + &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates); + if (m_configEnabled) { + m_refreshTimer.start(); + refreshAllStates(); + } +} + +LlmClient::~LlmClient() { + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } + m_tools->cancelAll(); +} + +void LlmClient::selectModel(const QString& id) { + if (id.isEmpty()) return; + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(id); + setModel(id); +} + +ToolContext LlmClient::toolContext() const { + ToolContext context; + if (m_turn) { + context.workspace = m_turn->session()->workspace()->path(); + context.outputBudgetChars = m_turn->outputBudgetChars(); + } + return context; +} + +void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target || !m_configEnabled) return; + + m_turn = std::make_unique(session, target, *m_tools); + if (m_turn->targetRow() < 0) { + fail(QStringLiteral( + "Internal error: generation target is not in the session")); + return; + } + + target->setStreaming(true); + setBusy(true); + + probeContextSize([this, session, target]() { + if (!m_turn || m_turn->session() != session || + m_turn->generation() != target) + return; + refreshModels(); + sendRound(); + }); +} + +void LlmClient::sendRound() { + if (!m_turn) return; + probeContextSize(); + + 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; + } + + Turn* turn = m_turn.get(); + QJsonArray messages = turn->contextMessages(); + + 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; + if (m_tools->enabled()) { + const QJsonArray toolSpecs = m_tools->specifications(toolContext()); + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + QNetworkReply* reply = + m_manager.post(request, QJsonDocument(body).toJson()); + if (!reply) { + fail(QStringLiteral("Failed to start request to LLM server")); + return; + } + m_reply = reply; + + connect(reply, &QNetworkReply::readyRead, this, [this, reply, turn]() { + if (m_reply != reply || m_turn.get() != turn) return; + m_buffer.append(reply->readAll()); + drainBuffer(); + }); + connect(reply, &QNetworkReply::finished, this, [this, reply, turn]() { + if (m_reply != reply) { + reply->deleteLater(); + 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(); + + if (m_turn.get() != turn) return; + + drainBuffer(); + + if (error == QNetworkReply::NoError) { + if (turn->streamDone) { + roundFinished(); + return; + } + const QByteArray tail = m_buffer; + m_buffer.clear(); + const QString serverMessage = jsonErrorMessage(tail.simplified()); + fail( + serverMessage.isEmpty() + ? QStringLiteral( + "LLM server closed the stream before " + "completing the response") + : serverMessage); + return; + } + if (error == QNetworkReply::OperationCanceledError) { + finishTurn(); + return; + } + const QString message = serverErrorMessage(responseBody, errorString); + const int reported = contextSizeFromErrorMessage(message); + if (reported > 0 && reported != m_tools->contextSize() && + !turn->contextRetryUsed) { + qWarning() << "LlmClient: prompt overflow, server reports" + << reported << "tokens; retrying round"; + setContextSize(reported); + turn->contextRetryUsed = true; + sendRound(); + return; + } + fail(message); + }); +} + +void LlmClient::applyToolCallDelta(const QJsonObject& call) { + Turn* turn = m_turn.get(); + if (!turn) return; + const int index = call[QStringLiteral("index")].toInt(-1); + if (index < 0) return; + while (turn->calls().size() <= index) + turn->calls().append(Turn::Call{}); + Turn::Call& builder = turn->calls()[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) { + turn->generation()->closeOpenSegments(); + builder.segment = + turn->generation()->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() { + Turn* turn = m_turn.get(); + if (!turn || turn->roundDone) return; + turn->roundDone = true; + + if (!turn->roundHasCalls()) { + finishTurn(); + return; + } + if (turn->finishReason != QLatin1String("tool_calls")) { + qWarning() << "LlmClient: stream ended" << turn->finishReason + << "with incomplete tool calls, ending turn"; + for (const auto& builder : turn->calls()) { + if (!builder.seen || !builder.segment) continue; + builder.segment->finish( + QStringLiteral( + "Stream truncated before the tool call completed"), + false); + } + finishTurn(); + return; + } + if (turn->round >= kMaxToolRounds) { + qWarning() << "LlmClient: tool round limit reached, ending turn"; + finishTurn(); + return; + } + + turn->generation()->closeOpenSegments(); + turn->appendToTranscript(turn->roundAssistantMessage()); + ++turn->round; + + setApprovalPending(true); +} + +void LlmClient::executeAllCalls() { + Turn* turn = m_turn.get(); + if (!turn) return; + turn->toolPhase = true; + turn->pendingCalls = 0; + turn->results() = QList(turn->calls().size()); + + for (int i = 0; i < turn->calls().size(); ++i) { + const Turn::Call& call = turn->calls().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()) { + turn->results()[i] = {false, errorText}; + if (call.segment) call.segment->finish(errorText, false); + continue; + } + + ToolContext context = toolContext(); + context.toolCallId = call.id; + + ++turn->pendingCalls; + tool->execute(context, args, [this, turn, i](const ToolResult& result) { + if (m_turn.get() != turn) return; + turn->results()[i] = result; + if (turn->calls().at(i).segment) + turn->calls().at(i).segment->finish(result.text, result.ok); + if (--turn->pendingCalls == 0) flushCallResults(); + }); + } + if (turn->pendingCalls == 0) flushCallResults(); +} + +void LlmClient::flushCallResults() { + Turn* turn = m_turn.get(); + if (!turn || !turn->toolPhase) return; + turn->toolPhase = false; + + for (const QJsonValue& value : turn->toolMessages()) + turn->appendToTranscript(value.toObject()); + sendRound(); +} + +void LlmClient::setApprovalPending(bool value) { + Turn* turn = m_turn.get(); + if (!turn) return; + if (turn->approvalPending == value) return; + turn->approvalPending = value; + turn->generation()->setApprovalPending(value); +} + +void LlmClient::approveTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; + setApprovalPending(false); + for (auto* call : generation->toolCalls()) { + if (call->status() != LlmToolCall::Status::Pending) continue; + call->setStatus(LlmToolCall::Status::Running); + call->begin(); + } + executeAllCalls(); +} + +void LlmClient::denyTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; + setApprovalPending(false); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish("Denied by user", false); + finishTurn(); +} + +void LlmClient::finishPendingCalls(const QString& resultText) { + Turn* turn = m_turn.get(); + if (!turn) return; + for (auto* call : turn->generation()->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(resultText, false); +} + +void LlmClient::stop() { + if (!m_busy) return; + Turn* turn = m_turn.get(); + if (!turn) return; + + if (turn->approvalPending) { + setApprovalPending(false); + finishPendingCalls(QStringLiteral("Cancelled")); + finishTurn(); + return; + } + if (turn->toolPhase) { + m_tools->cancelAll(); + for (auto* call : turn->generation()->toolCalls()) + if (call->running()) + call->finish(QStringLiteral("Cancelled"), false); + finishTurn(); + return; + } + + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } + endStream(); +} + +void LlmClient::endStream() { + Turn* turn = m_turn.get(); + if (!turn) return; + ChatSession* session = turn->session(); + ChatGeneration* generation = turn->generation(); + + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } + + generation->setStreaming(false); + setApprovalPending(false); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(QStringLiteral("Cancelled"), false); + + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && + generation->toolCallCount() == 0 && !generation->error()) { + ChatMessage* message = generation->message(); + if (message) { + if (message->generationCount() <= 1) + session->removeMessage(message); + else + message->removeGeneration(generation); + } + } + + m_turn.reset(); + setBusy(false); +} + +void LlmClient::finishTurn() { + ChatSession* session = m_turn ? m_turn->session() : nullptr; + endStream(); + if (session) { + m_store->applyPendingClear(session); + session->persist(); + } + if (m_configEnabled) refreshAllStates(); +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + ChatSession* session = m_turn ? m_turn->session() : nullptr; + if (Turn* turn = m_turn.get()) turn->generation()->setError(message); + finishTurn(); + if (session && !message.isEmpty()) + Q_EMIT generationFailed(session, 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) { + Turn* turn = m_turn.get(); + if (!turn || line.isEmpty() || !line.startsWith("data:")) return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + turn->streamDone = true; + roundFinished(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonValue errorValue = obj["error"]; + QString message; + if (errorValue.isObject()) + message = errorValue.toObject()["message"].toString(); + else if (errorValue.isString()) + message = errorValue.toString(); + fail( + message.isEmpty() ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_turn) break; + const QJsonObject choice = choiceValue.toObject(); + const QJsonObject delta = choice["delta"].toObject(); + + const QString finishReason = choice["finish_reason"].toString(); + if (!finishReason.isEmpty()) turn->finishReason = finishReason; + + turn->generation()->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); + turn->generation()->appendReasoning(reasoning); + + for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { + if (!m_turn) break; + applyToolCallDelta(callValue.toObject()); + } + } +} + +void LlmClient::refreshAllStates() { + if (!m_configEnabled) return; + if (m_endpoint.trimmed().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } + probeContextSize([this]() { refreshModels(); }); +} + +void LlmClient::refreshModels() { + if (!m_configEnabled) return; + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); + 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()) { + setServerError(QStringLiteral( + "LLM server is running but has no models loaded")); + return; + } + setServerError(QString()); + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty() || !models.contains(m_model)) { + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(models.first()); + setModel(models.first()); + } + }); +} + +void LlmClient::probeContextSize(std::function done) { + if (!m_configEnabled) { + if (done) done(); + return; + } + + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) { + setServerError( + QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + if (done) done(); + return; + } + + auto* reply = m_manager.get(QNetworkRequest(url)); + auto settled = std::make_shared(false); + const auto finish = [this, reply, settled, done]() { + if (*settled) return; + *settled = true; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + setServerError(QString()); + 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); + } + } else { + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); + } + if (size > 0) + setContextSize(size); + else if (m_tools->contextSize() <= 0) + setContextSize(4096); + if (done) done(); + }; + connect(reply, &QNetworkReply::finished, this, finish); + QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() { + if (!*settled) reply->abort(); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_turn || m_tools->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_turn->session()->setUsageTokens(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(); + + 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(); + onResult(result); + }); +} + +void LlmClient::requestTitle(ChatSession* session, const QString& userText) { + shortRequest( + QStringLiteral("title"), + prompts::titleSystemPrompt(), + userText, + [this, session = QPointer(session)](QString title) { + if (!session) { + qWarning() << "LlmClient: title request: session gone"; + return; + } + title = prompts::cleanTitle(title); + if (title.length() < 2) { + qWarning() << "LlmClient: title rejected (too short)" << title; + return; + } + qInfo() << "LlmClient: suggesting title" << title; + session->setTitle(title); + }); +} + +void LlmClient::requestIcon(ChatSession* session, const QString& userText) { + const QStringList icons = prompts::iconNames(); + shortRequest( + QStringLiteral("icon"), + prompts::iconSystemPrompt(), + 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; + session->setIcon(name); + }); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp new file mode 100644 index 0000000..58f457a --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include "chatstore.hpp" +#include "tool.hpp" +#include "turn.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class QJsonObject; +class QNetworkReply; + +namespace ZShell::llm { + +class ChatGeneration; +class ChatSession; + +class LlmClient : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + 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( + bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY + sandboxEnabledChanged) + Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) + + public: + explicit LlmClient(QObject* parent = nullptr); + ~LlmClient() override; + + [[nodiscard]] ChatStore* chats() const { return m_store; } + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] QStringList availableModels() const { + return m_availableModels; + } + [[nodiscard]] int contextSize() const { return m_tools->contextSize(); } + [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } + [[nodiscard]] bool sandboxEnabled() const { + return m_tools->sandboxEnabled(); + } + [[nodiscard]] QString serverError() const { return m_serverError; } + [[nodiscard]] ChatSession* streamingSession() const { + return m_turn ? m_turn->session() : nullptr; + } + + void setToolsEnabled(bool value); + void setSandboxEnabled(bool value); + + Q_INVOKABLE void selectModel(const QString& id); + Q_INVOKABLE void stop(); + Q_INVOKABLE void refreshModels(); + + void startGeneration(ChatSession* session, ChatGeneration* target); + void approveTools(ZShell::llm::ChatGeneration* generation); + void denyTools(ZShell::llm::ChatGeneration* generation); + void requestTitle(ChatSession* session, const QString& userText); + void requestIcon(ChatSession* session, const QString& userText); + + Q_SIGNALS: + void busyChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void toolsEnabledChanged(); + void sandboxEnabledChanged(); + void serverErrorChanged(); + void generationFailed( + ZShell::llm::ChatSession* session, const QString& message); + + private: + void sendRound(); + void applyToolCallDelta(const QJsonObject& call); + void roundFinished(); + void executeAllCalls(); + void flushCallResults(); + void setApprovalPending(bool value); + void finishPendingCalls(const QString& resultText); + void finishTurn(); + void endStream(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void updateTokenUsage(const QJsonObject& data); + void setBusy(bool value); + void setServerError(const QString& value); + void setModel(const QString& value); + void setContextSize(int size); + void probeContextSize(std::function done = {}); + void refreshAllStates(); + 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 jsonErrorMessage(const QByteArray& body); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + [[nodiscard]] ToolContext toolContext() const; + + QNetworkAccessManager m_manager; + ChatStore* m_store = nullptr; + ToolRegistry* m_tools = nullptr; + std::unique_ptr m_turn; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + QTimer m_refreshTimer; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + QString m_serverError; + double m_temperature = 0.7; + bool m_busy = false; + bool m_configEnabled = true; + static constexpr int kMaxToolRounds = 12; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.cpp b/Plugins/ZShell/Llm/markdownblock.cpp new file mode 100644 index 0000000..9b2d7e1 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.cpp @@ -0,0 +1,23 @@ +#include "markdownblock.hpp" + +namespace ZShell::llm { + +MarkdownBlock::MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent) + : QObject(parent) + , m_type(type) + , m_id(id) + , m_text(text) + , m_latex(latex) + , m_code(code) + , m_language(language) + , m_level(level) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp new file mode 100644 index 0000000..fee7576 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::llm { + +class MarkdownBlock : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Blocks are produced by LlmTextSegment") + + Q_PROPERTY(Type type READ type CONSTANT) + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString text READ text CONSTANT) + Q_PROPERTY(QString latex READ latex CONSTANT) + Q_PROPERTY(QString code READ code CONSTANT) + Q_PROPERTY(QString language READ language CONSTANT) + Q_PROPERTY(int level READ level CONSTANT) + + public: + enum class Type : int { Text = 0, Heading, Code, Math }; + Q_ENUM(Type) + + MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent = nullptr); + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString text() const { return m_text; } + [[nodiscard]] QString latex() const { return m_latex; } + [[nodiscard]] QString code() const { return m_code; } + [[nodiscard]] QString language() const { return m_language; } + [[nodiscard]] int level() const { return m_level; } + + private: + Type m_type; + QString m_id; + QString m_text; + QString m_latex; + QString m_code; + QString m_language; + int m_level = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp new file mode 100644 index 0000000..5d4a5ad --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -0,0 +1,125 @@ +#include "markdownparser.hpp" + +#include +#include + +#include +#include + +namespace ZShell::llm { + +QVector MarkdownParser::parse(const QString& source) { + QVector 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 appendText = [&](const QString& text) { + if (text.trimmed().isEmpty()) return; + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Text; + block.text = text; + blocks.append(block); + }; + + auto appendMath = [&](const QString& latex) { + if (latex.trimmed().isEmpty()) return; + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Math; + block.latex = latex; + blocks.append(block); + }; + + auto appendCode = [&](const QString& language, const QString& code) { + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Code; + block.language = language; + block.code = code; + blocks.append(block); + }; + + auto appendHeading = [&](int level, const QString& text) { + if (text.trimmed().isEmpty()) return; + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Heading; + block.level = level; + block.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..5ab6007 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "markdownblock.hpp" + +#include +#include + +namespace ZShell::llm { + +struct MarkdownBlockData { + MarkdownBlock::Type type = MarkdownBlock::Type::Text; + QString text; + QString latex; + QString code; + QString language; + int level = 0; +}; + +class MarkdownParser { + public: + [[nodiscard]] static QVector 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..542f142 --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -0,0 +1,366 @@ +#include "mathtext.hpp" + +#include "latinmodern-fonts.hpp" + +#include + +#include +#include +#include +#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; +} + +void configureLatinModern(JKQTMathText& renderer) { + const LatinModern& fonts = loadLatinModern(); + if (fonts.roman) + renderer.setFontRomanAndMath( + QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); + if (fonts.math) { + renderer.setFontMathRoman( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + renderer.setFallbackFontSymbols( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + } +} + +} // namespace + +LlmMathText::LlmMathText(QObject* parent) : QObject(parent) { + loadLatinModern(); +} + +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; + QString id; + qreal width = 0; + qreal height = 0; +}; + +struct MathStore { + QMutex mutex; + QHash entries; + QStringList order; + QHash idIndex; + QHash idRefs; + qint64 bytes = 0; + quint64 nextId = 1; +}; + +MathStore& mathStore() { + static MathStore store; + return store; +} + +qint64 imageBytes(const QImage& image) { + return image.isNull() ? 0 : static_cast(image.sizeInBytes()); +} + +void retainMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + ++store.idRefs[id]; +} + +void releaseMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + auto it = store.idRefs.find(id); + if (it == store.idRefs.end()) return; + if (--it.value() <= 0) store.idRefs.erase(it); +} + +} // namespace + +namespace { + +constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024; + +class MathImageProvider : public QQuickImageProvider { + public: + MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + Q_UNUSED(requestedSize) + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto keyIt = store.idIndex.constFind(id); + if (keyIt == store.idIndex.constEnd()) return {}; + const QString key = keyIt.value(); + const auto entryIt = store.entries.constFind(key); + if (entryIt == store.entries.constEnd()) return {}; + if (size) *size = entryIt->image.size(); + const qsizetype pos = store.order.indexOf(key); + if (pos >= 0) store.order.move(pos, store.order.size() - 1); + return entryIt->image; + } +}; + +void ensureImageProvider(QObject* owner) { + if (auto* engine = qmlEngine(owner)) + if (!engine->imageProvider(QStringLiteral("llmmath"))) + engine->addImageProvider( + QStringLiteral("llmmath"), new MathImageProvider()); +} + +} // namespace + +void LlmMathText::reRender() { + ensureImageProvider(this); + ++m_requestId; + if (m_latex.trimmed().isEmpty()) { + releaseMathId(m_imageId); + m_imageId.clear(); + 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); + bool cacheHit = false; + QString hitId; + qreal hitW = 0; + qreal hitH = 0; + bool hitOk = false; + { + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto it = store.entries.constFind(key); + if (it != store.entries.constEnd()) { + cacheHit = true; + hitId = it->id; + hitW = it->width; + hitH = it->height; + hitOk = it->ok; + } + } + if (cacheHit) { + releaseMathId(m_imageId); + retainMathId(hitId); + m_imageId = hitId; + m_width = hitW; + m_height = hitH; + m_ok = hitOk; + 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; + QThreadPool::globalInstance()->start( + [this, id = m_requestId, key, latex, color, pointSize, dpr]() { + JKQTMathText renderer(nullptr, true); + configureLatinModern(renderer); + MathRender render; + renderer.setFontPointSize(pointSize); + renderer.setFontColor(color); + if (renderer.parse( + latex, + JKQTMathText::LatexParser, + JKQTMathText::DefaultParseOptions)) { + { + QImage scratch(1, 1, QImage::Format_ARGB32); + QPainter painter(&scratch); + const QSizeF size = renderer.getSize(painter); + render.width = size.width() + 2 * kRenderMargin; + render.height = size.height() + 2 * kRenderMargin; + } + QPointer sizeGuard(this); + const qreal sizeW = render.width; + const qreal sizeH = render.height; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [sizeGuard, id, sizeW, sizeH]() { + LlmMathText* self = sizeGuard; + if (!self || id != self->m_requestId) return; + self->m_width = sizeW; + self->m_height = sizeH; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + + const QImage image = renderer.drawIntoImage( + false, + QColor(Qt::transparent), + kRenderMargin, + dpr, + kResolutionDpi); + if (!image.isNull()) { + render.image = image; + 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& store = mathStore(); + QMutexLocker locker(&store.mutex); + const bool isNew = !store.entries.contains(key); + if (isNew) + render.id = QString::number(store.nextId++); + else + render.id = store.entries[key].id; + store.idIndex.insert(render.id, key); + if (isNew) { + const qint64 newBytes = imageBytes(render.image); + while (store.order.size() >= kCacheLimit || + store.bytes + newBytes > kMaxCacheBytes) { + bool evicted = false; + for (int i = 0; i < store.order.size(); ++i) { + const QString oldKey = store.order.at(i); + const auto oldIt = + store.entries.constFind(oldKey); + if (oldIt == store.entries.constEnd()) { + store.order.removeAt(i); + continue; + } + if (store.idRefs.value(oldIt->id, 0) > 0) + continue; + store.order.removeAt(i); + store.bytes -= imageBytes(oldIt->image); + store.idIndex.remove(oldIt->id); + store.entries.remove(oldKey); + evicted = true; + break; + } + if (!evicted) break; + } + store.entries.insert(key, render); + store.order.append(key); + store.bytes += imageBytes(render.image); + } + } + releaseMathId(self->m_imageId); + retainMathId(render.id); + self->m_imageId = render.id; + self->m_width = render.width; + self->m_height = render.height; + self->m_ok = render.ok; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + }); +} + +LlmMathText::~LlmMathText() { + releaseMathId(m_imageId); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp new file mode 100644 index 0000000..f0210a9 --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -0,0 +1,62 @@ +#pragma once + +#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(QString imageId READ imageId 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); + ~LlmMathText() override; + + [[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]] QString imageId() const { return m_imageId; } + [[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(); + + QString m_latex; + QColor m_color; + double m_fontPointSize = 12.0; + qreal m_devicePixelRatio = 1.0; + QString m_imageId; + 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..a453b01 --- /dev/null +++ b/Plugins/ZShell/Llm/message.cpp @@ -0,0 +1,111 @@ +#include "message.hpp" + +#include "session.hpp" + +namespace ZShell::llm { + +ChatMessage::ChatMessage( + ChatSession* session, Role role, qint64 timestamp, QObject* parent) + : QObject(parent) + , m_session(session) + , m_role(role) + , m_timestamp(timestamp) {} + +QString ChatMessage::content() const { + if (m_role == Role::User) return m_content; + const ChatGeneration* generation = activeGeneration(); + return generation ? generation->content() : QString(); +} + +void ChatMessage::setContent(const QString& value) { + if (m_role != Role::User || m_content == value) return; + m_content = value; + Q_EMIT contentChanged(); +} + +ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { + if (m_role != Role::Assistant) return nullptr; + + auto* generation = new ChatGeneration(this, timestamp, this); + connect( + generation, &ChatGeneration::contentChanged, this, [this, generation]() { + if (generation == activeGeneration()) Q_EMIT contentChanged(); + }); + 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) { + ChatGeneration* 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(); + Q_EMIT contentChanged(); + } +} + +void ChatMessage::setActiveInternal(int index) { + if (index < 0 || index >= m_generations.size() || index == m_active) return; + m_active = index; + Q_EMIT activeGenerationChanged(); + Q_EMIT contentChanged(); +} + +void ChatMessage::setActiveGeneration(int index) { + setActiveInternal(index); +} + +void ChatMessage::setAttachments(QList attachments) { + QList stale; + for (Attachment* attachment : m_attachments) + if (!attachments.contains(attachment)) stale.append(attachment); + m_attachments.clear(); + for (Attachment* attachment : attachments) { + attachment->setParent(this); + m_attachments.append(attachment); + } + Q_EMIT attachmentsChanged(); + for (Attachment* attachment : stale) + attachment->deleteLater(); +} + +void ChatMessage::edit(const QString& newContent) { + if (m_role == Role::User) { + setContent(newContent); + } else if (ChatGeneration* generation = activeGeneration()) { + generation->setContent(newContent); + } else { + return; + } + + if (m_session) m_session->persist(); +} + +void ChatMessage::retry() { + if (m_role != Role::Assistant || !m_session) return; + m_session->retry(this); +} + +void ChatMessage::generate() { + if (m_role != Role::Assistant || !m_session) return; + m_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..022f7cf --- /dev/null +++ b/Plugins/ZShell/Llm/message.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include "attachment.hpp" +#include "generation.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class ChatMessage : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat messages are created by ChatMessageModel") + + Q_PROPERTY(Role role READ role CONSTANT) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + 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) + Q_PROPERTY( + QList attachments READ attachments NOTIFY + attachmentsChanged) + + public: + enum class Role : int { User = 0, Assistant }; + Q_ENUM(Role) + + ChatMessage( + ChatSession* session, + Role role, + qint64 timestamp, + QObject* parent = nullptr); + + [[nodiscard]] ChatSession* session() const { return m_session; } + [[nodiscard]] Role role() const { return m_role; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QString content() const; + [[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]] QList attachments() const { + return m_attachments; + } + [[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); + void setAttachments(QList attachments); + 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); + + void setContent(const QString& value); + + Q_SIGNALS: + void contentChanged(); + void generationsChanged(); + void activeGenerationChanged(); + void attachmentsChanged(); + + private: + void setActiveInternal(int index); + + ChatSession* m_session; + Role m_role; + qint64 m_timestamp; + QString m_content; + QList m_generations; + QList m_attachments; + 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..5267fd6 --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -0,0 +1,117 @@ +#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(m_session, role, timestamp, this); +} + +ChatMessage* ChatMessageModel::appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + auto* message = createMessage(role, timestamp); + if (role == ChatMessage::Role::User) { + message->setContent(content); + } else { + 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/prompts.cpp b/Plugins/ZShell/Llm/prompts.cpp new file mode 100644 index 0000000..4cc5ec5 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.cpp @@ -0,0 +1,68 @@ +#include "prompts.hpp" + +namespace ZShell::llm::prompts { + +QString sandboxPreamble(const QString& workspace, const QStringList& files) { + return QStringLiteral( + "The user attached the following files to this " + "conversation. They are available in your sandbox " + "directory at %1:\n%2\nruncommand executes shell " + "commands inside a bubblewrap sandbox: the rest of the " + "filesystem is read-only, there is no network access, " + "and only the sandbox directory is writable. Use it to " + "inspect, transform, or extract attached files (for " + "example to extract archives or search across many " + "files). Files you create stay inside the sandbox " + "directory. Use the webfetch tool for anything that " + "needs the network.") + .arg(workspace, files.join(QStringLiteral("\n"))); +} + +QString titleSystemPrompt() { + return 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."); +} + +QString iconSystemPrompt() { + return 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(iconNames().join(QStringLiteral(", "))); +} + +QStringList iconNames() { + return { + "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", + }; +} + +QString cleanTitle(const QString& raw) { + static 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'); + }; + + QString title = raw; + 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() > MaxTitleLength) + title = title.left(MaxTitleLength - 1) + QStringLiteral("…"); + return title; +} + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/prompts.hpp b/Plugins/ZShell/Llm/prompts.hpp new file mode 100644 index 0000000..4ae8ed0 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::prompts { + +[[nodiscard]] QString sandboxPreamble( + const QString& workspace, const QStringList& files); + +[[nodiscard]] QString titleSystemPrompt(); +[[nodiscard]] QString iconSystemPrompt(); + +[[nodiscard]] QStringList iconNames(); + +constexpr int MaxTitleLength = 48; + +[[nodiscard]] QString cleanTitle(const QString& raw); + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp new file mode 100644 index 0000000..365d722 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.cpp @@ -0,0 +1,167 @@ +#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) {} + +LlmSegment::~LlmSegment() = default; + +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(); + } + closed(); +} + +void LlmSegment::restore(qint64 elapsedMs) { + m_startedAt = m_timestamp; + m_endedAt = m_timestamp + qMax(0, elapsedMs); +} + +LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent) + : LlmSegment(type, timestamp, parent) { + m_markdownTimer.setInterval(kMarkdownRefreshMs); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); +} + +void LlmTextSegment::setText(const QString& value) { + if (m_text == value) return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::appendText(const QString& piece) { + if (piece.isEmpty()) return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::closed() { + if (type() != Type::Content) return; + if (m_markdownDirty) { + m_markdownTimer.stop(); + if (parseMarkdown()) m_markdownDirty = false; + } +} + +void LlmTextSegment::scheduleMarkdown() { + if (type() != Type::Content) return; + m_markdownDirty = true; + if (!m_markdownTimer.isActive()) m_markdownTimer.start(); +} + +bool LlmTextSegment::parseMarkdown() { + if (m_parseInFlight) return false; + m_parseInFlight = true; + const QString text = m_text; + QThreadPool::globalInstance()->start([this, text]() { + const QVector parsed = MarkdownParser::parse(text); + QPointer guard(this); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, parsed]() { + LlmTextSegment* segment = guard; + if (!segment) return; + segment->m_parseInFlight = false; + + QList blocks; + blocks.reserve(static_cast(parsed.size())); + for (int i = 0; i < static_cast(parsed.size()); ++i) { + const MarkdownBlockData& data = parsed.at(i); + const QString id = QStringLiteral("%1:%2").arg(i).arg( + static_cast(data.type)); + blocks.append(new MarkdownBlock( + data.type, + id, + data.text, + data.latex, + data.code, + data.language, + data.level, + segment)); + } + segment->m_blocks = blocks; + Q_EMIT segment->markdownChanged(); + if (segment->m_markdownDirty) segment->parseMarkdown(); + }, + Qt::QueuedConnection); + }); + return true; +} + +LlmToolCall::LlmToolCall(qint64 timestamp, QObject* parent) + : LlmSegment(Type::ToolCall, timestamp, parent) {} + +void LlmToolCall::setName(const QString& value) { + if (m_name == value) return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmToolCall::setToolCallId(const QString& value) { + if (m_toolCallId == value) return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmToolCall::appendArguments(const QString& piece) { + if (piece.isEmpty()) return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmToolCall::setResult(const QString& value) { + if (m_result == value) return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmToolCall::setStatus(Status value) { + if (m_status == value) return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmToolCall::finish(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp new file mode 100644 index 0000000..148a57c --- /dev/null +++ b/Plugins/ZShell/Llm/segment.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include "markdownblock.hpp" + +#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(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) + + public: + enum class Type : int { Reasoning = 0, Content, ToolCall }; + Q_ENUM(Type) + + LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + ~LlmSegment() override; + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] bool running() const { return m_running; } + [[nodiscard]] qint64 elapsedMs() const; + + void begin(); + void close(); + + void restore(qint64 elapsedMs); + + Q_SIGNALS: + void runningChanged(); + void elapsedMsChanged(); + + protected: + virtual void closed() {} + + qint64 m_startedAt = 0; + qint64 m_endedAt = 0; + + private: + Type m_type; + qint64 m_timestamp; + bool m_running = false; +}; + +class LlmTextSegment : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Text segments are managed by ChatGeneration") + + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY( + QList markdown READ markdown NOTIFY + markdownChanged) + + public: + LlmTextSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString text() const { return m_text; } + void setText(const QString& value); + void appendText(const QString& piece); + + [[nodiscard]] QList markdown() const { return m_blocks; } + + Q_SIGNALS: + void textChanged(); + void markdownChanged(); + + private: + void scheduleMarkdown(); + bool parseMarkdown(); + void closed() override; + + QString m_text; + QList m_blocks; + QTimer m_markdownTimer; + bool m_markdownDirty = false; + bool m_parseInFlight = false; +}; + +class LlmToolCall : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Tool calls are managed by ChatGeneration") + + 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) + + public: + enum class Status : int { None = 0, Running, Success, Error, Pending }; + Q_ENUM(Status) + + LlmToolCall(qint64 timestamp, QObject* parent = nullptr); + + [[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; } + + 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 finish(const QString& resultText, bool success); + + Q_SIGNALS: + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + + private: + QString m_name; + QString m_toolCallId; + QString m_arguments; + QString m_result; + Status m_status = Status::None; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp new file mode 100644 index 0000000..2a22a55 --- /dev/null +++ b/Plugins/ZShell/Llm/session.cpp @@ -0,0 +1,285 @@ +#include "session.hpp" + +#include "attachment.hpp" +#include "chatstore.hpp" +#include "clipboard.hpp" +#include "llmclient.hpp" +#include "prompts.hpp" +#include "workspace.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +constexpr int kMaxStoredMessages = 200; + +} // namespace + +ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent) + : QObject(parent), m_store(store), m_id(id) { + m_model = new ChatMessageModel(this, this); + m_workspace = new Workspace(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(); + if (m_store) m_store->saveFlags(this); +} + +void ChatSession::setUpdatedAt(qint64 value) { + if (m_updatedAt == value) return; + m_updatedAt = value; + Q_EMIT updatedAtChanged(); + if (m_store) m_store->reposition(this); +} + +void ChatSession::setPinned(bool value) { + if (m_pinned == value) return; + m_pinned = value; + Q_EMIT pinnedChanged(); + if (m_store) m_store->saveFlags(this); +} + +void ChatSession::setUsageTokens(int value) { + if (m_usageTokens == value) return; + m_usageTokens = value; + Q_EMIT usageTokensChanged(); +} + +void ChatSession::setCount(int value) { + if (m_messageCount == value) return; + m_messageCount = value; + Q_EMIT messageCountChanged(); +} + +void ChatSession::hydrate( + const QString& title, + const QString& icon, + qint64 createdAt, + qint64 updatedAt, + int messageCount, + bool pinned) { + m_title = title; + m_icon = icon; + m_createdAt = createdAt; + m_updatedAt = updatedAt; + m_messageCount = messageCount; + m_pinned = pinned; +} + +void ChatSession::ensureLoaded() { + if (m_loadRequested) return; + m_loadRequested = true; + if (m_store) m_store->loadMessagesInto(this); +} + +ChatMessageModel* ChatSession::messagesModel() { + ensureLoaded(); + return m_model; +} + +void ChatSession::markLoaded() { + if (m_loaded) return; + m_loaded = true; + Q_EMIT loaded(); +} + +void ChatSession::adoptMessages(QList messages) { + m_model->loadMessages(std::move(messages)); +} + +void ChatSession::persist() { + if (m_store) m_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) { + ChatGeneration* generation = target->activeGeneration(); + LlmClient* clientObject = client(); + if (!generation || !clientObject) return; + + if (isLoaded()) { + clientObject->startGeneration(this, generation); + return; + } + + connect( + this, + &ChatSession::loaded, + clientObject, + [this, generation, clientObject]() { + clientObject->startGeneration(this, generation); + }); + persist(); +} + +LlmClient* ChatSession::client() const { + return m_store ? m_store->client() : nullptr; +} + +void ChatSession::setAttachmentError(const QString& value) { + if (m_attachmentError == value) return; + m_attachmentError = value; + Q_EMIT attachmentErrorChanged(); +} + +QVariantList ChatSession::addAttachments(const QStringList& paths) { + if (!m_workspace) return {}; + setAttachmentError(QString()); + + QVariantList result; + QString error; + for (Attachment* attachment : m_workspace->importFiles(paths, error)) + result.append(QVariant::fromValue(attachment)); + if (!error.isEmpty()) setAttachmentError(error); + return result; +} + +bool ChatSession::clipboardHasAttachment() { + return clipboard::hasAttachment(); +} + +QVariantList ChatSession::pasteAttachments() { + QString error; + const QStringList paths = clipboard::attachmentPaths(error); + if (paths.isEmpty()) { + if (!error.isEmpty()) setAttachmentError(error); + return {}; + } + + const QVariantList attachments = addAttachments(paths); + + for (const QString& path : paths) + if (path.startsWith(QDir::tempPath())) QFile::remove(path); + + return attachments; +} + +void ChatSession::sendMessage(const QString& text) { + sendMessage(text, QVariantList()); +} + +void ChatSession::sendMessage( + const QString& text, const QVariantList& attachments) { + QList attachmentList; + for (const QVariant& variant : attachments) + if (auto* attachment = + qobject_cast(variant.value())) + attachmentList.append(attachment); + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty() && attachmentList.isEmpty()) return; + if (client() && client()->busy()) return; + + if (!m_committed && m_store) m_store->commitDraft(this); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + ChatMessage* userMessage = + appendNewest(ChatMessage::Role::User, trimmed, now); + if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList); + while (m_model->rowCount() > kMaxStoredMessages) + m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + + if (m_title.isEmpty()) { + QString titleSource = trimmed; + if (titleSource.isEmpty() && !attachmentList.isEmpty()) + titleSource = attachmentList.first()->name(); + m_title = prompts::cleanTitle(titleSource); + Q_EMIT titleChanged(); + persist(); + + if (LlmClient* clientObject = client()) { + clientObject->requestTitle(this, titleSource); + clientObject->requestIcon(this, titleSource); + } + } + + ChatMessage* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); +} + +void ChatSession::retry(ChatMessage* target) { + if (!target || target->role() != ChatMessage::Role::Assistant) return; + if (client() && client()->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; + + target->appendGeneration(QDateTime::currentMSecsSinceEpoch()); + startGeneration(target); +} + +void ChatSession::continueFrom(ChatMessage* message) { + if (!message) return; + if (client() && client()->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 { + ChatMessage* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); + } +} + +void ChatSession::clear() { + if (!m_store) return; + m_store->requestClear(this); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp new file mode 100644 index 0000000..cab1f3d --- /dev/null +++ b/Plugins/ZShell/Llm/session.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include "message.hpp" +#include "messagemodel.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatStore; +class LlmClient; +class Workspace; + +class ChatSession : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") + + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) + Q_PROPERTY(QString icon READ icon WRITE setIcon NOTIFY iconChanged) + Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) + Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(bool pinned READ pinned WRITE setPinned NOTIFY pinnedChanged) + Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) + Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged) + Q_PROPERTY( + ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) + Q_PROPERTY( + QString attachmentError READ attachmentError NOTIFY + attachmentErrorChanged) + + public: + ChatSession(ChatStore* store, 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]] int usageTokens() const { return m_usageTokens; } + [[nodiscard]] ChatMessageModel* messagesModel(); + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + void markLoaded(); + [[nodiscard]] QString attachmentError() const { return m_attachmentError; } + + [[nodiscard]] ChatStore* store() const { return m_store; } + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] Workspace* workspace() const { return m_workspace; } + + void setTitle(const QString& value); + void setIcon(const QString& value); + void setUpdatedAt(qint64 value); + void setPinned(bool value); + void setUsageTokens(int value); + + void hydrate( + const QString& title, + const QString& icon, + qint64 createdAt, + qint64 updatedAt, + int messageCount, + bool pinned); + + [[nodiscard]] bool isCommitted() const { return m_committed; } + void setCommitted(bool value) { m_committed = value; } + + void adoptMessages(QList messages); + void persist(); + void removeMessage(ChatMessage* message); + void clearMessages(); + + Q_INVOKABLE QVariantList addAttachments(const QStringList& paths); + Q_INVOKABLE bool clipboardHasAttachment(); + Q_INVOKABLE QVariantList pasteAttachments(); + Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void sendMessage( + const QString& text, const QVariantList& attachments); + 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(); + void usageTokensChanged(); + void attachmentErrorChanged(); + + private: + void onModelRowsChanged(); + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void startGeneration(ChatMessage* target); + void setCount(int value); + void setAttachmentError(const QString& value); + + ChatStore* m_store; + QString m_id; + bool m_committed = false; + QString m_title; + QString m_icon; + qint64 m_createdAt = 0; + qint64 m_updatedAt = 0; + bool m_pinned = false; + int m_messageCount = 0; + int m_usageTokens = 0; + ChatMessageModel* m_model = nullptr; + Workspace* m_workspace = nullptr; + bool m_loadRequested = false; + bool m_loaded = false; + QString m_attachmentError; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.cpp b/Plugins/ZShell/Llm/shelltool.cpp new file mode 100644 index 0000000..e9ce098 --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.cpp @@ -0,0 +1,235 @@ +#include "shelltool.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +ShellTool::ShellTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} + +ShellTool::~ShellTool() { + for (auto* job : m_jobs) { + if (job->timer) job->timer->stop(); + if (job->process) job->process->kill(); + delete job; + } + m_jobs.clear(); +} + +QString ShellTool::name() const { + return QStringLiteral("runcommand"); +} + +QString ShellTool::description() const { + return QStringLiteral( + "Run a shell command inside a bubblewrap sandbox that contains " + "the files the user attached to the conversation. The rest of " + "the filesystem is mounted read-only, there is no network " + "access, and only the sandbox directory is writable. The " + "command is executed with /bin/sh and the sandbox directory as " + "its working directory, so relative paths refer to the sandbox. " + "Use it to explore, transform, or extract attached files (for " + "example: ls, find, grep, unzip, tar, head, wc). The combined " + "output is limited; the exit code is always reported. The call " + "only completes when every child process has exited, so do not " + "start long-lived background processes."); +} + +QJsonObject ShellTool::parameters() const { + QJsonObject command; + command[QStringLiteral("type")] = QStringLiteral("string"); + command[QStringLiteral("description")] = + QStringLiteral("The shell command to execute"); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Timeout in seconds. Defaults to %1.") + .arg(DefaultTimeoutSeconds); + + QJsonObject properties; + properties[QStringLiteral("command")] = command; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("command")); + schema[QStringLiteral("required")] = required; + return schema; +} + +QStringList ShellTool::sandboxArguments(const QString& workspace) { + QStringList args; + args << QLatin1String("--unshare-user") << QLatin1String("--uid") + << QString::number(::getuid()) << QLatin1String("--gid") + << QString::number(::getgid()) << QLatin1String("--disable-userns") + << QLatin1String("--unshare-pid") << QLatin1String("--unshare-net") + << QLatin1String("--unshare-ipc") << QLatin1String("--unshare-uts") + << QLatin1String("--hostname") << QLatin1String("zshell-sandbox"); + args << QLatin1String("--ro-bind") << QLatin1String("/") + << QLatin1String("/") << QLatin1String("--tmpfs") + << QLatin1String("/tmp") << QLatin1String("--tmpfs") + << QLatin1String("/run") << QLatin1String("--bind") << workspace + << workspace << QLatin1String("--dev") << QLatin1String("/dev") + << QLatin1String("--proc") << QLatin1String("/proc"); + args << QLatin1String("--chdir") << workspace; + args << QLatin1String("--clearenv") << QLatin1String("--setenv") + << QLatin1String("PATH") + << QLatin1String( + "/usr/local/sbin:/usr/local/bin:/usr/sbin:" + "/usr/bin:/sbin:/bin") + << QLatin1String("--setenv") << QLatin1String("HOME") << workspace + << QLatin1String("--setenv") << QLatin1String("TMPDIR") + << QLatin1String("/tmp") << QLatin1String("--setenv") + << QLatin1String("SHELL") << QLatin1String("/bin/sh"); + const QByteArray user = qgetenv("USER"); + if (!user.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("USER") + << QString::fromLocal8Bit(user) << QLatin1String("--setenv") + << QLatin1String("LOGNAME") << QString::fromLocal8Bit(user); + const QByteArray lang = qgetenv("LANG"); + if (!lang.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("LANG") + << QString::fromLocal8Bit(lang); + args << QLatin1String("--new-session") + << QLatin1String("--die-with-parent"); + return args; +} + +bool ShellTool::available(const ToolContext& context) const { + return registry()->sandboxEnabled() && !context.workspace.isEmpty(); +} + +void ShellTool::execute( + const ToolContext& context, const QJsonObject& args, ToolDone done) { + const QString command = args[QStringLiteral("command")].toString(); + if (command.trimmed().isEmpty()) { + done({false, QStringLiteral("Missing required argument 'command'")}); + return; + } + if (!registry()->sandboxEnabled()) { + done( + {false, + QStringLiteral( + "The file sandbox is disabled, so no commands can run")}); + return; + } + const QString workspace = context.workspace; + if (workspace.isEmpty()) { + done( + {false, + QStringLiteral("No sandbox available: no chat session is active")}); + return; + } + QDir().mkpath(workspace); + + const int timeoutSeconds = qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds); + auto* job = new Job; + job->timeoutSeconds = timeoutSeconds; + job->outputBudgetChars = context.outputBudgetChars; + job->done = std::move(done); + m_jobs.append(job); + + QProcess* process = new QProcess(this); + process->setWorkingDirectory(workspace); + process->setProcessChannelMode(QProcess::MergedChannels); + job->process = process; + + QTimer* timer = new QTimer(this); + timer->setSingleShot(true); + connect(timer, &QTimer::timeout, this, [this, job, process]() { + job->timedOut = true; + process->kill(); + }); + timer->start(timeoutSeconds * 1000); + job->timer = timer; + + connect( + process, + QOverload::of(&QProcess::finished), + this, + [this, job](int, QProcess::ExitStatus) { finishJob(job); }); + connect( + process, + &QProcess::errorOccurred, + this, + [this, job, process](QProcess::ProcessError error) { + if (job->finished) return; + if (error == QProcess::FailedToStart || + process->state() == QProcess::NotRunning) { + job->startError = process->errorString(); + finishJob(job); + } else { + process->kill(); + } + }); + + QStringList commandArgs = sandboxArguments(workspace); + commandArgs << QStringLiteral("sh") << QStringLiteral("-c") << command; + process->start(QStringLiteral("bwrap"), commandArgs); +} + +void ShellTool::cancel() { + for (auto* job : m_jobs) { + if (job->finished || !job->process) continue; + job->cancelled = true; + job->process->kill(); + } +} + +void ShellTool::finishJob(Job* job) { + if (!job || job->finished) return; + job->finished = true; + if (job->timer) job->timer->stop(); + m_jobs.removeAll(job); + + QProcess* process = job->process; + const bool ran = process && process->state() != QProcess::NotRunning; + const int exitCode = ran ? process->exitCode() : -1; + QString output; + if (process) output = QString::fromUtf8(process->readAllStandardOutput()); + + QString note; + if (!job->startError.isEmpty()) + note = + QStringLiteral("Command failed to start: %1\n").arg(job->startError); + else if (job->cancelled) + note = QStringLiteral("Command was cancelled.\n"); + else if (job->timedOut) + note = QStringLiteral( + "Command timed out after %1 seconds and was killed.\n") + .arg(job->timeoutSeconds); + + int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars + : registry()->outputBudgetChars(1); + if (output.size() > budget) + output = output.left(budget) + QStringLiteral( + "\n[output truncated: %1 characters " + "total]") + .arg(output.size()); + + const QString result = + note + QStringLiteral("exit code: %1\n").arg(exitCode) + + (output.isEmpty() ? QStringLiteral("(no output)") : output); + const bool ok = !job->cancelled && !job->timedOut && exitCode == 0; + + ToolDone done = std::move(job->done); + if (process) process->deleteLater(); + delete job; + if (done) done({ok, result}); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.hpp b/Plugins/ZShell/Llm/shelltool.hpp new file mode 100644 index 0000000..c4f8f4a --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +class QProcess; +class QTimer; + +namespace ZShell::llm { + +class ShellTool : public LlmTool { + Q_OBJECT + + public: + explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr); + ~ShellTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + bool available(const ToolContext& context) const override; + void execute( + const ToolContext& context, + const QJsonObject& args, + ToolDone done) override; + void cancel() override; + + static constexpr int DefaultTimeoutSeconds = 60; + static constexpr int MaxTimeoutSeconds = 300; + + private: + struct Job { + QPointer process; + QPointer timer; + int timeoutSeconds = DefaultTimeoutSeconds; + int outputBudgetChars = 0; + ToolDone done; + QString startError; + bool timedOut = false; + bool cancelled = false; + bool finished = false; + }; + + static QStringList sandboxArguments(const QString& workspace); + + void finishJob(Job* job); + + QList m_jobs; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp new file mode 100644 index 0000000..622ab00 --- /dev/null +++ b/Plugins/ZShell/Llm/tool.cpp @@ -0,0 +1,78 @@ +#include "tool.hpp" + +namespace ZShell::llm { + +LlmTool::LlmTool(ToolRegistry* registry, QObject* parent) + : QObject(parent), m_registry(registry) {} + +LlmTool::~LlmTool() = default; + +bool LlmTool::available(const ToolContext& context) const { + Q_UNUSED(context); + return true; +} + +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::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 (auto* tool : m_tools) + if (tool->name() == name) return tool; + return nullptr; +} + +QJsonArray ToolRegistry::specifications(const ToolContext& context) const { + QJsonArray specs; + for (auto* tool : m_tools) + if (tool->available(context)) specs.append(tool->specification()); + return specs; +} + +void ToolRegistry::cancelAll() { + for (auto* tool : m_tools) + tool->cancel(); +} + +void ToolRegistry::setContextSize(int value) { + if (m_contextSize == value) return; + m_contextSize = value; +} + +void ToolRegistry::setEnabled(bool value) { + if (m_enabled == value) return; + m_enabled = value; +} + +void ToolRegistry::setSandboxEnabled(bool value) { + if (m_sandbox == value) return; + m_sandbox = value; +} + +int ToolRegistry::outputBudgetChars(int callCount) const { + if (m_contextSize <= 0) return DefaultInlineChars; + const int tokens = qBound( + MinBudgetTokens, + m_contextSize / qMax(1, callCount), + DefaultInlineChars / CharsPerToken); + return tokens * CharsPerToken; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp new file mode 100644 index 0000000..53a1d39 --- /dev/null +++ b/Plugins/ZShell/Llm/tool.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +struct ToolResult { + bool ok = false; + QString text; +}; + +struct ToolContext { + QString toolCallId; + QString workspace; + int outputBudgetChars = 0; +}; + +using ToolDone = std::function; + +class ToolRegistry; + +class LlmTool : public QObject { + Q_OBJECT + + public: + explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr); + ~LlmTool() override; + + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QString description() const = 0; + [[nodiscard]] virtual QJsonObject parameters() const = 0; + + [[nodiscard]] virtual bool available(const ToolContext& context) const; + + virtual void execute( + const ToolContext& context, const QJsonObject& args, ToolDone done) = 0; + + virtual void cancel(); + + [[nodiscard]] QJsonObject specification() const; + + [[nodiscard]] ToolRegistry* registry() const { return m_registry; } + + private: + ToolRegistry* m_registry; +}; + +class ToolRegistry : public QObject { + Q_OBJECT + + public: + explicit ToolRegistry(QObject* parent = nullptr); + + void registerTool(LlmTool* tool); + [[nodiscard]] LlmTool* tool(const QString& name) const; + [[nodiscard]] QList tools() const { return m_tools; } + [[nodiscard]] QJsonArray specifications(const ToolContext& context) const; + void cancelAll(); + + [[nodiscard]] bool enabled() const { return m_enabled; } + void setEnabled(bool value); + [[nodiscard]] bool sandboxEnabled() const { return m_sandbox; } + void setSandboxEnabled(bool value); + + [[nodiscard]] int contextSize() const { return m_contextSize; } + void setContextSize(int value); + + [[nodiscard]] int outputBudgetChars(int callCount) const; + + static constexpr int CharsPerToken = 4; + static constexpr int DefaultInlineChars = 64 * 1024; + static constexpr int MinBudgetTokens = 512; + + private: + bool m_enabled = true; + bool m_sandbox = true; + int m_contextSize = 0; + QList m_tools; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/turn.cpp b/Plugins/ZShell/Llm/turn.cpp new file mode 100644 index 0000000..fe39d74 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.cpp @@ -0,0 +1,391 @@ +#include "turn.hpp" + +#include "attachment.hpp" +#include "message.hpp" +#include "messagemodel.hpp" +#include "prompts.hpp" +#include "session.hpp" +#include "workspace.hpp" + +#include +#include + +namespace ZShell::llm { + +namespace { + +constexpr int kMinContextTokens = 512; +constexpr int kToolResultStubChars = 512; +constexpr double kPromptEstimateDiscount = 10.0 / 12.0; + +qsizetype jsonValueChars(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::String: + return value.toString().size(); + case QJsonValue::Array: { + qsizetype total = 0; + for (const QJsonValue& item : value.toArray()) + total += jsonValueChars(item); + return total; + } + case QJsonValue::Object: { + qsizetype total = 0; + const QJsonObject obj = value.toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) + total += jsonValueChars(it.value()); + return total; + } + default: + return 2; + } +} + +int estimateMessageTokens(const QJsonObject& message) { + return int(jsonValueChars(message) / ToolRegistry::CharsPerToken) + 4; +} + +int estimateArrayTokens(const QJsonArray& messages) { + int total = 0; + for (const QJsonValue& value : messages) + total += estimateMessageTokens(value.toObject()); + return total; +} + +int completionReserve(int contextSize) { + return qBound(2048, contextSize / 8, 8192); +} + +void shrinkUnitToBudget(QList& unit, int budgetTokens) { + static const QStringList kTextKeys = { + QStringLiteral("content"), QStringLiteral("reasoning_content")}; + for (int pass = 0; pass < 8; ++pass) { + int tokens = 0; + for (const QJsonObject& message : unit) + tokens += estimateMessageTokens(message); + if (tokens <= budgetTokens) return; + qsizetype bestMessage = -1; + QString bestKey; + qsizetype bestLength = 0; + for (qsizetype i = 0; i < unit.size(); ++i) { + const QJsonObject message = unit.at(i); + for (const QString& key : kTextKeys) { + const QJsonValue value = message.value(key); + if (value.isString() && value.toString().size() > bestLength) { + bestMessage = i; + bestKey = key; + bestLength = value.toString().size(); + } + } + } + if (bestMessage < 0) return; + QString text = unit.at(bestMessage).value(bestKey).toString(); + text.truncate(qMax(32, text.size() * 3 / 4)); + const QString marker = QStringLiteral("\n[…]"); + if (!text.endsWith(marker)) text += marker; + unit[bestMessage][bestKey] = text; + } +} + +} // namespace + +Turn::Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry) + : m_session(session), m_generation(generation), m_registry(registry) {} + +int Turn::targetRow() const { + if (!m_session) return -1; + const ChatMessage* message = m_generation ? m_generation->message() + : nullptr; + return message ? m_session->messagesModel()->rowOf(message) : -1; +} + +int Turn::transcriptTokens() const { + return estimateArrayTokens(m_transcript); +} + +int Turn::promptBudgetTokens() const { + const int contextSize = m_registry.contextSize(); + if (contextSize <= 0) return 0; + return int( + (contextSize - completionReserve(contextSize)) * + kPromptEstimateDiscount); +} + +int Turn::batchSize() const { + int size = 0; + for (const Call& call : m_calls) + if (call.seen) ++size; + return size; +} + +int Turn::outputBudgetChars() const { + const int batch = qMax(1, batchSize()); + const int promptLimit = promptBudgetTokens(); + if (promptLimit <= 0) return m_registry.outputBudgetChars(batch); + const int available = promptLimit - transcriptTokens(); + const int tokens = qMax(available / batch, ToolRegistry::MinBudgetTokens); + return tokens * ToolRegistry::CharsPerToken; +} + +void Turn::resetRound() { + roundDone = false; + streamDone = false; + toolPhase = false; + pendingCalls = 0; + finishReason.clear(); + m_calls.clear(); + m_results.clear(); +} + +void Turn::appendToTranscript(const QJsonObject& message) { + m_transcript.append(message); +} + +QJsonObject Turn::roundAssistantMessage() { + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + + const QString content = m_generation->content(); + const QString reasoning = m_generation->reasoning(); + 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 Call& call : m_calls) { + if (!call.seen) continue; + QJsonObject function; + function[QStringLiteral("name")] = call.name; + function[QStringLiteral("arguments")] = call.arguments; + QJsonObject entry; + entry[QStringLiteral("id")] = call.id; + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + calls.append(entry); + } + assistant[QStringLiteral("tool_calls")] = calls; + return assistant; +} + +QJsonArray Turn::toolMessages() const { + QJsonArray messages; + for (int i = 0; i < m_calls.size(); ++i) { + const Call& call = m_calls.at(i); + if (!call.seen) continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = m_results.at(i).text; + messages.append(toolMessage); + } + return messages; +} + +bool Turn::roundHasCalls() const { + for (const Call& call : m_calls) + if (call.seen) return true; + return false; +} + +void Turn::appendSandboxPreamble(QJsonArray& messages) const { + if (!m_registry.sandboxEnabled()) return; + const QString workspace = m_session->workspace()->path(); + if (workspace.isEmpty()) return; + + const auto* model = m_session->messagesModel(); + QStringList files; + QSet seenPaths; + for (int row = 0; row < model->rowCount(); ++row) { + const auto* message = model->at(row); + if (message->role() != ChatMessage::Role::User) continue; + for (const auto* attachment : message->attachments()) { + if (attachment->kind() != Attachment::Kind::File) continue; + const QString relativePath = + attachment->path().mid(workspace.size() + 1); + if (seenPaths.contains(relativePath)) continue; + seenPaths.insert(relativePath); + files.append(QStringLiteral("- %1 (available as %2)") + .arg(attachment->name(), relativePath)); + } + } + if (files.isEmpty()) return; + + QJsonObject preamble; + preamble[QStringLiteral("role")] = QStringLiteral("system"); + preamble[QStringLiteral("content")] = + prompts::sandboxPreamble(workspace, files); + messages.prepend(preamble); +} + +QJsonArray Turn::contextMessages() const { + const auto* model = m_session->messagesModel(); + const int stopBeforeRow = targetRow(); + + QList> units; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + if (message->role() == ChatMessage::Role::User) { + if (message->content().isEmpty() && + message->attachments().isEmpty()) + continue; + + QList unit; + + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + const QString text = message->content(); + const QList attachments = message->attachments(); + if (attachments.isEmpty()) { + user[QStringLiteral("content")] = text; + } else { + QJsonArray parts; + if (!text.isEmpty()) { + QJsonObject textPart; + textPart[QStringLiteral("type")] = QStringLiteral("text"); + textPart[QStringLiteral("text")] = text; + parts.append(textPart); + } + for (const auto* attachment : attachments) { + if (attachment->kind() != Attachment::Kind::Image) continue; + QFile file(attachment->path()); + if (!file.open(QIODevice::ReadOnly)) continue; + const QByteArray base64 = file.readAll().toBase64(); + file.close(); + if (base64.isEmpty()) continue; + QJsonObject imageUrl; + imageUrl[QStringLiteral("url")] = + QStringLiteral("data:%1;base64,%2") + .arg(attachment->mimeType()) + .arg(QString::fromLatin1(base64)); + QJsonObject imagePart; + imagePart[QStringLiteral("type")] = + QLatin1String("image_url"); + imagePart[QStringLiteral("image_url")] = imageUrl; + parts.append(imagePart); + } + if (parts.isEmpty()) + user[QStringLiteral("content")] = text; + else + user[QStringLiteral("content")] = parts; + } + unit.append(user); + units.append(unit); + continue; + } + + const ChatGeneration* generation = message->activeGeneration(); + if (!generation) continue; + const QList calls = generation->toolCalls(); + const bool hasAnyContent = !generation->content().isEmpty() || + !generation->reasoning().isEmpty() || + !calls.isEmpty(); + if (!hasAnyContent) continue; + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (!calls.isEmpty() || !generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + if (!calls.isEmpty()) { + QJsonArray callArray; + for (const auto* call : calls) { + QJsonObject function; + function[QStringLiteral("name")] = call->name(); + function[QStringLiteral("arguments")] = call->arguments(); + QJsonObject entry; + entry[QStringLiteral("id")] = call->toolCallId(); + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + callArray.append(entry); + } + assistant[QStringLiteral("tool_calls")] = callArray; + } + + QList unit; + unit.append(assistant); + for (const auto* call : calls) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call->toolCallId(); + toolMessage[QStringLiteral("content")] = call->result(); + unit.append(toolMessage); + } + units.append(unit); + } + + if (m_registry.contextSize() > 0) { + const int reserve = transcriptTokens(); + int budget = m_registry.contextSize() - + completionReserve(m_registry.contextSize()) - reserve; + budget = qMax(budget, kMinContextTokens); + + int total = 0; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + total += estimateMessageTokens(message); + while (units.size() > 1 && total > budget) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + while (units.size() > 1 && + units.first().first()[QStringLiteral("role")].toString() == + QLatin1String("assistant")) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + if (units.size() == 1) { + QList single = units.first(); + shrinkUnitToBudget(single, budget); + units[0] = single; + } + } + + QJsonArray messages; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + messages.append(message); + + for (const QJsonValue& value : m_transcript) + messages.append(value); + + if (m_registry.contextSize() > 0) { + const int promptLimit = promptBudgetTokens(); + while (estimateArrayTokens(messages) > promptLimit) { + bool stubbed = false; + for (qsizetype i = 0; i < messages.size(); ++i) { + QJsonObject message = messages.at(i).toObject(); + if (message.value("role").toString() != QLatin1String("tool")) + continue; + const QString content = message.value("content").toString(); + if (content.size() <= kToolResultStubChars) continue; + QString stub = content.left(kToolResultStubChars); + stub += QStringLiteral( + "\n[truncated to fit the context window; was " + "%1 characters]") + .arg(content.size()); + message["content"] = stub; + messages[i] = message; + stubbed = true; + break; + } + if (!stubbed) break; + } + } + + appendSandboxPreamble(messages); + return messages; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/turn.hpp b/Plugins/ZShell/Llm/turn.hpp new file mode 100644 index 0000000..7669a57 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "generation.hpp" +#include "segment.hpp" +#include "tool.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Turn { + public: + Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry); + + [[nodiscard]] ChatSession* session() const { return m_session; } + [[nodiscard]] ChatGeneration* generation() const { return m_generation; } + [[nodiscard]] const ToolRegistry& registry() const { return m_registry; } + + [[nodiscard]] QJsonArray contextMessages() const; + [[nodiscard]] int transcriptTokens() const; + [[nodiscard]] int promptBudgetTokens() const; + + [[nodiscard]] int targetRow() const; + + void resetRound(); + + [[nodiscard]] QJsonObject roundAssistantMessage(); + [[nodiscard]] QJsonArray toolMessages() const; + [[nodiscard]] bool roundHasCalls() const; + + struct Call { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + QList& calls() { return m_calls; } + [[nodiscard]] const QList& calls() const { return m_calls; } + + QList& results() { return m_results; } + [[nodiscard]] const QList& results() const { return m_results; } + + void appendToTranscript(const QJsonObject& message); + [[nodiscard]] const QJsonArray& transcript() const { return m_transcript; } + + [[nodiscard]] int batchSize() const; + [[nodiscard]] int outputBudgetChars() const; + + int round = 0; + int pendingCalls = 0; + bool roundDone = false; + bool streamDone = false; + bool toolPhase = false; + bool approvalPending = false; + bool contextRetryUsed = false; + QString finishReason; + + private: + void appendSandboxPreamble(QJsonArray& messages) const; + + ChatSession* m_session; + ChatGeneration* m_generation; + const ToolRegistry& m_registry; + QJsonArray m_transcript; + QList m_calls; + QList m_results; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp new file mode 100644 index 0000000..71c859f --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -0,0 +1,464 @@ +#include "webfetchtool.hpp" + +#include +#include +#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"; + +} // namespace + +WebFetchTool::WebFetchTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, 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. Only a limited amount of content is returned inline; " + "the full output of every call is saved in the sandbox directory " + "of the conversation, where it can be paged through with the " + "readfile tool. 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, ToolResult 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 ToolContext& context, const QJsonObject& args, ToolDone done) { + auto* job = new Job; + job->done = std::move(done); + job->toolCallId = context.toolCallId; + job->budgetChars = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); + job->storagePath = + context.workspace.isEmpty() + ? QDir(QDir::tempPath()) + .filePath(QStringLiteral("zshell-llm/webfetch")) + : context.workspace + QStringLiteral("/webfetch"); + m_jobs.append(job); + + auto fail = [this, job](const QString& message) { + job->timer->stop(); + completeJob(job, {false, 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, + ToolResult{ + false, + QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit")}); + return; + } + if (error != QNetworkReply::NoError) { + completeJob( + job, + ToolResult{ + false, + QStringLiteral("Request failed: %1").arg(errorString)}); + return; + } + if (status < 200 || status >= 300) { + completeJob( + job, + ToolResult{ + false, + 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, + ToolResult{ + false, + 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, + ToolResult{ + false, + 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); + + const QString savedPath = saveToFile(*job, content, mime); + + const int budget = job->budgetChars; + QString output = content; + if (content.size() > budget) { + output = content.left(budget); + const QString note = + QStringLiteral( + "\n\n[... truncated: showing %1 of %2 characters") + .arg(budget) + .arg(content.size()); + if (!savedPath.isEmpty()) + output += note + QStringLiteral( + ". The full content is saved to %1; use " + "the readfile tool to read the rest.") + .arg(savedPath); + else + output += note + QStringLiteral( + ". The remaining content is not " + "available."); + } + completeJob(job, ToolResult{true, output}); + }); +} + +QString WebFetchTool::saveToFile( + const Job& job, const QString& content, const QString& mime) const { + QDir dir(job.storagePath); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString(); + + QString fileName; + fileName.reserve(job.toolCallId.size()); + for (const QChar& c : job.toolCallId) + fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') || + c == QLatin1Char('_')) + ? c + : QLatin1Char('_'); + if (fileName.isEmpty()) fileName = QStringLiteral("fetch"); + + QString extension = QStringLiteral("txt"); + if (job.format == QLatin1String("html")) + extension = QStringLiteral("html"); + else if (mime.contains(QLatin1String("json"))) + extension = QStringLiteral("json"); + else if (mime.contains(QLatin1String("xml"))) + extension = QStringLiteral("xml"); + + const QString path = dir.filePath(fileName + QLatin1Char('.') + extension); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + file.write(content.toUtf8()); + return path; +} + +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..0a47ac3 --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -0,0 +1,60 @@ +#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; + + explicit WebFetchTool(ToolRegistry* registry, QObject* parent = nullptr); + ~WebFetchTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const ToolContext& context, + const QJsonObject& args, + ToolDone 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; + QString toolCallId; + QString storagePath; + int budgetChars = 0; + ToolDone done; + }; + + void completeJob(Job* job, ToolResult result); + QString saveToFile( + const Job& job, const QString& content, const QString& mime) const; + + QNetworkAccessManager m_manager; + QList m_jobs; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/workspace.cpp b/Plugins/ZShell/Llm/workspace.cpp new file mode 100644 index 0000000..6dc22df --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.cpp @@ -0,0 +1,89 @@ +#include "workspace.hpp" + +#include "session.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +Workspace::Workspace(ChatSession* session, QObject* parent) + : QObject(parent), m_session(session) { + m_path = pathFor(session->id()); +} + +QString Workspace::pathFor(const QString& sessionId) { + const QString state = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return state + QStringLiteral("/zshell/sandboxes/") + sessionId; +} + +bool Workspace::ensure() { + return QDir().mkpath(m_path); +} + +QList Workspace::importFiles( + const QStringList& paths, QString& error) { + error.clear(); + if (!ensure()) { + error = QStringLiteral("Could not create the sandbox directory"); + return {}; + } + + QMimeDatabase mimeDatabase; + QList result; + for (const QString& rawPath : paths) { + const QFileInfo info(rawPath); + if (!info.exists() || !info.isFile()) { + qWarning() << "Workspace: skipping" << rawPath + << ": file not found or not a file"; + error = QStringLiteral("File not found: %1").arg(info.fileName()); + continue; + } + + const QString mimeType = mimeDatabase.mimeTypeForFile(info).name(); + const bool isImage = + mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); + const qint64 cap = isImage ? MaxImageBytes : MaxFileBytes; + if (info.size() > cap) { + qWarning() << "Workspace: skipping" << rawPath << ": too large (" + << info.size() << "bytes, cap" << cap << ")"; + error = QStringLiteral("File too large: %1").arg(info.fileName()); + continue; + } + + QString destination = m_path + QLatin1Char('/') + info.fileName(); + int suffix = 1; + while (QFile::exists(destination)) { + const QString extension = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + destination = m_path + QLatin1Char('/') + info.completeBaseName() + + QStringLiteral("_%1%2").arg(suffix).arg(extension); + ++suffix; + } + + if (!QFile::copy(info.absoluteFilePath(), destination)) { + qWarning() << "Workspace: skipping" << rawPath << ": copy to" + << destination << "failed"; + error = + QStringLiteral("Could not copy file: %1").arg(info.fileName()); + continue; + } + + result.append(new Attachment( + info.fileName(), + destination, + isImage ? Attachment::Kind::Image : Attachment::Kind::File, + mimeType, + info.size(), + m_session)); + } + return result; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/workspace.hpp b/Plugins/ZShell/Llm/workspace.hpp new file mode 100644 index 0000000..e5739e9 --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "attachment.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Workspace : public QObject { + Q_OBJECT + + public: + explicit Workspace(ChatSession* session, QObject* parent = nullptr); + + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] static QString pathFor(const QString& sessionId); + + [[nodiscard]] bool ensure(); + + [[nodiscard]] QList importFiles( + const QStringList& paths, QString& error); + + static constexpr qint64 MaxImageBytes = 20 * 1024 * 1024; + static constexpr qint64 MaxFileBytes = 100 * 1024 * 1024; + + private: + ChatSession* m_session; + QString m_path; +}; + +} // namespace ZShell::llm diff --git a/Plugins/cmake/pch.cmake b/Plugins/cmake/pch.cmake index c5685fb..814d6b0 100644 --- a/Plugins/cmake/pch.cmake +++ b/Plugins/cmake/pch.cmake @@ -1,4 +1,5 @@ add_library(zshell-pch INTERFACE) +target_compile_options(zshell-pch INTERFACE $<$:-Wno-null-dereference>) target_precompile_headers(zshell-pch INTERFACE diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag new file mode 100644 index 0000000..1edf7f7 --- /dev/null +++ b/assets/shaders/geminiglow.frag @@ -0,0 +1,102 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + vec2 rectSize; + float radius; + float glowSize; + float innerGlow; + float lineWidth; + float phase; + float intensity; + vec4 color1; + vec4 color2; + vec4 color3; + vec4 color4; +}; + +const float TAU = 6.28318530718; + +float sdRoundBox(vec2 p, vec2 halfSize, float r) +{ + vec2 q = abs(p) - (halfSize - r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +vec3 palette(float t) +{ + t = fract(t) * 4.0; + float f = smoothstep(0.0, 1.0, fract(t)); + if (t < 1.0) return mix(color1.rgb, color2.rgb, f); + if (t < 2.0) return mix(color2.rgb, color3.rgb, f); + if (t < 3.0) return mix(color3.rgb, color4.rgb, f); + return mix(color4.rgb, color1.rgb, f); +} + +float hash(vec2 p) +{ + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() +{ + vec2 p = (qt_TexCoord0 - 0.5) * itemSize; + + float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); + float d = sdRoundBox(p, rectSize * 0.5, r); + + // ~1px antialiasing width + float aa = max(fwidth(d), 0.75); + + // Coverage of the shape: 1 inside, 0 outside, soft across the edge + float inside = 1.0 - smoothstep(-aa, aa, d); + + // Only skip work when clearly outside and there's no outer glow + if (glowSize <= 0.0 && d > aa) { + fragColor = vec4(0.0); + return; + } + + float ang = atan(p.y, p.x); + float a2 = phase * TAU; + float u = ang / TAU + 0.5 + phase; + u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); + vec3 col = palette(u); + + float g = 0.0; + if (d > 0.0) { + float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); + g = (glowSize > 0.0) ? pow(1.0 - t, 3.0) : 0.0; + } else if (innerGlow > 0.0) { + float t = clamp(-d / innerGlow, 0.0, 1.0); + g = pow(1.0 - t, 3.0); + } + + float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); + g *= pulse; + + // Line: inside only, with a soft inner edge AND a soft outer edge + float line = 0.0; + if (lineWidth > 0.0) { + float inner = 1.0 - smoothstep(lineWidth - aa, lineWidth + aa, -d); + line = inner * inside; + } + + float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); + + // Antialias the outer silhouette when there is no outer glow + if (glowSize <= 0.0) + a *= inside; + + col = mix(col, vec3(1.0), line * 0.25); + + a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; + a = clamp(a, 0.0, 1.0); + + fragColor = vec4(col * a, a) * qt_Opacity; +} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb new file mode 100644 index 0000000..487934a Binary files /dev/null and b/assets/shaders/geminiglow.frag.qsb differ 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/scripts/build-settings-index.py b/scripts/build-settings-index.py deleted file mode 100644 index b2e0df1..0000000 --- a/scripts/build-settings-index.py +++ /dev/null @@ -1,452 +0,0 @@ -from __future__ import annotations - -import json -import re -import sys -from collections import defaultdict -from functools import cache -from pathlib import Path - - -@cache -def read_lines(path: Path) -> tuple[str, ...]: - return tuple(path.read_text().splitlines()) - - -ROW_RE = re.compile( - r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{" -) -LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)') -ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') -CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$") -ONTOGGLED_RE = re.compile( - r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$" -) -ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') -SKIP_LABELS = {"Muted", "None"} -FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} -STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"} - - -def find_pages_dir(settings: Path) -> Path: - return settings / "Pages" - - -def discover_files(settings: Path) -> dict[str, Path]: - files: dict[str, Path] = {} - for p in find_pages_dir(settings).rglob("*.qml"): - files[p.stem] = p - return files - - -PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)') -PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') - - -def parse_page_registry(settings: Path) -> list[tuple[str, str]]: - text = (settings / "PageRegistry.qml").read_text().splitlines() - - start = next( - i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line) - ) - - out: list[tuple[str, str]] = [] - i = start + 1 - - while i < len(text): - line = text[i].strip() - - if line.startswith("]"): - break - - if line.startswith("//") or not line: - i += 1 - continue - - if line.startswith("{"): - name = None - icon = None - i += 1 - - while i < len(text): - s = text[i].strip() - - if s.startswith("}"): - if name is not None: - out.append((icon or "tune", name)) - break - - if name is None: - m = PAGE_NAME_RE.match(text[i]) - if m: - name = m.group(1) - - if icon is None: - mi = PAGE_ICON_RE.match(text[i]) - if mi: - icon = mi.group(1) - - i += 1 - - i += 1 - - return out - - -BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$") - - -def _strip_comment(line: str) -> str: - return line.split("//", 1)[0].rstrip() - - -def parse_block( - lines: list[str], i: int -) -> tuple[str, list[tuple[str, list]], int]: - line = _strip_comment(lines[i]).strip() - m = BLOCK_RE.match(line) - if not m: - raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}") - - name = m.group(1) - i += 1 - children: list[tuple[str, list]] = [] - - while i < len(lines): - s = _strip_comment(lines[i]).strip() - if not s: - i += 1 - continue - - if s.startswith("}"): - return name, children, i + 1 - - if BLOCK_RE.match(s): - child_name, child_children, i = parse_block(lines, i) - children.append((child_name, child_children)) - continue - - i += 1 - - raise ValueError(f"Unterminated block: {name}") - - -def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: - name, children = block - - if name != "Component": - return [name] - - for child_name, child_children in children: - if child_name == "StackPage": - out: list[str] = [] - for grand_name, grand_children in child_children: - if grand_name == "Component": - out.extend(collect_page_names((grand_name, grand_children))) - return out - - if child_name != "Component": - return [child_name] - - return [] - - -def parse_page_comps(settings: Path) -> list[list[str]]: - text = (settings / "PageCompRegistry.qml").read_text().splitlines() - - start = next( - i - for i, line in enumerate(text) - if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line)) - ) - - comps: list[list[str]] = [] - i = start + 1 - - while i < len(text): - s = _strip_comment(text[i]).strip() - if not s: - i += 1 - continue - if s.startswith("]"): - break - - if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": - block = parse_block(text, i) - names = collect_page_names((block[0], block[1])) - if names: - comps.append(names) - i = block[2] - continue - - i += 1 - - return comps - - -def dedup_crumbs( - labels: list[str], icons: list[str] -) -> tuple[list[str], list[str]]: - out_labels: list[str] = [] - out_icons: list[str] = [] - for lbl, ico in zip(labels, icons, strict=False): - if out_labels and out_labels[-1] == lbl: - continue - out_labels.append(lbl) - out_icons.append(ico) - return out_labels, out_icons - - -def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: - comps = parse_page_comps(settings) - registry = parse_page_registry(settings) - - top_meta: dict[int, tuple[str, str]] = {} - for i, (icon, label) in enumerate(registry): - top_meta[i] = (icon, label) - - nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} - for names in comps: - for name in names: - pf = files.get(name) - if not pf: - continue - pending_icon = pending_label = None - section = "" - expect_section = False - for ln in read_lines(pf): - if SECTION_RE.match(ln): - expect_section = True - continue - ml = LABEL_RE.match(ln) - if ml: - if expect_section: - section = ml.group(1) - expect_section = False - else: - pending_label = ml.group(1) - continue - mi = ICON_RE.match(ln) - if mi: - pending_icon = mi.group(1) - mo = re.search(r"openSubPage\((\d+)\)", ln) - if mo: - pos = int(mo.group(1)) - nav_children.setdefault(name, {})[pos] = ( - pending_icon or "tune", - pending_label or "", - section, - ) - pending_icon = pending_label = None - - nav: dict[str, dict] = {} - for top_idx, names in enumerate(comps): - if not names: - continue - main = names[0] - main_icon, main_label = top_meta.get(top_idx, ("tune", main)) - nav[main] = { - "pageIdx": top_idx, - "subPath": [], - "crumbIcons": [main_icon], - "crumbLabels": [main_label], - } - children = dict(nav_children.get(main, {})) - opened_via_subpage = set() - for owner, kids in nav_children.items(): - owner_group = next((ns for ns in comps if owner in ns), None) - if not owner_group: - continue - for kpos in kids: - if kpos < len(owner_group): - opened_via_subpage.add(owner_group[kpos]) - for pos in range(1, len(names)): - if pos not in children and names[pos] not in opened_via_subpage: - label = re.sub(r"(Detail)?Page$", "", names[pos]) - label = re.sub(r"(?= len(names): - continue - child = names[pos] - labels = [main_label] + ([section] if section else []) + [label] - icons = [main_icon] + ([icon] if section else []) + [icon] - labels, icons = dedup_crumbs(labels, icons) - nav[child] = { - "pageIdx": top_idx, - "subPath": [pos], - "crumbIcons": icons, - "crumbLabels": labels, - } - for gpos, (gicon, glabel, gsection) in nav_children.get( - child, {} - ).items(): - if gpos >= len(names): - continue - glabels = labels + ([gsection] if gsection else []) + [glabel] - gicons = icons + ([gicon] if gsection else []) + [gicon] - glabels, gicons = dedup_crumbs(glabels, gicons) - nav[names[gpos]] = { - "pageIdx": top_idx, - "subPath": [pos, gpos], - "crumbIcons": gicons, - "crumbLabels": glabels, - } - return nav - - -def tokenize(text: str) -> list[str]: - toks: list[str] = [] - for word in text.lower().split(): - parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] - for p in parts: - if p not in STOPWORDS and p not in toks: - toks.append(p) - if len(parts) > 1: - joined = "".join(parts) - if joined not in toks: - toks.append(joined) - return toks - - -SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)') -SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{") - - -def extract_settings( - files: dict[str, Path], nav: dict[str, dict] -) -> list[dict]: - entries: list[dict] = [] - for comp, meta in nav.items(): - pf = files.get(comp) - if not pf: - continue - lines = read_lines(pf) - section = "" - i = 0 - while i < len(lines): - if SECTION_RE.match(lines[i]): - for j in range(i + 1, min(i + 4, len(lines))): - m = LABEL_RE.match(lines[j]) - if m: - section = m.group(1) - break - row_match = ROW_RE.match(lines[i]) - if row_match: - row_type = row_match.group(1) - label = anchor = subtext = None - checked_path = toggled_path = None - for j in range(i + 1, min(i + 12, len(lines))): - if label is None: - m = LABEL_RE.match(lines[j]) - if m: - label = m.group(1) - if anchor is None: - a = ANCHOR_RE.match(lines[j]) - if a: - anchor = a.group(1) - if subtext is None: - st = SUBTEXT_RE.match(lines[j]) - if st: - subtext = st.group(1) - if checked_path is None: - ch = CHECKED_RE.match(lines[j]) - if ch: - checked_path = ch.group(1) - if toggled_path is None: - tg = ONTOGGLED_RE.match(lines[j]) - if tg: - toggled_path = tg.group(1) - toggle_path = ( - checked_path - if row_type == "ToggleRow" - and checked_path - and checked_path == toggled_path - else "" - ) - if label and label not in SKIP_LABELS and anchor: - extra = ( - " ".join(meta["crumbLabels"]) - + " " - + section - + " " - + (subtext or "") - ) - entries.append( - { - "pageIdx": meta["pageIdx"], - "subPath": meta["subPath"], - "crumbIcons": meta["crumbIcons"], - "crumbLabels": meta["crumbLabels"], - "title": label, - "anchor": anchor, - "section": section, - "subtext": subtext or "", - "togglePath": toggle_path, - "keywords": " ".join( - sorted(set(tokenize(label + " " + extra))) - ), - } - ) - i += 1 - return entries - - -def build_inverted_and_ranking(entries: list[dict]): - inverted: dict[str, list[int]] = defaultdict(list) - ranking: dict[str, dict[int, float]] = defaultdict(dict) - for idx, e in enumerate(entries): - fields = {"title": e["title"], "keywords": e["keywords"]} - seen: set[str] = set() - for field, text in fields.items(): - weight = FIELD_WEIGHT.get(field, 0.2) - for tok in tokenize(text): - if idx not in inverted[tok]: - inverted[tok].append(idx) - ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) - seen.add(tok) - for tok, ids in inverted.items(): - ids.sort(key=lambda i: ranking[tok][i], reverse=True) - return inverted, { - t: {str(k): v for k, v in d.items()} for t, d in ranking.items() - } - - -def main() -> int: - if len(sys.argv) != 3: - print(__doc__) - return 1 - settings = Path(sys.argv[1]) - out = Path(sys.argv[2]) - files = discover_files(settings) - nav = build_nav_map(settings, files) - entries = extract_settings(files, nav) - inverted, ranking = build_inverted_and_ranking(entries) - for e in entries: - e.pop("keywords", None) - out.write_text( - json.dumps( - { - "version": 2, - "entries": entries, - "inverted": inverted, - "ranking": ranking, - }, - ensure_ascii=False, - indent=2, - ) - ) - print( - f"settings index: {len(entries)} entries, " - f"{len(inverted)} tokens -> {out}" - ) - print("files:", len(files)) - print("comps:", len(parse_page_comps(settings))) - print("registry:", len(parse_page_registry(settings))) - print("nav:", len(nav)) - print("entries:", len(entries)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) 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])) diff --git a/scripts/mock-llama-server.py b/scripts/mock-llama-server.py new file mode 100644 index 0000000..ee7f011 --- /dev/null +++ b/scripts/mock-llama-server.py @@ -0,0 +1,639 @@ +import argparse +import contextlib +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MODEL_ID = "mock-model" +CTX_SIZE = 8192 + +_host = "127.0.0.1" +_port = 8080 + +_state_lock = threading.Lock() +_state = { + "loading": False, + "no_models": False, + "chat_mode": None, +} + + +def _now(): + return int(time.time()) + + +def _chunk(cid, delta, finish_reason=None): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "system_fingerprint": "mock", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } + + +def _usage_chunk(cid, prompt_tokens, completion_tokens): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + + +def _error(code, message, etype): + return {"error": {"code": code, "message": message, "type": etype}} + + +def _sse(obj): + return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() + + +def _sse_done(): + return b"data: [DONE]\n\n" + + +def _behaviour_for(text): + t = text.lower() + if "instant error" in t: + return "instant_error" + if "context overflow" in t: + return "context_overflow" + if "error during" in t: + return "error_during" + if "no done" in t or "truncate" in t: + return "no_done" + if "200 error" in t: + return "200_error" + if "two tool calls" in t or "two tool call" in t: + return "tool_call_duo" + if "some tool calls" in t: + return "tool_call_some" + if "many tool calls" in t: + return "tool_call_many" + if "unknown tool" in t: + return "tool_call_unknown" + if "tool call" in t: + return "tool_call" + return "normal" + + +_TOOL_MODES = ( + "tool_call", + "tool_call_duo", + "tool_call_some", + "tool_call_many", + "tool_call_unknown", +) + + +def _tool_call_delta(index, call_id=None, name=None, arguments=None): + call = {"index": index} + if call_id is not None: + call["id"] = call_id + if name is not None or arguments is not None: + function = {} + if name is not None: + function["name"] = name + if arguments is not None: + function["arguments"] = arguments + call["function"] = function + return {"tool_calls": [call]} + + +def _stream_tool_calls(w, cid, reasoning, calls): + w(_sse(_chunk(cid, {"role": "assistant"}))) + if reasoning: + for piece in reasoning: + w(_sse(_chunk(cid, {"reasoning_content": piece}))) + time.sleep(0.05) + for i, (call_id, name, arguments) in enumerate(calls): + w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name)))) + time.sleep(0.05) + for fragment in arguments: + w(_sse(_chunk(cid, _tool_call_delta(i, arguments=fragment)))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="tool_calls"))) + w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2))) + w(_sse_done()) + + +def _tool_result_snippets(messages, limit=160): + snippets = [] + for msg in messages or []: + if msg.get("role") != "tool": + continue + content = msg.get("content") + if isinstance(content, list): + content = " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + flat = " ".join(str(content or "").split()) + snippets.append(flat[:limit] + ("..." if len(flat) > limit else "")) + return snippets + + +def _stream_tool_round_done(w, cid, messages): + w(_sse(_chunk(cid, {"role": "assistant"}))) + parts = ["mock:", "tool", "round", "complete."] + for i, snippet in enumerate(_tool_result_snippets(messages), 1): + parts.append(f"result {i}:") + parts.append(snippet or "(empty)") + for word in parts: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.02) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 40, len(parts) + 1))) + w(_sse_done()) + + +def _stream_normal(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 12, len(words) + 1))) + w(_sse_done()) + + +def _stream_error_during(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) + + +def _stream_no_done(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + + +def _last_user_text(messages): + for msg in reversed(messages or []): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + content = " ".join(parts) + return content or "" + return "" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "MockLlama/1.0" + + def _send_json(self, status, obj): + body = json.dumps(obj, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _send_loading_error(self): + self._send_json(503, _error(503, "Loading model", "unavailable_error")) + + def _not_ready(self): + with _state_lock: + return _state["loading"] + + def _stream(self, status, writer): + self.send_response(status) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + try: + writer(self.wfile.write) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + self.wfile.flush() + + def do_GET(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + if path in ("/health", "/v1/health"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json(200, {"status": "ok"}) + if path in ("/props", "/v1/props"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json( + 200, + { + "total_slots": 1, + "model_alias": MODEL_ID, + "model_path": "/mock/model.gguf", + "default_generation_settings": { + "params": {"n_predict": 2048}, + "n_ctx": CTX_SIZE, + }, + "is_sleeping": False, + }, + ) + if path in ("/models", "/v1/models"): + if self._not_ready(): + return self._send_loading_error() + with _state_lock: + no_models = _state["no_models"] + data = ( + [] + if no_models + else [ + { + "id": MODEL_ID, + "object": "model", + "created": _now(), + "owned_by": "mock", + } + ] + ) + return self._send_json(200, {"object": "list", "data": data}) + if path == "/": + return self._send_json(200, {"status": "ok", "mock": True}) + return self._send_json(404, _error(404, "not found", "not_found_error")) + + def do_POST(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError: + body = {} + + if path == "/_mock": + return self._mock_control(body) + + if path in ("/chat/completions", "/v1/chat/completions"): + if self._not_ready(): + return self._send_loading_error() + return self._chat(body) + + return self._send_json(404, _error(404, "not found", "not_found_error")) + + def _mock_control(self, body): + with _state_lock: + if body.get("reset"): + _state["loading"] = False + _state["no_models"] = False + _state["chat_mode"] = None + if "loading" in body: + _state["loading"] = bool(body["loading"]) + if "no_models" in body: + _state["no_models"] = bool(body["no_models"]) + if "chat_mode" in body: + _state["chat_mode"] = body["chat_mode"] + snapshot = dict(_state) + self.log_message("control -> %s", json.dumps(snapshot)) + return self._send_json(200, {"ok": True, "state": snapshot}) + + def _chat(self, body): + text = _last_user_text(body.get("messages")) + with _state_lock: + forced = _state["chat_mode"] + mode = forced or _behaviour_for(text) + stream = bool(body.get("stream", True)) + cid = "chatcmpl-mock" + words = ["mock", "says", "hello", "from", "the", "mock", "server"] + self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) + + if mode == "instant_error": + return self._send_json( + 500, _error(500, "Mock: instant server error", "server_error") + ) + + if mode == "context_overflow": + return self._send_json( + 400, + _error( + 400, + "request (100000 tokens) exceeds the available context " + "size (2048 tokens), try increasing it", + "exceed_context_size_error", + ), + ) + + if mode == "200_error": + return self._send_json( + 200, _error(400, "Mock: 200-with-json-error", "server_error") + ) + + if mode in _TOOL_MODES: + if any(m.get("role") == "tool" for m in body.get("messages") or []): + return self._stream( + 200, + lambda w: _stream_tool_round_done( + w, cid, body.get("messages") + ), + ) + if mode == "tool_call": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ) + ] + reasoning = [ + "The user wants a tool call. ", + "I will read /etc/os-release.", + ] + elif mode == "tool_call_duo": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + elif mode == "tool_call_some": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + elif mode == "tool_call_many": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + f'{{"url": "http://{_host}:{_port}/", ', + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + else: + calls = [ + ( + "call_mock_3", + "nonexistent_tool", + ['{"target": "test"}'], + ) + ] + reasoning = ["Calling a tool the client ", "does not have."] + self.log_message( + "tool mode=%s calls=%s", mode, [c[1] for c in calls] + ) + return self._stream( + 200, lambda w: _stream_tool_calls(w, cid, reasoning, calls) + ) + + if not stream: + content = " ".join(words) + return self._send_json( + 200, + { + "id": cid, + "object": "chat.completion", + "created": _now(), + "model": MODEL_ID, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": content, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": len(words), + "total_tokens": 12 + len(words), + }, + }, + ) + + if mode == "error_during": + return self._stream( + 200, lambda w: _stream_error_during(w, cid, words) + ) + if mode == "no_done": + return self._stream(200, lambda w: _stream_no_done(w, cid, words)) + return self._stream(200, lambda w: _stream_normal(w, cid, words)) + + def log_message(self, format, *args): + print(f"{self.log_date_time_string()} - {format % args}", flush=True) + + +def main(): + ap = argparse.ArgumentParser( + description="Mock llama-server for exercising zshell LLM error handling" + ) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + + global _host, _port + _host = args.host + _port = args.port + + server = ThreadingHTTPServer((args.host, args.port), Handler) + server.daemon_threads = True + print( + f"mock llama-server on http://{args.host}:{args.port} " + f"(model={MODEL_ID}, ctx={CTX_SIZE})\n" + "type trigger words in the chat UI, or curl /_mock to change state", + flush=True, + ) + with contextlib.suppress(KeyboardInterrupt): + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/settings-indexer.js b/scripts/settings-indexer.js new file mode 100644 index 0000000..2b36e86 --- /dev/null +++ b/scripts/settings-indexer.js @@ -0,0 +1,450 @@ +.pragma library + +const STOPWORDS = [ + "a", + "an", + "and", + "are", + "for", + "in", + "not", + "notification", + "of", + "on", + "or", + "out", + "the", + "to", +]; + +const FIELD_WEIGHT = { + title: 1.0, + keywords: 0.4, +}; +const SKIP_LABELS = ["Muted", "None"]; + +function cleanLabel(text) { + return String(text ?? "") + .replace(/\s*\(?%\d+\)?/g, "") + .trim(); +} + +const PAGE_NAME_RE = /^\s*name:\s*qsTr\("([^"]+)"\)/; +const ROW_RE = + /^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|RowButton|InfoRow|PopupRow|DefaultRow|TextFieldRow|IconTextButton|TimeDialogSelect)\s*\{/; +const NAV_ROW_TYPES = ["NavRow", "IconTextButton"]; +const LABEL_RE = /^\s*(?:label|text):\s*qsTr\("([^"]+)"\)/; +const ANCHOR_RE = /^\s*(?:property\s+string\s+)?settingAnchor:\s*"([^"]+)"/; +const CHECKED_RE = /^\s*checked:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*$/; +const ONTOGGLED_RE = + /^\s*onToggled:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*=\s*checked\s*$/; +const ICON_RE = /^\s*icon:\s*"([^"]+)"/; +const SUBTEXT_RE = /^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)/; +const SECTION_RE = /^\s*SectionHeader\s*\{/; + +function tokenize(text) { + const toks = []; + for (const word of text.toLowerCase().split(/\s+/)) { + if (!word) continue; + const parts = word.split(/[^a-z0-9]+/).filter((p) => p); + for (const p of parts) { + if (!STOPWORDS.includes(p) && !toks.includes(p)) toks.push(p); + } + if (parts.length > 1) { + const joined = parts.join(""); + if (!toks.includes(joined)) toks.push(joined); + } + } + return toks; +} + +function discoverFiles(settingsDir, listFiles) { + const files = {}; + for (const p of listFiles(`${settingsDir}/Pages`, ".qml")) { + const name = p.slice(p.lastIndexOf("/") + 1).replace(/\.qml$/, ""); + files[name] = p; + } + return files; +} + +function parsePageRegistry(settingsDir, readLines) { + const lines = readLines(`${settingsDir}/PageRegistry.qml`); + const out = []; + let inArray = false; + let depth = 0; + let label = null; + let icon = null; + for (const line of lines) { + const s = line.trim(); + if (s.includes("pages:") && s.includes("[")) { + inArray = true; + continue; + } + if (!inArray) continue; + if (s.startsWith("//")) continue; + if (s.startsWith("]")) break; + if (s.startsWith("{")) { + depth++; + label = icon = null; + continue; + } + if (s.startsWith("}")) { + if (label !== null) out.push([icon || "tune", label]); + depth--; + continue; + } + if (depth >= 1) { + const m = PAGE_NAME_RE.exec(line); + if (m && label === null) label = m[1]; + const mi = ICON_RE.exec(line); + if (mi && icon === null) icon = mi[1]; + } + } + return out; +} + +function parsePageComps(settingsDir, readFile) { + const text = readFile(`${settingsDir}/PageCompRegistry.qml`); + const start = text.indexOf("pageComps:"); + if (start === -1) return []; + const comps = []; + let current = null; + let depth = 0; + for (const raw of text.slice(start).split("\n")) { + const line = raw.split("//")[0]; + const s = line.trim(); + if (s.startsWith("]")) break; + const atTop = depth === 0; + depth += + (line.match(/\{/g) ?? []).length - + (line.match(/\}/g) ?? []).length; + if (atTop && /^Component\s*\{/.test(s)) { + current = []; + comps.push(current); + continue; + } + if (current !== null) { + const m = /(^|\s)([A-Z][A-Za-z0-9]*)\s*\{\s*\}/.exec(s); + if (m) current.push(m[2]); + } + } + return comps; +} + +function dedupCrumbs(labels, icons) { + const outLabels = []; + const outIcons = []; + for (let i = 0; i < labels.length; i++) { + if ( + outLabels.length > 0 && + outLabels[outLabels.length - 1] === labels[i] + ) + continue; + outLabels.push(labels[i]); + outIcons.push(icons[i]); + } + return [outLabels, outIcons]; +} + +function buildNavMap(settingsDir, files, readFile, readLines) { + const comps = parsePageComps(settingsDir, readFile); + const registry = parsePageRegistry(settingsDir, readLines); + + const navChildren = {}; + for (const names of comps) { + for (const name of names) { + const pf = files[name]; + if (!pf) continue; + let pendingIcon = null; + let pendingLabel = null; + let section = ""; + let expectSection = false; + for (const ln of readLines(pf)) { + if (SECTION_RE.test(ln)) { + expectSection = true; + continue; + } + const ml = LABEL_RE.exec(ln); + if (ml) { + if (expectSection) { + section = ml[1]; + expectSection = false; + } else { + pendingLabel = ml[1]; + } + continue; + } + const mi = ICON_RE.exec(ln); + if (mi) pendingIcon = mi[1]; + const mo = /openSubPage\((\d+)\)/.exec(ln); + if (mo) { + const pos = parseInt(mo[1], 10); + if (!navChildren[name]) navChildren[name] = {}; + navChildren[name][pos] = [ + pendingIcon || "tune", + pendingLabel || "", + section, + ]; + pendingIcon = pendingLabel = null; + } + } + } + } + + const nav = {}; + for (let topIdx = 0; topIdx < comps.length; topIdx++) { + const names = comps[topIdx]; + if (names.length === 0) continue; + const main = names[0]; + const [mainIcon, mainLabel] = registry[topIdx] ?? ["tune", main]; + nav[main] = { + pageIdx: topIdx, + subPath: [], + crumbIcons: [mainIcon], + crumbLabels: [mainLabel], + }; + const children = Object.assign({}, navChildren[main] ?? {}); + const openedViaSubpage = []; + for (const owner in navChildren) { + const ownerGroup = comps.find((ns) => ns.includes(owner)); + if (!ownerGroup) continue; + for (const kpos in navChildren[owner]) { + const k = parseInt(kpos, 10); + if (k < ownerGroup.length) openedViaSubpage.push(ownerGroup[k]); + } + } + for (let pos = 1; pos < names.length; pos++) { + if (!(pos in children) && !openedViaSubpage.includes(names[pos])) { + let label = names[pos].replace(/(Detail)?Page$/, ""); + label = label.replace(/(?= names.length) continue; + const [icon, label, section] = children[posKey]; + const child = names[pos]; + let labels = [mainLabel] + .concat(section ? [section] : []) + .concat([label]); + let icons = [mainIcon].concat(section ? [icon] : []).concat([icon]); + [labels, icons] = dedupCrumbs(labels, icons); + nav[child] = { + pageIdx: topIdx, + subPath: [pos], + crumbIcons: icons, + crumbLabels: labels, + }; + const grandChildren = navChildren[child] ?? {}; + for (const gposKey in grandChildren) { + const gpos = parseInt(gposKey, 10); + if (gpos >= names.length) continue; + const [gicon, glabel, gsection] = grandChildren[gposKey]; + let glabels = labels + .concat(gsection ? [gsection] : []) + .concat([glabel]); + let gicons = icons + .concat(gsection ? [gicon] : []) + .concat([gicon]); + [glabels, gicons] = dedupCrumbs(glabels, gicons); + nav[names[gpos]] = { + pageIdx: topIdx, + subPath: [pos, gpos], + crumbIcons: gicons, + crumbLabels: glabels, + }; + } + } + } + return nav; +} + +function findBlockEnd(lines, start) { + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i].split("//")[0]; + depth += + (line.match(/\{/g) ?? []).length - + (line.match(/\}/g) ?? []).length; + if (i > start && depth <= 0) return i; + } + return lines.length; +} + +function extractSettings(files, nav, comps, readLines) { + const entries = []; + for (const comp in nav) { + const meta = nav[comp]; + const pf = files[comp]; + if (!pf) continue; + const lines = readLines(pf); + let section = ""; + for (let i = 0; i < lines.length; i++) { + if (SECTION_RE.test(lines[i])) { + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const m = LABEL_RE.exec(lines[j]); + if (m) { + section = m[1]; + break; + } + } + } + const rowMatch = ROW_RE.exec(lines[i]); + if (!rowMatch) continue; + const rowType = rowMatch[1]; + let label = null; + let anchor = null; + let subtext = null; + let checkedPath = null; + let toggledPath = null; + let targetPos = null; + const rowEnd = findBlockEnd(lines, i); + for (let j = i + 1; j < rowEnd; j++) { + if (label === null) { + const m = LABEL_RE.exec(lines[j]); + if (m) label = m[1]; + } + if (anchor === null) { + const a = ANCHOR_RE.exec(lines[j]); + if (a) anchor = a[1]; + } + if (subtext === null) { + const st = SUBTEXT_RE.exec(lines[j]); + if (st) subtext = st[1]; + } + if (checkedPath === null) { + const ch = CHECKED_RE.exec(lines[j]); + if (ch) checkedPath = ch[1]; + } + if (toggledPath === null) { + const tg = ONTOGGLED_RE.exec(lines[j]); + if (tg) toggledPath = tg[1]; + } + if (NAV_ROW_TYPES.includes(rowType) && targetPos === null) { + const sp = /openSubPage\((\d+)\)/.exec(lines[j]); + if (sp) targetPos = parseInt(sp[1], 10); + } + } + const togglePath = + rowType === "ToggleRow" && + checkedPath && + checkedPath === toggledPath + ? checkedPath + : ""; + if (label && !SKIP_LABELS.includes(label) && anchor) { + const extra = + meta.crumbLabels.join(" ") + + " " + + section + + " " + + (subtext && !/%\d/.test(subtext) ? subtext : ""); + const group = comps[meta.pageIdx] ?? []; + const targetSubPath = + targetPos !== null && targetPos < group.length + ? meta.subPath.concat([targetPos]) + : []; + entries.push({ + rowType: rowType, + pageIdx: meta.pageIdx, + subPath: meta.subPath, + targetSubPath: targetSubPath, + crumbIcons: meta.crumbIcons, + crumbLabels: meta.crumbLabels, + trailKey: meta.crumbLabels.join("/"), + title: cleanLabel(label), + anchor: anchor, + section: section, + subtext: subtext && !/%\d/.test(subtext) ? subtext : "", + togglePath: togglePath, + }); + } + } + } + return mergeInfoRows(entries); +} + +function mergeInfoRows(entries) { + const out = []; + const merged = {}; + + for (const entry of entries) { + const isInfo = entry.rowType === "InfoRow"; + delete entry.rowType; + + if (!isInfo || !entry.section) { + out.push(entry); + continue; + } + + const key = + entry.anchor.split("-")[0] + + "/" + + entry.trailKey + + "/" + + entry.section; + const existing = merged[key]; + if (existing === undefined) { + entry.keywords = entry.title; + entry.title = entry.section; + merged[key] = entry; + out.push(entry); + } else { + existing.keywords += " " + entry.title; + } + } + return out; +} + +function buildInvertedAndRanking(entries) { + const inverted = {}; + const ranking = {}; + for (let idx = 0; idx < entries.length; idx++) { + const e = entries[idx]; + const extra = + e.crumbLabels.join(" ") + + " " + + e.section + + " " + + e.subtext + + " " + + (e.keywords ?? ""); + const fields = { + title: e.title, + keywords: tokenize(e.title + " " + extra) + .sort() + .join(" "), + }; + for (const field in fields) { + const weight = FIELD_WEIGHT[field] ?? 0.2; + for (const tok of tokenize(fields[field])) { + if (!inverted[tok]) inverted[tok] = []; + if (!inverted[tok].includes(idx)) inverted[tok].push(idx); + if (!ranking[tok]) ranking[tok] = {}; + ranking[tok][idx] = Math.max(ranking[tok][idx] ?? 0.0, weight); + } + } + } + for (const tok in inverted) + inverted[tok].sort((a, b) => ranking[tok][b] - ranking[tok][a]); + return [inverted, ranking]; +} + +function buildIndex(settingsDir, readFile, listFiles) { + const lineCache = {}; + const readLines = (path) => { + if (!(path in lineCache)) lineCache[path] = readFile(path).split("\n"); + return lineCache[path]; + }; + const files = discoverFiles(settingsDir, listFiles); + const nav = buildNavMap(settingsDir, files, readFile, readLines); + const comps = parsePageComps(settingsDir, readFile); + const entries = extractSettings(files, nav, comps, readLines); + const [inverted, ranking] = buildInvertedAndRanking(entries); + return { + version: 3, + entries: entries, + inverted: inverted, + ranking: ranking, + }; +} diff --git a/shell.qml b/shell.qml index 7b9edcf..fcbc98b 100644 --- a/shell.qml +++ b/shell.qml @@ -20,30 +20,25 @@ ShellRoot { settings.watchFiles: true - Drawers { - } + Drawers {} Lock { id: lock } - Shortcuts { - } + Shortcuts {} IdleMonitors { lock: lock } - Polkit { - } + Polkit {} LazyLoader { activeAsync: root.laptop - component: BatteryService { - } + component: BatteryService {} } - ServiceLoader { - } + ServiceLoader {} } diff --git a/tests/run-qml-tests.sh b/tests/run-qml-tests.sh new file mode 100755 index 0000000..c24a86a --- /dev/null +++ b/tests/run-qml-tests.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DURATION="${1:-${QS_SMOKE_DURATION:-10}}" +QML_IMPORT_PATH="${QML_IMPORT_PATH:-$ROOT/build/qml}" + +if [ ! -d "$QML_IMPORT_PATH/ZShell" ]; then + echo "error: no ZShell plugin at $QML_IMPORT_PATH/ZShell" + echo "build the project first: cmake --build build" + exit 2 +fi + +OUT="$(mktemp /tmp/zshell-smoke.XXXXXX)" +trap 'rm -f "$OUT"' EXIT + +echo "booting shell for ${DURATION}s (a second bar will be visible)" +env QT_QPA_PLATFORM=wayland QML_IMPORT_PATH="$QML_IMPORT_PATH" \ + qs --no-color -p "$ROOT/shell.qml" >"$OUT" 2>&1 & +CLI_PID=$! + +IID="" +for _ in $(seq 1 100); do + IID="$(sed -nE 's|.*by-id/([a-z0-9]+)/log\.qslog.*|\1|p' "$OUT" | head -1)" + [ -n "$IID" ] && break + kill -0 "$CLI_PID" 2>/dev/null || break + sleep 0.2 +done + +if [ -z "$IID" ]; then + echo "FAIL: shell did not start" + cat "$OUT" + exit 2 +fi + +sleep "$DURATION" + +qs kill -i "$IID" >/dev/null 2>&1 +if qs list --all 2>/dev/null | grep -q "Instance $IID"; then + PID="$(qs list --all 2>/dev/null | awk -v id="$IID" ' + /^Instance /{cur = $2} + cur == id && /Process ID:/{print $3}')" + [ -n "$PID" ] && kill "$PID" 2>/dev/null +fi +wait "$CLI_PID" 2>/dev/null + +ERRORS="$(grep -aE '^[[:space:]]*ERROR' "$OUT" || true)" +if [ -n "$ERRORS" ]; then + echo + echo "FAIL: QML errors detected" + echo "$ERRORS" + exit 1 +fi + +WARNINGS="$(grep -aE '^[[:space:]]*WARN' "$OUT" | sed -E 's/^[[:space:]]*WARN[[:space:]]*//' | sort | uniq -c | sort -rn || true)" +echo +if [ -n "$WARNINGS" ]; then + echo "PASS: no errors (${DURATION}s boot). Non-fatal warnings:" + echo "$WARNINGS" +else + echo "PASS: no errors or warnings (${DURATION}s boot)." +fi +exit 0