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/.gitea/ISSUE_TEMPLATE/bug.yaml b/.gitea/ISSUE_TEMPLATE/bug.yaml index ac0449e..33ad098 100644 --- a/.gitea/ISSUE_TEMPLATE/bug.yaml +++ b/.gitea/ISSUE_TEMPLATE/bug.yaml @@ -38,7 +38,6 @@ body: attributes: label: OS options: - - NixOS - Arch - Debian / Ubuntu - Fedora diff --git a/.gitea/PULL_REQUEST_TEMPLATE.yaml b/.gitea/PULL_REQUEST_TEMPLATE.yaml index cd94046..e890116 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.yaml +++ b/.gitea/PULL_REQUEST_TEMPLATE.yaml @@ -30,6 +30,7 @@ body: - Refactor / style - CI / build / tooling - Documentation + - Other (Describe in description box with header "Issue type") validations: required: true 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 95c3714..b091ec4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,49 +26,51 @@ if(NOT DEFINED VERSION) endif() endif() +if(NOT DEFINED GIT_REVISION) + execute_process(COMMAND git rev-parse HEAD + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if("${GIT_REVISION}" STREQUAL "") + message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git") + endif() +endif() + set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}") project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}) message(STATUS "ZShell version: ${VERSION}") +include(GNUInstallDirs) + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install") +set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install") -set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") -set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") -set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") -set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") +set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir") +set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") +set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") 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> ) -if("shell" IN_LIST ENABLE_MODULES) - # Build settings index - find_package(Python3 COMPONENTS Interpreter REQUIRED) - set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json") - execute_process( - COMMAND ${Python3_EXECUTABLE} - "${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py" - "${CMAKE_SOURCE_DIR}/Modules/Settings" - "${SETTINGS_INDEX_JSON}" - RESULT_VARIABLE SETTINGS_INDEX_RESULT - ) - if(NOT SETTINGS_INDEX_RESULT EQUAL 0) - message(FATAL_ERROR "Failed to build settings search index") - endif() - +if("cli" IN_LIST ENABLE_MODULES) # Nuitka compilation set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli") set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist") @@ -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/BaseStyledSlider.qml b/Components/BaseStyledSlider.qml index 9d3b654..0ebb4ad 100644 --- a/Components/BaseStyledSlider.qml +++ b/Components/BaseStyledSlider.qml @@ -80,49 +80,29 @@ Slider { property bool moving - function update(): void { - animate = !moving; - binding.when = moving; - font.pointSize = moving ? Tokens.font.size.small : Tokens.font.size.larger; - font.family = moving ? Config.appearance.font.family.sans : Config.appearance.font.family.material; - } - anchors.centerIn: parent + anchors.verticalCenterOffset: 1 + text: moving ? Math.round(root.value * 100) : root.icon color: Colors.palette.m3inverseOnSurface - text: root.icon + font: moving ? Tokens.font.body.small : Tokens.font.icon.medium - onMovingChanged: anim.restart() - - Binding { - id: binding - - property: "text" - target: icon - value: Math.round(root.value * root.multiplier) - when: false - } - - SequentialAnimation { - id: anim - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardAccel - property: "scale" - target: icon - to: 0 - } - - ScriptAction { - script: icon.update() - } - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardDecel - property: "scale" - target: icon - to: 1 + Behavior on moving { + SequentialAnimation { + Anim { + target: icon + property: "scale" + to: 0.3 + duration: Tokens.anim.durations.small / 2 + easing: Tokens.anim.standardAccel + } + PropertyAction {} + Anim { + target: icon + property: "scale" + to: 1 + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel + } } } } @@ -130,7 +110,7 @@ Slider { } Behavior on value { Anim { - duration: Tokens.anim.durations.large + type: Anim.StandardLarge } } @@ -138,7 +118,6 @@ Slider { onValueChanged: { if (!initialized) { initialized = true; - oldValue = value; return; } diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 3f42ab7..baebc6b 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -20,11 +20,7 @@ CustomRect { property color disabledColor: Qt.alpha(Colors.palette.m3onSurface, 0.1) property color disabledOnColor: Qt.alpha(Colors.palette.m3onSurface, 0.38) property bool fillWidth - property font font: ({ - family: Config.appearance.font.family.sans, - pointSize: Tokens.font.size.larger, - bold: false - }) + property font font: Tokens.font.body.small property real horizontalPadding: padding readonly property alias hovered: stateLayer.containsMouse required implicitHeight @@ -55,7 +51,7 @@ CustomRect { if (internalChecked) return checkedRadius; if (isRound) - return (height || implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale); + return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale); return defaultRadius; } diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index df1511c..180c09c 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/ColorArcPicker.qml b/Components/ColorArcPicker.qml index 0a14f32..43b9c34 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -46,10 +46,10 @@ Item { } function syncFromPenColor() { - if (!drawing.drawingState) + if (!drawing.screenState) return; - const c = drawing.drawingState.penColor; + const c = drawing.screenState.penColor; if (c.hsvSaturation > 0) { currentHue = c.hsvHue; @@ -86,7 +86,7 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.drawingState.penColor = Qt.hsva(currentHue, drawing.drawingState.penColor.hsvSaturation, drawing.drawingState.penColor.hsvValue, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(currentHue, drawing.screenState.penColor.hsvSaturation, drawing.screenState.penColor.hsvValue, drawing.screenState.penColor.a); } implicitHeight: 180 @@ -112,7 +112,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Canvas { @@ -152,7 +152,7 @@ Item { CustomRect { anchors.centerIn: parent - color: root.drawing?.drawingState.penColor + color: root.drawing?.screenState.penColor implicitHeight: implicitWidth implicitWidth: canvas.height - root.handleSize - Tokens.padding.extraLarge * 2 radius: Tokens.rounding.full diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..1373d1e 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services Flickable { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive().isDrawing + interactive: !ShellState.forActive().isDrawing maximumFlickVelocity: 3000 rebound: Transition { @@ -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 0b8d618..97936d3 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -1,33 +1,36 @@ import QtQuick -import qs.Helpers +import qs.Services ListView { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive()?.isDrawing + interactive: !ShellState.forActive()?.isDrawing 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/CustomRadioButton.qml b/Components/CustomRadioButton.qml index aca7899..aca0f4c 100644 --- a/Components/CustomRadioButton.qml +++ b/Components/CustomRadioButton.qml @@ -6,7 +6,7 @@ import qs.Services RadioButton { id: root - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin @@ -14,7 +14,7 @@ RadioButton { anchors.left: outerCircle.right anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter - font.pointSize: root.font.pointSize + font: root.font text: root.text } indicator: Rectangle { @@ -29,8 +29,7 @@ RadioButton { radius: Tokens.rounding.full Behavior on border.color { - CAnim { - } + CAnim {} } StateLayer { 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 0159df0..d6690d2 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,32 +1,41 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Templates -import qs.Helpers -import ZShell.Config import qs.Services +import ZShell.Config 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: !Visibilities.getForActive().isDrawing - 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: { @@ -47,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 } } @@ -111,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 @@ -140,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/CustomSlider.qml b/Components/CustomSlider.qml index 39c5339..5acd92b 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -100,15 +100,19 @@ Slider { anchors.bottom: parent.bottom anchors.top: parent.top color: root.insetColor - font.pixelSize: Tokens.font.size.smaller * 2 + fontStyle: { + if (root.height < 96) + return Tokens.font.icon.pixelSize(24).build(); + else + return Tokens.font.icon.pixelSize(32).build(); + } text: root.insetIcon verticalAlignment: Text.AlignVCenter visible: !root.wavy && root.insetIcon !== "" x: Tokens.spacing.extraSmall + dockT * (handle.x + Tokens.spacing.small) Behavior on dockT { - Anim { - } + Anim {} } } @@ -138,8 +142,7 @@ Slider { startX: x Behavior on color { - CAnim { - } + CAnim {} } Anim on waveProgress { duration: root.waveDuration @@ -156,8 +159,7 @@ Slider { Behavior on filledWidth { id: widthBehavior - Anim { - } + Anim {} } Component.onCompleted: { diff --git a/Components/CustomText.qml b/Components/CustomText.qml index 08ae9ee..5445f5f 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -14,10 +14,9 @@ Text { property real animateTo: 1 color: Colors.palette.m3onSurface - font.family: Config.appearance.font.family.sans - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small linkColor: Colors.palette.m3onPrimaryFixedVariant - renderType: Text.NativeRendering + renderType: Text.QtRendering textFormat: Text.PlainText Behavior on color { diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index fa07bff..5b2280a 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -15,29 +15,33 @@ TextFieldBase { readonly property int clampedRadius: Math.min(horizontalPadding, Math.min(width, height) / 2, radius) readonly property string effectiveSupportingText: isError && errorText ? errorText : supportingText + readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 + readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 + readonly property real smallFontScale: smallFontSize / font.pointSize + readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 + readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 + readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) + + property int horizontalPadding: Tokens.padding.large + property int verticalPadding: Tokens.padding.large + + property int radius: Tokens.rounding.small + + property string supportingText + property string trailingIcon + property int smallFontSize: Tokens.font.label.small.pointSize + + property int type: CustomTextField.Outlined + property var validate // Regex or function property bool emptyIsValid: true property string errorText - readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 - readonly property int horizontalPadding: Tokens.padding.large property bool isError property string leadingIcon - readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 - property int radius: Tokens.rounding.small - property alias sLayer: stateLayer - readonly property real smallFontScale: smallFontSize / font.pointSize - property int smallFontSize: Tokens.font.size.small - property string supportingText - readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 - property string trailingIcon - readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 - property int type: CustomTextField.Outlined - readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) - property var validate // Regex or function - bottomPadding: Tokens.padding.large + supportingTextOffset - filledOffset + bottomPadding: verticalPadding + supportingTextOffset - filledOffset leftPadding: horizontalPadding + leadingOffset rightPadding: horizontalPadding + trailingOffset - topPadding: Tokens.padding.large + filledOffset + topPadding: verticalPadding + filledOffset background: Loader { anchors.bottomMargin: root.supportingTextOffset @@ -148,6 +152,7 @@ TextFieldBase { color: Colors.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.larger text: root.leadingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } @@ -162,6 +167,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant text: root.trailingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } } @@ -176,7 +182,7 @@ TextFieldBase { sourceComponent: CustomText { color: root.isError ? Colors.palette.m3error : Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.effectiveSupportingText } } @@ -214,12 +220,10 @@ TextFieldBase { } } Behavior on strokeColor { - CAnim { - } + CAnim {} } Behavior on strokeWidth { - Anim { - } + Anim {} } PathLine { @@ -294,8 +298,7 @@ TextFieldBase { implicitHeight: root.activeFocus ? 2 : 1 Behavior on implicitHeight { - Anim { - } + Anim {} } } } 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/IconButton.qml b/Components/IconButton.qml index 0210520..3bdfc48 100644 --- a/Components/IconButton.qml +++ b/Components/IconButton.qml @@ -8,6 +8,7 @@ ButtonBase { property alias icon: label.text readonly property alias label: label + font: Tokens.font.icon.medium activeColor: type === IconButton.Filled ? Colors.palette.m3primary : Colors.palette.m3secondary activeOnColor: type === IconButton.Filled ? Colors.palette.m3onPrimary : type === IconButton.Tonal ? Colors.palette.m3onSecondary : Colors.palette.m3primary implicitHeight: { @@ -36,7 +37,7 @@ ButtonBase { anchors.verticalCenterOffset: 1 color: root.onColor fill: !root.isToggle || root.internalChecked ? 1 : 0 - font.pointSize: root.font.pointSize + fontStyle: root.font Behavior on fill { Anim { diff --git a/Components/IconTextButton.qml b/Components/IconTextButton.qml index 6190121..fb568f6 100644 --- a/Components/IconTextButton.qml +++ b/Components/IconTextButton.qml @@ -46,10 +46,9 @@ ButtonBase { Layout.alignment: Qt.AlignVCenter color: root.onColor fill: root.internalChecked ? 1 : 0 - font: { + fontStyle: { const f = Qt.font(root.font); f.pointSize = Math.round(root.font.pointSize * 1.2); - f.family = "Material Symbols Rounded"; return f; } diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml new file mode 100644 index 0000000..c16a1a6 --- /dev/null +++ b/Components/LineGraph.qml @@ -0,0 +1,467 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property list values + + property int xSteps: 24 + property real yMin: NaN + property real yMax: NaN + property real yPaddingTop: 0 + property real yPaddingBottom: 0 + + property int verticalLineStep: 0 + property int horizontalLineStep: 0 + property bool firstXLine: true + property bool lastXLine: false + property int minHorizontalLines: 0 + + property var xLabel: null + property var yLabel: null + + property real smoothing: 0 + property bool curveSmoothing: true + property real markerX: -1 + property real markerGap: 2 // px gap between line and marker dot edge, each side + + property color lineColor: "white" + property color gridColor: "white" + property color markerColor: "white" + property color labelColor: "white" + property real gridAlpha: 0.12 + property real labelAlpha: 0.6 + property real fillAlpha: 0.15 + property real lineWidth: 2 + property real markerDotRadius: 4 + property real labelFontSize: 10 + + Canvas { + id: canvas + + anchors.fill: parent + renderStrategy: Canvas.Immediate + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + function strokeCurve(ctx, pts) { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + const n = pts.length; + + for (let i = 0; i < n - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); + } + } else { + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i]; + const p1 = pts[i + 1]; + const cx = (p0.x + p1.x) * 0.5; + ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); + } + } + } + + // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve + function segmentControls(pts, i) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + return [c1x, c1y, c2x, c2y]; + } + + const cx = (p1.x + p2.x) * 0.5; + return [cx, p1.y, cx, p2.y]; + } + + // Point on the curve at a given x (x is monotonic per segment) + function pointAtX(pts, targetX) { + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const p1 = pts[j]; + const p2 = pts[j + 1]; + const [c1x, c1y, c2x, c2y] = segmentControls(pts, j); + + let tlo = 0; + let thi = 1; + for (let it = 0; it < 24; it++) { + const tm = (tlo + thi) / 2; + const u = 1 - tm; + const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; + if (x < targetX) + tlo = tm; + else + thi = tm; + } + + const t = (tlo + thi) / 2; + const u = 1 - t; + const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; + return { x: targetX, y }; + } + + // Part of the curve left/right of a cut x, including the cut point + function cutLeft(pts, targetX) { + if (targetX <= pts[0].x) + return []; + if (targetX >= pts[pts.length - 1].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const left = pts.slice(0, j + 1); + if (targetX > pts[j].x) + left.push(pointAtX(pts, targetX)); + return left; + } + + function cutRight(pts, targetX) { + if (targetX >= pts[pts.length - 1].x) + return []; + if (targetX <= pts[0].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const right = pts.slice(j + 1); + if (targetX < pts[j + 1].x) + right.unshift(pointAtX(pts, targetX)); + return right; + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + const W = width; + const H = height; + if (W < 2 || H < 2) + return; + + const padLeft = root.labelFontSize * 3.4; + const padRight = 2; + const padTop = 2; + const padBottom = root.labelFontSize + 8; + const plot = { + x: padLeft, + y: padTop, + w: W - padLeft - padRight, + h: H - padTop - padBottom + }; + if (plot.w < 2 || plot.h < 2) + return; + + // Auto-fit: NaN yMin/yMax derive from the data, snapped to the + // horizontal step grid, honoring minHorizontalLines + let lo = root.yMin; + let hi = root.yMax; + const loAuto = Number.isNaN(lo); + const hiAuto = Number.isNaN(hi); + + if (loAuto || hiAuto) { + let dmin = Infinity; + let dmax = -Infinity; + for (const v of root.values) { + if (v == null) + continue; + if (v < dmin) + dmin = v; + if (v > dmax) + dmax = v; + } + if (dmin === Infinity) { + dmin = 0; + dmax = 1; + } + + const step = root.horizontalLineStep; + const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0; + + if (step > 0) { + if (hiAuto) { + const baseLo = loAuto ? Math.floor(dmin / step) * step : lo; + const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1); + lo = baseLo; + hi = baseLo + span * step; + } else { + const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1); + lo = hi - span * step; + } + } else { + if (loAuto) + lo = dmin; + if (hiAuto) + hi = dmax; + if (hi <= lo) + hi = lo + 1; + } + } + + const scaleBottom = lo - root.yPaddingBottom; + const scaleTop = hi + root.yPaddingTop; + const range = scaleTop - scaleBottom; + if (range <= 0 || root.xSteps < 1) + return; + + let series = root.values; + if (root.smoothing > 0 && series.length > 0) { + const radius = Math.round(root.smoothing); + const raw = series; + series = raw.map((v, i) => { + if (v == null) + return null; + + let sum = 0; + let n = 0; + for (let j = -radius; j <= radius; j++) { + const u = raw[i + j]; + if (u != null) { + sum += u; + n++; + } + } + return n > 0 ? sum / n : null; + }); + } + + const xAt = i => plot.x + (i / root.xSteps) * plot.w; + const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; + const crisp = v => Math.floor(v) + 0.5; + + // 1. Grid lines + ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); + ctx.lineWidth = 1; + + if (root.verticalLineStep > 0) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const x = crisp(xAt(i)); + ctx.beginPath(); + ctx.moveTo(x, plot.y); + ctx.lineTo(x, H); + ctx.stroke(); + } + } + + // Lines extend down into yPaddingBottom, snapped to the grid + const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); + + if (root.horizontalLineStep > 0) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const y = crisp(yAt(v)); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(plot.x + plot.w, y); + ctx.stroke(); + } + } + + // 2. Labels + ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); + ctx.font = root.labelFontSize + "px sans-serif"; + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + if (root.verticalLineStep > 0 && root.xLabel) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const text = root.xLabel(i); + if (text) + ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); + } + } + + ctx.textBaseline = "bottom"; + if (root.horizontalLineStep > 0 && root.yLabel) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const text = root.yLabel(v); + if (text) + ctx.fillText(text, 2, yAt(v) - 2); + } + } + + // 3. Curve, split into segments at null gaps + const segments = []; + let current = null; + for (let i = 0; i < series.length; i++) { + if (series[i] == null) { + if (current) + segments.push(current); + current = null; + continue; + } + const pt = { + x: xAt(i), + y: yAt(series[i]) + }; + if (!current) + current = []; + current.push(pt); + } + if (current) + segments.push(current); + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + const hasMarker = root.markerX >= 0 && root.markerX <= root.xSteps; + const markerPx = hasMarker ? xAt(root.markerX) : 0; + let gapPx = 0; + if (hasMarker) { + // markerGap is measured from the dot edge, perpendicular to the + // curve; project onto x via the local curve slope + let slope = 0; + const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); + if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) + slope = (pointAtX(seg, markerPx + 1).y - pointAtX(seg, markerPx - 1).y) / 2; + gapPx = (root.markerDotRadius + root.markerGap) / Math.sqrt(1 + slope * slope); + } + + for (const seg of segments) { + if (seg.length < 2) + continue; + + // Fill stays continuous under the marker + ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); + strokeCurve(ctx, seg); + ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); + ctx.lineTo(seg[0].x, plot.y + plot.h); + ctx.closePath(); + ctx.fill(); + + // Stroke has a gap around the marker + const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; + + for (const pts of pieces) { + if (pts.length < 2) + continue; + + ctx.strokeStyle = root.lineColor; + ctx.lineWidth = root.lineWidth; + strokeCurve(ctx, pts); + ctx.stroke(); + } + } + + // 4. Marker: dashed vertical + dot + if (root.markerX >= 0 && root.markerX <= root.xSteps) { + const x = markerPx; + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(crisp(x), plot.y); + ctx.lineTo(crisp(x), H); + ctx.stroke(); + ctx.setLineDash([]); + + const i0 = Math.floor(root.markerX); + const v0 = series[i0]; + const v1 = series[i0 + 1]; + let y = null; + + if (v0 != null && v1 != null) + y = yAt(v0 + (v1 - v0) * (root.markerX - i0)); + else if (v0 != null) + y = yAt(v0); + + if (y != null) { + ctx.fillStyle = root.markerColor; + ctx.beginPath(); + ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + + onValuesChanged: canvas.requestPaint() + onXStepsChanged: canvas.requestPaint() + onYMinChanged: canvas.requestPaint() + onYMaxChanged: canvas.requestPaint() + onYPaddingTopChanged: canvas.requestPaint() + onYPaddingBottomChanged: canvas.requestPaint() + onVerticalLineStepChanged: canvas.requestPaint() + onHorizontalLineStepChanged: canvas.requestPaint() + onFirstXLineChanged: canvas.requestPaint() + onLastXLineChanged: canvas.requestPaint() + onMinHorizontalLinesChanged: canvas.requestPaint() + onXLabelChanged: canvas.requestPaint() + onYLabelChanged: canvas.requestPaint() + onSmoothingChanged: canvas.requestPaint() + onCurveSmoothingChanged: canvas.requestPaint() + onMarkerXChanged: canvas.requestPaint() + onMarkerGapChanged: canvas.requestPaint() + onLineColorChanged: canvas.requestPaint() + onGridColorChanged: canvas.requestPaint() + onMarkerColorChanged: canvas.requestPaint() + onLabelColorChanged: canvas.requestPaint() + onGridAlphaChanged: canvas.requestPaint() + onLabelAlphaChanged: canvas.requestPaint() + onFillAlphaChanged: canvas.requestPaint() + onLineWidthChanged: canvas.requestPaint() + onMarkerDotRadiusChanged: canvas.requestPaint() + onLabelFontSizeChanged: canvas.requestPaint() +} diff --git a/Components/MaterialIcon.qml b/Components/MaterialIcon.qml index 0e81d10..73ca9d9 100644 --- a/Components/MaterialIcon.qml +++ b/Components/MaterialIcon.qml @@ -1,27 +1,14 @@ +import QtQuick import ZShell.Config import qs.Services CustomText { id: root - readonly property list allowed: [20, 24, 40, 48] property real fill: 0 property int grade: Colors.light ? 0 : -25 - readonly property int opsz: clampOpsz(optical) - property int optical: fontInfo.pixelSize + property font fontStyle: Tokens.font.icon.small - function clampOpsz(value): int { - return allowed.reduce((closest, current) => { - return Math.abs(current - value) < Math.abs(closest - value) ? current : closest; - }); - } - - font.family: "Material Symbols Rounded" - font.pointSize: Tokens.font.size.larger - font.variableAxes: ({ - FILL: fill, - GRAD: grade, - opsz: opsz, - wght: fontInfo.weight - }) + renderType: Text.NativeRendering + font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build() } diff --git a/Components/Menu.qml b/Components/Menu.qml index dc546c3..6a3db28 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -124,7 +124,7 @@ MouseArea { model: root.items CustomRect { - id: item + id: menuItem readonly property bool active: modelData === root?.active required property int index @@ -142,13 +142,13 @@ MouseArea { } StateLayer { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface enabled: root.expanded onClicked: { - root.itemSelected(item.modelData); - root.active = item.modelData; - item.modelData.clicked(); + root.itemSelected(menuItem.modelData); + root.active = menuItem.modelData; + menuItem.modelData.clicked(); root.expanded = false; } } @@ -162,26 +162,27 @@ MouseArea { MaterialIcon { Layout.alignment: Qt.AlignVCenter - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData?.icon ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData?.icon ?? "" + visible: text !== "" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - text: item.modelData?.text ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + text: menuItem.modelData?.text ?? "" } Loader { Layout.alignment: Qt.AlignVCenter - active: item.modelData?.trailingIcon.length > 0 + active: menuItem.modelData?.trailingIcon.length > 0 asynchronous: true visible: active sourceComponent: MaterialIcon { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData.trailingIcon + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData.trailingIcon } } } diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml new file mode 100644 index 0000000..5fb3dc6 --- /dev/null +++ b/Components/ScreenState.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell + +PersistentProperties { + required property ShellScreen modelData + + reloadableId: "screenState" + + // Drawer visibilities + property bool bar + property bool clipboard + property bool dashboard + property bool dock + property bool isDrawing + property bool launcher + property bool osd + property bool resources + property bool settings + property bool sidebar + + // Dashboard state + property int dashboardTab: 0 + property date dashboardDate: new Date() + + // Sidebar state + property int sidebarTab: 0 + property list expandedNotifs: [] + + // Utilities state + property string recordingConfirmDelete + property bool recordingListExpanded + property string recordingMode + + // Drawing state + property color penColor: "white" + property int penWidth: 4 + + // Screenshot state + property bool screenshot +} diff --git a/Components/SearchBar.qml b/Components/SearchBar.qml index f14b51e..5642d73 100644 --- a/Components/SearchBar.qml +++ b/Components/SearchBar.qml @@ -62,7 +62,7 @@ TextFieldBase { anchors.leftMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() text: "search" } diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 88d5f0b..aeda8ed 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Shapes import ZShell import ZShell.Config -import qs.Helpers import qs.Services MouseArea { @@ -52,7 +51,7 @@ MouseArea { anchors.fill: parent cursorShape: !enabled ? undefined : Qt.PointingHandCursor - enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing + enabled: parent.enabled && !ShellState.forActive()?.isDrawing hoverEnabled: true Behavior on stateOpacity { @@ -99,13 +98,13 @@ MouseArea { id: base anchors.fill: parent - bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 - bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 color: Colors.palette.m3onSurface opacity: root.stateOpacity // Pick up radius from parent if it has one (parent can be anything with radius props) // qmllint disable missing-property radius: root.parent?.radius ?? 0 + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 // qmllint enable missing-property 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/TextFieldBase.qml b/Components/TextFieldBase.qml index b68318b..0edd409 100644 --- a/Components/TextFieldBase.qml +++ b/Components/TextFieldBase.qml @@ -8,7 +8,7 @@ TextField { color: Colors.palette.m3onSurface cursorVisible: !readOnly - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small implicitHeight: contentHeight + topPadding + bottomPadding implicitWidth: contentWidth + leftPadding + rightPadding placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom @@ -18,14 +18,11 @@ TextField { verticalAlignment: TextInput.AlignVCenter Behavior on color { - CAnim { - } - } - cursorDelegate: Item { + CAnim {} } + cursorDelegate: Item {} Behavior on selectionColor { - CAnim { - } + CAnim {} } CustomRect { diff --git a/Components/Toast/ToastItem.qml b/Components/Toast/ToastItem.qml index b37ead8..254e20c 100644 --- a/Components/Toast/ToastItem.qml +++ b/Components/Toast/ToastItem.qml @@ -36,8 +36,7 @@ CustomRect { radius: Tokens.rounding.medium Behavior on border.color { - CAnim { - } + CAnim {} } Elevation { @@ -84,7 +83,7 @@ CustomRect { return Colors.palette.m3onError; return Colors.palette.m3onSurfaceVariant; } - font.pointSize: Math.round(Tokens.font.size.large * 1.2) + fontStyle: Tokens.font.icon.builders.large.scale(1.2).build() text: root.modelData.icon } } @@ -107,7 +106,7 @@ CustomRect { return Colors.palette.m3onSurface; } elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.small text: root.modelData.title } 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/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 0f8f305..3360c8b 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -10,6 +10,7 @@ import ZShell import qs.Components import qs.Modules import qs.Helpers +import qs.Services import qs.Paths import ZShell.Config @@ -25,7 +26,7 @@ Singleton { property alias server: server function shouldThrottle(appName: string): bool { - if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + if (props.dnd || ShellState.anySidebarOpen()) return false; const key = (appName || "unknown").trim().toLowerCase(); diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index 9b45c31..ac27301 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -3,28 +3,23 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import ZShell.Internal +import qs.Components Item { id: root readonly property alias content: contentLoader.item - readonly property PersistentProperties drawingState: PersistentProperties { - property color penColor: "white" - property int penWidth: 4 - - reloadableId: "drawingState" - } - required property PersistentProperties visibilities + required property ScreenState screenState Loader { id: contentLoader - active: root.visibilities.isDrawing + active: root.screenState.isDrawing anchors.fill: parent sourceComponent: StrokeCanvas { - penColor: root.drawingState.penColor - penWidth: root.drawingState.penWidth + penColor: root.screenState.penColor + penWidth: root.screenState.penWidth } } } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 6846a81..4e0d6a5 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -1,7 +1,7 @@ import Quickshell import QtQuick -import qs.Components import ZShell.Config +import qs.Components import qs.Helpers import qs.Modules.Bar.Popouts @@ -19,7 +19,7 @@ Item { required property ShellScreen screen property bool singleGestureTriggered: false property bool utilitiesShortcutActive - required property PersistentProperties visibilities + required property ScreenState screenState function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -63,7 +63,7 @@ Item { cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined dragThreshold: 0 - enabled: !root.visibilities.isDrawing + enabled: !root.screenState.isDrawing grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything maximumPointCount: 1 minimumPointCount: 1 @@ -76,45 +76,59 @@ Item { onCentroidChanged: { const x = centroid.position.x; const y = centroid.position.y; - const dragX = x - centroid.pressPosition.x; - const dragY = y - centroid.pressPosition.y; + const pressX = centroid.pressPosition.x; + const pressY = centroid.pressPosition.y; + const dragX = x - pressX; + const dragY = y - pressY; - if (centroid.pressPosition.y >= root.screen.height - Config.bar.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) - root.visibilities.launcher = true; + if (pressY >= root.screen.height - Config.bar.border && pressX > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) + root.screenState.launcher = true; if (root.singleGestureTriggered) return; - if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { - if (dragY > 20) { - root.visibilities.settings = true; + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY); + + if (inDashboard && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; root.singleGestureTriggered = true; - } else if (dragY < -20) { - root.visibilities.settings = false; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; root.singleGestureTriggered = true; } } - if (centroid.pressPosition.y > root.screen.height - root.geometry.insetBottom(root.borderThickness) && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) - root.visibilities.clipboard = true; + if (root.geometry.insetTop(root.borderThickness) > pressY && (Config.dashboard.showOnHover || !inDashboard)) { + if (dragY > 20) { + root.screenState.settings = true; + root.singleGestureTriggered = true; + } else if (dragY < -20) { + root.screenState.settings = false; + root.singleGestureTriggered = true; + } + } - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.geometry.insetTop(root.borderThickness) && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) + if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) + root.screenState.clipboard = true; + + if (!Config.dock.hoverToReveal && pressY > root.screen.height - root.geometry.insetTop(root.borderThickness) && pressX > root.screen.width / 5 && !root.screenState.launcher) if (dragY < -10) { - root.visibilities.dock = true; + root.screenState.dock = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x > root.screen.width - Config.bar.border && centroid.pressPosition.y < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { - root.visibilities.sidebar = true; + if (pressX > root.screen.width - Config.bar.border && pressY < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { + root.screenState.sidebar = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x >= root.screen.width - Config.bar.border && centroid.pressPosition.y > (root.screen.height / 2) && dragX < -20) { + if (pressX >= root.screen.width - Config.bar.border && pressY > (root.screen.height / 2) && dragX < -20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - if (centroid.pressPosition.x <= Config.bar.border && dragX > 20) { + if (pressX <= Config.bar.border && dragX > 20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } @@ -127,7 +141,7 @@ Item { property bool setInitialPoint: false acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.visibilities.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) + enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) onActiveChanged: { if (!active) { @@ -178,12 +192,12 @@ Item { HoverHandler { id: hoverHandler - cursorShape: root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined + cursorShape: root.screenState.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined onHoveredChanged: { if (!hovered) { if (!root.osdShortcutActive) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } @@ -193,13 +207,16 @@ Item { if (Config.bar.autoHide || root.bar.fullscreen) root.bar.isHovered = false; + + if (root.screenState.dashboard && Config.dashboard.showOnHover) + root.screenState.dashboard = false; } } onPointChanged: { const x = point.position.x; const y = point.position.y; - if (root.visibilities.isDrawing) { + if (root.screenState.isDrawing) { if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) { root.panels.drawing.expand(); root.drawing.content.hideHover(); @@ -210,7 +227,7 @@ Item { return; } - if (!root.visibilities.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) + if (!root.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) root.bar.isHovered = true; if (root.bar.fullscreen && !root.bar.isHovered) @@ -228,7 +245,7 @@ Item { const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y); if (!root.osdShortcutActive) { - root.visibilities.osd = showOsd; + root.screenState.osd = showOsd; root.panels.osd.hovered = showOsd; } else if (showOsd) { root.osdShortcutActive = false; @@ -236,8 +253,13 @@ Item { } } - if (Config.dock.enabled && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) - root.visibilities.dock = true; + if (Config.dock.enable && Config.dock.hoverToReveal && !root.screenState.dock && !root.screenState.launcher && root.inBottomPanel(root.panels.dock, x, y)) + root.screenState.dock = true; + + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, x, y); + + if (Config.dashboard.showOnHover) + root.screenState.dashboard = inDashboard; if (root.geometry.barContains(x, y)) root.bar.checkPopout(root.geometry.axisPos(x, y)); @@ -246,16 +268,17 @@ Item { Connections { function onDashboardChanged() { - if (root.visibilities.dashboard) { + if (root.screenState.dashboard) { const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); if (!inDashboardArea) { root.dashboardShortcutActive = true; } if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x) - root.visibilities.launcher = false; + root.screenState.launcher = false; - root.visibilities.settings = false; - root.visibilities.sidebar = false; + root.screenState.settings = false; + root.screenState.sidebar = false; + root.screenState.resources = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -263,12 +286,12 @@ Item { } function onIsDrawingChanged() { - if (!root.visibilities.isDrawing) + if (!root.screenState.isDrawing) root.drawing.clear(); } function onLauncherChanged() { - if (!root.visibilities.launcher) { + if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; @@ -276,23 +299,23 @@ Item { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } } - if (root.visibilities.launcher) { + if (root.screenState.launcher) { if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) { - root.visibilities.dashboard = false; + root.screenState.dashboard = false; } - root.visibilities.dock = false; - root.visibilities.settings = false; + root.screenState.dock = false; + root.screenState.settings = false; } } function onOsdChanged() { - if (root.visibilities.osd) { + if (root.screenState.osd) { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; @@ -303,32 +326,33 @@ Item { } function onResourcesChanged() { - if (root.visibilities.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { - root.popouts.hasCurrent = false; - } + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; - if (root.visibilities.resources) - root.visibilities.settings = false; + root.screenState.settings = false; + root.screenState.dashboard = false; + } } function onSettingsChanged() { - if (root.visibilities.settings) { - root.visibilities.resources = false; - root.visibilities.dashboard = false; - root.visibilities.sidebar = false; + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; root.panels.popouts.hasCurrent = false; - root.visibilities.launcher = false; + root.screenState.launcher = false; } } function onSidebarChanged() { - if (root.visibilities.sidebar) { - root.visibilities.dashboard = false; - root.visibilities.settings = false; + if (root.screenState.sidebar) { + root.screenState.dashboard = false; + root.screenState.settings = false; root.popouts.hasCurrent = false; } } - target: root.visibilities + target: root.screenState } } diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml new file mode 100644 index 0000000..e11bf02 --- /dev/null +++ b/Drawers/Interactions2.qml @@ -0,0 +1,315 @@ +import Quickshell +import QtQuick +import ZShell.Config +import ZShell.Internal +import qs.Components +import qs.Helpers +import qs.Modules.Bar.Popouts + +CustomMouseArea { + id: root + + required property Item bar + required property real borderThickness + property bool dashboardShortcutActive + property point dragStart + required property Drawing drawing + required property EdgeGeometry geometry + property bool osdShortcutActive + required property Panels panels + required property Wrapper popouts + required property ShellScreen screen + required property ScreenState screenState + property bool setInitialPoint: false + property bool singleGestureTriggered: false + property bool utilitiesShortcutActive + + function inBottomPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y); + } + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function inPopoutArea(x: real, y: real): bool { + const panel = panels.popoutsWrapper; + if (!geometry.horizontal) + return inLeftPanel(panel, x, y); + return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y); + } + + function inRightPanel(panel: Item, x: real, y: real): bool { + return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function inTopPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y); + } + + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = panel.y + geometry.insetTop(borderThickness); + return y >= panelY && y <= panelY + panel.height; + } + + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = panel.x + geometry.insetLeft(borderThickness); + return x >= panelX && x <= panelX + panel.width; + } + + acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: { + if (!containsMouse) { + if (!osdShortcutActive) { + screenState.osd = false; + panels.osd.hovered = false; + } + + if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { + popouts.hasCurrent = false; + } + + if (Config.bar.autoHide || bar.fullscreen) + bar.isHovered = false; + + if (screenState.dashboard && Config.dashboard.showOnHover) + screenState.dashboard = false; + } + } + onPositionChanged: event => { + const x = event.x; + const y = event.y; + const dragX = x - dragStart.x; + const dragY = y - dragStart.y; + + if (screenState.isDrawing) { + if (pressed) { + drawing.content.appendPoint(x, y); + } else { + if (inLeftPanel(panels.drawing, x, y)) { + if (!(pressedButtons & Qt.LeftButton)) { + panels.drawing.expand(); + drawing.content.hideHover(); + Cursor.clearOverride(); + } + } else { + Cursor.setOverride(Qt.BlankCursor); + drawing.content.showHover(x, y); + } + } + if (drawing.content.hoverVisible) + drawing.content.showHover(x, y); + return; + } + + if (root.bar.fullscreen && !root.bar.isHovered) + unhideTimer.start(); + + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; + + const entryAt = bar.entryAt(geometry.axisPos(x, y)); + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow"); + + if (!dashboardShortcutActive) { + screenState.dashboard = showDashboard; + } else if (showDashboard) { + dashboardShortcutActive = false; + } + + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) + screenState.dashboard = true; + else if (dragY < -Config.dashboard.dragThreshold) + screenState.dashboard = false; + } + + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) { + if (dragY > 20) { + screenState.settings = true; + } else if (dragY < -20) { + screenState.settings = false; + } + } + + if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50) + screenState.clipboard = true; + + if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher) + if (dragY < -10) + screenState.dock = true; + + if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + + if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); + + if (pressed && dragStart.x <= Config.bar.border && dragX > 20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); + + if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true)) + bar.isHovered = true; + + if (panels.sidebar.offsetScale === 1) { + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); + + if (!osdShortcutActive) { + screenState.osd = showOsd; + panels.osd.hovered = showOsd; + } else if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } + } + + if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y)) + screenState.dock = true; + + if (geometry.barContains(x, y)) + bar.checkPopout(geometry.axisPos(x, y)); + } + onPressed: event => { + dragStart = Qt.point(event.x, event.y); + + if (screenState.isDrawing) { + if (event.button & Qt.RightButton) { + drawing.content.clear(); + return; + } + + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.beginStroke(event.x, event.y); + } + } + onPressedChanged: { + if (!pressed) { + if (screenState.isDrawing) { + setInitialPoint = false; + drawing.content.endStroke(); + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.showHover(mouseX, mouseY); + } + } else { + if (screenState.isDrawing) { + panels.drawing.collapse(); + } + } + } + + Timer { + id: unhideTimer + + interval: Config.bar.revealDelay + repeat: false + running: false + + onTriggered: { + if (root.containsMouse) + root.bar.isHovered = true; + } + } + + Connections { + function onDashboardChanged(): void { + if (root.screenState.dashboard) { + const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + + root.screenState.launcher = false; + root.screenState.settings = false; + root.screenState.sidebar = false; + root.screenState.resources = false; + root.popouts.hasCurrent = false; + } else { + root.dashboardShortcutActive = false; + } + } + + function onIsDrawingChanged(): void { + if (root.screenState.isDrawing) { + Cursor.setOverride(Qt.BlankCursor); + } else { + Cursor.clearOverride(); + } + } + + function onLauncherChanged(): void { + if (!root.screenState.launcher) { + root.dashboardShortcutActive = false; + root.osdShortcutActive = false; + root.utilitiesShortcutActive = false; + + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + + if (!inOsdArea) { + root.screenState.osd = false; + root.panels.osd.hovered = false; + } + } + + if (root.screenState.launcher) { + root.screenState.dashboard = false; + root.screenState.dock = false; + root.screenState.settings = false; + } + } + + function onOsdChanged(): void { + if (root.screenState.osd) { + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + if (!inOsdArea) { + root.osdShortcutActive = true; + } + } else { + root.osdShortcutActive = false; + } + } + + function onResourcesChanged(): void { + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; + + root.screenState.settings = false; + root.screenState.dashboard = false; + } + } + + function onSettingsChanged(): void { + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; + root.panels.popouts.hasCurrent = false; + root.screenState.launcher = false; + } + } + + function onSidebarChanged(): void { + if (root.screenState.sidebar) { + if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x) + root.screenState.dashboard = false; + root.screenState.settings = false; + root.popouts.hasCurrent = false; + } + } + + target: root.screenState + } +} diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..f6542dd 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -37,12 +37,12 @@ Item { readonly property alias resources: resources readonly property alias resourcesWrapper: resourcesWrapper required property ShellScreen screen + required property ScreenState screenState readonly property alias settings: settings readonly property alias settingsWrapper: settingsWrapper readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property PersistentProperties visibilities anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -64,7 +64,7 @@ Item { id: resources position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -84,8 +84,8 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter screen: root.screen + screenState: root.screenState sidebarOrSessionVisible: sidebar.visible - visibilities: root.visibilities } } @@ -96,7 +96,7 @@ Item { anchors.verticalCenter: parent.verticalCenter drawing: root.drawingItem screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Popouts.ClipWrapper { @@ -121,8 +121,8 @@ Item { anchors.right: parent.right anchors.top: parent.top panels: root + screenState: root.screenState sidebarPanel: sidebar - visibilities: root.visibilities } Launcher.Wrapper { @@ -132,7 +132,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Utils.Wrapper { @@ -140,9 +140,8 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts + screenState: root.screenState sidebar: sidebar - visibilities: root.visibilities } Item { @@ -150,13 +149,11 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.bottom: root.geometry.barOnLeft || root.geometry.barOnBottom ? parent.bottom : undefined - anchors.left: root.geometry.barOnLeft ? parent.left : undefined - anchors.right: root.geometry.barOnLeft ? undefined : parent.right - anchors.top: root.geometry.barOnBottom || root.geometry.barOnLeft ? undefined : parent.top - clip: true - implicitHeight: root.geometry.horizontal ? dashboard.implicitHeight * (1 - offsetScale) : dashboard.implicitHeight - implicitWidth: root.geometry.horizontal ? dashboard.implicitWidth : dashboard.implicitWidth * (1 - offsetScale) + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + clip: offsetScale > 0 + implicitHeight: dashboard.implicitHeight * (1 - offsetScale) + implicitWidth: dashboard.implicitWidth Behavior on offsetScale { Anim { @@ -169,8 +166,7 @@ Item { id: dashboard offsetScale: dashboardWrapper.offsetScale - position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -181,7 +177,7 @@ Item { anchors.right: parent.right anchors.top: notifications.bottom panels: root - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -197,7 +193,7 @@ Item { anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } @@ -208,7 +204,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Clipboard.Wrapper { @@ -217,6 +213,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index ec24aec..877c061 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -51,49 +51,47 @@ CustomWindow { readonly property alias menuRegion: regions.menuPopoutRegion readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) property var root: Quickshell.shellDir + readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property real sdfBorderOffset: 2 * fsTransitionProg - readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) + readonly property real shadowOpacity: 0.5 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" - contentItem.focus: true - mask: visibilities.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) + mask: screenState.isDrawing || screenState.screenshot ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { - Anim { - } + Anim {} } Behavior on surfaceColor { - CAnim { - } + CAnim {} } contentItem.Keys.onEscapePressed: { if (Config.bar.autoHide) - visibilities.bar = false; - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.dock = false; - visibilities.clipboard = false; + screenState.bar = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.dock = false; + screenState.clipboard = false; panels.popouts.hasCurrent = false; } onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.clipboard = false; + screenState.dock = false; panels.popouts.hasCurrent = false; } @@ -146,53 +144,40 @@ CustomWindow { id: focusGrab active: { - const v = visibilities; - const conf = Config; - if (!N.PopupManager.closed && panels.popouts.hasCurrent) + const s = root.screenState; + const c = Config; + + if ((s.launcher && c.launcher.enabled) || (s.sidebar && c.sidebar.enabled) || (s.resources && c.dashboard.performance.enabled) || (s.clipboard && c.clipboard.enabled)) return true; - if ((v.launcher && conf.launcher.enabled) || (v.sidebar && conf.sidebar.enabled) || (v.resources && conf.dashboard.performance.enabled) || (v.dashboard && conf.dashboard.enabled) || v.settings || (v.clipboard && conf.clipboard.enabled) || (v.dock && conf.dock.enabled)) + if (s.dashboard && !c.dashboard.showOnHover && c.dashboard.enabled) return true; - if (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!conf.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + if (!c.dock.hoverToReveal && s.dock && c.dock.enable) + return true; + if (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) + return true; + if (s.settings) return true; return false; } windows: [root] onCleared: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + root.screenState.launcher = false; + root.screenState.sidebar = false; + root.screenState.dashboard = false; + root.screenState.osd = false; + root.screenState.settings = false; + root.screenState.resources = false; + root.screenState.clipboard = false; + root.screenState.dock = false; panels.popouts.hasCurrent = false; } } - PersistentProperties { - id: visibilities - - property bool bar - property bool clipboard - property bool dashboard - property bool dock - property bool isDrawing - property bool launcher - property bool notif: NotifServer.popups.length > 0 - property bool osd - property bool resources - property bool settings - property bool sidebar - - Component.onCompleted: Visibilities.load(root.screen, this) - } - Binding { property: "bar" - target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.bar.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered + target: root.screenState + value: root.screenState.sidebar || root.screenState.dashboard || root.screenState.osd || (!Config.bar.hideWhenNotif && NotifServer.popups.length > 0) || root.screenState.resources || root.screenState.settings || bar.isHovered when: Config.bar.autoHide } @@ -200,23 +185,19 @@ CustomWindow { id: surface anchors.fill: parent - layer.enabled: true opacity: root.surfaceColor.a - layer.effect: MultiEffect { - blurMax: 32 - shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity)) - shadowEnabled: true - } - - BlobGroup { + BlobGroup2 { id: blobGroup + anchors.fill: parent color: root.surfaceColor + shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity)) + shadowRadius: 18 smoothing: Config.bar.smoothing } - BlobInvertedRect { + BlobInvertedRect2 { anchors.fill: parent anchors.margins: -50 borderBottom: geometry.insetBottom(root.borderThickness) - anchors.margins - root.sdfBorderOffset @@ -266,6 +247,7 @@ CustomWindow { PanelBg { id: osdBg + atRestOverride: panels.osd.offsetScale < 0.99 deformAmount: 0.1 implicitHeight: panels.osd.height implicitWidth: panels.osd.width @@ -277,6 +259,7 @@ CustomWindow { PanelBg { id: notifsBg + atRestOverride: panel.implicitHeight > 0 panel: panels.notifications radius: Tokens.rounding.large } @@ -304,21 +287,23 @@ CustomWindow { y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) Behavior on extraExtent { - Anim { - } + Anim {} } } PanelBg { id: resourcesBg + property real extraExtent: 0.2 + + atRestOverride: panels.resources.offsetScale < 0.99 deformAmount: 0.05 - implicitHeight: panels.resources.height - implicitWidth: panels.resources.width + implicitHeight: panels.resources.height * (1 + extraExtent) + implicitWidth: panels.resources.width * (1 + extraExtent) panel: panels.resourcesWrapper radius: Tokens.rounding.largeIncreased + Tokens.padding.small - x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) - y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) + x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) - panels.resources.width * extraExtent + y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) - panels.resources.height * extraExtent } PanelBg { @@ -348,9 +333,15 @@ CustomWindow { PanelBg { id: clipboardBg + property real extraExtent: 0.2 + deformAmount: 0.03 + implicitHeight: panels.clipboard.height * (1 + extraExtent) + implicitWidth: panels.clipboard.width * (1 + extraExtent) panel: panels.clipboard radius: 29 + x: panels.clipboard.x + geometry.insetLeft(root.borderThickness) - panels.clipboard.width * extraExtent + y: panels.clipboard.y + geometry.insetTop(root.borderThickness) } } @@ -358,37 +349,11 @@ CustomWindow { id: drawing anchors.fill: parent - layer.enabled: true - visibilities: visibilities + screenState: root.screenState z: 2 - - layer.effect: MultiEffect { - maskEnabled: true - maskInverted: true - maskSource: maskSource - } } - Item { - id: maskSource - - anchors.fill: parent - layer.enabled: true - visible: false - - CustomRect { - readonly property int extraWidth: radius - - color: "white" - implicitHeight: panels.drawing.height - implicitWidth: panels.drawing.width + extraWidth - radius: drawingBg.radius - x: -extraWidth + root.borderThickness + panels.drawing.x - y: panels.drawing.y + bar.implicitHeight - } - } - - Interactions { + Interactions2 { id: interactions anchors.fill: parent @@ -400,7 +365,7 @@ CustomWindow { panels: panels popouts: panels.popouts screen: root.screen - visibilities: visibilities + screenState: root.screenState Panels { id: panels @@ -410,7 +375,7 @@ CustomWindow { drawingItem: drawing geometry: geometry screen: root.screen - visibilities: visibilities + screenState: root.screenState clipboard.transform: Matrix4x4 { matrix: clipboardBg.deformMatrix @@ -453,22 +418,40 @@ CustomWindow { anchors.bottom: geometry.barOnBottom ? parent.bottom : undefined anchors.left: parent.left anchors.top: geometry.barOnBottom ? undefined : parent.top - enabled: !visibilities.isDrawing + enabled: !screenState.isDrawing fullscreen: root.hasFullscreen height: geometry.horizontal ? implicitHeight : parent.height popouts: panels.popouts popoutsWrapper: panels.popoutsWrapper position: geometry.position screen: root.screen - visibilities: visibilities + screenState: root.screenState width: geometry.horizontal ? parent.width : implicitWidth } } - component PanelBg: BlobRect { + LazyLoader { + id: loader + + property bool closing: !root.screenState.screenshot + + onClosingChanged: if (!closing) + activeAsync = true + + Picker { + loader: loader + parent: root.contentItem + screen: root.screen + screenState: root.screenState + } + } + + component PanelBg: BlobRect2 { property real deformAmount: 0.15 required property Item panel + atRestOverride: panel?.offsetScale < 0.99 ?? false + damping: 28.3 deformScale: (deformAmount * Config.appearance.deform.scale) / 10000 group: blobGroup implicitHeight: panel.height diff --git a/Greeter/Services/Colors.qml b/Greeter/Services/Colors.qml index ee9d678..ac4dce6 100644 --- a/Greeter/Services/Colors.qml +++ b/Greeter/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,8 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; - Config.save(); + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/Greeter/WeatherInfo.qml b/Greeter/WeatherInfo.qml index d7f0f1c..60cda3a 100644 --- a/Greeter/WeatherInfo.qml +++ b/Greeter/WeatherInfo.qml @@ -119,7 +119,8 @@ ColumnLayout { length: count }, () => null); - return forecast.slice(0, count); + const start = Math.max(0, Weather.currentHourIndex); + return forecast.slice(start, start + count); } ColumnLayout { diff --git a/Helpers/AreaPicker.qml b/Helpers/AreaPicker.qml deleted file mode 100644 index ce15fab..0000000 --- a/Helpers/AreaPicker.qml +++ /dev/null @@ -1,88 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Wayland -import Quickshell.Io -import Quickshell.Hyprland -import ZShell -import qs.Components - -Scope { - LazyLoader { - id: root - - property bool closing - property bool freeze - - Variants { - model: Quickshell.screens - - PanelWindow { - id: win - - required property ShellScreen modelData - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "areapicker" - color: "transparent" - mask: root.closing ? empty : null - screen: modelData - - anchors { - bottom: true - left: true - right: true - top: true - } - - Region { - id: empty - - } - - Picker { - loader: root - screen: win.modelData - } - } - } - } - - IpcHandler { - function open(): void { - root.freeze = false; - root.closing = false; - root.activeAsync = true; - } - - function openFreeze(): void { - root.freeze = true; - root.closing = false; - root.activeAsync = true; - } - - target: "picker" - } - - CustomShortcut { - name: "screenshot" - - onPressed: { - root.freeze = false; - root.closing = false; - root.activeAsync = true; - } - } - - CustomShortcut { - name: "screenshotFreeze" - - onPressed: { - root.freeze = true; - root.closing = false; - root.activeAsync = true; - } - } -} diff --git a/Helpers/Hyprsunset.qml b/Helpers/Hyprsunset.qml deleted file mode 100644 index ca82be2..0000000 --- a/Helpers/Hyprsunset.qml +++ /dev/null @@ -1,37 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick -import ZShell.Services -import ZShell.Config - -Singleton { - id: root - - readonly property bool enabled: service.enabled - readonly property int end: Config.general.color.scheduleHyprsunsetEnd - property bool manualToggle: false - readonly property int start: Config.general.color.scheduleHyprsunsetStart - readonly property int temp: Config.general.color.hyprsunsetTemp - - function checkStartup(): void { - if (!Config.general.color.scheduleHyprsunset) - return; - - service.apply(); - } - - function toggleNightLight(): void { - service.manualToggle = true; - service.toggle(); - } - - HyprsunsetManager { - id: service - - activeAuto: Config.general.color.scheduleHyprsunset - endTime: root.end - startTime: root.start - temp: root.temp - } -} diff --git a/Helpers/ModeScheduler.qml b/Helpers/ModeScheduler.qml index a45e15a..14b6aa8 100644 --- a/Helpers/ModeScheduler.qml +++ b/Helpers/ModeScheduler.qml @@ -11,14 +11,14 @@ import qs.Services Singleton { id: root - readonly property int darkEnd: Config.general.color.scheduleDarkEnd - readonly property int darkStart: Config.general.color.scheduleDarkStart - readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration + readonly property int darkEnd: Config.colors.scheduleDarkEnd + readonly property int darkStart: Config.colors.scheduleDarkStart + readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen function applyDarkMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); - Config.general.color.mode = "dark"; + Config.colors.mode = "dark"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]); @@ -30,13 +30,13 @@ Singleton { function applyLightMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); - Config.general.color.mode = "light"; + Config.colors.mode = "light"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]); Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]); - if (Config.general.color.neovimColors) + if (Config.colors.neovimColors) Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]); } diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml deleted file mode 100644 index b78c540..0000000 --- a/Helpers/NetworkUsage.qml +++ /dev/null @@ -1,242 +0,0 @@ -pragma Singleton - -import QtQuick -import Quickshell -import Quickshell.Io -import ZShell.Internal -import ZShell.Config - -Singleton { - id: root - - property real _downloadSpeed: 0 - property real _downloadTotal: 0 - - property real _initialRxBytes: 0 - property real _initialTxBytes: 0 - property bool _initialized: false - - property real _prevRxBytes: 0 - property real _prevTimestamp: 0 - property real _prevTxBytes: 0 - property real _uploadSpeed: 0 - property real _uploadTotal: 0 - - readonly property CircularBuffer downloadBuffer: _downloadBuffer - - readonly property real downloadSpeed: _downloadSpeed - - readonly property real downloadTotal: _downloadTotal - readonly property int historyLength: Config.dashboard.performance.showVram ? 70 : 30 - property int refCount: 0 - readonly property CircularBuffer uploadBuffer: _uploadBuffer - readonly property real uploadSpeed: _uploadSpeed - readonly property real uploadTotal: _uploadTotal - -function resetSampling(): void { - netDevFile.reload(); - - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; -} - - function formatBytes(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B/s" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B/s" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB/s" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB/s" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB/s" - }; - } - } - - function formatBytesTotal(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB" - }; - } - } - - function parseNetDev(content: string): var { - const lines = content.split("\n"); - let totalRx = 0; - let totalTx = 0; - - for (let i = 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) - continue; - - const parts = line.split(/\s+/); - if (parts.length < 10) - continue; - - const iface = parts[0].replace(":", ""); - // Skip loopback interface - if (iface === "lo") - continue; - - const rxBytes = parseFloat(parts[1]) || 0; - const txBytes = parseFloat(parts[9]) || 0; - - totalRx += rxBytes; - totalTx += txBytes; - } - - return { - rx: totalRx, - tx: totalTx - }; - } - - CircularBuffer { - id: _downloadBuffer - - capacity: root.historyLength + 1 - } - - CircularBuffer { - id: _uploadBuffer - - capacity: root.historyLength + 1 - } - - FileView { - id: netDevFile - - path: "/proc/net/dev" - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval - repeat: true - running: true - triggeredOnStart: true - - onTriggered: { - if (root.refCount <= 0) { - root.resetSampling(); - return; - } - - netDevFile.reload(); - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - if (!root._initialized) { - root._initialRxBytes = data.rx; - root._initialTxBytes = data.tx; - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - root._initialized = true; - return; - } - - const timeDelta = (now - root._prevTimestamp) / 1000; // seconds - if (timeDelta > 0) { - // Calculate byte deltas - let rxDelta = data.rx - root._prevRxBytes; - let txDelta = data.tx - root._prevTxBytes; - - // Handle counter overflow (when counters wrap around from max to 0) - // This happens when counters exceed 32-bit or 64-bit limits - if (rxDelta < 0) { - // Counter wrapped around - assume 64-bit counter - rxDelta += Math.pow(2, 64); - } - if (txDelta < 0) { - txDelta += Math.pow(2, 64); - } - - // Calculate speeds - root._downloadSpeed = rxDelta / timeDelta; - root._uploadSpeed = txDelta / timeDelta; - - if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) - _downloadBuffer.push(root._downloadSpeed); - - if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) - _uploadBuffer.push(root._uploadSpeed); - } - - // Calculate totals with overflow handling - let downTotal = data.rx - root._initialRxBytes; - let upTotal = data.tx - root._initialTxBytes; - - // Handle counter overflow for totals - if (downTotal < 0) { - downTotal += Math.pow(2, 64); - } - if (upTotal < 0) { - upTotal += Math.pow(2, 64); - } - - root._downloadTotal = downTotal; - root._uploadTotal = upTotal; - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - } - } -} diff --git a/Helpers/Nightlight.qml b/Helpers/Nightlight.qml new file mode 100644 index 0000000..e64958d --- /dev/null +++ b/Helpers/Nightlight.qml @@ -0,0 +1,39 @@ +pragma Singleton + +import Quickshell +import QtQuick +import ZShell.Services +import ZShell.Config + +Singleton { + id: root + + readonly property bool enabled: service.enabled + readonly property int end: Config.display.nightlight.scheduleEnd + property bool manualToggle: false + readonly property int start: Config.display.nightlight.scheduleStart + readonly property int temp: Config.display.nightlight.temp + + function checkStartup(): void { + if (!Config.display.nightlight.schedule) + return; + + service.apply(); + } + + function toggleNightLight(): void { + service.manualToggle = true; + service.toggle(true); + } + + NightlightManager { + id: service + + activeAuto: Config.display.nightlight.schedule + endTime: root.end + startTime: root.start + temp: root.temp + fadeDuration: Config.display.nightlight.fadeDuration / 1000 + useNativeNightlight: Config.display.nightlight.useNative + } +} diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..9839ff9 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -41,8 +41,9 @@ Item { property real rsy: Math.min(sy, ey) readonly property real scaleRatio: Hypr.monitorFor(screen).scale required property ShellScreen screen + required property ScreenState screenState property real sh: Math.abs(sy - ey) - readonly property color shadowColor: Hypr.options.decoration.shadow.color + readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black" readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled readonly property var shadowOffset: Hypr.options.decoration.shadow.offset readonly property int shadowRange: Hypr.options.decoration.shadow.range @@ -97,10 +98,6 @@ Item { const args = Config.screenshot.mode === "auto" ? ["--rounding", `${rounding}`, "--radius", root.cornerRadius, "--shadow", root.shadowEnabled, "--shadow-blur", `${shadow_blur}`, "--shadow-color", `${r},${g},${b},${a}`, "--shadow-offset-x", root.shadowOffset[0], "--shadow-offset-y", root.shadowOffset[1]] : []; const postCmd = Config.screenshot.enable_pp ? ["zshell-img-tools", "--scale", root.scaleRatio, ...args, "--image"] : null; - overlay.visible = border.visible = false; - handles.visible = false; - toolbar.visible = false; - ZShellIo.saveItem(captureLayer, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => { const finalize = finalPath => { if (copyToClipboard) { @@ -126,39 +123,35 @@ Item { focus: true opacity: 0 + Behavior on rsx { + enabled: !selectArea.pressed && root.mode === "select" + + Anim {} + } + Behavior on rsy { + enabled: !selectArea.pressed && root.mode === "select" + + Anim {} + } + Behavior on sh { + enabled: !selectArea.pressed && root.mode === "select" + + Anim {} + } + Behavior on sw { + enabled: !selectArea.pressed && root.mode === "select" + + Anim {} + } Behavior on opacity { Anim { duration: 300 } } - Behavior on rsx { - enabled: !selectArea.pressed && root.mode === "select" - - ExAnim { - } - } - Behavior on rsy { - enabled: !selectArea.pressed && root.mode === "select" - - ExAnim { - } - } - Behavior on sh { - enabled: !selectArea.pressed && root.mode === "select" - - ExAnim { - } - } - Behavior on sw { - enabled: !selectArea.pressed && root.mode === "select" - - ExAnim { - } - } Component.onCompleted: { Hypr.extras.refreshOptions(); - screencopy.active = true; + root.forceActiveFocus(); opacity = 1; @@ -223,13 +216,63 @@ Item { onReleased: root.enterEditMode() } + Connections { + function onClosingChanged(): void { + if (root.loader.closing) + closeAnim.start(); + else { + closeAnim.stop(); + returnAnim.start(); + } + } + + target: root.loader + } + + ParallelAnimation { + id: returnAnim + + Anim { + duration: 300 + property: "opacity" + target: root + to: 1 + } + + Anim { + property: "rsx" + target: root + to: Math.min(root.sx, root.ex) + } + + Anim { + property: "rsy" + target: root + to: Math.min(root.sy, root.ey) + } + + Anim { + property: "sw" + target: root + to: Math.abs(root.sx - root.ex) + } + + Anim { + property: "sh" + target: root + to: Math.abs(root.sy - root.ey) + } + } + SequentialAnimation { id: closeAnim - PropertyAction { - property: "closing" - target: root.loader - value: true + ScriptAction { + script: { + const states = ShellState.allStates(); + for (const s of states) + s.screenshot = false; + } } ParallelAnimation { @@ -240,19 +283,19 @@ Item { to: 0 } - ExAnim { + Anim { properties: "rsx,rsy" target: root to: 0 } - ExAnim { + Anim { property: "sw" target: root to: root.screen.width } - ExAnim { + Anim { property: "sh" target: root to: root.screen.height @@ -271,17 +314,12 @@ Item { anchors.fill: parent - Loader { + ScreencopyView { id: screencopy - active: false anchors.fill: parent - asynchronous: true - - sourceComponent: ScreencopyView { - captureSource: root.screen - paintCursor: false - } + captureSource: root.screen + paintCursor: false } AnnotationCanvas { @@ -290,6 +328,7 @@ Item { anchors.fill: parent inkColor: root.inkColor layer.enabled: true + selection: selectionRect tool: root.mode === "edit" && root.tool !== "move" ? root.tool : "none" layer.effect: MultiEffect { @@ -301,15 +340,14 @@ Item { } } - Rectangle { + CustomRect { id: overlay anchors.fill: parent color: "white" layer.enabled: true opacity: 0.3 - radius: root.realRounding - visible: screencopy.item?.hasContent ?? false + visible: screencopy.hasContent layer.effect: MultiEffect { maskEnabled: true @@ -338,39 +376,44 @@ Item { } } - Rectangle { + CustomRect { id: border border.color: Colors.palette.m3primary border.width: root.realBorderWidth - color: "transparent" implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 - visible: screencopy.item?.hasContent ?? false + visible: screencopy.hasContent x: selectionRect.x - root.realBorderWidth y: selectionRect.y - root.realBorderWidth Behavior on border.color { - Anim { - } + Anim {} } } SelectionHandles { id: handles + border: root.realBorderWidth selH: root.sh selW: root.sw selX: root.rsx selY: root.rsy + selection: selectionRect + tool: root.tool visible: root.mode === "edit" && root.tool === "move" onBoundsChanged: (x, y, w, h) => { - root.sx = x; - root.sy = y; - root.ex = x + w; - root.ey = y + h; + let nx = Math.max(0, x); + let ny = Math.max(0, y); + let nw = Math.min(w, root.screen.width - nx); + let nh = Math.min(h, root.screen.height - ny); + root.sx = nx; + root.sy = ny; + root.ex = nx + nw; + root.ey = ny + nh; } onMoveBy: (dx, dy) => { const w = root.sw, h = root.sh; @@ -401,7 +444,4 @@ Item { onToolSelected: t => root.tool = t onUndoRequested: annotations.undo() } - - component ExAnim: Anim { - } } diff --git a/Helpers/Picker/AnnotationCanvas.qml b/Helpers/Picker/AnnotationCanvas.qml index 7ff98b0..4116eaf 100755 --- a/Helpers/Picker/AnnotationCanvas.qml +++ b/Helpers/Picker/AnnotationCanvas.qml @@ -8,13 +8,13 @@ Item { property color inkColor: "#ff3b30" property real inkWidth: 4 + required property Rectangle selection property string tool: "none" signal changed function clearAll() { - committedCanvas.clear(); - previewCanvas.clear(); + canvas.clear(); changed(); } @@ -54,90 +54,85 @@ Item { } function undo() { - committedCanvas.undoLastGroup(); + canvas.undoLastGroup(); changed(); } anchors.fill: parent + enabled: tool !== "none" StrokeCanvas { - id: committedCanvas + id: canvas anchors.fill: parent penColor: root.inkColor penWidth: root.inkWidth } - StrokeCanvas { - id: previewCanvas - - anchors.fill: parent - penColor: root.inkColor - penWidth: root.inkWidth - } - - PointHandler { + MouseArea { id: drawHandler property real startX property real startY - property bool started: false acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.tool !== "none" + anchors.fill: parent + cursorShape: undefined + enabled: root.enabled - onActiveChanged: { - if (active) + onPositionChanged: e => { + const x = e.x; + const y = e.y; + + if (!pressed) return; if (root.tool === "pen") { - committedCanvas.endStroke(); - } else if (started) { - const pts = root.sampleShape(root.tool, startX, startY, point.position.x, point.position.y); - if (pts.length > 0) { - committedCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - committedCanvas.appendPoint(pts[i][0], pts[i][1]); - committedCanvas.endStroke(); - } - previewCanvas.clear(); - } - started = false; - root.changed(); - } - onPointChanged: { - if (!active) + canvas.appendPoint(x, y); return; + } - if (point.pressedButtons & Qt.RightButton) { - root.clearAll(); - return; + const pts = root.sampleShape(root.tool, startX, startY, x, y); + if (pts.length > 0) { + canvas.beginStroke(pts[0][0], pts[0][1], true); + for (let i = 1; i < pts.length; i++) + canvas.appendPoint(pts[i][0], pts[i][1]); } + } + onPressed: e => { + if (e.button & Qt.LeftButton) { + startX = e.x; + startY = e.y; + canvas.beginStroke(e.x, e.y); + } else if (e.button & Qt.RightButton) { + root.clearAll(); + } + } + onPressedChanged: if (!pressed) + canvas.endStroke() + } + + HoverHandler { + cursorShape: { + if (root.tool === "none") + return undefined; const x = point.position.x; const y = point.position.y; + const withinXWidth = x > root.selection.x && x < root.selection.x + root.selection.implicitWidth; + const withinYHeight = y > root.selection.y && y < root.selection.y + root.selection.implicitHeight; - if (root.tool === "pen") { - if (!started) { - started = true; - committedCanvas.beginStroke(x, y); - return; - } - committedCanvas.appendPoint(x, y); - return; - } - - if (!started) { - started = true; - startX = point.pressPosition.x; - startY = point.pressPosition.y; - } - const pts = root.sampleShape(root.tool, startX, startY, x, y); - if (pts.length > 0) { - previewCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - previewCanvas.appendPoint(pts[i][0], pts[i][1]); + if (withinXWidth && withinYHeight) { + if (!drawHandler.pressed) + canvas.showHover(x, y); + else + canvas.showHover(drawHandler.mouseX, drawHandler.mouseY); + return Qt.BlankCursor; + } else { + canvas.hideHover(); + return undefined; } } + enabled: root.enabled } } diff --git a/Helpers/Picker/AnnotationToolbar.qml b/Helpers/Picker/AnnotationToolbar.qml index d95eb37..fb6b673 100755 --- a/Helpers/Picker/AnnotationToolbar.qml +++ b/Helpers/Picker/AnnotationToolbar.qml @@ -73,7 +73,7 @@ CustomRect { required property var modelData color: root.tool === modelData.id ? "#3584e4" : "transparent" - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: tool.modelData.glyph inactiveColor: root.tool === modelData.id ? Colors.palette.m3primary : "transparent" inactiveOnColor: root.tool === modelData.id ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface @@ -103,7 +103,7 @@ CustomRect { required property color modelData fillWidth: false - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize @@ -128,7 +128,7 @@ CustomRect { spacing: Tokens.spacing.extraSmall IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "undo" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -140,7 +140,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_history" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -152,7 +152,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "content_copy" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -164,7 +164,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_forever" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -176,7 +176,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "check" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface diff --git a/Helpers/Picker/SelectionHandles.qml b/Helpers/Picker/SelectionHandles.qml index eca4b0f..3359220 100755 --- a/Helpers/Picker/SelectionHandles.qml +++ b/Helpers/Picker/SelectionHandles.qml @@ -1,18 +1,23 @@ pragma ComponentBehavior: Bound import QtQuick +import ZShell.Internal +import qs.Services Item { id: root - property color handleBorder: "#3584e4" - property color handleFill: "white" + required property int border + property color handleBorder: Colors.palette.m3primary + property color handleFill: Colors.palette.m3onPrimary property real hs: 12 property real minSize: 24 required property real selH required property real selW required property real selX required property real selY + required property Rectangle selection + required property string tool signal boundsChanged(x: real, y: real, w: real, h: real) signal dragFinished @@ -23,12 +28,16 @@ Item { x: selX y: selY + Component.onDestruction: Cursor.clearOverride() + DragHandler { id: moveHandler property real accumX: 0 property real accumY: 0 + cursorShape: undefined + dragThreshold: 0 target: null onActiveChanged: { @@ -45,51 +54,63 @@ Item { } } + HoverHandler { + cursorShape: { + if (root.tool !== "move") + return undefined; + + if ((moveHandler.centroid.pressedButtons & Qt.LeftButton) && moveHandler.active) + return Qt.ClosedHandCursor; + else + return Qt.OpenHandCursor; + } + } + Handle { edge: "tl" - x: -root.hs / 2 - y: -root.hs / 2 + x: -(root.hs + root.border) / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "t" x: root.width / 2 - root.hs / 2 - y: -root.hs / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "tr" - x: root.width - root.hs / 2 - y: -root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "r" - x: root.width - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 y: root.height / 2 - root.hs / 2 } Handle { edge: "br" - x: root.width - root.hs / 2 - y: root.height - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "b" x: root.width / 2 - root.hs / 2 - y: root.height - root.hs / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "bl" - x: -root.hs / 2 - y: root.height - root.hs / 2 + x: -(root.hs + root.border) / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "l" - x: -root.hs / 2 + x: -(root.hs + root.border) / 2 y: root.height / 2 - root.hs / 2 } @@ -107,6 +128,8 @@ Item { z: 10 HoverHandler { + id: hover + cursorShape: { switch (handle.edge) { case "tl": @@ -132,6 +155,7 @@ Item { property real startX property real startY + dragThreshold: 0 grabPermissions: PointerHandler.CanTakeOverFromAnything | PointerHandler.TakeOverForbidden target: null @@ -141,8 +165,10 @@ Item { startY = root.y; startW = root.width; startH = root.height; + Cursor.setOverride(hover.cursorShape); } else { root.dragFinished(); + Cursor.clearOverride(); } } onTranslationChanged: { diff --git a/Helpers/Strings.qml b/Helpers/Strings.qml index f1980b5..0e9061b 100644 --- a/Helpers/Strings.qml +++ b/Helpers/Strings.qml @@ -5,6 +5,15 @@ import Quickshell Singleton { property var _regexCache: ({}) + function percent(value: int): string { + // TRANSLATORS: %1 = a number + return qsTr("%1%").arg(value); + } + + function percentOne(value: real): string { + return percent(Math.round(value * 100)); + } + function testRegexList(filterList: list, target: string): bool { const regexChecker = /^\^.*\$$/; for (const filter of filterList) { diff --git a/Helpers/Visibilities.qml b/Helpers/Visibilities.qml deleted file mode 100644 index fe22047..0000000 --- a/Helpers/Visibilities.qml +++ /dev/null @@ -1,16 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - property var bars: new Map() - property var screens: new Map() - - function getForActive(): PersistentProperties { - return screens.get(Hypr.focusedMonitor); - } - - function load(screen: ShellScreen, visibilities: var): void { - screens.set(Hypr.monitorFor(screen), visibilities); - } -} diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index ba4b64c..c335feb 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -5,7 +5,9 @@ import Quickshell import Quickshell.Io import QtQuick import ZShell.Models +import ZShell.Internal import ZShell.Config +import qs.Components import qs.Services import qs.Helpers import qs.Paths @@ -38,7 +40,7 @@ Searcher { previewPath = path; showPreview = true; - if (Config.general.color.schemeGeneration) + if (Config.colors.schemeGen) previewColorsProc.running = true; } @@ -62,8 +64,8 @@ Searcher { WallpaperPath.currentWallpaperPath = path; Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); + if (Config.colors.schemeGen) + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); } function stopPreview(): void { @@ -140,4 +142,40 @@ Searcher { } } } + + WallpaperService { + id: wallpaper + + path: root.current + } + + Variants { + model: Quickshell.screens + + WallpaperObject { + readonly property var crops: root.getCrop(modelData.name) + required property var modelData + + cropHeight: crops?.height ?? 1.0 + cropWidth: crops?.width ?? 1.0 + cropX: crops?.x ?? 0.0 + cropY: crops?.y ?? 0.0 + screen: modelData.name + service: wallpaper + zoom: crops?.zoom ?? 1.0 + + Behavior on cropHeight { + Anim {} + } + Behavior on cropWidth { + Anim {} + } + Behavior on cropX { + Anim {} + } + Behavior on cropY { + Anim {} + } + } + } } diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index ecf5e80..0b9b535 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -3,25 +3,36 @@ pragma Singleton import QtQuick import Quickshell import ZShell +import ZShell.Internal import ZShell.Config Singleton { id: root readonly property var cachedCities: new Map() - property var cc - property string city readonly property string description: cc?.weatherDesc ?? qsTr("No weather") readonly property string feelsLike: formatTemp(cc?.feelsLikeC) - property list forecast - property list hourlyForecast readonly property int humidity: cc?.humidity ?? 0 readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" - property string loc readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string temp: formatTemp(cc?.tempC) readonly property real windSpeed: cc?.windSpeed ?? 0 + readonly property CircularBuffer precipitationForecast: precipitationBuffer + + property var cc + property string city + property list forecast + property list hourlyForecast + readonly property int currentHourIndex: { + const now = new Date(); + for (let i = 0; i < hourlyForecast.length; i++) { + if (new Date(hourlyForecast[i].timestamp.replace("T", " ")) >= now) + return i; + } + return -1; + } + property string loc function fetchCityFromCoords(coords: string): void { if (cachedCities.has(coords)) { @@ -112,11 +123,12 @@ Singleton { forecast = forecastList; const hourlyList = []; - const now = new Date(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); for (let i = 0; i < json.hourly.time.length; i++) { const time = new Date(json.hourly.time[i].replace("T", " ")); - if (time < now) + if (time < startOfToday) continue; hourlyList.push({ @@ -129,6 +141,10 @@ Singleton { }); } hourlyForecast = hourlyList; + precipitationBuffer.clear(); + + for (const hour of hourlyList) + precipitationBuffer.push(hour.precipChance); }); } @@ -258,6 +274,11 @@ Singleton { onLocChanged: fetchWeatherData() + CircularBuffer { + id: precipitationBuffer + capacity: 24 + } + Connections { function onWeatherLocationChanged(): void { root.reload(); diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 5673cfb..d6b2d02 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts @@ -16,12 +17,12 @@ GridLayout { required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen + required property ScreenState screenState readonly property int vPadding: 6 - required property PersistentProperties visibilities function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); - return horizontal ? c.x : c.y; + return horizontal ? Math.floor(c.x) : Math.floor(c.y); } function checkPopout(pos: real): void { @@ -34,7 +35,7 @@ GridLayout { return; } - if (visibilities.sidebar || visibilities.dashboard || visibilities.resources || visibilities.settings) + if (screenState.sidebar || screenState.dashboard || screenState.resources || screenState.settings) return; if (id === "statusIcons" && Config.bar.popouts.statusIcons) { @@ -88,10 +89,10 @@ GridLayout { role: "id" DelegateChoice { - roleValue: "hyprsunset" + roleValue: "nightlight" delegate: EntryWrapper { - HyprsunsetWidget { + NightlightWidget { horizontal: root.horizontal visible: !root.fullscreen || root.isHovered } @@ -149,7 +150,7 @@ GridLayout { delegate: EntryWrapper { Resources { horizontal: root.horizontal - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -173,7 +174,7 @@ GridLayout { NotifBell { horizontal: root.horizontal popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -187,7 +188,7 @@ GridLayout { horizontal: root.horizontal loader: root popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 5fd3a70..82ff8e8 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -10,7 +10,7 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 + readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.sizes.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentThickness property real extent: fullscreen ? 0 : Config.bar.border required property bool fullscreen @@ -21,9 +21,9 @@ Item { required property ClipWrapper popoutsWrapper required property string position required property ShellScreen screen - readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || visibilities.bar)) || isHovered + readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || screenState.bar)) || isHovered readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function checkPopout(pos: real): void { (content.item as Bar)?.checkPopout(pos); @@ -85,7 +85,7 @@ Item { popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Bar/Border.qml b/Modules/Bar/Border.qml index c67fa45..95503f6 100644 --- a/Modules/Bar/Border.qml +++ b/Modules/Bar/Border.qml @@ -11,7 +11,7 @@ Item { id: root required property Item bar - required property PersistentProperties visibilities + required property ScreenState screenState anchors.fill: parent diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e7de27d..e47f198 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,7 +13,8 @@ import qs.Services WidgetBase { id: root - readonly property color contentColor: visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property font font: Tokens.font.clock.family(Config.appearance.font.family.clock).build() + readonly property color contentColor: Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -44,10 +45,9 @@ WidgetBase { } required property GridLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2 - required property PersistentProperties visibilities + readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 + required property ScreenState screenState - color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -57,14 +57,13 @@ WidgetBase { anchors.centerIn: parent color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font height: implicitHeight text: Time.dateStr visible: root.horizontal Behavior on color { - CAnim { - } + CAnim {} } } @@ -84,12 +83,11 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, modelData) Behavior on color { - CAnim { - } + CAnim {} } } } @@ -114,7 +112,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: { if (modelData.includes("h")) return Time.hourStr; @@ -126,8 +124,7 @@ WidgetBase { } Behavior on color { - CAnim { - } + CAnim {} } } } @@ -135,23 +132,13 @@ WidgetBase { CustomText { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, "AP") visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 Behavior on color { - CAnim { - } + CAnim {} } } } - - StateLayer { - acceptedButtons: Qt.LeftButton - color: root.contentColor - - onClicked: { - root.visibilities.dashboard = !root.visibilities.dashboard; - } - } } diff --git a/Modules/Bar/Components/Common/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml index 2f13bc9..6a78dfa 100644 --- a/Modules/Bar/Components/Common/BatteryIcon.qml +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -28,8 +28,7 @@ Row { CustomText { color: Battery.getColors(root.percentage, root.devState).fg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) @@ -60,8 +59,7 @@ Row { clip: true color: Battery.getColors(root.percentage, root.devState).bg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) diff --git a/Modules/Bar/Components/Common/WidgetBase.qml b/Modules/Bar/Components/Common/WidgetBase.qml index 2a0fb95..3795a31 100644 --- a/Modules/Bar/Components/Common/WidgetBase.qml +++ b/Modules/Bar/Components/Common/WidgetBase.qml @@ -6,7 +6,7 @@ import qs.Services CustomRect { id: root - readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.bar.innerSize * 1.5)) + readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.sizes.bar.innerSize * 1.5)) required property bool horizontal color: Colors.tPalette.m3surfaceContainer diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 32fb007..7d2297f 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -19,14 +19,12 @@ WidgetBase { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } TextMetrics { @@ -47,15 +45,14 @@ WidgetBase { columns: root.horizontal ? -1 : 1 Behavior on implicitWidth { - Anim { - } + Anim {} } MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Players.active?.isPlaying ? "music_note" : "music_off" } @@ -65,7 +62,6 @@ WidgetBase { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter implicitHeight: root.horizontal ? root.implicitHeight : root.textWidth implicitWidth: root.horizontal ? root.textWidth : root.implicitWidth diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/NightlightWidget.qml similarity index 76% rename from Modules/Bar/Components/HyprsunsetWidget.qml rename to Modules/Bar/Components/NightlightWidget.qml index 8afd545..f875d5b 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/NightlightWidget.qml @@ -8,15 +8,15 @@ import qs.Services WidgetBase { id: root - property bool tempEnabled: Hyprsunset.enabled + property bool tempEnabled: Nightlight.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full StateLayer { onClicked: { - Hyprsunset.toggleNightLight(); - Hyprsunset.manualToggle = true; + Nightlight.toggleNightLight(); + Nightlight.manualToggle = true; } } @@ -25,12 +25,11 @@ WidgetBase { animate: true color: root.tempEnabled ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { - Anim { - } + Anim {} } } } diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index c255deb..dfeb760 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -11,35 +11,33 @@ WidgetBase { id: root required property Wrapper popouts - required property PersistentProperties visibilities + required property ScreenState screenState - color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full MaterialIcon { id: notificationCenterIcon anchors.centerIn: parent - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - fill: root.visibilities.sidebar ? 1 : 0 - font.pointSize: Tokens.font.size.larger + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + fill: root.screenState.sidebar ? 1 : 0 + fontStyle: Tokens.font.icon.size(16).build() text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" Behavior on color { - CAnim { - } + CAnim {} } Behavior on fill { - Anim { - } + Anim {} } } StateLayer { - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface onClicked: { - root.visibilities.sidebar = !root.visibilities.sidebar; + root.screenState.sidebar = !root.screenState.sidebar; } } } diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index 4314ee7..b22d9e7 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import QtQuick.Shapes +import Quickshell import qs.Components import ZShell.Config import qs.Services @@ -12,6 +12,7 @@ GridLayout { property real animatedPercentage: 0 readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI + readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 required property bool horizontal property string icon required property color iconColor @@ -22,6 +23,10 @@ GridLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 + function snap(v) { + return Math.round(v * dpr) / dpr; + } + columnSpacing: Tokens.spacing.medium columns: horizontal ? -1 : 1 percentage: 0 @@ -44,31 +49,39 @@ GridLayout { Layout.preferredWidth: 16 color: root.iconColor - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.icon } - CustomClippingRect { + CustomRect { + id: track + Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4 Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - radius: Tokens.rounding.full + radius: Math.min(width, height) / 2 - CustomRect { - id: fill + Item { + id: clipper - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: root.horizontal ? parent.right : undefined - anchors.top: root.horizontal ? undefined : parent.top - color: root.mainColor - implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0 - implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width) + anchors.bottom: root.horizontal ? parent.bottom : undefined + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + clip: true + height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2 + width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width) - // Behavior on implicitHeight { - // Anim { - // } - // } + CustomRect { + anchors.bottom: root.horizontal ? parent.bottom : undefined + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + color: root.mainColor + height: track.height + radius: track.radius + width: track.width + } } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 56cb3d9..32bd1f2 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -12,18 +12,18 @@ import qs.Components WidgetBase { id: root - required property PersistentProperties visibilities + required property ScreenState screenState clip: true - color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.medium * 2 implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.medium * 2 : baseSize radius: Tokens.rounding.full StateLayer { - color: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - onClicked: root.visibilities.resources = !root.visibilities.resources + onClicked: root.screenState.resources = !root.screenState.resources } GridLayout { @@ -53,8 +53,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Cpu.percentage warningThreshold: 95 } @@ -64,8 +64,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory_alt" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary percentage: Memory.percentage warningThreshold: 80 } @@ -75,8 +75,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "gamepad" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary percentage: Gpu.percentage } @@ -85,8 +85,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "developer_board" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Gpu.memoryUsed / Gpu.memoryTotal } } diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index 93c4752..a6fab2a 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: Audio.muted ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.muted ? "volume_off" : "volume_up" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index 8f58616..cb61bee 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: (Audio.sourceMuted ?? false) ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.sourceMuted ? "mic_off" : "mic" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml index 8cdc2a7..48c1b71 100644 --- a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -15,20 +15,16 @@ Item { implicitWidth: currentItem?.implicitWidth ?? 0 Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Component.onCompleted: Battery.startDaemon() @@ -67,7 +63,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/Bar/Components/Tray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml index d7b8fc1..9a1e228 100644 --- a/Modules/Bar/Components/Tray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -62,10 +62,10 @@ Item { root.popouts.currentName = `traymenu${root.ind}`; root.popouts.currentCenter = Qt.binding(() => root.mapToItem(root.loader, root.implicitWidth / 2, 0).x); root.popouts.hasCurrent = true; - if (visibilities.sidebar || visibilities.dashboard || visibilities.settings) { - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.settings = false; + if (screenState.sidebar || screenState.dashboard || screenState.settings) { + screenState.sidebar = false; + screenState.dashboard = false; + screenState.settings = false; } } } diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index 6fe8ac7..fae91a7 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -42,7 +42,7 @@ WidgetBase { Repeater { id: repeater - model: SystemTray.items + model: SystemTray.items.values.filter(i => i.status !== Status.Passive && i.hasMenu) TrayItem { id: trayItem diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml index a525040..a44c0de 100644 --- a/Modules/Bar/Components/UpdatesWidget.qml +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -27,14 +27,13 @@ WidgetBase { MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + fontStyle: Tokens.font.icon.small text: "package_2" } CustomText { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter color: root.textColor - font.pointSize: Tokens.font.size.normal text: root.countUpdates } } diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml index c213072..7515093 100644 --- a/Modules/Bar/Components/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -52,8 +52,7 @@ Item { elide: Qt.ElideRight elideWidth: root.maxSize - (root.horizontal ? root.current.anchors.leftMargin : 0) - font.family: "Rubik" - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small text: Hypr.activeToplevel?.title ?? qsTr("Desktop") onElideWidthChanged: root.current.text = elidedText @@ -81,8 +80,7 @@ Item { width: root.horizontal ? implicitWidth : implicitHeight Behavior on opacity { - Anim { - } + Anim {} } transform: [ Translate { diff --git a/Modules/Bar/Components/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml index cfa9a76..9c5d448 100644 --- a/Modules/Bar/Components/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -17,7 +17,7 @@ Item { required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) + readonly property real shortSize: Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown @@ -30,14 +30,12 @@ Item { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } CustomRect { diff --git a/Modules/Bar/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml index b702dd7..09c94e1 100644 --- a/Modules/Bar/Popouts/UPowerPopout.qml +++ b/Modules/Bar/Popouts/UPowerPopout.qml @@ -181,8 +181,7 @@ Column { } ] transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -246,7 +245,7 @@ Column { anchors.centerIn: parent color: profiles.current === text ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: profiles.current === text ? 1 : 0 - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge grade: Colors.light ? 0 : (text === "balance" ? -25 : 0) text: parent.icon diff --git a/Modules/Bar/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index 8a143f3..a5b380d 100644 --- a/Modules/Bar/Popouts/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -36,15 +36,17 @@ CustomClippingRect { MaterialIcon { id: noUpdatesIcon - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top + anchors.centerIn: parent + anchors.verticalCenterOffset: -noUpdatesText.implicitHeight color: Colors.tPalette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge * 3 + fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build() horizontalAlignment: Text.AlignHCenter text: "check" } CustomText { + id: noUpdatesText + anchors.horizontalCenter: parent.horizontalCenter anchors.top: noUpdatesIcon.bottom color: Colors.tPalette.m3onSurfaceVariant @@ -89,7 +91,7 @@ CustomClippingRect { anchors.rightMargin: Tokens.padding.small MaterialIcon { - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge text: "package_2" } @@ -100,7 +102,7 @@ CustomClippingRect { Layout.fillWidth: true Layout.preferredHeight: 25 elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium text: update.sections[0] } @@ -122,7 +124,7 @@ CustomClippingRect { Layout.preferredWidth: 125 animate: true color: Colors.palette.m3tertiary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 @@ -133,7 +135,7 @@ CustomClippingRect { MaterialIcon { Layout.fillHeight: true color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large horizontalAlignment: Text.AlignHCenter text: "arrow_right_alt" verticalAlignment: Text.AlignVCenter @@ -146,7 +148,7 @@ CustomClippingRect { Layout.preferredWidth: 120 animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 4be55ea..f4683c6 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -14,7 +14,7 @@ Item { readonly property int itemHeight: Config.clipboard.sizes.itemHeight required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin @@ -49,7 +49,7 @@ Item { Keys.onUpPressed: view.decrementCurrentIndex() onAccepted: { ClipHistory.copy(view.currentItem.modelData); - root.visibilities.clipboard = false; + root.screenState.clipboard = false; } } } @@ -66,8 +66,7 @@ Item { radius: 25 Behavior on implicitWidth { - Anim { - } + Anim {} } Column { @@ -104,7 +103,6 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small text: lineRow.modelData.line } } @@ -252,7 +250,7 @@ Item { anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: clipItem.isImage ? "image" : "text_fields" } diff --git a/Modules/Clipboard/Wrapper.qml b/Modules/Clipboard/Wrapper.qml index 1a0a8f7..cfd60a3 100644 --- a/Modules/Clipboard/Wrapper.qml +++ b/Modules/Clipboard/Wrapper.qml @@ -11,8 +11,8 @@ Item { property int contentHeight property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.clipboard && Config.clipboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.clipboard && Config.clipboard.enabled + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + Tokens.padding.small * 2 @@ -35,7 +35,7 @@ Item { sourceComponent: Content { screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 13e8e49..69b0c2f 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -1,82 +1,169 @@ pragma ComponentBehavior: Bound import Quickshell -import Quickshell.Widgets import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell import qs.Components Item { id: root - required property PersistentProperties dashState - readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 + readonly property var dashboardTabs: { + const allTabs = [ + { + component: dashComponent, + iconName: "dashboard", + text: qsTr("Dashboard"), + enabled: Config.dashboard.showDashboard + }, + { + component: weatherComponent, + iconName: "cloud", + text: qsTr("Weather"), + enabled: Config.dashboard.showWeather + }, + { + component: mediaComponent, + iconName: "queue_music", + text: qsTr("Media"), + enabled: Config.dashboard.showMedia + }, + { + component: procsComponent, + iconName: "speed", + text: qsTr("Processes"), + enabled: Config.dashboard.showProcesses + }, + ]; + return allTabs.filter(tab => tab.enabled); + } + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: nonAnimHeight implicitWidth: nonAnimWidth Behavior on implicitHeight { Anim { + type: Anim.SlowEffects } } Behavior on implicitWidth { Anim { + type: Anim.SlowEffects } } - ClippingRectangle { + Tabs { + id: tabs + + anchors.left: parent.left + anchors.margins: Tokens.padding.small + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + nonAnimWidth: view.implicitWidth + screenState: root.screenState + tabs: root.dashboardTabs + } + + Item { id: viewWrapper + property int radius: Tokens.rounding.extraLarge - anchors.margins + anchors.bottom: parent.bottom anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.right: parent.right - anchors.top: parent.top - color: "transparent" - radius: Tokens.rounding.extraLarge - anchors.margins + anchors.top: tabs.bottom + anchors.topMargin: Tokens.spacing.small + clip: view.currentIndex !== 3 || (!view.currentItem?.settled ?? true) Item { id: view - readonly property int currentIndex: root.dashState?.currentTab - readonly property Item currentItem: row.children[currentIndex] + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - implicitHeight: currentItem.implicitHeight - implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.implicitWidth ?? 0 - RowLayout { - id: row + Repeater { + id: repeater - Pane { - index: 0 + delegate: Loader { + id: paneLoader - sourceComponent: Dash { - dashState: root.dashState - visibilities: root.visibilities + required property int index + required property var modelData + + readonly property real targetX: index < view.currentIndex ? -view.implicitWidth : index > view.currentIndex ? view.implicitWidth : 0 + readonly property bool settled: x === targetX + + active: index === view.currentIndex || opacity > 0 + opacity: index === view.currentIndex ? 1 : 0 + sourceComponent: modelData.component + visible: opacity > 0 + x: targetX + y: 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } } + Behavior on x { + Anim { + type: Anim.DefaultEffects + } + } + } + model: ScriptModel { + values: root.dashboardTabs + } + } + + Component { + id: dashComponent + + Dash { + radius: viewWrapper.radius + screenState: root.screenState + } + } + + Component { + id: weatherComponent + + WeatherTab { + radius: viewWrapper.radius + } + } + + Component { + id: mediaComponent + + Media { + radius: viewWrapper.radius + screenState: root.screenState + } + } + + Component { + id: procsComponent + + ProcessesTab { + radius: viewWrapper.radius } } } } - - component Pane: Loader { - id: pane - - required property int index - - Layout.alignment: Qt.AlignTop - - Component.onCompleted: active = Qt.binding(() => { - // Always keep current tab loaded - if (pane.index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index bf7bc2a..4f61d07 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -11,10 +11,9 @@ import qs.Services GridLayout { id: root - required property PersistentProperties dashState - readonly property bool dashboardVisible: visibilities.dashboard - property int radius: Tokens.rounding.medium - required property PersistentProperties visibilities + readonly property bool dashboardVisible: screenState.dashboard + required property int radius + required property ScreenState screenState columnSpacing: Tokens.spacing.small rowSpacing: Tokens.spacing.small @@ -61,19 +60,18 @@ GridLayout { User { id: user - dashState: root.dashState + screenState: root.screenState } } Rect { Layout.columnSpan: 2 Layout.fillHeight: true - Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth Layout.row: 0 radius: root.radius - Weather { - } + Weather {} } Rect { @@ -87,7 +85,7 @@ GridLayout { Calendar { id: calendar - dashState: root.dashState + screenState: root.screenState } } @@ -104,20 +102,6 @@ GridLayout { } } - CustomClippingRect { - Layout.column: 0 - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: media.implicitHeight - Layout.row: 2 - color: Colors.tPalette.m3surfaceContainer - radius: root.radius - - Media { - id: media - } - } - component Rect: CustomRect { color: Colors.tPalette.m3surfaceContainer } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index 8161957..9d198ef 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -12,12 +12,12 @@ CustomMouseArea { id: root property int activeGrid: 1 - required property PersistentProperties dashState + required property ScreenState screenState property bool initialized: false property int month1 property int month2 - readonly property int realCurrMonth: dashState.currentDate.getMonth() - readonly property int realCurrYear: dashState.currentDate.getFullYear() + readonly property int realCurrMonth: screenState.dashboardDate.getMonth() + readonly property int realCurrYear: screenState.dashboardDate.getFullYear() property int year1 property int year2 @@ -43,9 +43,9 @@ CustomMouseArea { function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); else if (event.angleDelta.y < 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); } acceptedButtons: Qt.MiddleButton @@ -61,7 +61,7 @@ CustomMouseArea { initialized = true; } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() onRealCurrMonthChanged: handleDateChange() onRealCurrYearChanged: handleDateChange() @@ -135,7 +135,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) } Item { @@ -158,7 +158,7 @@ CustomMouseArea { } } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() } Item { @@ -206,7 +206,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) } } diff --git a/Modules/Dashboard/Dash/DateTime.qml b/Modules/Dashboard/Dash/DateTime.qml deleted file mode 100644 index 3a76d5c..0000000 --- a/Modules/Dashboard/Dash/DateTime.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Services - -Item { - id: root - - anchors.bottom: parent.bottom - anchors.top: parent.top - implicitWidth: 110 - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.hourStr - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3primary - font.family: "Rubik" - font.pointSize: 18 * 0.9 - text: "•••" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.minuteStr - } - } -} diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml deleted file mode 100644 index 53cda5a..0000000 --- a/Modules/Dashboard/Dash/Media.qml +++ /dev/null @@ -1,329 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Services.Mpris -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import QtQuick.Effects -import ZShell.Components -import ZShell.Services -import ZShell.Config -import qs.Modules.Dashboard.Dash.Media -import qs.Daemons -import qs.Components -import qs.Helpers -import qs.Services - -Item { - id: root - readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 - property real playerProgress: { - const active = Players.active; - return active?.length ? (active.position % active.length) / active.length : 0; - } - property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small - - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - function lengthStr(length: int): string { - if (length < 0) - return "-1:-1"; - - const hours = Math.floor(length / 3600); - const mins = Math.floor((length % 3600) / 60); - const secs = Math.floor(length % 60).toString().padStart(2, "0"); - - if (hours > 0) - return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - return `${mins}:${secs}`; - } - - Behavior on playerProgress { - Anim { - duration: Tokens.anim.durations.large - } - } - - Timer { - running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: Players.active?.positionChanged() - } - - ServiceRef { - service: Audio.cava - } - - BackgroundShapes { - anchors.fill: parent - } - - Shape { - id: visualizer - - readonly property int bars: Config.services.visualizerBars - property color color: Qt.alpha(Colors.palette.m3primary, 0.5) - property color fillColor: Qt.alpha(color, 0.1) - - anchors.fill: parent - anchors.leftMargin: -(shape.strokeWidth / 2) - layer.enabled: true - asynchronous: true - preferredRendererType: Shape.CurveRenderer - - ShapePath { - id: shape - strokeColor: visualizer.color - fillColor: visualizer.fillColor - strokeWidth: 2 - - PathSvg { - path: shape.curvePath - } - - property string curvePath: { - const values = Audio.cava.values; - const n = values.length; - - if (n < 2) - return ""; - - const h = root.height + shape.strokeWidth / 2; - const w = root.width + shape.strokeWidth; - - function x(i) { - return i * w / (n - 1); - } - - function y(i) { - return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2; - } - - let d = `M 0 ${h} `; - d += `L ${x(0)} ${y(0)} `; - - for (let i = 0; i < n - 1; ++i) { - const x0 = x(i); - const y0 = y(i); - - const x1 = x(i + 1); - const y1 = y(i + 1); - - const prev = Math.max(0, i - 1); - const next = Math.min(n - 1, i + 2); - - const c1x = x0 + (x1 - x(prev)) / 6; - const c1y = y0 + (y1 - y(prev)) / 6; - - const c2x = x1 - (x(next) - x0) / 6; - const c2y = y1 - (y(next) - y0) / 6; - - d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `; - } - - d += `L ${w} ${h} `; - d += `L 0 ${h} Z`; - - return d; - } - } - } - - CustomRect { - anchors.fill: visualizer - - color: Qt.alpha(Colors.palette.m3shadow, 0.2) - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: visualizer - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.large - anchors.leftMargin: Tokens.padding.extraLarge - - CoverVisualizer { - Layout.fillHeight: true - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.leftMargin: Tokens.spacing.largeIncreased - Layout.rightMargin: Tokens.spacing.largeIncreased - - spacing: Tokens.spacing.small - - MarqueeText { - id: title - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: album - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: artist - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3secondary - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - RowLayout { - id: positionSliderLayout - Layout.topMargin: Tokens.spacing.large - Layout.fillWidth: true - spacing: Tokens.spacing.small - - TextMetrics { - id: timeMetrics - - text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - } - - CustomText { - id: positionLabel - - Layout.preferredWidth: timeMetrics.width - text: root.lengthStr(Players.active?.position ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - - CustomSlider { - id: positionSlider - - Layout.fillWidth: true - value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 - enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength - wavy: true - animateWave: Players.active?.isPlaying ?? false - waveFrequency: 5 - waveDuration: 2000 - interactionOnMove: false - onInteraction: value => { - const active = Players.active; - if (active?.canSeek && active?.positionSupported) - active.position = value * active.length; - } - Binding { - target: positionLabel - property: "text" - value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) - when: positionSlider.dragging - } - } - - CustomText { - Layout.preferredWidth: timeMetrics.width - text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - } - ButtonRow { - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - Layout.leftMargin: positionSliderLayout.implicitWidth / 4 - Layout.rightMargin: positionSliderLayout.implicitWidth / 4 - - IconButton { - type: IconButton.Tonal - icon: "shuffle" - isRound: true - shapeMorph: true - checked: Players.active?.shuffle ?? false - enabled: Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - } - - IconButton { - id: previousBtn - - type: IconButton.Tonal - icon: "skip_previous" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoPrevious - onClicked: Players.active?.previous() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: playPauseBtn - - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - isRound: true - shapeMorph: true - fillWidth: true - checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: nextBtn - - type: IconButton.Tonal - icon: "skip_next" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoNext - onClicked: Players.active?.next() - font.pointSize: Tokens.font.size.large - } - - IconButton { - type: IconButton.Tonal - icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" - isRound: true - shapeMorph: true - checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - enabled: Players.active?.loopSupported - onClicked: { - const state = Players.active.loopState; - if (state === MprisLoopState.None) - Players.active.loopState = MprisLoopState.Track; - else if (state === MprisLoopState.Track) - Players.active.loopState = MprisLoopState.Playlist; - else - Players.active.loopState = MprisLoopState.None; - } - } - } - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 0c6e0c8..34da84c 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -9,7 +9,7 @@ import qs.Helpers Row { id: root - required property PersistentProperties dashState + required property ScreenState screenState padding: 20 spacing: 12 @@ -23,8 +23,7 @@ Row { MaterialIcon { anchors.centerIn: parent fill: 1 - font.pointSize: Math.floor(info.implicitHeight / 2) || 1 - grade: 200 + fontStyle: Tokens.font.icon.large text: "person" } @@ -52,7 +51,7 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: Colors.palette.m3primary implicitSize: Math.floor(13 * 1.34) source: SystemInfo.osLogo @@ -65,9 +64,8 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideRight - font.pointSize: 13 text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } @@ -100,10 +98,10 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: line.colour fill: 1 - font.pointSize: 13 + fontStyle: Tokens.font.icon.small text: line.icon } @@ -114,9 +112,8 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideNone - font.pointSize: 13 text: `: ${line.text}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } } diff --git a/Modules/Dashboard/Dash/Weather.qml b/Modules/Dashboard/Dash/Weather.qml index c951fb7..906ac93 100644 --- a/Modules/Dashboard/Dash/Weather.qml +++ b/Modules/Dashboard/Dash/Weather.qml @@ -19,7 +19,7 @@ Item { anchors.verticalCenter: parent.verticalCenter animate: true color: Colors.palette.m3secondary - font.pointSize: 54 + fontStyle: Tokens.font.icon.extraLarge text: Weather.icon } @@ -35,8 +35,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.extraLarge - font.weight: 500 + font: Tokens.font.body.large text: Weather.temp } diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml new file mode 100644 index 0000000..acf4399 --- /dev/null +++ b/Modules/Dashboard/Media.qml @@ -0,0 +1,158 @@ +import QtQuick +import QtQuick.Layouts +import M3Shapes +import ZShell.Config +import qs.Modules.Dashboard.Media +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + property ScreenState screenState + required property int radius + + implicitWidth: 1000 + implicitHeight: 320 + + CustomClippingRect { + anchors.fill: parent + radius: root.radius + + BackgroundShapes { + anchors.fill: parent + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.extraLarge + + CoverVisualizer { + Layout.fillHeight: true + implicitWidth: 250 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + state: Players.active ? "" : "noMedia" + + states: State { + name: "noMedia" + + PropertyChanges { + noMedia.opacity: 1 + content.opacity: 0 + } + } + + transitions: [ + Transition { + from: "" + + SequentialAnimation { + Anim { + target: content + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: noMedia + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + to: "" + + SequentialAnimation { + Anim { + target: noMedia + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: content + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: noMedia + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Tokens.padding.extraLarge * 2 + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialShape { + Layout.topMargin: (pathBounds().height - implicitSize) / 2 + Layout.bottomMargin: (pathBounds().height - implicitSize) / 2 + Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3primaryContainer + implicitSize: icon.implicitHeight + Tokens.padding.extraLarge * 2 + shape: MaterialShape.ClamShell + + Behavior on color { + CAnim {} + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "queue_music" + fontStyle: Tokens.font.icon.large + color: Colors.palette.m3onPrimaryContainer + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Nothing playing") + font: Tokens.font.body.medium + } + + CustomText { + text: qsTr("Play something for it to show up here!") + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.body.large + } + } + } + + Loader { + id: content + + anchors.fill: parent + asynchronous: true + active: opacity > 0 + + sourceComponent: RowLayout { + spacing: Tokens.spacing.extraLarge + + Details { + Layout.fillWidth: true + } + + LyricsAndSelector { + Layout.fillHeight: true + implicitWidth: 300 + } + } + } + } + } +} diff --git a/Modules/Dashboard/Dash/Media/BackgroundShapes.qml b/Modules/Dashboard/Media/BackgroundShapes.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/BackgroundShapes.qml rename to Modules/Dashboard/Media/BackgroundShapes.qml diff --git a/Modules/Dashboard/Dash/Media/CoverArt.qml b/Modules/Dashboard/Media/CoverArt.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/CoverArt.qml rename to Modules/Dashboard/Media/CoverArt.qml diff --git a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml b/Modules/Dashboard/Media/CoverVisualizer.qml similarity index 63% rename from Modules/Dashboard/Dash/Media/CoverVisualizer.qml rename to Modules/Dashboard/Media/CoverVisualizer.qml index b7476a0..171db11 100644 --- a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml +++ b/Modules/Dashboard/Media/CoverVisualizer.qml @@ -11,8 +11,8 @@ Item { id: cover anchors.centerIn: parent - implicitHeight: Config.dashboard.sizes.mediaCoverArtSize - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize + implicitHeight: 200 + implicitWidth: 200 shape.shape: MaterialShape.Cookie9Sided } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml new file mode 100644 index 0000000..2d6c6c9 --- /dev/null +++ b/Modules/Dashboard/Media/Details.qml @@ -0,0 +1,194 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +ColumnLayout { + id: root + + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + spacing: Tokens.spacing.extraSmall + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackTitle ?? "" + font: Tokens.font.title.large + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackArtist || qsTr("Unknown artist") + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colors.palette.m3secondary + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + RowLayout { + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.small + + TextMetrics { + id: timeMetrics + + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + font: Tokens.font.label.medium + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal + icon: "skip_previous" + isRound: true + shapeMorph: true + font: Tokens.font.icon.large + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font: Tokens.font.icon.large + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font: Tokens.font.icon.large + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) + } + } +} diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml new file mode 100644 index 0000000..cdec3dc --- /dev/null +++ b/Modules/Dashboard/Media/LyricList.qml @@ -0,0 +1,317 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Effects +import qs.Services +import qs.Helpers + +Item { + id: root + + // Funny binding hack to make lyrics update + readonly property var _: { + const p = Players.active; + if (p) + Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length); + else + Lyrics.clearTrack(); + } + + readonly property real fadeAmount: 0.1 + property list lyricList: Lyrics.lyrics + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + layer.enabled: true + visible: false + implicitWidth: root.width + implicitHeight: root.height + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.alpha("black", 0) + position: 0 + } + GradientStop { + color: Qt.alpha("black", 1) + position: root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 1) + position: 1 - root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 0) + position: 1 + } + } + } + } + + state: { + if (Lyrics.hasLyrics) + return "hasLyrics"; + if (Lyrics.loading) + return "loading"; + return "noLyrics"; + } + + states: [ + State { + name: "loading" + + PropertyChanges { + loadingIndicator.opacity: 1 + lyrics.opacity: 0 + noLyrics.opacity: 0 + } + }, + State { + name: "hasLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 1 + noLyrics.opacity: 0 + } + }, + State { + name: "noLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 0 + noLyrics.opacity: 1 + } + } + ] + + transitions: [ + Transition { + from: "loading" + + SequentialAnimation { + Anim { + target: loadingIndicator + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [lyrics, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "noLyrics" + + SequentialAnimation { + Anim { + target: noLyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, lyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: loadingIndicator + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.large + + CustomRect { + Layout.alignment: Qt.AlignHCenter + implicitWidth: shape.implicitSize + Tokens.padding.medium * 2 + implicitHeight: shape.implicitSize + Tokens.padding.medium * 2 + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: shape + + anchors.centerIn: parent + implicitSize: Math.round(300 / 5) + containsIcon: true // This removes the pentagon, which is not centered + } + } + + CustomText { + text: qsTr("Loading lyrics...") + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.body.medium + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Loader { + id: noLyrics + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "sentiment_sad" + fontStyle: Tokens.font.icon.extraLarge + color: Colors.palette.m3outline + } + + CustomText { + text: qsTr("No lyrics found") + color: Colors.palette.m3outline + font: Tokens.font.body.medium + } + } + } + + CustomListView { + id: lyrics + + anchors.fill: parent + anchors.topMargin: parent.height * root.fadeAmount / 2 + anchors.bottomMargin: parent.height * root.fadeAmount / 2 + + displayMarginBeginning: anchors.topMargin + displayMarginEnd: anchors.bottomMargin + + model: root.lyricList + Component.onCompleted: { + currentIndex = Qt.binding(() => { + model; // Force update when lyrics change + return Lyrics.indexForTime(Players.active?.position ?? 0); + }); + positionViewAtIndex(currentIndex, ListView.Center); + } + onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)) + + highlightRangeMode: ListView.ApplyRange + highlightMoveDuration: Tokens.anim.durations.large + highlightMoveVelocity: -1 + preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2 + preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2 + + spacing: Tokens.spacing.small + opacity: 0 + enabled: opacity > 0 + + delegate: CustomText { + id: lyric + + required property string modelData + required property int index + property real effectScale: ListView.isCurrentItem ? 1 : 0 + + anchors.left: lyrics.contentItem.left + anchors.right: lyrics.contentItem.right + + text: modelData || ". . ." + color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline + font: Tokens.font.body.medium + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + layer.enabled: effectScale > 0 + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colors.palette.m3primary + shadowOpacity: 0.5 * lyric.effectScale + shadowBlur: 0.6 * lyric.effectScale + blur: 0.4 * lyric.effectScale + } + + Behavior on effectScale { + Anim { + type: Anim.SlowEffects + } + } + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + const p = Players.active; + if (p) + p.position = Lyrics.timeForIndex(lyric.index); + } + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Behavior on lyricList { + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + PropertyAction {} + Anim { + target: lyrics + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml new file mode 100644 index 0000000..328cc5b --- /dev/null +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services + +Item { + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + RowLayout { + Layout.bottomMargin: -Tokens.spacing.medium + spacing: Tokens.spacing.medium + z: 1 + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + text: "lyrics" + fontStyle: Tokens.font.icon.large + } + + CustomText { + Layout.fillWidth: true + text: qsTr("Lyrics") + font: Tokens.font.body.large + } + + LyricsInfo {} + } + + LyricList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + SplitButton { + Layout.alignment: Qt.AlignHCenter + + type: SplitButton.Tonal + enabled: Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players", "no media players active") + + minLeftWidth: layout.width - expandBtn.implicitWidth - spacing + label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2 + label.elide: Text.ElideRight + + stateLayer.enabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } +} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml new file mode 100644 index 0000000..cc15f41 --- /dev/null +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -0,0 +1,212 @@ +import QtQuick +import QtQuick.Layouts +import ZShell +import ZShell.Blobs +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + property bool open + readonly property real padding: Tokens.padding.large + + implicitWidth: btn.implicitWidth * 0.9 + implicitHeight: btn.implicitHeight * 0.9 + + BlobGroup { + id: blobGroup + + color: Colors.palette.m3surfaceContainerHighest + smoothing: Tokens.rounding.medium + cornerFill: false + + Behavior on color { + CAnim {} + } + } + + BlobRect { + id: btnRect + + anchors.fill: parent + anchors.margins: !btn.pressed && btn.containsMouse ? -Tokens.padding.extraSmall : 0 + group: blobGroup + radius: Tokens.rounding.medium + + Behavior on anchors.margins { + Anim {} + } + } + + BlobRect { + id: rect + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: parent.width + implicitHeight: parent.height + + group: blobGroup + radius: Tokens.rounding.medium + deformScale: 0.00001 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + rect.anchors.rightMargin: root.width - Tokens.spacing.small + rect.anchors.topMargin: -Tokens.padding.medium + rect.implicitWidth: Math.max(layout.implicitWidth, placeholder.implicitWidth) + root.padding * 2 + rect.implicitHeight: Math.max(layout.implicitHeight, placeholder.implicitHeight) + root.padding * 2 + content.opacity: 1 + } + } + + transitions: Transition { + Anim { + properties: "rightMargin,implicitWidth" + } + Anim { + properties: "topMargin,implicitHeight" + easing: Tokens.anim.expressiveFastSpatial + } + Anim { + property: "opacity" + type: Anim.DefaultEffects + } + } + + Behavior on implicitWidth { + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } + + Item { + id: content + + anchors.fill: parent + clip: true + opacity: 0 + state: Lyrics.loading || !Lyrics.hasLyrics ? "" : "hasLyrics" + + states: State { + name: "hasLyrics" + + PropertyChanges { + layout.opacity: 1 + placeholder.opacity: 0 + } + } + + transitions: [ + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: layout + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: placeholder + property: "opacity" + type: Anim.DefaultEffects + } + } + }, + Transition { + to: "hasLyrics" + + SequentialAnimation { + Anim { + target: placeholder + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: layout + property: "opacity" + type: Anim.DefaultEffects + } + } + } + ] + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + opacity: 0 + + CustomText { + // TRANSLATORS: %1 = lyrics backend name, e.g. LRCLIB + text: qsTr("Backend: %1").arg(ZUtils.enumToString(Lyrics, "backend")) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + + CustomText { + Layout.maximumWidth: 1000 / 2 + // TRANSLATORS: %1/%2/%3 = matched track title, artist and album + text: qsTr("Selected candidate: %1 | %2 | %3").arg(Lyrics.selectedCandidate.title).arg(Lyrics.selectedCandidate.artist).arg(Lyrics.selectedCandidate.album) + color: Colors.palette.m3onSurfaceVariant + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + animate: true + } + + CustomText { + // TRANSLATORS: %1 = lyric timing offset; ms is the millisecond unit, leave untranslated + text: qsTr("Offset: %1 ms").arg(Lyrics.offset) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + } + + Item { + id: placeholder + + anchors.centerIn: parent + implicitWidth: placeholderText.implicitWidth + implicitHeight: placeholderText.implicitHeight + + CustomText { + id: placeholderText + + text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.body.medium + animate: true + } + } + } + } + + MouseArea { + id: btn + + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Tokens.padding.extraSmall * 2 + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: root.open = !root.open + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "more_vert" + fontStyle: Tokens.font.icon.medium + } + } +} diff --git a/Modules/Dashboard/Processes/ProcessesColumns.qml b/Modules/Dashboard/Processes/ProcessesColumns.qml new file mode 100644 index 0000000..0dec3d4 --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesColumns.qml @@ -0,0 +1,220 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Services + +Singleton { + id: root + + readonly property var columnDefs: [ + { + id: "command", + title: qsTr("Command"), + width: 200, + field: 3 + }, + { + id: "pid", + title: qsTr("PID"), + width: 60, + field: 1 + }, + { + id: "cpuUsage", + title: qsTr("CPU %"), + width: 70, + field: 7 + }, + { + id: "memoryUsage", + title: qsTr("Memory"), + width: 90, + field: 8 + }, + { + id: "elapsed", + title: qsTr("Elapsed"), + width: 80, + field: 9 + }, + { + id: "ppid", + title: qsTr("PPID"), + width: 60, + field: 2 + }, + { + id: "user", + title: qsTr("User"), + width: 100, + field: 11 + }, + { + id: "uid", + title: qsTr("UID"), + width: 60, + field: 10 + }, + { + id: "state", + title: qsTr("State"), + width: 60, + field: 5 + }, + { + id: "sleeping", + title: qsTr("Sleeping"), + width: 80, + field: 6 + }, + { + id: "commandLine", + title: qsTr("Command line"), + width: 240, + field: 4 + }, + { + id: "ioReadBytes", + title: qsTr("IO read"), + width: 90, + field: 12 + }, + { + id: "ioWriteBytes", + title: qsTr("IO write"), + width: 90, + field: 13 + } + ] + + readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ + icon: e.icon, + command: e.command + })) + + Component.onCompleted: { + Processes.setDesktopEntries(root.desktopEntries); + Qt.callLater(ensurePinnedFirst); + } + + onDesktopEntriesChanged: Processes.setDesktopEntries(root.desktopEntries) + + readonly property string pinnedColumnId: "command" + + readonly property var hiddenColumns: { + const visible = Config.dashboard.processColumns.values.map(e => e.id); + return root.columnDefs.filter(d => !visible.includes(d.id)); + } + + function pinnedIndex(): int { + const values = Config.dashboard.processColumns.values; + for (let i = 0; i < values.length; i++) + if (values[i].id === root.pinnedColumnId) + return i; + return -1; + } + + function ensurePinnedFirst() { + const i = root.pinnedIndex(); + if (i > 0) + Qt.callLater(() => { + const idx = root.pinnedIndex(); + if (idx > 0) + Config.dashboard.processColumns.move(idx, 0); + }); + } + + function defFor(id: string): var { + for (const def of root.columnDefs) + if (def.id === id) + return def; + return null; + } + + function addColumn(id: string) { + const def = root.defFor(id); + Config.dashboard.processColumns.insert({ + id: id, + width: def ? def.width : 100 + }); + } + + function removeColumn(index: int) { + const entry = Config.dashboard.processColumns.at(index); + if (entry && entry.id === root.pinnedColumnId) + return; + Config.dashboard.processColumns.remove(index); + } + + function moveColumns(from: int, to: int) { + const list = Config.dashboard.processColumns; + const entry = list.at(from); + if (!entry) + return; + if (entry.id === root.pinnedColumnId) + return; + if (to === 0 && list.at(0)?.id === root.pinnedColumnId) + return; + + list.move(from, to); + + const i = root.pinnedIndex(); + if (i > 0) + list.move(i, 0); + } + + function resizeColumn(index: int, width: real) { + const entry = Config.dashboard.processColumns.at(index); + if (entry) + entry.width = Math.max(50, width); + } + + function dampOvershoot(overshoot: real, maxOvershoot: real): real { + return overshoot / (1 + overshoot / maxOvershoot); + } + + function formatCell(id: string, value: var): string { + switch (id) { + case "cpuUsage": + return value.toFixed(1) + "%"; + case "memoryUsage": + return root.formatBytes(value * 1024); + case "ioReadBytes": + case "ioWriteBytes": + return root.formatBytes(value); + case "elapsed": + return root.formatElapsed(value); + case "sleeping": + return value ? qsTr("Yes") : qsTr("No"); + default: + return String(value); + } + } + + function formatBytes(bytes: var): string { + if (bytes === undefined || bytes === null || bytes <= 0) + return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let v = Number(bytes); + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + ++i; + } + return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + " " + units[i]; + } + + function formatElapsed(secs: var): string { + const s = Math.max(0, Math.floor(Number(secs))); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + if (h > 0) + return h + "h " + m + "m"; + if (m > 0) + return m + "m " + ss + "s"; + return ss + "s"; + } +} diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml new file mode 100644 index 0000000..307d4da --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -0,0 +1,665 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Internal +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + required property int headerHeight + required property ProcessesList pList + + readonly property alias columnList: columnList + readonly property alias columnsModel: columnsData + readonly property bool anyHeld: columnList.contentItem.children.some(c => c.held) + + z: 1 + implicitHeight: headerHeight + + function widthForColumn(id: string, configured: real): real { + for (const item of columnList.contentItem.children) { + if (item.modelData !== undefined && item.modelData.id === id) + return item.liveWidth > 0 ? item.liveWidth : (configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100)); + } + return configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100); + } + + function fillMissingWidths() { + const n = columnsData.values.length; + if (n === 0 || columnList.width <= 0) + return; + + const spacingTotal = columnList.spacing * Math.max(0, n - 1); + + let savedTotal = 0; + const unsavedIndices = []; + for (let i = 0; i < n; i++) { + const w = columnsData.values[i].width; + if (w > 0) + savedTotal += w; + else + unsavedIndices.push(i); + } + + if (unsavedIndices.length === 0) + return; + + const available = columnList.width - spacingTotal - savedTotal; + if (available <= 0) + return; + + const each = Math.max(50, available / unsavedIndices.length); + for (const idx of unsavedIndices) + ProcessesColumns.resizeColumn(idx, each); + } + + Connections { + target: columnList + function onWidthChanged() { + root.fillMissingWidths(); + } + + function onCountChanged() { + resizeOverlay.geometryTick++; + root.fillMissingWidths(); + } + + function onContentWidthChanged() { + resizeOverlay.geometryTick++; + } + } + + Connections { + target: columnsData + function onValuesChanged() { + root.fillMissingWidths(); + resizeOverlay.geometryTick++; + } + } + + Component.onCompleted: Qt.callLater(fillMissingWidths) + Component.onDestruction: Cursor.clearOverride() + + CustomListView { + id: columnList + + anchors.fill: parent + orientation: ListView.Horizontal + interactive: false + spacing: Tokens.spacing.extraSmall / 2 + + add: Transition { + Anim { + from: 0 + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + } + displaced: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + move: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + remove: Transition { + Anim { + to: 0 + properties: "opacity" + type: Anim.DefaultEffects + } + } + + model: DelegateModel { + id: columnsVisual + + delegate: ColumnHeaderCell { + parentModel: columnsVisual + } + + model: ScriptModel { + id: columnsData + values: Config.dashboard.processColumns.values + } + } + } + + Item { + id: resizeOverlay + + property real gapBoost: pressed ? Tokens.spacing.small : Tokens.spacing.extraSmall + property bool pressed: false + property int geometryTick: 0 + property int activeGapIndex: -1 + + anchors.fill: columnList + z: 10 + + Component.onCompleted: Qt.callLater(() => geometryTick++) + + Repeater { + model: Math.max(0, columnsData.values.length - 1) + + MouseArea { + id: handleRoot + + required property int index + + readonly property bool hovered: containsMouse + + property ColumnHeaderCell leftCell: { + const left = leftCell; + if (resizing && left) + return left; + const tick = resizeOverlay.geometryTick; + return resolveCell(true); + } + + property ColumnHeaderCell rightCell: { + const right = rightCell; + if (resizing && right) + return right; + const tick = resizeOverlay.geometryTick; + return resolveCell(false); + } + + property bool resizing: false + property real originX: 0 + property real originWidth: 0 + property real originRightWidth: 0 + property real originAbsX: 0 + + function resolveCell(left: bool): ColumnHeaderCell { + if (left) { // qmllint disable incompatible-type + const leftItem = columnList.itemAtIndex(index); + if (leftItem) + return leftItem; + else + return null; + } else { + const rightItem = columnList.itemAtIndex(index + 1); + if (rightItem) + return rightItem; + else + return null; + } + } + + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + preventStealing: true + hoverEnabled: true + enabled: !root.anyHeld + + x: { + if (!leftCell) + return 0; + const gapCenter = leftCell.x + leftCell.width + columnList.spacing / 2; + return gapCenter - width / 2; + } + y: 0 + implicitWidth: 16 + implicitHeight: columnList.height + + function pairAvailableWidth(rightCell) { + let othersWidth = 0; + for (const item of columnList.contentItem.children) { + if (item.modelData === undefined || item === handleRoot.leftCell || item === rightCell) + continue; + othersWidth += item.width; + } + const spacingTotal = columnList.spacing * Math.max(0, columnsData.values.length - 1); + return columnList.width - othersWidth - spacingTotal; + } + + CustomRect { + id: handle + + readonly property bool shouldBeVisible: handleRoot.hovered || handleRoot.pressed + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + implicitWidth: shouldBeVisible ? resizeOverlay.gapBoost : 0 + color: Colors.palette.m3secondary + radius: Tokens.rounding.full + opacity: shouldBeVisible ? 1.0 : 0 + + Behavior on implicitWidth { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on opacity { + Anim {} + } + + transform: Scale { + origin { + x: handle.implicitWidth / 2 + y: handle.height / 2 + } + + yScale: handle.shouldBeVisible ? 1 : 0.5 + xScale: handle.shouldBeVisible ? 1 : 0.5 + + Behavior on xScale { + Anim {} + } + + Behavior on yScale { + Anim {} + } + } + } + + onHoveredChanged: resizeOverlay.activeGapIndex = hovered || pressed ? handleRoot.index : (resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex) + + onPressedChanged: { + resizeOverlay.pressed = pressed; + if (!pressed) + resizeOverlay.activeGapIndex = hovered ? handleRoot.index : resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex; + } + + onPressed: e => { + if (!handleRoot.leftCell) + return; + + Cursor.setOverride(Qt.ClosedHandCursor); + resizing = true; + originAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + originWidth = handleRoot.leftCell.implicitWidth; + originRightWidth = handleRoot.rightCell ? handleRoot.rightCell.implicitWidth : 0; + } + + onPositionChanged: e => { + if (!resizing || !handleRoot.leftCell) + return; + + const nowAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + let newLeftWidth = Math.max(50, originWidth + (nowAbsX - originAbsX)); + + const rightCell = handleRoot.rightCell; + let roundedLeft = Math.round(newLeftWidth); + + if (rightCell) { + const available = Math.round(handleRoot.pairAvailableWidth(rightCell)); + const maxLeftWidth = Math.max(50, available - 50); + roundedLeft = Math.min(roundedLeft, maxLeftWidth); + rightCell.liveWidth = Math.max(50, available - roundedLeft); + } + + handleRoot.leftCell.liveWidth = roundedLeft; + } + + onReleased: { + Cursor.clearOverride(); + resizing = false; + const cell = handleRoot.leftCell; + const rightCell = handleRoot.rightCell; + + if (cell.liveWidth !== 0) + ProcessesColumns.resizeColumn(cell.DelegateModel.itemsIndex, cell.liveWidth); + if (rightCell.liveWidth !== 0) + ProcessesColumns.resizeColumn(rightCell.DelegateModel.itemsIndex, rightCell.liveWidth); + if (cell || rightCell) { + cell.liveWidth = 0; + rightCell.liveWidth = 0; + } + resizeOverlay.geometryTick++; + } + } + } + } + + component ColumnHeaderCell: Item { + id: cell + + required property var modelData + required property int index + required property DelegateModel parentModel + + property int leftRadius: pushSide !== 1 && DelegateModel.itemsIndex === 0 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property int rightRadius: pushSide !== -1 && DelegateModel.itemsIndex === parentModel.count - 1 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property real radiusLerpProg + property bool held + property bool moved: false + property int pressIndex: -1 + property point pressPos + property real lastMoveX: 0 + property real liveWidth: 0 + + readonly property bool isActiveSort: Processes.sortField === (ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0) + readonly property int pushSide: { + const gapIdx = resizeOverlay.activeGapIndex; + if (gapIdx < 0) + return 0; + const myIdx = cell.DelegateModel.itemsIndex; + if (myIdx === gapIdx) + return -1; + if (myIdx === gapIdx + 1) + return 1; + return 0; + } + property real gapBoost: resizeOverlay.gapBoost + readonly property real gapShrink: gapBoost / 2 + + property real gapProg: pushSide !== 0 ? 1 : 0 + property real gapX: pushSide === 1 ? 1 : 0 + + function lerpRadius(a: real, b: real): real { + return a + (b - a) * radiusLerpProg; + } + + Behavior on gapBoost { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapX { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapProg { + Anim { + type: Anim.FastSpatial + } + } + + implicitHeight: root.headerHeight + implicitWidth: liveWidth > 0 ? liveWidth : (modelData.width > 0 ? modelData.width : (ProcessesColumns.defFor(modelData.id)?.width ?? 100)) + z: held || returnAnim.running ? 1 : 0 + + states: State { + name: "held" + + PropertyChanges { + cellBg.color: Colors.palette.m3primaryContainer + elevation.opacity: 1 + placeholder.opacity: 0.1 + cell.radiusLerpProg: 1 + title.color: Colors.palette.m3onPrimaryContainer + removeBtn.inactiveOnColor: Colors.palette.m3onPrimaryContainer + } + } + + Behavior on leftRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on rightRadius { + Anim { + type: Anim.DefaultEffects + } + } + transitions: Transition { + Anim { + properties: "opacity,radiusLerpProg" + type: Anim.SlowEffects + } + + PropertyAction { + properties: "color,inactiveOnColor" + } + } + state: held ? "held" : "" + + CustomRect { + id: placeholder + + anchors.fill: parent + color: Colors.palette.m3primary + opacity: 0 + topLeftRadius: cell.leftRadius + bottomLeftRadius: cell.leftRadius + topRightRadius: cell.rightRadius + bottomRightRadius: cell.rightRadius + } + + DragHandler { + id: dragHandler + + property real centerOffset: 0 + + enabled: cell.modelData.id !== ProcessesColumns.pinnedColumnId + target: null + xAxis.enabled: true + dragThreshold: 10 + grabPermissions: PointerHandler.CanTakeOverFromItems + + onActiveChanged: { + if (active) { + Cursor.setOverride(Qt.ClosedHandCursor); + returnAnim.stop(); + + cell.pressIndex = cell.DelegateModel.itemsIndex; + cell.lastMoveX = cell.x; + cell.moved = true; + cell.held = true; + + const p = centroid.position; + cell.pressPos = Qt.point(p.x, p.y); + dragHandler.centerOffset = p.x - cell.width / 2; + + cellContent.x = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLarge; + const xDiff = cell.x - cell.lastMoveX; + const x = dragHandler.centroid.position.x - cell.pressPos.x - xDiff; + const absX = cell.mapToItem(columnList.contentItem, x, 0).x; + const maxX = columnList.contentWidth - cell.width; + + if (absX < 0) + return x - absX - ProcessesColumns.dampOvershoot(-absX, maxOvershoot); + + if (absX > maxX) + return x - absX + maxX + ProcessesColumns.dampOvershoot(absX - maxX, maxOvershoot); + + return x; + }); + + cellContent.y = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLargeIncreased; + const y = dragHandler.centroid.position.y - cell.pressPos.y; + + return ProcessesColumns.dampOvershoot(Math.abs(y), maxOvershoot) * Math.sign(y); + }); + } else { + Cursor.clearOverride(); + if (cell.held) { + cellContent.x = cellContent.x; + cellContent.y = cellContent.y; + + cell.held = false; + returnAnim.start(); + + if (cell.DelegateModel.itemsIndex !== cell.pressIndex) { + ProcessesColumns.moveColumns(cell.pressIndex, cell.DelegateModel.itemsIndex); + } + } + } + } + + onCentroidChanged: { + if (!active) + return; + + const p = centroid.position; + cell.lastMoveX = cell.x; + + const cursorAbsX = cell.mapToItem(columnList.contentItem, p.x, 0).x; + + const centerAbsX = cursorAbsX - dragHandler.centerOffset; + + const movingRight = p.x > cell.pressPos.x; + const grabbedRight = dragHandler.centerOffset > 0; + + const useCursor = movingRight === grabbedRight; + const refX = useCursor ? cursorAbsX : centerAbsX; + + const myIndex = cell.DelegateModel.itemsIndex; + + const rightItem = columnList.itemAtIndex(myIndex + 1); + if (rightItem) { + const rightMid = rightItem.x + rightItem.width / 2; + if (refX > rightMid) { + columnsVisual.items.move(myIndex, myIndex + 1); + return; + } + } + + const leftItem = columnList.itemAtIndex(myIndex - 1); + if (leftItem) { + const leftMid = leftItem.x + leftItem.width / 2; + const pinnedAtZero = columnList.itemAtIndex(0)?.modelData?.id === ProcessesColumns.pinnedColumnId; + if (refX < leftMid && !(myIndex === 1 && pinnedAtZero)) { + columnsVisual.items.move(myIndex, myIndex - 1); + } + } + } + } + + Anim { + id: returnAnim + + properties: "x,y" + target: cellContent + to: 0 + } + + Item { + id: cellVisual + + x: cell.gapShrink * cell.gapX + y: 0 + width: cell.width - cell.gapShrink * cell.gapProg + height: cell.height + + Item { + id: cellContent + + width: cellVisual.width + height: cellVisual.height + + readonly property int topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + readonly property int bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + Elevation { + id: elevation + + anchors.fill: parent + level: 3 + opacity: 0 + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + } + + CustomRect { + id: cellBg + + anchors.fill: parent + color: cell.isActiveSort ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + + topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + StateLayer { + id: layer + + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + manualHoverOverride: cell.held + + onClicked: { + Processes.sortBy(ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0); + root.pList.positionViewAtBeginning(); + } + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + anchors.rightMargin: 12 + + CustomText { + id: title + anchors.left: parent.left + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.rightMargin: Tokens.spacing.extraSmall + anchors.verticalCenter: parent.verticalCenter + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurfaceVariant + elide: CustomText.ElideRight + text: ProcessesColumns.defFor(cell.modelData.id)?.title ?? cell.modelData.id + } + + IconButton { + id: removeBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + type: IconButton.Text + icon: "close" + font: Tokens.font.icon.small + inactiveOnColor: Colors.palette.m3onPrimary + visible: cell.modelData.id !== ProcessesColumns.pinnedColumnId + onClicked: ProcessesColumns.removeColumn(cell.DelegateModel.itemsIndex) + } + + MaterialIcon { + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.verticalCenter: parent.verticalCenter + + text: "keyboard_arrow_down" + opacity: cell.isActiveSort ? 1 : 0 + visible: opacity > 0 + rotation: !Processes.sortAscending && opacity > 0 ? 180 : 0 + color: Colors.palette.m3onPrimary + + Behavior on rotation { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + } + } + } + } + + component HiddenColumnItem: MenuItem { + required property var modelData + + text: modelData.title + } +} diff --git a/Modules/Dashboard/Processes/ProcessesList.qml b/Modules/Dashboard/Processes/ProcessesList.qml new file mode 100644 index 0000000..8f368db --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesList.qml @@ -0,0 +1,140 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +CustomListView { + id: root + + required property ProcessesHeader columnHeader + required property int rowHeight + property real rowSpacing: Tokens.spacing.small + + model: Processes.list + spacing: root.rowSpacing + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: root + } + + delegate: CustomRect { + id: processRow + + required property var modelData + required property int index + + implicitWidth: ListView.view.width + implicitHeight: rowLayout.implicitHeight + radius: Tokens.rounding.full + + StateLayer { + id: rowHover + } + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + spacing: Tokens.spacing.extraSmall / 2 + + CustomClippingRect { + id: iconClip + + Layout.fillHeight: true + Layout.margins: Tokens.padding.medium + implicitWidth: height + radius: Tokens.rounding.full + + IconImage { + id: appIcon + anchors.fill: parent + implicitSize: height + source: Quickshell.iconPath(processRow.modelData.iconName, "application-x-executable") + } + } + + Repeater { + id: cellRepeater + + model: root.columnHeader.columnsModel + + Item { + id: rowCell + + required property var modelData + required property int index + + implicitWidth: root.columnHeader.widthForColumn(modelData.id, modelData.width) - (index === 0 ? iconClip.implicitWidth + iconClip.Layout.margins * 2 + rowLayout.spacing : 0) + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + Layout.preferredWidth: implicitWidth + Layout.fillHeight: true + + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Tokens.padding.small + anchors.leftMargin: -(rowLayout.spacing) + + implicitWidth: 2 + color: Colors.palette.m3outlineVariant + visible: rowCell.index !== 0 + } + + Item { + id: layout + anchors.fill: parent + anchors.margins: Tokens.padding.small + anchors.leftMargin: rowCell.index !== 0 ? Tokens.padding.medium : 0 + anchors.rightMargin: Tokens.padding.medium + implicitHeight: mainText.implicitHeight + subText.implicitHeight + mainText.anchors.margins * 2 + subText.anchors.topMargin + + CustomText { + id: mainText + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + elide: CustomText.ElideRight + text: { + const value = processRow.modelData[rowCell.modelData.id]; + return value === undefined ? "" : ProcessesColumns.formatCell(rowCell.modelData.id, value); + } + + states: State { + name: "centerAnchored" + when: !subText.visible + + AnchorChanges { + target: mainText + anchors.top: undefined + anchors.verticalCenter: layout.verticalCenter + } + } + } + + CustomText { + id: subText + anchors.top: mainText.bottom + anchors.left: parent.left + anchors.right: parent.right + visible: rowCell.modelData.id === "command" + elide: CustomText.ElideRight + text: rowCell.modelData.id === "command" ? processRow.modelData["commandLine"] : "" + font: Tokens.font.label.small + color: Colors.palette.m3outline + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/Processes/ResourceUsage.qml b/Modules/Dashboard/Processes/ResourceUsage.qml new file mode 100644 index 0000000..70de2df --- /dev/null +++ b/Modules/Dashboard/Processes/ResourceUsage.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Internal +import ZShell.Services + +Singleton { + id: root + + readonly property int historyLength: 30 + readonly property CircularBuffer cpuBuffer: _cpuBuffer + readonly property CircularBuffer memoryBuffer: _memoryBuffer + + readonly property list cpuInfo: [ + { + label: qsTr("Usage"), + value: `${Math.round(Cpu.percentage * 100)}%` + }, + { + label: qsTr("Temp"), + value: `${Math.ceil(Cpu.temperature)}°${"C"}` + } + ] + + readonly property list memoryInfo: [ + { + label: qsTr("Used"), + value: memoryUsageFormat(Memory.used, Memory.total) + }, + { + label: qsTr("Cached"), + value: memoryTotalFormat(Memory.cached) + }, + { + label: qsTr("Writes"), + value: memoryTotalFormat(Memory.writeTotal) + }, + { + label: qsTr("Reads"), + value: memoryTotalFormat(Memory.readTotal) + } + ] + + function memoryTotalFormat(kib: int): string { + const fmt = UsageFmt.formatKib(kib); + return `${fmt.value.toFixed(1)} ${fmt.unit}`; + } + + function memoryUsageFormat(used: int, total: int): string { + const fmt = UsageFmt.formatKib(used, total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + + Connections { + function onUpdateTriggered(): void { + if (Cpu.percentage >= 0 && isFinite(Cpu.percentage)) + _cpuBuffer.push(Cpu.percentage); + } + + target: Cpu + } + + Connections { + function onUpdateTriggered(): void { + if (Memory.percentage >= 0 && isFinite(Memory.percentage)) + _memoryBuffer.push(Memory.percentage); + } + + target: Memory + } + + CircularBuffer { + id: _cpuBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } + + CircularBuffer { + id: _memoryBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } +} diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml new file mode 100644 index 0000000..afaedce --- /dev/null +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -0,0 +1,187 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + required property int radius + + implicitHeight: layout.implicitHeight + + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + + RowLayout { + id: layout + anchors.fill: parent + spacing: Tokens.spacing.medium + + UsageCard { + accent: Colors.palette.m3primary + buffer: ResourceUsage.cpuBuffer + icon: "memory" + label: qsTr("CPU") + values: ResourceUsage.cpuInfo + } + + UsageCard { + accent: Colors.palette.m3tertiary + buffer: ResourceUsage.memoryBuffer + icon: "memory_alt" + label: qsTr("Memory") + values: ResourceUsage.memoryInfo + } + } + + component UsageCard: CustomRect { + id: card + + property color accent + required property var buffer + property string icon + property string label + property list values + + color: Colors.tPalette.m3surfaceContainer + radius: root.radius + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 + + ColumnLayout { + id: mainLayout + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: 0 + + RowLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + color: card.accent + text: card.icon + } + + CustomText { + text: card.label + } + } + + Item { + id: graphWrapper + Layout.fillHeight: true + Layout.fillWidth: true + implicitHeight: infoColumn.implicitHeight + + Item { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: infoColumn.left + + SparklineItem { + id: sparkline + + anchors.fill: parent + historyLength: ResourceUsage.historyLength + line1: card.buffer + line1Color: card.accent + line1FillAlpha: 0.15 + maxValue: 1.0 + slideProgress: 1 + + Connections { + function onValuesChanged(): void { + slideAnim.restart(); + } + + target: card.buffer + } + + NumberAnimation { + id: slideAnim + + duration: Config.dashboard.resourceUpdateInterval + easing.type: Easing.Linear + from: 0 + property: "slideProgress" + target: sparkline + to: 1 + } + } + + // "Collecting data" placeholder + CustomText { + anchors.centerIn: parent + color: Colors.palette.m3outline + text: qsTr("Collecting data...") + visible: card.buffer.count < 2 + } + } + + ColumnLayout { + id: infoColumn + anchors.right: parent.right + anchors.bottom: parent.bottom + width: { + const prefWidth = graphWrapper.width / 3; + const minWidth = graphWrapper.width / 4; + if (prefWidth < implicitWidth) + return implicitWidth; + else if (prefWidth >= implicitWidth * 2) + return minWidth; + else + return prefWidth; + } + + Repeater { + model: card.values + + Item { + id: moreInfo + + required property var modelData + + Layout.leftMargin: Tokens.spacing.medium + Layout.fillWidth: true + implicitWidth: label.implicitWidth + value.implicitWidth + label.anchors.margins * 2 + implicitHeight: label.implicitHeight + + CustomText { + id: label + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + color: Colors.palette.m3onSurfaceVariant + text: moreInfo.modelData.label + } + + CustomText { + id: value + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + color: card.accent + text: moreInfo.modelData.value + } + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml new file mode 100644 index 0000000..646928c --- /dev/null +++ b/Modules/Dashboard/ProcessesTab.qml @@ -0,0 +1,54 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Services +import qs.Modules.Dashboard.Processes +import qs.Components +import qs.Services + +Item { + id: root + + required property int radius + + implicitWidth: Tokens.sizes.dashboard.processesWidth + implicitHeight: Tokens.sizes.dashboard.processesHeight + + ServiceRef { + service: Processes + } + + ProcessesHeader { + id: header + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + headerHeight: Tokens.sizes.dashboard.processesHeaderHeight + pList: processList + } + + ProcessesList { + id: processList + + anchors.top: header.bottom + anchors.bottom: sysInfo.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small + + columnHeader: header + rowHeight: Tokens.sizes.dashboard.processesRowHeight + } + + SystemInfo { + id: sysInfo + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + radius: root.radius + } +} diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index 8a0bccf..56234fa 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -1,23 +1,24 @@ pragma ComponentBehavior: Bound -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Modules -import Quickshell -import Quickshell.Widgets import QtQuick -import QtQuick.Controls +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 real nonAnimWidth - required property PersistentProperties state + required property ScreenState screenState + required property var tabs - implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + readonly property alias count: bar.count + readonly property int tabIndicatorSpacing: Tokens.spacing.small + + implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight TabBar { id: bar @@ -25,85 +26,99 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: root.tabIndicatorSpacing + + currentIndex: root.screenState.dashboardTab + onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex + + implicitHeight: contentHeight background: null - currentIndex: root.state.currentTab + contentItem: RowLayout { + spacing: 0 - onCurrentIndexChanged: root.state.currentTab = currentIndex - - Tab { - iconName: "dashboard" - text: qsTr("Dashboard") + Repeater { + model: bar.contentModel + } } - Tab { - iconName: "queue_music" - text: qsTr("Media") - } + Repeater { + model: ScriptModel { + values: root.tabs + } - Tab { - iconName: "speed" - text: qsTr("Performance") - } + delegate: Tab { + required property var modelData - Tab { - iconName: "cloud" - text: qsTr("Weather") + iconName: modelData.iconName + text: modelData.text + } } - - // Tab { - // iconName: "workspaces" - // text: qsTr("Workspaces") - // } } Item { id: indicator anchors.top: bar.bottom - clip: true - implicitHeight: 40 - implicitWidth: bar.currentItem.implicitWidth + anchors.topMargin: root.tabIndicatorSpacing + + implicitWidth: { + const tab = bar.currentItem; + if (tab) + return tab.implicitWidth; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width; + } + implicitHeight: 3 + 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; + const tabWidth = tab?.implicitWidth ?? width; + return width * bar.currentIndex + (width - tabWidth) / 2; + } + + clip: true + + CustomRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: Colors.palette.m3primary + radius: Tokens.rounding.full + } + + Behavior on x { + Anim {} } 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 + Anim {} } } CustomRect { id: separator + anchors.top: indicator.bottom anchors.left: parent.left anchors.right: parent.right - anchors.top: indicator.bottom - color: Colors.palette.m3outlineVariant + implicitHeight: 1 + color: Colors.palette.m3outlineVariant } component Tab: TabButton { id: tab - readonly property bool current: TabBar.tabBar.currentItem === this required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + Layout.fillWidth: true + Layout.preferredWidth: 1 // Uniform width across all tabs + implicitWidth: implicitContentWidth + implicitHeight: implicitContentHeight background: null contentItem: CustomMouseArea { @@ -111,117 +126,47 @@ Item { function onWheel(event: WheelEvent): void { if (event.angleDelta.y < 0) - root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, bar.count - 1); else if (event.angleDelta.y > 0) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); } - cursorShape: Qt.PointingHandCursor - implicitHeight: icon.height + label.height implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height - onPressed: event => { - root.state.currentTab = tab.TabBar.index; + hoverEnabled: true + cursorShape: Qt.PointingHandCursor - const stateY = stateWrapper.y; - rippleAnim.x = event.x; - rippleAnim.y = event.y - stateY; + onPressed: root.screenState.dashboardTab = tab.TabBar.index - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); - - rippleAnim.restart(); - } - - SequentialAnimation { - id: rippleAnim - - property real radius - property real x - property real y - - PropertyAction { - property: "x" - target: ripple - value: rippleAnim.x - } - - PropertyAction { - property: "y" - target: ripple - value: rippleAnim.y - } - - PropertyAction { - property: "opacity" - target: ripple - value: 0.08 - } - - Anim { - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - easing.type: Easing.BezierSpline - property: "opacity" - target: ripple - to: 0 - } - } - - ClippingRectangle { - id: stateWrapper + StateLayer { + id: stateLayer + anchors.fill: undefined anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - color: "transparent" - implicitHeight: parent.height + 8 * 2 - radius: Tokens.rounding.small + implicitHeight: parent.height + root.tabIndicatorSpacing * 2 - CustomRect { - id: stateLayer - - anchors.fill: parent - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomRect { - id: ripple - - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: 0 - radius: Tokens.rounding.full - - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 - } - } + radius: Tokens.rounding.medium + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface + onClicked: root.screenState.dashboardTab = tab.TabBar.index } MaterialIcon { id: icon - anchors.bottom: label.top anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: 18 - text: tab.iconName + fontStyle: Tokens.font.icon.medium Behavior on fill { Anim { + type: Anim.DefaultEffects } } } @@ -229,10 +174,11 @@ Item { CustomText { id: label - anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + anchors.bottom: parent.bottom + text: tab.text + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant } } } diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml new file mode 100644 index 0000000..f0ac43d --- /dev/null +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +RowLayout { + id: root + + spacing: Tokens.spacing.largeIncreased + + readonly property list forecastToday: Weather.hourlyForecast.slice(0, 25) + + property var now: new Date() + property bool useTwelveHourClock: Config.services.useTwelveHourClock + property bool curveSmoothing: true + property real smoothing: 2 + property real markerGap: Math.min(Math.max(Tokens.spacing.medium / 2, 0), 6) + + readonly property real markerX: { + const d = root.now; + return d.getHours() + d.getMinutes() / 60; + } + + function formatHour(h) { + if (root.useTwelveHourClock) { + const hr = h % 12 === 0 ? 12 : h % 12; + return hr + (h < 12 ? " AM" : " PM"); + } + return h; + } + + Timer { + interval: 60000 + repeat: true + running: true + + onTriggered: root.now = new Date() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "device_thermostat" + fontStyle: Tokens.font.icon.medium + color: Colors.palette.m3secondary + } + + CustomText { + text: qsTr("Temperature") + } + } + + Item { + id: temperature + + implicitHeight: 150 + Layout.fillWidth: true + + LineGraph { + id: tempGraph + + anchors.fill: parent + values: root.forecastToday.map(e => e.tempC) + verticalLineStep: 3 + xSteps: 24 + horizontalLineStep: 10 + minHorizontalLines: 4 + yPaddingTop: horizontalLineStep + yPaddingBottom: horizontalLineStep + xLabel: i => root.formatHour(i) + yLabel: v => Math.round(v) + "°" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3secondary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3secondary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "water_drop" + fontStyle: Tokens.font.icon.medium + color: Colors.palette.m3tertiary + } + + CustomText { + text: qsTr("Chance of precipitation") + } + } + + Item { + id: precipitation + + Layout.fillWidth: true + implicitHeight: 150 + + LineGraph { + id: graph + + anchors.fill: parent + + values: root.forecastToday.map(e => e?.precipChance ?? null) + xSteps: 24 + yMin: 0 + yMax: 100 + yPaddingTop: 20 + verticalLineStep: 3 + lastXLine: false + horizontalLineStep: 20 + xLabel: i => root.formatHour(i) + yLabel: v => v + "%" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3tertiary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3tertiary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } +} diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml new file mode 100644 index 0000000..7069abb --- /dev/null +++ b/Modules/Dashboard/WeatherTab.qml @@ -0,0 +1,263 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Dashboard.Weather +import qs.Services +import qs.Helpers + +Item { + id: root + + required property int radius + readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight + Component.onCompleted: Weather.reload() + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.medium + + RowLayout { + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large + Layout.fillWidth: true + + Column { + spacing: Tokens.spacing.extraSmall + + CustomText { + text: Weather.city || qsTr("Loading...") + font: Tokens.font.body.builders.large.size(24).weight(Font.DemiBold).build() + color: Colors.palette.m3onSurface + } + + CustomText { + text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") + font: Tokens.font.body.small + color: Colors.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + } + + Row { + spacing: Tokens.spacing.largeIncreased + + WeatherStat { + icon: "wb_twilight" + label: qsTr("Sunrise") + value: Weather.sunrise + colour: Colors.palette.m3tertiary + } + + WeatherStat { + icon: "bedtime" + label: qsTr("Sunset") + value: Weather.sunset + colour: Colors.palette.m3tertiary + } + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: bigInfoRow.implicitHeight + bigInfoRow.anchors.margins * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + Graphs { + id: bigInfoRow + anchors.fill: parent + anchors.margins: Tokens.padding.medium + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + DetailCard { + icon: Weather.icon + label: qsTr("Temperature") + value: Weather.temp + colour: Colors.palette.m3primary + } + DetailCard { + icon: "thermostat" + label: qsTr("Feels like", "apparent temperature") + value: Weather.feelsLike + colour: Colors.palette.m3primary + } + DetailCard { + icon: "water_drop" + label: qsTr("Humidity") + value: Strings.percent(Weather.humidity) + colour: Colors.palette.m3secondary + } + DetailCard { + icon: "air" + label: qsTr("Wind") + value: Weather.windSpeed ? qsTr("%1 km/h").arg(Weather.windSpeed) : "--" + colour: Colors.palette.m3tertiary + } + } + + CustomText { + Layout.topMargin: Tokens.spacing.medium + Layout.leftMargin: Tokens.padding.medium + visible: forecastRepeater.count > 0 + text: qsTr("7-day forecast") + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() + color: Colors.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + Repeater { + id: forecastRepeater + + model: Weather.forecast + + CustomRect { + id: forecastItem + + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 + + radius: root.radius + color: Colors.tPalette.m3surfaceContainer + + ColumnLayout { + id: forecastItemColumn + + anchors.centerIn: parent + spacing: Tokens.spacing.small + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() + color: Colors.palette.m3primary + } + + CustomText { + Layout.topMargin: -Tokens.spacing.extraSmall + Layout.alignment: Qt.AlignHCenter + text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") + font: Tokens.font.body.small + opacity: 0.7 + color: Colors.palette.m3onSurfaceVariant + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.modelData.icon + fontStyle: Tokens.font.icon.extraLarge + color: Colors.palette.m3secondary + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const min = Weather.formatTemp(forecastItem.modelData.minTempC, true); + const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); + return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); + } + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() + color: Colors.palette.m3tertiary + } + } + } + } + } + } + + component DetailCard: CustomRect { + id: detailRoot + + property string icon + property string label + property string value + property color colour + + Layout.fillWidth: true + Layout.preferredHeight: 60 + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Tokens.spacing.medium + + MaterialIcon { + text: detailRoot.icon + color: detailRoot.colour + fontStyle: Tokens.font.icon.large + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + text: detailRoot.label + font: Tokens.font.body.small + opacity: 0.7 + horizontalAlignment: Text.AlignLeft + } + CustomText { + text: detailRoot.value + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() + horizontalAlignment: Text.AlignLeft + } + } + } + } + + component WeatherStat: RowLayout { + id: weatherStat + + property string icon + property string label + property string value + property color colour + + spacing: Tokens.spacing.small + + MaterialIcon { + text: weatherStat.icon + fontStyle: Tokens.font.icon.extraLarge + color: weatherStat.colour + } + + Column { + Layout.alignment: Qt.AlignVCenter + CustomText { + text: weatherStat.label + font: Tokens.font.body.small + color: Colors.palette.m3onSurfaceVariant + } + CustomText { + text: weatherStat.value + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() + color: Colors.palette.m3onSurface + } + } + } +} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index c78a97f..b2e0f4b 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -8,24 +8,13 @@ import ZShell.Config Item { id: root - readonly property PersistentProperties dashState: PersistentProperties { - property date currentDate: new Date() - property int currentTab - - reloadableId: "dashboardState" - } readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale - required property string position - readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.dashboard && Config.dashboard.enabled + required property ScreenState screenState - anchors.bottom: position === "bottom" ? parent.bottom : undefined - anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 - anchors.left: position === "left" ? parent.left : undefined - anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 - anchors.top: position === "top" ? parent.top : undefined - anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.top: parent.top + anchors.topMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale @@ -35,15 +24,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: root.position === "top" ? parent.bottom : undefined - anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined - anchors.right: root.position !== "left" ? undefined : parent.right - anchors.top: root.position === "bottom" ? parent.top : undefined - anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter sourceComponent: Content { - dashState: root.dashState - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 88e9bc0..98050f3 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -8,177 +8,198 @@ import ZShell.Config import qs.Paths import qs.Helpers import qs.Services +import qs.Services Item { - id: root + id: root - anchors.fill: parent - z: 998 - visible: false + anchors.fill: parent + z: 998 + visible: false - property real menuX: 0 - property real menuY: 0 + property real menuX: 0 + property real menuY: 0 - MouseArea { - anchors.fill: parent - onClicked: root.close() - } + MouseArea { + anchors.fill: parent + onClicked: root.close() + } - CustomClippingRect { - id: popupBackground - readonly property real padding: 4 + CustomClippingRect { + id: popupBackground + readonly property real padding: 4 - x: root.menuX - y: root.menuY + x: root.menuX + y: root.menuY - color: Colors.tPalette.m3surface - radius: Tokens.rounding.medium + color: Colors.tPalette.m3surface + radius: Tokens.rounding.medium - implicitWidth: menuLayout.implicitWidth + padding * 2 - implicitHeight: menuLayout.implicitHeight + padding * 2 + implicitWidth: menuLayout.implicitWidth + padding * 2 + implicitHeight: menuLayout.implicitHeight + padding * 2 - Behavior on opacity { Anim {} } - opacity: root.visible ? 1 : 0 + Behavior on opacity { + Anim {} + } + opacity: root.visible ? 1 : 0 - ColumnLayout { - id: menuLayout - anchors.centerIn: parent - spacing: 0 + ColumnLayout { + id: menuLayout + anchors.centerIn: parent + spacing: 0 - CustomRect { - Layout.preferredWidth: 200 - radius: popupBackground.radius - popupBackground.padding - implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.preferredWidth: 200 + radius: popupBackground.radius - popupBackground.padding + implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: openTerminalRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: openTerminalRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "terminal"; font.pointSize: 20 } - CustomText { text: "Open terminal"; Layout.fillWidth: true } - } + MaterialIcon { + text: "terminal" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Open terminal" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]) - root.close() - } - } - } + onClicked: { + Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]); + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: settingsRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: settingsRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "settings"; font.pointSize: 20 } - CustomText { text: "ZShell settings"; Layout.fillWidth: true } - } + MaterialIcon { + text: "settings" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "ZShell settings" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = true; - root.close() - } - } - } + onClicked: { + const state = ShellState.forActive(); + state.settings = true; + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: logoutRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: logoutRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "logout"; font.pointSize: 20 } - CustomText { text: "Logout"; Layout.fillWidth: true } - } + MaterialIcon { + text: "logout" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Logout" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Hyprland.dispatch("global quickshell:sessionOpen") - root.close() - } - } - } + onClicked: { + Hyprland.dispatch("global quickshell:sessionOpen"); + root.close(); + } + } + } - // CustomRect { - // Layout.fillWidth: true - // implicitHeight: 1 - // color: Colors.palette.m3outlineVariant - // Layout.topMargin: 4 - // Layout.bottomMargin: 4 - // } - // - // CustomRect { - // Layout.fillWidth: true - // radius: popupBackground.radius - popupBackground.padding - // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 - // - // RowLayout { - // id: desktopIconsRow - // spacing: 8 - // anchors.fill: parent - // anchors.leftMargin: Tokens.padding.small - // - // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } - // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } - // } - // - // StateLayer { - // anchors.fill: parent - // - // onClicked: { - // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons - // root.close() - // } - // } - // } - } - } + // CustomRect { + // Layout.fillWidth: true + // implicitHeight: 1 + // color: Colors.palette.m3outlineVariant + // Layout.topMargin: 4 + // Layout.bottomMargin: 4 + // } + // + // CustomRect { + // Layout.fillWidth: true + // radius: popupBackground.radius - popupBackground.padding + // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 + // + // RowLayout { + // id: desktopIconsRow + // spacing: 8 + // anchors.fill: parent + // anchors.leftMargin: Tokens.padding.small + // + // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } + // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } + // } + // + // StateLayer { + // anchors.fill: parent + // + // onClicked: { + // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons + // root.close() + // } + // } + // } + } + } - function openAt(mouseX, mouseY, parentW, parentH) { - menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)) - menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)) - visible = true - } + function openAt(mouseX, mouseY, parentW, parentH) { + menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)); + menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)); + visible = true; + } - function close() { - visible = false - } + function close() { + visible = false; + } } diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml index 4b9b570..349d0cb 100644 --- a/Modules/DesktopIcons/DesktopIconContextMenu.qml +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -54,8 +54,7 @@ Item { y: contextMenu.menuY Behavior on opacity { - Anim { - } + Anim {} } ColumnLayout { @@ -77,7 +76,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "open_in_new" } @@ -120,7 +119,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: contextMenu.targetIsDir ? "terminal" : "apps" } @@ -165,7 +164,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "content_copy" } @@ -199,7 +198,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "edit" } @@ -241,7 +240,7 @@ Item { MaterialIcon { color: deleteButton.hovered ? Colors.palette.m3onError : Colors.palette.m3error - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "delete" } diff --git a/Modules/Dock/Content.qml b/Modules/Dock/Content.qml index 5fda10a..1cb257c 100644 --- a/Modules/Dock/Content.qml +++ b/Modules/Dock/Content.qml @@ -27,7 +27,7 @@ Item { property var pendingCommitIds: [] readonly property int rounding: Tokens.rounding.largeIncreased required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState property var visualIds: [] function beginVisualDrag(appId, modelData, item) { @@ -198,7 +198,7 @@ Item { anchors.centerIn: parent appListRoot: root appToplevel: modelData - visibilities: root.visibilities + screenState: root.screenState visible: root.draggedAppId !== slot.appId } @@ -314,7 +314,7 @@ Item { appListRoot: root appToplevel: root.draggedModelData enabled: false - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index ab9164b..d6ca2f0 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -19,7 +19,7 @@ CustomRect { property real iconSize: implicitHeight - 20 readonly property bool isSeparator: appToplevel?.appId === "__dock_separator__" property int lastFocused: -1 - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: Config.dock.height implicitWidth: isSeparator ? 1 : implicitHeight @@ -62,12 +62,12 @@ CustomRect { onClicked: { if (appToplevel?.toplevels.length === 0) { root.desktopEntry?.execute(); - root.visibilities.dock = false; + root.screenState.dock = false; return; } lastFocused = (lastFocused + 1) % appToplevel?.toplevels.length; appToplevel?.toplevels[lastFocused].activate(); - root.visibilities.dock = false; + root.screenState.dock = false; } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index 6e0df2f..df41f2e 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,8 +12,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.dock && Config.dock.enable + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -39,7 +39,7 @@ Item { sourceComponent: Content { panels: root.panels screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 5fe6eea..a580337 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -12,7 +12,7 @@ Item { readonly property var colors1: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4"] readonly property var colors2: ["#3b82f6", "#a855f7", "#ec4899", "#ffffff", "#000000"] required property var drawing - required property var visibilities + required property ScreenState screenState required property Wrapper wrapper function syncFromPenColor() { @@ -20,17 +20,17 @@ Item { return; if (!saturationSlider.pressed) - saturationSlider.value = drawing.drawingState.penColor.hsvSaturation; + saturationSlider.value = drawing.screenState.penColor.hsvSaturation; if (!brightnessSlider.pressed) - brightnessSlider.value = drawing.drawingState.penColor.hsvValue; + brightnessSlider.value = drawing.screenState.penColor.hsvValue; } function updatePenColorFromHsv() { if (!drawing) return; - drawing.drawingState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.screenState.penColor.a); } implicitHeight: column.height + Tokens.padding.small * 2 @@ -43,7 +43,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Column { @@ -133,9 +133,9 @@ Item { multiplier: 1 orientation: Qt.Horizontal to: 45 - value: root.drawing.drawingState.penWidth + value: root.drawing.screenState.penWidth - onMoved: root.drawing.drawingState.penWidth = value + onMoved: root.drawing.screenState.penWidth = value } ButtonRow { @@ -145,7 +145,6 @@ Item { IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "close" inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError @@ -153,12 +152,11 @@ Item { shapeMorph: true text: "Exit" - onClicked: root.visibilities.isDrawing = false + onClicked: root.screenState.isDrawing = false } IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "ink_eraser" inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: Colors.palette.m3onSurfaceVariant @@ -193,7 +191,7 @@ Item { required property ButtonRow row fillWidth: false - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize @@ -203,6 +201,6 @@ Item { shapeMorph: true shapeMorphExpansion: pressed ? 12 : 0 - onClicked: root.drawing.drawingState.penColor = modelData + onClicked: root.drawing.screenState.penColor = modelData } } diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index 4389b06..654950e 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -15,8 +15,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 property bool pinned: false required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.isDrawing - required property var visibilities + readonly property bool shouldBeActive: screenState.isDrawing + required property ScreenState screenState function collapse(): void { if (pinned) @@ -74,11 +74,10 @@ Item { opacity: root.expanded ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: MaterialIcon { - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium text: "arrow_forward_ios" } } @@ -93,12 +92,11 @@ Item { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: Content { drawing: root.drawing - visibilities: root.visibilities + screenState: root.screenState wrapper: root } } diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..5ff38f6 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -15,7 +15,7 @@ CustomListView { property string displayText readonly property string requestedState: stateForText(search.text) required property SearchBar search - required property PersistentProperties visibilities + required property ScreenState screenState function resultsForText(text: string): var { switch (stateForText(text)) { @@ -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; @@ -44,7 +46,7 @@ CustomListView { } function syncDisplayText(): void { - if (visibilities.launcher && requestedState === displayState) + if (screenState.launcher && requestedState === displayState) displayText = search.text; } @@ -55,7 +57,7 @@ CustomListView { preferredHighlightBegin: 0 preferredHighlightEnd: height spacing: Tokens.spacing.small - state: visibilities.launcher ? requestedState : displayState + state: screenState.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -163,6 +165,13 @@ CustomListView { PropertyChanges { root.delegate: variantItem } + }, + State { + name: "files" + + PropertyChanges { + root.delegate: filesItem + } } ] transitions: Transition { @@ -236,7 +245,7 @@ CustomListView { id: appItem AppItem { - visibilities: root.visibilities + screenState: root.screenState } } @@ -264,6 +273,14 @@ CustomListView { } } + Component { + id: filesItem + + FilesItem { + list: root + } + } + Connections { function onTextChanged() { root.syncDisplayText(); @@ -277,6 +294,6 @@ CustomListView { root.syncDisplayText(); } - target: root.visibilities + target: root.screenState } } diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index e1aeb80..3db4299 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -13,14 +13,14 @@ CarouselView { readonly property var screen: QsWindow.window?.screen required property SearchBar search readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) - required property var visibilities + required property ScreenState screenState currentIndex: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); return idx >= 0 ? idx : 0; } delegateProperties: ({ - visibilities: root.visibilities + screenState: root.screenState }) focalWidth: Config.launcher.sizes.wallpaperWidth glideDuration: 250 @@ -32,7 +32,7 @@ CarouselView { return 0; const barMargins = Config.bar.border * 2; let margins = Math.round(screen.width / 6); - if (visibilities.utilities || visibilities.sidebar) + if (screenState.utilities || screenState.sidebar) margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..4db1508 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -15,7 +15,7 @@ Item { readonly property int padding: Tokens.padding.largeIncreased / 4 required property var panels readonly property int rounding: Tokens.rounding.largeIncreased - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.height + listWrapper.height + padding * 2 implicitWidth: listWrapper.width + padding * 2 @@ -38,7 +38,7 @@ Item { panels: root.panels rounding: root.rounding search: search - visibilities: root.visibilities + screenState: root.screenState } } @@ -55,7 +55,7 @@ Item { Component.onCompleted: forceActiveFocus() Keys.onDownPressed: list.currentList?.decrementCurrentIndex() - Keys.onEscapePressed: root.visibilities.launcher = false + Keys.onEscapePressed: root.screenState.launcher = false Keys.onPressed: event => { if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_J) { @@ -81,26 +81,28 @@ Item { if (Colors.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) Wallpapers.previewColorLock = true; Wallpapers.setWallpaper(currentItem.modelData.path); - root.visibilities.launcher = false; + root.screenState.launcher = false; } 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 { Apps.launch(currentItem.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } } Connections { function onLauncherChanged(): void { - if (!root.visibilities.launcher) + if (!root.screenState.launcher) search.text = ""; } - target: root.visibilities + target: root.screenState } } } diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 0081914..54093de 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -20,7 +20,7 @@ Item { required property int rounding required property SearchBar search readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `) - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter @@ -28,7 +28,7 @@ Item { state: showWallpapers ? "wallpapers" : "apps" Behavior on implicitHeight { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -36,7 +36,7 @@ Item { } } Behavior on implicitWidth { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -53,8 +53,7 @@ Item { to: 0 } - PropertyAction { - } + PropertyAction {} Anim { duration: Tokens.anim.durations.small @@ -99,7 +98,7 @@ Item { sourceComponent: AppList { search: root.search - visibilities: root.visibilities + screenState: root.screenState } } @@ -115,7 +114,7 @@ Item { sourceComponent: CarouselView { panels: root.panels search: root.search - visibilities: root.visibilities + screenState: root.screenState } } @@ -130,18 +129,16 @@ Item { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } MaterialIcon { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search" } @@ -150,14 +147,13 @@ Item { CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger - font.weight: 500 + font: Tokens.font.body.builders.large.weight(Font.Medium).build() text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results") } CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.state === "wallpapers" && Wallpapers.list.length === 0 ? qsTr("Try putting some wallpapers in %1").arg(Paths.shortenHome(Paths.wallsdir)) : qsTr("Try searching for something else") } } diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..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); @@ -33,7 +33,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -47,7 +47,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -57,7 +56,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.desc ?? "" width: root.width - icon.width - Tokens.rounding.medium * 2 } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..c93a8e5 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -12,18 +12,18 @@ Item { id: root required property DesktopEntry modelData - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { Apps.launch(root.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } @@ -51,7 +51,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -61,7 +60,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" width: root.width - icon.width - Tokens.rounding.medium - Tokens.padding.medium * 2 } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..c52d71a 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -16,7 +16,7 @@ Item { function onClicked(): void { Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } anchors.left: parent?.left @@ -24,7 +24,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.onClicked(); @@ -40,7 +40,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: "function" } @@ -81,7 +81,7 @@ Item { onClicked: { Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } } @@ -92,13 +92,12 @@ Item { anchors.rightMargin: Tokens.spacing.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium opacity: stateLayer.containsMouse ? 1 : 0 text: qsTr("Open in calculator") Behavior on opacity { - Anim { - } + Anim {} } } @@ -109,7 +108,7 @@ Item { anchors.rightMargin: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: "open_in_new" } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..e84ca5a --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules.Launcher.Services +import qs.Services + +Item { + id: root + + required property var list + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: Config.launcher.sizes.itemHeight + + function onClicked(): void { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.launch(["xdg-open", root.modelData.path]); + } + + StateLayer { + id: sLayer + + radius: Tokens.rounding.medium + manualHoverOverride: openLocation.hovered + + onClicked: { + root.onClicked(); + } + } + + IconButton { + id: openLocation + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + icon: "open_in_new" + isRound: true + + scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4 + opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.showInFolder(root.modelData.path); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + + IconImage { + id: icon + + anchors.verticalCenter: parent.verticalCenter + implicitSize: Tokens.font.icon.extraLarge.pointSize + source: Quickshell.iconPath(root.modelData?.icon) ?? "" + visible: !root.modelData?.isImage + } + + Loader { + active: opacity > 0 + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: LoadingIndicator { + color: Colors.palette.m3primaryContainer + implicitSize: Tokens.font.icon.extraLarge.pointSize + } + } + + FadeImage { + id: image + + property int implicitSize: Tokens.font.icon.extraLarge.pointSize + + width: implicitSize + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + source: root.modelData?.isImage ? root.modelData?.path ?? "" : "" + visible: root.modelData?.isImage + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.verticalCenter: icon.verticalCenter + implicitHeight: name.implicitHeight + desc.implicitHeight + implicitWidth: parent.width - icon.width + + CustomText { + id: name + + font: Tokens.font.body.medium + text: root.modelData?.name ?? "" + elide: Text.ElideRight + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + + CustomText { + id: desc + + anchors.top: name.bottom + color: Colors.palette.m3outline + elide: Text.ElideRight + font: Tokens.font.body.small + text: root.modelData?.path ?? "" + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + } + } +} diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..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); @@ -32,7 +32,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -44,7 +44,6 @@ Item { width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Tokens.spacing.medium : 0) CustomText { - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -53,7 +52,7 @@ Item { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.description ?? "" } } @@ -67,7 +66,7 @@ Item { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "check" } } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 806f89e..293c48c 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -12,7 +12,7 @@ Item { required property var modelData // FileSystemEntry-like {path, relativePath} required property real tileHeight required property real tileWidth - required property PersistentProperties visibilities + required property ScreenState screenState signal requestActivate @@ -32,8 +32,7 @@ Item { MaterialIcon { anchors.centerIn: parent color: Colors.tPalette.m3outline - font.pointSize: Tokens.font.size.extraLarge * 2 - font.weight: 600 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() text: "image" } diff --git a/Modules/Launcher/Services/Actions.qml b/Modules/Launcher/Services/Actions.qml index 80df3fc..135dbc3 100644 --- a/Modules/Launcher/Services/Actions.qml +++ b/Modules/Launcher/Services/Actions.qml @@ -42,10 +42,10 @@ Searcher { if (command[0] === "autocomplete" && command.length > 1) { list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; } else if (command[0] === "setMode" && command.length > 1) { - list.visibilities.launcher = false; + list.screenState.launcher = false; Colors.setMode(command[1]); } else { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(command); } } 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/Launcher/Services/SchemeVariants.qml b/Modules/Launcher/Services/SchemeVariants.qml index 08a754f..5488706 100644 --- a/Modules/Launcher/Services/SchemeVariants.qml +++ b/Modules/Launcher/Services/SchemeVariants.qml @@ -79,7 +79,7 @@ Searcher { required property string variant function onClicked(list: AppList): void { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--scheme", variant]); Config.colors.schemeType = variant; } diff --git a/Modules/Launcher/Wrapper.qml b/Modules/Launcher/Wrapper.qml index e5ae86c..1fa0c24 100644 --- a/Modules/Launcher/Wrapper.qml +++ b/Modules/Launcher/Wrapper.qml @@ -12,7 +12,7 @@ Item { property int contentHeight readonly property real maxHeight: { let max = screen.height - Tokens.spacing.largeIncreased * 2; - if (visibilities.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) + if (screenState.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) max -= panels.resources.nonAnimHeight; if (panels.popouts.hasCurrent) if (panels.popouts.current?.x + panels.popouts.current?.width > root.x && panels.popouts.current?.x < root.x + root.width) @@ -22,8 +22,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.launcher - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.launcher + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -57,7 +57,7 @@ Item { sourceComponent: Content { maxHeight: root.maxHeight panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml index 06ad24b..481d757 100644 --- a/Modules/Lock/Center.qml +++ b/Modules/Lock/Center.qml @@ -28,27 +28,21 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.hourStr } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3primary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: ":" } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.minuteStr } } @@ -57,9 +51,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.topMargin: -Tokens.padding.large * 2 color: Colors.palette.m3tertiary - font.bold: true - font.family: Config.appearance.font.family.mono - font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) + font: Tokens.font.title.builders.medium.weight(Font.DemiBold).build() text: Time.format("dddd, d MMMM yyyy") } @@ -74,7 +66,7 @@ ColumnLayout { MaterialIcon { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Math.floor(root.centerWidth / 4) + fontStyle: Tokens.font.icon.size(root.centerWidth / 4).build() text: "person" } @@ -144,8 +136,7 @@ ColumnLayout { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -159,6 +150,7 @@ ColumnLayout { id: inputField pam: root.lock.pam + centerScale: root.centerScale } CustomRect { @@ -180,7 +172,6 @@ ColumnLayout { anchors.centerIn: parent color: root.lock.pam.buffer ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.weight: 500 text: "arrow_forward" } } @@ -193,8 +184,7 @@ ColumnLayout { implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) Behavior on implicitHeight { - Anim { - } + Anim {} } CustomText { @@ -226,7 +216,7 @@ ColumnLayout { anchors.right: parent.right animateProp: "opacity" color: Colors.palette.m3onSurfaceVariant - font.family: Config.appearance.font.family.mono + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter lineHeight: 1.2 opacity: shouldBeVisible && !message.msg ? 1 : 0 @@ -234,12 +224,10 @@ ColumnLayout { wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } onMsgChanged: { @@ -295,8 +283,7 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right color: Colors.palette.m3error - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter opacity: 0 scale: 0.7 diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index 67511c3..c7ac527 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -14,6 +14,7 @@ Item { property string buffer required property Pam pam + required property real centerScale readonly property alias placeholder: placeholder Layout.fillHeight: true @@ -41,8 +42,7 @@ Item { anchors.centerIn: parent animate: true color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.builders.medium.scale(root.centerScale).width(110).build() opacity: root.buffer ? 0 : 1 text: { if (root.pam.passwd.active) @@ -53,8 +53,7 @@ Item { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -71,7 +70,7 @@ Item { anchors.centerIn: parent anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 - implicitHeight: Tokens.font.size.normal + implicitHeight: Tokens.font.body.medium.pointSize implicitWidth: fullWidth interactive: false orientation: Qt.Horizontal @@ -84,12 +83,11 @@ Item { implicitHeight: charList.implicitHeight implicitWidth: implicitHeight opacity: 0 - radius: Tokens.rounding.small / 2 + radius: Tokens.rounding.full scale: 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { Anim { @@ -137,8 +135,7 @@ Item { Behavior on implicitWidth { id: imWidthBehavior - Anim { - } + Anim {} } model: ScriptModel { values: root.buffer.split("") diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index 6086d68..540e704 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -236,8 +236,7 @@ WlSessionLockSurface { id: lockIcon anchors.centerIn: parent - font.bold: true - font.pointSize: Tokens.font.size.extraLarge * 4 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(4).weight(Font.Bold).build() text: "lock" } diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index d292965..326f5ba 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -57,7 +57,7 @@ CustomClippingRect { animate: true color: Colors.palette.m3primary elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.large horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackTitle ?? qsTr("Nothing playing")) || qsTr("Unknown track") } @@ -67,7 +67,6 @@ CustomClippingRect { animate: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackArtist ?? qsTr("Try playing some music!")) || qsTr("Unknown artist") } diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 3b468a9..c441ff9 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -66,9 +66,7 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.large - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -94,8 +92,7 @@ ColumnLayout { to: 1 } } - delegate: NotifGroup { - } + delegate: NotifGroup {} displaced: Transition { Anim { properties: "opacity,scale" diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 09bf5d5..0d6d6ad 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -80,7 +80,7 @@ CustomRect { MaterialIcon { color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) } } @@ -133,14 +133,14 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData } CustomText { animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs[0]?.timeStr ?? "" } @@ -153,12 +153,10 @@ CustomRect { radius: Tokens.rounding.full Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } StateLayer { @@ -181,7 +179,7 @@ CustomRect { Layout.leftMargin: Tokens.padding.extraSmall / 2 animate: true color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs.length } @@ -261,8 +259,7 @@ CustomRect { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { Repeater { @@ -270,8 +267,7 @@ CustomRect { values: root.notifs.slice(Config.notifs.groupPreviewNum) } - NotifLine { - } + NotifLine {} } } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index e06fff7..7ce6c90 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -70,14 +70,13 @@ CustomRect { implicitSize: width Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { anchors.centerIn: parent color: res.fgColor - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: res.icon } } diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index fc6a820..cd92521 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -16,7 +16,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: Weather.description } @@ -29,7 +29,7 @@ ColumnLayout { animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Weather.temp } @@ -44,7 +44,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: qsTr("Feels like %1").arg(Weather.temp) visible: root.rootHeight > 550 } @@ -53,7 +53,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.normal text: { const today = Weather.forecast[0]; return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 07632b3..dd8cf82 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -24,14 +24,14 @@ CustomRect { MaterialIcon { Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "schedule" } CustomText { id: title - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium text: qsTr("Hourly forecast") } } @@ -60,21 +60,20 @@ CustomRect { MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === 0 ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided Behavior on color { - CAnim { - } + CAnim {} } CustomText { id: temp anchors.centerIn: parent - color: hour.index === 0 ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal + color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + font: Tokens.font.body.medium text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } } @@ -82,7 +81,7 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: hour.cond.icon } @@ -96,7 +95,7 @@ CustomRect { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") } } diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 46477a9..c2b6c48 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -13,7 +13,7 @@ Item { readonly property int padding: Tokens.padding.smaller required property Item panels - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.right: parent.right @@ -27,16 +27,16 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x) return 0; - if (visibilities.osd) { + if (screenState.osd) { const h = panels.osd.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; } - if (visibilities.session) { + if (screenState.session) { const h = panels.session.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; @@ -47,8 +47,7 @@ Item { implicitWidth: Config.notifs.sizes.width + padding * 2 Behavior on implicitHeight { - Anim { - } + Anim {} } ClippingWrapperRectangle { @@ -65,8 +64,7 @@ Item { orientation: Qt.Vertical spacing: 0 - delegate: NotifWrapper { - } + delegate: NotifWrapper {} displaced: Transition { Anim { property: "y" diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index 46e2bc4..0cf7d74 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -94,8 +94,7 @@ CustomRect { implicitHeight: root.nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -171,6 +170,7 @@ CustomRect { sourceComponent: MaterialIcon { color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + fontStyle: Tokens.font.icon.medium } } } @@ -222,6 +222,7 @@ CustomRect { maximumLineCount: 1 opacity: root.expanded ? 1 : 0 text: appNameMetrics.elidedText + font: Tokens.font.label.medium Behavior on opacity { Anim { @@ -246,14 +247,12 @@ CustomRect { anchors.leftMargin: Tokens.spacing.small anchors.top: parent.top animate: true - font.pointSize: Tokens.font.size.small height: implicitHeight maximumLineCount: 1 text: summaryMetrics.elidedText Behavior on height { - Anim { - } + Anim {} } states: State { name: "expanded" @@ -281,8 +280,7 @@ CustomRect { property: "topMargin" } - AnchorAnim { - } + AnchorAnim {} } } @@ -303,6 +301,7 @@ CustomRect { anchors.top: parent.top color: Colors.palette.m3onSurfaceVariant text: "•" + font: Tokens.font.body.small states: State { name: "expanded" @@ -314,8 +313,7 @@ CustomRect { } } transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -327,7 +325,7 @@ CustomRect { anchors.top: parent.top animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr } @@ -355,14 +353,13 @@ CustomRect { anchors.verticalCenterOffset: root.expanded ? -1 : 1 rotation: root.expanded ? 180 : 0 text: "expand_more" + fontStyle: Tokens.font.icon.medium Behavior on anchors.verticalCenterOffset { - Anim { - } + Anim {} } Behavior on rotation { - Anim { - } + Anim {} } } } @@ -376,7 +373,7 @@ CustomRect { anchors.top: summary.bottom animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: root.expanded ? 0 : 1 text: bodyPreviewMetrics.elidedText textFormat: Text.MarkdownText @@ -407,6 +404,7 @@ CustomRect { animate: true color: Colors.palette.m3onSurfaceVariant height: text ? implicitHeight : 0 + font: Tokens.font.body.medium opacity: root.expanded ? 1 : 0 text: root.modelData.body textFormat: Text.MarkdownText @@ -447,6 +445,7 @@ CustomRect { enabled: root.expanded fillWidth: root.modelData.actions.length === 0 icon: "close" + font: Tokens.font.icon.builders.small.size(14).build() inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant isRound: true @@ -464,7 +463,6 @@ CustomRect { enabled: root.expanded fillWidth: true - font.pointSize: Tokens.font.size.small implicitWidth: label.implicitWidth inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant @@ -493,6 +491,7 @@ CustomRect { label.animate: true padding: Tokens.padding.extraSmall shapeMorph: true + font: Tokens.font.icon.builders.small.size(14).build() onClicked: { Quickshell.clipboardText = root.modelData.body; 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 06f0c5d..6167cb7 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -1,14 +1,26 @@ -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 { id: root - required property Props props - required property var visibilities + required property ScreenState screenState + + Connections { + function onIsWindowChanged(): void { + if (ChatState.isWindow) + root.screenState.sidebarTab = 0; + } + + target: ChatState + } ColumnLayout { id: layout @@ -16,15 +28,135 @@ Item { 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 { - props: root.props - visibilities: root.visibilities + 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/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 4f3c13c..ae7a016 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -15,8 +15,7 @@ CustomRect { required property bool expanded required property NotifServer.Notif modelData readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState color: { const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2); @@ -137,7 +136,7 @@ CustomRect { onLinkActivated: link => { Qt.openUrlExternally(link); - root.visibilities.sidebar = false; + root.screenState.sidebar = false; } } diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 5cd56cc..e0cbdf0 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -14,8 +14,7 @@ Item { id: root readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) - required property Props props - required property var visibilities + required property ScreenState screenState anchors.fill: parent anchors.margins: Tokens.padding.small @@ -38,19 +37,15 @@ Item { anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large opacity: root.notifCount > 0 ? 1 : 0 text: root.notifCount Behavior on anchors.leftMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } } @@ -63,9 +58,7 @@ Item { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline elide: Text.ElideRight - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") } } @@ -87,8 +80,7 @@ Item { opacity: root.notifCount > 0 ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: 20 @@ -96,9 +88,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: "CaskaydiaCove NF" - font.pointSize: 18 - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -120,8 +110,7 @@ Item { id: notifList container: view - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } @@ -162,20 +151,17 @@ Item { scale: root.notifCount > 0 ? 1 : 0.5 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: IconButton { id: clearBtn - font.pointSize: Math.round(18 * 1.2) + font: Tokens.font.icon.large icon: "clear_all" - padding: 8 - radius: Tokens.rounding.full + isRound: true onClicked: clearTimer.start() diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 597a7ba..759e6d9 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -12,8 +12,7 @@ LazyListView { id: root required property Flickable container - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -118,8 +117,7 @@ LazyListView { container: root.container modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index bf6a376..faa67eb 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -16,7 +16,7 @@ CustomRect { readonly property list activeNotifs: notifs.filter(n => !n.closed) readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" required property Flickable container - readonly property bool expanded: props.expandedNotifs.includes(modelData) + readonly property bool expanded: screenState.expandedNotifs.includes(modelData) readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" required property string modelData readonly property int nonAnimHeight: { @@ -26,7 +26,7 @@ CustomRect { } readonly property int notifCount: activeNotifs.length readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) - required property Props props + required property ScreenState screenState readonly property int urgency: { if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) return NotificationUrgency.Critical; @@ -34,14 +34,13 @@ CustomRect { return NotificationUrgency.Normal; return NotificationUrgency.Low; } - required property PersistentProperties visibilities function toggleExpand(expand: bool): void { if (expand) { if (!expanded) - props.expandedNotifs.push(modelData); + screenState.expandedNotifs.push(modelData); } else if (expanded) { - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } } @@ -59,7 +58,7 @@ CustomRect { Component.onDestruction: { if (notifCount === 0 && expanded) - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } RowLayout { @@ -233,8 +232,7 @@ CustomRect { container: root.container expanded: root.expanded notifs: root.notifs - props: root.props - visibilities: root.visibilities + screenState: root.screenState onRequestToggleExpand: expand => root.toggleExpand(expand) } diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 904a787..b047878 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -14,8 +14,7 @@ LazyListView { required property Flickable container required property bool expanded required property list notifs - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState signal requestToggleExpand(expand: bool) @@ -122,8 +121,7 @@ LazyListView { anchors.fill: parent expanded: root.expanded modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml deleted file mode 100644 index 21a2541..0000000 --- a/Modules/Notifications/Sidebar/Props.qml +++ /dev/null @@ -1,7 +0,0 @@ -import Quickshell - -PersistentProperties { - property list expandedNotifs: [] - - reloadableId: "sidebar" -} diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml new file mode 100644 index 0000000..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/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 6731cbe..5094d86 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -11,8 +11,7 @@ import qs.Services CustomRect { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer @@ -46,7 +45,7 @@ CustomRect { anchors.horizontalCenterOffset: -0.5 anchors.verticalCenterOffset: 1.5 color: Recorder.running ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "screen_record" } } @@ -58,7 +57,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Screen Recorder") } @@ -66,13 +65,13 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") } } SplitButton { - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + active: menuItems.find(m => root.screenState.recordingMode === m.icon + m.text) ?? menuItems[0] enabled: !Recorder.running menuOnTop: true @@ -107,7 +106,7 @@ CustomRect { } ] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + menu.onItemSelected: item => root.screenState.recordingMode = item.icon + item.text } } @@ -126,8 +125,7 @@ CustomRect { enabled: false - Anim { - } + Anim {} } Behavior on running { SequentialAnimation { @@ -155,8 +153,7 @@ CustomRect { value: true } - PropertyAction { - } + PropertyAction {} PropertyAction { property: "enabled" @@ -190,8 +187,7 @@ CustomRect { id: recordingList RecordingList { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } @@ -208,8 +204,7 @@ CustomRect { radius: Tokens.rounding.full Behavior on implicitWidth { - Anim { - } + Anim {} } SequentialAnimation on opacity { alwaysRunToEnd: true @@ -237,13 +232,13 @@ CustomRect { anchors.centerIn: parent animate: true color: Recorder.paused ? Colors.palette.m3onTertiary : Colors.palette.m3onError - font.family: Config.appearance.font.family.mono + font: Tokens.font.mono.small text: Recorder.paused ? "PAUSED" : "REC" } } CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: { const elapsed = Recorder.elapsed; @@ -267,8 +262,9 @@ CustomRect { IconButton { checked: Recorder.paused - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: Recorder.paused ? "play_arrow" : "pause" + isRound: true isToggle: true label.animate: true type: IconButton.Tonal @@ -280,8 +276,9 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "stop" + isRound: true inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..e06e927 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -14,8 +14,7 @@ import qs.Services ColumnLayout { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState spacing: 0 @@ -23,30 +22,30 @@ ColumnLayout { Layout.fillWidth: true cursorShape: Qt.PointingHandCursor - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded RowLayout { spacing: Tokens.spacing.small MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "list" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Recordings") } IconButton { - icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + icon: root.screenState.recordingListExpanded ? "unfold_less" : "unfold_more" label.animate: true type: IconButton.Text - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded } } } @@ -57,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.props.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.body.large.pointSize + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list @@ -109,8 +108,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]); } } @@ -119,8 +121,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]); } } } @@ -135,8 +140,7 @@ ColumnLayout { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: FileSystemModel { nameFilters: ["recording_*.mp4"] @@ -162,32 +166,28 @@ ColumnLayout { opacity: list.count === 0 ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: Tokens.spacing.small MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.extraLarge - opacity: root.props.recordingListExpanded ? 1 : 0 - scale: root.props.recordingListExpanded ? 1 : 0 + fontStyle: Tokens.font.icon.extraLarge + opacity: root.screenState.recordingListExpanded ? 1 : 0 + scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } @@ -196,23 +196,20 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + Layout.preferredWidth: !root.screenState.recordingListExpanded ? implicitWidth : 0 color: Colors.palette.m3outline - opacity: !root.props.recordingListExpanded ? 1 : 0 - scale: !root.props.recordingListExpanded ? 1 : 0 + opacity: !root.screenState.recordingListExpanded ? 1 : 0 + scale: !root.screenState.recordingListExpanded ? 1 : 0 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/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..e0128f9 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); @@ -40,8 +38,8 @@ CustomRect { return true; }); } + required property ScreenState screenState readonly property int splitIndex: Math.ceil(quickToggles.length / 2) - required property PersistentProperties visibilities color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased @@ -55,7 +53,7 @@ CustomRect { spacing: Tokens.spacing.medium CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Quick Toggles") } @@ -168,8 +166,8 @@ CustomRect { isToggle: false onClicked: { - root.visibilities.sidebar = false; - root.visibilities.settings = true; + root.screenState.sidebar = false; + root.screenState.settings = true; } } } diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..3aa8206 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,16 +1,14 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts -import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config +import qs.Components +import qs.Modules.Notifications.Sidebar.Utils.Cards Item { id: root - required property BarPopouts.Wrapper popouts - required property PersistentProperties props - required property var visibilities + required property ScreenState screenState implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth @@ -21,19 +19,16 @@ Item { anchors.fill: parent spacing: 8 - IdleInhibit { - } + IdleInhibit {} Record { - props: root.props - visibilities: root.visibilities + screenState: root.screenState z: 1 } Toggles { Layout.fillWidth: true - popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml index ae190e1..7cb4b6e 100644 --- a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml +++ b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml @@ -15,8 +15,7 @@ CustomRect { radius: Tokens.rounding.small Behavior on implicitHeight { - Anim { - } + Anim {} } RowLayout { @@ -39,7 +38,7 @@ CustomRect { anchors.centerIn: parent color: IdleInhibitor.enabled ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: 18 + fontStyle: Tokens.font.icon.large text: "coffee" } } @@ -51,7 +50,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: 13 + font: Tokens.font.body.medium text: qsTr("Keep Awake") } @@ -59,7 +58,7 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: 11 + font: Tokens.font.body.small text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") } } @@ -83,16 +82,13 @@ CustomRect { scale: IdleInhibitor.enabled ? 1 : 0.5 Behavior on anchors.bottomMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primary @@ -105,7 +101,7 @@ CustomRect { anchors.centerIn: parent color: Colors.palette.m3onPrimary - font.pointSize: Math.round(11 * 0.9) + font: Tokens.font.body.builders.small.size(Math.round(Tokens.font.body.small.pointSize * 0.9)).build() text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..c44850e 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -4,26 +4,17 @@ import QtQuick import Quickshell import ZShell.Config import qs.Components -import qs.Modules.Bar.Popouts as BarPopouts Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts - readonly property PersistentProperties props: PersistentProperties { - property string recordingConfirmDelete - property bool recordingListExpanded: false - property string recordingMode - - reloadableId: "utilities" - } - readonly property bool shouldBeActive: visibilities.sidebar + required property ScreenState screenState + readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive required property Item sidebar - required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight + 8 * 2 + implicitHeight: content.implicitHeight + Tokens.padding.small * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) opacity: 1 - offsetScale visible: offsetScale < 1 @@ -45,9 +36,7 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..6934644 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -1,18 +1,21 @@ pragma ComponentBehavior: Bound import qs.Components +import qs.Components.Toast +import ZShell import ZShell.Config +import ZShell.Llm +import Quickshell import QtQuick Item { id: root + readonly property bool chatActive: screenState.sidebarTab === 1 property real offsetScale: shouldBeActive ? 0 : 1 required property var panels - readonly property Props props: Props { - } - readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled - required property var visibilities + required property ScreenState screenState + readonly property bool shouldBeActive: root.screenState.sidebar && Config.sidebar.enabled anchors.rightMargin: (-implicitWidth - 5) * offsetScale implicitWidth: Config.sidebar.sizes.width @@ -26,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 @@ -38,8 +52,7 @@ Item { sourceComponent: Content { implicitWidth: Config.sidebar.sizes.width - Tokens.padding.small * 2 - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index 03131f8..e010b09 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -7,7 +7,7 @@ Item { required property Item panels required property Item sidebarPanel - required property var visibilities + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth @@ -17,6 +17,6 @@ Item { id: content panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index d1b8b35..e1ac3d9 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -16,7 +16,7 @@ Item { required property bool muted required property bool sourceMuted required property real sourceVolume - required property var visibilities + required property ScreenState screenState required property real volume implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 @@ -53,7 +53,7 @@ Item { // Microphone volume WrappedLoader { - shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) + shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.screenState.session) sourceComponent: CustomMouseArea { function onWheel(event: WheelEvent) { diff --git a/Modules/Osd/Wrapper.qml b/Modules/Osd/Wrapper.qml index e879a4c..e388beb 100644 --- a/Modules/Osd/Wrapper.qml +++ b/Modules/Osd/Wrapper.qml @@ -16,16 +16,16 @@ Item { property bool muted property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled) property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 required property bool sidebarOrSessionVisible property bool sourceMuted property real sourceVolume - required property var visibilities + required property ScreenState screenState property real volume function show(): void { - visibilities.osd = true; + screenState.osd = true; timer.restart(); } @@ -90,7 +90,7 @@ Item { onTriggered: { if (!root.hovered) - root.visibilities.osd = false; + root.screenState.osd = false; } } @@ -107,7 +107,7 @@ Item { muted: root.muted sourceMuted: root.sourceMuted sourceVolume: root.sourceVolume - visibilities: root.visibilities + screenState: root.screenState volume: root.volume } } diff --git a/Modules/Polkit/Polkit.qml b/Modules/Polkit/Polkit.qml index 7bff94e..c598cd5 100644 --- a/Modules/Polkit/Polkit.qml +++ b/Modules/Polkit/Polkit.qml @@ -112,7 +112,7 @@ Scope { MaterialIcon { anchors.fill: parent - font.pointSize: 64 + fontStyle: Tokens.font.icon.size(64).build() horizontalAlignment: Text.AlignHCenter text: "security" verticalAlignment: Text.AlignVCenter @@ -129,8 +129,7 @@ Scope { CustomText { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) - font.bold: true - font.pointSize: 16 + font: Tokens.font.title.builders.medium.weight(Font.Bold).build() text: polkitAgent.flow?.message ?? "" wrapMode: Text.WordWrap } @@ -139,8 +138,7 @@ Scope { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) color: Colors.tPalette.m3onSurfaceVariant - font.bold: true - font.pointSize: 12 + font: Tokens.font.title.builders.small.weight(Font.Bold).build() text: polkitAgent.flow?.supplementaryMessage || "No Additional Information" wrapMode: Text.WordWrap } @@ -155,6 +153,7 @@ Scope { placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderTextColor: polkitAgent.flow?.failed ? Colors.palette.m3onError : Colors.tPalette.m3onSurfaceVariant selectByMouse: true + font: Tokens.font.body.small background: CustomRect { color: (polkitAgent.flow?.failed && passInput.text === "") ? Colors.palette.m3error : Colors.tPalette.m3surfaceVariant @@ -342,8 +341,7 @@ Scope { visible: panelWindow.visible Behavior on color { - CAnim { - } + CAnim {} } anchors { diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml index 668a329..2b488de 100644 --- a/Modules/Resources/Cards/HeroCard.qml +++ b/Modules/Resources/Cards/HeroCard.qml @@ -27,8 +27,7 @@ CustomClippingRect { implicitWidth: parent.width * root.usage Behavior on implicitWidth { - Anim { - } + Anim {} } } @@ -45,8 +44,7 @@ CustomClippingRect { value: root.usage Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { @@ -54,7 +52,7 @@ CustomClippingRect { anchors.centerIn: parent color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.icon } } @@ -125,15 +123,14 @@ CustomClippingRect { anchors.right: parent.right color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Usage") } CustomText { anchors.right: parent.right color: root.accent - font.pointSize: Tokens.font.size.extraLarge - font.weight: Font.Medium + font: Tokens.font.headline.builders.small.width(50).build() text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" } } diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index 168663d..c2f8041 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -65,7 +65,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(Memory.percentage * 100) + "%" } diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index fd4b3dc..ea6474c 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -1,8 +1,8 @@ import QtQuick import QtQuick.Layouts -import ZShell.Internal +import ZShell.Components +import ZShell.Services import ZShell.Config -import qs.Helpers import qs.Components import qs.Services @@ -14,7 +14,7 @@ CustomRect { implicitWidth: 300 radius: Tokens.rounding.largeIncreased - Ref { + ServiceRef { service: NetworkUsage } @@ -66,8 +66,7 @@ CustomRect { Behavior on smoothMax { enabled: sparkline.initialized - Anim { - } + Anim {} } Component.onCompleted: { @@ -79,12 +78,12 @@ CustomRect { } Connections { - function onValuesChanged(): void { + function onUpdateTriggered(): void { sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); slideAnim.restart(); } - target: NetworkUsage.downloadBuffer + target: NetworkUsage } NumberAnimation { @@ -130,8 +129,8 @@ CustomRect { CustomText { color: Colors.palette.m3tertiary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -158,8 +157,8 @@ CustomRect { CustomText { color: Colors.palette.m3secondary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -186,8 +185,8 @@ CustomRect { CustomText { color: Colors.palette.m3onSurfaceVariant text: { - const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); - const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); + const down = UsageFmt.formatBytes(NetworkUsage.downloadTotal ?? 0); + const up = UsageFmt.formatBytes(NetworkUsage.uploadTotal ?? 0); return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; } } diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index 238bed7..a1a1e57 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -43,8 +43,7 @@ CustomRect { value: root.percentage Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -56,14 +55,14 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "hard_drive" } CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(root.percentage * 100) + "%" } @@ -112,8 +111,7 @@ CustomRect { model: Storage.disks - DiskItem { - } + DiskItem {} } } } @@ -123,6 +121,6 @@ CustomRect { activeIcon: "storage" icon: modelData === Storage.primaryDisk ? "check" : "" - text: modelData.mount + text: modelData.name } } diff --git a/Modules/Resources/Cards/VramCard.qml b/Modules/Resources/Cards/VramCard.qml index 789bc39..034f00f 100644 --- a/Modules/Resources/Cards/VramCard.qml +++ b/Modules/Resources/Cards/VramCard.qml @@ -53,8 +53,7 @@ CustomRect { value: Gpu.memoryUsed / Gpu.memoryTotal Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -67,7 +66,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(circularIndicator.value * 100) + "%" } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 9004694..99adbf0 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -11,8 +11,8 @@ Item { readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 required property string position - readonly property bool shouldBeActive: root.visibilities.resources - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.resources + required property ScreenState screenState anchors.bottom: position === "bottom" ? parent.bottom : undefined anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 diff --git a/Modules/ServiceLoader.qml b/Modules/ServiceLoader.qml new file mode 100644 index 0000000..4d8eae3 --- /dev/null +++ b/Modules/ServiceLoader.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick +import qs.Helpers + +Scope { + Component.onCompleted: { + Wallpapers; + } +} diff --git a/Modules/Settings/Common/AudioDeviceList.qml b/Modules/Settings/Common/AudioDeviceList.qml index febdd2a..d0d21bb 100644 --- a/Modules/Settings/Common/AudioDeviceList.qml +++ b/Modules/Settings/Common/AudioDeviceList.qml @@ -60,12 +60,11 @@ ItemList { anchors.centerIn: parent color: device.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: device.active ? 1 : 0 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: root.iconName Behavior on fill { - Anim { - } + Anim {} } } } @@ -73,13 +72,13 @@ ItemList { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") } MaterialIcon { color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium opacity: device.active ? 1 : 0 text: "check" diff --git a/Modules/Settings/Common/BlobPopup.qml b/Modules/Settings/Common/BlobPopup.qml index 1978d10..bad56b1 100644 --- a/Modules/Settings/Common/BlobPopup.qml +++ b/Modules/Settings/Common/BlobPopup.qml @@ -35,8 +35,7 @@ Item { smoothing: Tokens.rounding.largeIncreased Behavior on color { - CAnim { - } + CAnim {} } } @@ -49,8 +48,7 @@ Item { radius: root.open ? Tokens.rounding.largeIncreased : Tokens.rounding.largeIncreased Behavior on anchors.margins { - Anim { - } + Anim {} } Behavior on radius { Anim { @@ -122,7 +120,7 @@ Item { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "view_apps" } } diff --git a/Modules/Settings/Common/ConnectedRect.qml b/Modules/Settings/Common/ConnectedRect.qml index 761273e..04762a6 100644 --- a/Modules/Settings/Common/ConnectedRect.qml +++ b/Modules/Settings/Common/ConnectedRect.qml @@ -17,9 +17,9 @@ CustomRect { bottomLeftRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall bottomRightRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall color: Colors.tPalette.m3surfaceContainer - opacity: enabled && (!parent || parent.enabled) ? 1 : 0.5 - topLeftRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall - topRightRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall + opacity: enabled && (parent?.enabled ?? false) ? 1 : 0.5 + topLeftRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall Behavior on opacity { Anim { diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 1d4d547..f06d808 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -10,17 +10,24 @@ import qs.Services Item { id: root - property bool acceptAllowed: true required property string acceptLabel required property Component content required property string header - property int horizontalContentMargin - required property string icon 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 + 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 signal accepted @@ -44,8 +51,7 @@ Item { color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer Behavior on color { - CAnim { - } + CAnim {} } } @@ -56,6 +62,7 @@ Item { enabled: false hoverEnabled: enabled parent: root.open ? root.rootParent : root + preventStealing: true onClicked: root.open = false } @@ -75,7 +82,8 @@ Item { backdrop.enabled: true dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased - dialogBg.radius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased dialogContent.opacity: 1 dialogWrapper.height: root.openHeight dialogWrapper.width: root.openWidth @@ -103,7 +111,7 @@ Item { } Anim { - properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" type: Anim.DefaultEffects } @@ -131,12 +139,13 @@ Item { id: dialogBg anchors.fill: parent - bottomLeftRadius: Tokens.rounding.largeIncreased - bottomRightRadius: Tokens.rounding.largeIncreased + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall deformScale: 0 group: blobGroup opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) - radius: Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall } RowButton { @@ -145,9 +154,11 @@ Item { anchors.left: parent.left anchors.right: parent.right color: "transparent" - height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim + first: root.first + height: Math.min(implicitHeight, parent.height) icon: root.icon - last: true + last: root.last + subtext: root.subtext text: root.label transform: Matrix4x4 { @@ -166,6 +177,8 @@ Item { opacity: 0 sourceComponent: MouseArea { + preventStealing: true + onWheel: event => event.accepted = true ColumnLayout { @@ -175,7 +188,7 @@ Item { spacing: 0 CustomText { - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.builders.large.weight(Font.Normal).build() text: root.header } @@ -216,6 +229,7 @@ Item { TextButton { horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: qsTr("Cancel") type: TextButton.Text verticalPadding: Tokens.padding.small @@ -230,6 +244,7 @@ Item { enabled: root.acceptAllowed horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: root.acceptLabel type: TextButton.Text verticalPadding: Tokens.padding.small diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..c9bf846 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -9,6 +9,8 @@ DialogRowButton { id: root required property var model + + property var initialSelect property var selectedItem function keyFor(item: var): string { @@ -65,7 +67,6 @@ DialogRowButton { anchors.verticalCenter: parent.verticalCenter color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -76,7 +77,7 @@ DialogRowButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium opacity: item.selected ? 1 : 0 text: "check" @@ -91,7 +92,14 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } } } diff --git a/Modules/Settings/Common/InfoRow.qml b/Modules/Settings/Common/InfoRow.qml index 0cec60b..63f510d 100644 --- a/Modules/Settings/Common/InfoRow.qml +++ b/Modules/Settings/Common/InfoRow.qml @@ -24,7 +24,7 @@ ConnectedRect { MaterialIcon { color: root.iconColor - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: root.icon } } @@ -53,14 +53,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } @@ -72,7 +72,7 @@ ConnectedRect { Layout.maximumWidth: root.width / 2 color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignRight } } diff --git a/Modules/Settings/Common/ItemList.qml b/Modules/Settings/Common/ItemList.qml index b5fcd41..d32f678 100644 --- a/Modules/Settings/Common/ItemList.qml +++ b/Modules/Settings/Common/ItemList.qml @@ -23,8 +23,7 @@ ConnectedRect { implicitHeight: (showList && list.count > 0 ? list.contentHeight : placeholder.implicitHeight + Tokens.padding.extraLarge * 2) + extraHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -46,7 +45,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.placeholderIcon } @@ -54,7 +53,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: root.placeholderText } } diff --git a/Modules/Settings/Common/ListEditor.qml b/Modules/Settings/Common/ListEditor.qml index e9e7705..9685068 100644 --- a/Modules/Settings/Common/ListEditor.qml +++ b/Modules/Settings/Common/ListEditor.qml @@ -54,14 +54,12 @@ ListView { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: DelegateModel { id: visualModel - delegate: ListRow { - } + delegate: ListRow {} model: ScriptModel { id: valuesModel } @@ -280,7 +278,7 @@ ListView { id: dragIcon color: Qt.alpha(Colors.palette.m3onSurfaceVariant, enabledSwitch.checked ? 1 : 0.5) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "drag_indicator" } @@ -290,7 +288,6 @@ ListView { Layout.fillWidth: true color: Qt.alpha(Colors.palette.m3onSurface, enabledSwitch.checked ? 1 : 0.5) elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -298,13 +295,13 @@ ListView { id: enabledSwitch checked: root.toggledFor(item.modelData) - font.pointSize: Tokens.font.size.normal + font: Tokens.font.icon.medium onToggled: root.itemToggled(item.DelegateModel.itemsIndex, checked) } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete" inactiveOnColor: Colors.palette.m3error isRound: true diff --git a/Modules/Settings/Common/NavRow.qml b/Modules/Settings/Common/NavRow.qml index a496c53..7d0ca3a 100644 --- a/Modules/Settings/Common/NavRow.qml +++ b/Modules/Settings/Common/NavRow.qml @@ -1,71 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Services -ConnectedRect { - id: root - - property alias icon: icon.text - property alias status: status.text - property alias text: label.text - - signal clicked - - Layout.fillWidth: true - implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 - - StateLayer { - onClicked: root.clicked() - } - - RowLayout { - id: navLayout - - anchors.fill: parent - anchors.leftMargin: Tokens.padding.largeIncreased - anchors.margins: Tokens.padding.small - anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.medium - - MaterialIcon { - id: icon - - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - CustomText { - id: label - - Layout.fillWidth: true - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - } - - CustomText { - id: status - - Layout.fillWidth: true - animate: true - color: Colors.palette.m3outline - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - visible: text - } - } - - MaterialIcon { - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - text: "chevron_right" - } - } +RowButton { + trailingIcon: "chevron_right" + subLabel.animate: true } diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 5b4200c..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; } @@ -134,6 +134,7 @@ ColumnLayout { inactiveOnColor: Colors.palette.m3onSurfaceVariant isRound: true type: IconButton.Tonal + font: Tokens.font.icon.medium onClicked: root.sState.closeSubPage() } @@ -142,7 +143,7 @@ ColumnLayout { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: root.title } } @@ -154,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/Common/PopupRow.qml b/Modules/Settings/Common/PopupRow.qml index 3e7394f..0b3a22a 100644 --- a/Modules/Settings/Common/PopupRow.qml +++ b/Modules/Settings/Common/PopupRow.qml @@ -40,7 +40,7 @@ ConnectedRect { id: icon color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium } ColumnLayout { @@ -52,7 +52,7 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { @@ -62,7 +62,7 @@ ConnectedRect { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 5d2953d..419fae8 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -17,6 +17,7 @@ ConnectedRect { property alias subtext: subLabel.text property alias text: label.text property string trailingIcon + property string activeItem signal clicked(event: MouseEvent) @@ -40,16 +41,17 @@ ConnectedRect { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } MaterialIcon { id: iconLabel color: Colors.palette.m3onSurfaceVariant + visible: root.icon !== "" fill: 1 - font.pointSize: Tokens.font.size.normal + text: root.icon + fontStyle: Tokens.font.icon.medium } Column { @@ -64,7 +66,6 @@ ConnectedRect { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.small } CustomText { @@ -74,11 +75,31 @@ ConnectedRect { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } + Loader { + active: root.activeItem && root.subtext + asynchronous: true + visible: active + Layout.fillHeight: true + + sourceComponent: CustomRect { + color: Colors.palette.m3secondaryContainer + radius: Tokens.rounding.full + implicitWidth: text.implicitWidth + Tokens.padding.medium * 2 + + CustomText { + id: text + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: root.activeItem + } + } + } + Loader { active: root.trailingIcon asynchronous: true @@ -86,7 +107,7 @@ ConnectedRect { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: root.trailingIcon } } diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml new file mode 100644 index 0000000..4bfc086 --- /dev/null +++ b/Modules/Settings/Common/SearchPopup.qml @@ -0,0 +1,127 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +DialogRowButton { + id: root + + required property var model + property var selectedItem + property var initialSelect + + acceptAllowed: !!selectedItem + horizontalContentMargin: -Tokens.padding.extraSmall + separateContent: true + + content: Component { + ColumnLayout { + VerticalFadeListView { + bottomMargin: Tokens.padding.large + Layout.fillHeight: true + Layout.fillWidth: true + model: ScriptModel { + values: { + const s = searchField.text; + var regex = new RegExp(s, "i"); + + return root.model.filter(n => regex.test(n)); + } + } + spacing: 0 + topMargin: Tokens.padding.large + + delegate: CustomRect { + id: item + + required property var modelData + readonly property bool selected: root.selectedItem === modelData + + anchors.left: ListView.view.contentItem.left + anchors.margins: 1 // Gets cut off for some reason without this + anchors.right: ListView.view.contentItem.right + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + implicitHeight: label.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + StateLayer { + id: stateLayer + + onClicked: root.selectedItem = item.modelData + } + + CustomText { + id: label + + anchors.left: parent.left + anchors.margins: Tokens.padding.large + anchors.right: item.selected ? checkIcon.left : parent.right + anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins + anchors.verticalCenter: parent.verticalCenter + color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + elide: Text.ElideRight + text: item.modelData + } + + MaterialIcon { + id: checkIcon + + anchors.margins: Tokens.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: Colors.palette.m3onTertiaryContainer + fontStyle: Tokens.font.icon.medium + opacity: item.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + } + + SearchBar { + id: searchField + + Layout.fillWidth: true + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + clearIcon.font: Tokens.font.icon.medium + clearIcon.padding: Tokens.padding.extraSmall + font: Tokens.font.body.small + placeholderText: qsTr("Search...") + searchIcon.anchors.leftMargin: Tokens.padding.large + searchIcon.fontStyle: Tokens.font.icon.medium + + Behavior on bg.border.color { + CAnim {} + } + } + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } + } +} diff --git a/Modules/Settings/Common/SectionHeader.qml b/Modules/Settings/Common/SectionHeader.qml index 0433f65..b826399 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -13,5 +13,5 @@ CustomText { Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0) color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.label.medium } diff --git a/Modules/Settings/Common/SelectRow.qml b/Modules/Settings/Common/SelectRow.qml index 0689182..ce97cad 100644 --- a/Modules/Settings/Common/SelectRow.qml +++ b/Modules/Settings/Common/SelectRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/SliderRow.qml b/Modules/Settings/Common/SliderRow.qml index d4669ea..10c69b1 100644 --- a/Modules/Settings/Common/SliderRow.qml +++ b/Modules/Settings/Common/SliderRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { id: valueLabel color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } } diff --git a/Modules/Settings/Common/SpinRow.qml b/Modules/Settings/Common/SpinRow.qml index 93c9d13..429d4d1 100644 --- a/Modules/Settings/Common/SpinRow.qml +++ b/Modules/Settings/Common/SpinRow.qml @@ -39,14 +39,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml new file mode 100644 index 0000000..32ec8a4 --- /dev/null +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property string value + property alias placeholderText: input.placeholderText + property string errorText + property alias maximumLength: input.maximumLength + property alias validate: input.validate + property alias validator: input.validator + property bool smallField + readonly property alias field: input + + signal valueEdited(value: string) + signal editingFinished(value: string) + + function clear(): void { + input.clear(); + } + + Component.onDestruction: { + if (value !== input.text) + editingFinished(input.text); + } + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium + Math.max(0, Tokens.padding.large - input.verticalPadding) * 2 + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + visible: root.subtext + text: input.isError && root.errorText ? root.errorText : root.subtext + color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline + elide: Text.ElideRight + animate: root.errorText + } + } + + CustomTextField { + id: input + + Layout.preferredWidth: root.smallField ? Tokens.sizes.nexus.smallTextFieldWidth : Tokens.sizes.nexus.textFieldWidth + Layout.maximumWidth: root.width / 2 + Layout.alignment: Qt.AlignVCenter + verticalPadding: Tokens.padding.small + + text: root.value + + onTextEdited: root.valueEdited(text) + onEditingFinished: root.editingFinished(text) + } + } +} diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml new file mode 100644 index 0000000..701b2ed --- /dev/null +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -0,0 +1,249 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Blobs +import qs.Components +import ZShell.Config +import qs.Services + +Item { + id: root + + property bool acceptAllowed: true + required property string acceptLabel + required property Component content + required property string header + property int horizontalContentMargin + property string icon + required property string label + property alias iconLabel: openButton.iconText + property alias rowButton: openButton + property alias contentItem: dialogContent.item + property string subtext: "" + property bool open + property real openHeight: Math.min(rootParent.height * 0.8, 400) + property real openWidth: Math.min(rootParent.width * 0.8, 600) + required property Item rootParent + property bool first: false + property bool last: false + required property string settingAnchor + property color openColor: Colors.palette.m3surfaceContainerHighest + + signal accepted + signal cancelled + + function reparentWrapper(): void { + const newParent = open ? rootParent : root; + const pos = dialogWrapper.mapToItem(newParent, 0, 0); + dialogWrapper.parent = newParent; + dialogWrapper.x = pos.x; + dialogWrapper.y = pos.y; + } + + Layout.fillWidth: true + implicitHeight: openButton.implicitHeight + z: open || dialogTransition.running ? 2 : 0 + + BlobGroup { + id: blobGroup + + color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: dialogWrapper + + height: openButton.implicitHeight + width: root.width + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased + dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogContent.opacity: 1 + dialogWrapper.height: root.openHeight + dialogWrapper.width: root.openWidth + dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2 + dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2 + elevation.opacity: 1 + openButton.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: dialogBg.bottomLeftRadius + bottomRightRadius: dialogBg.bottomRightRadius + level: 4 + opacity: 0 + radius: dialogBg.radius + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + + BlobRect { + id: dialogBg + + anchors.fill: parent + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + TimeDialogToggle { + id: openButton + + anchors.left: parent.left + anchors.right: parent.right + color: "transparent" + height: Math.min(implicitHeight, parent.height) + subtext: root.subtext + last: root.last + first: root.first + text: root.label + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + + onClicked: root.open = true + } + + Loader { + id: dialogContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + acceptedButtons: Qt.NoButton + cursorShape: undefined + enabled: dialogContent.opacity > 0 + clip: true + implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2 + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font: Tokens.font.title.builders.large.weight(Font.Normal).build() + text: root.header + } + + Loader { + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + sourceComponent: root.content + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.cancelled(); + root.open = false; + } + } + + TextButton { + enabled: root.acceptAllowed + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + root.accepted(); + root.open = false; + } + } + } + } + } + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + } +} diff --git a/Modules/Settings/Common/TimeDialogSelect.qml b/Modules/Settings/Common/TimeDialogSelect.qml new file mode 100644 index 0000000..33967dc --- /dev/null +++ b/Modules/Settings/Common/TimeDialogSelect.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Helpers +import qs.Components +import qs.Services + +TimeDialogRow { + id: root + + required property int start + required property int end + property int endCurrent + property int startCurrent + openColor: Colors.palette.m3surfaceContainer + openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0) + openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0) + + function keyFor(item: var): string { + return item.id; + } + + function labelFor(item: var): string { + return item.label; + } + + horizontalContentMargin: -Tokens.padding.extraSmall + + content: Component { + TimeInput { + id: timeContent + start: root.start + end: root.end + + onEndEdit: value => root.endCurrent = value + onStartEdit: value => root.startCurrent = value + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + root.z = 5; + } else { + root.rootParent.interactive = true; + root.z = 1; + } + } +} diff --git a/Modules/Settings/Common/OverlayRow.qml b/Modules/Settings/Common/TimeDialogToggle.qml similarity index 81% rename from Modules/Settings/Common/OverlayRow.qml rename to Modules/Settings/Common/TimeDialogToggle.qml index f7a6dae..9d75041 100644 --- a/Modules/Settings/Common/OverlayRow.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -10,12 +10,14 @@ ConnectedRect { property alias checked: switchButton.checked property int horizontalPadding: Tokens.padding.largeIncreased - required property Component popup property alias subtext: subtext.text property alias text: text.text + property alias icon: icon + property string iconText: "open_in_new" property int verticalPadding: Tokens.padding.small - signal clicked(checked: bool) + signal clicked + signal toggled(checked: bool) Layout.fillWidth: true implicitHeight: layout.implicitHeight + verticalPadding * 2 @@ -32,14 +34,14 @@ ConnectedRect { CustomText { id: text - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { id: subtext color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small wrapMode: Text.WordWrap } } @@ -50,11 +52,11 @@ ConnectedRect { anchors.right: switchButton.left anchors.rightMargin: Tokens.spacing.medium anchors.verticalCenter: parent.verticalCenter - text: "open_in_new" + text: root.iconText } StateLayer { - onClicked: PopupManager.requestOpen(root.popup) + onClicked: root.clicked() } CustomSwitch { @@ -64,6 +66,6 @@ ConnectedRect { anchors.rightMargin: root.horizontalPadding anchors.verticalCenter: parent.verticalCenter - onClicked: root.clicked(checked) + onClicked: root.toggled(checked) } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index 05e8052..f01100f 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -6,15 +6,13 @@ import qs.Components import qs.Helpers import qs.Services -CustomClippingRect { +ColumnLayout { id: root - required property var object - required property list settings - property bool shouldBeActive: true - - signal applySettings(startTime: int, endTime: int) - signal close + required property int start + required property int end + signal endEdit(time: int) + signal startEdit(time: int) function convertHour(timeValue: int): int { return Math.floor(timeValue / 60); @@ -28,555 +26,459 @@ CustomClippingRect { return hour * 60 + minute; } - color: Colors.palette.m3surfaceContainer - implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin - implicitWidth: column.implicitWidth + column.anchors.margins * 2 - radius: Tokens.rounding.largeIncreased + spacing: Tokens.spacing.medium - ColumnLayout { - id: column + RowLayout { + CustomRect { + id: startHourRect - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: startHourField + + function setConfigText(): string { + var val = root.convertHour(root.start); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (startHourField.text.length >= 2) { + startHourField.text = "0" + startHourField.text[0]; + } else if (startHourField.text.length === 1) { + startHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + startHourField.text = setConfigText(); + startHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + startHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = startHourField.text.length; + + if (textLen >= 2 && startHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startHourField.text + digit); + } else { + val = parseInt(startHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + startHourField.text = "0" + val; + } else { + startHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); + } + } + } CustomText { - text: qsTr("Select time") + id: startSeparator + + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() + text: ":" } - RowLayout { - CustomRect { - id: startHourRect + CustomRect { + id: startMinuteRect - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: startHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startHourField.text.length >= 2) { - startHourField.text = "0" + startHourField.text[0]; - } else if (startHourField.text.length === 1) { - startHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startHourField.text = setConfigText(root.settings[1]); - startHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - startHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startHourField.text.length; - - if (textLen >= 2 && startHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startHourField.text + digit); - } else { - val = parseInt(startHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - startHourField.text = "0" + val; - } else { - startHourField.text = val.toString(); - } - } - - event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startHourField.text === "") - return; - var val = parseInt(startHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startHourField.text) - startHourField.text = newText; - } - } - } - - CustomText { - id: startSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small CustomRect { - id: startMinuteRect + anchors.fill: parent + border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small + Behavior on border.width { + Anim {} + } + } - CustomRect { - anchors.fill: parent - border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width + TextFieldBase { + id: startMinuteField - Behavior on border.width { - Anim { - } + function setConfigText(): string { + var val = root.convertMinute(root.start); + if (val === 0) { + return "00"; } + return String(val); } - TextFieldBase { - id: startMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startMinuteField.text.length >= 2) { - startMinuteField.text = "0" + startMinuteField.text[0]; - } else if (startMinuteField.text.length === 1) { - startMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startMinuteField.text = setConfigText(root.settings[1]); - startMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - startMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startMinuteField.text.length; - - if (textLen >= 2 && startMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startMinuteField.text + digit); - } else { - val = parseInt(startMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - startMinuteField.text = "0" + val; - } else { - startMinuteField.text = val.toString(); - } - } + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startMinuteField.text === "") - return; - var val = parseInt(startMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startMinuteField.text) - startMinuteField.text = newText; - } - } - } - - Item { - id: spacer - - Layout.preferredWidth: Tokens.spacing.largeIncreased - } - - CustomRect { - id: endHourRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { + if (startMinuteField.text.length >= 2) { + startMinuteField.text = "0" + startMinuteField.text[0]; + } else if (startMinuteField.text.length === 1) { + startMinuteField.text = "0"; } - } - } - - TextFieldBase { - id: endHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endHourField.text.length >= 2) { - endHourField.text = "0" + endHourField.text[0]; - } else if (endHourField.text.length === 1) { - endHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endHourField.text = setConfigText(root.settings[2]); - endHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - endHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endHourField.text.length; - - if (textLen >= 2 && endHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endHourField.text + digit); - } else { - val = parseInt(endHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - endHourField.text = "0" + val; - } else { - endHourField.text = val.toString(); - } - } - + return; + } else if (event.key === Qt.Key_Escape) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endHourField.text === "") - return; - var val = parseInt(endHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endHourField.text) - endHourField.text = newText; - } - } - } - - CustomText { - id: endSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } - - CustomRect { - id: endMinuteRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: endMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); + startMinuteField.text = setConfigText(); + startMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + startMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startHourField.focus = true; } - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endMinuteField.text.length >= 2) { - endMinuteField.text = "0" + endMinuteField.text[0]; - } else if (endMinuteField.text.length === 1) { - endMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endMinuteField.text = setConfigText(root.settings[2]); - endMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - endMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endMinuteField.text.length; - - if (textLen >= 2 && endMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endMinuteField.text + digit); - } else { - val = parseInt(endMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - endMinuteField.text = "0" + val; - } else { - endMinuteField.text = val.toString(); - } - } - + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endMinuteField.text === "") + var digit = event.text; + var textLen = startMinuteField.text.length; + + if (textLen >= 2 && startMinuteField.text[0] !== '0') { return; - var val = parseInt(endMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endMinuteField.text) - endMinuteField.text = newText; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startMinuteField.text + digit); + } else { + val = parseInt(startMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + startMinuteField.text = "0" + val; + } else { + startMinuteField.text = val.toString(); + } } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); } } } - RowLayout { - CustomText { - Layout.preferredWidth: spacer.x + spacer.width - text: qsTr("Start") + Item { + id: spacer + + Layout.preferredWidth: Tokens.spacing.largeIncreased + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } } - CustomText { - Layout.preferredWidth: endMinuteRect.width + endHourRect.width - text: qsTr("End") + TextFieldBase { + id: endHourField + + function setConfigText(): string { + var val = root.convertHour(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endHourField.text.length >= 2) { + endHourField.text = "0" + endHourField.text[0]; + } else if (endHourField.text.length === 1) { + endHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endHourField.text = setConfigText(); + endHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + endHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endHourField.text.length; + + if (textLen >= 2 && endHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endHourField.text + digit); + } else { + val = parseInt(endHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + endHourField.text = "0" + val; + } else { + endHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } + } + } + + CustomText { + id: endSeparator + + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: endMinuteField + + function setConfigText(): string { + var val = root.convertMinute(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endMinuteField.text.length >= 2) { + endMinuteField.text = "0" + endMinuteField.text[0]; + } else if (endMinuteField.text.length === 1) { + endMinuteField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endMinuteField.text = setConfigText(); + endMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + endMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endHourField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endMinuteField.text.length; + + if (textLen >= 2 && endMinuteField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endMinuteField.text + digit); + } else { + val = parseInt(endMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + endMinuteField.text = "0" + val; + } else { + endMinuteField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } } } } RowLayout { - id: buttonRow - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: column.bottom - anchors.topMargin: Tokens.spacing.medium - - Item { - id: buttonSpacer - - Layout.fillWidth: true + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") } - ButtonRow { - spacing: Tokens.spacing.medium - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "close" - inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: Colors.palette.m3onSurfaceVariant - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Cancel" - - onClicked: root.close() - } - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "check" - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Apply" - - onClicked: { - const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text); - const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text); - root.applySettings(start, end); - } - } + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") } } } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 09f22f5..2eea3f5 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -12,6 +12,7 @@ CustomSwitch { property alias last: bg.last property string settingAnchor property string subtext + property string iconText function flashHighlight(): void { bg.flashHighlight(); @@ -19,6 +20,7 @@ CustomSwitch { Layout.fillWidth: true cLayer: 2 + font: Tokens.font.body.small horizontalPadding: Tokens.padding.largeIncreased implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 @@ -58,13 +60,12 @@ CustomSwitch { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: root.font opacity: root.enabled ? 1 : 0.5 text: root.text Behavior on opacity { - Anim { - } + Anim {} } } @@ -75,16 +76,22 @@ CustomSwitch { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small opacity: root.enabled ? 1 : 0.5 text: root.subtext visible: root.subtext Behavior on opacity { - Anim { - } + Anim {} } } + + MaterialIcon { + id: icon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.iconText ?? "" + } } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index b2fea36..27eaa47 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -14,8 +14,20 @@ Item { id: wrapper property int buttonRowHeight: Tokens.padding.large * 3 - property bool changesMade: false + readonly property bool changesMade: { + cropRevision; + + const r = cropRectLoader.item; + if (!r || !currentScreen || scaledImg.status !== Image.Ready) + return false; + + const t = r.savedTarget(); + + return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; + } + property int cropRevision: 0 readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -26,12 +38,13 @@ Item { const cropRect = cropRectLoader.item; - const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; - const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; - const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; - const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; + const cropXPercent = (cropRect.x - cropRect.imageX) / cropRect.imageW; + const cropYPercent = (cropRect.y - cropRect.imageY) / cropRect.imageH; + const cropWidthPercent = cropRect.width / cropRect.imageW; + const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); + cropRevision++; } function refreshScreens(): void { @@ -49,6 +62,10 @@ Item { Qt.callLater(syncCropToScreen); } + function sliderFromZoom(zoom: real): real { + return (1 - 1 / zoom) / (1 - 1 / maxZoom); + } + function syncCropToScreen(): void { if (cropRectLoader.item) cropRectLoader.item.restoreFromData(); @@ -71,6 +88,10 @@ Item { cropRect.clampToBounds(); } + function zoomFromSlider(t: real): real { + return 1 / (1 - t * (1 - 1 / maxZoom)); + } + anchors.left: parent.left anchors.right: parent.right implicitHeight: shouldBeActive ? 430 : 0 @@ -99,34 +120,10 @@ Item { Qt.callLater(syncCropToScreen); } - IconButton { - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - icon: "check" - opacity: wrapper.changesMade ? 1 : 0 - scale: wrapper.changesMade ? 1 : 0 - z: 2 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - onClicked: { - wrapper.applyCrop(); - wrapper.changesMade = false; - } - } - ButtonRow { id: screenSelector - anchors.bottom: scaledImg.top + anchors.bottom: imageSlot.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.leftMargin: Tokens.padding.extraLarge * 2 @@ -144,6 +141,7 @@ Item { required property var modelData fillWidth: true + font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() inactiveColor: isCurrent ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3surfaceContainer, 0.7) inactiveOnColor: isCurrent ? Colors.palette.m3onPrimary : Qt.alpha(Colors.palette.m3onSurface, 0.7) isRound: true @@ -170,24 +168,54 @@ Item { Layout.leftMargin: Tokens.padding.small Layout.preferredHeight: Tokens.padding.medium * 3 Layout.rightMargin: Tokens.padding.small - from: 1.0 implicitHeight: Tokens.padding.small * 3 insetIcon: "crop" - to: 5.0 - value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 + value: cropRectLoader.item ? wrapper.sliderFromZoom(cropRectLoader.item.zoom) : 0 onInteraction: value => { - wrapper.zoomClipRect(1 + (value * 4)); - wrapper.changesMade = true; + wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); + } + } + + ButtonRow { + spacing: Tokens.spacing.medium + + IconTextButton { + enabled: wrapper.changesMade + font: Tokens.font.body.large + horizontalPadding: Tokens.padding.large + icon: "close" + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + shapeMorph: true + text: qsTr("Cancel") + + onClicked: { + wrapper.syncCropToScreen(); + } + } + + IconTextButton { + enabled: wrapper.changesMade + font: Tokens.font.body.large + horizontalPadding: Tokens.padding.large + icon: "check" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + isRound: true + shapeMorph: true + text: qsTr("Apply") + + onClicked: { + wrapper.applyCrop(); + } } } } - Image { - id: scaledImg - - property var displayData - property real monitorScale: 1.0 + Item { + id: imageSlot anchors.bottom: sliderLayout.top anchors.bottomMargin: Tokens.spacing.medium @@ -195,147 +223,231 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: wrapper.buttonRowHeight + Tokens.spacing.medium - // anchors.top: screenSelector.bottom - asynchronous: true - fillMode: Image.PreserveAspectFit - retainWhileLoading: true - source: Wallpapers.current - sourceSize.height: parent.height - sourceSize.width: parent.width - onPaintedWidthChanged: { - if (paintedWidth > 0 && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + CustomClippingRect { + id: fitBox - wrapper.syncCropToScreen(); - } - } - onSourceChanged: { - if (cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 + readonly property real fitW: imageSlot.height * aspect - wrapper.syncCropToScreen(); - } - } - onStatusChanged: { - if (scaledImg.status == Image.Ready && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + anchors.centerIn: parent + implicitHeight: Math.round(fitW / aspect) + implicitWidth: Math.round(fitW) + radius: Tokens.rounding.largeIncreased - wrapper.syncCropToScreen(); - } - } + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: scaledImg.status === Image.Ready ? 0 : 1 - Loader { - id: cropRectLoader - - active: scaledImg.status === Image.Ready - - sourceComponent: Component { - CustomRect { - id: cropRect - - property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 - readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { - let fittedHeight = scaledImg.paintedHeight; - let fittedWidth = fittedHeight * aspectRatio; - - if (fittedWidth > scaledImg.paintedWidth) { - fittedWidth = scaledImg.paintedWidth; - fittedHeight = fittedWidth / aspectRatio; - } - - return fittedWidth; + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } - readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 - readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 - property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight - property real zoom: 1.0 + } + sourceComponent: CustomRect { + color: Colors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + radius: Tokens.rounding.full - function centerInImage() { - x = imageX + (scaledImg.paintedWidth - width) / 2; - y = imageY + (scaledImg.paintedHeight - height) / 2; + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } - - function clampToBounds() { - x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); - y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - height)); - } - - function restoreFromData() { - let data = Wallpapers.getCrop(wrapper.currentScreen.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = imageX + (data.x * scaledImg.paintedWidth); - y = imageY + (data.y * scaledImg.paintedHeight); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } - } - - border.color: Colors.palette.m3primary - border.width: 2 - height: baseHeight / zoom - opacity: 1 - width: baseWidth / zoom - - Behavior on opacity { - Anim { - } - } - - Component.onCompleted: { - restoreFromData(); - } - onHeightChanged: clampToBounds() - onWidthChanged: clampToBounds() } } - } - MouseArea { - id: mouse + Image { + id: scaledImg - function updateCrop(mouseX, mouseY) { - if (!cropRectLoader.item) - return; + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectFit + mipmap: true + opacity: status === Image.Ready ? 1 : 0 + retainWhileLoading: true + smooth: true + source: Wallpapers.current + sourceSize.height: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return imageSlot.height * dpr; + } - const cropRect = cropRectLoader.item; - - let nx = mouseX - cropRect.width * 0.5; - let ny = mouseY - cropRect.height * 0.5; - - nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); - ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); - - cropRect.x = nx; - cropRect.y = ny; + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } } - anchors.fill: parent - hoverEnabled: true - preventStealing: true + Loader { + id: cropRectLoader - onPositionChanged: mouse => { - if (pressed) { + active: scaledImg.status === Image.Ready + + sourceComponent: Component { + CustomRect { + id: cropRect + + property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 + readonly property real baseHeight: baseWidth / aspectRatio + readonly property real baseWidth: { + let fittedHeight = imageH; + let fittedWidth = fittedHeight * aspectRatio; + + if (fittedWidth > imageW) { + fittedWidth = imageW; + fittedHeight = fittedWidth / aspectRatio; + } + + return fittedWidth; + } + readonly property real imageH: Math.round(fitBox.height) + readonly property real imageW: Math.round(fitBox.width) + readonly property real imageX: 0 + readonly property real imageY: 0 + property real imgAspectRatio: imageH > 0 ? imageW / imageH : 1 + property real zoom: 1.0 + + function centerInImage() { + x = Math.round(imageX + (imageW - width) / 2); + y = Math.round(imageY + (imageH - height) / 2); + } + + function clampToBounds() { + x = Math.max(imageX, Math.min(x, imageX + imageW - width)); + y = Math.max(imageY, Math.min(y, imageY + imageH - height)); + } + + function restoreFromData() { + const t = savedTarget(); + zoom = t.zoom; + x = t.x; + y = t.y; + } + + // Where the rect should be according to saved data (same rules as a restore). + function savedTarget() { + const data = Wallpapers.getCrop(wrapper.currentScreen.name); + const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); + + const z = hasCrop && data.zoom > 0 ? data.zoom : 1.0; + const w = Math.round(baseWidth / z); + const h = Math.round(baseHeight / z); + + let tx = hasCrop ? Math.round(imageX + data.x * imageW) : Math.round(imageX + (imageW - w) / 2); + let ty = hasCrop ? Math.round(imageY + data.y * imageH) : Math.round(imageY + (imageH - h) / 2); + + tx = Math.max(imageX, Math.min(tx, imageX + imageW - w)); + ty = Math.max(imageY, Math.min(ty, imageY + imageH - h)); + + return { + x: tx, + y: ty, + zoom: z + }; + } + + color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" + height: Math.round(baseHeight / zoom) + width: Math.round(baseWidth / zoom) + + Behavior on color { + CAnim { + } + } + + Component.onCompleted: { + restoreFromData(); + } + onHeightChanged: clampToBounds() + onWidthChanged: clampToBounds() + } + } + } + + ShaderEffect { + property color borderColor: Colors.palette.m3tertiary + property real borderWidth: 2 + property real cornerRadius: fitBox.radius + property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) + readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 + property vector2d itemSize: Qt.vector2d(width, height) + property real pixelScale: dpr + + function snap(v: real): real { + return Math.round(v * dpr) / dpr; + } + + anchors.fill: parent + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) + } + + MouseArea { + id: mouse + + property real grabX: 0 + property real grabY: 0 + + function beginGrab(mx, my): void { + const r = cropRectLoader.item; + if (!r) + return; + + const inside = insideCropRect(mx, my); + + if (inside) { + grabX = mx - r.x; + grabY = my - r.y; + } else { + grabX = r.width * 0.5; + grabY = r.height * 0.5; + } + } + + function insideCropRect(mx, my): bool { + const r = cropRectLoader.item; + if (!r) + return false; + + return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; + } + + function updateCrop(mx, my): void { + if (!cropRectLoader.item) + return; + + const cropRect = cropRectLoader.item; + + let nx = mx - grabX; + let ny = my - grabY; + + nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + cropRect.imageW - cropRect.width)); + ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + cropRect.imageH - cropRect.height)); + + cropRect.x = nx; + cropRect.y = ny; + } + + anchors.fill: parent + cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + hoverEnabled: true + preventStealing: true + + onPositionChanged: mouse => { + if (pressed) { + updateCrop(mouse.x, mouse.y); + } + } + onPressed: mouse => { + beginGrab(mouse.x, mouse.y); updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; } } - onPressed: mouse => { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - onReleased: { - wrapper.changesMade = true; - } } } } diff --git a/Modules/Settings/Content.qml b/Modules/Settings/Content.qml index 66b616a..75a05c2 100644 --- a/Modules/Settings/Content.qml +++ b/Modules/Settings/Content.qml @@ -24,8 +24,7 @@ CustomClippingRect { radius: Tokens.rounding.largeIncreased + Tokens.padding.small Behavior on blobColor { - CAnim { - } + CAnim {} } TapHandler { @@ -70,8 +69,4 @@ CustomClippingRect { anchors.top: parent.top sState: root.sState } - - PopupOverlay { - anchors.fill: parent - } } diff --git a/Modules/Settings/NavPane.qml b/Modules/Settings/NavPane.qml index e802071..820e3e3 100644 --- a/Modules/Settings/NavPane.qml +++ b/Modules/Settings/NavPane.qml @@ -18,16 +18,15 @@ ColumnLayout { Layout.fillWidth: true bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.font: Tokens.font.icon.medium clearIcon.padding: Tokens.padding.extraSmall - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.large placeholderText: qsTr("Search settings") searchIcon.anchors.leftMargin: Tokens.padding.largeIncreased - searchIcon.font.pointSize: Tokens.font.size.large + searchIcon.fontStyle: Tokens.font.icon.medium Behavior on bg.border.color { - CAnim { - } + CAnim {} } Binding { diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 5ccf726..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 @@ -107,7 +103,7 @@ VerticalFadeFlickable { anchors.fill: parent anchors.margins: Tokens.padding.large - spacing: Tokens.spacing.small + spacing: Tokens.spacing.medium CustomRect { Layout.bottomMargin: -1 @@ -122,7 +118,7 @@ VerticalFadeFlickable { anchors.verticalCenterOffset: 1 color: item.isCurrentPage ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: item.modelData.noFill ? 0 : 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() grade: 25 text: item.modelData.icon } @@ -135,6 +131,7 @@ VerticalFadeFlickable { CustomText { Layout.fillWidth: true elide: Text.ElideRight + font: Tokens.font.body.medium text: item.modelData.name } @@ -142,6 +139,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight + font: Tokens.font.label.small text: item.modelData.description } } @@ -198,7 +196,7 @@ VerticalFadeFlickable { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() text: group.modelData.icon } @@ -206,7 +204,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3secondary elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: group.modelData.page } } @@ -262,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 @@ -283,7 +277,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: { const labels = result.modelData.crumbLabels.slice(1); const section = result.modelData.section; @@ -297,7 +291,6 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal text: SettingsSearcher.highlight(result.modelData.title, root.search, Colors.palette.m3primary) textFormat: text.includes(" 0 @@ -319,7 +312,8 @@ VerticalFadeFlickable { z: 1 onClicked: { - root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); + const target = result.modelData.targetSubPath; + root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor); } } @@ -349,7 +343,6 @@ VerticalFadeFlickable { Layout.fillWidth: true Layout.topMargin: Tokens.padding.large color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter text: qsTr("No matching settings") visible: root.searching && root.results.length === 0 diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 732c2b4..2929512 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.Modules.Settings.Pages.Network import qs.Services @@ -24,13 +25,15 @@ QtObject { // Wallpaper & style StackPage { Component { - WallpaperPage { - } + WallpaperPage {} } Component { - WallpaperSelect { - } + WallpaperSelect {} + } + + Component { + ColorsFonts {} } } }, @@ -38,8 +41,7 @@ QtObject { // Screenshot StackPage { Component { - ScreenshotPage { - } + ScreenshotPage {} } } }, @@ -48,46 +50,38 @@ QtObject { Component { StackPage { Component { - NetworkPage { - } + NetworkPage {} } Component { - AllNetworks { - } + AllNetworks {} } Component { - SavedNetworks { - } + SavedNetworks {} } Component { - AddNetwork { - } + AddNetwork {} } Component { - OldNetwork { - } + OldNetwork {} } } }, Component { - PlaceholderComp { - } + PlaceholderComp {} }, Component { // Audio StackPage { Component { - AudioPage { - } + AudioPage {} } Component { - AppVolumes { - } + AppVolumes {} } } }, @@ -96,49 +90,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 {} } } }, @@ -147,18 +137,15 @@ QtObject { Component { StackPage { Component { - AppsPage { - } + AppsPage {} } Component { - AllApps { - } + AllApps {} } Component { - AppInfo { - } + AppInfo {} } } }, @@ -167,13 +154,11 @@ QtObject { Component { StackPage { Component { - ServicesPage { - } + ServicesPage {} } Component { - NotificationsPage { - } + NotificationsPage {} } } }, @@ -182,15 +167,13 @@ QtObject { Component { StackPage { Component { - AboutPage { - } + AboutPage {} } } } ] readonly property Component placeholderComp: Component { - PlaceholderComp { - } + PlaceholderComp {} } component PlaceholderComp: Item { @@ -203,7 +186,7 @@ QtObject { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: "handyman" } diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index f853699..3235e04 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Components import ZShell.Config +import qs.Services Item { id: root diff --git a/Modules/Settings/Pages/AboutPage.qml b/Modules/Settings/Pages/AboutPage.qml index 1fae077..53bc19e 100644 --- a/Modules/Settings/Pages/AboutPage.qml +++ b/Modules/Settings/Pages/AboutPage.qml @@ -56,14 +56,14 @@ PageBase { CustomText { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.small - font.pointSize: Tokens.font.size.large + font: Tokens.font.headline.large text: "ZShell" } CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: ZUtils.version ? `v${ZUtils.version}` : "…" } } diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index dc05f73..228ffab 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -52,11 +52,11 @@ PageBase { anchors.leftMargin: Tokens.padding.largeIncreased anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.small + spacing: Tokens.spacing.medium IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } @@ -67,7 +67,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -75,7 +75,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -84,14 +84,14 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index b3273f4..4f549da 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -47,7 +47,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 3) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 3) source: Quickshell.iconPath(root.app?.icon, "image-missing") } @@ -57,7 +57,7 @@ PageBase { CustomText { Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.app?.name ?? "" wrapMode: Text.WordWrap } @@ -65,7 +65,7 @@ PageBase { CustomText { Layout.fillWidth: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.app?.comment || root.app?.genericName) ?? "" visible: text wrapMode: Text.WordWrap @@ -152,7 +152,7 @@ PageBase { id: label Layout.alignment: Qt.AlignTop - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small } Item { @@ -165,7 +165,6 @@ PageBase { Layout.fillWidth: true Layout.maximumWidth: implicitWidth + 1 // Whyyyyyyyyy color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere } } diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index a9ff2f7..32206a0 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -76,7 +76,7 @@ PageBase { icon: "apps" last: true settingAnchor: "apps-all-apps" - status: qsTr("Browse installed apps, set favorites and hidden") + subtext: qsTr("Browse installed apps, set favorites and hidden") text: qsTr("All apps") onClicked: root.sState.openSubPage(1) @@ -143,7 +143,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } @@ -154,7 +154,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -162,7 +162,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -171,7 +171,7 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } diff --git a/Modules/Settings/Pages/Audio/AppVolumes.qml b/Modules/Settings/Pages/Audio/AppVolumes.qml index 59d634b..9d59da6 100644 --- a/Modules/Settings/Pages/Audio/AppVolumes.qml +++ b/Modules/Settings/Pages/Audio/AppVolumes.qml @@ -28,7 +28,6 @@ PageBase { Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small text: qsTr("Adjust the volume of individual apps currently playing audio.") wrapMode: Text.WordWrap } diff --git a/Modules/Settings/Pages/AudioPage.qml b/Modules/Settings/Pages/AudioPage.qml index fa8f225..7c6e0f7 100644 --- a/Modules/Settings/Pages/AudioPage.qml +++ b/Modules/Settings/Pages/AudioPage.qml @@ -103,7 +103,7 @@ PageBase { spacing: Tokens.spacing.small MaterialIcon { - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "tune" } @@ -114,7 +114,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: qsTr("App volumes") } @@ -123,14 +123,14 @@ PageBase { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) } } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index 2835082..8dc3b1a 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -57,6 +57,7 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + last: true label: qsTr("Add entry") model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); @@ -66,6 +67,7 @@ PageBase { })); } rootParent: root.flickable + settingAnchor: "panels-bar-status-icons-add-item" onAccepted: { if (!selectedItem) diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index c14f3bd..8f6ddb6 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -91,7 +91,7 @@ PageBase { first: true icon: "widgets" settingAnchor: "bar-tray" - status: qsTr("System tray icons") + subtext: qsTr("System tray icons") text: qsTr("Tray") onClicked: root.sState.openSubPage(6) @@ -100,7 +100,7 @@ PageBase { NavRow { icon: "signal_cellular_alt" settingAnchor: "bar-statusicons" - status: qsTr("Visible indicators") + subtext: qsTr("Visible indicators") text: qsTr("Status icons") onClicked: root.sState.openSubPage(7) @@ -110,7 +110,7 @@ PageBase { icon: "schedule" last: true settingAnchor: "bar-clock" - status: qsTr("Date, icon, background") + subtext: qsTr("Date, icon, background") text: qsTr("Clock") onClicked: root.sState.openSubPage(8) diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index 7657761..12d7d3e 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -33,5 +33,73 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + + // Tabs + SectionHeader { + text: qsTr("Tabs") + } + + ToggleRow { + first: true + checked: Config.dashboard.showDashboard + settingAnchor: "dashboard-show-dashboard" + text: qsTr("Dashboard") + + onToggled: Config.dashboard.showDashboard = checked + } + + ToggleRow { + checked: Config.dashboard.showWeather + settingAnchor: "dashboard-show-weather" + text: qsTr("Weather") + + onToggled: Config.dashboard.showWeather = checked + } + + ToggleRow { + checked: Config.dashboard.showMedia + settingAnchor: "dashboard-show-media" + text: qsTr("Media") + + onToggled: Config.dashboard.showMedia = checked + } + + ToggleRow { + checked: Config.dashboard.showProcesses + settingAnchor: "dashboard-show-processes" + text: qsTr("Processes") + last: true + + onToggled: Config.dashboard.showProcesses = checked + } + + // Behavior + SectionHeader { + text: qsTr("Behavior") + } + + ToggleRow { + checked: Config.dashboard.showOnHover + first: true + settingAnchor: "dashboard-showonhover" + text: qsTr("Show on hover") + subtext: qsTr("Dashboard %1 be shown when hovering the middle-top part of the shell").arg(checked ? "will" : "will not") + + onToggled: Config.dashboard.showOnHover = checked + } + + SpinRow { + from: 30 + settingAnchor: "dashboard-drag-threshold" + stepSize: 20 + enabled: !Config.dashboard.showOnHover + subtext: qsTr("How far you need to drag before the panel opens") + text: qsTr("Drag threshold") + to: 100 + value: Config.dashboard.dragThreshold + last: true + + onMoved: v => Config.dashboard.dragThreshold = v + } } } 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 e99a36e..8de209b 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -88,7 +88,12 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + onOpenChanged: { + root.sState.dimmed = open; + } + settingAnchor: "panels-sidebar-quick-toggles-add-item" label: qsTr("Add entry") + last: true model: { const present = new Set(Config.utilities.quickToggles.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ @@ -108,5 +113,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/Pages/PanelsPage.qml b/Modules/Settings/Pages/PanelsPage.qml index eeb33ba..6a16a5b 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -17,7 +17,7 @@ PageBase { first: true icon: "dock_to_bottom" settingAnchor: "panels-bar" - status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") + subtext: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") text: qsTr("Bar") onClicked: root.sState.openSubPage(1) @@ -26,7 +26,7 @@ PageBase { NavRow { icon: "dashboard" settingAnchor: "panels-dashboard" - status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Dashboard") onClicked: root.sState.openSubPage(2) @@ -35,7 +35,7 @@ PageBase { NavRow { icon: "insert_chart" settingAnchor: "panels-resources" - status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Resources") onClicked: root.sState.openSubPage(3) @@ -44,7 +44,7 @@ PageBase { NavRow { icon: "apps" settingAnchor: "panels-launcher" - status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Launcher") onClicked: root.sState.openSubPage(4) @@ -54,7 +54,7 @@ PageBase { icon: "dock_to_right" last: true settingAnchor: "panels-sidebar" - status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Sidebar") onClicked: root.sState.openSubPage(5) diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index e179635..e636fb5 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -25,18 +25,6 @@ PageBase { text: qsTr("None") } ] - readonly property list gpuValues: ["", "NVIDIA", "GENERIC", "None"] - - function gpuKeyToIndex(key: string): int { - const u = (key ?? "").trim().toUpperCase(); - if (u === "") - return 0; // Auto - if (u === "NVIDIA") - return 1; - if (u === "GENERIC") - return 2; - return 3; // None - } title: qsTr("Services") @@ -72,7 +60,7 @@ PageBase { icon: "notifications" last: true settingAnchor: "services-notifications" - status: qsTr("Notifications, toasts, timeouts") + subtext: qsTr("Notifications, toasts, timeouts") text: qsTr("Notifications") onClicked: root.sState.openSubPage(1) @@ -202,16 +190,16 @@ PageBase { } ToggleRow { - checked: Config.general.color.smart + checked: Config.colors.smart settingAnchor: "services-smart-color-scheme" subtext: qsTr("Derive theme mode from the wallpaper") text: qsTr("Smart color scheme") - onToggled: Config.general.color.smart = checked + onToggled: Config.colors.smart = checked } SelectRow { - active: root.gpuItems[root.gpuKeyToIndex(Config.services.gpuType)] + active: root.gpuItems[Config.services.gpuType] last: true menuItems: root.gpuItems menuOnTop: true @@ -219,7 +207,7 @@ PageBase { subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") text: qsTr("GPU") - onSelected: item => Config.services.gpuType = root.gpuValues[root.gpuItems.indexOf(item)] + onSelected: item => Config.services.gpuType = root.gpuItems.indexOf(item) } } } diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml new file mode 100644 index 0000000..be09d14 --- /dev/null +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Paths +import qs.Services +import qs.Modules.Settings.Common + +PageBase { + id: root + + readonly property list fonts: Qt.fontFamilies() + readonly property var schemes: [ + { + id: "onedark", + label: qsTr("One Dark") + }, + { + id: "nord", + label: qsTr("Nord") + }, + { + id: "dracula", + label: qsTr("Dracula") + }, + { + id: "darkgreen", + label: qsTr("Darkgreen") + }, + { + id: "solarized", + label: qsTr("Solarized") + }, + { + id: "rosepine", + label: qsTr("Rosepine") + }, + { + id: "gruvbox", + label: qsTr("Gruvbox") + }, + { + id: "catppuccin", + label: qsTr("Catppuccin") + }, + { + id: "tokyonight", + label: qsTr("Tokyo Night") + }, + { + id: "shadotheme", + label: qsTr("Shadotheme") + } + ] + + isSubPage: true + title: qsTr("Colors & font") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + SectionHeader { + first: true + text: qsTr("Colors") + } + + ToggleRow { + text: qsTr("Dynamic colors") + first: true + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled") + checked: Config.colors.schemeGen + settingAnchor: "colors-fonts-dynamic-colors" + + onToggled: { + Config.colors.schemeGen = checked; + + if (checked) + Colors.setMode(Config.colors.mode); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-scheme" + model: root.schemes + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen + header: qsTr("Select scheme") + rowButton.activeItem: root.schemes.find(s => { + var regex = new RegExp(Config.colors.presets.name, "i"); + return regex.test(s.id); + }).label ?? "" + label: qsTr("Preset scheme") + subtext: qsTr("Select preset color scheme") + rootParent: root.flickable + + FileView { + id: view + path: `${Paths.state}/presets.json` + + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var presets + } + } + + initialSelect: Config.colors.presets.name.toLowerCase() + + onAccepted: { + if (!selectedItem) + return; + + const s = selectedItem.toLowerCase(); + let v = ""; + let a = ""; + const existingVariant = Config.colors.presets.variant; + const existingAccent = Config.colors.presets.accent; + const variantKeys = Object.keys(adapter.presets[s].variants); + + if (existingVariant !== "" && variantKeys.some(v => v === existingVariant)) + v = existingVariant; + + if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else if (v !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-variant" + // qmlformat off + model: { + const v = adapter.presets[Config.colors.presets.name].variants; + const map = Object.keys(v).map(v => ({ + id: v, + label: qsTr("%1%2") + .arg(v.charAt(0).toUpperCase()) + .arg(v.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen && model.length > 1 + header: qsTr("Select variant") + rowButton.activeItem: { + const v = Config.colors.presets.variant ?? ""; + if (v === "") + return ""; + return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase()); + } + label: qsTr("Variant") + subtext: qsTr("Select preset variant") + rootParent: root.flickable + + initialSelect: Config.colors.presets.variant + + onAccepted: { + if (!selectedItem) + return; + + const v = selectedItem.toLowerCase(); + const s = Config.colors.presets.name; + const existingAccent = Config.colors.presets.accent; + let a = ""; + + if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-accent" + // qmlformat off + model: { + const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? []; + + if (a.length === 0) + return a; + + const map = a.map(a => ({ + id: a, + label: qsTr("%1%2") + .arg(a.charAt(0).toUpperCase()) + .arg(a.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.colors.schemeGen && model.length > 1 + header: qsTr("Select accent") + rowButton.activeItem: { + const a = Config.colors.presets.accent ?? ""; + if (a === "") + return ""; + return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase()); + } + label: qsTr("Accent") + last: true + subtext: qsTr("Select preset accent") + rootParent: root.flickable + + initialSelect: Config.colors.presets.accent + + onAccepted: { + if (!selectedItem) + return; + + const a = selectedItem.toLowerCase(); + const v = Config.colors.presets.variant; + const s = Config.colors.presets.name; + + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + } + } + + SectionHeader { + text: qsTr("Fonts") + } + + SearchPopup { + id: selectSansFont + + settingAnchor: "colors-fonts-sans-font" + model: root.fonts + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + header: qsTr("Select font") + first: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.sans, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Sans font") + subtext: qsTr("Select desired sans font") + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.sans + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.sans = selectedItem; + } + } + + SearchPopup { + id: selectMonoFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + settingAnchor: "colors-fonts-mono-font" + separateContent: false + header: qsTr("Select font") + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.mono, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Mono font") + subtext: qsTr("Select desired mono font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.mono + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.mono = selectedItem; + } + } + + SearchPopup { + id: selectClockFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + settingAnchor: "colors-fonts-clock-font" + header: qsTr("Select font") + last: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.clock, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Clock font") + subtext: qsTr("Select desired clock font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.clock + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.clock = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 18016d2..a4d4906 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -25,7 +25,7 @@ PageBase { CustomText { Layout.topMargin: Tokens.spacing.largeIncreased - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.small text: qsTr("Wallpapers") } diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 40274ad..f3fcb6e 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -33,18 +33,35 @@ PageBase { } } - IconTextButton { + ButtonRow { Layout.alignment: Qt.AlignHCenter - enabled: Config.background.enabled - horizontalPadding: Tokens.padding.extraLarge - icon: "wallpaper" - isRound: true - shapeMorph: true - text: qsTr("Wallpapers") - type: IconTextButton.Tonal - verticalPadding: Tokens.padding.small + spacing: Tokens.spacing.small - onClicked: root.sState.openSubPage(1) // Wallpaper page + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "wallpaper" + isRound: true + shapeMorph: true + text: qsTr("Wallpapers") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(1) // Wallpaper page + } + + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "palette" + isRound: true + shapeMorph: true + text: qsTr("Colors & font") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(2) // Colors & font page + } } ToggleRow { @@ -76,85 +93,116 @@ PageBase { onToggled: Colors.setMode(checked ? "dark" : "light") } - OverlayRow { - id: darkMode - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkEnd); + d.setMinutes(Config.colors.scheduleDarkEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkStart); + d.setMinutes(Config.colors.scheduleDarkStart); return Qt.formatTime(d, "hh:mm AP"); } - checked: Config.general.color.scheduleDark + rowButton.checked: Config.colors.scheduleDark + rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked + start: Config.colors.scheduleDarkStart + end: Config.colors.scheduleDarkEnd first: true + last: true settingAnchor: "style-schedule-dark-mode" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule dark mode") + label: qsTr("Schedule dark mode") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleDarkStart = start; - Config.general.color.scheduleDarkEnd = end; - ModeScheduler.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleDark = value; + onAccepted: { + Config.colors.scheduleDarkStart = startCurrent; + Config.colors.scheduleDarkEnd = endCurrent; + ModeScheduler.checkStartup(); } } - OverlayRow { - id: hyprsunset - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); + d.setMinutes(Config.display.nightlight.scheduleEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetStart); + d.setMinutes(Config.display.nightlight.scheduleStart); return Qt.formatTime(d, "hh:mm AP"); } + rowButton.checked: Config.display.nightlight.schedule + rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked + start: Config.display.nightlight.scheduleStart + end: Config.display.nightlight.scheduleEnd + first: true + settingAnchor: "style-schedule-nightlight" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") + subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) + label: qsTr("Schedule nightlight") + + onOpenChanged: { + root.sState.dimmed = open; + } + + onAccepted: { + Config.display.nightlight.scheduleStart = startCurrent; + Config.display.nightlight.scheduleEnd = endCurrent; + Nightlight.checkStartup(); + } + } + + ToggleRow { Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - checked: Config.general.color.scheduleHyprsunset + + checked: Config.display.nightlight.useNative + settingAnchor: "display-nightlight-useNative" + text: qsTr("Use native") + subtext: qsTr("Use native shader nightlight for more features") + + onToggled: Config.display.nightlight.useNative = checked + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 2000 + settingAnchor: "display-nightlight-temp" + stepSize: 100 + subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)") + text: qsTr("Temperature") + to: 6500 + value: Config.display.nightlight.temp + + onMoved: v => Config.display.nightlight.temp = v + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 0 + settingAnchor: "display-nightlight-fade-duration" + enabled: Config.display.nightlight.useNative last: true - settingAnchor: "style-schedule-hyprsunset" - subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule hyprsunset") + stepSize: 1 + subtext: qsTr("How long it takes for the gradual nightlight to finish (s)") + text: qsTr("Fade duration") + to: 60 * 20 + value: Config.display.nightlight.fadeDuration / 1000 - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleHyprsunsetStart = start; - Config.general.color.scheduleHyprsunsetEnd = end; - Hyprsunset.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } - } - - onClicked: value => { - Config.general.color.scheduleHyprsunset = value; - } + onMoved: v => Config.display.nightlight.fadeDuration = v * 1000 } } } diff --git a/Modules/Settings/PopupManager.qml b/Modules/Settings/PopupManager.qml deleted file mode 100644 index 4b37c02..0000000 --- a/Modules/Settings/PopupManager.qml +++ /dev/null @@ -1,20 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick - -Singleton { - id: root - - property bool closed: true - property Component currentPopup: null - - function requestClose(): void { - closed = true; - } - - function requestOpen(component: Component): void { - currentPopup = component; - closed = false; - } -} diff --git a/Modules/Settings/PopupOverlay.qml b/Modules/Settings/PopupOverlay.qml deleted file mode 100644 index d18440e..0000000 --- a/Modules/Settings/PopupOverlay.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property bool closing: PopupManager.closed - readonly property var currentPopup: PopupManager.currentPopup - property bool shouldBeVisible: loader.status === Loader.Ready - - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: shouldBeVisible && !closing ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - - onVisibleChanged: if (!visible) - PopupManager.currentPopup = null - - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - propagateComposedEvents: false - - onClicked: { - const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; - const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; - if (insideItemHeight && insideItemWidth) - return; - - PopupManager.requestClose(); - } - } - - Loader { - id: loader - - anchors.centerIn: parent - sourceComponent: root.currentPopup - } -} diff --git a/Modules/Settings/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/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index 79e80d8..11933e1 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -16,6 +16,7 @@ QtObject { property string searchText property DesktopEntry selectedApp property BluetoothDevice selectedBtDevice + property bool dimmed property string selectedWallpaperCategory property list subPageIdxStack diff --git a/Modules/Settings/Wrapper.qml b/Modules/Settings/Wrapper.qml index 6de8131..a096857 100644 --- a/Modules/Settings/Wrapper.qml +++ b/Modules/Settings/Wrapper.qml @@ -12,8 +12,8 @@ Item { required property var panels property string queuedMode: "wallpaper" required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.settings - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.settings + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index 68c6662..1d11eb9 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import qs.Components import qs.Helpers +import qs.Services Scope { id: root @@ -10,6 +11,18 @@ Scope { readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false property bool launcherInterrupted + CustomShortcut { + description: "Open screenshot" + name: "screenshot" + + onPressed: { + const states = ShellState.allStates(); + for (const s of states) { + s.screenshot = true; + } + } + } + CustomShortcut { description: "Toggle launcher" name: "toggle-launcher" @@ -17,55 +30,60 @@ Scope { onPressed: { root.launcherInterrupted = false; if (!root.launcherInterrupted && !root.hasFullscreen) { - const visibilities = Visibilities.getForActive(); - visibilities.launcher = !visibilities.launcher; + const state = ShellState.forActive(); + state.launcher = !state.launcher; } root.launcherInterrupted = false; } } CustomShortcut { + description: "Toggle drawing" name: "toggle-drawing" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.isDrawing = !visibilities.isDrawing; + const state = ShellState.forActive(); + state.isDrawing = !state.isDrawing; } } CustomShortcut { + description: "Toggle sidebar" name: "toggle-nc" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.sidebar = !visibilities.sidebar; + const state = ShellState.forActive(); + state.sidebar = !state.sidebar; } } CustomShortcut { + description: "Show On-Screen-Display" name: "show-osd" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.osd = !visibilities.osd; + const state = ShellState.forActive(); + state.osd = !state.osd; } } CustomShortcut { + description: "Toggle settings" name: "toggle-settings" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = !visibilities.settings; + const state = ShellState.forActive(); + state.settings = !state.settings; } } CustomShortcut { + description: "Toggle clipboard" name: "toggle-clipboard" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.clipboard = !visibilities.clipboard; + const state = ShellState.forActive(); + state.clipboard = !state.clipboard; } } } diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml deleted file mode 100644 index 76e44dd..0000000 --- a/Modules/Wallpaper/WallBackground.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Hyprland -import QtQuick -import qs.Components -import qs.Helpers -import ZShell.Internal - -Item { - id: root - - readonly property var displayData: Wallpapers.getCrop(screen.name) - required property ShellScreen screen - property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale) - property real screenScale: Hyprland.monitorFor(screen).scale - - anchors.fill: parent - - Component.onCompleted: Hyprland.refreshMonitors() - - WallpaperImage { - id: img - - anchors.fill: parent - cropHeight: root.displayData?.height ?? 1.0 - cropWidth: root.displayData?.width ?? 1.0 - cropX: root.displayData?.x ?? 0.0 - cropY: root.displayData?.y ?? 0.0 - fadeDuration: 150 - screenResolution: root.screenResolution - source: Wallpapers.current - - Behavior on cropHeight { - Anim { - } - } - Behavior on cropWidth { - Anim { - } - } - Behavior on cropX { - Anim { - } - } - Behavior on cropY { - Anim { - } - } - } -} diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml deleted file mode 100644 index 9de4551..0000000 --- a/Modules/Wallpaper/Wallpaper.qml +++ /dev/null @@ -1,47 +0,0 @@ -import Quickshell -import QtQuick -import Quickshell.Wayland -import ZShell.Config -import qs.Modules.DesktopIcons - -Loader { - active: Config.background.enabled - asynchronous: false - - sourceComponent: Variants { - model: Quickshell.screens - - PanelWindow { - id: root - - required property var modelData - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Background - WlrLayershell.namespace: "ZShell-Wallpaper" - color: "transparent" - screen: modelData - - anchors { - bottom: true - left: true - right: true - top: true - } - - WallBackground { - screen: root.screen - } - - Loader { - id: loader - - active: Config.general.desktopIcons - anchors.fill: parent - - sourceComponent: DesktopIcons { - } - } - } - } -} diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 4a2ff2f..43f93e5 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1 +1,45 @@ +find_package(Qt6 REQUIRED COMPONENTS + ShaderTools + Core + Qml + Gui + GuiPrivate + Quick + CanvasPainter + QuickPrivate + Concurrent + Sql + Network + DBus + CanvasPainter + WaylandClient + WaylandClientPrivate +) +find_package(PkgConfig REQUIRED) +pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) +pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) +pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) +pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) +pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) +pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) +pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) +pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) +pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0) + +if(NOT Cava_FOUND) + pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) +endif() + +include(cmake/sensorslib.cmake) + +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") +qt_standard_project_setup(REQUIRES 6.9) + +include(cmake/pch.cmake) +include(cmake/qml-module.cmake) +include(cmake/wayland-protocol.cmake) + +add_library(zshell-util INTERFACE) +target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") + add_subdirectory(ZShell) diff --git a/Plugins/ZShell/Blobs/CMakeLists.txt b/Plugins/ZShell/Blobs/CMakeLists.txt index 05ec0d9..32798f5 100644 --- a/Plugins/ZShell/Blobs/CMakeLists.txt +++ b/Plugins/ZShell/Blobs/CMakeLists.txt @@ -1,20 +1,24 @@ qml_module(ZShell-blobs URI ZShell.Blobs - SOURCES - blobgroup.cpp - blobshape.cpp - blobrect.cpp - blobinvertedrect.cpp - blobmaterial.cpp - LIBRARIES - Qt::Quick + SOURCES + blobgroup.cpp + blobshape.cpp + blobrect.cpp + blobinvertedrect.cpp + blobgroup2.cpp + blobshape2.cpp + blobrect2.cpp + blobinvertedrect2.cpp + blobmaterial.cpp + LIBRARIES + Qt::Quick ) qt_add_shaders(ZShell-blobs "blob_shaders" - BATCHABLE OPTIMIZED NOHLSL NOMSL + BATCHABLE OPTIMIZED NOHLSL NOMSL GLSL "300es,330" - PREFIX "/" - FILES - shaders/blob.frag - shaders/blob.vert + PREFIX "/" + FILES + shaders/blob.frag + shaders/blob.vert ) diff --git a/Plugins/ZShell/Blobs/blobgroup2.cpp b/Plugins/ZShell/Blobs/blobgroup2.cpp new file mode 100644 index 0000000..0f642ed --- /dev/null +++ b/Plugins/ZShell/Blobs/blobgroup2.cpp @@ -0,0 +1,387 @@ +#include "blobgroup2.hpp" +#include "blobinvertedrect2.hpp" +#include "blobshape2.hpp" + +#include +#include + +#include +#include +#include + +static float cpuSdBox( + float px, float py, float cx, float cy, float hw, float hh) { + const float dx = std::abs(px - cx) - hw; + const float dy = std::abs(py - cy) - hh; + const float mdx = std::max(dx, 0.0f); + const float mdy = std::max(dy, 0.0f); + return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f); +} + +static float cpuSmoothstep(float edge0, float edge1, float x) { + const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +static float cornerFillFactor(float sd, float smoothFactor) { + const float outside = cpuSmoothstep(0.0f, smoothFactor, sd); + const float inside = cpuSmoothstep(0.0f, -smoothFactor, sd); + return std::max(outside, inside); +} + +BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) { + setFlag(ItemHasContents); +} + +BlobGroup2::~BlobGroup2() { + for (auto* shape : std::as_const(m_shapes)) + shape->m_group = nullptr; + if (m_invertedRect) + static_cast(m_invertedRect)->m_group = nullptr; +} + +void BlobGroup2::setSmoothing(qreal s) { + if (qFuzzyCompare(m_smoothing, s)) return; + m_smoothing = s; + emit smoothingChanged(); + markDirty(); +} + +void BlobGroup2::setColor(const QColor& c) { + if (m_color == c) return; + m_color = c; + emit colorChanged(); + markDirty(); +} + +void BlobGroup2::setCornerFill(bool e) { + if (m_cornerFill == e) return; + m_cornerFill = e; + emit cornerFillChanged(); + markDirty(); +} + +void BlobGroup2::setShadowColor(const QColor& c) { + if (m_shadowColor == c) return; + m_shadowColor = c; + emit shadowColorChanged(); + markDirty(); +} + +void BlobGroup2::setShadowRadius(qreal r) { + if (qFuzzyCompare(m_shadowRadius, r)) return; + m_shadowRadius = r; + emit shadowRadiusChanged(); + markDirty(); +} + +void BlobGroup2::addShape(BlobShape2* shape) { + if (!shape || m_shapes.contains(shape)) return; + m_shapes.append(shape); + markDirty(); +} + +void BlobGroup2::removeShape(BlobShape2* shape) { + m_shapes.removeOne(shape); + markDirty(); +} + +void BlobGroup2::setInvertedRect(BlobInvertedRect2* rect) { + if (m_invertedRect == rect) return; + m_invertedRect = rect; + markDirty(); +} + +void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) { + if (m_invertedRect != rect) return; + m_invertedRect = nullptr; + markDirty(); +} + +void BlobGroup2::markDirty() { + m_physicsUpdated = false; + m_dataBuilt = false; + // Physics runs during polish, before the render pass. The panel content is + // transformed by deformMatrix from QML, so the matrix has to be final for this + // frame; building the data in updatePaintNode would let the transform lag one + // frame behind the material. + polish(); + update(); +} + +void BlobGroup2::updatePolish() { + // The group is the only thing that draws, so it also drives the shapes' + // physics. + ensureDataBuilt(); +} + +void BlobGroup2::ensurePhysicsUpdated() { + if (m_physicsUpdated) return; + m_physicsUpdated = true; + for (auto* shape : std::as_const(m_shapes)) + shape->updatePhysics(); +} + +void BlobGroup2::ensureDataBuilt() { + if (m_dataBuilt) return; + ensurePhysicsUpdated(); + + // Physics can request the next frame through markDirty(); the data is + // refreshed here anyway, so the flag is set after it runs. + m_dataBuilt = true; + m_data.rects.clear(); + m_bounds = QRectF(); + + const float smoothFactor = static_cast(m_smoothing); + // The same reach the shader uses to decide whether a rect can contribute, plus + // the shadow, so every pixel the shader can give a value to is inside the node. + const double extent = static_cast(smoothFactor) * 1.5 + + static_cast(m_shadowRadius); + + // Group order, no pruning by proximity: the shader sees this list for every pixel. A + // shape that is not pushing is not in the list at all: shapes here are data for the + // group draw, so leaving a panel at rest out means it cannot define any part of the + // edge. A closing panel is still in the list while its curve runs out, even with no + // size left, which is what keeps the bounce. + // entryShapes maps a shared entry back to the shape it came from. + QVector entryShapes; + + for (auto* shape : std::as_const(m_shapes)) { + if (!shape->isMoving()) continue; + if (shape->width() <= 0 || shape->height() <= 0) continue; + if (static_cast(m_data.rects.size()) >= BlobGroupData::kMaxRects) + continue; + + const QPointF scene = shape->mapToScene(QPointF(0, 0)); + const QMatrix4x4& dm = shape->m_deformMatrix; + + BlobRectData r; + r.cx = static_cast(scene.x() + shape->width() / 2.0); + r.cy = static_cast(scene.y() + shape->height() / 2.0); + r.hw = static_cast(shape->width()) * 0.5f; + r.hh = static_cast(shape->height()) * 0.5f; + shape->cornerRadii(r.radius); + r.offsetX = dm(0, 3); + r.offsetY = dm(1, 3); + + const float a = dm(0, 0), b = dm(1, 0); + const float c = dm(0, 1), e = dm(1, 1); + const float det = a * e - c * b; + const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; + 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 + 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(e) * r.hh; + + m_data.rects.append(r); + m_bounds = m_bounds.united(QRectF( + static_cast(r.cx) + static_cast(r.offsetX) - + static_cast(r.screenHalfX) - extent, + static_cast(r.cy) + static_cast(r.offsetY) - + static_cast(r.screenHalfY) - extent, + 2.0 * (static_cast(r.screenHalfX) + extent), + 2.0 * (static_cast(r.screenHalfY) + extent))); + + entryShapes.append(shape); + } + + const int count = static_cast(m_data.rects.size()); + + // Exclusion gates only the smooth blend. + for (int i = 0; i < count; ++i) { + int mask = 0; + BlobShape2* const si = entryShapes[i]; + for (int j = 0; j < count; ++j) { + if (j == i) continue; + BlobShape2* const sj = entryShapes[j]; + if (si->isExcluded(sj) || sj->isExcluded(si)) mask |= (1 << j); + } + m_data.rects[i].excludeMask = mask; + } + + constexpr float minR = 2.0f; + const bool cornerFill = m_cornerFill; + + for (int i = 0; i < count; ++i) { + auto& ri = m_data.rects[i]; + BlobShape2* const si = entryShapes[i]; + float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; + + const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; + const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; + const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; + const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + + if (cornerFill) { + for (int j = 0; j < count; ++j) { + if (j == i) continue; + if (ri.excludeMask & (1 << j)) continue; + BlobShape2* const sj = entryShapes[j]; + if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) + continue; + const auto& rj = m_data.rects[j]; + fTr = std::min( + fTr, + cornerFillFactor( + cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fBr = std::min( + fBr, + cornerFillFactor( + cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fBl = std::min( + fBl, + cornerFillFactor( + cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fTl = std::min( + fTl, + cornerFillFactor( + cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + } + } + + ri.radius[0] = std::max(ri.radius[0] * fTr, minR); + ri.radius[1] = std::max(ri.radius[1] * fBr, minR); + ri.radius[2] = std::max(ri.radius[2] * fBl, minR); + ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + } + + // Inverted rect. The frame term is evaluated for every pixel in the bounds, + // so the group flag cannot be gated per shape. + if (!m_invertedRect) return; + + auto* inv = static_cast(m_invertedRect); + if (inv->width() <= 0 || inv->height() <= 0) return; + + const QPointF invScene = inv->mapToScene(QPointF(0, 0)); + const float outerCX = static_cast(invScene.x() + inv->width() / 2.0); + const float outerCY = + static_cast(invScene.y() + inv->height() / 2.0); + const float outerHW = static_cast(inv->width()) * 0.5f; + const float outerHH = static_cast(inv->height()) * 0.5f; + + const auto* invRect = m_invertedRect; + const float innerCX = + outerCX + static_cast( + (invRect->borderLeft() - invRect->borderRight()) / 2.0); + const float innerCY = + outerCY + static_cast( + (invRect->borderTop() - invRect->borderBottom()) / 2.0); + const float innerHW = + outerHW - static_cast( + (invRect->borderLeft() + invRect->borderRight()) / 2.0); + const float innerHH = + outerHH - static_cast( + (invRect->borderTop() + invRect->borderBottom()) / 2.0); + + m_data.hasInverted = true; + m_data.invertedRadius = static_cast(invRect->radius()); + + m_data.invertedOuter[0] = outerCX; + m_data.invertedOuter[1] = outerCY; + m_data.invertedOuter[2] = outerHW; + m_data.invertedOuter[3] = outerHH; + + m_data.invertedInner[0] = innerCX; + m_data.invertedInner[1] = innerCY; + m_data.invertedInner[2] = innerHW; + m_data.invertedInner[3] = innerHH; + + m_bounds = m_bounds.united(QRectF( + static_cast(outerCX) - static_cast(outerHW) - extent, + static_cast(outerCY) - static_cast(outerHH) - extent, + 2.0 * (static_cast(outerHW) + extent), + 2.0 * (static_cast(outerHH) + extent))); +} + +void BlobGroup2::fillMaterial(BlobMaterial* material) { + material->m_paddedX = static_cast(m_bounds.x()); + material->m_paddedY = static_cast(m_bounds.y()); + material->m_paddedW = static_cast(m_bounds.width()); + material->m_paddedH = static_cast(m_bounds.height()); + + material->m_smoothFactor = static_cast(m_smoothing); + // The single group draw has no owner, so the shader skips the ownership test. + material->m_myIndex = -2; + material->m_color = m_color; + material->m_hasInverted = m_data.hasInverted ? 1 : 0; + material->m_invertedRadius = data().invertedRadius; + memcpy( + material->m_invertedOuter, + data().invertedOuter, + sizeof(data().invertedOuter)); + memcpy( + material->m_invertedInner, + data().invertedInner, + sizeof(data().invertedInner)); + + material->m_shadowRadius = static_cast(m_shadowRadius); + material->m_shadowColor[0] = static_cast(m_shadowColor.redF()); + material->m_shadowColor[1] = static_cast(m_shadowColor.greenF()); + material->m_shadowColor[2] = static_cast(m_shadowColor.blueF()); + material->m_shadowColor[3] = static_cast(m_shadowColor.alphaF()); + + const int count = + static_cast(qMin(data().rects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = data().rects[i]; +} + +QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + // Data is built during polish. This only covers a render pass that happens + // without one. + ensureDataBuilt(); + + if (m_data.rects.isEmpty() && !m_data.hasInverted) { + delete oldNode; + return nullptr; + } + + auto* node = static_cast(oldNode); + if (!node) { + node = new QSGGeometryNode; + + auto* geometry = + new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // The shader evaluates the field at the scene position a vertex maps to, so + // the group item itself must not be transformed. The vertex positions are the + // bounds in this item's frame; the material carries the same rect in scene + // coordinates. + auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); + const QPointF p0 = mapFromScene(m_bounds.topLeft()); + const QPointF p1 = mapFromScene(m_bounds.bottomRight()); + + v[0].set(static_cast(p0.x()), static_cast(p0.y()), 0.0f, 0.0f); + v[1].set(static_cast(p1.x()), static_cast(p0.y()), 1.0f, 0.0f); + v[2].set(static_cast(p0.x()), static_cast(p1.y()), 0.0f, 1.0f); + v[3].set(static_cast(p1.x()), static_cast(p1.y()), 1.0f, 1.0f); + + node->markDirty(QSGNode::DirtyGeometry); + + fillMaterial(static_cast(node->material())); + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} diff --git a/Plugins/ZShell/Blobs/blobgroup2.hpp b/Plugins/ZShell/Blobs/blobgroup2.hpp new file mode 100644 index 0000000..9f922e6 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobgroup2.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include "blobmaterial.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +class BlobShape2; +class BlobInvertedRect2; + +// One shared valuation for a whole group, drawn by one node. +// +// The shapes here are data only: they say where the material should be. The group +// draws a single quad over everything the shader can evaluate, so a pixel cannot +// be evaluated by one shape and missed by another, and no second draw can +// disagree with the first. That is what makes overlapping shapes read as one +// surface - one value, one writer, one alpha per pixel. +struct BlobGroupData { + // The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit + // mask. The shared list is capped at 16, in group order, so the cap is the + // same list for every pixel. + static constexpr int kMaxRects = 16; + + QVector rects; + bool hasInverted = false; + float invertedRadius = 0; + float invertedOuter[4] = {}; + float invertedInner[4] = {}; +}; + +class BlobGroup2 : public QQuickItem { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + Q_PROPERTY( + bool cornerFill READ cornerFill WRITE setCornerFill NOTIFY + cornerFillChanged) + Q_PROPERTY( + QColor shadowColor READ shadowColor WRITE setShadowColor NOTIFY + shadowColorChanged) + Q_PROPERTY( + qreal shadowRadius READ shadowRadius WRITE setShadowRadius NOTIFY + shadowRadiusChanged) + + friend class BlobShape2; + + public: + explicit BlobGroup2(QQuickItem* parent = nullptr); + ~BlobGroup2() override; + + [[nodiscard]] qreal smoothing() const { return m_smoothing; } + + void setSmoothing(qreal s); + + [[nodiscard]] QColor color() const { return m_color; } + + void setColor(const QColor& c); + + [[nodiscard]] bool cornerFill() const { return m_cornerFill; } + + void setCornerFill(bool e); + + [[nodiscard]] QColor shadowColor() const { return m_shadowColor; } + + void setShadowColor(const QColor& c); + + [[nodiscard]] qreal shadowRadius() const { return m_shadowRadius; } + + void setShadowRadius(qreal r); + + void addShape(BlobShape2* shape); + void removeShape(BlobShape2* shape); + + void setInvertedRect(BlobInvertedRect2* rect); + void clearInvertedRect(BlobInvertedRect2* rect); + + [[nodiscard]] const QList& shapes() const { return m_shapes; } + + [[nodiscard]] BlobInvertedRect2* invertedRect() const { + return m_invertedRect; + } + + [[nodiscard]] const BlobGroupData& data() const { return m_data; } + + // One draw per group, so a change to any shape changes the value for every + // pixel. Refresh the whole group. + void markDirty(); + + signals: + void smoothingChanged(); + void colorChanged(); + void cornerFillChanged(); + void shadowColorChanged(); + void shadowRadiusChanged(); + + protected: + void updatePolish() override; + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + private: + void ensurePhysicsUpdated(); + void ensureDataBuilt(); + void fillMaterial(BlobMaterial* material); + + qreal m_smoothing = 32.0; + QColor m_color{0x44, 0x88, 0xff}; + bool m_cornerFill = true; + QColor m_shadowColor{0, 0, 0, 128}; + qreal m_shadowRadius = 0.0; + QList m_shapes; + BlobInvertedRect2* m_invertedRect = nullptr; + bool m_physicsUpdated = false; + bool m_dataBuilt = false; + BlobGroupData m_data; + + // Scene-space union of what the shader can evaluate for any shape. The node + // covers exactly this, so gaps are not possible. + QRectF m_bounds; +}; diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.cpp b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp new file mode 100644 index 0000000..2972cf2 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp @@ -0,0 +1,44 @@ +#include "blobinvertedrect2.hpp" +#include "blobgroup2.hpp" + +BlobInvertedRect2::BlobInvertedRect2(QQuickItem* parent) : BlobShape2(parent) {} + +BlobInvertedRect2::~BlobInvertedRect2() { + if (m_group) m_group->clearInvertedRect(this); +} + +void BlobInvertedRect2::setBorderLeft(qreal v) { + if (qFuzzyCompare(m_borderLeft, v)) return; + m_borderLeft = v; + emit borderLeftChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderRight(qreal v) { + if (qFuzzyCompare(m_borderRight, v)) return; + m_borderRight = v; + emit borderRightChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderTop(qreal v) { + if (qFuzzyCompare(m_borderTop, v)) return; + m_borderTop = v; + emit borderTopChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderBottom(qreal v) { + if (qFuzzyCompare(m_borderBottom, v)) return; + m_borderBottom = v; + emit borderBottomChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::registerWithGroup() { + if (m_group) m_group->setInvertedRect(this); +} + +void BlobInvertedRect2::unregisterFromGroup() { + if (m_group) m_group->clearInvertedRect(this); +} diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.hpp b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp new file mode 100644 index 0000000..4b7fb85 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "blobshape2.hpp" + +#include + +class BlobInvertedRect2 : public BlobShape2 { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY + borderLeftChanged) + Q_PROPERTY( + qreal borderRight READ borderRight WRITE setBorderRight NOTIFY + borderRightChanged) + Q_PROPERTY( + qreal borderTop READ borderTop WRITE setBorderTop NOTIFY + borderTopChanged) + Q_PROPERTY( + qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY + borderBottomChanged) + + public: + explicit BlobInvertedRect2(QQuickItem* parent = nullptr); + ~BlobInvertedRect2() override; + + [[nodiscard]] qreal borderLeft() const { return m_borderLeft; } + + void setBorderLeft(qreal v); + + [[nodiscard]] qreal borderRight() const { return m_borderRight; } + + void setBorderRight(qreal v); + + [[nodiscard]] qreal borderTop() const { return m_borderTop; } + + void setBorderTop(qreal v); + + [[nodiscard]] qreal borderBottom() const { return m_borderBottom; } + + void setBorderBottom(qreal v); + + signals: + void borderLeftChanged(); + void borderRightChanged(); + void borderTopChanged(); + void borderBottomChanged(); + + protected: + void registerWithGroup() override; + void unregisterFromGroup() override; + + private: + qreal m_borderLeft = 0; + qreal m_borderRight = 0; + qreal m_borderTop = 0; + qreal m_borderBottom = 0; +}; diff --git a/Plugins/ZShell/Blobs/blobmaterial.cpp b/Plugins/ZShell/Blobs/blobmaterial.cpp index cdd5cd7..528d617 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.cpp +++ b/Plugins/ZShell/Blobs/blobmaterial.cpp @@ -33,7 +33,7 @@ bool BlobMaterialShader::updateUniformData( Q_UNUSED(oldMaterial); auto* mat = static_cast(newMaterial); QByteArray* buf = state.uniformData(); - Q_ASSERT(buf->size() >= 1440); + Q_ASSERT(buf->size() >= 1472); if (state.isMatrixDirty()) { const QMatrix4x4 m = state.combinedMatrix(); @@ -56,7 +56,7 @@ bool BlobMaterialShader::updateUniformData( // Rect count (offset 88) memcpy(buf->data() + 88, &mat->m_rectCount, 4); - // My index (offset 92) + // My index (offset 92). -2 skips the ownership test in the shader. memcpy(buf->data() + 92, &mat->m_myIndex, 4); // Color as vec4 (offset 96, 16 bytes) @@ -100,5 +100,10 @@ bool BlobMaterialShader::updateUniformData( memcpy(buf->data() + base + 64, r.radius, 16); } + // Shadow: after rectData, which ends at 1440. shadowColor is 16-byte aligned, + // so it lands at 1456. + memcpy(buf->data() + 1440, &mat->m_shadowRadius, 4); + memcpy(buf->data() + 1456, mat->m_shadowColor, 16); + return true; } diff --git a/Plugins/ZShell/Blobs/blobmaterial.hpp b/Plugins/ZShell/Blobs/blobmaterial.hpp index 4a30448..19536a5 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.hpp +++ b/Plugins/ZShell/Blobs/blobmaterial.hpp @@ -32,12 +32,17 @@ class BlobMaterial : public QSGMaterial { float m_paddedH = 0; float m_smoothFactor = 32.0f; int m_rectCount = 0; + // -2 means no owner test: the single group draw writes every pixel it covers. int m_myIndex = -2; QColor m_color{0x44, 0x88, 0xff}; int m_hasInverted = 0; float m_invertedRadius = 0; float m_invertedOuter[4] = {}; float m_invertedInner[4] = {}; + // Phase 2: shadow around the material edges. Matches the uniform block, which + // puts these after rectData. + float m_shadowRadius = 0.0f; + float m_shadowColor[4] = {0.0f, 0.0f, 0.0f, 0.5f}; BlobRectData m_rects[16] = {}; }; diff --git a/Plugins/ZShell/Blobs/blobrect.cpp b/Plugins/ZShell/Blobs/blobrect.cpp index f7dc72c..3100604 100644 --- a/Plugins/ZShell/Blobs/blobrect.cpp +++ b/Plugins/ZShell/Blobs/blobrect.cpp @@ -5,6 +5,10 @@ #include #include +namespace { +constexpr float k_retireMs = 120.0f; +} + BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {} BlobRect::~BlobRect() { @@ -12,41 +16,75 @@ BlobRect::~BlobRect() { } void BlobRect::updatePolish() { - if (m_physicsActive) { - float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + - std::abs(m_dm11 - 1.0f); - float totalVel = - std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + BlobShape::updatePolish(); - if (totalDelta < 0.004f && totalVel < 0.05f) { + if (!m_physicsActive) return; + + if (m_retiring) { + const float t = std::clamp( + static_cast(m_retireElapsed.elapsed()) / k_retireMs, + 0.0f, + 1.0f); + const float s = t * t * (3.0f - 2.0f * t); + + if (t >= 1.0f) { m_dm00 = 1.0f; m_dm01 = 0.0f; m_dm11 = 1.0f; - m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; m_deformMatrix = QMatrix4x4(); - emit rawDeformMatrixChanged(); - updateCenteredDeformMatrix(); + m_retiring = false; m_physicsActive = false; - - if (m_group) { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); - } } else { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_physicsActive && m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + m_deformMatrix = QMatrix4x4( + m_dm00, + m_dm01, + 0, + 0, + m_dm01, + m_dm11, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1); } + + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + if (!m_physicsActive) return; + } else { + const float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (totalDelta < 0.004f && totalVel < 0.05f) beginRetire(); } - BlobShape::updatePolish(); + QMetaObject::invokeMethod( + this, + [this]() { + if (m_physicsActive && m_group) m_group->markDirty(); + }, + Qt::QueuedConnection); +} + +void BlobRect::beginRetire() { + m_retire00 = m_dm00; + m_retire01 = m_dm01; + m_retire11 = m_dm11; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retireElapsed.start(); + m_retiring = true; } void BlobRect::updatePhysics() { @@ -74,6 +112,13 @@ void BlobRect::updatePhysics() { const float speed = std::sqrt(velX * velX + velY * velY); + if (m_retiring) { + if (speed > 5.0f) + m_retiring = false; + else + return; + } + if (!m_physicsActive) { if (speed < 5.0f) return; m_physicsActive = true; @@ -297,6 +342,8 @@ void BlobRect::excludeCornersRemoveLast(QQmlListProperty* prop) { } void BlobRect::checkAtRest(float speed) { + if (m_retiring) return; + constexpr float kEpsilon = 0.002f; const bool atRest = std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && @@ -304,25 +351,5 @@ void BlobRect::checkAtRest(float speed) { std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && speed < 5.0f; - if (atRest) { - m_dm00 = 1.0f; - m_dm01 = 0.0f; - m_dm11 = 1.0f; - m_dmVel00 = 0.0f; - m_dmVel01 = 0.0f; - m_dmVel11 = 0.0f; - m_deformMatrix = QMatrix4x4(); - emit rawDeformMatrixChanged(); - updateCenteredDeformMatrix(); - m_physicsActive = false; - - if (m_group) { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); - } - } + if (atRest) beginRetire(); } diff --git a/Plugins/ZShell/Blobs/blobrect.hpp b/Plugins/ZShell/Blobs/blobrect.hpp index 51a6157..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(); } } @@ -108,20 +108,23 @@ class BlobRect : public BlobShape { private: void checkAtRest(float speed); + void beginRetire(); - // Physics state QPointF m_prevScenePos; QElapsedTimer m_elapsed; bool m_physicsActive = false; bool m_hasPrevPos = false; - // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, - // m11) Rest state is identity: m00=1, m01=0, m11=1 + bool m_retiring = false; + QElapsedTimer m_retireElapsed; + float m_retire00 = 1.0f; + float m_retire01 = 0.0f; + float m_retire11 = 1.0f; + 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/blobrect2.cpp b/Plugins/ZShell/Blobs/blobrect2.cpp new file mode 100644 index 0000000..3fdfa28 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobrect2.cpp @@ -0,0 +1,353 @@ +#include "blobrect2.hpp" +#include "blobgroup2.hpp" + +#include +#include + +namespace { +constexpr float k_retireMs = 120.0f; +} // namespace + +BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {} + +BlobRect2::~BlobRect2() { + if (m_group) m_group->removeShape(this); +} + +void BlobRect2::finishAtIdentity() { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + m_retiring = false; + m_physicsActive = false; + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); +} + +void BlobRect2::beginRetire() { + // The settle check would otherwise re-enter this every frame and restart the + // curve timer, so the curve never reaches t = 1. + if (m_retiring) return; + + m_retire00 = m_dm00; + m_retire01 = m_dm01; + m_retire11 = m_dm11; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retireElapsed.start(); + m_retiring = true; +} + +void BlobRect2::updatePhysics() { + // Shapes are data only, so the group drives physics here. + if (m_retiring) { + const float t = std::clamp( + static_cast(m_retireElapsed.elapsed()) / k_retireMs, + 0.0f, + 1.0f); + const float s = t * t * (3.0f - 2.0f * t); + + if (t >= 1.0f) { + finishAtIdentity(); + return; + } + + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + if (!m_physicsActive) return; + } + + // No "not active" guard here: an inactive shape still has to be evaluated so it + // can see the panel start moving and become active. The frame loop is only kept + // alive while active, and it is requested before anything can return early — + // requesting it at the end lets a shape stop being evaluated with the matrix + // still off identity, which is what left the panel contents blurry. + if (m_physicsActive && m_group) m_group->markDirty(); + + const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); + + if (!m_hasPrevPos) { + m_prevScenePos = scenePos; + m_elapsed.start(); + m_hasPrevPos = true; + return; + } + + const float dt = static_cast(m_elapsed.restart()) / 1000.0f; + if (dt > 0.1f || dt < 0.001f) { + m_prevScenePos = scenePos; + if (m_physicsActive) checkAtRest(0.0f); + return; + } + + const float velX = + static_cast(scenePos.x() - m_prevScenePos.x()) / dt; + const float velY = + static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + m_prevScenePos = scenePos; + + const float speed = std::sqrt(velX * velX + velY * velY); + + if (m_retiring) { + if (speed > 5.0f) + m_retiring = false; + else + return; + } + + if (!m_physicsActive) { + if (speed < 5.0f) return; + m_physicsActive = true; + } + + const float kStretchFactor = static_cast(m_deformScale); + constexpr float kMaxStretch = 0.35f; + + float target00 = 1.0f; + float target01 = 0.0f; + float target11 = 1.0f; + + if (speed > 5.0f) { + const float targetStretch = + 1.0f + std::min(speed * kStretchFactor, kMaxStretch); + const float targetCompress = 1.0f / targetStretch; + + const float cosA = velX / speed; + const float sinA = velY / speed; + const float cos2 = cosA * cosA; + const float sin2 = sinA * sinA; + const float cs = cosA * sinA; + + target00 = targetStretch * cos2 + targetCompress * sin2; + target01 = (targetStretch - targetCompress) * cs; + target11 = targetStretch * sin2 + targetCompress * cos2; + } + + const float kStiffness = static_cast(m_stiffness); + const float kDamping = static_cast(m_damping); + const float invDamp = 1.0f / (1.0f + kDamping * dt); + + m_dmVel00 = (m_dmVel00 - kStiffness * (m_dm00 - target00) * dt) * invDamp; + m_dm00 += m_dmVel00 * dt; + + m_dmVel01 = (m_dmVel01 - kStiffness * (m_dm01 - target01) * dt) * invDamp; + m_dm01 += m_dmVel01 * dt; + + m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp; + m_dm11 += m_dmVel11 * dt; + + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + // Start the retire as soon as the matrix is close to identity, as in v1. Waiting + // for every term to fall below kEpsilon keeps the matrix just off identity for + // longer, and the panel content is transformed by it, which reads as blur on + // text. + const float totalDelta = + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + // The residual is already inside the rest epsilon, so the eased curve would only + // be aborted or restarted every frame by the panel creeping. Snap instead. + if (totalDelta < 0.004f && totalVel < 0.05f) { + finishAtIdentity(); + return; + } + + checkAtRest(speed); +} + +void BlobRect2::setTopLeftRadius(qreal r) { + if (!qFuzzyCompare(m_topLeftRadius, r)) { + m_topLeftRadius = r; + emit topLeftRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setTopRightRadius(qreal r) { + if (!qFuzzyCompare(m_topRightRadius, r)) { + m_topRightRadius = r; + emit topRightRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setBottomLeftRadius(qreal r) { + if (!qFuzzyCompare(m_bottomLeftRadius, r)) { + m_bottomLeftRadius = r; + emit bottomLeftRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setBottomRightRadius(qreal r) { + if (!qFuzzyCompare(m_bottomRightRadius, r)) { + m_bottomRightRadius = r; + emit bottomRightRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto base = std::min(static_cast(m_radius), maxR); + out[0] = std::min( + m_topRightRadius >= 0 ? static_cast(m_topRightRadius) : base, + maxR); + out[1] = std::min( + m_bottomRightRadius >= 0 ? static_cast(m_bottomRightRadius) + : base, + maxR); + out[2] = std::min( + m_bottomLeftRadius >= 0 ? static_cast(m_bottomLeftRadius) : base, + maxR); + out[3] = std::min( + m_topLeftRadius >= 0 ? static_cast(m_topLeftRadius) : base, + maxR); +} + +bool BlobRect2::isExcluded(const BlobShape2* other) const { + for (const auto& ptr : m_exclude) { + if (ptr == other) return true; + } + return false; +} + +bool BlobRect2::isCornerExcluded(const BlobShape2* other) const { + for (const auto& ptr : m_excludeCorners) { + if (ptr == other) return true; + } + return false; +} + +QQmlListProperty BlobRect2::exclude() { + return QQmlListProperty( + this, + nullptr, + &excludeAppend, + &excludeCount, + &excludeAt, + &excludeClear, + &excludeReplace, + &excludeRemoveLast); +} + +QQmlListProperty BlobRect2::excludeCorners() { + return QQmlListProperty( + this, + nullptr, + &excludeCornersAppend, + &excludeCornersCount, + &excludeCornersAt, + &excludeCornersClear, + &excludeCornersReplace, + &excludeCornersRemoveLast); +} + +void BlobRect2::excludeAppend( + QQmlListProperty* prop, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_exclude.append(rect); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +qsizetype BlobRect2::excludeCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_exclude.size(); +} + +BlobRect2* BlobRect2::excludeAt( + QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_exclude.at(index); +} + +void BlobRect2::excludeClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) return; + self->m_exclude.clear(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_exclude[index] = rect; + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) return; + self->m_exclude.removeLast(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeCornersAppend( + QQmlListProperty* prop, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_excludeCorners.append(rect); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +qsizetype BlobRect2::excludeCornersCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_excludeCorners.size(); +} + +BlobRect2* BlobRect2::excludeCornersAt( + QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_excludeCorners.at(index); +} + +void BlobRect2::excludeCornersClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_excludeCorners.isEmpty()) return; + self->m_excludeCorners.clear(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::excludeCornersReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_excludeCorners[index] = rect; + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::excludeCornersRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_excludeCorners.isEmpty()) return; + self->m_excludeCorners.removeLast(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::checkAtRest(float speed) { + if (m_retiring) return; + + constexpr float kEpsilon = 0.002f; + const bool atRest = + std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && + std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && + std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && + speed < 5.0f; + + if (atRest) beginRetire(); +} diff --git a/Plugins/ZShell/Blobs/blobrect2.hpp b/Plugins/ZShell/Blobs/blobrect2.hpp new file mode 100644 index 0000000..16832f0 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobrect2.hpp @@ -0,0 +1,191 @@ +#pragma once + +#include "blobshape2.hpp" + +#include +#include +#include +#include + +class BlobRect2 : public BlobShape2 { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal stiffness READ stiffness WRITE setStiffness NOTIFY + stiffnessChanged) + Q_PROPERTY(qreal damping READ damping WRITE setDamping NOTIFY dampingChanged) + Q_PROPERTY( + qreal deformScale READ deformScale WRITE setDeformScale NOTIFY + deformScaleChanged) + Q_PROPERTY( + QQmlListProperty exclude READ exclude NOTIFY excludeChanged) + Q_PROPERTY( + QQmlListProperty excludeCorners READ excludeCorners NOTIFY + excludeCornersChanged) + Q_PROPERTY( + bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY + atRestOverrideChanged) + Q_PROPERTY( + qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY + topLeftRadiusChanged) + Q_PROPERTY( + qreal topRightRadius READ topRightRadius WRITE setTopRightRadius NOTIFY + topRightRadiusChanged) + Q_PROPERTY( + qreal bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius + NOTIFY bottomLeftRadiusChanged) + Q_PROPERTY( + qreal bottomRightRadius READ bottomRightRadius WRITE + setBottomRightRadius NOTIFY bottomRightRadiusChanged) + + public: + explicit BlobRect2(QQuickItem* parent = nullptr); + ~BlobRect2() override; + + [[nodiscard]] qreal stiffness() const { return m_stiffness; } + + void setStiffness(qreal s) { + if (!qFuzzyCompare(m_stiffness, s)) { + m_stiffness = s; + emit stiffnessChanged(); + } + } + + [[nodiscard]] qreal damping() const { return m_damping; } + + void setDamping(qreal value) { + if (!qFuzzyCompare(m_damping, value)) { + m_damping = value; + emit dampingChanged(); + } + } + + [[nodiscard]] qreal deformScale() const { return m_deformScale; } + + void setDeformScale(qreal s) { + if (!qFuzzyCompare(m_deformScale, s)) { + m_deformScale = s; + emit deformScaleChanged(); + } + } + + QQmlListProperty exclude(); + QQmlListProperty excludeCorners(); + + [[nodiscard]] bool atRestOverride() const { return m_atRestOverride; } + + void setAtRestOverride(bool o) { + if (m_atRestOverride == o) return; + m_atRestOverride = o; + emit atRestOverrideChanged(); + if (m_group) m_group->markDirty(); + } + + bool isExcluded(const BlobShape2* other) const override; + bool isCornerExcluded(const BlobShape2* other) const override; + // The shape that is pushing is the one that can shape the bar edge. A shape that is + // retiring still counts, because the retire curve is still moving the surface. + // atRestOverride keeps the shape in the group's data even at rest; when it is false + // the actual spring state decides, so it is not an override of the state itself. + bool isMoving() const override { + return m_atRestOverride || m_physicsActive || m_retiring; + } + void cornerRadii(float out[4]) const override; + + [[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; } + + void setTopLeftRadius(qreal r); + + [[nodiscard]] qreal topRightRadius() const { return m_topRightRadius; } + + void setTopRightRadius(qreal r); + + [[nodiscard]] qreal bottomLeftRadius() const { return m_bottomLeftRadius; } + + void setBottomLeftRadius(qreal r); + + [[nodiscard]] qreal bottomRightRadius() const { + return m_bottomRightRadius; + } + + void setBottomRightRadius(qreal r); + + signals: + void stiffnessChanged(); + void dampingChanged(); + void deformScaleChanged(); + void excludeChanged(); + void excludeCornersChanged(); + void atRestOverrideChanged(); + void topLeftRadiusChanged(); + void topRightRadiusChanged(); + void bottomLeftRadiusChanged(); + void bottomRightRadiusChanged(); + + protected: + void updatePhysics() override; + + private: + void checkAtRest(float speed); + void beginRetire(); + void finishAtIdentity(); + + // Physics state + QPointF m_prevScenePos; + QElapsedTimer m_elapsed; + bool m_physicsActive = false; + bool m_hasPrevPos = false; + + // Set from QML. Keeps the shape in the group's data while it is at rest; when it is + // false the spring state decides on its own. + bool m_atRestOverride = false; + + bool m_retiring = false; + QElapsedTimer m_retireElapsed; + float m_retire00 = 1.0f; + 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; + + qreal m_stiffness = 200.0; + qreal m_damping = 16.0; + qreal m_deformScale = 0.0005; + + qreal m_topLeftRadius = -1; + qreal m_topRightRadius = -1; + qreal m_bottomLeftRadius = -1; + qreal m_bottomRightRadius = -1; + + QList> m_exclude; + QList> m_excludeCorners; + + static void excludeAppend( + QQmlListProperty* prop, BlobRect2* rect); + static qsizetype excludeCount(QQmlListProperty* prop); + static BlobRect2* excludeAt( + QQmlListProperty* prop, qsizetype index); + static void excludeClear(QQmlListProperty* prop); + static void excludeReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect); + static void excludeRemoveLast(QQmlListProperty* prop); + + static void excludeCornersAppend( + QQmlListProperty* prop, BlobRect2* rect); + static qsizetype excludeCornersCount(QQmlListProperty* prop); + static BlobRect2* excludeCornersAt( + QQmlListProperty* prop, qsizetype index); + static void excludeCornersClear(QQmlListProperty* prop); + static void excludeCornersReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect); + static void excludeCornersRemoveLast(QQmlListProperty* prop); +}; diff --git a/Plugins/ZShell/Blobs/blobshape.cpp b/Plugins/ZShell/Blobs/blobshape.cpp index 99d4eec..c73d02c 100644 --- a/Plugins/ZShell/Blobs/blobshape.cpp +++ b/Plugins/ZShell/Blobs/blobshape.cpp @@ -168,8 +168,6 @@ void BlobShape::updatePolish() { if (other->width() <= 0 || other->height() <= 0) continue; - if (isExcluded(other)) continue; - const QPointF otherScene = other->mapToScene(QPointF(0, 0)); bool include = false; @@ -194,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); @@ -205,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/Blobs/blobshape2.cpp b/Plugins/ZShell/Blobs/blobshape2.cpp new file mode 100644 index 0000000..8ce1232 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobshape2.cpp @@ -0,0 +1,67 @@ +#include "blobshape2.hpp" +#include "blobgroup2.hpp" + +#include +#include + +BlobShape2::BlobShape2(QQuickItem* parent) : QQuickItem(parent) {} + +void BlobShape2::setGroup(BlobGroup2* g) { + if (m_group == g) return; + if (m_group && isComponentComplete()) unregisterFromGroup(); + m_group = g; + if (m_group && isComponentComplete()) registerWithGroup(); + emit groupChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobShape2::setRadius(qreal r) { + if (qFuzzyCompare(m_radius, r)) return; + m_radius = r; + emit radiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobShape2::componentComplete() { + QQuickItem::componentComplete(); + if (m_group) registerWithGroup(); +} + +void BlobShape2::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + updateCenteredDeformMatrix(); + + // One draw covers the whole group, so any shape change is a group change. + if (m_group) m_group->markDirty(); +} + +void BlobShape2::updateCenteredDeformMatrix() { + const auto cx = static_cast(width()) * 0.5f; + const auto cy = static_cast(height()) * 0.5f; + QMatrix4x4 result; + result.translate(cx, cy); + result *= m_deformMatrix; + result.translate(-cx, -cy); + if (m_centeredDeformMatrix != result) { + m_centeredDeformMatrix = result; + emit deformMatrixChanged(); + } +} + +void BlobShape2::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto r = std::min(static_cast(m_radius), maxR); + out[0] = r; + out[1] = r; + out[2] = r; + out[3] = r; +} + +void BlobShape2::registerWithGroup() { + if (m_group) m_group->addShape(this); +} + +void BlobShape2::unregisterFromGroup() { + if (m_group) m_group->removeShape(this); +} diff --git a/Plugins/ZShell/Blobs/blobshape2.hpp b/Plugins/ZShell/Blobs/blobshape2.hpp new file mode 100644 index 0000000..230b660 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobshape2.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include "blobgroup2.hpp" + +#include +#include + +class BlobGroup2; + +// Data-only shape. +// +// The group draws a single node for the whole group, so a shape here contributes +// only what the shader needs: position, radii, exclusion, physics. A shape does +// not create a render node, so no pixel can be evaluated by one shape and missed +// by another. +class BlobShape2 : public QQuickItem { + Q_OBJECT + Q_PROPERTY(BlobGroup2* group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged) + Q_PROPERTY( + QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged) + Q_PROPERTY( + QMatrix4x4 rawDeformMatrix READ rawDeformMatrix NOTIFY + rawDeformMatrixChanged) + + friend class BlobGroup2; + + public: + explicit BlobShape2(QQuickItem* parent = nullptr); + ~BlobShape2() override = default; + + [[nodiscard]] BlobGroup2* group() const { return m_group; } + + void setGroup(BlobGroup2* g); + + [[nodiscard]] qreal radius() const { return m_radius; } + + void setRadius(qreal r); + + [[nodiscard]] QMatrix4x4 deformMatrix() const { + return m_centeredDeformMatrix; + } + + [[nodiscard]] QMatrix4x4 rawDeformMatrix() const { return m_deformMatrix; } + + signals: + void groupChanged(); + void radiusChanged(); + void deformMatrixChanged(); + void rawDeformMatrixChanged(); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + virtual bool isExcluded(const BlobShape2*) const { return false; } + + // Whether this shape is part of the group's data. Only a pushing shape is, so a shape + // at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride. + virtual bool isMoving() const { return false; } + + virtual bool isCornerExcluded(const BlobShape2* /*other*/) const { + return false; + } + + virtual void cornerRadii(float out[4]) const; + + // Called by the group when it rebuilds the shared data, once per frame. + virtual void updatePhysics() {} + + virtual void registerWithGroup(); + virtual void unregisterFromGroup(); + void updateCenteredDeformMatrix(); + + BlobGroup2* m_group = nullptr; + qreal m_radius = 0; + QMatrix4x4 m_deformMatrix; // identity by default + QMatrix4x4 m_centeredDeformMatrix; +}; diff --git a/Plugins/ZShell/Blobs/shaders/blob.frag b/Plugins/ZShell/Blobs/shaders/blob.frag index ddee3f7..1231bc6 100644 --- a/Plugins/ZShell/Blobs/shaders/blob.frag +++ b/Plugins/ZShell/Blobs/shaders/blob.frag @@ -19,6 +19,8 @@ layout(std140, binding = 0) uniform buf { vec4 invertedOuter; vec4 invertedInner; vec4 rectData[80]; + float shadowRadius; + vec4 shadowColor; }; float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { @@ -63,9 +65,10 @@ float smaxSharpA(float a, float b, float k) { void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); - // Phase 1: compute per-rect SDF, track owner. We can't smin yet because - // excluded pairs need to skip the smooth blend, which requires pairwise pass - // below. + // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the + // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner: + // myIndex stays -2, so the ownership test is skipped and this pass writes every + // pixel the quad covers. Excluded pairs still need the pairwise pass below. float dArr[16]; int owner = -2; float minDist = 1e10; @@ -82,8 +85,10 @@ void main() { // Offset center for asymmetric deformation vec2 center = rect.xy + props.yz; - // AABB early-out: skip rects far from this pixel - vec2 extent = sh.xy + vec2(smoothFactor * 1.5); + // AABB early-out: skip rects far from this pixel. The reach has to include the + // shadow, otherwise a rect that is only inside the shadow range would be + // skipped and its distance could not be used. + vec2 extent = sh.xy + vec2(smoothFactor * 1.5 + shadowRadius); if (abs(pixel.x - center.x) > extent.x || abs(pixel.y - center.y) > extent.y) { dArr[i] = 1e10; @@ -251,14 +256,39 @@ void main() { } } - // Each renderer only outputs pixels it owns, but allow rendering - // blend zones to prevent gaps (mergedSdf < smoothFactor means in blend) - // myIndex == -1: inverted rect renders border-owned pixels - // myIndex >= 0: individual rect renders its owned pixels - if (owner != myIndex && mergedSdf > smoothFactor) + // A pixel this far from every rect cannot have a value, so it is not written. + // The one-unit margin keeps the outermost pixels of the quad out of the AA: their + // derivatives are undefined (the quad neighbour is outside the primitive), which + // is what drew a line along the coverage edge. + float reach = max(smoothFactor, shadowRadius); + if (mergedSdf > reach - 1.0) discard; - float fw = fwidth(mergedSdf); - float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); - fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; + // Only the owner writes a pixel, which is what makes the per-shape path look + // like one surface: qt_Opacity is applied once per pixel instead of once per + // overlapping draw. The single group draw has no owner (myIndex == -2), so the + // test is skipped and the value below is the only value a pixel can have. + if (myIndex >= -1 && owner != myIndex) + discard; + + // Fixed-width AA: fwidth is capped at one unit, so a bad derivative near the quad + // boundary cannot make a wide edge. + float fw = min(fwidth(mergedSdf), 1.0); + float materialAlpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + + // Phase 2: the shadow, as a distance falloff from the merged surface. The + // quadratic reaches zero with zero slope at its outer edge, so that edge needs no + // AA of its own. Only the single group draw writes a shadow: in the per-shape + // path overlapping draws would darken the same area twice. + float shadowAlpha = 0.0; + if (shadowRadius > 0.0 && myIndex == -2) { + float t = clamp(mergedSdf / shadowRadius, 0.0, 1.0); + shadowAlpha = (1.0 - t) * (1.0 - t) * shadowColor.a; + } + + // Material over shadow, premultiplied. + float totalAlpha = materialAlpha + shadowAlpha * (1.0 - materialAlpha); + vec3 rgb = color.rgb * materialAlpha + + shadowColor.rgb * shadowAlpha * (1.0 - materialAlpha); + fragColor = vec4(rgb, totalAlpha) * qt_Opacity; } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..7d1bbaf 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,57 +1,6 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter) -find_package(PkgConfig REQUIRED) -find_library(SENSORS_LIBRARY NAMES sensors REQUIRED) -find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED) -pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) -pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) -pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) -pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) -pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0) - -if(NOT Cava_FOUND) - pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) -endif() - -if(NOT TARGET Sensors::Sensors) - add_library(Sensors::Sensors UNKNOWN IMPORTED) - set_target_properties(Sensors::Sensors PROPERTIES - IMPORTED_LOCATION "${SENSORS_LIBRARY}" - INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}" - ) -endif() - -set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") -qt_standard_project_setup(REQUIRES 6.9) - -function(qml_module arg_TARGET) - cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES") - qt_add_qml_module(${arg_TARGET} - URI ${arg_URI} - VERSION 1.0 - SOURCES ${arg_SOURCES} - RESOURCES ${arg_RESOURCES} - ) - - qt_query_qml_module(${arg_TARGET} - URI module_uri - VERSION module_version - PLUGIN_TARGET module_plugin_target - TARGET_PATH module_target_path - QMLDIR module_qmldir - TYPEINFO module_typeinfo - ) - set(module_dir "${INSTALL_QMLDIR}/${module_target_path}") - install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") - install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}") - install(FILES "${module_qmldir}" DESTINATION "${module_dir}") - install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") - target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES}) -endfunction() - -set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json") - qml_module(ZShell URI ZShell + INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp @@ -60,8 +9,6 @@ qml_module(ZShell toaster.hpp toaster.cpp qalculator.hpp qalculator.cpp zutils.hpp zutils.cpp - RESOURCES - "${SETTINGS_INDEX_JSON}" LIBRARIES Qt::Gui Qt::Quick @@ -69,13 +16,37 @@ qml_module(ZShell Qt::Sql Qt::DBus PkgConfig::Qalculate + 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 + CONFIG_NODE + CONFIG_NODE_NO_CTOR + CONFIG_LIST_TYPE + SETTINGS_NODE + SETTINGS_NODE_NO_CTOR + SETTINGS_LIST_TYPE +) + +add_subdirectory(Settings) add_subdirectory(Models) add_subdirectory(Internal) add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) +add_subdirectory(Llm) + +pkg_check_modules(HYPRLAND_PROBE hyprland) +if(HYPRLAND_PROBE_FOUND) + add_subdirectory(HyprPlugins) +else() + message(STATUS + "hyprland development headers not found; skipping zshell-nightlight plugin") +endif() diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..6d21cc0 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,10 +1,19 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp + chatlistview.hpp chatlistview.cpp wavyline.hpp wavyline.cpp + wavylinerenderer.hpp wavylinerenderer.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + sparklinecontent.hpp sparklinecontent.cpp + sparklineitem.hpp sparklineitem.cpp + sparklinesceneitem.hpp sparklinesceneitem.cpp + wheelinverter.hpp + LIBRARIES + Qt::Quick + Qt::QuickPrivate + Qt::CanvasPainter + ZShell-internal ) 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 new file mode 100644 index 0000000..97b4dd5 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -0,0 +1,248 @@ +#include "sparklinecontent.hpp" + +#include +#include +#include + +namespace ZShell::components { + +SparklineContent::SparklineContent(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +qreal SparklineContent::stepX() const { + if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0; + if (m_sliding) return m_viewportW / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return m_viewportW / static_cast(len - 1); +} + +void SparklineContent::setViewportSize( + qreal viewportWidth, qreal viewportHeight) { + if (qFuzzyCompare(m_viewportW, viewportWidth) && + qFuzzyCompare(m_viewportH, viewportHeight)) + return; + m_viewportW = viewportWidth; + m_viewportH = viewportHeight; + invalidate(); +} + +void SparklineContent::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineContent::invalidate); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) m_line1 = nullptr; + if (m_line2 == buffer) m_line2 = nullptr; + invalidate(); + }); +} + +void SparklineContent::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + update(); +} + +void SparklineContent::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + update(); +} + +void SparklineContent::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + update(); +} + +void SparklineContent::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + update(); +} + +void SparklineContent::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidate(); +} + +void SparklineContent::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidate(); +} + +void SparklineContent::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidate(); +} + +void SparklineContent::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidate(); +} + +void SparklineContent::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidate(); +} + +void SparklineContent::invalidate() { + m_dirty = true; + applyGeometry(); + update(); +} + +void SparklineContent::applyGeometry() { + const qreal pad = m_sliding ? stepX() : 0.0; + setSize(QSizeF(m_viewportW + pad, m_viewportH)); +} + +SparklineContent::LineGeometry SparklineContent::buildGeometry( + internal::CircularBuffer* buffer) const { + LineGeometry geom; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || + m_viewportW <= 0.0) + return geom; + if (m_historyLength < 2) return geom; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return geom; + + qreal startX = 0.0; + if (m_sliding) + startX = m_viewportW - (len - 1) * step + step; + else + startX = 0.0; + + const qreal h = m_viewportH; + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal fillBottom = h; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath linePath; + linePath.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + 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]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + geom.stroke = linePath; + + QPainterPath fillPath = linePath; + fillPath.lineTo(startX + (len - 1) * step, fillBottom); + fillPath.lineTo(startX, fillBottom); + fillPath.closeSubpath(); + geom.fill = fillPath; + geom.hasData = true; + + return geom; +} + +void SparklineContent::rebuildIfNeeded() { + if (!m_dirty) return; + m_geom1 = buildGeometry(m_line1); + m_geom2 = buildGeometry(m_line2); + m_dirty = false; +} + +void SparklineContent::paint(QPainter* painter) { + rebuildIfNeeded(); + if (!m_geom1.hasData && !m_geom2.hasData) return; + + painter->setRenderHint(QPainter::Antialiasing, true); + + auto drawOne = + [&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) { + if (!geom.hasData) return; + + QColor fillColor = color; + fillColor.setAlphaF(static_cast(fillAlpha)); + painter->setPen(Qt::NoPen); + painter->setBrush(fillColor); + painter->drawPath(geom.fill); + + QPen pen(color, m_lineWidth); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(geom.stroke); + }; + + drawOne(m_geom1, m_line1Color, m_line1FillAlpha); + drawOne(m_geom2, m_line2Color, m_line2FillAlpha); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinecontent.hpp b/Plugins/ZShell/Components/sparklinecontent.hpp new file mode 100644 index 0000000..9417b94 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +namespace ZShell::components { + +class SparklineContent : public QQuickPaintedItem { + Q_OBJECT + + public: + explicit SparklineContent(QQuickItem* parent = nullptr); + + void paint(QPainter* painter) override; + + void setLine1(internal::CircularBuffer* buffer); + void setLine2(internal::CircularBuffer* buffer); + void setLine1Color(const QColor& color); + void setLine2Color(const QColor& color); + void setLine1FillAlpha(qreal alpha); + void setLine2FillAlpha(qreal alpha); + void setMaxValue(qreal value); + void setLineWidth(qreal width); + void setCurveSmoothing(bool enabled); + void setHistoryLength(int length); + void setSliding(bool sliding); + + void setViewportSize(qreal viewportWidth, qreal viewportHeight); + + [[nodiscard]] qreal stepX() const; + + private: + struct LineGeometry { + QPainterPath stroke; + QPainterPath fill; + bool hasData = false; + }; + + void invalidate(); + void rebuildIfNeeded(); + [[nodiscard]] LineGeometry buildGeometry( + internal::CircularBuffer* buffer) const; + void connectBuffer(internal::CircularBuffer* buffer); + void applyGeometry(); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_lineWidth = 2.0; + bool m_curveSmoothing = false; + int m_historyLength = 30; + bool m_sliding = true; + + qreal m_viewportW = 0.0; + qreal m_viewportH = 0.0; + + LineGeometry m_geom1; + LineGeometry m_geom2; + bool m_dirty = true; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklineitem.cpp b/Plugins/ZShell/Components/sparklineitem.cpp new file mode 100644 index 0000000..c6d1078 --- /dev/null +++ b/Plugins/ZShell/Components/sparklineitem.cpp @@ -0,0 +1,179 @@ +#include "sparklineitem.hpp" + +#include "sparklinecontent.hpp" + +namespace ZShell::components { + +SparklineItem::SparklineItem(QQuickItem* parent) : QQuickItem(parent) { + setClip(true); + m_content = new SparklineContent(this); + m_content->setHistoryLength(m_historyLength); + m_content->setSliding(m_sliding); +} + +void SparklineItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + m_content->setViewportSize(newGeometry.width(), newGeometry.height()); + updateContentOffset(); +} + +void SparklineItem::updateContentOffset() { + m_content->setX(-m_content->stepX() * m_slideProgress); +} + +void SparklineItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +internal::CircularBuffer* SparklineItem::line1() const { + return m_line1; +} + +void SparklineItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + m_line1 = buffer; + m_content->setLine1(buffer); + applySmoothingRadius(buffer); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineItem::line2() const { + return m_line2; +} + +void SparklineItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + m_line2 = buffer; + m_content->setLine2(buffer); + applySmoothingRadius(buffer); + emit line2Changed(); +} + +QColor SparklineItem::line1Color() const { + return m_line1Color; +} + +void SparklineItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_content->setLine1Color(color); + emit line1ColorChanged(); +} + +QColor SparklineItem::line2Color() const { + return m_line2Color; +} + +void SparklineItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_content->setLine2Color(color); + emit line2ColorChanged(); +} + +qreal SparklineItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_content->setLine1FillAlpha(alpha); + emit line1FillAlphaChanged(); +} + +qreal SparklineItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_content->setLine2FillAlpha(alpha); + emit line2FillAlphaChanged(); +} + +qreal SparklineItem::maxValue() const { + return m_maxValue; +} + +void SparklineItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + m_content->setMaxValue(value); + emit maxValueChanged(); +} + +qreal SparklineItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + updateContentOffset(); + emit slideProgressChanged(); +} + +int SparklineItem::historyLength() const { + return m_historyLength; +} + +void SparklineItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + m_content->setHistoryLength(length); + updateContentOffset(); + emit historyLengthChanged(); +} + +qreal SparklineItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + m_content->setLineWidth(width); + emit lineWidthChanged(); +} + +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + m_content->setSliding(sliding); + updateContentOffset(); + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +} + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +} + +void SparklineItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + m_content->setCurveSmoothing(enabled); + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Components/sparklineitem.hpp similarity index 57% rename from Plugins/ZShell/Internal/sparklineitem.hpp rename to Plugins/ZShell/Components/sparklineitem.hpp index 3cea1ed..81d640b 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Components/sparklineitem.hpp @@ -3,20 +3,25 @@ #include #include #include -#include +#include +#include -#include "circularbuffer.hpp" +#include "internal/circularbuffer.hpp" -namespace ZShell::internal { +namespace ZShell::components { -class SparklineItem : public QQuickPaintedItem { +class SparklineContent; + +class SparklineItem : public QQuickItem { Q_OBJECT QML_ELEMENT Q_PROPERTY( - CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY line1Changed) + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) Q_PROPERTY( - CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY line2Changed) + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) Q_PROPERTY( QColor line1Color READ line1Color WRITE setLine1Color NOTIFY line1ColorChanged) @@ -40,17 +45,22 @@ class SparklineItem : public QQuickPaintedItem { Q_PROPERTY( qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) public: explicit SparklineItem(QQuickItem* parent = nullptr); - void paint(QPainter* painter) override; + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); - [[nodiscard]] CircularBuffer* line1() const; - void setLine1(CircularBuffer* buffer); - - [[nodiscard]] CircularBuffer* line2() const; - void setLine2(CircularBuffer* buffer); + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); [[nodiscard]] QColor line1Color() const; void setLine1Color(const QColor& color); @@ -76,6 +86,15 @@ class SparklineItem : public QQuickPaintedItem { [[nodiscard]] qreal lineWidth() const; void setLineWidth(qreal width); + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + signals: void line1Changed(); void line2Changed(); @@ -87,17 +106,21 @@ class SparklineItem : public QQuickPaintedItem { void slideProgressChanged(); void historyLengthChanged(); void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); + + protected: + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; private: - void drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha); - void connectBuffer(CircularBuffer* buffer); + void updateContentOffset(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; - CircularBuffer* m_line1 = nullptr; - CircularBuffer* m_line2 = nullptr; + SparklineContent* m_content = nullptr; + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; QColor m_line1Color; QColor m_line2Color; qreal m_line1FillAlpha = 0.15; @@ -106,6 +129,9 @@ class SparklineItem : public QQuickPaintedItem { qreal m_slideProgress = 0.0; int m_historyLength = 30; qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; }; -} // namespace ZShell::internal +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.cpp b/Plugins/ZShell/Components/sparklinesceneitem.cpp new file mode 100644 index 0000000..eedac27 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.cpp @@ -0,0 +1,449 @@ +#include "sparklinesceneitem.hpp" + +#include +#include +#include +#include + +namespace ZShell::components { + +SparklineSceneItem::SparklineSceneItem(QQuickItem* parent) + : QQuickItem(parent) { + setClip(true); + setFlag(QQuickItem::ItemHasContents, true); +} + +void SparklineSceneItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + if (newGeometry.size() != oldGeometry.size()) invalidateGeometry(); +} + +void SparklineSceneItem::invalidateGeometry() { + m_geometryDirty = true; + update(); +} + +void SparklineSceneItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +void SparklineSceneItem::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineSceneItem::invalidateGeometry); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) { + m_line1 = nullptr; + emit line1Changed(); + } + if (m_line2 == buffer) { + m_line2 = nullptr; + emit line2Changed(); + } + invalidateGeometry(); + }); +} + +qreal SparklineSceneItem::stepX() const { + if (m_historyLength < 2 || width() <= 0.0) return 0.0; + if (m_sliding) return width() / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return width() / static_cast(len - 1); +} + +SparklineSceneItem::LineSample SparklineSceneItem::buildSample( + internal::CircularBuffer* buffer) const { + LineSample sample; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || width() <= 0.0) + return sample; + if (m_historyLength < 2) return sample; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return sample; + + qreal startX = m_sliding ? (width() - (len - 1) * step + step) : 0.0; + + const qreal h = height(); + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath path; + path.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + 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]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + path.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + path.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + const auto subpaths = path.toSubpathPolygons(); + if (subpaths.isEmpty()) return sample; + + sample.polyline = subpaths.first(); + sample.fillBottom = h; + sample.hasData = sample.polyline.size() >= 2; + return sample; +} + +void SparklineSceneItem::writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample) { + const int n = static_cast(sample.polyline.size()); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + for (int i = 0; i < n; ++i) { + const QPointF& p = sample.polyline[i]; + 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); +} + +void SparklineSceneItem::writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { + const int n = static_cast(sample.polyline.size()); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + const qreal half = lineWidth * 0.5; + + for (int i = 0; i < n; ++i) { + QPointF dir; + if (i == 0) + dir = sample.polyline[1] - sample.polyline[0]; + else if (i == n - 1) + dir = sample.polyline[n - 1] - sample.polyline[n - 2]; + else + dir = sample.polyline[i + 1] - sample.polyline[i - 1]; + + const qreal len = std::hypot(dir.x(), dir.y()); + QPointF normal = len > 1e-6 ? QPointF(-dir.y() / len, dir.x() / len) + : QPointF(0, 1); + + const QPointF& p = sample.polyline[i]; + const QPointF offset = normal * half; + 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); +} + +void SparklineSceneItem::setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha) { + auto* material = static_cast(node->material()); + QColor c = color; + c.setAlphaF(static_cast(alpha)); + material->setColor(c); + node->markDirty(QSGNode::DirtyMaterial); +} + +QSGNode* SparklineSceneItem::updatePaintNode( + QSGNode* node, UpdatePaintNodeData*) { + bool samplesRebuilt = false; + if (m_geometryDirty) { + m_sample1 = buildSample(m_line1); + m_sample2 = buildSample(m_line2); + m_geometryDirty = false; + samplesRebuilt = true; + } + + const bool hasAny = m_sample1.hasData || m_sample2.hasData; + if (!hasAny) { + delete node; + m_transformNode = nullptr; + m_line1FillNode = m_line1StrokeNode = nullptr; + m_line2FillNode = m_line2StrokeNode = nullptr; + return nullptr; + } + + auto* transformNode = static_cast(node); + bool justCreated = false; + if (!transformNode) { + transformNode = new QSGTransformNode(); + m_transformNode = transformNode; + + auto makeNode = [] { + auto* n = new QSGGeometryNode(); + n->setGeometry( + new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0)); + n->setFlag(QSGNode::OwnsGeometry); + n->setMaterial(new QSGFlatColorMaterial()); + n->setFlag(QSGNode::OwnsMaterial); + return n; + }; + + m_line1FillNode = makeNode(); + m_line1StrokeNode = makeNode(); + m_line2FillNode = makeNode(); + m_line2StrokeNode = makeNode(); + transformNode->appendChildNode(m_line1FillNode); + transformNode->appendChildNode(m_line1StrokeNode); + transformNode->appendChildNode(m_line2FillNode); + transformNode->appendChildNode(m_line2StrokeNode); + + justCreated = true; + } + + QMatrix4x4 matrix; + matrix.translate(static_cast(-stepX() * m_slideProgress), 0.0f); + transformNode->setMatrix(matrix); + + const bool needsGeometryWrite = samplesRebuilt || justCreated; + const bool needsColorWrite = m_colorsDirty || justCreated; + + auto writeLine = [&](QSGGeometryNode* fillNode, + QSGGeometryNode* strokeNode, + const LineSample& sample, + const QColor& color, + qreal fillAlpha) { + if (needsGeometryWrite) { + if (sample.hasData) { + writeFillGeometry(fillNode, sample); + writeStrokeGeometry(strokeNode, sample, m_lineWidth); + } else { + fillNode->geometry()->allocate(0); + strokeNode->geometry()->allocate(0); + fillNode->markDirty(QSGNode::DirtyGeometry); + strokeNode->markDirty(QSGNode::DirtyGeometry); + } + } + if (needsColorWrite) { + setMaterialColor(fillNode, color, fillAlpha); + setMaterialColor(strokeNode, color, 1.0); + } + }; + + writeLine( + m_line1FillNode, + m_line1StrokeNode, + m_sample1, + m_line1Color, + m_line1FillAlpha); + writeLine( + m_line2FillNode, + m_line2StrokeNode, + m_sample2, + m_line2Color, + m_line2FillAlpha); + + m_colorsDirty = false; + + return transformNode; +} + +internal::CircularBuffer* SparklineSceneItem::line1() const { + return m_line1; +} + +void SparklineSceneItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineSceneItem::line2() const { + return m_line2; +} + +void SparklineSceneItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line2Changed(); +} + +QColor SparklineSceneItem::line1Color() const { + return m_line1Color; +} + +void SparklineSceneItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_colorsDirty = true; + update(); + emit line1ColorChanged(); +} + +QColor SparklineSceneItem::line2Color() const { + return m_line2Color; +} + +void SparklineSceneItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_colorsDirty = true; + update(); + emit line2ColorChanged(); +} + +qreal SparklineSceneItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineSceneItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line1FillAlphaChanged(); +} + +qreal SparklineSceneItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineSceneItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line2FillAlphaChanged(); +} + +qreal SparklineSceneItem::maxValue() const { + return m_maxValue; +} + +void SparklineSceneItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidateGeometry(); + emit maxValueChanged(); +} + +qreal SparklineSceneItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineSceneItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + update(); + emit slideProgressChanged(); +} + +int SparklineSceneItem::historyLength() const { + return m_historyLength; +} + +void SparklineSceneItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidateGeometry(); + emit historyLengthChanged(); +} + +qreal SparklineSceneItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineSceneItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidateGeometry(); + emit lineWidthChanged(); +} + +bool SparklineSceneItem::sliding() const { + return m_sliding; +} + +void SparklineSceneItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidateGeometry(); + emit slidingChanged(); +} + +qreal SparklineSceneItem::smoothing() const { + return m_smoothing; +} + +void SparklineSceneItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +bool SparklineSceneItem::curveSmoothing() const { + return m_curveSmoothing; +} + +void SparklineSceneItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidateGeometry(); + emit curveSmoothingChanged(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.hpp b/Plugins/ZShell/Components/sparklinesceneitem.hpp new file mode 100644 index 0000000..5309793 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +class QSGGeometryNode; +class QSGTransformNode; + +namespace ZShell::components { + +class SparklineSceneItem : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) + Q_PROPERTY( + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) + Q_PROPERTY( + QColor line1Color READ line1Color WRITE setLine1Color NOTIFY + line1ColorChanged) + Q_PROPERTY( + QColor line2Color READ line2Color WRITE setLine2Color NOTIFY + line2ColorChanged) + Q_PROPERTY( + qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY + line1FillAlphaChanged) + Q_PROPERTY( + qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY + line2FillAlphaChanged) + Q_PROPERTY( + qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) + Q_PROPERTY( + qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY + slideProgressChanged) + Q_PROPERTY( + int historyLength READ historyLength WRITE setHistoryLength NOTIFY + historyLengthChanged) + Q_PROPERTY( + qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY + lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) + + public: + explicit SparklineSceneItem(QQuickItem* parent = nullptr); + + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); + + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); + + [[nodiscard]] QColor line1Color() const; + void setLine1Color(const QColor& color); + + [[nodiscard]] QColor line2Color() const; + void setLine2Color(const QColor& color); + + [[nodiscard]] qreal line1FillAlpha() const; + void setLine1FillAlpha(qreal alpha); + + [[nodiscard]] qreal line2FillAlpha() const; + void setLine2FillAlpha(qreal alpha); + + [[nodiscard]] qreal maxValue() const; + void setMaxValue(qreal value); + + [[nodiscard]] qreal slideProgress() const; + void setSlideProgress(qreal progress); + + [[nodiscard]] int historyLength() const; + void setHistoryLength(int length); + + [[nodiscard]] qreal lineWidth() const; + void setLineWidth(qreal width); + + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + + signals: + void line1Changed(); + void line2Changed(); + void line1ColorChanged(); + void line2ColorChanged(); + void line1FillAlphaChanged(); + void line2FillAlphaChanged(); + void maxValueChanged(); + void slideProgressChanged(); + void historyLengthChanged(); + void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); + + protected: + QSGNode* updatePaintNode(QSGNode* node, UpdatePaintNodeData* data) override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + private: + struct LineSample { + QVector polyline; // local coords, slideProgress baked to 0 + qreal fillBottom = 0.0; + bool hasData = false; + }; + + void connectBuffer(internal::CircularBuffer* buffer); + void invalidateGeometry(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; + [[nodiscard]] qreal stepX() const; + [[nodiscard]] LineSample buildSample(internal::CircularBuffer* buffer) const; + static void writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample); + static void writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth); + static void setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_slideProgress = 0.0; + int m_historyLength = 30; + qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; + + LineSample m_sample1; + LineSample m_sample2; + + bool m_geometryDirty = true; + bool m_colorsDirty = true; + + QSGTransformNode* m_transformNode = nullptr; + QSGGeometryNode* m_line1FillNode = nullptr; + QSGGeometryNode* m_line1StrokeNode = nullptr; + QSGGeometryNode* m_line2FillNode = nullptr; + QSGGeometryNode* m_line2StrokeNode = nullptr; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/wavyline.cpp b/Plugins/ZShell/Components/wavyline.cpp index 8619f05..e5c2539 100644 --- a/Plugins/ZShell/Components/wavyline.cpp +++ b/Plugins/ZShell/Components/wavyline.cpp @@ -1,12 +1,10 @@ #include "wavyline.hpp" - -#include -#include +#include "wavylinerenderer.hpp" namespace ZShell::controls { WavyLine::WavyLine(QQuickItem* parent) - : QQuickPaintedItem(parent) + : QCanvasPainterItem(parent) , m_lineWidth(4) , m_amplitudeMultiplier(0.5) , m_frequency(6) @@ -18,10 +16,13 @@ WavyLine::WavyLine(QQuickItem* parent) , m_startAngle(0) , m_fullAngle(360) , m_radius(-1) - , m_value(1) - , m_startAngleRad(0) - , m_fullAngleRad(2 * M_PI) { - setAntialiasing(true); + , m_value(1) { + setFillColor(Qt::transparent); + setAlphaBlending(true); +} + +QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const { + return new WavyLineRenderer; } int WavyLine::lineWidth() const { @@ -127,7 +128,6 @@ qreal WavyLine::startAngle() const { void WavyLine::setStartAngle(qreal startAngle) { if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) { m_startAngle = startAngle; - m_startAngleRad = startAngle * M_PI / 180.0; emit startAngleChanged(); update(); } @@ -140,7 +140,6 @@ qreal WavyLine::fullAngle() const { void WavyLine::setFullAngle(qreal fullAngle) { if (!qFuzzyCompare(m_fullAngle + 1.0, fullAngle + 1.0)) { m_fullAngle = fullAngle; - m_fullAngleRad = fullAngle * M_PI / 180.0; emit fullAngleChanged(); update(); } @@ -170,90 +169,4 @@ void WavyLine::setValue(qreal value) { } } -void WavyLine::paint(QPainter* painter) { - painter->setRenderHint(QPainter::Antialiasing); - painter->setPen(QPen(m_color, m_lineWidth, Qt::SolidLine, Qt::RoundCap)); - - if (m_pathType == Arc) { - paintArc(painter); - } else { - paintLinear(painter); - } -} - -void WavyLine::paintLinear(QPainter* painter) { - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; - const auto phase = m_waveProgress * 2 * M_PI; - const auto centerY = height() / 2; - const auto len = m_fullLength > 0 ? m_fullLength : 1; - const auto start = m_lineWidth / 2.0; - const auto fullEnd = width() - m_lineWidth / 2.0; - const auto drawEnd = start + (fullEnd - start) * m_value; - - QPainterPath path; - bool first = true; - - for (int x = m_lineWidth / 2; x <= drawEnd; ++x) { - const auto theta = - m_frequency * 2 * M_PI * (x + m_startX) / len + phase; - const auto waveY = centerY + amplitude * qSin(theta); - if (first) { - path.moveTo(x, waveY); - first = false; - } else { - path.lineTo(x, waveY); - } - } - - painter->drawPath(path); -} - -void WavyLine::paintArc(QPainter* painter) { - if (m_fullAngleRad <= 0) { - return; - } - - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; - const auto cx = width() / 2.0; - const auto cy = height() / 2.0; - const auto radius = - m_radius > 0 - ? m_radius - : (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0; - - if (radius <= 0) { - return; - } - - const auto phase = m_waveProgress * 2 * M_PI; - const auto arcLen = radius * m_fullAngleRad; - const auto len = m_fullLength > 0 ? m_fullLength : arcLen; - const auto drawAngleRad = m_fullAngleRad * m_value; - - if (drawAngleRad <= 0) { - return; - } - - const auto N = qMax(64, qCeil(radius * drawAngleRad)); - const auto dTheta = drawAngleRad / N; - - QPainterPath path; - - for (int i = 0; i <= N; ++i) { - const auto theta = m_startAngleRad + i * dTheta; - const auto s = i * dTheta * radius; - const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase; - const auto r = radius + amplitude * qSin(phi); - const auto px = cx + r * qCos(theta); - const auto py = cy + r * qSin(theta); - if (i == 0) { - path.moveTo(px, py); - } else { - path.lineTo(px, py); - } - } - - painter->drawPath(path); -} - } // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavyline.hpp b/Plugins/ZShell/Components/wavyline.hpp index 8f25589..4025664 100644 --- a/Plugins/ZShell/Components/wavyline.hpp +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -1,11 +1,14 @@ #pragma once #include -#include +#include +#include namespace ZShell::controls { -class WavyLine : public QQuickPaintedItem { +class WavyLineRenderer; + +class WavyLine : public QCanvasPainterItem { Q_OBJECT QML_ELEMENT @@ -82,8 +85,6 @@ class WavyLine : public QQuickPaintedItem { [[nodiscard]] qreal value() const; void setValue(qreal value); - void paint(QPainter* painter) override; - signals: void lineWidthChanged(); void amplitudeMultiplierChanged(); @@ -98,9 +99,12 @@ class WavyLine : public QQuickPaintedItem { void radiusChanged(); void valueChanged(); + protected: + [[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer() + const override; + private: - void paintLinear(QPainter* painter); - void paintArc(QPainter* painter); + friend class WavyLineRenderer; int m_lineWidth; qreal m_amplitudeMultiplier; @@ -114,8 +118,6 @@ class WavyLine : public QQuickPaintedItem { qreal m_fullAngle; qreal m_radius; qreal m_value; - qreal m_startAngleRad; - qreal m_fullAngleRad; }; } // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavylinerenderer.cpp b/Plugins/ZShell/Components/wavylinerenderer.cpp new file mode 100644 index 0000000..7da2b07 --- /dev/null +++ b/Plugins/ZShell/Components/wavylinerenderer.cpp @@ -0,0 +1,116 @@ +#include "wavylinerenderer.hpp" +#include "wavyline.hpp" +#include + +namespace ZShell::controls { + +void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) { + auto* wavyLine = static_cast(item); + + m_lineWidth = wavyLine->m_lineWidth; + m_amplitudeMultiplier = wavyLine->m_amplitudeMultiplier; + m_frequency = wavyLine->m_frequency; + m_startX = wavyLine->m_startX; + m_fullLength = wavyLine->m_fullLength; + m_color = wavyLine->m_color; + m_waveProgress = wavyLine->m_waveProgress; + m_pathType = wavyLine->m_pathType; + m_startAngle = wavyLine->m_startAngle; + m_fullAngle = wavyLine->m_fullAngle; + m_radius = wavyLine->m_radius; + m_value = wavyLine->m_value; +} + +void WavyLineRenderer::paint(QCanvasPainter* painter) { + painter->clearRect(0, 0, width(), height()); + + painter->setStrokeStyle(m_color); + painter->setLineWidth(m_lineWidth); + painter->setLineCap(QCanvasPainter::LineCap::Round); + + if (m_pathType == WavyLine::Arc) { + paintArc(painter); + } else { + paintLinear(painter); + } +} + +void WavyLineRenderer::paintLinear(QCanvasPainter* painter) { + const auto amplitude = m_lineWidth * m_amplitudeMultiplier; + const auto phase = m_waveProgress * 2 * M_PI; + const auto centerY = height() / 2; + const auto len = m_fullLength > 0 ? m_fullLength : 1; + const auto start = m_lineWidth / 2.0; + const auto fullEnd = width() - m_lineWidth / 2.0; + const auto drawEnd = start + (fullEnd - start) * m_value; + + painter->beginPath(); + + bool first = true; + for (int x = m_lineWidth / 2; x <= drawEnd; ++x) { + const auto theta = + m_frequency * 2 * M_PI * (x + m_startX) / len + phase; + const auto waveY = centerY + amplitude * qSin(theta); + if (first) { + painter->moveTo(x, waveY); + first = false; + } else { + painter->lineTo(x, waveY); + } + } + + painter->stroke(); +} + +void WavyLineRenderer::paintArc(QCanvasPainter* painter) { + if (m_fullAngle <= 0) { + return; + } + + const auto startAngleRad = m_startAngle * M_PI / 180.0; + const auto fullAngleRad = m_fullAngle * M_PI / 180.0; + + const auto amplitude = m_lineWidth * m_amplitudeMultiplier; + const auto cx = width() / 2.0; + const auto cy = height() / 2.0; + const auto radius = + m_radius > 0 + ? m_radius + : (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0; + + if (radius <= 0) { + return; + } + + const auto phase = m_waveProgress * 2 * M_PI; + const auto arcLen = radius * fullAngleRad; + const auto len = m_fullLength > 0 ? m_fullLength : arcLen; + const auto drawAngleRad = fullAngleRad * m_value; + + if (drawAngleRad <= 0) { + return; + } + + const auto N = qMax(64, qCeil(radius * drawAngleRad)); + const auto dTheta = drawAngleRad / N; + + painter->beginPath(); + + for (int i = 0; i <= N; ++i) { + const auto theta = startAngleRad + i * dTheta; + const auto s = i * dTheta * radius; + const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase; + const auto r = radius + amplitude * qSin(phi); + const auto px = cx + r * qCos(theta); + const auto py = cy + r * qSin(theta); + if (i == 0) { + painter->moveTo(px, py); + } else { + painter->lineTo(px, py); + } + } + + painter->stroke(); +} + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavylinerenderer.hpp b/Plugins/ZShell/Components/wavylinerenderer.hpp new file mode 100644 index 0000000..191ca5f --- /dev/null +++ b/Plugins/ZShell/Components/wavylinerenderer.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include "wavyline.hpp" + +namespace ZShell::controls { + +class WavyLineRenderer final : public QCanvasPainterItemRenderer { + public: + void synchronizeData(QCanvasPainterItem* item) override; + void paint(QCanvasPainter* painter) override; + + private: + void paintLinear(QCanvasPainter* painter); + void paintArc(QCanvasPainter* painter); + + int m_lineWidth; + qreal m_amplitudeMultiplier; + int m_frequency; + qreal m_startX; + qreal m_fullLength; + QColor m_color; + qreal m_waveProgress; + WavyLine::PathType m_pathType; + qreal m_startAngle; + qreal m_fullAngle; + qreal m_radius; + qreal m_value; +}; + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wheelinverter.hpp b/Plugins/ZShell/Components/wheelinverter.hpp new file mode 100644 index 0000000..38aa599 --- /dev/null +++ b/Plugins/ZShell/Components/wheelinverter.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class WheelInverter : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged) + + public: + using QQuickItem::QQuickItem; + + QQuickItem* target() const { return m_target; } + + void setTarget(QQuickItem* target) { + if (m_target == target) return; + + if (m_target) m_target->removeEventFilter(this); + + m_target = target; + + if (m_target) m_target->installEventFilter(this); + + emit targetChanged(); + } + + protected: + class InvertedWheelEvent : public QWheelEvent { + public: + using QWheelEvent::QWheelEvent; + + bool invertedByWheelInverter = true; + }; + + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched != m_target || event->type() != QEvent::Wheel) + return QQuickItem::eventFilter(watched, event); + + auto* wheel = static_cast(event); + + const bool inverted = dynamic_cast(wheel) != + nullptr; + + if (inverted) return false; + + auto* window = m_target ? m_target->window() : nullptr; + if (!window) { + qInfo() << "[WheelInverter] no window"; + return false; + } + + const QPointF windowPos = + window->mapFromGlobal(wheel->globalPosition()); + + auto* invertedEvent = new InvertedWheelEvent( + windowPos, + wheel->globalPosition(), + -wheel->pixelDelta(), + -wheel->angleDelta(), + wheel->buttons(), + wheel->modifiers(), + wheel->phase(), + wheel->inverted(), + wheel->source(), + wheel->pointingDevice()); + + invertedEvent->setTimestamp(wheel->timestamp()); + + QCoreApplication::postEvent(window, invertedEvent); + + return true; + } + + signals: + void targetChanged(); + + private: + QPointer m_target; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..5b11d2f 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,21 +1,26 @@ qml_module(ZShell-config URI ZShell.Config + INCLUDE_PREFIX config SOURCES - confignode.hpp confignode.cpp - configobject.hpp configobject.cpp - configlist.hpp configlist.cpp - config.hpp config.cpp + rootnodes.hpp rootnodes.cpp + common.hpp common.cpp + migration.hpp migration.cpp anim.hpp anim.cpp + fontbuilder.hpp fontbuilder.cpp + font.hpp font.cpp tokens.hpp - appearance.hpp + appearance.hpp appearance.cpp background.hpp bar.hpp clipboard.hpp colors.hpp dashboard.hpp + display.hpp dock.hpp + enums.hpp general.hpp launcher.hpp + llm.hpp lock.hpp notifs.hpp osd.hpp @@ -23,4 +28,13 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp + DEPENDENCIES + ZShell.Settings +) + +target_link_libraries(ZShell-config PUBLIC + Qt::Gui + zshell-util + ZShell + ZShell-settings ) diff --git a/Plugins/ZShell/Config/anim.cpp b/Plugins/ZShell/Config/anim.cpp index 50926c2..0f89423 100644 --- a/Plugins/ZShell/Config/anim.cpp +++ b/Plugins/ZShell/Config/anim.cpp @@ -1,32 +1,82 @@ #include "anim.hpp" -#include "tokens.hpp" + #include +#include "appearance.hpp" +#include "tokens.hpp" + namespace ZShell::config { -QEasingCurve AnimEasing::buildCurve(const QList& points) { +AnimTokens::AnimTokens(QObject* parent) : QObject(parent) {} + +QEasingCurve AnimTokens::emphasized() const { + return m_emphasized; +} + +QEasingCurve AnimTokens::emphasizedAccel() const { + return m_emphasizedAccel; +} + +QEasingCurve AnimTokens::emphasizedDecel() const { + return m_emphasizedDecel; +} + +QEasingCurve AnimTokens::standard() const { + return m_standard; +} + +QEasingCurve AnimTokens::standardAccel() const { + return m_standardAccel; +} + +QEasingCurve AnimTokens::standardDecel() const { + return m_standardDecel; +} + +QEasingCurve AnimTokens::expressiveFastSpatial() const { + return m_expressiveFastSpatial; +} + +QEasingCurve AnimTokens::expressiveDefaultSpatial() const { + return m_expressiveDefaultSpatial; +} + +QEasingCurve AnimTokens::expressiveSlowSpatial() const { + return m_expressiveSlowSpatial; +} + +QEasingCurve AnimTokens::expressiveFastEffects() const { + return m_expressiveFastEffects; +} + +QEasingCurve AnimTokens::expressiveDefaultEffects() const { + return m_expressiveDefaultEffects; +} + +QEasingCurve AnimTokens::expressiveSlowEffects() const { + return m_expressiveSlowEffects; +} + +AnimDurations* AnimTokens::durations() const { + return m_durations; +} + +QEasingCurve AnimTokens::buildCurve(const QList& points) { QEasingCurve curve(QEasingCurve::BezierSpline); + for (int i = 0; i + 5 < points.size(); i += 6) { - QPointF c1(points[i], points[i + 1]); - QPointF c2(points[i + 2], points[i + 3]); - QPointF end(points[i + 4], points[i + 5]); + const QPointF c1(points[i], points[i + 1]); + const QPointF c2(points[i + 2], points[i + 3]); + const QPointF end(points[i + 4], points[i + 5]); curve.addCubicBezierSegment(c1, c2, end); } + return curve; } -void AnimEasing::bindCurves(AnimCurves* curves) { - m_curves = curves; - connect( - curves, - &AnimCurves::propertiesChanged, - this, - &AnimEasing::rebuildCurves); - rebuildCurves(); -} - -void AnimEasing::rebuildCurves() { +void AnimTokens::rebuildCurves() { if (!m_curves) return; + m_emphasized = buildCurve(m_curves->emphasized()); m_emphasizedAccel = buildCurve(m_curves->emphasizedAccel()); m_emphasizedDecel = buildCurve(m_curves->emphasizedDecel()); @@ -41,7 +91,24 @@ void AnimEasing::rebuildCurves() { m_expressiveDefaultEffects = buildCurve(m_curves->expressiveDefaultEffects()); m_expressiveSlowEffects = buildCurve(m_curves->expressiveSlowEffects()); + emit curvesChanged(); } +void AnimTokens::bindCurves(AnimCurves* curves) { + m_curves = curves; + + connect( + curves, &AnimCurves::optionChanged, this, &AnimTokens::rebuildCurves); + + rebuildCurves(); +} + +void AnimTokens::bindDurations(AnimDurations* durations) { + if (m_durations == durations) return; + + m_durations = durations; + emit durationsChanged(); +} + } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/anim.hpp b/Plugins/ZShell/Config/anim.hpp index c5f95bc..8ff9694 100644 --- a/Plugins/ZShell/Config/anim.hpp +++ b/Plugins/ZShell/Config/anim.hpp @@ -9,8 +9,10 @@ namespace ZShell::config { class AnimCurves; class AnimDurations; -class AnimEasing : public QObject { +class AnimTokens : public QObject { Q_OBJECT + Q_MOC_INCLUDE("tokens.hpp") + Q_MOC_INCLUDE("appearance.hpp") QML_ANONYMOUS Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) @@ -42,54 +44,53 @@ class AnimEasing : public QObject { QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY curvesChanged) + Q_PROPERTY( + ZShell::config::AnimDurations* durations READ durations NOTIFY + durationsChanged) + public: - explicit AnimEasing(QObject* parent = nullptr) : QObject(parent) {} + explicit AnimTokens(QObject* parent = nullptr); void bindCurves(AnimCurves* curves); + void bindDurations(AnimDurations* durations); - [[nodiscard]] QEasingCurve emphasized() const { return m_emphasized; } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_emphasizedAccel; - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_emphasizedDecel; - } - [[nodiscard]] QEasingCurve standard() const { return m_standard; } - [[nodiscard]] QEasingCurve standardAccel() const { return m_standardAccel; } - [[nodiscard]] QEasingCurve standardDecel() const { return m_standardDecel; } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_expressiveFastSpatial; - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_expressiveDefaultSpatial; - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_expressiveSlowSpatial; - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_expressiveFastEffects; - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_expressiveDefaultEffects; - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_expressiveSlowEffects; - } + [[nodiscard]] QEasingCurve emphasized() const; + [[nodiscard]] QEasingCurve emphasizedAccel() const; + [[nodiscard]] QEasingCurve emphasizedDecel() const; + [[nodiscard]] QEasingCurve standard() const; + [[nodiscard]] QEasingCurve standardAccel() const; + [[nodiscard]] QEasingCurve standardDecel() const; + [[nodiscard]] QEasingCurve expressiveFastSpatial() const; + [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const; + [[nodiscard]] QEasingCurve expressiveSlowSpatial() const; + [[nodiscard]] QEasingCurve expressiveFastEffects() const; + [[nodiscard]] QEasingCurve expressiveDefaultEffects() const; + [[nodiscard]] QEasingCurve expressiveSlowEffects() const; + [[nodiscard]] AnimDurations* durations() const; signals: void curvesChanged(); + void durationsChanged(); private: - static QEasingCurve buildCurve(const QList& points); void rebuildCurves(); + static QEasingCurve buildCurve(const QList& points); AnimCurves* m_curves = nullptr; - QEasingCurve m_emphasized, m_emphasizedAccel, m_emphasizedDecel; - QEasingCurve m_standard, m_standardAccel, m_standardDecel; - QEasingCurve m_expressiveFastSpatial, m_expressiveDefaultSpatial, - m_expressiveSlowSpatial; - QEasingCurve m_expressiveFastEffects, m_expressiveDefaultEffects, - m_expressiveSlowEffects; + AnimDurations* m_durations = nullptr; + + QEasingCurve m_emphasized; + QEasingCurve m_emphasizedAccel; + QEasingCurve m_emphasizedDecel; + QEasingCurve m_standard; + QEasingCurve m_standardAccel; + QEasingCurve m_standardDecel; + QEasingCurve m_expressiveFastSpatial; + QEasingCurve m_expressiveDefaultSpatial; + QEasingCurve m_expressiveSlowSpatial; + QEasingCurve m_expressiveFastEffects; + QEasingCurve m_expressiveDefaultEffects; + QEasingCurve m_expressiveSlowEffects; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.cpp b/Plugins/ZShell/Config/appearance.cpp new file mode 100644 index 0000000..3ba878d --- /dev/null +++ b/Plugins/ZShell/Config/appearance.cpp @@ -0,0 +1,219 @@ +#include "appearance.hpp" + +#include "tokens.hpp" + +namespace ZShell::config { + +namespace { + +template +void connectTokenSignals(Source* source, Target* target) { + const auto* meta = source->metaObject(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + + if (prop.hasNotifySignal()) + QObject::connect( + source, + prop.notifySignal(), + target, + target->metaObject()->method( + target->metaObject()->indexOfSignal("valuesChanged()"))); + } + + QObject::connect( + target, &Target::scaleChanged, target, &Target::valuesChanged); +} + +} // namespace + +void AppearanceRounding::bindTokens(RoundingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceRounding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceRounding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceRounding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceRounding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceRounding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceRounding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +int AppearanceRounding::full() const { + return m_tokens ? m_tokens->full() : 0; +} + +void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceSpacing::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceSpacing::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceSpacing::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceSpacing::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceSpacing::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceSpacing::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +void AppearancePadding::bindTokens(PaddingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearancePadding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearancePadding::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int AppearancePadding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearancePadding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearancePadding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearancePadding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearancePadding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +void AnimDurations::bindTokens(AnimDurationTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AnimDurations::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AnimDurations::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AnimDurations::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AnimDurations::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AnimDurations::expressiveFastSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultSpatial() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveFastEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultEffects() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowEffects() * m_scale) + : 0; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index b4b0e5a..b774969 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -1,83 +1,347 @@ #pragma once -#include "configobject.hpp" -#include +#include +#include +#include + +#include "settings/objectnode.hpp" +#include "common.hpp" namespace ZShell::config { -class Anim : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; - CFG_PROPERTY(int, mediaGifSpeedAdjustment, 300) - CFG_PROPERTY(qreal, sessionGifSpeed, 0.7) +class RoundingTokens; +class SpacingTokens; +class PaddingTokens; +class AnimDurationTokens; + +class AppearanceRounding : public settings::ObjectNode { + CONFIG_NODE(AppearanceRounding, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) + Q_PROPERTY(int full READ full NOTIFY valuesChanged) public: - explicit Anim(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(RoundingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + [[nodiscard]] int full() const; + + signals: + void valuesChanged(); + + private: + RoundingTokens* m_tokens = nullptr; }; -class Deform : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearanceSpacing : public settings::ObjectNode { + CONFIG_NODE(AppearanceSpacing, settings::ObjectNode) - CFG_PROPERTY(qreal, scale, 1) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit Deform(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(SpacingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + + signals: + void valuesChanged(); + + private: + SpacingTokens* m_tokens = nullptr; }; -class FontFamily : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearancePadding : public settings::ObjectNode { + CONFIG_NODE(AppearancePadding, settings::ObjectNode) - CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) - CFG_PROPERTY(QString, material, QStringLiteral("Material Symbols Rounded")) - CFG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) - CFG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit FontFamily(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(PaddingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + + signals: + void valuesChanged(); + + private: + PaddingTokens* m_tokens = nullptr; }; -class Font : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +namespace detail { +struct FontConfig { + QString family = {}; + int size; + int weight = QFont::Normal; + bool italic = false; + QVariantMap vaxes = {{u"ROND"_s, 25}}; +}; + +} // namespace detail + +class FontSizeNode : public settings::ObjectNode { + CONFIG_NODE(FontSizeNode, settings::ObjectNode) +}; + +class FontStyleNode : public settings::ObjectNode { + CONFIG_NODE(FontStyleNode, settings::ObjectNode) + + public: + virtual FontSizeNode* largeNode() const = 0; + virtual FontSizeNode* mediumNode() const = 0; + virtual FontSizeNode* smallNode() const = 0; + virtual FontSizeNode* extraLargeNode() const { return nullptr; } +}; + +#define ARG(...) __VA_ARGS__ +#define FONT(...) ZShell::config::detail::FontConfig __VA_ARGS__ +#define FONT_CONFIG(Style, Size, props) \ + class FontConfig##Style##Size : public FontSizeNode { \ + CONFIG_NODE(FontConfig##Style##Size, FontSizeNode) \ + \ + CONFIG_PROPERTY( \ + QString, \ + family, \ + ARG(props).family) /* Empty inherits the style family */ \ + CONFIG_PROPERTY(int, size, ARG(props).size) \ + CONFIG_PROPERTY(int, weight, ARG(props).weight) \ + CONFIG_PROPERTY(bool, italic, ARG(props).italic) \ + CONFIG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ + }; +#define FONT_STYLE(Style, family_, large_, medium_, small_) \ + FONT_CONFIG(Style, Large, ARG(large_)) \ + FONT_CONFIG(Style, Medium, ARG(medium_)) \ + FONT_CONFIG(Style, Small, ARG(small_)) \ + \ + class FontStyle##Style : public FontStyleNode { \ + CONFIG_NODE(FontStyle##Style, FontStyleNode) \ + \ + CONFIG_PROPERTY(QString, family, family_) \ + CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ + CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ + CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ + \ + public: \ + FontSizeNode* largeNode() const override { return m_large; } \ + FontSizeNode* mediumNode() const override { return m_medium; } \ + FontSizeNode* smallNode() const override { return m_small; } \ + }; + +// clang-format off +FONT_STYLE(Headline, u"GoogleSansFlex"_s, + FONT({ .size = 32, .weight = QFont::Medium }), + FONT({ .size = 28, .weight = QFont::Medium }), + FONT({ .size = 24, .weight = QFont::Medium }) +) +FONT_STYLE(Title, u"GoogleSansFlex"_s, + FONT({ .size = 22, .weight = QFont::Medium }), + FONT({ .size = 16, .weight = QFont::Medium }), + FONT({ .size = 14, .weight = QFont::Medium }) +) +FONT_STYLE(Body, u"GoogleSansFlex"_s, + FONT({ .size = 16 }), + FONT({ .size = 14 }), + FONT({ .size = 12 }) +) +FONT_STYLE(Label, u"GoogleSansFlex"_s, + FONT({ .size = 14, .weight = QFont::Medium }), + FONT({ .size = 12, .weight = QFont::Medium }), + FONT({ .size = 11 }) +) +FONT_STYLE(Mono, u"CaskaydiaCove NF"_s, + FONT({ .size = 16, .vaxes = {} }), + FONT({ .size = 14, .vaxes = {} }), + FONT({ .size = 12, .vaxes = {} }) +) +// clang-format on + +FONT_CONFIG(Icon, ExtraLarge, FONT({.size = static_cast(48 / 1.33)})) +FONT_CONFIG(Icon, Large, FONT({.size = static_cast(32 / 1.33)})) +FONT_CONFIG(Icon, Medium, FONT({.size = static_cast(24 / 1.33)})) +FONT_CONFIG(Icon, Small, FONT({.size = static_cast(20 / 1.33)})) + +class FontStyleIcon : public FontStyleNode { + CONFIG_NODE(FontStyleIcon, FontStyleNode) + + CONFIG_PROPERTY(QString, family, u"Material Symbols Rounded"_s) + CONFIG_SUBOBJECT(FontConfigIconExtraLarge, extraLarge) + CONFIG_SUBOBJECT(FontConfigIconLarge, large) + CONFIG_SUBOBJECT(FontConfigIconMedium, medium) + CONFIG_SUBOBJECT(FontConfigIconSmall, small) + + public: + FontSizeNode* extraLargeNode() const override { return m_extraLarge; } + FontSizeNode* largeNode() const override { return m_large; } + FontSizeNode* mediumNode() const override { return m_medium; } + FontSizeNode* smallNode() const override { return m_small; } +}; + +#undef ARG +#undef FONT +#undef FONT_CONFIG +#undef FONT_STYLE + +class FontFamily : public settings::ObjectNode { + CONFIG_NODE(FontFamily, settings::ObjectNode) + + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY( + QString, material, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) + CONFIG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) +}; + +class AppearanceFont : public settings::ObjectNode { + CONFIG_NODE(AppearanceFont, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) CONFIG_SUBOBJECT(FontFamily, family) - - public: - explicit Font(QObject* parent = nullptr) - : ConfigObject(parent), m_family(new FontFamily(this)) {} + CONFIG_SUBOBJECT(FontStyleHeadline, headline) + CONFIG_SUBOBJECT(FontStyleTitle, title) + CONFIG_SUBOBJECT(FontStyleBody, body) + CONFIG_SUBOBJECT(FontStyleLabel, label) + CONFIG_SUBOBJECT(FontStyleMono, mono) + CONFIG_SUBOBJECT(FontStyleIcon, icon) + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) }; -class Transparency : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimDurations : public settings::ObjectNode { + CONFIG_NODE(AnimDurations, settings::ObjectNode) - CFG_PROPERTY(qreal, base, 0.85) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(qreal, layers, 0.4) + CONFIG_GLOBAL_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int expressiveFastSpatial READ expressiveFastSpatial NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveFastEffects READ expressiveFastEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveSlowEffects READ expressiveSlowEffects NOTIFY + valuesChanged) public: - explicit Transparency(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(AnimDurationTokens* tokens); + + [[nodiscard]] int small() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int large() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int expressiveFastSpatial() const; + [[nodiscard]] int expressiveDefaultSpatial() const; + [[nodiscard]] int expressiveSlowSpatial() const; + [[nodiscard]] int expressiveFastEffects() const; + [[nodiscard]] int expressiveDefaultEffects() const; + [[nodiscard]] int expressiveSlowEffects() const; + + signals: + void valuesChanged(); + + private: + AnimDurationTokens* m_tokens = nullptr; }; -class Appearance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Anim : public settings::ObjectNode { + CONFIG_NODE(Anim, settings::ObjectNode) + + CONFIG_PROPERTY(int, mediaGifSpeedAdjustment, 300) + CONFIG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(AnimDurations, durations) +}; + +class Deform : public settings::ObjectNode { + CONFIG_NODE(Deform, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) +}; + +class AppearanceTransparency : public settings::ObjectNode { + CONFIG_NODE(AppearanceTransparency, settings::ObjectNode) + + CONFIG_GLOBAL_PROPERTY(bool, enabled, false) + CONFIG_GLOBAL_PROPERTY(qreal, base, 0.85) + CONFIG_GLOBAL_PROPERTY(qreal, layers, 0.4) +}; + +class Appearance : public settings::ObjectNode { + CONFIG_NODE(Appearance, settings::ObjectNode) CONFIG_SUBOBJECT(Anim, anim) CONFIG_SUBOBJECT(Deform, deform) - CONFIG_SUBOBJECT(Font, font) - CONFIG_SUBOBJECT(Transparency, transparency) - - public: - explicit Appearance(QObject* parent = nullptr) - : ConfigObject(parent) - , m_anim(new Anim(this)) - , m_deform(new Deform(this)) - , m_font(new Font(this)) - , m_transparency(new Transparency(this)) {} + CONFIG_SUBOBJECT(AppearanceFont, font) + CONFIG_SUBOBJECT(AppearanceRounding, rounding) + CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + CONFIG_SUBOBJECT(AppearancePadding, padding) + CONFIG_SUBOBJECT(AppearanceTransparency, transparency) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/background.hpp b/Plugins/ZShell/Config/background.hpp index 98331f8..d079a2a 100644 --- a/Plugins/ZShell/Config/background.hpp +++ b/Plugins/ZShell/Config/background.hpp @@ -1,18 +1,14 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Background : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Background : public settings::ObjectNode { + CONFIG_NODE(Background, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, wallFadeDuration, 300) - - public: - explicit Background(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, wallFadeDuration, 300) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 4a0c8ae..eb08a50 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include #include #include @@ -9,66 +8,53 @@ namespace ZShell::config { class BarEntry : public ListEntry { - Q_OBJECT - QML_ANONYMOUS - - public: - explicit BarEntry(QObject* parent = nullptr) : ListEntry(parent) {} + CONFIG_NODE(BarEntry, ListEntry) }; -class Popouts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Popouts : public settings::ObjectNode { + CONFIG_NODE(Popouts, settings::ObjectNode) - CFG_PROPERTY(bool, statusIcons, true) - CFG_PROPERTY(bool, tray, true) - - public: - explicit Popouts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, statusIcons, true) + CONFIG_PROPERTY(bool, tray, true) }; -class Tray : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Tray : public settings::ObjectNode { + CONFIG_NODE(Tray, settings::ObjectNode) - CFG_PROPERTY(bool, recolorIcons, false) - CFG_PROPERTY(bool, showOnHover, true) - CFG_PROPERTY(int, trayIconSize, 24) + CONFIG_PROPERTY(bool, recolorIcons, false) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, trayIconSize, 24) CONFIG_LIST( EntryList, statusIcons, - { + DEFAULT_ARG({ LIST_ENTRY(audio, true), LIST_ENTRY(microphone, true), LIST_ENTRY(bluetooth, false), LIST_ENTRY(network, false), LIST_ENTRY(wifi, false), LIST_ENTRY(power, true), - }) - - public: - explicit Tray(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class Bar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Bar : public settings::ObjectNode { + CONFIG_NODE(Bar, settings::ObjectNode) - CFG_PROPERTY(bool, autoHide, false) - CFG_PROPERTY(int, border, 4) - CFG_PROPERTY(bool, fullscreenReveal, true) - CFG_PROPERTY(int, height, 24) - CFG_PROPERTY(bool, hideWhenNotif, true) + CONFIG_PROPERTY(bool, autoHide, false) + CONFIG_PROPERTY(int, border, 4) + CONFIG_PROPERTY(bool, fullscreenReveal, true) + CONFIG_PROPERTY(int, height, 24) + CONFIG_PROPERTY(bool, hideWhenNotif, true) CONFIG_SUBOBJECT(Popouts, popouts) - CFG_PROPERTY(int, revealDelay, 100) - CFG_PROPERTY(int, rounding, 14) - CFG_PROPERTY(int, smoothing, 32) - CFG_PROPERTY(QString, position, QStringLiteral("top")) + CONFIG_PROPERTY(int, revealDelay, 100) + CONFIG_PROPERTY(int, rounding, 14) + CONFIG_PROPERTY(int, smoothing, 32) + CONFIG_PROPERTY(QString, position, QStringLiteral("top")) CONFIG_SUBOBJECT(Tray, tray) CONFIG_LIST( EntryList, entries, - { + DEFAULT_ARG({ LIST_ENTRY(workspaces, true), LIST_ENTRY(audio, true), LIST_ENTRY(media, true), @@ -78,20 +64,15 @@ class Bar : public ConfigObject { LIST_ENTRY(spacer, true), LIST_ENTRY(activeWindow, true), LIST_ENTRY(spacer, true), - LIST_ENTRY(hyprsunset, true), + LIST_ENTRY(nightlight, true), LIST_ENTRY(tray, true), LIST_ENTRY(statusIcons, true), LIST_ENTRY(network, false), LIST_ENTRY(upower, true), LIST_ENTRY(clock, true), LIST_ENTRY(notifBell, true), - }) - - public: - explicit Bar(QObject* parent = nullptr) - : ConfigObject(parent) - , m_popouts(new Popouts(this)) - , m_tray(new Tray(this)) {} + }), + .identityKey = QStringLiteral("id")) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/clipboard.hpp b/Plugins/ZShell/Config/clipboard.hpp index b2c0708..321c861 100644 --- a/Plugins/ZShell/Config/clipboard.hpp +++ b/Plugins/ZShell/Config/clipboard.hpp @@ -1,33 +1,24 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class ClipboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ClipboardSizes : public settings::ObjectNode { + CONFIG_NODE(ClipboardSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 60) - CFG_PROPERTY(int, minPreviewWidth, 200) - CFG_PROPERTY(int, previewWidth, 800) - CFG_PROPERTY(int, width, 500) - - public: - explicit ClipboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 60) + CONFIG_PROPERTY(int, minPreviewWidth, 200) + CONFIG_PROPERTY(int, previewWidth, 800) + CONFIG_PROPERTY(int, width, 500) }; -class Clipboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Clipboard : public settings::ObjectNode { + CONFIG_NODE(Clipboard, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxEntriesShown, 10) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxEntriesShown, 10) CONFIG_SUBOBJECT(ClipboardSizes, sizes) - - public: - explicit Clipboard(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new ClipboardSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/colors.hpp b/Plugins/ZShell/Config/colors.hpp index 0554df6..fd904e6 100644 --- a/Plugins/ZShell/Config/colors.hpp +++ b/Plugins/ZShell/Config/colors.hpp @@ -1,31 +1,29 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Presets : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Presets : public settings::ObjectNode { + CONFIG_NODE(Presets, settings::ObjectNode) - CFG_PROPERTY(QString, accent, QString()) - CFG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) - CFG_PROPERTY(QString, variant, QStringLiteral("latte")) - - public: - explicit Presets(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QString, accent, QString()) + CONFIG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) + CONFIG_PROPERTY(QString, variant, QStringLiteral("latte")) }; -class Colors : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Colors : public settings::ObjectNode { + CONFIG_NODE(Colors, settings::ObjectNode) CONFIG_SUBOBJECT(Presets, presets) - CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) - - public: - explicit Colors(QObject* parent = nullptr) - : ConfigObject(parent), m_presets(new Presets(this)) {} + CONFIG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) + CONFIG_PROPERTY(bool, scheduleDark, false) + CONFIG_PROPERTY(int, scheduleDarkEnd, 600) + CONFIG_PROPERTY(int, scheduleDarkStart, 1140) + CONFIG_PROPERTY(bool, schemeGen, true) + CONFIG_PROPERTY(bool, neovimColors, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("dark")) + CONFIG_PROPERTY(bool, smart, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.cpp b/Plugins/ZShell/Config/common.cpp new file mode 100644 index 0000000..b9ba202 --- /dev/null +++ b/Plugins/ZShell/Config/common.cpp @@ -0,0 +1,22 @@ +#include "common.hpp" + +#include +#include + +namespace ZShell::config { + +Q_LOGGING_CATEGORY(lcConfig, "zshell.config", QtInfoMsg) + +QString configDir() { + const QString dir = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + + QStringLiteral("/zshell"); + QDir().mkpath(dir); + return dir; +} + +QString monitorConfigDir() { + return configDir() + QStringLiteral("/monitors"); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.hpp b/Plugins/ZShell/Config/common.hpp new file mode 100644 index 0000000..c96cf57 --- /dev/null +++ b/Plugins/ZShell/Config/common.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include "settings/macros.hpp" +#include "settings/objectnode.hpp" + +#define CONFIG_NODE_NO_CTOR SETTINGS_NODE_NO_CTOR +#define CONFIG_NODE SETTINGS_NODE +#define CONFIG_PROPERTY SETTINGS_PROPERTY +#define CONFIG_GLOBAL_PROPERTY SETTINGS_GLOBAL_PROPERTY + +#define CONFIG_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) +#define CONFIG_GLOBAL_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_GLOBAL_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) + +#define CONFIG_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT(ZShell::config::Type, name) +#define CONFIG_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_GLOBAL_SUBOBJECT(ZShell::config::Type, name) + +#define CONFIG_LIST_TYPE SETTINGS_LIST_TYPE +#define CONFIG_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define CONFIG_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_GLOBAL_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +namespace ZShell::config { + +Q_DECLARE_LOGGING_CATEGORY(lcConfig) + +QString configDir(); +QString monitorConfigDir(); + +class ListEntry : public settings::ObjectNode { + CONFIG_NODE(ListEntry, settings::ObjectNode) + + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(bool, enabled, true) +}; +CONFIG_LIST_TYPE(ListEntry, EntryList) + +} // namespace ZShell::config + +// Shorthand for declaring an ID'd entry (bar entries/status icons, quick toggles, etc) +#define LIST_ENTRY(id, enabled) \ + settings::vmap({ \ + {QStringLiteral("id"), QStringLiteral(#id)}, \ + {QStringLiteral("enabled"), enabled}, \ + }) diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp deleted file mode 100644 index 268b4c1..0000000 --- a/Plugins/ZShell/Config/config.cpp +++ /dev/null @@ -1,278 +0,0 @@ -#include "config.hpp" - -#include "appearance.hpp" -#include "background.hpp" -#include "bar.hpp" -#include "clipboard.hpp" -#include "colors.hpp" -#include "dashboard.hpp" -#include "dock.hpp" -#include "general.hpp" -#include "launcher.hpp" -#include "lock.hpp" -#include "notifs.hpp" -#include "osd.hpp" -#include "screenshot.hpp" -#include "services.hpp" -#include "sidebar.hpp" -#include "utilities.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Config::Config(QObject* parent) - : ConfigObject(parent) - , m_appearance(new Appearance(this)) - , m_background(new Background(this)) - , m_bar(new Bar(this)) - , m_clipboard(new Clipboard(this)) - , m_colors(new Colors(this)) - , m_dashboard(new Dashboard(this)) - , m_dock(new Dock(this)) - , m_general(new General(this)) - , m_launcher(new Launcher(this)) - , m_lock(new Lock(this)) - , m_notifs(new Notifs(this)) - , m_osd(new Osd(this)) - , m_screenshot(new Screenshot(this)) - , m_services(new Services(this)) - , m_sidebar(new Sidebar(this)) - , m_utilities(new Utilities(this)) { - connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); - - m_saveTimer.setSingleShot(true); - m_saveTimer.setInterval(300); - connect(&m_saveTimer, &QTimer::timeout, this, &Config::flushAsync); - - m_reloadTimer.setSingleShot(true); - m_reloadTimer.setInterval(50); - connect(&m_reloadTimer, &QTimer::timeout, this, &Config::reloadAsync); - - connectAutoSave(this); - - connect( - &m_watcher, - &QFileSystemWatcher::directoryChanged, - this, - &Config::onWatcherEvent); - - connect( - &m_watcher, - &QFileSystemWatcher::fileChanged, - this, - &Config::onWatcherEvent); - - loadSync(); - updateWatch(); - - m_firstLoadDone = true; -} - -Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); -} - -QString Config::filePath() const { - const QString dir = - QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + - QStringLiteral("/zshell"); - QDir().mkpath(dir); - return dir + QStringLiteral("/config.json"); -} - -void Config::loadSync() { - m_loading = true; - - QFile f(filePath()); - QJsonObject before; - - bool existed = false; - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - if (doc.isObject()) { - before = doc.object(); - existed = true; - } else { - qInfo() << "Config: existing config at" << filePath() - << "is empty or not a valid JSON object - using defaults"; - } - } else { - qInfo() << "Config: no existing config at" << filePath() - << "- using defaults"; - } - - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!existed || after != before) saveNow(); -} - -void Config::updateWatch() { - const QFileInfo info(filePath()); - - const QString dir = info.absolutePath(); - const QString file = info.absoluteFilePath(); - - if (!m_watcher.directories().contains(dir)) m_watcher.addPath(dir); - if (info.exists() && !m_watcher.files().contains(file)) - m_watcher.addPath(file); -} - -void Config::onWatcherEvent(const QString&) { - updateWatch(); - - const QFileInfo info(filePath()); - if (!info.exists()) return; - - QFile file(info.absoluteFilePath()); - if (!file.open(QIODevice::ReadOnly)) return; - - const QByteArray hash = - QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); - - { - QMutexLocker lock(&m_writeMutex); - - if (hash == m_lastWriteHash) return; - } - - m_reloadPending = true; - - if (!m_loading) m_reloadTimer.start(); -} - -void Config::loadAsync() { - m_loading = true; - - auto future = QtConcurrent::run([this]() { - QFile f(filePath()); - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - const bool valid = doc.isObject(); - const QJsonObject before = valid ? doc.object() : QJsonObject(); - - QMetaObject::invokeMethod( - this, - [this, valid, before]() { - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!valid || after != before) saveNow(); - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } else { - qInfo() << "Config: failed to reload from" << filePath() - << "- using in-memory values"; - - QMetaObject::invokeMethod( - this, - [this]() { - m_loading = false; - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } - }); - - m_loadFuture = future; -} - -void Config::load() { - if (!m_firstLoadDone) { - loadSync(); - } else { - loadAsync(); - } -} - -void Config::reloadAsync() { - if (m_loading) return; - - m_reloadPending = false; - loadAsync(); -} - -void Config::scheduleSave() { - if (m_loading) return; - m_saveTimer.start(); -} - -void Config::flushAsync() { - const QByteArray data = - QJsonDocument(toJson().toObject()).toJson(QJsonDocument::Indented); - - const QByteArray hash = - QCryptographicHash::hash(data, QCryptographicHash::Sha256); - - QThreadPool::globalInstance()->start([this, data, hash]() { - const bool success = writeAtomically(data); - - QMetaObject::invokeMethod( - this, - [this, success, hash]() { - if (!success) return; - - QMutexLocker lock(&m_writeMutex); - m_lastWriteHash = hash; - }, - Qt::QueuedConnection); - }); -} - -void Config::saveNow() { - m_saveTimer.stop(); - flushAsync(); -} - -void Config::connectAutoSave(ConfigNode* node) { - connect(node, &ConfigNode::propertiesChanged, this, [this] { - scheduleSave(); - }); - - for (auto* child : node->childNodes()) - connectAutoSave(child); -} - -bool Config::writeAtomically(const QByteArray& data) { - QSaveFile f(filePath()); - - if (!f.open(QIODevice::WriteOnly)) { - qWarning() << "Config: failed to open for atomic write:" - << f.errorString(); - return false; - } - - if (f.write(data) != data.size()) { - qWarning() << "Config: failed to write:" << f.errorString(); - return false; - } - - if (!f.commit()) { - qWarning() << "Config: commit failed:" << f.errorString(); - return false; - } - - return true; -} - -}; // namespace ZShell::config diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp deleted file mode 100644 index e1881c5..0000000 --- a/Plugins/ZShell/Config/config.hpp +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include -#include -#include -#include - -class QQmlEngine; -class QJSEngine; - -namespace ZShell::config { - -class Appearance; -class Background; -class Bar; -class Clipboard; -class Colors; -class Dashboard; -class Dock; -class General; -class Launcher; -class Lock; -class Notifs; -class Osd; -class Screenshot; -class Services; -class Sidebar; -class Utilities; - -class Config : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_MOC_INCLUDE("appearance.hpp") - Q_MOC_INCLUDE("background.hpp") - Q_MOC_INCLUDE("bar.hpp") - Q_MOC_INCLUDE("clipboard.hpp") - Q_MOC_INCLUDE("colors.hpp") - Q_MOC_INCLUDE("dashboard.hpp") - Q_MOC_INCLUDE("dock.hpp") - Q_MOC_INCLUDE("general.hpp") - Q_MOC_INCLUDE("launcher.hpp") - Q_MOC_INCLUDE("lock.hpp") - Q_MOC_INCLUDE("notifs.hpp") - Q_MOC_INCLUDE("osd.hpp") - Q_MOC_INCLUDE("screenshot.hpp") - Q_MOC_INCLUDE("services.hpp") - Q_MOC_INCLUDE("sidebar.hpp") - Q_MOC_INCLUDE("utilities.hpp") - - CONFIG_SUBOBJECT(Appearance, appearance) - CONFIG_SUBOBJECT(Background, background) - CONFIG_SUBOBJECT(Bar, bar) - CONFIG_SUBOBJECT(Clipboard, clipboard) - CONFIG_SUBOBJECT(Colors, colors) - CONFIG_SUBOBJECT(Dashboard, dashboard) - CONFIG_SUBOBJECT(Dock, dock) - CONFIG_SUBOBJECT(General, general) - CONFIG_SUBOBJECT(Launcher, launcher) - CONFIG_SUBOBJECT(Lock, lock) - CONFIG_SUBOBJECT(Notifs, notifs) - CONFIG_SUBOBJECT(Osd, osd) - CONFIG_SUBOBJECT(Screenshot, screenshot) - CONFIG_SUBOBJECT(Services, services) - CONFIG_SUBOBJECT(Sidebar, sidebar) - CONFIG_SUBOBJECT(Utilities, utilities) - - public: - explicit Config(QObject* parent = nullptr); - static Config* create(QQmlEngine*, QJSEngine*); - - Q_INVOKABLE void load(); - Q_INVOKABLE void saveNow(); - Q_INVOKABLE void reloadAsync(); - - private Q_SLOTS: - void scheduleSave(); - void flushAsync(); - void onWatcherEvent(const QString& path); - - private: - QString filePath() const; - bool writeAtomically(const QByteArray& data); - void updateWatch(); - void loadSync(); - void loadAsync(); - void connectAutoSave(ConfigNode* node); - - QTimer m_saveTimer; - QTimer m_reloadTimer; - QFileSystemWatcher m_watcher; - - QMutex m_writeMutex; - QByteArray m_lastWriteHash; - - bool m_reloadPending = false; - bool m_loading = false; - bool m_firstLoadDone = false; - QFuture m_loadFuture; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.cpp b/Plugins/ZShell/Config/configlist.cpp deleted file mode 100644 index 4f75cda..0000000 --- a/Plugins/ZShell/Config/configlist.cpp +++ /dev/null @@ -1,314 +0,0 @@ -#include "configlist.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -ConfigList::ConfigList(QObject* parent, const QVariantList& defaults) - : ConfigNode(parent), m_defaults(QJsonArray::fromVariantList(defaults)) {} - -int ConfigList::count() const { - return static_cast(m_items.size()); -} - -QVariantList ConfigList::values() const { - QVariantList vals; - vals.reserve(m_items.size()); - for (auto* const item : m_items) - vals.append(QVariant::fromValue(item)); - return vals; -} - -ConfigObject* ConfigList::itemAt(int index) const { - if (index < 0 || index >= m_items.size()) return nullptr; - - return m_items.at(index); -} - -const QList& ConfigList::items() const { - return m_items; -} - -void ConfigList::remove(int index) { - if (index < 0 || index >= m_items.size()) { - qCWarning(lcConfig) - << metaObject()->className() << "cannot remove index" << index - << "of" << count(); - return; - } - - destroyItem(m_items.takeAt(index)); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::move(int from, int to) { - if (from < 0 || from >= m_items.size() || to < 0 || to >= m_items.size()) { - qCWarning(lcConfig) << metaObject()->className() << "cannot move" - << from << "to" << to << "of" << count(); - return; - } - - if (from == to) return; - - m_items.move(from, to); - - m_loaded = true; - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::clear() { - if (m_items.isEmpty()) return; - - destroyItems(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::loadFromJson(const QJsonValue& json) { - if (!json.isArray()) { - qCWarning( - lcConfig, - "Option '%s' must be a list, resetting to default", - qUtf8Printable(propertyPath())); - - resetToDefaults(); - materializeDefaults(); - return; - } - - populate(json.toArray()); - m_loaded = true; -} - -QJsonValue ConfigList::toJson() const { - if (m_loaded) return elementsToJson(); - - return QJsonValue::Undefined; -} - -void ConfigList::clearLoadedKeys() { - m_loaded = false; -} - -QStringList ConfigList::unknownKeys() const { - QStringList keys; - - for (int i = 0; i < m_items.size(); ++i) { - const auto childKeys = m_items.at(i)->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString("[%1]").arg(i), childKey)); - } - - return keys; -} - -void ConfigList::materializeDefaults() { - for (auto* const item : m_items) - item->materializeDefaults(); - - if (m_global) return; - - m_loaded = true; -} - -void ConfigList::resyncFromGlobal() { - syncValuesFromGlobal(); -} - -ConfigObject* ConfigList::insertItem(const QVariantMap& props, int index) { - const auto pos = index < 0 || index > m_items.size() ? m_items.size() - : index; - - appendItem(QJsonObject::fromVariantMap(props)); - m_items.move(m_items.size() - 1, pos); - - auto* const item = m_items.at(pos); - const auto unknown = item->unknownKeys(); - for (const auto& key : unknown) - qCWarning(lcConfig) << "Unknown option" << key << "for" - << item->metaObject()->className(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); - - return item; -} - -QJsonArray ConfigList::elementsToJson() const { - QJsonArray arr; - - for (auto* const item : m_items) - arr.append(item->toJson().toObject()); - - return arr; -} - -void ConfigList::resetToDefaults() { - loadFromJsonQuietly(m_defaults); - m_loaded = false; -} - -void ConfigList::syncValuesFromGlobal() { - if (m_loaded) return; - - if (auto* const global = qobject_cast(m_global)) - populate(global->elementsToJson()); -} - -void ConfigList::onGlobalPropertiesChanged(const QMap&) { - syncValuesFromGlobal(); -} - -QString ConfigList::childPath(const ConfigNode* child) const { - for (int i = 0; i < m_items.size(); ++i) - if (m_items.at(i) == child) return QString("[%1]").arg(i); - - return {}; -} - -void ConfigList::populate(const QJsonArray& arr) { - const auto current = elementsToJson(); - if (current == arr) return; - - const auto old = m_items; - QList reused(old.size(), false); - - m_items.clear(); - m_items.reserve(arr.size()); - - for (int i = 0; i < arr.size(); ++i) { - auto match = - i < old.size() && !reused.at(i) && current.at(i) == arr.at(i) ? i - : -1; - - for (int j = 0; match < 0 && j < old.size(); ++j) - if (!reused.at(j) && current.at(j) == arr.at(i)) match = j; - - auto update = false; - for (int j = 0; match < 0 && j < old.size(); ++j) { - if (!reused.at(j) && - sameElement(old.at(j), current.at(j), arr.at(i))) { - match = j; - update = true; - } - } - - if (match < 0) { - appendItem(arr.at(i)); - continue; - } - - reused[match] = true; - - if (update) old.at(match)->loadFromJsonQuietly(arr.at(i)); - - m_items.append(old.at(match)); - } - - for (int i = 0; i < old.size(); ++i) - if (!reused.at(i)) destroyItem(old.at(i)); - - if (m_items.size() != old.size()) emit countChanged(); - - emit valuesChanged(); - - notifyChanged(); -} - -bool ConfigList::sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json) { - const auto keys = item->identityKeys(); - if (keys.isEmpty()) return false; - - const auto currentObj = current.toObject(); - const auto newObj = json.toObject(); - - if (currentObj.keys() != newObj.keys()) return false; - - for (const auto& key : keys) - if (!newObj.contains(key) || currentObj.value(key) != newObj.value(key)) - return false; - - return true; -} - -void ConfigList::appendItem(const QJsonValue& json) { - auto* const item = createItem(); - QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); - - if (m_items.isEmpty()) rejectGlobalOnlyElement(item); - - item->loadFromJsonQuietly(json); - - connectItem(item); - - m_items.append(item); -} - -void ConfigList::rejectGlobalOnlyElement(const ConfigObject* item) const { - const auto keys = item->globalOnlyKeys(); - if (keys.isEmpty()) return; - - qCCritical( - lcConfig, - "%s declares global-only options (%s) which do not work inside a list, " - "use CONFIG_GLOBAL_LIST on %s instead", - item->metaObject()->className(), - qUtf8Printable(keys.join(", ")), - metaObject()->className()); -} - -void ConfigList::connectItem(ConfigNode* node) { - connect( - node, &ConfigNode::propertiesChanged, this, &ConfigList::onItemChanged); - - const auto children = node->childNodes(); - for (auto* const child : children) - connectItem(child); -} - -void ConfigList::disconnectItem(ConfigNode* node) { - node->disconnect(this); - - const auto children = node->childNodes(); - for (auto* const child : children) - disconnectItem(child); -} - -void ConfigList::destroyItems() { - for (auto* const item : std::as_const(m_items)) - destroyItem(item); - - m_items.clear(); -} - -void ConfigList::destroyItem(ConfigObject* item) { - disconnectItem(item); - item->deleteLater(); -} - -void ConfigList::onItemChanged() { - m_loaded = true; - notifyChanged(); -} - -void ConfigList::notifyChanged() { - notifyPropertyChanged("values", count()); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.hpp b/Plugins/ZShell/Config/configlist.hpp deleted file mode 100644 index 5787526..0000000 --- a/Plugins/ZShell/Config/configlist.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -class ConfigList : public ConfigNode { - Q_OBJECT - QML_ANONYMOUS - - Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) - - public: - explicit ConfigList( - QObject* parent = nullptr, const QVariantList& defaults = {}); - - [[nodiscard]] int count() const; - [[nodiscard]] QVariantList values() const; - [[nodiscard]] ConfigObject* itemAt(int index) const; - [[nodiscard]] const QList& items() const; - - Q_INVOKABLE void remove(int index); - Q_INVOKABLE void move(int from, int to); - Q_INVOKABLE void clear(); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - signals: - void countChanged(); - void valuesChanged(); - - protected: - virtual ConfigObject* createItem() = 0; - - void resetToDefaults(); - - ConfigObject* insertItem(const QVariantMap& props, int index = -1); - - [[nodiscard]] QJsonArray elementsToJson() const; - - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - private: - void populate(const QJsonArray& arr); - static bool sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json); - void appendItem(const QJsonValue& json); - void destroyItems(); - void destroyItem(ConfigObject* item); - void rejectGlobalOnlyElement(const ConfigObject* item) const; - void connectItem(ConfigNode* node); - void disconnectItem(ConfigNode* node); - void onItemChanged(); - void notifyChanged(); - - QJsonArray m_defaults; - QList m_items; - bool m_loaded = false; -}; - -} // namespace ZShell::config - -#define CONFIG_LIST_TYPE(Element, Name) \ - class Name : public ZShell::config::ConfigList { \ - Q_OBJECT \ - QML_ANONYMOUS \ - \ - public: \ - explicit Name( \ - QObject* parent = nullptr, const QVariantList& defaults = {}) \ - : ZShell::config::ConfigList(parent, defaults) { \ - resetToDefaults(); \ - } \ - \ - Q_INVOKABLE ZShell::config::Element* at(int index) const { \ - return static_cast(itemAt(index)); \ - } \ - Q_INVOKABLE ZShell::config::Element* insert( \ - const QVariantMap& props, int index = -1) { \ - return static_cast( \ - insertItem(props, index)); \ - } \ - \ - protected: \ - [[nodiscard]] ZShell::config::ConfigObject* createItem() override { \ - return new ZShell::config::Element(this); \ - } \ - }; - -#define CONFIG_LIST(Type, name, ...) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = new Type(this __VA_OPT__(, __VA_ARGS__)); - -#define LIST_ENTRY(id, enabled) \ - vmap({{"id", QString::fromUtf8(#id)}, {"enabled", enabled}}) - -namespace ZShell::config { - -class ListEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(QString, id) - CFG_PROPERTY(bool, enabled, true) - - public: - explicit ListEntry(QObject* parent = nullptr) : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("id")}; - } -}; - -CONFIG_LIST_TYPE(ListEntry, EntryList) - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.cpp b/Plugins/ZShell/Config/confignode.cpp deleted file mode 100644 index b120c6e..0000000 --- a/Plugins/ZShell/Config/confignode.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "confignode.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -Q_LOGGING_CATEGORY(lcConfig, "zshell.config") - -ConfigNode::ConfigNode(QObject* parent) : QObject(parent) { - m_batchTimer = new QTimer(this); - m_batchTimer->setSingleShot(true); - m_batchTimer->setInterval(0); - connect( - m_batchTimer, &QTimer::timeout, this, &ConfigNode::emitBatchedChanges); -} - -void ConfigNode::loadFromJsonQuietly(const QJsonValue& json) { - setNotifySuppressed(true); - loadFromJson(json); - setNotifySuppressed(false); -} - -void ConfigNode::setNotifySuppressed(bool suppressed) { - m_suppressNotify = suppressed; - - const auto children = childNodes(); - for (auto* const child : children) - child->setNotifySuppressed(suppressed); -} - -QList ConfigNode::childNodes() const { - return {}; -} - -void ConfigNode::syncFromGlobal(ConfigNode* global) { - m_global = global; - resyncFromGlobal(); -} - -bool ConfigNode::isOverlay() const { - return m_global != nullptr && this != m_global; -} - -QString ConfigNode::propertyPath(const QString& name) const { - if (name.isEmpty()) return objectName(); - - QObject* const parentObj = parent(); - if (parentObj) { - if (auto* parentNode = qobject_cast(parentObj)) { - const auto childName = parentNode->childPath(this); - if (!childName.isEmpty()) { - return joinPath(parentNode->propertyPath(childName), name); - } - } - } - return name; -} - -int ConfigNode::basePropertyOffset() { - return QObject::staticMetaObject.propertyCount(); -} - -void ConfigNode::notifyPropertyChanged( - const QString& name, const QVariant& value) { - if (m_suppressNotify) return; - - m_pendingChanges[name] = value; - if (m_batchTimer && !m_batchTimer->isActive()) m_batchTimer->start(); -} - -QString ConfigNode::joinPath(const QString& parent, const QString& child) { - // List elements are addressed as parent[i], everything else as parent.child - if (child.startsWith(QLatin1Char('['))) return parent + child; - - return parent + QLatin1Char('.') + child; -} - -void ConfigNode::emitBatchedChanges() { - if (m_pendingChanges.isEmpty()) return; - - QMap changes = m_pendingChanges; - m_pendingChanges.clear(); - emit propertiesChanged(changes); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.hpp b/Plugins/ZShell/Config/confignode.hpp deleted file mode 100644 index d4e4389..0000000 --- a/Plugins/ZShell/Config/confignode.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Q_DECLARE_LOGGING_CATEGORY(lcConfig) - -class ConfigNode : public QObject { - Q_OBJECT - - public: - explicit ConfigNode(QObject* parent = nullptr); - - virtual void loadFromJson(const QJsonValue& json) = 0; - void loadFromJsonQuietly(const QJsonValue& json); - [[nodiscard]] virtual QJsonValue toJson() const = 0; - - virtual void clearLoadedKeys() = 0; - [[nodiscard]] virtual QStringList unknownKeys() const = 0; - [[nodiscard]] virtual QList childNodes() const; - - virtual void materializeDefaults() = 0; - - void syncFromGlobal(ConfigNode* global); - virtual void resyncFromGlobal() = 0; - - [[nodiscard]] bool isOverlay() const; - [[nodiscard]] QString propertyPath(const QString& name = {}) const; - - [[nodiscard]] static int basePropertyOffset(); - - signals: - void propertiesChanged(const QMap& changed); - - protected: - virtual void syncValuesFromGlobal() = 0; - virtual void onGlobalPropertiesChanged( - const QMap& changed) = 0; - - [[nodiscard]] virtual QString childPath(const ConfigNode* child) const = 0; - - void notifyPropertyChanged(const QString& name, const QVariant& value); - - [[nodiscard]] static QString joinPath( - const QString& parent, const QString& child); - - ConfigNode* m_global = nullptr; - - private: - void setNotifySuppressed(bool suppressed); - void emitBatchedChanges(); - - QMap m_pendingChanges; - QTimer* m_batchTimer = nullptr; - bool m_suppressNotify = false; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp deleted file mode 100644 index 9ee0517..0000000 --- a/Plugins/ZShell/Config/configobject.cpp +++ /dev/null @@ -1,344 +0,0 @@ -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -namespace { - -bool isStringArray(const QJsonArray& arr) { - for (const auto& v : arr) { - if (!v.isString()) return false; - } - return true; -} - -bool jsonValueMatchesType(const QJsonValue& val, QMetaType type) { - switch (val.type()) { - case QJsonValue::Bool: - return type.id() == QMetaType::Bool; - - case QJsonValue::Double: - switch (type.id()) { - case QMetaType::Int: - case QMetaType::UInt: - case QMetaType::LongLong: - case QMetaType::ULongLong: - case QMetaType::Double: - case QMetaType::Float: - return true; - default: - return false; - } - - case QJsonValue::String: - return type.id() == QMetaType::QString; - - case QJsonValue::Array: - if (type.id() == QMetaType::QStringList) - return isStringArray(val.toArray()); - return type.id() == QMetaType::QVariantList; - - case QJsonValue::Object: - case QJsonValue::Null: - case QJsonValue::Undefined: - default: - return false; - } -} - -} // namespace - -ConfigObject::ConfigObject(QObject* parent) : ConfigNode(parent) {} - -void ConfigObject::loadFromJson(const QJsonValue& json) { - const auto obj = json.toObject(); - const auto* meta = metaObject(); - - QSet known; - QSet invalid; - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - known.insert(key); - - if (!obj.contains(key)) continue; - - const auto jsonVal = obj.value(key); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - if (auto* const node = prop.read(this).value()) { - node->loadFromJson(jsonVal); - continue; - } - } - - if (!prop.isWritable()) continue; - - if (!jsonValueMatchesType(jsonVal, prop.metaType())) { - qWarning() << "Config: type mismatch for" << key << "in" - << meta->className() << "- expected" - << prop.metaType().name() << "got value" << jsonVal - << "- resetting to default"; - invalid.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QStringList) { - QStringList list; - const auto jsonArr = jsonVal.toArray(); - for (const auto& v : jsonArr) - list.append(v.toString()); - prop.write(this, QVariant::fromValue(list)); - m_loadedKeys.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - continue; - } - - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - } - - m_extras = {}; - for (auto it = obj.begin(); it != obj.end(); ++it) { - if (!known.contains(it.key()) || invalid.contains(it.key())) { - m_extras.insert(it.key(), it.value()); - } - } - - materializeDefaults(); -} - -QJsonValue ConfigObject::toJson() const { - QJsonObject obj; - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (!prop.isReadable()) continue; - - const auto key = QString::fromUtf8(prop.name()); - - const auto value = prop.read(this); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - if (auto* const node = value.value()) { - const auto childJson = node->toJson(); - if (!childJson.isUndefined()) obj.insert(key, childJson); - continue; - } - } - - if (!m_loadedKeys.contains(key)) continue; - - if (!prop.isWritable()) continue; - - if (prop.metaType().id() == QMetaType::QStringList) { - QJsonArray arr; - const auto strList = value.toStringList(); - for (const auto& s : strList) - arr.append(s); - obj.insert(key, arr); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - obj.insert(key, QJsonArray::fromVariantList(value.toList())); - continue; - } - - obj.insert(key, QJsonValue::fromVariant(value)); - } - - if (obj.isEmpty()) return QJsonValue::Undefined; - - return obj; -} - -void ConfigObject::clearLoadedKeys() { - m_loadedKeys.clear(); - m_extras = {}; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - if (auto* const node = prop.read(this).value()) - node->clearLoadedKeys(); - } -} - -QStringList ConfigObject::identityKeys() const { - return {}; -} - -QStringList ConfigObject::unknownKeys() const { - auto keys = m_extras.keys(); - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - auto* const node = prop.read(this).value(); - if (!node) continue; - - const auto childKeys = node->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString::fromUtf8(prop.name()), childKey)); - } - - return keys; -} - -QList ConfigObject::childNodes() const { - QList nodes; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (auto* const node = prop.read(this).value()) - nodes.append(node); - } - - return nodes; -} - -void ConfigObject::materializeDefaults() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - node->materializeDefaults(); - continue; - } - - if (!prop.isWritable()) continue; - - if (m_global) continue; - - m_loadedKeys.insert(key); - } -} - -void ConfigObject::syncValuesFromGlobal() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - if (auto* const globalNode = - prop.read(m_global).value()) - node->syncFromGlobal(globalNode); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - auto val = prop.read(m_global); - prop.write(this, val); - m_loadedKeys.remove(key); - } - } -} - -void ConfigObject::resyncFromGlobal() { - if (!m_global) return; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - node->resyncFromGlobal(); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - prop.write(this, prop.read(m_global)); - m_loadedKeys.remove(key); - } - } -} - -bool ConfigObject::isPropertyLoaded(const QString& name) const { - return m_loadedKeys.contains(name); -} - -bool ConfigObject::isGlobalOnly(const QString& name) const { - return m_globalOnlyKeys.contains(name); -} - -QStringList ConfigObject::globalOnlyKeys() const { - return {m_globalOnlyKeys.begin(), m_globalOnlyKeys.end()}; -} - -void ConfigObject::resetOption(const QString& name) { - m_loadedKeys.remove(name); - - const int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); - if (idx < 0) return; - - const auto prop = metaObject()->property(idx); - - if (auto* const node = prop.read(this).value()) { - node->clearLoadedKeys(); - node->resyncFromGlobal(); - return; - } - - if (m_global && prop.isWritable()) prop.write(this, prop.read(m_global)); -} - -void ConfigObject::onGlobalPropertiesChanged( - const QMap& changed) { - for (auto it = changed.begin(); it != changed.end(); ++it) { - if (m_loadedKeys.contains(it.key()) || isGlobalOnly(it.key())) continue; - - int idx = metaObject()->indexOfProperty(it.key().toUtf8().constData()); - if (idx >= 0) { - metaObject()->property(idx).write(this, it.value()); - m_loadedKeys.remove(it.key()); - } - } -} - -QString ConfigObject::childPath(const ConfigNode* child) const { - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (prop.read(this).value() == child) - return QString::fromUtf8(prop.name()); - } - - return {}; -} - -void ConfigObject::markPropertyLoaded(const QString& name) { - m_loadedKeys.insert(name); -} - -void ConfigObject::markGlobalOnly(const QString& name) { - m_globalOnlyKeys.insert(name); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.hpp b/Plugins/ZShell/Config/configobject.hpp deleted file mode 100644 index 7de39ed..0000000 --- a/Plugins/ZShell/Config/configobject.hpp +++ /dev/null @@ -1,101 +0,0 @@ -#pragma once - -#include "confignode.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -inline QVariantMap vmap( - std::initializer_list> entries) { - QVariantMap map; - for (const auto& [key, value] : entries) - map.insert(key, value); - return map; -} - -#define CFG_PROPERTY(Type, name, ...) \ - Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ - public: \ - [[nodiscard]] Type name() const { \ - return m_##name; \ - } \ - void set_##name(const Type& value) { \ - if (ConfigObject::updateMember(m_##name, value)) { \ - markPropertyLoaded(QStringLiteral(#name)); \ - Q_EMIT name##Changed(); \ - notifyPropertyChanged( \ - QStringLiteral(#name), QVariant::fromValue(m_##name)); \ - } \ - } \ - Q_SIGNAL void name##Changed(); \ - \ - private: \ - Type m_##name __VA_OPT__(= __VA_ARGS__); - -#define CONFIG_SUBOBJECT(Type, name) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = nullptr; - -class ConfigObject : public ConfigNode { - Q_OBJECT - - public: - explicit ConfigObject(QObject* parent = nullptr); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - [[nodiscard]] QList childNodes() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - [[nodiscard]] virtual QStringList identityKeys() const; - - [[nodiscard]] bool isPropertyLoaded(const QString& name) const; - [[nodiscard]] bool isGlobalOnly(const QString& name) const; - [[nodiscard]] QStringList globalOnlyKeys() const; - - Q_INVOKABLE void resetOption(const QString& name); - - template static bool updateMember(T& member, const T& value) { - if constexpr (std::is_floating_point_v) { - if (qFuzzyCompare(member + 1.0, value + 1.0)) return false; - } else { - if (member == value) return false; - } - member = value; - return true; - } - - protected: - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - void markPropertyLoaded(const QString& name); - void markGlobalOnly(const QString& name); - - private: - QSet m_loadedKeys; - QSet m_globalOnlyKeys; - QJsonObject m_extras; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 97471b8..e9fd3d0 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -1,63 +1,53 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Performance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Performance : public settings::ObjectNode { + CONFIG_NODE(Performance, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(bool, showBattery, false) - CFG_PROPERTY(bool, showCpu, true) - CFG_PROPERTY(bool, showGpu, true) - CFG_PROPERTY(bool, showMemory, true) - CFG_PROPERTY(bool, showNetwork, true) - CFG_PROPERTY(bool, showStorage, true) - CFG_PROPERTY(bool, showVram, true) - - public: - explicit Performance(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showBattery, false) + CONFIG_PROPERTY(bool, showCpu, true) + CONFIG_PROPERTY(bool, showGpu, true) + CONFIG_PROPERTY(bool, showMemory, true) + CONFIG_PROPERTY(bool, showNetwork, true) + CONFIG_PROPERTY(bool, showStorage, true) + CONFIG_PROPERTY(bool, showVram, true) }; -class DashboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ProcessColumnEntry : public settings::ObjectNode { + CONFIG_NODE(ProcessColumnEntry, settings::ObjectNode) - CFG_PROPERTY(int, dateTimeWidth, 110) - CFG_PROPERTY(int, infoIconSize, 25) - CFG_PROPERTY(int, infoWidth, 200) - CFG_PROPERTY(int, mediaCoverArtSize, 150) - CFG_PROPERTY(int, mediaProgressSweep, 180) - CFG_PROPERTY(int, mediaProgressThickness, 8) - CFG_PROPERTY(int, mediaVisualizerSize, 200) - CFG_PROPERTY(int, mediaWidth, 200) - CFG_PROPERTY(int, resourceProgessThickness, 10) - CFG_PROPERTY(int, resourceSize, 200) - CFG_PROPERTY(int, tabIndicatorHeight, 3) - CFG_PROPERTY(int, tabIndicatorSpacing, 5) - CFG_PROPERTY(int, weatherWidth, 250) - - public: - explicit DashboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(double, width, 0) }; -class Dashboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +CONFIG_LIST_TYPE(ProcessColumnEntry, ProcessColumnList) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, mediaUpdateInterval, 2000) +class Dashboard : public settings::ObjectNode { + CONFIG_NODE(Dashboard, settings::ObjectNode) + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, dragThreshold, 50) + CONFIG_PROPERTY(int, mediaUpdateInterval, 2000) + CONFIG_PROPERTY(bool, showWeather, true) + CONFIG_PROPERTY(bool, showDashboard, true) + CONFIG_PROPERTY(bool, showMedia, true) + CONFIG_PROPERTY(bool, showProcesses, false) CONFIG_SUBOBJECT(Performance, performance) - CFG_PROPERTY(int, resourceUpdateInterval, 1000) - CONFIG_SUBOBJECT(DashboardSizes, sizes) - - public: - explicit Dashboard(QObject* parent = nullptr) - : ConfigObject(parent) - , m_performance(new Performance(this)) - , m_sizes(new DashboardSizes(this)) {} + CONFIG_PROPERTY(int, resourceUpdateInterval, 1000) + CONFIG_LIST( + ProcessColumnList, + processColumns, + DEFAULT_ARG({ + settings::vmap({{"id", "command"}}), + settings::vmap({{"id", "pid"}}), + settings::vmap({{"id", "cpuUsage"}}), + settings::vmap({{"id", "memoryUsage"}}), + })) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/display.hpp b/Plugins/ZShell/Config/display.hpp new file mode 100644 index 0000000..c36da44 --- /dev/null +++ b/Plugins/ZShell/Config/display.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "common.hpp" +#include + +namespace ZShell::config { + +class Nightlight : public settings::ObjectNode { + CONFIG_NODE(Nightlight, settings::ObjectNode) + + CONFIG_PROPERTY(bool, schedule, true) + CONFIG_PROPERTY(int, scheduleStart, 1200) + CONFIG_PROPERTY(int, scheduleEnd, 570) + CONFIG_PROPERTY(bool, useNative, true) + CONFIG_PROPERTY(int, fadeDuration, 2000) + CONFIG_PROPERTY(int, temp, 2600) +}; + +class Display : public settings::ObjectNode { + CONFIG_NODE(Display, settings::ObjectNode) + + CONFIG_SUBOBJECT(Nightlight, nightlight) +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dock.hpp b/Plugins/ZShell/Config/dock.hpp index 817ce9a..1ea4e10 100644 --- a/Plugins/ZShell/Config/dock.hpp +++ b/Plugins/ZShell/Config/dock.hpp @@ -1,29 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Dock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Dock : public settings::ObjectNode { + CONFIG_NODE(Dock, settings::ObjectNode) - CFG_PROPERTY(bool, enable, false) - CFG_PROPERTY(int, height, 80) - CFG_PROPERTY(bool, hoverToReveal, false) - CFG_PROPERTY(QStringList, ignoredAppRegexes, {}) - CFG_PROPERTY( + CONFIG_PROPERTY(bool, enable, false) + CONFIG_PROPERTY(int, height, 80) + CONFIG_PROPERTY(bool, hoverToReveal, false) + CONFIG_PROPERTY(QStringList, ignoredAppRegexes, {}) + CONFIG_PROPERTY( QStringList, pinnedApps, (QStringList{ QStringLiteral("com.ayugram.desktop"), QStringLiteral("com.obsproject.studio"), QStringLiteral("librewolf")})) - CFG_PROPERTY(bool, pinnedOnStartup, false) - - public: - explicit Dock(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, pinnedOnStartup, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp new file mode 100644 index 0000000..9ba61a0 --- /dev/null +++ b/Plugins/ZShell/Config/enums.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +#define ENUM(Name, ...) \ + namespace Name { \ + \ + Q_NAMESPACE \ + QML_ELEMENT \ + \ + enum Enum : quint8 { __VA_ARGS__ }; \ + Q_ENUM_NS(Enum) \ + }; + +ENUM(BarWorkspaceDisplay, Shapes, Text, Icons) +ENUM(BarWorkspaceCapitalisation, Preserve, Upper, Lower) +ENUM(LyricsBackend, Auto, Local, LRCLIB, NetEase) +ENUM(GpuType, Auto, Nvidia, Generic, None) +ENUM(NotifsFullscreen, On, Off) +ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) +ENUM(DataUnit, Binary, Decimal) +ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) + +#undef ENUM + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.cpp b/Plugins/ZShell/Config/font.cpp new file mode 100644 index 0000000..8e89cab --- /dev/null +++ b/Plugins/ZShell/Config/font.cpp @@ -0,0 +1,288 @@ +#include "font.hpp" + +#include "appearance.hpp" +#include "rootnodes.hpp" + +namespace ZShell::config { + +FontStyleBase::FontStyleBase(QObject* parent) : QObject(parent) {} + +QFont FontStyleBase::large() const { + return m_large; +} + +QFont FontStyleBase::medium() const { + return m_medium; +} + +QFont FontStyleBase::small() const { + return m_small; +} + +QFont FontStyleBase::buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale) { + const auto family = cfg->property("family").toString(); + const auto size = cfg->property("size").toInt(); + const auto weight = cfg->property("weight").value(); + const auto italic = cfg->property("italic").toBool(); + const auto vaxes = cfg->property("vaxes").toMap(); + + QFont font; + font.setFamily(family.isEmpty() ? fallbackFamily : family); + const int scaledSize = static_cast(size * scale); + const int cappedSize = scaledSize > 0 ? scaledSize : 1; + font.setPointSize(cappedSize); + font.setVariableAxis("opsz", static_cast(cappedSize)); + font.setWeight(weight); + font.setVariableAxis("wght", static_cast(weight)); + font.setItalic(italic); + + for (auto it = vaxes.constBegin(); it != vaxes.constEnd(); ++it) { + if (auto tag = QFont::Tag::fromString(it.key())) + font.setVariableAxis(*tag, it.value().toFloat()); + } + + return font; +} + +void FontStyleBase::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) { + disconnect(m_cfg, nullptr, this, nullptr); + disconnect(m_cfg->largeNode(), nullptr, this, nullptr); + disconnect(m_cfg->mediumNode(), nullptr, this, nullptr); + disconnect(m_cfg->smallNode(), nullptr, this, nullptr); + } + + m_cfg = cfg; + + if (cfg) { + connect( + cfg, &settings::Node::optionChanged, this, &FontStyleBase::rebuild); + connect( + cfg->largeNode(), + &settings::Node::optionChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->mediumNode(), + &settings::Node::optionChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->smallNode(), + &settings::Node::optionChanged, + this, + &FontStyleBase::rebuild); + } + + rebuild(); +} + +void FontStyleBase::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_large = buildFont(m_cfg->largeNode(), family, m_scale); + m_medium = buildFont(m_cfg->mediumNode(), family, m_scale); + m_small = buildFont(m_cfg->smallNode(), family, m_scale); + } else { + m_large = QFont(); + m_medium = QFont(); + m_small = QFont(); + } + emit fontsChanged(); +} + +void FontStyleBase::setScale(qreal scale) { + if (qFuzzyCompare(m_scale + 1.0, scale + 1.0)) return; + m_scale = scale; + rebuild(); +} + +FontStyle::FontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new FontBuilders(this, this)) {} + +FontBuilders* FontStyle::builders() const { + return m_builders; +} + +IconFontStyle::IconFontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new IconFontBuilders(this, this)) {} + +FontBuilder IconFontStyle::size(int pointSize) { + return FontBuilder(m_small).size(pointSize); +} + +FontBuilder IconFontStyle::pixelSize(int pixelSize) { + return FontBuilder(m_small).pixelSize(pixelSize); +} + +void IconFontStyle::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) disconnect(m_cfg->extraLargeNode(), nullptr, this, nullptr); + + FontStyleBase::bind(cfg); + + if (cfg) + connect( + cfg->extraLargeNode(), + &settings::Node::optionChanged, + this, + &IconFontStyle::rebuild); +} + +QFont IconFontStyle::extraLarge() const { + return m_extraLarge; +} + +IconFontBuilders* IconFontStyle::builders() const { + return m_builders; +} + +void IconFontStyle::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_extraLarge = buildFont(m_cfg->extraLargeNode(), family, m_scale); + } else { + m_extraLarge = QFont(); + } + FontStyleBase::rebuild(); +} + +FontBuilders::FontBuilders(const FontStyleBase* style, QObject* parent) + : QObject(parent), m_style(style) { + connect( + style, + &FontStyleBase::fontsChanged, + this, + &FontBuilders::buildersChanged); +} + +FontBuilder FontBuilders::large() const { + return FontBuilder(m_style->large()); +} + +FontBuilder FontBuilders::medium() const { + return FontBuilder(m_style->medium()); +} + +FontBuilder FontBuilders::small() const { + return FontBuilder(m_style->small()); +} + +IconFontBuilders::IconFontBuilders(const IconFontStyle* style, QObject* parent) + : FontBuilders(style, parent) {} + +FontBuilder IconFontBuilders::extraLarge() const { + return FontBuilder( + static_cast(m_style)->extraLarge()); +} + +FontTokens::FontTokens(QObject* parent) + : QObject(parent) + , m_headline(new FontStyle(this)) + , m_title(new FontStyle(this)) + , m_body(new FontStyle(this)) + , m_label(new FontStyle(this)) + , m_mono(new FontStyle(this)) + , m_icon(new IconFontStyle(this)) { + bindFont(ConfigSingleton::instance()->appearance()->font()); +} + +FontStyle* FontTokens::headline() const { + return m_headline; +} + +FontStyle* FontTokens::title() const { + return m_title; +} + +FontStyle* FontTokens::body() const { + return m_body; +} + +FontStyle* FontTokens::label() const { + return m_label; +} + +FontStyle* FontTokens::mono() const { + return m_mono; +} + +IconFontStyle* FontTokens::icon() const { + return m_icon; +} + +FontBuilder FontTokens::clock() const { + return FontBuilder(m_clock); +} + +QString FontTokens::workspaces() const { + return m_font ? m_font->workspaces() : QString(); +} + +void FontTokens::bindFont(AppearanceFont* font) { + if (m_font == font) return; + + if (m_font) disconnect(m_font, nullptr, this, nullptr); + + m_font = font; + + if (font) { + rebuildScale(); + m_headline->bind(font->headline()); + m_title->bind(font->title()); + m_body->bind(font->body()); + m_label->bind(font->label()); + m_mono->bind(font->mono()); + m_icon->bind(font->icon()); + + connect( + font, + &AppearanceFont::clockChanged, + this, + &FontTokens::rebuildClock); + connect( + font, + &AppearanceFont::scaleChanged, + this, + &FontTokens::rebuildScale); + connect( + font, + &AppearanceFont::workspacesChanged, + this, + &FontTokens::workspacesChanged); + } else { + rebuildScale(); + m_headline->bind(nullptr); + m_title->bind(nullptr); + m_body->bind(nullptr); + m_label->bind(nullptr); + m_mono->bind(nullptr); + m_icon->bind(nullptr); + } + + rebuildClock(); + emit workspacesChanged(); +} + +void FontTokens::rebuildScale() { + const qreal s = m_font ? m_font->scale() : 1; + m_headline->setScale(s); + m_title->setScale(s); + m_body->setScale(s); + m_label->setScale(s); + m_mono->setScale(s); + m_icon->setScale(s); +} + +void FontTokens::rebuildClock() { + QFont f; + if (m_font) f.setFamily(m_font->clock()); + m_clock = f; + emit clockChanged(); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp new file mode 100644 index 0000000..ac5c4a7 --- /dev/null +++ b/Plugins/ZShell/Config/font.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include "fontbuilder.hpp" + +#include +#include +#include +#include + +namespace ZShell::config { + +class AppearanceFont; +class FontBuilders; +class FontStyleBase; +class FontSizeNode; +class FontStyleNode; +class IconFontStyle; + +class FontBuilders : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder large READ large NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder medium READ medium NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder small READ small NOTIFY buildersChanged + FINAL) + + public: + explicit FontBuilders(const FontStyleBase* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder large() const; + [[nodiscard]] FontBuilder medium() const; + [[nodiscard]] FontBuilder small() const; + + signals: + void buildersChanged(); + + protected: + const FontStyleBase* m_style; +}; + +class IconFontBuilders : public FontBuilders { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder extraLarge READ extraLarge NOTIFY + buildersChanged FINAL) + + public: + explicit IconFontBuilders( + const IconFontStyle* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder extraLarge() const; +}; + +class FontStyleBase : public QObject { + Q_OBJECT + + Q_PROPERTY(QFont large READ large NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont medium READ medium NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont small READ small NOTIFY fontsChanged FINAL) + + public: + explicit FontStyleBase(QObject* parent = nullptr); + + virtual void bind(FontStyleNode* cfg); + void setScale(qreal scale); + + [[nodiscard]] QFont large() const; + [[nodiscard]] QFont medium() const; + [[nodiscard]] QFont small() const; + + signals: + void fontsChanged(); + + protected: + virtual void rebuild(); + + static QFont buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale); + + FontStyleNode* m_cfg = nullptr; + qreal m_scale = 1; + QFont m_large; + QFont m_medium; + QFont m_small; +}; + +class FontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(FontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit FontStyle(QObject* parent = nullptr); + + [[nodiscard]] FontBuilders* builders() const; + + private: + FontBuilders* m_builders; +}; + +class IconFontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(QFont extraLarge READ extraLarge NOTIFY fontsChanged FINAL) + Q_PROPERTY(IconFontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit IconFontStyle(QObject* parent = nullptr); + + Q_INVOKABLE FontBuilder size(int pointSize); + Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + + void bind(FontStyleNode* cfg) override; + + [[nodiscard]] QFont extraLarge() const; + [[nodiscard]] IconFontBuilders* builders() const; + + protected: + void rebuild() override; + + private: + QFont m_extraLarge; + IconFontBuilders* m_builders; +}; + +class FontTokens : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) + Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) + Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) + Q_PROPERTY(FontStyle* label READ label CONSTANT FINAL) + Q_PROPERTY(FontStyle* mono READ mono CONSTANT FINAL) + Q_PROPERTY(IconFontStyle* icon READ icon CONSTANT FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder clock READ clock NOTIFY clockChanged FINAL) + Q_PROPERTY(QString workspaces READ workspaces NOTIFY workspacesChanged FINAL) + + public: + explicit FontTokens(QObject* parent = nullptr); + + void bindFont(AppearanceFont* font); + + [[nodiscard]] FontStyle* headline() const; + [[nodiscard]] FontStyle* title() const; + [[nodiscard]] FontStyle* body() const; + [[nodiscard]] FontStyle* label() const; + [[nodiscard]] FontStyle* mono() const; + [[nodiscard]] IconFontStyle* icon() const; + [[nodiscard]] FontBuilder clock() const; + [[nodiscard]] QString workspaces() const; + + signals: + void clockChanged(); + void workspacesChanged(); + + private: + void rebuildClock(); + void rebuildScale(); + + AppearanceFont* m_font = nullptr; + FontStyle* m_headline; + FontStyle* m_title; + FontStyle* m_body; + FontStyle* m_label; + FontStyle* m_mono; + IconFontStyle* m_icon; + QFont m_clock; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.cpp b/Plugins/ZShell/Config/fontbuilder.cpp new file mode 100644 index 0000000..3726711 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.cpp @@ -0,0 +1,105 @@ +#include "fontbuilder.hpp" + +#include + +namespace { + +Q_LOGGING_CATEGORY(lcFontBuilder, "zshell.fontbuilder", QtInfoMsg) + +} // namespace + +namespace ZShell::config { + +FontBuilder::FontBuilder(const QFont& font) : m_font(font) {} + +FontBuilder FontBuilder::family(const QString& family) { + m_font.setFamily(family); + return *this; +} + +FontBuilder FontBuilder::size(int pointSize) { + if (pointSize <= 0) pointSize = 1; + m_font.setPointSize(pointSize); + m_font.setVariableAxis("opsz", static_cast(pointSize)); + return *this; +} + +FontBuilder FontBuilder::pixelSize(int pixelSize) { + if (pixelSize <= 0) pixelSize = 1; + m_font.setPixelSize(pixelSize); + return *this; +} + +FontBuilder FontBuilder::weight(QFont::Weight weight) { + m_font.setWeight(weight); + m_font.setVariableAxis("wght", static_cast(weight)); + return *this; +} + +FontBuilder FontBuilder::italic(bool on) { + m_font.setItalic(on); + return *this; +} + +FontBuilder FontBuilder::stretch(int stretch) { + m_font.setStretch(stretch); + return *this; +} + +FontBuilder FontBuilder::letterSpacing(qreal spacing, bool absolute) { + m_font.setLetterSpacing( + absolute ? QFont::AbsoluteSpacing : QFont::PercentageSpacing, spacing); + return *this; +} + +FontBuilder FontBuilder::capitalisation(QFont::Capitalization cap) { + m_font.setCapitalization(cap); + return *this; +} + +FontBuilder FontBuilder::vaxis(const QString& tag, float value) { + if (auto t = QFont::Tag::fromString(tag)) + m_font.setVariableAxis(*t, value); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << tag << "to QFont::Tag"; + return *this; +} + +FontBuilder FontBuilder::vaxes(const QVariantMap& axes) { + for (auto it = axes.constBegin(); it != axes.constEnd(); ++it) { + if (it.value().canConvert()) { + if (auto tag = QFont::Tag::fromString(it.key())) + m_font.setVariableAxis(*tag, it.value().toFloat()); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << it.key() << "to QFont::Tag"; + } else { + qCWarning(lcFontBuilder) + << "Unable to convert value" << it.value() << "to float"; + } + } + return *this; +} + +QFont FontBuilder::build() const { + return m_font; +} + +FontBuilder FontBuilder::fill(float value) { + return vaxis(QStringLiteral("FILL"), value); +} + +FontBuilder FontBuilder::grade(float value) { + return vaxis(QStringLiteral("GRAD"), value); +} + +FontBuilder FontBuilder::width(float value) { + return vaxis(QStringLiteral("wdth"), value); +} + +FontBuilder FontBuilder::scale(qreal factor) { + return size(static_cast(m_font.pointSize() * factor)); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.hpp b/Plugins/ZShell/Config/fontbuilder.hpp new file mode 100644 index 0000000..d474f92 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::config { + +class FontBuilder { + Q_GADGET + QML_ANONYMOUS + + public: + FontBuilder() = default; + explicit FontBuilder(const QFont& font); + + [[nodiscard]] Q_INVOKABLE FontBuilder family(const QString& family); + [[nodiscard]] Q_INVOKABLE FontBuilder size(int pointSize); + [[nodiscard]] Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + [[nodiscard]] Q_INVOKABLE FontBuilder weight(QFont::Weight weight); + [[nodiscard]] Q_INVOKABLE FontBuilder italic(bool on = true); + [[nodiscard]] Q_INVOKABLE FontBuilder stretch(int stretch); + [[nodiscard]] Q_INVOKABLE FontBuilder + letterSpacing(qreal spacing, bool absolute = true); + [[nodiscard]] Q_INVOKABLE FontBuilder + capitalisation(QFont::Capitalization cap); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxis(const QString& tag, float value); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxes(const QVariantMap& axes); + [[nodiscard]] Q_INVOKABLE QFont build() const; + + [[nodiscard]] Q_INVOKABLE FontBuilder fill(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder grade(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder width(float value); + + [[nodiscard]] Q_INVOKABLE FontBuilder scale(qreal factor); + + private: + QFont m_font; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9d0f8fd..c90b4cf 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,158 +1,103 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" +#include #include namespace ZShell::config { -class BatteryThresholdEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class BatteryThresholdEntry : public settings::ObjectNode { + CONFIG_NODE(BatteryThresholdEntry, settings::ObjectNode) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, message) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, perc) - - public: - explicit BatteryThresholdEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("perc")}; - } + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, message, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, perc, {}) }; CONFIG_LIST_TYPE(BatteryThresholdEntry, BatteryThresholdList) -class IdleTimeoutEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class IdleTimeoutEntry : public settings::ObjectNode { + CONFIG_NODE(IdleTimeoutEntry, settings::ObjectNode) - CFG_PROPERTY(QString, idleAction) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, timeout) - - public: - explicit IdleTimeoutEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QString, idleAction, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, timeout, {}) }; CONFIG_LIST_TYPE(IdleTimeoutEntry, IdleTimeoutList) -class Apps : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Apps : public settings::ObjectNode { + CONFIG_NODE(Apps, settings::ObjectNode) - CFG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) - CFG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) - CFG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) - CFG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) - CFG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) - CFG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) - CFG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) - CFG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) - - public: - explicit Apps(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) + CONFIG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) + CONFIG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) + CONFIG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) + CONFIG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) + CONFIG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) + CONFIG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) + CONFIG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) }; -class Battery : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Battery : public settings::ObjectNode { + CONFIG_NODE(Battery, settings::ObjectNode) - CFG_PROPERTY(int, critPerc, 5) + CONFIG_PROPERTY(int, critPerc, 5) CONFIG_LIST( BatteryThresholdList, popupThresholds, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery low"}, {"name", "Low battery"}, {"perc", 20}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lower"}, {"name", "Low battery"}, {"perc", 15}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lowest"}, {"name", "Low battery"}, {"perc", 10}}), - }) - - public: - explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class ColorSettings : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, hyprsunsetTemp, 2600) - CFG_PROPERTY(QString, mode, QStringLiteral("dark")) - CFG_PROPERTY(bool, neovimColors, false) - CFG_PROPERTY(bool, scheduleDark, false) - CFG_PROPERTY(int, scheduleDarkEnd, 600) - CFG_PROPERTY(int, scheduleDarkStart, 1140) - CFG_PROPERTY(bool, scheduleHyprsunset, true) - CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570) - CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200) - CFG_PROPERTY(bool, schemeGeneration, true) - CFG_PROPERTY(bool, smart, false) - - public: - explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class Idle : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Idle : public settings::ObjectNode { + CONFIG_NODE(Idle, settings::ObjectNode) CONFIG_LIST( IdleTimeoutList, timeouts, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"idleAction", QStringLiteral("lock")}, {"name", QStringLiteral("Lock")}, {"timeout", 180}}), - }) - - public: - explicit Idle(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class General : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class General : public settings::ObjectNode { + CONFIG_NODE(General, settings::ObjectNode) CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Battery, battery) - CONFIG_SUBOBJECT(ColorSettings, color) - CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) - CFG_PROPERTY(bool, desktopIcons, true) + CONFIG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) + CONFIG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) - CFG_PROPERTY(QString, logo, QString()) - CFG_PROPERTY(bool, showOverFullscreen, true) - CFG_PROPERTY( + CONFIG_PROPERTY( + QString, + lyricsDir, + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + + QStringLiteral("/Lyrics/")) + CONFIG_PROPERTY(QString, logo, QString()) + CONFIG_PROPERTY(bool, showOverFullscreen, true) + CONFIG_PROPERTY( QString, wallpaperPath, QStringLiteral("/mnt/IronWolf/SDImages/SWWW_Wals/")) - - public: - explicit General(QObject* parent = nullptr) - : ConfigObject(parent) - , m_apps(new Apps(this)) - , m_battery(new Battery(this)) - , m_color(new ColorSettings(this)) - , m_idle(new Idle(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/launcher.hpp b/Plugins/ZShell/Config/launcher.hpp index 24976ca..27dafca 100644 --- a/Plugins/ZShell/Config/launcher.hpp +++ b/Plugins/ZShell/Config/launcher.hpp @@ -1,68 +1,53 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" + +#include "common.hpp" #include namespace ZShell::config { -class LauncherActionEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; +using settings::vmap; - CFG_PROPERTY(QVariantList, command) - CFG_PROPERTY(bool, dangerous, false) - CFG_PROPERTY(QString, description) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, name) +class LauncherActionEntry : public settings::ObjectNode { + CONFIG_NODE(LauncherActionEntry, settings::ObjectNode) - public: - explicit LauncherActionEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QVariantList, command, {}) + CONFIG_PROPERTY(bool, dangerous, false) + CONFIG_PROPERTY(QString, description, {}) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, name, {}) }; CONFIG_LIST_TYPE(LauncherActionEntry, LauncherActionList) -class LauncherSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LauncherSizes : public settings::ObjectNode { + CONFIG_NODE(LauncherSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 50) - CFG_PROPERTY(int, itemWidth, 600) - CFG_PROPERTY(int, wallpaperHeight, 200) - CFG_PROPERTY(int, wallpaperWidth, 280) - - public: - explicit LauncherSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 50) + CONFIG_PROPERTY(int, itemWidth, 600) + CONFIG_PROPERTY(int, wallpaperHeight, 200) + CONFIG_PROPERTY(int, wallpaperWidth, 280) }; -class UseFuzzy : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UseFuzzy : public settings::ObjectNode { + CONFIG_NODE(UseFuzzy, settings::ObjectNode) - CFG_PROPERTY(bool, actions, true) - CFG_PROPERTY(bool, apps, true) - CFG_PROPERTY(bool, schemes, true) - CFG_PROPERTY(bool, variants, true) - CFG_PROPERTY(bool, wallpapers, true) - - public: - explicit UseFuzzy(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, actions, true) + CONFIG_PROPERTY(bool, apps, true) + CONFIG_PROPERTY(bool, schemes, true) + CONFIG_PROPERTY(bool, variants, true) + CONFIG_PROPERTY(bool, wallpapers, true) }; -class Launcher : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Launcher : public settings::ObjectNode { + CONFIG_NODE(Launcher, settings::ObjectNode) - CFG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) + CONFIG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) CONFIG_LIST( LauncherActionList, actions, - { + DEFAULT_ARG({ vmap( {{"command", QVariantList{"autocomplete", "calc"}}, {"dangerous", false}, @@ -134,44 +119,18 @@ class Launcher : public ConfigObject { {"enabled", true}, {"icon", "bedtime"}, {"name", "Sleep"}}), - }) - CFG_PROPERTY(int, dragThreshold, 150) - CFG_PROPERTY(bool, enableDangerousActions, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY( - QStringList, - favoriteApps, - {QStringLiteral("Balatro"), - QStringLiteral("Slay the Spire 2"), - QStringLiteral("com.obsproject.Studio")}) - CFG_PROPERTY( - QStringList, - hiddenApps, - {QStringLiteral("zellij"), - QStringLiteral("vim"), - QStringLiteral("jshell-java-openjdk"), - QStringLiteral("jconsole-java-openjdk"), - QStringLiteral("jshell-java21-openjdk"), - QStringLiteral("jconsole-java21-openjdk"), - QStringLiteral("jshell-java17-openjdk"), - QStringLiteral("jconsole-java17-openjdk"), - QStringLiteral("org.neovim.nvim"), - QStringLiteral("avahi-discover"), - QStringLiteral("bvnc"), - QStringLiteral("bssh"), - QStringLiteral("nm-connection-editor")}) - CFG_PROPERTY(int, maxAppsShown, 18) - CFG_PROPERTY(int, maxWallpapers, 7) + })) + CONFIG_PROPERTY(int, dragThreshold, 150) + CONFIG_PROPERTY(bool, enableDangerousActions, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QStringList, favoriteApps, {}) + CONFIG_PROPERTY(QStringList, hiddenApps, {}) + CONFIG_PROPERTY(int, maxAppsShown, 18) + CONFIG_PROPERTY(int, maxWallpapers, 7) CONFIG_SUBOBJECT(LauncherSizes, sizes) - CFG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) + CONFIG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) CONFIG_SUBOBJECT(UseFuzzy, useFuzzy) - CFG_PROPERTY(bool, uwsm, true) - - public: - explicit Launcher(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new LauncherSizes(this)) - , m_useFuzzy(new UseFuzzy(this)) {} + CONFIG_PROPERTY(bool, uwsm, true) }; } // namespace ZShell::config 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/lock.hpp b/Plugins/ZShell/Config/lock.hpp index 479f3ef..c911a18 100644 --- a/Plugins/ZShell/Config/lock.hpp +++ b/Plugins/ZShell/Config/lock.hpp @@ -1,36 +1,27 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class LockSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LockSizes : public settings::ObjectNode { + CONFIG_NODE(LockSizes, settings::ObjectNode) - CFG_PROPERTY(int, centerWidth, 600) - CFG_PROPERTY(qreal, heightMult, 0.7) - CFG_PROPERTY(qreal, ratio, 1.78) - - public: - explicit LockSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, centerWidth, 600) + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 1.78) }; -class Lock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Lock : public settings::ObjectNode { + CONFIG_NODE(Lock, settings::ObjectNode) - CFG_PROPERTY(int, blurAmount, 10) - CFG_PROPERTY(bool, enableFprint, true) - CFG_PROPERTY(int, maxFprintTries, 3) - CFG_PROPERTY(bool, recolorLogo, false) - CFG_PROPERTY(bool, showNotifContent, true) - CFG_PROPERTY(bool, showNotifIcon, true) + CONFIG_PROPERTY(int, blurAmount, 10) + CONFIG_PROPERTY(bool, enableFprint, true) + CONFIG_PROPERTY(int, maxFprintTries, 3) + CONFIG_PROPERTY(bool, recolorLogo, false) + CONFIG_PROPERTY(bool, showNotifContent, true) + CONFIG_PROPERTY(bool, showNotifIcon, true) CONFIG_SUBOBJECT(LockSizes, sizes) - - public: - explicit Lock(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new LockSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.cpp b/Plugins/ZShell/Config/migration.cpp new file mode 100644 index 0000000..da78206 --- /dev/null +++ b/Plugins/ZShell/Config/migration.cpp @@ -0,0 +1,123 @@ +#include "migration.hpp" + +#include + +namespace ZShell::config { + +namespace { + +// A stored value is "missing" if the path does not resolve to an entry. +// Note: default-constructed QJsonValue is Null, not Undefined. +bool isMissing(const QJsonValue& v) { + return v.isUndefined() || v.isNull(); +} + +QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) { + QJsonObject cur = obj; + + for (int i = 0; i < path.size() - 1; ++i) { + if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined; + cur = cur.value(path.at(i)).toObject(); + } + + const QString key = path.last(); + if (!cur.contains(key)) return QJsonValue::Undefined; + return cur.value(key); +} + +QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) { + const QString head = path.takeFirst(); + + if (path.isEmpty()) { + obj[head] = value; + return obj; + } + + QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject() + : QJsonObject(); + obj[head] = placeAt(child, path, value); + return obj; +} + +// Removes `path` from `obj`. Returns true if a value was removed. +// Parent objects left empty are removed as well. +bool removeAt(QJsonObject& obj, const QStringList& path) { + const QString head = path.first(); + + if (path.size() == 1) { + if (!obj.contains(head)) return false; + obj.remove(head); + return true; + } + + if (!obj.value(head).isObject()) return false; + + QJsonObject child = obj.value(head).toObject(); + const bool removed = removeAt(child, path.mid(1)); + + if (child.isEmpty()) + obj.remove(head); + else + obj[head] = child; + + return removed; +} + +} // namespace + +const QList& ConfigMigrations::rules() { + static const QList s_rules = { + migrate( + "general.color.scheduleHyprsunset", "display.nightlight.schedule"), + migrate( + "general.color.scheduleHyprsunsetStart", + "display.nightlight.scheduleStart"), + migrate( + "general.color.scheduleHyprsunsetEnd", + "display.nightlight.scheduleEnd"), + migrate("general.color.scheduleDark", "colors.scheduleDark"), + migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"), + migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"), + migrate("general.color.schemeGeneration", "colors.schemeGen"), + migrate( + "general.color.useNativeNightlight", + "display.nightlight.useNative"), + migrate( + "general.color.nativeFadeDuration", + "display.nightlight.fadeDuration"), + migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"), + migrate("general.color.neovimColors", "colors.neovimColors"), + migrate("general.color.mode", "colors.mode"), + migrate("general.color.smart", "colors.smart"), + }; + return s_rules; +} + +QJsonObject ConfigMigrations::apply(const QJsonObject& json) { + QJsonObject obj = json; + + for (const auto& rule : rules()) { + const QStringList from = + rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts); + const QStringList to = + rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts); + + if (from.isEmpty() || to.isEmpty()) continue; + + const QJsonValue oldValue = valueAt(obj, from); + if (isMissing(oldValue)) continue; + + const bool targetExists = !isMissing(valueAt(obj, to)); + if (!targetExists) obj = placeAt(obj, to, oldValue); + + if (removeAt(obj, from)) { + qInfo() << "Config migration:" << rule.from << "->" << rule.to + << (targetExists ? "(target already set, old value dropped)" + : "(value moved)"); + } + } + + return obj; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.hpp b/Plugins/ZShell/Config/migration.hpp new file mode 100644 index 0000000..1c03505 --- /dev/null +++ b/Plugins/ZShell/Config/migration.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +// A single migration rule: move the value stored at the dotted `from` path +// to the dotted `to` path, then delete the old key (and any parent objects +// left empty). +// +// Rules are applied to the raw JSON file on every load, before the schema +// loads it. They are idempotent: once the old key is gone the rule never +// fires again, so no version stamping is needed. Old rules can stay in the +// table forever. +// +// WARNING: the target path must exist in the schema (i.e. a ConfigObject +// property somewhere in the tree). If it does not, the migrated value is +// loaded as an unknown key and silently dropped on the next save. +struct ConfigMigrationRule { + QString from; + QString to; +}; + +// Helper for writing rules: migrate("general.color.foo", "display.bar.foo") +inline ConfigMigrationRule migrate(const QString& from, const QString& to) { + return {from, to}; +} + +class ConfigMigrations { + public: + [[nodiscard]] static const QList& rules(); + + // Applies all rules to `json`, returning the migrated object. + [[nodiscard]] static QJsonObject apply(const QJsonObject& json); +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/notifs.hpp b/Plugins/ZShell/Config/notifs.hpp index b229f11..9283020 100644 --- a/Plugins/ZShell/Config/notifs.hpp +++ b/Plugins/ZShell/Config/notifs.hpp @@ -1,39 +1,30 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class NotifsSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class NotifsSizes : public settings::ObjectNode { + CONFIG_NODE(NotifsSizes, settings::ObjectNode) - CFG_PROPERTY(int, badge, 20) - CFG_PROPERTY(int, image, 41) - CFG_PROPERTY(int, width, 400) - - public: - explicit NotifsSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, badge, 20) + CONFIG_PROPERTY(int, image, 41) + CONFIG_PROPERTY(int, width, 400) }; -class Notifs : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Notifs : public settings::ObjectNode { + CONFIG_NODE(Notifs, settings::ObjectNode) - CFG_PROPERTY(bool, actionOnClick, false) - CFG_PROPERTY(int, appNotifCooldown, 0) - CFG_PROPERTY(qreal, clearThreshold, 0.3) - CFG_PROPERTY(int, defaultExpireTimeout, 5000) - CFG_PROPERTY(int, expandThreshold, 20) - CFG_PROPERTY(bool, expire, true) - CFG_PROPERTY(int, groupPreviewNum, 5) - CFG_PROPERTY(bool, openExpanded, false) - CFG_PROPERTY(bool, showInFullscreen, false) + CONFIG_PROPERTY(bool, actionOnClick, false) + CONFIG_PROPERTY(int, appNotifCooldown, 0) + CONFIG_PROPERTY(qreal, clearThreshold, 0.3) + CONFIG_PROPERTY(int, defaultExpireTimeout, 5000) + CONFIG_PROPERTY(int, expandThreshold, 20) + CONFIG_PROPERTY(bool, expire, true) + CONFIG_PROPERTY(int, groupPreviewNum, 5) + CONFIG_PROPERTY(bool, openExpanded, false) + CONFIG_PROPERTY(bool, showInFullscreen, false) CONFIG_SUBOBJECT(NotifsSizes, sizes) - - public: - explicit Notifs(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new NotifsSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/osd.hpp b/Plugins/ZShell/Config/osd.hpp index bb4bae3..96de78e 100644 --- a/Plugins/ZShell/Config/osd.hpp +++ b/Plugins/ZShell/Config/osd.hpp @@ -1,34 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class OsdSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class OsdSizes : public settings::ObjectNode { + CONFIG_NODE(OsdSizes, settings::ObjectNode) - CFG_PROPERTY(int, sliderHeight, 150) - CFG_PROPERTY(int, sliderWidth, 30) - - public: - explicit OsdSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, sliderHeight, 150) + CONFIG_PROPERTY(int, sliderWidth, 30) }; -class Osd : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Osd : public settings::ObjectNode { + CONFIG_NODE(Osd, settings::ObjectNode) - CFG_PROPERTY(bool, allMonBrightness, true) - CFG_PROPERTY(bool, enableBrightness, true) - CFG_PROPERTY(bool, enableMicrophone, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, hideDelay, 3000) + CONFIG_PROPERTY(bool, allMonBrightness, true) + CONFIG_PROPERTY(bool, enableBrightness, true) + CONFIG_PROPERTY(bool, enableMicrophone, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, hideDelay, 3000) CONFIG_SUBOBJECT(OsdSizes, sizes) - - public: - explicit Osd(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new OsdSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.cpp b/Plugins/ZShell/Config/rootnodes.cpp new file mode 100644 index 0000000..82218b7 --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.cpp @@ -0,0 +1,191 @@ +#include "rootnodes.hpp" + +#include +#include + +#include "../toaster.hpp" +#include "common.hpp" +#include "migration.hpp" + +namespace ZShell::config { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString nameFor(const QString& key) { + return key.isEmpty() ? u"global"_s : key; +} + +} // namespace + +namespace detail { + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen) { + if (kind != ConfigKind::Shell || !screen.isEmpty()) return; + + auto* const config = static_cast(layer); + if (!config->utilities()->toasts()->configLoaded()) return; + + const auto issues = static_cast(config->diagnostics().count()); + const auto message = issues > 0 + ? QStringLiteral("Config loaded with %1 issue%2") + .arg(issues) + .arg(issues == 1 ? "" : "s") + : QStringLiteral("Config loaded successfully!"); + Toaster::instance()->toast( + QStringLiteral("Config loaded"), + message, + issues > 0 ? QStringLiteral("settings_alert") + : QStringLiteral("rule_settings"), + issues > 0 ? Toast::Type::Warning : Toast::Type::Info); +} + +void loadFailed(ConfigKind kind, const QString& error, const QString& screen) { + QString title; + if (kind == ConfigKind::Tokens) + title = screen.isEmpty() + ? QStringLiteral("Failed to parse token config") + : QStringLiteral("Failed to parse token config for %1") + .arg(screen); + else + title = + screen.isEmpty() + ? QStringLiteral("Failed to parse config") + : QStringLiteral("Failed to parse config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Warning); +} + +void saveFailed(ConfigKind kind, const QString& error, const QString& screen) { + if (kind != ConfigKind::Shell) return; + + const auto title = + screen.isEmpty() + ? QStringLiteral("Failed to save config") + : QStringLiteral("Failed to save config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Error); +} + +} // namespace detail + +ConfigRoot::ConfigRoot( + const QString& path, ConfigRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + migrateFile(path); + bindTokens(); + qCDebug(lcConfig) << "Created config root for" << nameFor(key()); +} + +void ConfigRoot::migrateFile(const QString& path) { + QFile file(path); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) return; + + const auto doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) return; + + const QJsonObject raw = doc.object(); + const QJsonObject migrated = ConfigMigrations::apply(raw); + if (migrated == raw) return; + + file.close(); + + QSaveFile save(path); + if (!save.open(QIODevice::WriteOnly)) { + qCWarning(lcConfig) << "Failed to open" << path + << "for migration write:" << save.errorString(); + return; + } + + if (save.write(QJsonDocument(migrated).toJson(QJsonDocument::Indented)) != + save.size()) { + qCWarning(lcConfig) + << "Failed to write migrated config:" << save.errorString(); + return; + } + + if (!save.commit()) { + qCWarning(lcConfig) + << "Failed to commit migrated config:" << save.errorString(); + return; + } + + qCDebug(lcConfig) << "Migrated config file" << path; +} + +void ConfigRoot::bindTokens() { + qCDebug(lcConfig) << "Binding appearance to token values for" + << nameFor(key()); + + auto* const tokens = TokensSingleton::instance()->appearance(); + m_appearance->rounding()->bindTokens(tokens->rounding()); + m_appearance->spacing()->bindTokens(tokens->spacing()); + m_appearance->padding()->bindTokens(tokens->padding()); + m_appearance->anim()->durations()->bindTokens(tokens->animDurations()); +} + +TokensRoot::TokensRoot( + const QString& path, TokensRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + qCDebug(lcConfig) << "Created tokens root for" << nameFor(key()); +} + +#define SINGLETON_IMPL(Type, Root, file, kind) \ + Type* Type::instance() { \ + static Type instance; \ + return &instance; \ + } \ + \ + Type* Type::create(QQmlEngine*, QJSEngine*) { \ + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); \ + return instance(); \ + } \ + \ + Root* Type::forScreen(const QString& screen) { \ + bool created; \ + auto* const layer = m_layers.get(screen, this, &created); \ + if (created) initLayer(layer); \ + return layer; \ + } \ + \ + Type::Type(QObject* parent) \ + : Root(configDir() + u'/' + file, nullptr, parent) \ + , m_layers(monitorConfigDir(), file, this) { \ + initLayer(this); \ + } \ + \ + void Type::onTreeLoaded(settings::RootNode* layer) { \ + detail::loaded( \ + kind, layer, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeLoadFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::loadFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeSaveFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::saveFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::initLayer(Root* layer) { \ + QObject::connect(layer, &Root::treeLoaded, this, &Type::onTreeLoaded); \ + QObject::connect( \ + layer, &Root::treeLoadFailed, this, &Type::onTreeLoadFailed); \ + QObject::connect( \ + layer, &Root::treeSaveFailed, this, &Type::onTreeSaveFailed); \ + layer->load(); \ + } + +SINGLETON_IMPL( + ConfigSingleton, ConfigRoot, u"config.json"_s, detail::ConfigKind::Shell) +SINGLETON_IMPL( + TokensSingleton, TokensRoot, u"tokens.json"_s, detail::ConfigKind::Tokens) + +#undef SINGLETON_IMPL + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp new file mode 100644 index 0000000..308e90a --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -0,0 +1,168 @@ +#pragma once + +#include +#include + +#include "settings/layerregistry.hpp" +#include "settings/rootnode.hpp" +#include "appearance.hpp" +#include "background.hpp" +#include "bar.hpp" +#include "clipboard.hpp" +#include "colors.hpp" +#include "common.hpp" +#include "dashboard.hpp" +#include "display.hpp" +#include "dock.hpp" +#include "general.hpp" +#include "launcher.hpp" +#include "llm.hpp" +#include "lock.hpp" +#include "notifs.hpp" +#include "osd.hpp" +#include "screenshot.hpp" +#include "services.hpp" +#include "sidebar.hpp" +#include "tokens.hpp" +#include "utilities.hpp" + +namespace ZShell::config { + +class ConfigRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(ConfigRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_SUBOBJECT(Appearance, appearance) + CONFIG_SUBOBJECT(Background, background) + CONFIG_SUBOBJECT(Bar, bar) + CONFIG_SUBOBJECT(Clipboard, clipboard) + CONFIG_SUBOBJECT(Colors, colors) + CONFIG_SUBOBJECT(Dashboard, dashboard) + CONFIG_SUBOBJECT(Display, display) + CONFIG_SUBOBJECT(Dock, dock) + CONFIG_SUBOBJECT(General, general) + CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) + CONFIG_SUBOBJECT(Lock, lock) + CONFIG_SUBOBJECT(Notifs, notifs) + CONFIG_SUBOBJECT(Osd, osd) + CONFIG_SUBOBJECT(Screenshot, screenshot) + CONFIG_SUBOBJECT(Services, services) + CONFIG_SUBOBJECT(Sidebar, sidebar) + CONFIG_SUBOBJECT(Utilities, utilities) + + public: + explicit ConfigRoot( + const QString& path, + ConfigRoot* fallback = nullptr, + QObject* parent = nullptr); + + private: + // Binds the computed appearance values to the global token base values + void bindTokens(); + + // Applies ConfigMigrations to the file before the first load + void migrateFile(const QString& path); +}; + +class TokensRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(TokensRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_SUBOBJECT(AppearanceTokens, appearance) + CONFIG_SUBOBJECT(SizeTokens, sizes) + + public: + explicit TokensRoot( + const QString& path, + TokensRoot* fallback = nullptr, + QObject* parent = nullptr); +}; + +namespace detail { + +enum class ConfigKind : quint8 { Shell, Tokens }; + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen); +void loadFailed(ConfigKind kind, const QString& error, const QString& screen); +void saveFailed(ConfigKind kind, const QString& error, const QString& screen); + +} // namespace detail + +#define SINGLETON_DECL(Type, Root, QmlName) \ + class Type : public Root { \ + Q_OBJECT \ + QML_NAMED_ELEMENT(QmlName) \ + QML_SINGLETON \ + \ + public: \ + static Type* instance(); \ + static Type* create(QQmlEngine*, QJSEngine*); \ + \ + [[nodiscard]] Q_INVOKABLE Root* forScreen(const QString& screen); \ + \ + private: \ + explicit Type(QObject* parent = nullptr); \ + \ + void onTreeLoaded(settings::RootNode* layer); \ + void onTreeLoadFailed(settings::RootNode* layer, const QString& error); \ + void onTreeSaveFailed(settings::RootNode* layer, const QString& error); \ + \ + void initLayer(Root* layer); \ + \ + settings::LayerRegistry m_layers; \ + }; + +SINGLETON_DECL(ConfigSingleton, ConfigRoot, Config) +SINGLETON_DECL(TokensSingleton, TokensRoot, TokenConfig) + +#undef SINGLETON_DECL + +class Tokens : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY( + ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) + Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) + Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) + Q_PROPERTY(ZShell::config::SizeTokens* sizes READ sizes CONSTANT) + Q_PROPERTY(ZShell::config::FontTokens* font READ font CONSTANT) + Q_PROPERTY(ZShell::config::AnimTokens* anim READ anim CONSTANT) + + public: + explicit Tokens(QObject* parent = nullptr) + : QObject(parent) + , m_font(new FontTokens(this)) + , m_anim(new AnimTokens(this)) { + m_anim->bindCurves(TokensSingleton::instance()->appearance()->curves()); + m_anim->bindDurations( + ConfigSingleton::instance()->appearance()->anim()->durations()); + m_font->bindFont(ConfigSingleton::instance()->appearance()->font()); + } + + [[nodiscard]] AppearanceRounding* rounding() const { + return ConfigSingleton::instance()->appearance()->rounding(); + } + [[nodiscard]] AppearancePadding* padding() const { + return ConfigSingleton::instance()->appearance()->padding(); + } + [[nodiscard]] AppearanceSpacing* spacing() const { + return ConfigSingleton::instance()->appearance()->spacing(); + } + [[nodiscard]] SizeTokens* sizes() const { + return TokensSingleton::instance()->sizes(); + } + [[nodiscard]] FontTokens* font() const { return m_font; } + [[nodiscard]] AnimTokens* anim() const { return m_anim; } + + static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } + + private: + FontTokens* m_font; + AnimTokens* m_anim; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/screenshot.hpp b/Plugins/ZShell/Config/screenshot.hpp index 101ea41..a38828b 100644 --- a/Plugins/ZShell/Config/screenshot.hpp +++ b/Plugins/ZShell/Config/screenshot.hpp @@ -1,27 +1,23 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Screenshot : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Screenshot : public settings::ObjectNode { + CONFIG_NODE(Screenshot, settings::ObjectNode) - CFG_PROPERTY(bool, enable_pp, false) - CFG_PROPERTY(QString, mode, QStringLiteral("manual")) - CFG_PROPERTY(int, radius, 49) - CFG_PROPERTY(bool, rounding, false) - CFG_PROPERTY(bool, saveOnCopy, false) - CFG_PROPERTY(bool, shadow, true) - CFG_PROPERTY(int, shadow_blur, 0) - CFG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) - CFG_PROPERTY(int, shadow_offset_x, 30) - CFG_PROPERTY(int, shadow_offset_y, 50) - - public: - explicit Screenshot(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enable_pp, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("manual")) + CONFIG_PROPERTY(int, radius, 49) + CONFIG_PROPERTY(bool, rounding, false) + CONFIG_PROPERTY(bool, saveOnCopy, false) + CONFIG_PROPERTY(bool, shadow, true) + CONFIG_PROPERTY(int, shadow_blur, 0) + CONFIG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) + CONFIG_PROPERTY(int, shadow_offset_x, 30) + CONFIG_PROPERTY(int, shadow_offset_y, 50) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 45d2c2e..8e6891c 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,54 +1,43 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" + +#include "common.hpp" +#include "enums.hpp" namespace ZShell::config { -class PlayerAliasEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class PlayerAliasEntry : public settings::ObjectNode { + CONFIG_NODE(PlayerAliasEntry, settings::ObjectNode) - CFG_PROPERTY(QString, from) - CFG_PROPERTY(QString, to) - - public: - explicit PlayerAliasEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("from")}; - } + CONFIG_PROPERTY(QString, from, {}) + CONFIG_PROPERTY(QString, to, {}) }; CONFIG_LIST_TYPE(PlayerAliasEntry, PlayerAliasList) -class Services : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Services : public settings::ObjectNode { + CONFIG_NODE(Services, settings::ObjectNode) - CFG_PROPERTY(qreal, audioIncrement, 0.1) - CFG_PROPERTY(qreal, brightnessIncrement, 0.1) - CFG_PROPERTY(bool, ddcutilService, true) - CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) - CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) - CFG_PROPERTY(qreal, maxVolume, 1) - CFG_PROPERTY(qreal, minBrightness, 0.01) + CONFIG_PROPERTY(qreal, audioIncrement, 0.1) + CONFIG_PROPERTY(qreal, brightnessIncrement, 0.1) + CONFIG_PROPERTY(bool, ddcutilService, true) + CONFIG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) + CONFIG_ENUM_PROPERTY(GpuType, gpuType, GpuType::Auto) + CONFIG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) + CONFIG_PROPERTY(qreal, maxVolume, 1) + CONFIG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( PlayerAliasList, playerAliases, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"from", QStringLiteral("com.github.th_ch.youtube_music")}, {"to", QStringLiteral("YT Music")}}), - }) - CFG_PROPERTY(bool, updates, true) - CFG_PROPERTY(bool, useFahrenheit, false) - CFG_PROPERTY(bool, useTwelveHourClock, false) - CFG_PROPERTY(int, visualizerBars, 50) - CFG_PROPERTY(QString, weatherLocation, QString()) - - public: - explicit Services(QObject* parent = nullptr) : ConfigObject(parent) {} + })) + CONFIG_PROPERTY(bool, updates, true) + CONFIG_PROPERTY(bool, useFahrenheit, false) + CONFIG_PROPERTY(bool, useTwelveHourClock, false) + CONFIG_PROPERTY(int, visualizerBars, 50) + CONFIG_PROPERTY(QString, weatherLocation, QString()) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/sidebar.hpp b/Plugins/ZShell/Config/sidebar.hpp index bd9840a..f9db473 100644 --- a/Plugins/ZShell/Config/sidebar.hpp +++ b/Plugins/ZShell/Config/sidebar.hpp @@ -1,30 +1,21 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class SidebarSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SidebarSizes : public settings::ObjectNode { + CONFIG_NODE(SidebarSizes, settings::ObjectNode) - CFG_PROPERTY(int, width, 520) - - public: - explicit SidebarSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, width, 520) }; -class Sidebar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Sidebar : public settings::ObjectNode { + CONFIG_NODE(Sidebar, settings::ObjectNode) - CFG_PROPERTY(int, dragThreshold, 30) - CFG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, dragThreshold, 30) + CONFIG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(SidebarSizes, sizes) - - public: - explicit Sidebar(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new SidebarSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index b58d8ea..c19848f 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -1,292 +1,174 @@ #pragma once -#include "configobject.hpp" -#include "anim.hpp" - -#include -#include +#include #include #include #include -#include #include +#include + +#include "settings/objectnode.hpp" +#include "anim.hpp" +#include "common.hpp" +#include "font.hpp" + namespace ZShell::config { -class AppearanceRounding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimCurves : public settings::ObjectNode { + CONFIG_NODE(AnimCurves, settings::ObjectNode) - CFG_PROPERTY(qreal, extraSmall, 4) - CFG_PROPERTY(qreal, full, std::numeric_limits::max()) - CFG_PROPERTY(qreal, large, 16) - CFG_PROPERTY(qreal, largeIncreased, 20) - CFG_PROPERTY(qreal, extraLarge, 28) - CFG_PROPERTY(qreal, extraLargeIncreased, 32) - CFG_PROPERTY(qreal, extraExtraLarge, 48) - CFG_PROPERTY(qreal, medium, 12) - CFG_PROPERTY(qreal, scale, 1) - CFG_PROPERTY(qreal, small, 8) - - public: - explicit AppearanceRounding(QObject* parent = nullptr) - : ConfigObject(parent) {} + CONFIG_GLOBAL_PROPERTY( + QList, + emphasized, + DEFAULT_ARG( + {0.05, + 0, + 2.0 / 15.0, + 0.06, + 1.0 / 6.0, + 0.4, + 5.0 / 24.0, + 0.82, + 0.25, + 1, + 1, + 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedAccel, DEFAULT_ARG({0.3, 0, 0.8, 0.15, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedDecel, DEFAULT_ARG({0.05, 0.7, 0.1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standard, DEFAULT_ARG({0.2, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardAccel, DEFAULT_ARG({0.3, 0, 1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardDecel, DEFAULT_ARG({0, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastSpatial, + DEFAULT_ARG({0.42, 1.67, 0.21, 0.9, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultSpatial, + DEFAULT_ARG({0.38, 1.21, 0.22, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowSpatial, + DEFAULT_ARG({0.39, 1.29, 0.35, 0.98, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastEffects, + DEFAULT_ARG({0.31, 0.94, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultEffects, + DEFAULT_ARG({0.34, 0.8, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowEffects, + DEFAULT_ARG({0.34, 0.88, 0.34, 1, 1, 1})) }; -class AppearancePadding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class RoundingTokens : public settings::ObjectNode { + CONFIG_NODE(RoundingTokens, settings::ObjectNode) - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, smaller, 5) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearancePadding(QObject* parent = nullptr) - : ConfigObject(parent) {} + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) + CONFIG_PROPERTY(int, full, std::numeric_limits::max()) }; -class AppearanceSpacing : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SpacingTokens : public settings::ObjectNode { + CONFIG_NODE(SpacingTokens, settings::ObjectNode) - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearanceSpacing(QObject* parent = nullptr) - : ConfigObject(parent) {} + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) }; -class FontSizeTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class PaddingTokens : public settings::ObjectNode { + CONFIG_NODE(PaddingTokens, settings::ObjectNode) - CFG_PROPERTY(int, small, 11) - CFG_PROPERTY(int, smaller, 12) - CFG_PROPERTY(int, normal, 13) - CFG_PROPERTY(int, larger, 15) - CFG_PROPERTY(int, large, 18) - CFG_PROPERTY(int, extraLarge, 28) - - public: - explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, smaller, 5) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) }; -class FontTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimDurationTokens : public settings::ObjectNode { + CONFIG_NODE(AnimDurationTokens, settings::ObjectNode) - CONFIG_SUBOBJECT(FontSizeTokens, size) - - public: - explicit FontTokens(QObject* parent = nullptr) - : ConfigObject(parent), m_size(new FontSizeTokens(this)) {} + CONFIG_GLOBAL_PROPERTY(int, small, 200) + CONFIG_GLOBAL_PROPERTY(int, normal, 400) + CONFIG_GLOBAL_PROPERTY(int, large, 600) + CONFIG_GLOBAL_PROPERTY(int, extraLarge, 1000) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastSpatial, 350) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultSpatial, 500) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowSpatial, 650) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastEffects, 150) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultEffects, 200) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowEffects, 300) }; -class AnimCurves : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearanceTokens : public settings::ObjectNode { + CONFIG_NODE(AppearanceTokens, settings::ObjectNode) - CFG_PROPERTY(QList, emphasized) - CFG_PROPERTY(QList, emphasizedAccel) - CFG_PROPERTY(QList, emphasizedDecel) - CFG_PROPERTY(QList, standard) - CFG_PROPERTY(QList, standardAccel) - CFG_PROPERTY(QList, standardDecel) - CFG_PROPERTY(QList, expressiveFastSpatial) - CFG_PROPERTY(QList, expressiveDefaultSpatial) - CFG_PROPERTY(QList, expressiveSlowSpatial) - CFG_PROPERTY(QList, expressiveFastEffects) - CFG_PROPERTY(QList, expressiveDefaultEffects) - CFG_PROPERTY(QList, expressiveSlowEffects) - - public: - explicit AnimCurves(QObject* parent = nullptr) - : ConfigObject(parent) - , m_emphasized( - {0.05, - 0, - 2.0 / 15.0, - 0.06, - 1.0 / 6.0, - 0.4, - 5.0 / 24.0, - 0.82, - 0.25, - 1, - 1, - 1}) - , m_emphasizedAccel({0.3, 0, 0.8, 0.15, 1, 1}) - , m_emphasizedDecel({0.05, 0.7, 0.1, 1, 1, 1}) - , m_standard({0.2, 0, 0, 1, 1, 1}) - , m_standardAccel({0.3, 0, 1, 1, 1, 1}) - , m_standardDecel({0, 0, 0, 1, 1, 1}) - , m_expressiveFastSpatial({0.42, 1.67, 0.21, 0.9, 1, 1}) - , m_expressiveDefaultSpatial({0.38, 1.21, 0.22, 1, 1, 1}) - , m_expressiveSlowSpatial({0.39, 1.29, 0.35, 0.98, 1, 1}) - , m_expressiveFastEffects({0.31, 0.94, 0.34, 1, 1, 1}) - , m_expressiveDefaultEffects({0.34, 0.8, 0.34, 1, 1, 1}) - , m_expressiveSlowEffects({0.34, 0.88, 0.34, 1, 1, 1}) {} + CONFIG_SUBOBJECT(AnimCurves, curves) + CONFIG_SUBOBJECT(RoundingTokens, rounding) + CONFIG_SUBOBJECT(SpacingTokens, spacing) + CONFIG_SUBOBJECT(PaddingTokens, padding) + CONFIG_SUBOBJECT(AnimDurationTokens, animDurations) }; -class AnimDurations : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class BarTokens : public settings::ObjectNode { + CONFIG_NODE(BarTokens, settings::ObjectNode) - CFG_PROPERTY(int, small, 200) - CFG_PROPERTY(int, scale, 1) - CFG_PROPERTY(int, normal, 400) - CFG_PROPERTY(int, large, 600) - CFG_PROPERTY(int, extraLarge, 1000) - CFG_PROPERTY(int, expressiveFastSpatial, 350) - CFG_PROPERTY(int, expressiveDefaultSpatial, 500) - CFG_PROPERTY(int, expressiveSlowSpatial, 650) - CFG_PROPERTY(int, expressiveFastEffects, 150) - CFG_PROPERTY(int, expressiveDefaultEffects, 200) - CFG_PROPERTY(int, expressiveSlowEffects, 300) - - public: - explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, innerSize, 28) }; -class AnimTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class DashboardTokens : public settings::ObjectNode { + CONFIG_NODE(DashboardTokens, settings::ObjectNode) - CONFIG_SUBOBJECT(AnimDurations, durations) - - Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedAccel READ emphasizedAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedDecel READ emphasizedDecel NOTIFY curvesChanged) - Q_PROPERTY(QEasingCurve standard READ standard NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardAccel READ standardAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardDecel READ standardDecel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastSpatial READ expressiveFastSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultSpatial READ expressiveDefaultSpatial - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastEffects READ expressiveFastEffects NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultEffects READ expressiveDefaultEffects - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY - curvesChanged) - - public: - explicit AnimTokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_curves(new AnimCurves(this)) - , m_durations(new AnimDurations(this)) - , m_easing(new AnimEasing(this)) { - connect( - m_easing, - &AnimEasing::curvesChanged, - this, - &AnimTokens::curvesChanged); - m_easing->bindCurves(m_curves); - } - - [[nodiscard]] QEasingCurve emphasized() const { - return m_easing->emphasized(); - } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_easing->emphasizedAccel(); - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_easing->emphasizedDecel(); - } - [[nodiscard]] QEasingCurve standard() const { return m_easing->standard(); } - [[nodiscard]] QEasingCurve standardAccel() const { - return m_easing->standardAccel(); - } - [[nodiscard]] QEasingCurve standardDecel() const { - return m_easing->standardDecel(); - } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_easing->expressiveFastSpatial(); - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_easing->expressiveDefaultSpatial(); - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_easing->expressiveSlowSpatial(); - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_easing->expressiveFastEffects(); - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_easing->expressiveDefaultEffects(); - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_easing->expressiveSlowEffects(); - } - - signals: - void curvesChanged(); - - private: - AnimCurves* m_curves; - AnimEasing* m_easing; + CONFIG_PROPERTY(int, processesWidth, 1200) + CONFIG_PROPERTY(int, processesHeight, 800) + CONFIG_PROPERTY(int, processesHeaderHeight, 40) + CONFIG_PROPERTY(int, processesRowHeight, 40) + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, mediaVisualizerSize, 200) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, resourceProgessThickness, 10) + CONFIG_PROPERTY(int, resourceSize, 200) + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, weatherWidth, 250) }; -class BarTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SizeTokens : public settings::ObjectNode { + CONFIG_NODE(SizeTokens, settings::ObjectNode) - CFG_PROPERTY(int, innerSize, 28) - - public: - explicit BarTokens(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class Tokens : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - CONFIG_SUBOBJECT(AppearanceRounding, rounding) - CONFIG_SUBOBJECT(AppearancePadding, padding) - CONFIG_SUBOBJECT(AppearanceSpacing, spacing) - CONFIG_SUBOBJECT(FontTokens, font) - CONFIG_SUBOBJECT(AnimTokens, anim) + CONFIG_SUBOBJECT(DashboardTokens, dashboard) CONFIG_SUBOBJECT(BarTokens, bar) - - public: - explicit Tokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_rounding(new AppearanceRounding(this)) - , m_padding(new AppearancePadding(this)) - , m_spacing(new AppearanceSpacing(this)) - , m_font(new FontTokens(this)) - , m_anim(new AnimTokens(this)) - , m_bar(new BarTokens(this)) {} - - static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/utilities.hpp b/Plugins/ZShell/Config/utilities.hpp index 4d3fc21..553754f 100644 --- a/Plugins/ZShell/Config/utilities.hpp +++ b/Plugins/ZShell/Config/utilities.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configobject.hpp" -#include "configlist.hpp" +#include "common.hpp" #include #include @@ -9,75 +8,57 @@ namespace ZShell::config { -class UtilitiesSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UtilitiesSizes : public settings::ObjectNode { + CONFIG_NODE(UtilitiesSizes, settings::ObjectNode) - CFG_PROPERTY(int, toastWidth, 430) - CFG_PROPERTY(int, width, 430) - - public: - explicit UtilitiesSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, toastWidth, 430) + CONFIG_PROPERTY(int, width, 430) }; -class Toasts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Toasts : public settings::ObjectNode { + CONFIG_NODE(Toasts, settings::ObjectNode) - CFG_PROPERTY(bool, audioInputChanged, true) - CFG_PROPERTY(bool, audioOutputChanged, true) - CFG_PROPERTY(bool, capsLockChanged, true) - CFG_PROPERTY(bool, chargingChanged, true) - CFG_PROPERTY(bool, configLoaded, true) - CFG_PROPERTY(bool, dndChanged, true) - CFG_PROPERTY(bool, gameModeChanged, true) - CFG_PROPERTY(bool, kbLayoutChanged, true) - CFG_PROPERTY(bool, kbLimit, true) - CFG_PROPERTY(bool, nowPlaying, false) - CFG_PROPERTY(bool, numLockChanged, true) - CFG_PROPERTY(bool, vpnChanged, true) - - public: - explicit Toasts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, audioInputChanged, true) + CONFIG_PROPERTY(bool, audioOutputChanged, true) + CONFIG_PROPERTY(bool, capsLockChanged, true) + CONFIG_PROPERTY(bool, chargingChanged, true) + CONFIG_PROPERTY(bool, configLoaded, true) + CONFIG_PROPERTY(bool, dndChanged, true) + CONFIG_PROPERTY(bool, gameModeChanged, true) + CONFIG_PROPERTY(bool, kbLayoutChanged, true) + CONFIG_PROPERTY(bool, kbLimit, true) + CONFIG_PROPERTY(bool, nowPlaying, false) + CONFIG_PROPERTY(bool, numLockChanged, true) + CONFIG_PROPERTY(bool, vpnChanged, true) }; -class Vpn : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Vpn : public settings::ObjectNode { + CONFIG_NODE(Vpn, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(QStringList, provider, (QStringList{QStringLiteral("netbird")})) - - public: - explicit Vpn(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY( + QStringList, provider, (QStringList{QStringLiteral("netbird")})) }; -class Utilities : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Utilities : public settings::ObjectNode { + CONFIG_NODE(Utilities, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxToasts, 4) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxToasts, 4) CONFIG_SUBOBJECT(UtilitiesSizes, sizes) CONFIG_SUBOBJECT(Toasts, toasts) CONFIG_SUBOBJECT(Vpn, vpn) CONFIG_LIST( EntryList, quickToggles, - {LIST_ENTRY(dnd, true), - LIST_ENTRY(wifi, true), - LIST_ENTRY(settings, true), - LIST_ENTRY(bluetooth, true), - LIST_ENTRY(mic, true), - LIST_ENTRY(audio, true), - LIST_ENTRY(gameMode, true)}) - - public: - explicit Utilities(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new UtilitiesSizes(this)) - , m_toasts(new Toasts(this)) - , m_vpn(new Vpn(this)) {} + DEFAULT_ARG( + {LIST_ENTRY(dnd, true), + LIST_ENTRY(wifi, true), + LIST_ENTRY(settings, true), + LIST_ENTRY(bluetooth, true), + LIST_ENTRY(mic, true), + LIST_ENTRY(audio, true), + LIST_ENTRY(gameMode, true)})) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/HyprPlugins/CMakeLists.txt b/Plugins/ZShell/HyprPlugins/CMakeLists.txt new file mode 100644 index 0000000..0316838 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/CMakeLists.txt @@ -0,0 +1,12 @@ +pkg_check_modules(HYPRLAND REQUIRED hyprland) + +add_library(zshell-nightlight SHARED main.cpp) + +target_compile_features(zshell-nightlight PRIVATE cxx_std_23) +target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS}) +target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS}) +target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER}) + +target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique) + +install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins") diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp new file mode 100644 index 0000000..0da6f88 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include + +namespace ZShell::services { + +inline std::array kelvinToGain(int kelvin) { + const double t = std::clamp(kelvin, 1000, 40000) / 100.0; + double r, g, b; + + r = t <= 66.0 + ? 1.0 + : std::clamp( + 1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0); + g = t <= 66.0 + ? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0) + : std::clamp( + 1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0); + b = t >= 66.0 + ? 1.0 + : (t <= 19.0 + ? 0.0 + : std::clamp( + 0.54320678911 * std::log(t - 10.0) - 1.19625408914, + 0.0, + 1.0)); + + return {static_cast(r), static_cast(g), static_cast(b)}; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp new file mode 100644 index 0000000..8e8f2e4 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -0,0 +1,447 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include +#include +#include +#undef private + +#include + +extern "C" { +#include +#include +} + +#include "colortemp.hpp" + +using ZShell::services::kelvinToGain; + +using Render::IFramebuffer; +using Render::GL::CHyprOpenGLImpl; +using Render::GL::g_pHyprOpenGL; + +inline HANDLE PHANDLE = nullptr; + +static std::filesystem::path g_runtimeDir{"/tmp"}; +static std::filesystem::path g_cacheBase{"/tmp/.cache"}; + +static void initEnvPaths() { + if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env; + + if (const char* env = getenv("XDG_CACHE_HOME")) { + if (*env) g_cacheBase = env; + } else if (const char* home = getenv("HOME")) { + g_cacheBase = std::filesystem::path(home) / ".cache"; + } +} + +static std::ofstream& debugLog() { + static std::ofstream log( + g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app); + return log; +} + +static std::mutex g_logMutex; +static std::atomic g_logSeq{0}; + +static void dlog(const std::string& msg) { + std::lock_guard lk(g_logMutex); + auto& log = debugLog(); + log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] " + << msg << std::endl; +} + +static constexpr int BAKED_STEPS = 24; + +static std::string glslFloat(float v) { + std::ostringstream ss; + ss << std::fixed << std::setprecision(6) << v; + return ss.str(); +} + +static std::filesystem::path cacheDir() { + return g_cacheBase / "zshell" / "night-light"; +} + +static std::filesystem::path shaderPath() { + return cacheDir() / "nightlight.frag"; +} + +static std::filesystem::path bakedShaderPath(const std::array& gain) { + return cacheDir() / + ("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) + + "-" + glslFloat(gain[2]) + ".frag"); +} + +static std::string defaultShaderSource() { + return "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "uniform vec3 tint;\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n" + "}\n"; +} + +static std::string bakedShaderSource(const std::array& gain) { + std::ostringstream src; + src << "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "const vec3 GAIN = vec3(" + << glslFloat(gain[0]) << "," << glslFloat(gain[1]) << "," + << glslFloat(gain[2]) + << ");\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n" + "}\n"; + return src.str(); +} + +static void ensureShaderFile( + const std::filesystem::path& path, const std::string& source) { + std::error_code ec; + if (std::filesystem::is_regular_file(path, ec)) return; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + out << source; +} + +static bool g_active = false; +static bool g_uniformMode = false; +static bool g_modeDecided = false; +static std::array g_currentGain{1.f, 1.f, 1.f}; + +static int g_currentKelvin = 6500; +static int g_lastKelvin = 6500; + +struct STransition { + bool active = false; + std::chrono::steady_clock::time_point start; + float durationSec = 0.4f; + std::array fromGain{1.f, 1.f, 1.f}; + std::array toGain{1.f, 1.f, 1.f}; + int lastBakedStep = -1; +}; +static STransition g_transition; + +static void postStateEvent(bool enabled, bool animating); + +static bool gainIsNeutral(const std::array& gain) { + for (float v : gain) + if (std::abs(v - 1.f) > 0.001f) return false; + return true; +} + +static CFunctionHook* g_pBeginHook = nullptr; +using origBegin = void (*)( + CHyprOpenGLImpl*, + PHLMONITOR, + const CRegion&, + SP, + std::optional); + +static CFunctionHook* g_pSaveMirrorHook = nullptr; +using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&); + +static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { + const bool saved = thisptr->m_applyFinalShader; + thisptr->m_applyFinalShader = false; + const bool result = (*reinterpret_cast( + g_pSaveMirrorHook->m_original))(thisptr, box); + thisptr->m_applyFinalShader = saved; + return result; +} + +static void installShader() { + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + ensureShaderFile(shaderPath(), defaultShaderSource()); + gl->applyScreenShader(shaderPath().string()); + + auto* shader = gl->m_finalScreenShader.get(); + if (shader && shader->program() >= 1 && + shader->getUniformLocation(SHADER_TINT) != -1) { + g_uniformMode = true; + } else { + g_uniformMode = false; + if (!g_modeDecided) + dlog( + "shader has no 'tint' uniform, falling back to " + "per-step recompiled shaders"); + } + g_modeDecided = true; +} + +static void setUniformGain(const std::array& gain) { + auto* gl = g_pHyprOpenGL.get(); + auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; + if (!gl || !shader) return; + + gl->useShader(gl->m_finalScreenShader); + shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); +} + +static void hkBegin( + CHyprOpenGLImpl* thisptr, + PHLMONITOR mon, + const CRegion& damage, + SP fb, + std::optional finalDamage) { + (*reinterpret_cast( + g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); + + if (fb) { + auto* renderer = g_pHyprRenderer.get(); + if (renderer) renderer->m_renderData.blockScreenShader = true; + } + + if (!g_active) return; + + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1) + installShader(); + if (!gl->m_finalScreenShader.get() || + gl->m_finalScreenShader->program() < 1) + return; + + if (g_transition.active) { + auto* monPtr = mon.get(); + if (!monPtr) return; + const float elapsed = + std::chrono::duration( + std::chrono::steady_clock::now() - g_transition.start) + .count(); + const float t = + std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); + + for (size_t i = 0; i < 3; ++i) + g_currentGain[i] = + g_transition.fromGain[i] + + (g_transition.toGain[i] - g_transition.fromGain[i]) * t; + + if (g_uniformMode) { + setUniformGain(g_currentGain); + } else { + const int step = static_cast(t * BAKED_STEPS); + if (step != g_transition.lastBakedStep) { + g_transition.lastBakedStep = step; + ensureShaderFile( + bakedShaderPath(g_currentGain), + bakedShaderSource(g_currentGain)); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } + } + + if (t >= 1.f) { + g_transition.active = false; + if (gainIsNeutral(g_currentGain)) { + g_active = false; + gl->applyScreenShader(""); + } + postStateEvent(g_active, false); + } else { + monPtr->m_forceFullFrames = 2; + monPtr->scheduleFrame(); + } + } else if (g_uniformMode) { + setUniformGain(g_currentGain); + } +} + +static constexpr int KELVIN_MIN = 2000; +static constexpr int KELVIN_MAX = 6500; + +static int luaNightlightSet(lua_State* L) { + const int rawKelvin = static_cast(luaL_checknumber(L, 1)); + const float durationSec = + lua_gettop(L) >= 2 ? static_cast(luaL_checknumber(L, 2)) : 0.4f; + + const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX); + if (targetKelvin != rawKelvin) + dlog( + "clamped " + std::to_string(rawKelvin) + "K to " + + std::to_string(targetKelvin) + "K"); + + g_transition.fromGain = g_currentGain; + g_transition.toGain = kelvinToGain(targetKelvin); + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + g_active = true; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = targetKelvin; + + postStateEvent(true, true); + + lua_pushinteger(L, targetKelvin); + return 1; +} + +static int luaNightlightDisable(lua_State* L) { + if (!g_active) return 0; + + const float durationSec = + lua_gettop(L) >= 1 ? static_cast(luaL_checknumber(L, 1)) : 0.4f; + + g_transition.fromGain = g_currentGain; + g_transition.toGain = {1.f, 1.f, 1.f}; + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = 6500; + + postStateEvent(false, true); + + return 0; +} + +static int luaNightlightState(lua_State* L) { + lua_pushstring( + L, + std::format( + "{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}", + g_active ? "true" : "false", + g_transition.active ? "true" : "false", + g_lastKelvin, + g_currentKelvin) + .c_str()); + return 1; +} + +static void postStateEvent(bool enabled, bool animating) { + if (!g_pEventManager) return; + g_pEventManager->postEvent( + SHyprIPCEvent{ + .event = "zshell-nightlight", + .data = std::format( + "{},{},{},{}", + enabled ? 1 : 0, + animating ? 1 : 0, + g_lastKelvin, + g_currentKelvin)}); +} + +static const SFunctionMatch* findExactMethod( + const std::vector& methods, + const std::string& classAndMethod) { + for (const auto& m : methods) { + if (m.signature.find(classAndMethod) != std::string::npos) return &m; + } + return nullptr; +} + +static std::string manglePrefix( + const std::string& className, const std::string& methodName) { + return std::to_string(className.size()) + className + + std::to_string(methodName.size()) + methodName; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + initEnvPaths(); + dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ==="); + + const std::string serverHash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (serverHash != clientHash) { + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] Mismatched headers! Can't proceed.", + CHyprColor{1.0f, 0.2f, 0.2f, 1.0f}, + 5000); + throw std::runtime_error("[zshell-nightlight] version mismatch"); + } + + static const auto SAVEMIRROR_METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror"); + const SFunctionMatch* saveMirrorTarget = findExactMethod( + SAVEMIRROR_METHODS, + manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror")); + + if (saveMirrorTarget) { + g_pSaveMirrorHook = HyprlandAPI::createFunctionHook( + PHANDLE, + saveMirrorTarget->address, + reinterpret_cast(&hkSaveBufferForMirror)); + if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook()) + dlog( + "saveBufferForMirror hook installed: " + + saveMirrorTarget->signature); + } + + if (!g_pSaveMirrorHook) + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — " + "captures may still show the nightlight", + CHyprColor{1.f, 0.6f, 0.2f, 1.f}, + 8000); + + static const auto METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); + const SFunctionMatch* target = + findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin")); + if (!target) + throw std::runtime_error( + "[zshell-nightlight] couldn't uniquely resolve " + "CHyprOpenGLImpl::begin"); + + g_pBeginHook = HyprlandAPI::createFunctionHook( + PHANDLE, target->address, reinterpret_cast(&hkBegin)); + if (!g_pBeginHook || !g_pBeginHook->hook()) + throw std::runtime_error("[zshell-nightlight] failed to hook begin()"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlSet", luaNightlightSet)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlDisable", luaNightlightDisable)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlState", luaNightlightState)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + return { + "zshell-nightlight", + "Animated nightlight, excluded from screencopy", + "ZShell", + "1.2"}; +} + + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + + +APICALL EXPORT void PLUGIN_EXIT() {} diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 0726068..b2c039a 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,19 +1,28 @@ +wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") +wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") +wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") +wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") + qml_module(ZShell-internal URI ZShell.Internal + INCLUDE_PREFIX internal SOURCES hyprextras.hpp hyprextras.cpp hyprdevices.hpp hyprdevices.cpp cachingimagemanager.hpp cachingimagemanager.cpp circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp - sparklineitem.hpp sparklineitem.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp + wallpaperservice.hpp wallpaperservice.cpp + wallpapersurface.hpp wallpapersurface.cpp + wallpaperobject.hpp wallpaperobject.cpp lidwatcher.hpp lidwatcher.cpp visualizerbars.hpp visualizerbars.cpp linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp + cursor.hpp LIBRARIES Qt::Gui Qt::Quick @@ -22,4 +31,12 @@ qml_module(ZShell-internal Qt::Network Qt::DBus Qt::CanvasPainter + Qt::GuiPrivate + wlp-layer-shell + wlp-xdg-shell + wlp-viewporter + wlp-fractional-scale + PkgConfig::EGL + PkgConfig::WAYLAND_EGL + PkgConfig::GL ) diff --git a/Plugins/ZShell/Internal/circularbuffer.cpp b/Plugins/ZShell/Internal/circularbuffer.cpp index b1c12bc..dcf7b8c 100644 --- a/Plugins/ZShell/Internal/circularbuffer.cpp +++ b/Plugins/ZShell/Internal/circularbuffer.cpp @@ -19,6 +19,8 @@ void CircularBuffer::setCapacity(int capacity) { m_capacity = capacity; m_data.resize(capacity); m_data.fill(0.0); + m_smoothed.resize(capacity); + m_smoothed.fill(0.0); m_head = 0; m_count = 0; @@ -30,6 +32,8 @@ void CircularBuffer::setCapacity(int capacity) { m_count++; } + recomputeAllSmoothed(); + emit capacityChanged(); emit countChanged(); emit valuesChanged(); @@ -56,6 +60,19 @@ qreal CircularBuffer::maximum() const { return maxVal; } +int CircularBuffer::smoothingRadius() const { + return m_smoothingRadius; +} + +void CircularBuffer::setSmoothingRadius(int radius) { + radius = std::max(0, radius); + if (m_smoothingRadius == radius) return; + m_smoothingRadius = radius; + recomputeAllSmoothed(); + emit smoothingRadiusChanged(); + emit valuesChanged(); +} + void CircularBuffer::push(qreal value) { if (m_capacity <= 0) return; @@ -65,6 +82,9 @@ void CircularBuffer::push(qreal value) { m_count++; emit countChanged(); } + + updateSmoothedTail(); + emit valuesChanged(); } @@ -85,4 +105,44 @@ qreal CircularBuffer::at(int index) const { return m_data[actualIndex]; } +qreal CircularBuffer::smoothedAt(int index) const { + if (index < 0 || index >= m_count) return 0.0; + + const int actualIndex = + (m_head - m_count + index + m_capacity) % m_capacity; + return m_smoothed[actualIndex]; +} + +void CircularBuffer::writeSmoothedAt(int logicalIndex) { + const int actualIndex = + (m_head - m_count + logicalIndex + m_capacity) % m_capacity; + + if (m_smoothingRadius <= 0) { + m_smoothed[actualIndex] = m_data[actualIndex]; + return; + } + + qreal sum = 0.0; + int sampleCount = 0; + for (int j = -m_smoothingRadius; j <= m_smoothingRadius; ++j) { + const int idx = logicalIndex + j; + if (idx < 0 || idx >= m_count) continue; + const int a = (m_head - m_count + idx + m_capacity) % m_capacity; + sum += m_data[a]; + sampleCount++; + } + m_smoothed[actualIndex] = sampleCount > 0 ? sum / sampleCount : 0.0; +} + +void CircularBuffer::updateSmoothedTail() { + const int start = std::max(0, m_count - 1 - m_smoothingRadius); + for (int i = start; i < m_count; ++i) + writeSmoothedAt(i); +} + +void CircularBuffer::recomputeAllSmoothed() { + for (int i = 0; i < m_count; ++i) + writeSmoothedAt(i); +} + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/circularbuffer.hpp b/Plugins/ZShell/Internal/circularbuffer.hpp index a2496f5..26475c1 100644 --- a/Plugins/ZShell/Internal/circularbuffer.hpp +++ b/Plugins/ZShell/Internal/circularbuffer.hpp @@ -15,6 +15,9 @@ class CircularBuffer : public QObject { Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(QList values READ values NOTIFY valuesChanged) Q_PROPERTY(qreal maximum READ maximum NOTIFY valuesChanged) + Q_PROPERTY( + int smoothingRadius READ smoothingRadius WRITE setSmoothingRadius NOTIFY + smoothingRadiusChanged) public: explicit CircularBuffer(QObject* parent = nullptr); @@ -26,20 +29,34 @@ class CircularBuffer : public QObject { [[nodiscard]] QList values() const; [[nodiscard]] qreal maximum() const; + [[nodiscard]] int smoothingRadius() const; + void setSmoothingRadius(int radius); + Q_INVOKABLE void push(qreal value); Q_INVOKABLE void clear(); Q_INVOKABLE [[nodiscard]] qreal at(int index) const; + Q_INVOKABLE [[nodiscard]] qreal smoothedAt(int index) const; + signals: void capacityChanged(); void countChanged(); void valuesChanged(); + void smoothingRadiusChanged(); private: + void writeSmoothedAt(int logicalIndex); + + void updateSmoothedTail(); + + void recomputeAllSmoothed(); + QVector m_data; + QVector m_smoothed; int m_head = 0; int m_count = 0; int m_capacity = 0; + int m_smoothingRadius = 0; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/cursor.hpp b/Plugins/ZShell/Internal/cursor.hpp new file mode 100644 index 0000000..7496195 --- /dev/null +++ b/Plugins/ZShell/Internal/cursor.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ZShell::internal { + +class Cursor : public QObject { + Q_OBJECT + QML_SINGLETON + QML_ELEMENT + + public: + Q_INVOKABLE void setOverride(Qt::CursorShape shape) { + if (m_shape == shape) return; + + if (m_shape) + QGuiApplication::changeOverrideCursor(QCursor(shape)); + else { + QGuiApplication::setOverrideCursor(QCursor(shape)); + } + m_shape = shape; + } + + Q_INVOKABLE void clearOverride() { + if (!m_shape) return; + + QGuiApplication::restoreOverrideCursor(); + m_shape.reset(); + } + + private: + std::optional m_shape; +}; + +} // namespace ZShell::internal 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/protocols/wlr-layer-shell-unstable-v1.xml b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml new file mode 100644 index 0000000..283f391 --- /dev/null +++ b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml @@ -0,0 +1,407 @@ + + + + Copyright © 2017 Drew DeVault + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Clients can use this interface to assign the surface_layer role to + wl_surfaces. Such surfaces are assigned to a "layer" of the output and + rendered with a defined z-depth respective to each other. They may also be + anchored to the edges and corners of a screen and specify input handling + semantics. This interface should be suitable for the implementation of + many desktop shell components, and a broad number of other applications + that interact with the desktop. + + + + + Create a layer surface for an existing surface. This assigns the role of + layer_surface, or raises a protocol error if another role is already + assigned. + + Creating a layer surface from a wl_surface which has a buffer attached + or committed is a client error, and any attempts by a client to attach + or manipulate a buffer prior to the first layer_surface.configure call + must also be treated as errors. + + After creating a layer_surface object and setting it up, the client + must perform an initial commit without any buffer attached. + The compositor will reply with a layer_surface.configure event. + The client must acknowledge it and is then allowed to attach a buffer + to map the surface. + + You may pass NULL for output to allow the compositor to decide which + output to use. Generally this will be the one that the user most + recently interacted with. + + Clients can specify a namespace that defines the purpose of the layer + surface. + + + + + + + + + + + + + + + + + These values indicate which layers a surface can be rendered in. They + are ordered by z depth, bottom-most first. Traditional shell surfaces + will typically be rendered between the bottom and top layers. + Fullscreen shell surfaces are typically rendered at the top layer. + Multiple surfaces can share a single layer, and ordering within a + single layer is undefined. + + + + + + + + + + + + + This request indicates that the client will not use the layer_shell + object any more. Objects that have been created through this instance + are not affected. + + + + + + + An interface that may be implemented by a wl_surface, for surfaces that + are designed to be rendered as a layer of a stacked desktop-like + environment. + + Layer surface state (layer, size, anchor, exclusive zone, + margin, interactivity) is double-buffered, and will be applied at the + time wl_surface.commit of the corresponding wl_surface is called. + + Attaching a null buffer to a layer surface unmaps it. + + Unmapping a layer_surface means that the surface cannot be shown by the + compositor until it is explicitly mapped again. The layer_surface + returns to the state it had right after layer_shell.get_layer_surface. + The client can re-map the surface by performing a commit without any + buffer attached, waiting for a configure event and handling it as usual. + + + + + Sets the size of the surface in surface-local coordinates. The + compositor will display the surface centered with respect to its + anchors. + + If you pass 0 for either value, the compositor will assign it and + inform you of the assignment in the configure event. You must set your + anchor to opposite edges in the dimensions you omit; not doing so is a + protocol error. Both values are 0 by default. + + Size is double-buffered, see wl_surface.commit. + + + + + + + + Requests that the compositor anchor the surface to the specified edges + and corners. If two orthogonal edges are specified (e.g. 'top' and + 'left'), then the anchor point will be the intersection of the edges + (e.g. the top left corner of the output); otherwise the anchor point + will be centered on that edge, or in the center if none is specified. + + Anchor is double-buffered, see wl_surface.commit. + + + + + + + Requests that the compositor avoids occluding an area with other + surfaces. The compositor's use of this information is + implementation-dependent - do not assume that this region will not + actually be occluded. + + A positive value is only meaningful if the surface is anchored to one + edge or an edge and both perpendicular edges. If the surface is not + anchored, anchored to only two perpendicular edges (a corner), anchored + to only two parallel edges or anchored to all edges, a positive value + will be treated the same as zero. + + A positive zone is the distance from the edge in surface-local + coordinates to consider exclusive. + + Surfaces that do not wish to have an exclusive zone may instead specify + how they should interact with surfaces that do. If set to zero, the + surface indicates that it would like to be moved to avoid occluding + surfaces with a positive exclusive zone. If set to -1, the surface + indicates that it would not like to be moved to accommodate for other + surfaces, and the compositor should extend it all the way to the edges + it is anchored to. + + For example, a panel might set its exclusive zone to 10, so that + maximized shell surfaces are not shown on top of it. A notification + might set its exclusive zone to 0, so that it is moved to avoid + occluding the panel, but shell surfaces are shown underneath it. A + wallpaper or lock screen might set their exclusive zone to -1, so that + they stretch below or over the panel. + + The default value is 0. + + Exclusive zone is double-buffered, see wl_surface.commit. + + + + + + + Requests that the surface be placed some distance away from the anchor + point on the output, in surface-local coordinates. Setting this value + for edges you are not anchored to has no effect. + + The exclusive zone includes the margin. + + Margin is double-buffered, see wl_surface.commit. + + + + + + + + + + Types of keyboard interaction possible for layer shell surfaces. The + rationale for this is twofold: (1) some applications are not interested + in keyboard events and not allowing them to be focused can improve the + desktop experience; (2) some applications will want to take exclusive + keyboard focus. + + + + + This value indicates that this surface is not interested in keyboard + events and the compositor should never assign it the keyboard focus. + + This is the default value, set for newly created layer shell surfaces. + + This is useful for e.g. desktop widgets that display information or + only have interaction with non-keyboard input devices. + + + + + Request exclusive keyboard focus if this surface is above the shell surface layer. + + For the top and overlay layers, the seat will always give + exclusive keyboard focus to the top-most layer which has keyboard + interactivity set to exclusive. If this layer contains multiple + surfaces with keyboard interactivity set to exclusive, the compositor + determines the one receiving keyboard events in an implementation- + defined manner. In this case, no guarantee is made when this surface + will receive keyboard focus (if ever). + + For the bottom and background layers, the compositor is allowed to use + normal focus semantics. + + This setting is mainly intended for applications that need to ensure + they receive all keyboard events, such as a lock screen or a password + prompt. + + + + + This requests the compositor to allow this surface to be focused and + unfocused by the user in an implementation-defined manner. The user + should be able to unfocus this surface even regardless of the layer + it is on. + + Typically, the compositor will want to use its normal mechanism to + manage keyboard focus between layer shell surfaces with this setting + and regular toplevels on the desktop layer (e.g. click to focus). + Nevertheless, it is possible for a compositor to require a special + interaction to focus or unfocus layer shell surfaces (e.g. requiring + a click even if focus follows the mouse normally, or providing a + keybinding to switch focus between layers). + + This setting is mainly intended for desktop shell components (e.g. + panels) that allow keyboard interaction. Using this option can allow + implementing a desktop shell that can be fully usable without the + mouse. + + + + + + + Set how keyboard events are delivered to this surface. By default, + layer shell surfaces do not receive keyboard events; this request can + be used to change this. + + This setting is inherited by child surfaces set by the get_popup + request. + + Layer surfaces receive pointer, touch, and tablet events normally. If + you do not want to receive them, set the input region on your surface + to an empty region. + + Keyboard interactivity is double-buffered, see wl_surface.commit. + + + + + + + This assigns an xdg_popup's parent to this layer_surface. This popup + should have been created via xdg_surface::get_popup with the parent set + to NULL, and this request must be invoked before committing the popup's + initial state. + + See the documentation of xdg_popup for more details about what an + xdg_popup is and how it is used. + + + + + + + When a configure event is received, if a client commits the + surface in response to the configure event, then the client + must make an ack_configure request sometime before the commit + request, passing along the serial of the configure event. + + If the client receives multiple configure events before it + can respond to one, it only has to ack the last configure event. + + A client is not required to commit immediately after sending + an ack_configure request - it may even ack_configure several times + before its next surface commit. + + A client may send multiple ack_configure requests before committing, but + only the last request sent before a commit indicates which configure + event the client really is responding to. + + + + + + + This request destroys the layer surface. + + + + + + The configure event asks the client to resize its surface. + + Clients should arrange their surface for the new states, and then send + an ack_configure request with the serial sent in this configure event at + some point before committing the new surface. + + The client is free to dismiss all but the last configure event it + received. + + The width and height arguments specify the size of the window in + surface-local coordinates. + + The size is a hint, in the sense that the client is free to ignore it if + it doesn't resize, pick a smaller size (to satisfy aspect ratio or + resize in steps of NxM pixels). If the client picks a smaller size and + is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the + surface will be centered on this axis. + + If the width or height arguments are zero, it means the client should + decide its own window dimension. + + + + + + + + + The closed event is sent by the compositor when the surface will no + longer be shown. The output may have been destroyed or the user may + have asked for it to be removed. Further changes to the surface will be + ignored. The client should destroy the resource after receiving this + event, and create a new surface if they so choose. + + + + + + + + + + + + + + + + + + + + + + + Change the layer that the surface is rendered on. + + Layer is double-buffered, see wl_surface.commit. + + + + + + + + + Requests an edge for the exclusive zone to apply. The exclusive + edge will be automatically deduced from anchor points when possible, + but when the surface is anchored to a corner, it will be necessary + to set it explicitly to disambiguate, as it is not possible to deduce + which one of the two corner edges should be used. + + The edge must be one the surface is anchored to, otherwise the + invalid_exclusive_edge protocol error will be raised. + + + + + diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp deleted file mode 100644 index c4f2314..0000000 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ /dev/null @@ -1,227 +0,0 @@ -#include "sparklineitem.hpp" - -#include -#include -#include -#include - -namespace ZShell::internal { - -SparklineItem::SparklineItem(QQuickItem* parent) : QQuickPaintedItem(parent) { - setAntialiasing(true); -} - -void SparklineItem::paint(QPainter* painter) { - const bool has1 = m_line1 && m_line1->count() >= 2; - const bool has2 = m_line2 && m_line2->count() >= 2; - if (!has1 && !has2) return; - - painter->setRenderHint(QPainter::Antialiasing, true); - - // Draw line1 first (behind), then line2 (in front) - if (has1) drawLine(painter, m_line1, m_line1Color, m_line1FillAlpha); - if (has2) drawLine(painter, m_line2, m_line2Color, m_line2FillAlpha); -} - -void SparklineItem::drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha) { - if (m_historyLength < 2) return; - - const qreal w = width(); - const qreal h = height(); - const int len = buffer->count(); - if (len < 2 || m_maxValue <= 0.0) return; - - const qreal stepX = w / static_cast(m_historyLength - 1); - const qreal startX = - w - (len - 1) * stepX - stepX * m_slideProgress + stepX; - - const qreal strokePad = qCeil(m_lineWidth / 2); - const qreal curvePad = 3.0; - const qreal topPad = strokePad + curvePad; - const qreal bottomPad = strokePad; - const qreal plotTop = topPad; - const qreal plotBottom = h - bottomPad; - const qreal fillBottom = h; - const qreal plotH = qMax(1.0, plotBottom - plotTop); - - QVector points; - points.reserve(len); - - for (int i = 0; i < len; ++i) { - const qreal x = startX + i * stepX; - const qreal value = qBound(0.0, buffer->at(i), m_maxValue); - const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; - points.append(QPointF(x, y)); - } - - QPainterPath linePath; - linePath.moveTo(points[0]); - - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; - - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - const QPointF c1(ctrlX, p0.y()); - const QPointF c2(ctrlX, p1.y()); - - linePath.cubicTo(c1, c2, p1); - } - - QPen pen(color, m_lineWidth); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - painter->setPen(pen); - painter->setBrush(Qt::NoBrush); - painter->drawPath(linePath); - - QPainterPath fillPath = linePath; - fillPath.lineTo(startX + (len - 1) * stepX, fillBottom); - fillPath.lineTo(startX, fillBottom); - fillPath.closeSubpath(); - - QColor fillColor = color; - fillColor.setAlphaF(static_cast(fillAlpha)); - painter->setPen(Qt::NoPen); - painter->setBrush(fillColor); - painter->drawPath(fillPath); -} - -void SparklineItem::connectBuffer(CircularBuffer* buffer) { - if (!buffer) return; - - connect(buffer, &CircularBuffer::valuesChanged, this, [this]() { - update(); - }); - connect(buffer, &QObject::destroyed, this, [this, buffer]() { - if (m_line1 == buffer) { - m_line1 = nullptr; - emit line1Changed(); - } - if (m_line2 == buffer) { - m_line2 = nullptr; - emit line2Changed(); - } - update(); - }); -} - -CircularBuffer* SparklineItem::line1() const { - return m_line1; -} - -void SparklineItem::setLine1(CircularBuffer* buffer) { - if (m_line1 == buffer) return; - if (m_line1) disconnect(m_line1, nullptr, this, nullptr); - m_line1 = buffer; - connectBuffer(buffer); - emit line1Changed(); - update(); -} - -CircularBuffer* SparklineItem::line2() const { - return m_line2; -} - -void SparklineItem::setLine2(CircularBuffer* buffer) { - if (m_line2 == buffer) return; - if (m_line2) disconnect(m_line2, nullptr, this, nullptr); - m_line2 = buffer; - connectBuffer(buffer); - emit line2Changed(); - update(); -} - -QColor SparklineItem::line1Color() const { - return m_line1Color; -} - -void SparklineItem::setLine1Color(const QColor& color) { - if (m_line1Color == color) return; - m_line1Color = color; - emit line1ColorChanged(); - update(); -} - -QColor SparklineItem::line2Color() const { - return m_line2Color; -} - -void SparklineItem::setLine2Color(const QColor& color) { - if (m_line2Color == color) return; - m_line2Color = color; - emit line2ColorChanged(); - update(); -} - -qreal SparklineItem::line1FillAlpha() const { - return m_line1FillAlpha; -} - -void SparklineItem::setLine1FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; - m_line1FillAlpha = alpha; - emit line1FillAlphaChanged(); - update(); -} - -qreal SparklineItem::line2FillAlpha() const { - return m_line2FillAlpha; -} - -void SparklineItem::setLine2FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; - m_line2FillAlpha = alpha; - emit line2FillAlphaChanged(); - update(); -} - -qreal SparklineItem::maxValue() const { - return m_maxValue; -} - -void SparklineItem::setMaxValue(qreal value) { - if (qFuzzyCompare(m_maxValue, value)) return; - m_maxValue = value; - emit maxValueChanged(); - update(); -} - -qreal SparklineItem::slideProgress() const { - return m_slideProgress; -} - -void SparklineItem::setSlideProgress(qreal progress) { - if (qFuzzyCompare(m_slideProgress, progress)) return; - m_slideProgress = progress; - emit slideProgressChanged(); - update(); -} - -int SparklineItem::historyLength() const { - return m_historyLength; -} - -void SparklineItem::setHistoryLength(int length) { - if (m_historyLength == length) return; - m_historyLength = length; - emit historyLengthChanged(); - update(); -} - -qreal SparklineItem::lineWidth() const { - return m_lineWidth; -} - -void SparklineItem::setLineWidth(qreal width) { - if (qFuzzyCompare(m_lineWidth, width)) return; - m_lineWidth = width; - emit lineWidthChanged(); - update(); -} - -} // namespace ZShell::internal 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 3a77d76..11b2d08 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -1,6 +1,7 @@ #include "strokecanvasrenderer.hpp" #include "strokecanvasitem.hpp" #include +#include namespace ZShell::internal { @@ -148,14 +149,27 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { m_penColor = canvas->m_penColor; m_penWidth = canvas->m_penWidth; - while (m_strokes.size() < canvas->m_strokes.size()) - m_strokes.append(canvas->m_strokes[m_strokes.size()]); + // Detect removed strokes: present in renderer but no longer in canvas + QSet canvasGroupIds; + for (const auto& stroke : canvas->m_strokes) + canvasGroupIds.insert(stroke.groupId); - if (canvas->m_strokes.isEmpty() && !m_strokes.isEmpty()) { - for (const auto& stroke : m_strokes) - if (stroke.groupId >= 0) - m_pendingGroupRemovals.append(stroke.groupId); - m_strokes.clear(); + 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); + m_strokes.remove(i); + } + } + + // Detect added strokes: present in canvas but not yet in renderer + QSet rendererGroupIds; + for (const auto& stroke : m_strokes) + rendererGroupIds.insert(stroke.groupId); + + for (const auto& stroke : canvas->m_strokes) { + if (!rendererGroupIds.contains(stroke.groupId)) + m_strokes.append(stroke); } m_currentStroke = canvas->m_currentStroke; diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Internal/wallpaperimage.cpp index b6ad891..f6de9de 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Internal/wallpaperimage.cpp @@ -285,13 +285,15 @@ QSGNode* WallpaperImage::updatePaintNode( Q_UNUSED(oldNode); if (m_hasPending) { + qDebug() << "pending format" << m_pendingImage.format() + << double(m_pendingImage.sizeInBytes()) / 1048576.0 << "MB"; if (m_previousTexture) { delete m_previousTexture; m_previousTexture = nullptr; } m_previousTexture = m_texture; m_texture = window()->createTextureFromImage( - m_pendingImage, QQuickWindow::TextureHasAlphaChannel); + m_pendingImage, QQuickWindow::TextureIsOpaque); m_pendingImage = QImage(); m_hasPending = false; diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Internal/wallpaperobject.cpp new file mode 100644 index 0000000..b11057e --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.cpp @@ -0,0 +1,83 @@ +#include "wallpaperobject.hpp" + +namespace ZShell::internal { + +WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {} + +WallpaperObject::~WallpaperObject() { + if (m_service && !m_screen.isEmpty()) m_service->removeScreen(m_screen); +} + +void WallpaperObject::registerScreen() { + if (!m_service || m_screen.isEmpty()) return; + + m_service->addScreen(m_screen); +} + +void WallpaperObject::setService(WallpaperService* service) { + if (m_service == service) return; + m_service = service; + emit serviceChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setScreen(const QString& screen) { + if (m_screen == screen) return; + m_screen = screen; + emit screenChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setCropX(qreal x) { + if (qFuzzyCompare(m_cropX, x)) return; + m_cropX = x; + emit cropXChanged(); + push(); +} + +void WallpaperObject::setCropY(qreal y) { + if (qFuzzyCompare(m_cropY, y)) return; + m_cropY = y; + emit cropYChanged(); + push(); +} + +void WallpaperObject::setCropWidth(qreal w) { + if (qFuzzyCompare(m_cropWidth, w)) return; + m_cropWidth = w; + emit cropWidthChanged(); + push(); +} + +void WallpaperObject::setCropHeight(qreal h) { + if (qFuzzyCompare(m_cropHeight, h)) return; + m_cropHeight = h; + emit cropHeightChanged(); + push(); +} + +void WallpaperObject::setZoom(qreal zoom) { + if (qFuzzyCompare(m_zoom, zoom)) return; + m_zoom = zoom; + emit zoomChanged(); + push(); +} + +void WallpaperObject::push() { + if (!m_service || m_screen.isEmpty()) return; + + QVariantMap crop; + crop.insert("x", m_cropX); + crop.insert("y", m_cropY); + crop.insert("width", m_cropWidth); + crop.insert("height", m_cropHeight); + crop.insert("zoom", m_zoom); + + m_service->setCrop(m_screen, crop); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Internal/wallpaperobject.hpp new file mode 100644 index 0000000..eb3d8a4 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +class WallpaperObject : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + WallpaperService* service READ service WRITE setService NOTIFY + serviceChanged) + Q_PROPERTY(QString screen READ screen WRITE setScreen NOTIFY screenChanged) + + Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged) + Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged) + Q_PROPERTY( + qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY + cropWidthChanged) + Q_PROPERTY( + qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY + cropHeightChanged) + Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) + + public: + explicit WallpaperObject(QObject* parent = nullptr); + ~WallpaperObject() override; + + WallpaperService* service() const { return m_service; } + void setService(WallpaperService* service); + + QString screen() const { return m_screen; } + void setScreen(const QString& screen); + + qreal cropX() const { return m_cropX; } + void setCropX(qreal x); + + qreal cropY() const { return m_cropY; } + void setCropY(qreal y); + + qreal cropWidth() const { return m_cropWidth; } + void setCropWidth(qreal w); + + qreal cropHeight() const { return m_cropHeight; } + void setCropHeight(qreal h); + + qreal zoom() const { return m_zoom; } + void setZoom(qreal zoom); + + signals: + void serviceChanged(); + void screenChanged(); + void cropXChanged(); + void cropYChanged(); + void cropWidthChanged(); + void cropHeightChanged(); + void zoomChanged(); + + private: + void registerScreen(); + + void push(); + + QPointer m_service; + QString m_screen; + + qreal m_cropX = 0.0; + qreal m_cropY = 0.0; + qreal m_cropWidth = 1.0; + qreal m_cropHeight = 1.0; + qreal m_zoom = 1.0; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp new file mode 100644 index 0000000..40fb00b --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -0,0 +1,743 @@ +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include "wallpaperservice.hpp" + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +namespace ZShell::internal { + +namespace { + +const char* kVertexShader = R"( +#version 300 es +void main() { + // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +)"; + +const char* kFragmentShader = R"( +#version 300 es +precision highp float; +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +out vec4 outColor; +void main() { + // The pixels are stored top-down, so the fragment coordinate is flipped. + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + outColor = texture(source, crop.xy + p * crop.zw); +} +)"; + +constexpr int kWaitTimeoutMs = 20; + +void logShader(uint32_t object, bool program) { + int ok = 0; + if (program) + glGetProgramiv(object, GL_LINK_STATUS, &ok); + else + glGetShaderiv(object, GL_COMPILE_STATUS, &ok); + + if (ok) return; + + char info[512] = {}; + if (program) + glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info); + else + glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); + + qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") + << "failed:" << info; +} + +QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { + QVariantMap crop = crops.value(screen).toMap(); + + if (crop.isEmpty()) { + crop.insert("x", 0.0); + crop.insert("y", 0.0); + crop.insert("width", 1.0); + crop.insert("height", 1.0); + crop.insert("zoom", 1.0); + } + + return crop; +} + +} // namespace + +WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {} + +WallpaperService::~WallpaperService() { + m_stop = true; + wake(); + + if (m_thread.joinable()) m_thread.join(); + + for (auto* surface : m_surfaces) + delete surface; + m_surfaces.clear(); + + if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) { + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + if (m_texture) glDeleteTextures(1, &m_texture); + if (m_program) glDeleteProgram(m_program); + } + + eglDestroyContext(m_eglDisplay, m_context); + } + + if (m_queue) wl_event_queue_destroy(m_queue); + + if (m_wake[0] >= 0) { + close(m_wake[0]); + close(m_wake[1]); + } + + if (m_layerShell) m_layerShell->destroy(); + if (m_viewporter) m_viewporter->destroy(); + if (m_scaleManager) m_scaleManager->destroy(); + if (m_registry) wl_registry_destroy(m_registry); +} + +QString WallpaperService::path() const { + std::lock_guard lock(m_mutex); + return m_path; +} + +void WallpaperService::setPath(const QString& path) { + if (m_path == path) return; + + { + std::lock_guard lock(m_mutex); + m_path = path; + + m_forceDecode = true; + m_imageChanged = true; + } + + emit pathChanged(); + + start(); + queueAll(); +} + +void WallpaperService::addScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (m_screens.contains(screen)) return; + + m_screens.append(screen); + } + + start(); + + queueAll(); +} + +void WallpaperService::removeScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (!m_screens.removeOne(screen)) return; + } + + queueAll(); +} + +const QVariantMap& WallpaperService::crops() const { + std::lock_guard lock(m_mutex); + return m_crops; +} + +void WallpaperService::setCrops(const QVariantMap& crops) { + { + std::lock_guard lock(m_mutex); + + if (m_crops == crops) return; + + m_crops = crops; + } + + emit cropsChanged(); + queueAll(); +} + +void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) { + WallpaperSurface* surface = nullptr; + + { + std::lock_guard lock(m_mutex); + + if (m_crops.value(screen) == crop) return; + + m_crops.insert(screen, crop); + + for (auto* candidate : m_surfaces) { + if (candidate->screen() == screen) { + surface = candidate; + break; + } + } + } + + emit cropsChanged(); + + if (surface) queue(surface); +} + +void WallpaperService::redraw() { + queueAll(); +} + +void WallpaperService::start() { + { + std::lock_guard lock(m_mutex); + + if (started || m_path.isEmpty() || m_screens.isEmpty()) return; + + started = true; + } + + auto* interface = + qApp->nativeInterface(); + if (!interface) { + qWarning() << "WallpaperService: this is not a Wayland session"; + return; + } + + m_display = interface->display(); + m_compositor = interface->compositor(); + if (!m_display || !m_compositor) return; + + m_queue = wl_display_create_queue(m_display); + if (!m_queue) return; + + if (pipe2(m_wake, O_CLOEXEC) < 0) { + qWarning() << "WallpaperService: could not make the pipe to wake on"; + return; + } + + bindGlobals(); + + if (!setupGl()) return; + + m_drawing = true; + m_thread = std::thread([this] { loop(); }); + + wake(); +} + +void WallpaperService::wake() { + if (m_wake[1] < 0) return; + + char byte = 0; + + if (::write(m_wake[1], &byte, 1) < 0) + qWarning() << "WallpaperService: could not wake the drawing thread"; +} + +void WallpaperService::queue(WallpaperSurface* surface) { + { + std::lock_guard lock(m_mutex); + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::queueAll() { + { + std::lock_guard lock(m_mutex); + + for (auto* surface : m_surfaces) + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::loop() { + while (!m_stop) { + present(); + + if (wl_display_prepare_read(m_display) == 0) { + wl_display_flush(m_display); + + struct pollfd fds[2] = { + {wl_display_get_fd(m_display), POLLIN, 0}, + {m_wake[0], POLLIN, 0}, + }; + + int ready = poll(fds, 2, kWaitTimeoutMs); + + if (ready > 0 && fds[0].revents) { + wl_display_read_events(m_display); + continue; + } + + if (ready > 0 && fds[1].revents) { + char byte = 0; + ::read(m_wake[0], &byte, 1); + } + + wl_display_cancel_read(m_display); + continue; + } + + struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}}; + + if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue; + + char byte = 0; + ::read(m_wake[0], &byte, 1); + } +} + +void WallpaperService::present() { + wl_display_dispatch_queue_pending(m_display, m_queue); + + addSurfaces(); + + ensureDecodeSize(); + + std::vector pending; + { + std::lock_guard lock(m_mutex); + pending.swap(m_pending); + } + + for (auto* surface : pending) + drawSurface(surface); +} + +void WallpaperService::addSurfaces() { + if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return; + + QList gone; + QList needed; + + { + std::lock_guard lock(m_mutex); + + QList kept; + for (auto* surface : m_surfaces) { + if (m_screens.contains(surface->screen())) + kept.append(surface); + else + gone.append(surface); + } + + m_surfaces = kept; + + for (const auto& screen : m_screens) { + bool exists = false; + for (auto* surface : m_surfaces) { + if (surface->screen() == screen) { + exists = true; + break; + } + } + + if (!exists) needed.append(screen); + } + } + + for (auto* surface : gone) + delete surface; + + for (const auto& screen : needed) + addSurface(screen); +} + +void WallpaperService::registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version) { + auto* service = static_cast(data); + + void* proxy = nullptr; + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) { + proxy = new QtWayland::zwlr_layer_shell_v1( + registry, int(name), int(version)); + } else if (strcmp(interface, "wp_viewporter") == 0) { + proxy = new QtWayland::wp_viewporter(registry, int(name), int(version)); + } else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) { + proxy = new QtWayland::wp_fractional_scale_manager_v1( + registry, int(name), int(version)); + } else { + return; + } + + service->storeGlobal(interface, proxy); + + service->queueAll(); +} + +void WallpaperService::storeGlobal(const char* interface, void* proxy) { + std::lock_guard lock(m_mutex); + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) + m_layerShell = static_cast(proxy); + else if (strcmp(interface, "wp_viewporter") == 0) + m_viewporter = static_cast(proxy); + else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) + m_scaleManager = + static_cast(proxy); +} + +void WallpaperService::bindGlobals() { + m_registry = wl_display_get_registry(m_display); + + static const struct wl_registry_listener listener = { + registryGlobal, + nullptr, + }; + + wl_registry_add_listener(m_registry, &listener, this); +} + +bool WallpaperService::setupGl() { + const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) { + m_eglDisplay = + eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr); + } else { + m_eglDisplay = + eglGetDisplay(reinterpret_cast(m_display)); + } + + if (m_eglDisplay == EGL_NO_DISPLAY) { + qWarning() << "WallpaperService: no EGL display"; + return false; + } + + EGLint major = 0, minor = 0; + if (!eglInitialize(m_eglDisplay, &major, &minor)) { + qWarning() << "WallpaperService: eglInitialize failed"; + return false; + } + + const EGLint configAttributes[] = { + EGL_SURFACE_TYPE, + EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_NONE}; + + EGLint count = 0; + if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || + count == 0) { + qWarning() << "WallpaperService: no EGL config for window surfaces"; + return false; + } + + const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + if (m_context == EGL_NO_CONTEXT) { + qWarning() << "WallpaperService: eglCreateContext failed"; + return false; + } + + return true; +} + +uint32_t WallpaperService::createProgram() { + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &kVertexShader, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &kFragmentShader, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "crop") < 0) { + qWarning() << "WallpaperService: the program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_sizeUniform = glGetUniformLocation(program, "size"); + m_cropUniform = glGetUniformLocation(program, "crop"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::uploadImage() { + if (m_image.isNull()) return; + + if (m_texture == 0) { + glGenTextures(1, &m_texture); + glBindTexture(GL_TEXTURE_2D, m_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + } else { + glBindTexture(GL_TEXTURE_2D, m_texture); + } + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + m_image.width(), + m_image.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + m_image.constBits()); +} + +bool WallpaperService::decode(const QString& path) { + if (path.isEmpty()) return false; + + QImageReader reader(path); + reader.setAutoTransform(true); + + QSize size = reader.size(); + if (size.isValid()) m_sourceSize = size; + + if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) + reader.setScaledSize( + m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); + + QImage image = reader.read(); + if (image.isNull()) { + qWarning() << "WallpaperService: could not read the image" << path; + return false; + } + + m_image = image.convertToFormat(QImage::Format_RGBA8888); + return !m_image.isNull(); +} + +bool WallpaperService::ensureDecodeSize() { + QSize largest; + QString path; + + { + std::lock_guard lock(m_mutex); + + largest = largestBufferSize(); + if (largest.isEmpty()) return false; + + if (!m_forceDecode && !m_decodeSize.isEmpty() && + qint64(largest.width()) * largest.height() <= + qint64(m_decodeSize.width()) * m_decodeSize.height()) + return false; + + QSize target = + m_sourceSize.isEmpty() + ? largest + : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); + + if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) + return false; + + m_forceDecode = false; + m_decodeSize = largest; + path = m_path; + } + + if (!decode(path)) return false; + + { + std::lock_guard lock(m_mutex); + m_imageChanged = true; + } + + return true; +} + +QSize WallpaperService::largestBufferSize() { + QSize largest; + + for (auto* surface : m_surfaces) { + QSize buffer = surface->bufferSize(); + if (buffer.isEmpty()) continue; + + if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() > + qint64(largest.width()) * largest.height()) + largest = buffer; + } + + return largest; +} + +QRectF WallpaperService::sourceRect( + const QVariantMap& crops, const QString& screen, const QSize& size) const { + QVariantMap crop = cropFor(crops, screen); + + QRectF rect( + crop.value("x", 0.0).toDouble(), + crop.value("y", 0.0).toDouble(), + crop.value("width", 1.0).toDouble(), + crop.value("height", 1.0).toDouble()); + + if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; + + qreal imageWidth = qreal(m_image.width()); + qreal imageHeight = qreal(m_image.height()); + + QRectF pixels( + rect.x() * imageWidth, + rect.y() * imageHeight, + rect.width() * imageWidth, + rect.height() * imageHeight); + + qreal targetRatio = qreal(size.width()) / qreal(size.height()); + qreal rectRatio = pixels.width() / pixels.height(); + + if (rectRatio > targetRatio) { + qreal newWidth = pixels.height() * targetRatio; + pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0); + pixels.setWidth(newWidth); + } else if (rectRatio < targetRatio) { + qreal newHeight = pixels.width() / targetRatio; + pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0); + pixels.setHeight(newHeight); + } + + return QRectF( + pixels.x() / imageWidth, + pixels.y() / imageHeight, + pixels.width() / imageWidth, + pixels.height() / imageHeight); +} + +void WallpaperService::drawSurface(WallpaperSurface* surface) { + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning() << "WallpaperService: the context cannot be made current"; + return; + } + + QVariantMap crops; + QSize size; + bool upload = false; + + { + std::lock_guard lock(m_mutex); + + crops = m_crops; + size = surface->bufferSize(); + + upload = m_imageChanged; + m_imageChanged = false; + } + + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_texture == 0 || upload) uploadImage(); + + if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; + + glUseProgram(m_program); + glBindTexture(GL_TEXTURE_2D, m_texture); + + glUniform2f( + GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); + + QRectF rect = sourceRect(crops, surface->screen(), size); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + + glViewport(0, 0, size.width(), size.height()); + glDrawArrays(GL_TRIANGLES, 0, 3); + + if (!eglSwapBuffers(m_eglDisplay, target)) + qWarning() << "WallpaperService: eglSwapBuffers failed"; +} + +void WallpaperService::addSurface(const QString& screen) { + if (!m_layerShell || !m_viewporter || !m_scaleManager) return; + + QScreen* qscreen = nullptr; + for (auto* candidate : QGuiApplication::screens()) { + if (candidate->name() == screen) { + qscreen = candidate; + break; + } + } + + if (!qscreen) { + qWarning() << "WallpaperService:" << screen << "is not a screen"; + return; + } + + auto* waylandScreen = + dynamic_cast(qscreen->handle()); + if (!waylandScreen || !waylandScreen->output()) { + qWarning() << "WallpaperService:" << screen + << "is not a Wayland screen"; + return; + } + + wl_surface* wlSurface = wl_compositor_create_surface(m_compositor); + if (!wlSurface) return; + + auto* surface = + new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen); + + { + std::lock_guard lock(m_mutex); + m_surfaces.append(surface); + } +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp new file mode 100644 index 0000000..a68d84d --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "wallpapersurface.hpp" + +namespace ZShell::internal { + +class WallpaperSurface; + +class WallpaperService : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + + public: + explicit WallpaperService(QObject* parent = nullptr); + ~WallpaperService() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + void addScreen(const QString& screen); + void removeScreen(const QString& screen); + + [[nodiscard]] const QVariantMap& crops() const; + void setCrops(const QVariantMap& crops); + + Q_INVOKABLE void redraw(); + + Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + + signals: + void pathChanged(); + void cropsChanged(); + + private: + friend class WallpaperSurface; + + void bindGlobals(); + static void registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version); + + void storeGlobal(const char* interface, void* proxy); + + [[nodiscard]] bool setupGl(); + [[nodiscard]] uint32_t createProgram(); + + void uploadImage(); + + bool decode(const QString& path); + + bool ensureDecodeSize(); + + [[nodiscard]] QSize largestBufferSize(); + + void queue(WallpaperSurface* surface); + void queueAll(); + + void startDrawing(); + void loop(); + + void present(); + + void drawSurface(WallpaperSurface* surface); + + void wake(); + + [[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; } + + [[nodiscard]] QRectF sourceRect( + const QVariantMap& crops, + const QString& screen, + const QSize& size) const; + + void start(); + + void addSurfaces(); + void addSurface(const QString& screen); + + QString m_path; + QStringList m_screens; + QVariantMap m_crops; + + QImage m_image; + + QSize m_sourceSize; + QSize m_decodeSize; + + wl_display* m_display = nullptr; + wl_registry* m_registry = nullptr; + wl_compositor* m_compositor = nullptr; + QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr; + QtWayland::wp_viewporter* m_viewporter = nullptr; + QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr; + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLConfig m_config = nullptr; + EGLContext m_context = EGL_NO_CONTEXT; + uint32_t m_texture = 0; + uint32_t m_program = 0; + int m_sizeUniform = -1; + int m_cropUniform = -1; + + QList m_surfaces; + + std::thread m_thread; + mutable std::recursive_mutex m_mutex; + std::vector m_pending; + wl_event_queue* m_queue = nullptr; + + int m_wake[2] = {-1, -1}; + + bool m_stop = false; + bool m_drawing = false; + + bool started = false; + + bool m_forceDecode = false; + bool m_imageChanged = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp new file mode 100644 index 0000000..8fe99e6 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -0,0 +1,147 @@ +#include "wallpapersurface.hpp" + +#include + +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +const QString kNamespace = "ZShell-Wallpaper"; + +void setQueue(void* proxy, struct wl_event_queue* queue) { + wl_proxy_set_queue(static_cast(proxy), queue); +} + +WallpaperSurface::WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen) + : m_service(service), m_screen(screen), m_surface(surface) { + if (!surface) { + qWarning() << "WallpaperSurface: no surface for" << screen; + return; + } + + setQueue(m_surface, m_service->eventQueue()); + + auto* layer = m_service->m_layerShell->get_layer_surface( + m_surface, + output, + QtWayland::zwlr_layer_shell_v1::layer_background, + kNamespace); + setQueue(layer, m_service->eventQueue()); + QtWayland::zwlr_layer_surface_v1::init(layer); + + set_size(0, 0); + set_anchor(anchor_top | anchor_bottom | anchor_left | anchor_right); + set_exclusive_zone(-1); + set_keyboard_interactivity(keyboard_interactivity_none); + set_margin(0, 0, 0, 0); + + auto* viewport = m_service->m_viewporter->get_viewport(m_surface); + setQueue(viewport, m_service->eventQueue()); + QtWayland::wp_viewport::init(viewport); + + auto* scale = m_service->m_scaleManager->get_fractional_scale(m_surface); + setQueue(scale, m_service->eventQueue()); + QtWayland::wp_fractional_scale_v1::init(scale); + + wl_surface_commit(m_surface); +} + +WallpaperSurface::~WallpaperSurface() { + if (m_eglSurface != EGL_NO_SURFACE) + eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); + + if (m_window) wl_egl_window_destroy(m_window); + + if (QtWayland::wp_viewport::isInitialized()) + QtWayland::wp_viewport::destroy(); + if (QtWayland::wp_fractional_scale_v1::isInitialized()) + QtWayland::wp_fractional_scale_v1::destroy(); + if (QtWayland::zwlr_layer_surface_v1::isInitialized()) + QtWayland::zwlr_layer_surface_v1::destroy(); + + if (m_surface) wl_surface_destroy(m_surface); +} + +void WallpaperSurface::redraw() { + m_service->queue(this); +} + +void WallpaperSurface::zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) { + ack_configure(serial); + + if (width == 0 || height == 0) return; + + m_size = QSize(int(width), int(height)); + + if (!configured) configured = true; + + updateBuffer(); +} + +void WallpaperSurface::wp_fractional_scale_v1_preferred_scale(uint32_t scale) { + if (scale == 0) return; + + qreal newScale = qreal(scale) / 120.0; + if (qFuzzyCompare(newScale, m_scale)) return; + + m_scale = newScale; + updateBuffer(); +} + +void WallpaperSurface::zwlr_layer_surface_v1_closed() {} + +void WallpaperSurface::updateBuffer() { + if (m_size.isEmpty()) return; + + QSize buffer = QSize( + int(std::llround(m_size.width() * m_scale)), + int(std::llround(m_size.height() * m_scale))); + + if (buffer.isEmpty()) return; + + if (m_bufferSize != buffer) { + m_bufferSize = buffer; + + if (m_window) { + wl_egl_window_resize( + m_window, buffer.width(), buffer.height(), 0, 0); + } else { + m_window = + wl_egl_window_create(m_surface, buffer.width(), buffer.height()); + if (!m_window) { + qWarning() + << "WallpaperSurface: could not make a window surface for" + << m_screen; + return; + } + } + + if (m_eglSurface == EGL_NO_SURFACE) { + m_eglSurface = eglCreateWindowSurface( + m_service->m_eglDisplay, + m_service->m_config, + reinterpret_cast(m_window), + nullptr); + if (m_eglSurface == EGL_NO_SURFACE) { + qWarning() + << "WallpaperSurface: eglCreateWindowSurface failed for" + << m_screen; + return; + } + } + } + + if (QtWayland::wp_viewport::isInitialized()) + set_destination(m_size.width(), m_size.height()); + + m_service->queue(this); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Internal/wallpapersurface.hpp new file mode 100644 index 0000000..d534c99 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace ZShell::internal { + +class WallpaperService; + +class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, + public QtWayland::wp_viewport, + public QtWayland::wp_fractional_scale_v1 { + public: + WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen); + ~WallpaperSurface() override; + + [[nodiscard]] const QString& screen() const { return m_screen; } + + [[nodiscard]] const QSize& size() const { return m_size; } + [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } + [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } + + void redraw(); + + private: + void zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) override; + void zwlr_layer_surface_v1_closed() override; + void wp_fractional_scale_v1_preferred_scale(uint32_t scale) override; + + void updateBuffer(); + + WallpaperService* m_service; + QString m_screen; + wl_surface* m_surface; + wl_egl_window* m_window = nullptr; + EGLSurface m_eglSurface = EGL_NO_SURFACE; + + QSize m_size; + QSize m_bufferSize; + qreal m_scale = 1.0; + + bool configured = false; +}; + +} // namespace ZShell::internal 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/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt index 785db17..9bac1a8 100644 --- a/Plugins/ZShell/Models/CMakeLists.txt +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -1,8 +1,8 @@ qml_module(ZShell-models - URI ZShell.Models - SOURCES - filesystemmodel.hpp filesystemmodel.cpp - LIBRARIES - Qt::Gui - Qt::Concurrent + URI ZShell.Models + SOURCES + filesystemmodel.hpp filesystemmodel.cpp + LIBRARIES + Qt::Gui + Qt::Concurrent ) diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 7d1d2ff..73c7cce 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -9,7 +9,7 @@ qml_module(ZShell-services cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp - hyprsunsetmanager.hpp hyprsunsetmanager.cpp + nightlightmanager.hpp nightlightmanager.cpp tickingservice.hpp tickingservice.cpp sensorslib.hpp sensorslib.cpp usagefmt.hpp usagefmt.cpp @@ -18,11 +18,25 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + processes.hpp processes.cpp + networkusage.hpp networkusage.cpp + lyriccandidate.hpp lyriccandidate.cpp + lyrics.hpp lyrics.cpp LIBRARIES Qt6::Core Qt6::Qml + Qt6::Concurrent PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors + ZShell-config + ZShell-settings + ZShell-internal + ZShell + zshell-util + DEPENDENCIES + ZShell + ZShell.Config + ZShell.Internal ) diff --git a/Plugins/ZShell/Services/diskinfo.cpp b/Plugins/ZShell/Services/diskinfo.cpp index dae8468..dfaff61 100644 --- a/Plugins/ZShell/Services/diskinfo.cpp +++ b/Plugins/ZShell/Services/diskinfo.cpp @@ -24,6 +24,18 @@ QString DiskInfo::mount() const { return m_mount; } +// Display label. mount stays the full path because it is the identity used to +// match entries across ticks, so this only shortens what the UI shows. For +// physical disks mount is already the device name and is returned unchanged. +QString DiskInfo::name() const { + const qsizetype slash = m_mount.lastIndexOf(QLatin1Char('/')); + if (slash < 0) { + return m_mount; + } + const QString leaf = m_mount.mid(slash + 1); + return leaf.isEmpty() ? m_mount : leaf; +} + qreal DiskInfo::used() const { return static_cast(m_usedBytes) / kKib; } diff --git a/Plugins/ZShell/Services/diskinfo.hpp b/Plugins/ZShell/Services/diskinfo.hpp index 3b51b0b..4bae0bb 100644 --- a/Plugins/ZShell/Services/diskinfo.hpp +++ b/Plugins/ZShell/Services/diskinfo.hpp @@ -11,6 +11,7 @@ class DiskInfo : public QObject { QML_UNCREATABLE("DiskInfo is created by DiskUsage") Q_PROPERTY(QString mount READ mount CONSTANT) + Q_PROPERTY(QString name READ name CONSTANT) Q_PROPERTY(qreal used READ used NOTIFY usedChanged) Q_PROPERTY(qreal total READ total NOTIFY totalChanged) Q_PROPERTY(qreal free READ free NOTIFY freeChanged) @@ -26,6 +27,7 @@ class DiskInfo : public QObject { QObject* parent = nullptr); [[nodiscard]] QString mount() const; + [[nodiscard]] QString name() const; [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; [[nodiscard]] qreal free() const; diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index 8d3d1da..25993b7 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -2,97 +2,187 @@ #include "sensorslib.hpp" -#include -#include +#include "config/rootnodes.hpp" +#include "config/services.hpp" + #include #include -#include +#include #include -#include -#include -#include -#include -#include namespace ZShell::services { +using Qt::StringLiterals::operator""_s; + namespace { -constexpr const char* kTypeDetectScript = - "if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null " - "2>&1; then echo NVIDIA;" - " elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep " - "-q .; then echo GENERIC;" - " else echo NONE; fi"; +QStringList cardFiles(const QString& relative) { + static const QRegularExpression k_cardRe(u"^card\\d+$"_s); -constexpr const char* kNameDetectScript = - "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null" - " || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' " - "-f1" - " || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"; + QStringList files; + QDirIterator it(u"/sys/class/drm"_s, QDir::Dirs | QDir::NoDotAndDotDot); + while (it.hasNext()) { + const QString path = it.next(); + if (!k_cardRe.match(it.fileName()).hasMatch()) continue; + const QString file = path + relative; + if (QFile::exists(file)) files << file; + } + return files; +} + +std::optional readNumber(const QString& path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return std::nullopt; + bool ok = false; + const qreal v = f.readAll().trimmed().toDouble(&ok); + if (!ok) return std::nullopt; + return v; +} + +QString cleanName(QString s) { + static const QRegularExpression k_noise( + u"\\(R\\)|\\(TM\\)|Graphics"_s, + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression k_spaces(u"\\s+"_s); + s.replace(k_noise, {}); + s.replace(k_spaces, u" "_s); + return s.trimmed(); +} + +QString parseNvidiaName(const QByteArray& out) { + const QString first = + QString::fromUtf8(out).split(u'\n').value(0).trimmed(); + return first.isEmpty() ? QString() : cleanName(first); +} + +QString parseGlxinfoName(const QByteArray& out) { + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + for (const QString& line : lines) { + const qsizetype idx = line.indexOf(u"Device:"_s); + if (idx < 0) { + continue; + } + + QString rest = line.mid(idx + 7); + const qsizetype paren = rest.indexOf(u'('); + if (paren >= 0) { + rest = rest.left(paren); + } + + const QString cleaned = cleanName(rest); + if (!cleaned.isEmpty()) { + return cleaned; + } + } + + return {}; +} + +QString parseLspciName(const QByteArray& out) { + static const QRegularExpression k_lineRe( + u"vga|3d controller|display"_s, + QRegularExpression::CaseInsensitiveOption); + + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + QString match; + for (const QString& line : lines) { + if (k_lineRe.match(line).hasMatch()) { + match = line; + break; + } + } + + if (match.isEmpty()) { + return {}; + } + + static const QRegularExpression k_bracketRe(u"\\[([^\\]]+)\\][^\\[]*$"_s); + const auto bracket = k_bracketRe.match(match); + if (bracket.hasMatch()) { + return cleanName(bracket.captured(1)); + } + + static const QRegularExpression k_colonRe(u":\\s+(.+)"_s); + const auto colon = k_colonRe.match(match); + if (colon.hasMatch()) { + return cleanName(colon.captured(1)); + } + + return {}; +} + +struct NameSource { + QString program; + QStringList args; + QString (*parse)(const QByteArray&); +}; + +const std::array& nameSources() { + static const std::array k_sources = {{ + { + .program = u"nvidia-smi"_s, + .args = {u"--query-gpu=name"_s, u"--format=csv,noheader"_s}, + .parse = &parseNvidiaName, + }, + { + .program = u"glxinfo"_s, + .args = {u"-B"_s}, + .parse = &parseGlxinfoName, + }, + { + .program = u"lspci"_s, + .args = {}, + .parse = &parseLspciName, + }, + }}; + return k_sources; +} + +constexpr int k_nvidiaSource = 0; +constexpr int k_firstGenericSource = 1; } // namespace Gpu::Gpu(QObject* parent) : TickingService(parent) { - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); + auto* svc = config::ConfigSingleton::instance()->services(); + m_userType = svc->gpuType(); + QObject::connect(svc, &config::Services::gpuTypeChanged, this, [this, svc] { + const GpuType value = svc->gpuType(); + if (value == m_userType) return; + m_userType = value; + resolveGpu(); + }); - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (file.open(QIODevice::ReadOnly)) { - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType(services.value("gpuType").toString())); - } - } - }; + resolveGpu(); - reloadConfig(); + m_restartTimer = new QTimer(this); + m_restartTimer->setSingleShot(true); + m_restartTimer->setInterval(500); + QObject::connect(m_restartTimer, &QTimer::timeout, this, [this] { + if (!m_nvidiaProc || m_type != GpuType::Nvidia) return; - static QFileSystemWatcher* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (file.exists()) { - QJsonDocument doc = - QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType( - services.value("gpuType").toString())); - } - } - }); - }); - watcher->addPath(configPath); + stopNvidiaUsage(); + startNvidiaUsage(); + }); + + QObject::connect(this, &TickingService::updateIntervalChanged, this, [this] { + if (!m_nvidiaProc) return; + m_restartTimer->start(); + }); +} + +Gpu::~Gpu() { + if (!m_nvidiaProc) return; + m_nvidiaProc->disconnect(this); + m_nvidiaProc->terminate(); + if (!m_nvidiaProc->waitForFinished(500)) { + m_nvidiaProc->kill(); + m_nvidiaProc->waitForFinished(200); } - - // Detection must run before any ServiceRef appears: callers may gate the ref on - // `type !== Gpu.None`, which would otherwise deadlock the detection. - if (m_userType == Auto) { - detectTypeOnce(); - } - detectNameOnce(); } -Gpu::Type Gpu::type() const { - return m_userType == Auto ? m_autoType : m_userType; -} - -Gpu::Type Gpu::userType() const { - return m_userType; -} - -Gpu::Type Gpu::autoType() const { - return m_autoType; +GpuType Gpu::type() const { + return m_type; } QString Gpu::name() const { @@ -115,28 +205,17 @@ qreal Gpu::memoryTotal() const { return m_memoryTotal; } -void Gpu::setUserType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_userType = value; - Q_EMIT userTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } +bool Gpu::detecting() const { + return m_detecting; } -void Gpu::setAutoType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_autoType = value; - Q_EMIT autoTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } +void Gpu::setType(GpuType value) { + if (value == m_type) return; + + m_type = value; + if (m_type != GpuType::Nvidia) stopNvidiaUsage(); + if (m_type == GpuType::None) resetUsage(); + emit typeChanged(); } void Gpu::setName(QString value) { @@ -147,6 +226,12 @@ void Gpu::setName(QString value) { Q_EMIT nameChanged(); } +void Gpu::setDetecting(bool value) { + if (value == m_detecting) return; + m_detecting = value; + emit detectingChanged(); +} + void Gpu::setMemoryUsed(qreal value) { if (std::abs(m_memoryUsed - value) < 0.001) return; m_memoryUsed = value; @@ -160,168 +245,192 @@ void Gpu::setMemoryTotal(qreal value) { } void Gpu::tick() { - const Type t = type(); - if (t == Generic) { + if (m_type == GpuType::Generic) { readGenericUsage(); readGenericMemory(); readGpuTemperature(); - } else if (t == Nvidia) { + } else if (m_type == GpuType::Nvidia) { startNvidiaUsage(); } else { - if (std::abs(m_percentage) > 0.0001) { - m_percentage = 0.0; - Q_EMIT percentageChanged(); - } - if (std::abs(m_temperature) > 0.05) { - m_temperature = 0.0; - Q_EMIT temperatureChanged(); - } + resetUsage(); } } -void Gpu::detectTypeOnce() { - if (m_typeProc) { - return; +void Gpu::refreshSysfsCache() { + m_busyFiles = cardFiles(u"/device/gpu_busy_percent"_s); + + m_vramFiles.clear(); + const QStringList totals = cardFiles(u"/device/mem_info_vram_total"_s); + for (const QString& total : totals) { + QString used = total; + used.replace(u"mem_info_vram_total"_s, u"mem_info_vram_used"_s); + if (QFile::exists(used)) m_vramFiles.append({used, total}); } - m_typeProc = new QProcess(this); - QObject::connect( - m_typeProc, - &QProcess::finished, - this, - [this](int, QProcess::ExitStatus) { - const QByteArray out = - m_typeProc->readAllStandardOutput().trimmed(); - if (!out.isEmpty()) { - setAutoType(parseType(QString::fromLatin1(out))); - } - m_typeProc->deleteLater(); - m_typeProc = nullptr; - }); - m_typeProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kTypeDetectScript)}); } -void Gpu::detectNameOnce() { - if (m_nameProc) { +void Gpu::resolveGpu() { + refreshSysfsCache(); + const int generation = ++m_generation; + + if (m_userType != GpuType::Auto) setType(m_userType); + + if (m_userType == GpuType::None) { + setName({}); + setDetecting(false); return; } - m_nameProc = new QProcess(this); + + setName({}); + setDetecting(true); + tryNameSource( + m_userType == GpuType::Generic ? k_firstGenericSource : k_nvidiaSource, + generation); +} + +int Gpu::probeEnd() const { + return m_userType == GpuType::Nvidia + ? k_firstGenericSource + : static_cast(nameSources().size()); +} + +void Gpu::tryNameSource(int index, int generation) { + const NameSource& src = nameSources().at(static_cast(index)); + runProcess( + src.program, + src.args, + [this, index, generation, parse = src.parse](const QByteArray& out) { + finishNameSource(index, generation, parse(out)); + }); +} + +void Gpu::finishNameSource(int index, int generation, QString name) { + if (generation != m_generation) { + return; + } + + if (m_userType == GpuType::Auto && index == k_nvidiaSource) { + if (!name.isEmpty()) + setType(GpuType::Nvidia); + else + setType(m_busyFiles.isEmpty() ? GpuType::None : GpuType::Generic); + + if (m_type == GpuType::None) { + setName({}); + setDetecting(false); + return; + } + } + + if (!name.isEmpty()) { + setName(std::move(name)); + setDetecting(false); + return; + } + + const int next = index + 1; + if (next < probeEnd()) { + tryNameSource(next, generation); + } else { + setName({}); + setDetecting(false); + } +} + +void Gpu::runProcess( + const QString& program, + const QStringList& args, + std::function callback) { + auto* proc = new QProcess(this); + proc->setStandardErrorFile(QProcess::nullDevice()); + + const auto finish = + [proc, callback = std::move(callback)](const QByteArray& out) { + callback(out); + proc->deleteLater(); + }; + QObject::connect( - m_nameProc, + proc, &QProcess::finished, this, - [this](int, QProcess::ExitStatus) { - const QString output = - QString::fromUtf8(m_nameProc->readAllStandardOutput()).trimmed(); - if (!output.isEmpty()) { - const QString lower = output.toLower(); - if (lower.contains(QStringLiteral("nvidia")) || - lower.contains(QStringLiteral("geforce")) || - lower.contains(QStringLiteral("rtx")) || - lower.contains(QStringLiteral("gtx")) || - lower.contains(QStringLiteral("rx"))) { - setName(cleanName(output)); - } else { - static const QRegularExpression bracketRe( - QStringLiteral("\\[([^\\]]+)\\][^\\[]*$")); - const auto bracket = bracketRe.match(output); - if (bracket.hasMatch()) { - setName(cleanName(bracket.captured(1))); - } else { - static const QRegularExpression colonRe( - QStringLiteral(":\\s*(.+)")); - const auto colon = colonRe.match(output); - if (colon.hasMatch()) { - setName(cleanName(colon.captured(1))); - } - } - } - } - m_nameProc->deleteLater(); - m_nameProc = nullptr; + [finish, proc](int code, QProcess::ExitStatus status) { + const bool ok = status == QProcess::NormalExit && code == 0; + finish(ok ? proc->readAllStandardOutput() : QByteArray()); }); - m_nameProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kNameDetectScript)}); + QObject::connect( + proc, + &QProcess::errorOccurred, + this, + [finish](QProcess::ProcessError err) { + if (err == QProcess::FailedToStart) { + finish(QByteArray()); + } + }); + + proc->start(program, args); +} + +void Gpu::onStopped() { + stopNvidiaUsage(); } void Gpu::readGenericMemory() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); - - qreal totalMem = 0.0; - qreal usedMem = 0.0; - for (const QString& card : paths) { - QFile total( - QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_total") - .arg(card)); - if (!total.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = total.readAll().trimmed().toDouble(&ok); - total.close(); - if (ok) { - totalMem += v; - } - - QFile used(QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_used") - .arg(card)); - if (!used.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok1 = false; - const qreal v1 = used.readAll().trimmed().toDouble(&ok1); - used.close(); - if (ok1) { - usedMem += v1; - } + qreal total = 0.0; + qreal used = 0.0; + for (const VramFiles& v : std::as_const(m_vramFiles)) { + const auto t = readNumber(v.total); + const auto u = readNumber(v.used); + if (!t || !u) continue; + total += *t; + used += *u; } - - setMemoryTotal(totalMem); - setMemoryUsed(usedMem); + setMemoryTotal(total); + setMemoryUsed(used); } void Gpu::readGenericUsage() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); qreal sum = 0.0; int count = 0; - for (const QString& card : paths) { - QFile f(QStringLiteral("/sys/class/drm/%1/device/gpu_busy_percent") - .arg(card)); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = f.readAll().trimmed().toDouble(&ok); - f.close(); - if (ok) { - sum += v; + for (const QString& path : std::as_const(m_busyFiles)) { + if (const auto v = readNumber(path)) { + sum += *v; ++count; } } const qreal newPerc = count > 0 ? sum / count / 100.0 : 0.0; if (std::abs(newPerc - m_percentage) > 0.0001) { m_percentage = newPerc; - Q_EMIT percentageChanged(); + emit percentageChanged(); } } -void Gpu::startNvidiaUsage() { - if (m_nvidiaProc) { +void Gpu::stopNvidiaUsage() { + m_restartTimer->stop(); + + if (!m_nvidiaProc) return; + + QProcess* proc = m_nvidiaProc; + m_nvidiaProc = nullptr; + + proc->disconnect(this); + if (proc->state() == QProcess::NotRunning) { + proc->deleteLater(); return; } - m_nvidiaProc = new QProcess(this); + QObject::connect(proc, &QProcess::finished, proc, &QObject::deleteLater); + proc->terminate(); +} + +void Gpu::startNvidiaUsage() { + if (m_nvidiaProc) return; + + auto* proc = new QProcess(this); + m_nvidiaProc = proc; + QObject::connect( - m_nvidiaProc, &QProcess::readyReadStandardOutput, this, [this]() { - while (m_nvidiaProc->canReadLine()) { - const QByteArray out = m_nvidiaProc->readLine(); + proc, &QProcess::readyReadStandardOutput, this, [this, proc]() { + while (proc->canReadLine()) { + const QByteArray out = proc->readLine(); const QString output = QString::fromUtf8(out).trimmed(); if (output.isEmpty()) continue; @@ -357,21 +466,21 @@ void Gpu::startNvidiaUsage() { } }); - QObject::connect(m_nvidiaProc, &QProcess::finished, this, [this]() { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + auto cleanup = [this, proc]() { + if (m_nvidiaProc == proc) m_nvidiaProc = nullptr; + + proc->deleteLater(); + }; + + QObject::connect(proc, &QProcess::finished, this, cleanup); QObject::connect( - m_nvidiaProc, + proc, &QProcess::errorOccurred, this, - [this](QProcess::ProcessError) { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + [cleanup](QProcess::ProcessError) { cleanup(); }); - m_nvidiaProc->start( + proc->start( QStringLiteral("nvidia-smi"), {QStringLiteral( "--query-gpu=utilization.gpu,temperature.gpu," @@ -386,32 +495,21 @@ void Gpu::readGpuTemperature() { const qreal newTemp = t.value_or(0.0); if (std::abs(newTemp - m_temperature) > 0.05) { m_temperature = newTemp; - Q_EMIT temperatureChanged(); + emit temperatureChanged(); } } -Gpu::Type Gpu::parseType(const QString& s) { - const QString u = s.trimmed().toUpper(); - if (u.isEmpty()) { - return Auto; +void Gpu::resetUsage() { + if (std::abs(m_percentage) > 0.0001) { + m_percentage = 0.0; + emit percentageChanged(); } - if (u == QStringLiteral("NVIDIA")) { - return Nvidia; + if (std::abs(m_temperature) > 0.05) { + m_temperature = 0.0; + emit temperatureChanged(); } - if (u == QStringLiteral("GENERIC")) { - return Generic; - } - return None; -} - -QString Gpu::cleanName(QString s) { - static const QRegularExpression noise( - QStringLiteral("\\(R\\)|\\(TM\\)|Graphics"), - QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression spaces(QStringLiteral("\\s+")); - s.replace(noise, QString()); - s.replace(spaces, QStringLiteral(" ")); - return s.trimmed(); + setMemoryUsed(0.0); + setMemoryTotal(0.0); } } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/gpu.hpp b/Plugins/ZShell/Services/gpu.hpp index dbe567c..e082d1b 100644 --- a/Plugins/ZShell/Services/gpu.hpp +++ b/Plugins/ZShell/Services/gpu.hpp @@ -1,31 +1,29 @@ #pragma once #include "tickingservice.hpp" +#include "config/enums.hpp" #include #include +#include namespace ZShell::services { +using GpuType = config::GpuType::Enum; + +struct VramFiles { + QString used; + QString total; +}; + class Gpu : public TickingService { Q_OBJECT QML_ELEMENT QML_SINGLETON - public: - enum Type { - Auto, // user override is empty (config "") — defer to detected autoType - None, // no usable GPU - Nvidia, // queried via nvidia-smi - Generic, // queried via /sys/class/drm/card*/device/gpu_busy_percent - }; - Q_ENUM(Type) - - private: - Q_PROPERTY(Type type READ type NOTIFY typeChanged) - Q_PROPERTY(Type userType READ userType NOTIFY userTypeChanged) - Q_PROPERTY(Type autoType READ autoType NOTIFY autoTypeChanged) + Q_PROPERTY(GpuType type READ type NOTIFY typeChanged) Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(bool detecting READ detecting NOTIFY detectingChanged) Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) Q_PROPERTY(qreal memoryUsed READ memoryUsed NOTIFY memoryUsedChanged) @@ -33,11 +31,11 @@ class Gpu : public TickingService { public: explicit Gpu(QObject* parent = nullptr); + ~Gpu() override; - [[nodiscard]] Type type() const; - [[nodiscard]] Type userType() const; - [[nodiscard]] Type autoType() const; + [[nodiscard]] GpuType type() const; [[nodiscard]] QString name() const; + [[nodiscard]] bool detecting() const; [[nodiscard]] qreal percentage() const; [[nodiscard]] qreal temperature() const; [[nodiscard]] qreal memoryUsed() const; @@ -45,9 +43,8 @@ class Gpu : public TickingService { signals: void typeChanged(); - void userTypeChanged(); - void autoTypeChanged(); void nameChanged(); + void detectingChanged(); void percentageChanged(); void temperatureChanged(); void memoryUsedChanged(); @@ -55,35 +52,52 @@ class Gpu : public TickingService { protected: void tick() override; + void onStopped() override; private: - void detectTypeOnce(); - void detectNameOnce(); + void resolveGpu(); + + [[nodiscard]] int probeEnd() const; + + void tryNameSource(int index, int generation); + void finishNameSource(int index, int generation, QString name); + void readGenericUsage(); + void stopNvidiaUsage(); void startNvidiaUsage(); void readGpuTemperature(); void readGenericMemory(); + void resetUsage(); - void setUserType(Type value); - void setAutoType(Type value); + void runProcess( + const QString& program, + const QStringList& args, + std::function callback); + + void setType(GpuType value); void setName(QString value); + void setDetecting(bool value); void setMemoryUsed(qreal value); void setMemoryTotal(qreal value); - [[nodiscard]] static Type parseType(const QString& s); - [[nodiscard]] static QString cleanName(QString s); + void refreshSysfsCache(); - Type m_userType = Auto; - Type m_autoType = None; + GpuType m_userType = GpuType::Auto; + GpuType m_type = GpuType::None; QString m_name; + bool m_detecting = false; qreal m_percentage = 0.0; qreal m_temperature = 0.0; qreal m_memoryUsed = 0.0; qreal m_memoryTotal = 0.0; - QProcess* m_typeProc = nullptr; - QProcess* m_nameProc = nullptr; + QStringList m_busyFiles; + QList m_vramFiles; + int m_generation = 0; + bool m_nvidiaQuerying = false; + QProcess* m_nvidiaProc = nullptr; + QTimer* m_restartTimer = nullptr; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp deleted file mode 100644 index 0b33f6f..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "hyprsunsetmanager.hpp" -#include -#include -#include -#include - -namespace ZShell::services { - -HyprsunsetManager::HyprsunsetManager(QObject* parent) : QObject(parent) { - connect(&m_timer, &QTimer::timeout, this, &HyprsunsetManager::apply); - connect(&m_manualTimer, &QTimer::timeout, this, [this] { - m_manualToggle = false; - emit manualToggleChanged(); - apply(); - }); - connect(&m_startCooldown, &QTimer::timeout, this, [this] { - m_startAllowed = true; - apply(); - }); - - m_startCooldown.start(2000); - m_manualTimer.setSingleShot(true); - m_timer.start(60000); - - m_process.setStandardInputFile(QProcess::nullDevice()); - m_process.setStandardOutputFile(QProcess::nullDevice()); -} - -int HyprsunsetManager::startTime() const { - return m_startTime; -} - -int HyprsunsetManager::endTime() const { - return m_endTime; -} - -bool HyprsunsetManager::manualToggle() const { - return m_manualToggle; -} - -bool HyprsunsetManager::enabled() const { - return m_enabled; -} - -bool HyprsunsetManager::activeAuto() const { - return m_activeAuto; -} - -int HyprsunsetManager::temp() const { - return m_temp; -} - -void HyprsunsetManager::setActiveAuto(bool activeAuto) { - if (activeAuto == m_activeAuto) return; - - m_activeAuto = activeAuto; - emit activeAutoChanged(); -} - -void HyprsunsetManager::setManualToggle(bool toggle) { - if (toggle == m_manualToggle) return; - - m_manualToggle = toggle; - emit manualToggleChanged(); - - m_manualTimer.start(60 * 60 * 1000); -} - -void HyprsunsetManager::setEndTime(const int& time) { - if (time == m_endTime) return; - - m_endTime = time; - emit endTimeChanged(); - apply(); -} - -void HyprsunsetManager::setStartTime(const int& time) { - if (time == m_startTime) return; - - m_startTime = time; - emit startTimeChanged(); - apply(); -} - -void HyprsunsetManager::setTemp(const int& temp) { - if (temp == m_temp) return; - - m_temp = temp; - emit tempChanged(); - apply(); -} - -void HyprsunsetManager::toggle() { - if (m_enabled) { - end(); - } else { - start(); - } -} - -void HyprsunsetManager::start() { - if (m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = true; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments( - {"hyprsunset", "temperature", QString::number(m_temp)}); - m_process.startDetached(); -} - -void HyprsunsetManager::end() { - if (!m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = false; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments({"hyprsunset", "identity"}); - m_process.startDetached(); -} - -void HyprsunsetManager::apply() { - if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - - const auto current = QTime::currentTime(); - const auto currentMin = current.hour() * 60 + current.minute(); - bool isDarkTime = false; - - if (m_startTime <= m_endTime) { - isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); - } else { - isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); - } - - if (isDarkTime) { - start(); - } else { - end(); - } -} - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.hpp b/Plugins/ZShell/Services/hyprsunsetmanager.hpp deleted file mode 100644 index 7241c0d..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace ZShell::services { - -class HyprsunsetManager : public QObject { - Q_OBJECT - QML_ELEMENT - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) - Q_PROPERTY( - int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) - Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) - Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) - Q_PROPERTY( - bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY - activeAutoChanged) - Q_PROPERTY( - bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY - manualToggleChanged) - - public: - explicit HyprsunsetManager(QObject* parent = nullptr); - - [[nodiscard]] int startTime() const; - [[nodiscard]] int endTime() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] int temp() const; - [[nodiscard]] bool activeAuto() const; - [[nodiscard]] bool manualToggle() const; - - Q_INVOKABLE void toggle(); - Q_INVOKABLE void apply(); - - void setStartTime(const int& time); - void setEndTime(const int& time); - void setTemp(const int& temp); - void setActiveAuto(bool activeAuto); - void setManualToggle(bool toggle); - - signals: - void enabledChanged(); - void startTimeChanged(); - void activeAutoChanged(); - void endTimeChanged(); - void tempChanged(); - void manualToggleChanged(); - - private: - int m_startTime; - int m_endTime; - bool m_enabled = false; - bool m_manualToggle = false; - bool m_activeAuto; - bool m_startAllowed = false; - bool m_initialized = false; - QTimer m_startCooldown; - int m_temp; - QProcess m_process; - QTimer m_timer; - QTimer m_manualTimer; - void start(); - void end(); -}; - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyriccandidate.cpp b/Plugins/ZShell/Services/lyriccandidate.cpp new file mode 100644 index 0000000..8678769 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.cpp @@ -0,0 +1,55 @@ +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +LyricCandidate::LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album, + qreal duration) + : m_backend(backend) + , m_id(std::move(id)) + , m_title(std::move(title)) + , m_artist(std::move(artist)) + , m_album(std::move(album)) + , m_duration(duration) {} + +LyricsBackend LyricCandidate::backend() const { + return m_backend; +} + +QString LyricCandidate::id() const { + return m_id; +} + +QString LyricCandidate::title() const { + return m_title; +} + +QString LyricCandidate::artist() const { + return m_artist; +} + +QString LyricCandidate::album() const { + return m_album; +} + +qreal LyricCandidate::duration() const { + return m_duration; +} + +bool LyricCandidate::isValid() const { + return !m_id.isEmpty(); +} + +bool LyricCandidate::operator==(const LyricCandidate& o) const noexcept { + return m_backend == o.m_backend && m_id == o.m_id; +} + +bool LyricCandidate::operator!=(const LyricCandidate& o) const noexcept { + return !(*this == o); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyriccandidate.hpp b/Plugins/ZShell/Services/lyriccandidate.hpp new file mode 100644 index 0000000..f3f0700 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "config/enums.hpp" + +namespace ZShell::services { + +using LyricsBackend = config::LyricsBackend::Enum; + +class LyricCandidate { + Q_GADGET + QML_VALUE_TYPE(lyricCandidate) + + Q_PROPERTY(ZShell::config::LyricsBackend::Enum backend READ backend) + Q_PROPERTY(QString id READ id) + Q_PROPERTY(QString title READ title) + Q_PROPERTY(QString artist READ artist) + Q_PROPERTY(QString album READ album) + Q_PROPERTY(qreal duration READ duration) + + public: + LyricCandidate() = default; + LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album = {}, + qreal duration = 0.0); + + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] QString id() const; + [[nodiscard]] QString title() const; + [[nodiscard]] QString artist() const; + [[nodiscard]] QString album() const; + [[nodiscard]] qreal duration() const; + + [[nodiscard]] bool isValid() const; + bool operator==(const LyricCandidate& o) const noexcept; + bool operator!=(const LyricCandidate& o) const noexcept; + + private: + LyricsBackend m_backend = LyricsBackend::Auto; + QString m_id; + QString m_title; + QString m_artist; + QString m_album; + qreal m_duration = 0.0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp new file mode 100644 index 0000000..29fe616 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -0,0 +1,1178 @@ +#include "lyrics.hpp" + +#include +#include +#include +#include +#include +#include + +#include "config/rootnodes.hpp" +#include "config/general.hpp" +#include "config/services.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcLyrics, "ZShell.lyrics", QtInfoMsg) + +} // namespace + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; +using Qt::StringLiterals::operator""_ba; + +namespace { + +constexpr int k_loadDebounceMs = 50; +constexpr qreal k_indexFudge = 0.1; + +[[nodiscard]] const QHash& netEaseHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"_ba}, + {"Referer"_ba, "https://music.163.com/"_ba}, + }; + return k_h; +} + +[[nodiscard]] const QHash& lrclibHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "ZShell-shell (https://github.com/ZShell-dots/shell)"_ba}, + }; + return k_h; +} + +[[nodiscard]] QString joinArtists(const QString& s) { + return s.trimmed(); +} + +[[nodiscard]] QString sanitizeFilenamePart(const QString& s) { + QString out; + out.reserve(s.size()); + for (const QChar c : s) { + if (c == u'/' || c == u'\0') { + out.append(u'_'); + } else { + out.append(c); + } + } + return out; +} + +[[nodiscard]] bool containsCi(const QString& haystack, const QString& needle) { + return haystack.contains(needle, Qt::CaseInsensitive); +} + +} // namespace + +Lyrics::Lyrics(QObject* parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_loadDebounce(new QTimer(this)) { + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(k_loadDebounceMs); + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); + + const auto* cfg = config::ConfigSingleton::instance(); + const auto* svcCfg = cfg->services(); + const auto* genCfg = cfg->general(); + + m_preferredBackend = svcCfg->lyricsBackend(); + + QObject::connect( + svcCfg, + &config::Services::lyricsBackendChanged, + this, + &Lyrics::onPreferredBackendConfigChanged); + QObject::connect( + genCfg, + &config::General::lyricsDirChanged, + this, + &Lyrics::onLyricsDirChanged); + + loadLyricsMap(); +} + +QStringList Lyrics::lyrics() const { + return m_lyrics; +} + +LyricsBackend Lyrics::backend() const { + return m_backend; +} + +LyricsBackend Lyrics::preferredBackend() const { + return m_preferredBackend; +} + +void Lyrics::setPreferredBackend(LyricsBackend value) { + if (m_preferredBackend == value) { + return; + } + m_preferredBackend = value; + emit preferredBackendChanged(); + + config::ConfigSingleton::instance()->services()->set_lyricsBackend(value); + + scheduleLoad(); +} + +QList Lyrics::lyricCandidates() const { + return m_candidates; +} + +LyricCandidate Lyrics::selectedCandidate() const { + return m_selected; +} + +void Lyrics::setSelectedCandidate(const LyricCandidate& value) { + if (m_selected == value) { + return; + } + m_selected = value; + emit selectedCandidateChanged(); + + if (!value.isValid()) { + return; + } + + const auto b = value.backend(); + setBackend(b); + setLoading(true); + + cancelInFlight(); + const int reqId = newRequestId(); + + if (b == LyricsBackend::LRCLIB || b == LyricsBackend::NetEase) { + const QString cached = readCachedLrc(b, value.id()); + if (!cached.isEmpty()) { + const auto lines = parseLrc(cached); + if (!lines.isEmpty()) { + setLines(lines, b); + setLoading(false); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + return; + } + } + } + + if (b == LyricsBackend::LRCLIB) { + fetchLrclibById(value.id(), reqId); + } else if (b == LyricsBackend::NetEase) { + fetchNetEaseLyricsById(value.id(), reqId); + } else if (b == LyricsBackend::Local) { + // For local, the id is the file path. Read directly. + QFile f(value.id()); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + setLines(parseLrc(text), LyricsBackend::Local); + setLoading(false); + } else { + qCWarning(lcLyrics) + << "selectedCandidate: cannot open local file" << value.id(); + setLoading(false); + } + } + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +bool Lyrics::loading() const { + return m_loading; +} + +bool Lyrics::hasLyrics() const { + return m_hasLyrics; +} + +qreal Lyrics::offset() const { + return m_offset; +} + +void Lyrics::setOffset(qreal value) { + if (qFuzzyCompare(m_offset, value)) { + return; + } + m_offset = value; + emit offsetChanged(); + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +QString Lyrics::trackArtist() const { + return m_artist; +} + +QString Lyrics::trackTitle() const { + return m_title; +} + +int Lyrics::indexForTime(qreal time) const { + if (m_lines.isEmpty()) { + return -1; + } + const qreal target = time - m_offset + k_indexFudge; + qsizetype lo = 0; + qsizetype hi = m_lines.size(); + while (lo < hi) { + const qsizetype mid = lo + (hi - lo) / 2; + if (m_lines.at(mid).time <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return static_cast(lo - 1); +} + +qreal Lyrics::timeForIndex(int index) const { + if (index < 0 || index >= m_lines.size()) { + return -1.0; + } + return m_lines.at(index).time + m_offset; +} + +void Lyrics::setTrack( + const QString& artist, + const QString& title, + const QString& album, + qreal duration) { + const QString a = artist.trimmed(); + const QString t = title.trimmed(); + + if (a == m_artist && t == m_title && album == m_album && + qFuzzyCompare(duration + 1.0, m_duration + 1.0)) { + return; + } + + m_artist = a; + m_title = t; + m_album = album; + m_duration = duration; + emit trackChanged(); + + scheduleLoad(); +} + +void Lyrics::clearTrack() { + cancelInFlight(); + m_artist.clear(); + m_title.clear(); + m_album.clear(); + m_duration = 0.0; + emit trackChanged(); + + clearCandidates(); + clearLines(); + setLoading(false); +} + +void Lyrics::refresh() { + scheduleLoad(); +} + +void Lyrics::setBackend(LyricsBackend value) { + if (m_backend == value) { + return; + } + m_backend = value; + emit backendChanged(); +} + +void Lyrics::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + emit loadingChanged(); +} + +void Lyrics::setLines(QVector lines, LyricsBackend source) { + std::ranges::sort(lines, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + m_lines = std::move(lines); + QStringList list; + list.reserve(m_lines.size()); + for (const auto& l : std::as_const(m_lines)) { + list.append(l.text); + } + m_lyrics = std::move(list); + + setBackend(source); + emit lyricsChanged(); + + const auto hasLyrics = !m_lines.isEmpty(); + if (hasLyrics != m_hasLyrics) { + m_hasLyrics = hasLyrics; + emit hasLyricsChanged(); + } +} + +void Lyrics::clearLines() { + if (!m_hasLyrics) { + return; + } + + // Doesn't actually clear lines, set a flag instead so anims can run + m_hasLyrics = false; + emit hasLyricsChanged(); +} + +void Lyrics::appendCandidates(const QList& add) { + if (add.isEmpty()) { + return; + } + bool changed = false; + for (const auto& c : add) { + if (!m_candidates.contains(c)) { + m_candidates.append(c); + changed = true; + } + } + if (changed) { + emit lyricCandidatesChanged(); + } +} + +void Lyrics::clearCandidates() { + if (m_candidates.isEmpty()) { + return; + } + m_candidates.clear(); + emit lyricCandidatesChanged(); +} + +void Lyrics::scheduleLoad() { + m_loadDebounce->start(); +} + +int Lyrics::newRequestId() { + return ++m_currentRequestId; +} + +void Lyrics::cancelInFlight() { + for (auto it = m_pendingReplies.begin(); it != m_pendingReplies.end(); + ++it) { + for (auto& ptr : it.value()) { + if (auto* reply = ptr.data()) { + reply->abort(); + reply->deleteLater(); + } + } + } + m_pendingReplies.clear(); +} + +void Lyrics::trackReply(int reqId, QNetworkReply* reply) { + if (!reply) { + return; + } + m_pendingReplies[reqId].append(QPointer(reply)); +} + +void Lyrics::doLoad() { + if (m_artist.isEmpty() && m_title.isEmpty()) { + clearLines(); + clearCandidates(); + setLoading(false); + return; + } + + cancelInFlight(); + const int reqId = newRequestId(); + + setLoading(true); + clearLines(); + clearCandidates(); + + // Restore per-track prefs (offset, last-selected backend/id) + m_settingFromPrefs = true; + const QJsonObject saved = m_lyricsMap.value(trackKey()).toObject(); + setOffset(saved.value(u"offset"_s).toDouble(0.0)); + LyricCandidate restored; + const QString savedBackendKey = saved.value(u"backend"_s).toString(); + const QString savedId = saved.value(u"id"_s).toString(); + if (!savedBackendKey.isEmpty() && !savedId.isEmpty()) { + restored = LyricCandidate( + backendFromKey(savedBackendKey), + savedId, + m_title, + m_artist, + m_album, + m_duration); + } + m_settingFromPrefs = false; + + // Always populate online candidates for the picker, regardless of preferred backend + searchLrclibCandidates(reqId); + searchNetEaseCandidates(reqId); + + if (restored.isValid()) { + // Honor saved selection for this track + m_settingFromPrefs = true; + setSelectedCandidate(restored); + m_settingFromPrefs = false; + return; + } + + // Primary attempt by preferred backend + switch (m_preferredBackend) { + case LyricsBackend::Local: + tryLocal(reqId); + break; + case LyricsBackend::LRCLIB: + tryLrclib(reqId); + break; + case LyricsBackend::NetEase: + tryNetEase(reqId); + break; + case LyricsBackend::Auto: + default: + tryLocal(reqId); + break; + } +} + +void Lyrics::chainNext(LyricsBackend justFailed, int reqId) { + if (m_preferredBackend != LyricsBackend::Auto) { + // Non-auto modes don't chain + setLoading(false); + return; + } + switch (justFailed) { + case LyricsBackend::Local: + tryLrclib(reqId); + return; + case LyricsBackend::LRCLIB: + tryNetEase(reqId); + return; + case LyricsBackend::NetEase: + default: + setLoading(false); + return; + } +} + +void Lyrics::tryLocal(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::Local); + + const QString dir = lyricsDir(); + if (dir.isEmpty()) { + chainNext(LyricsBackend::Local, reqId); + return; + } + + const QString direct = tryReadLocalLrc(dir, m_artist, m_title); + if (!direct.isEmpty()) { + QFile f(direct); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + const QString recursive = findLocalLrcRecursive(dir, m_artist, m_title); + if (!recursive.isEmpty()) { + QFile f(recursive); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + qCDebug(lcLyrics) << "no local lrc for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::Local, reqId); +} + +void Lyrics::tryLrclib(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::LRCLIB); + + QUrl url(u"https://lrclib.net/api/get"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + if (!m_album.isEmpty()) { + q.addQueryItem(u"album_name"_s, m_album); + } + + constexpr qreal k_maxDurationSecs = std::numeric_limits::max(); + if (m_duration > 0 && qIsFinite(m_duration) && + m_duration < k_maxDurationSecs) { + q.addQueryItem(u"duration"_s, QString::number(qRound(m_duration))); + } + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) << "lrclib /get error:" << reply->errorString(); + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonObject obj = doc.object(); + const QString synced = obj.value(u"syncedLyrics"_s).toString(); + const qint64 id = static_cast(obj.value(u"id"_s).toDouble()); + + if (synced.isEmpty()) { + qCDebug(lcLyrics) + << "lrclib: no syncedLyrics for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + const auto lines = parseLrc(synced); + if (lines.isEmpty()) { + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + writeCachedLrc(LyricsBackend::LRCLIB, QString::number(id), synced); + setLines(lines, LyricsBackend::LRCLIB); + const LyricCandidate cand( + LyricsBackend::LRCLIB, + QString::number(id), + obj.value(u"trackName"_s).toString(), + obj.value(u"artistName"_s).toString(), + obj.value(u"albumName"_s).toString(), + obj.value(u"duration"_s).toDouble()); + appendCandidates({cand}); + m_selected = cand; + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + }); +} + +void Lyrics::tryNetEase(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::NetEase); + + // Reset cookies (LyricsBackend::NetEase rejects requests with stale cookies sometimes) + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease /search error:" << reply->errorString(); + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + // Find best match by artist substring + qint64 bestId = -1; + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + const QJsonArray artists = s.value(u"artists"_s).toArray(); + if (artists.isEmpty()) { + continue; + } + const QString sArtist = + artists.first().toObject().value(u"name"_s).toString(); + if (containsCi(m_artist, sArtist) || + containsCi(sArtist, m_artist)) { + bestId = static_cast(s.value(u"id"_s).toDouble()); + break; + } + } + + if (bestId < 0) { + qCDebug(lcLyrics) << "netease: no artist match for" << m_artist + << "-" << m_title; + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + fetchNetEaseLyricsById(QString::number(bestId), reqId); + }); +} + +void Lyrics::searchLrclibCandidates(int reqId) { + QUrl url(u"https://lrclib.net/api/search"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "lrclib /search error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray arr = doc.array(); + + QList add; + add.reserve(arr.size()); + for (const auto& v : arr) { + const QJsonObject o = v.toObject(); + if (o.value(u"syncedLyrics"_s).isNull() && + o.value(u"plainLyrics"_s).isNull()) { + continue; + } + add.append(LyricCandidate( + LyricsBackend::LRCLIB, + QString::number( + static_cast(o.value(u"id"_s).toDouble())), + o.value(u"trackName"_s).toString(), + o.value(u"artistName"_s).toString(), + o.value(u"albumName"_s).toString(), + o.value(u"duration"_s).toDouble())); + } + appendCandidates(add); + }); +} + +void Lyrics::searchNetEaseCandidates(int reqId) { + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease candidates error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + QList add; + add.reserve(songs.size()); + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + QStringList artistNames; + const QJsonArray artists = s.value(u"artists"_s).toArray(); + artistNames.reserve(artists.size()); + for (const auto& a : artists) { + artistNames.append(a.toObject().value(u"name"_s).toString()); + } + add.append(LyricCandidate( + LyricsBackend::NetEase, + QString::number( + static_cast(s.value(u"id"_s).toDouble())), + s.value(u"name"_s).toString(), + artistNames.join(u", "_s))); + } + appendCandidates(add); + }); +} + +void Lyrics::fetchLrclibById(const QString& id, int reqId) { + const QUrl url(u"https://lrclib.net/api/get/"_s + id); + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "lrclib /get/{id} error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString synced = + doc.object().value(u"syncedLyrics"_s).toString(); + if (synced.isEmpty()) { + qCDebug(lcLyrics) << "lrclib /get/{id}: no syncedLyrics"; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::LRCLIB, id, synced); + setLines(parseLrc(synced), LyricsBackend::LRCLIB); + setLoading(false); + }); +} + +void Lyrics::fetchNetEaseLyricsById(const QString& id, int reqId) { + QUrl url(u"https://music.163.com/api/song/lyric"_s); + QUrlQuery q; + q.addQueryItem(u"id"_s, id); + q.addQueryItem(u"lv"_s, u"1"_s); + q.addQueryItem(u"kv"_s, u"1"_s); + q.addQueryItem(u"tv"_s, u"-1"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "netease /lyric error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString lrc = doc.object() + .value(u"lrc"_s) + .toObject() + .value(u"lyric"_s) + .toString(); + if (lrc.isEmpty()) { + qCDebug(lcLyrics) << "netease /lyric: empty for id" << id; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::NetEase, id, lrc); + setLines(parseLrc(lrc), LyricsBackend::NetEase); + setLoading(false); + }); +} + +QNetworkReply* Lyrics::getJson( + const QUrl& url, const QHash& headers) { + QNetworkRequest req(url); + req.setAttribute( + QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + req.setRawHeader("Cache-Control"_ba, "no-cache, no-store"_ba); + req.setRawHeader("Pragma"_ba, "no-cache"_ba); + req.setRawHeader("Connection"_ba, "close"_ba); + req.setRawHeader("Accept"_ba, "application/json"_ba); + for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) { + req.setRawHeader(it.key(), it.value()); + } + return m_nam->get(req); +} + +void Lyrics::onPreferredBackendConfigChanged() { + const LyricsBackend desired = + config::ConfigSingleton::instance()->services()->lyricsBackend(); + if (desired == m_preferredBackend) { + return; + } + m_preferredBackend = desired; + emit preferredBackendChanged(); + scheduleLoad(); +} + +void Lyrics::onLyricsDirChanged() { + scheduleLoad(); +} + +void Lyrics::loadLyricsMap() { + m_lyricsMap = {}; + m_lyricsMapLoaded = false; + + QFile f(lyricsMapPath()); + if (!f.open(QIODevice::ReadOnly)) { + m_lyricsMapLoaded = true; + return; + } + const QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err); + if (err.error != QJsonParseError::NoError) { + qCWarning(lcLyrics) + << "lyrics_map.json parse error:" << err.errorString(); + m_lyricsMapLoaded = true; + return; + } + m_lyricsMap = doc.object(); + m_lyricsMapLoaded = true; +} + +void Lyrics::persistTrackPrefs() { + if (!m_lyricsMapLoaded || trackKey().isEmpty()) { + return; + } + const QString key = trackKey(); + QJsonObject entry = m_lyricsMap.value(key).toObject(); + entry.insert(u"offset"_s, m_offset); + if (m_selected.isValid()) { + entry.insert(u"backend"_s, backendKey(m_selected.backend())); + entry.insert(u"id"_s, m_selected.id()); + } + m_lyricsMap.insert(key, entry); + + QDir().mkpath(stateDir()); + + QSaveFile out(lyricsMapPath()); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) << "cannot open" << lyricsMapPath() + << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = + QJsonDocument(m_lyricsMap).toJson(QJsonDocument::Compact); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << lyricsMapPath(); + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) << "commit failed for" << lyricsMapPath() << ":" + << out.errorString(); + } +} + +QString Lyrics::lyricsDir() { + QString dir = config::ConfigSingleton::instance()->general()->lyricsDir(); + if (dir.isEmpty()) { + return {}; + } + if (dir == u"~"_s) { + dir = QDir::homePath(); + } else if (dir.startsWith(u"~/"_s)) { + dir.replace(0, 1, QDir::homePath()); + } + while (dir.endsWith(u'/') && dir.size() > 1) { + dir.chop(1); + } + return dir; +} + +QString Lyrics::lyricsMapPath() { + return stateDir() + u"/lyrics_map.json"_s; +} + +QString Lyrics::trackKey() const { + if (m_artist.isEmpty() && m_title.isEmpty()) { + return {}; + } + return u"%1 - %2"_s.arg(joinArtists(m_artist), m_title); +} + +QString Lyrics::backendKey(LyricsBackend value) { + switch (value) { + case LyricsBackend::Local: + return u"Local"_s; + case LyricsBackend::LRCLIB: + return u"LRCLIB"_s; + case LyricsBackend::NetEase: + return u"NetEase"_s; + case LyricsBackend::Auto: + default: + return u"Auto"_s; + } +} + +LyricsBackend Lyrics::backendFromKey(const QString& key) { + if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::Local; + } + if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::LRCLIB; + } + if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::NetEase; + } + return LyricsBackend::Auto; +} + +const QString& Lyrics::stateDir() { + static const QString k_dir = [] { + QString state = qEnvironmentVariable("XDG_STATE_HOME"); + if (state.isEmpty()) { + state = QDir::homePath() + u"/.local/state"_s; + } + return state + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +const QString& Lyrics::cacheDir() { + static const QString k_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) { + cache = QDir::homePath() + u"/.cache"_s; + } + return cache + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +QString Lyrics::cachePathFor(LyricsBackend backend, const QString& id) { + if (id.isEmpty() || backend == LyricsBackend::Auto || + backend == LyricsBackend::Local) { + return {}; + } + return u"%1/%2/%3.lrc"_s.arg( + cacheDir(), backendKey(backend), sanitizeFilenamePart(id)); +} + +QString Lyrics::readCachedLrc(LyricsBackend backend, const QString& id) { + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return {}; + } + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(f.readAll()); +} + +void Lyrics::writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text) { + if (text.isEmpty()) { + return; + } + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return; + } + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) + << "cannot open" << path << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = text.toUtf8(); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << path; + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) + << "commit failed for" << path << ":" << out.errorString(); + } +} + +QString Lyrics::tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title) { + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + const QString flat = u"%1/%2 - %3.lrc"_s.arg( + dir, sanitizeFilenamePart(artist), sanitizeFilenamePart(title)); + return QFile::exists(flat) ? flat : QString(); +} + +QString Lyrics::findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title) { + if (dir.isEmpty()) { + return {}; + } + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + + QDirIterator it( + dir, + QStringList{u"*.lrc"_s}, + QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + + while (it.hasNext()) { + const QString path = it.next(); + const QString name = it.fileName(); + if ((artist.isEmpty() || containsCi(name, artist)) && + (title.isEmpty() || containsCi(name, title))) { + return path; + } + } + return {}; +} + +QVector Lyrics::parseLrc(const QString& text) { + QVector result; + if (text.isEmpty()) { + return result; + } + + static const QRegularExpression k_timeRegex( + u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s); + static const QStringList k_creditKeywords = { + u"作词"_s, + u"作曲"_s, + u"编曲"_s, + u"制作"_s, + u"收录"_s, + u"演奏"_s, + u"词:"_s, + u"曲:"_s, + u"Lyricist"_s, + u"Composer"_s, + u"Arranger"_s, + u"Producer"_s, + u"Mixing"_s, + u"Mastering"_s, + }; + + const QStringList lines = text.split(u'\n'); + for (const QString& line : lines) { + QList matches; + auto it = k_timeRegex.globalMatch(line); + while (it.hasNext()) { + matches.append(it.next()); + } + if (matches.isEmpty()) { + continue; + } + + QString lyric = line; + lyric.replace(k_timeRegex, QString()); + lyric = lyric.trimmed(); + + const qreal firstTime = matches.first().captured(1).toInt() * 60.0 + + matches.first().captured(2).toDouble(); + + if (firstTime < 20.0) { + bool isCredit = false; + for (const QString& k : k_creditKeywords) { + if (lyric.contains(k, Qt::CaseInsensitive)) { + isCredit = true; + break; + } + } + if (isCredit && + (lyric.contains(u':') || lyric.contains(QChar(0xFF1A)) || + lyric.size() < 25)) { + continue; + } + } + + for (const auto& m : matches) { + const qreal t = + m.captured(1).toInt() * 60.0 + m.captured(2).toDouble(); + result.append(LyricLine{.time = t, .text = lyric}); + } + } + + std::ranges::sort(result, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + return result; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.hpp b/Plugins/ZShell/Services/lyrics.hpp new file mode 100644 index 0000000..864af88 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +struct LyricLine { + qreal time = 0.0; + QString text; +}; + +class Lyrics : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList lyrics READ lyrics NOTIFY lyricsChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum backend READ backend NOTIFY + backendChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum preferredBackend READ + preferredBackend WRITE setPreferredBackend NOTIFY + preferredBackendChanged) + Q_PROPERTY( + QList lyricCandidates READ + lyricCandidates NOTIFY lyricCandidatesChanged) + Q_PROPERTY( + ZShell::services::LyricCandidate selectedCandidate READ selectedCandidate + WRITE setSelectedCandidate NOTIFY selectedCandidateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY hasLyricsChanged) + Q_PROPERTY(qreal offset READ offset WRITE setOffset NOTIFY offsetChanged) + Q_PROPERTY(QString trackArtist READ trackArtist NOTIFY trackChanged) + Q_PROPERTY(QString trackTitle READ trackTitle NOTIFY trackChanged) + + public: + explicit Lyrics(QObject* parent = nullptr); + + [[nodiscard]] QStringList lyrics() const; + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] LyricsBackend preferredBackend() const; + void setPreferredBackend(LyricsBackend value); + [[nodiscard]] QList lyricCandidates() const; + [[nodiscard]] LyricCandidate selectedCandidate() const; + void setSelectedCandidate(const LyricCandidate& value); + [[nodiscard]] bool loading() const; + [[nodiscard]] bool hasLyrics() const; + [[nodiscard]] qreal offset() const; + void setOffset(qreal value); + [[nodiscard]] QString trackArtist() const; + [[nodiscard]] QString trackTitle() const; + + [[nodiscard]] Q_INVOKABLE int indexForTime(qreal time) const; + [[nodiscard]] Q_INVOKABLE qreal timeForIndex(int index) const; + Q_INVOKABLE void setTrack( + const QString& artist, + const QString& title, + const QString& album = {}, + qreal duration = 0.0); + Q_INVOKABLE void clearTrack(); + Q_INVOKABLE void refresh(); + + signals: + void lyricsChanged(); + void backendChanged(); + void preferredBackendChanged(); + void lyricCandidatesChanged(); + void selectedCandidateChanged(); + void loadingChanged(); + void hasLyricsChanged(); + void offsetChanged(); + void trackChanged(); + + private: + void setBackend(LyricsBackend value); + void setLoading(bool value); + void setLines(QVector lines, LyricsBackend source); + void clearLines(); + void appendCandidates(const QList& add); + void clearCandidates(); + + void scheduleLoad(); + void doLoad(); + void cancelInFlight(); + int newRequestId(); + + void tryLocal(int reqId); + void tryLrclib(int reqId); + void tryNetEase(int reqId); + void chainNext(LyricsBackend justFailed, int reqId); + + void searchLrclibCandidates(int reqId); + void searchNetEaseCandidates(int reqId); + + void fetchLrclibById(const QString& id, int reqId); + void fetchNetEaseLyricsById(const QString& id, int reqId); + + QNetworkReply* getJson( + const QUrl& url, const QHash& headers = {}); + void trackReply(int reqId, QNetworkReply* reply); + + void onPreferredBackendConfigChanged(); + void onLyricsDirChanged(); + + void loadLyricsMap(); + void persistTrackPrefs(); + + [[nodiscard]] static QString lyricsDir(); + [[nodiscard]] static QString lyricsMapPath(); + [[nodiscard]] QString trackKey() const; + [[nodiscard]] static QString backendKey(LyricsBackend value); + [[nodiscard]] static LyricsBackend backendFromKey(const QString& key); + + [[nodiscard]] static const QString& stateDir(); + [[nodiscard]] static const QString& cacheDir(); + [[nodiscard]] static QString cachePathFor( + LyricsBackend backend, const QString& id); + [[nodiscard]] static QString readCachedLrc( + LyricsBackend backend, const QString& id); + static void writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text); + + [[nodiscard]] static QVector parseLrc(const QString& text); + [[nodiscard]] static QString tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title); + [[nodiscard]] static QString findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title); + + QNetworkAccessManager* m_nam; + QTimer* m_loadDebounce; + + QVector m_lines; + QStringList m_lyrics; + LyricsBackend m_backend = LyricsBackend::Auto; + LyricsBackend m_preferredBackend = LyricsBackend::Auto; + QList m_candidates; + LyricCandidate m_selected; + bool m_loading = false; + bool m_hasLyrics = false; + qreal m_offset = 0.0; + + QString m_artist; + QString m_title; + QString m_album; + qreal m_duration = 0.0; + + int m_currentRequestId = 0; + QHash>> m_pendingReplies; + + QJsonObject m_lyricsMap; + bool m_lyricsMapLoaded = false; + bool m_settingFromPrefs = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp index eb8020c..07e9c55 100644 --- a/Plugins/ZShell/Services/memory.cpp +++ b/Plugins/ZShell/Services/memory.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace ZShell::services { Memory::Memory(QObject* parent) : TickingService(parent) {} @@ -15,6 +17,18 @@ qreal Memory::total() const { return m_total; } +qreal Memory::cached() const { + return m_cached; +} + +qreal Memory::readTotal() const { + return m_readTotal; +} + +qreal Memory::writeTotal() const { + return m_writeTotal; +} + qreal Memory::percentage() const { return m_total > 0.0 ? m_used / m_total : 0.0; } @@ -31,6 +45,7 @@ void Memory::tick() { QStringLiteral("MemTotal: *(\\d+)")); static const QRegularExpression reAvail( QStringLiteral("MemAvailable: *(\\d+)")); + static const QRegularExpression reCached(QStringLiteral("Cached: *(\\d+)")); const QString text = QString::fromLatin1(data); const auto totalMatch = reTotal.match(text); @@ -45,14 +60,50 @@ void Memory::tick() { return; } const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0; + const auto cachedMatch = reCached.match(text); + const quint64 cachedKib = + cachedMatch.hasMatch() ? cachedMatch.captured(1).toULongLong() : 0; - if (totalKib == m_lastTotal && usedKib == m_lastUsed) { + static const QRegularExpression rePgin(QStringLiteral("pgpgin (\\d+)")); + static const QRegularExpression rePgout(QStringLiteral("pgpgout (\\d+)")); + quint64 readKib = 0; + quint64 writeKib = 0; + { + QFile vf(QStringLiteral("/proc/vmstat")); + if (vf.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString vtext = QString::fromLatin1(vf.readAll()); + vf.close(); + const double pageKib = getpagesize() / 1024.0; + const auto pgin = rePgin.match(vtext); + const auto pgout = rePgout.match(vtext); + if (pgin.hasMatch()) { + readKib = static_cast( + static_cast(pgin.captured(1).toULongLong()) * + pageKib); + } + if (pgout.hasMatch()) { + writeKib = static_cast( + static_cast(pgout.captured(1).toULongLong()) * + pageKib); + } + } + } + + if (totalKib == m_lastTotal && usedKib == m_lastUsed && + cachedKib == m_lastCached && readKib == m_lastReadKib && + writeKib == m_lastWriteKib) { return; } m_lastTotal = totalKib; m_lastUsed = usedKib; + m_lastCached = cachedKib; + m_lastReadKib = readKib; + m_lastWriteKib = writeKib; m_total = static_cast(totalKib); m_used = static_cast(usedKib); + m_cached = static_cast(cachedKib); + m_readTotal = static_cast(readKib); + m_writeTotal = static_cast(writeKib); Q_EMIT changed(); } diff --git a/Plugins/ZShell/Services/memory.hpp b/Plugins/ZShell/Services/memory.hpp index e21bbc5..cb33ac7 100644 --- a/Plugins/ZShell/Services/memory.hpp +++ b/Plugins/ZShell/Services/memory.hpp @@ -14,14 +14,20 @@ class Memory : public TickingService { Q_PROPERTY(qreal used READ used NOTIFY changed) Q_PROPERTY(qreal total READ total NOTIFY changed) + Q_PROPERTY(qreal cached READ cached NOTIFY changed) Q_PROPERTY(qreal percentage READ percentage NOTIFY changed) + Q_PROPERTY(qreal readTotal READ readTotal NOTIFY changed) + Q_PROPERTY(qreal writeTotal READ writeTotal NOTIFY changed) public: explicit Memory(QObject* parent = nullptr); [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; + [[nodiscard]] qreal cached() const; [[nodiscard]] qreal percentage() const; + [[nodiscard]] qreal readTotal() const; + [[nodiscard]] qreal writeTotal() const; signals: void changed(); @@ -32,8 +38,14 @@ class Memory : public TickingService { private: qreal m_used = 0.0; qreal m_total = 1.0; + qreal m_cached = 0.0; + qreal m_readTotal = 0.0; + qreal m_writeTotal = 0.0; quint64 m_lastUsed = 0; quint64 m_lastTotal = 0; + quint64 m_lastCached = 0; + quint64 m_lastReadKib = 0; + quint64 m_lastWriteKib = 0; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/networkusage.cpp b/Plugins/ZShell/Services/networkusage.cpp new file mode 100644 index 0000000..493b03f --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.cpp @@ -0,0 +1,153 @@ +#include "networkusage.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "config/rootnodes.hpp" +#include "config/dashboard.hpp" + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; + +NetworkUsage::NetworkUsage(QObject* parent) + : TickingService(parent) + , m_downloadBuffer(new internal::CircularBuffer(this)) + , m_uploadBuffer(new internal::CircularBuffer(this)) { + m_downloadBuffer->setSmoothingRadius(2); + m_uploadBuffer->setSmoothingRadius(2); + + auto* resources = + config::ConfigSingleton::instance()->dashboard()->performance(); + setHistoryLength(resources->showVram() ? 70 : 30); + QObject::connect( + resources, + &ZShell::config::Performance::showVramChanged, + this, + [this, resources] { + setHistoryLength(resources->showVram() ? 70 : 30); + }); +} + +qreal NetworkUsage::downloadSpeed() const { + return m_downloadSpeed; +} + +qreal NetworkUsage::uploadSpeed() const { + return m_uploadSpeed; +} + +qreal NetworkUsage::downloadTotal() const { + return m_downloadTotal; +} + +qreal NetworkUsage::uploadTotal() const { + return m_uploadTotal; +} + +int NetworkUsage::historyLength() const { + return m_historyLength; +} + +void NetworkUsage::setHistoryLength(int length) { + if (length <= 0 || length == m_historyLength) { + return; + } + m_historyLength = length; + m_downloadBuffer->setCapacity(m_historyLength + 1); + m_uploadBuffer->setCapacity(m_historyLength + 1); + Q_EMIT historyLengthChanged(); +} + +internal::CircularBuffer* NetworkUsage::downloadBuffer() const { + return m_downloadBuffer; +} + +internal::CircularBuffer* NetworkUsage::uploadBuffer() const { + return m_uploadBuffer; +} + +void NetworkUsage::tick() { + QFile f(u"/proc/net/dev"_s); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + // Skip headers + f.readLine(); + f.readLine(); + + quint64 totalRx = 0; + quint64 totalTx = 0; + + while (!f.atEnd()) { + const QByteArray line = f.readLine(); + const qsizetype splitIdx = line.indexOf(':'); + if (splitIdx == -1) { + continue; + } + const QByteArray iface = line.left(splitIdx).trimmed(); + if (iface == QByteArrayView("lo")) { + continue; // Skip loopback interface + } + + // Parse every counter through tx bytes to validate the row + const char* pos = line.constData() + splitIdx + 1; + const char* const end = line.constData() + line.size(); + + std::array fields{}; + bool valid = true; + for (unsigned long long& field : fields) { + while (pos < end && (*pos == ' ' || *pos == '\t')) + ++pos; + + const auto [next, ec] = std::from_chars(pos, end, field); + if (ec != std::errc{}) { + valid = false; + break; + } + pos = next; + } + + if (!valid) continue; + + totalRx += static_cast(fields[0]); + totalTx += static_cast(fields[8]); + } + f.close(); + + if (!m_initialized) { + m_prevRx = totalRx; + m_prevTx = totalTx; + m_timer.start(); + m_initialized = true; + return; + } + + const qreal elapsed = static_cast(m_timer.restart()) / 1000.0; + const quint64 rxDelta = totalRx >= m_prevRx ? totalRx - m_prevRx : 0; + const quint64 txDelta = totalTx >= m_prevTx ? totalTx - m_prevTx : 0; + + m_downloadTotal += static_cast(rxDelta); + m_uploadTotal += static_cast(txDelta); + + if (elapsed > 0.0) { + // Calculate speeds + m_downloadSpeed = static_cast(rxDelta) / elapsed; + m_uploadSpeed = static_cast(txDelta) / elapsed; + + m_downloadBuffer->push(m_downloadSpeed); + m_uploadBuffer->push(m_uploadSpeed); + } + + m_prevRx = totalRx; + m_prevTx = totalTx; + + emit changed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/networkusage.hpp b/Plugins/ZShell/Services/networkusage.hpp new file mode 100644 index 0000000..7a6555b --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include "internal/circularbuffer.hpp" +#include "tickingservice.hpp" + +namespace ZShell::services { + +class NetworkUsage : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(qreal downloadSpeed READ downloadSpeed NOTIFY changed) + Q_PROPERTY(qreal uploadSpeed READ uploadSpeed NOTIFY changed) + Q_PROPERTY(qreal downloadTotal READ downloadTotal NOTIFY changed) + Q_PROPERTY(qreal uploadTotal READ uploadTotal NOTIFY changed) + Q_PROPERTY(int historyLength READ historyLength NOTIFY historyLengthChanged) + + Q_PROPERTY( + ZShell::internal::CircularBuffer* downloadBuffer READ downloadBuffer + CONSTANT) + Q_PROPERTY( + ZShell::internal::CircularBuffer* uploadBuffer READ uploadBuffer + CONSTANT) + + public: + explicit NetworkUsage(QObject* parent = nullptr); + + [[nodiscard]] qreal downloadSpeed() const; + [[nodiscard]] qreal uploadSpeed() const; + [[nodiscard]] qreal downloadTotal() const; + [[nodiscard]] qreal uploadTotal() const; + [[nodiscard]] int historyLength() const; + + [[nodiscard]] internal::CircularBuffer* downloadBuffer() const; + [[nodiscard]] internal::CircularBuffer* uploadBuffer() const; + + signals: + void changed(); + void historyLengthChanged(); + + protected: + void tick() override; + + private: + void setHistoryLength(int length); + + qreal m_downloadSpeed = 0.0; + qreal m_uploadSpeed = 0.0; + qreal m_downloadTotal = 0.0; + qreal m_uploadTotal = 0.0; + int m_historyLength = 30; + + internal::CircularBuffer* m_downloadBuffer = nullptr; + internal::CircularBuffer* m_uploadBuffer = nullptr; + + quint64 m_prevRx = 0; + quint64 m_prevTx = 0; + bool m_initialized = false; + QElapsedTimer m_timer; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp new file mode 100644 index 0000000..a155c84 --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -0,0 +1,443 @@ +#include "nightlightmanager.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace { +constexpr const char* NL_EVENT = "zshell-nightlight"; +constexpr int RECONNECT_MS = 2000; +} // namespace + +NightlightManager::NightlightManager(QObject* parent) : QObject(parent) { + connect(&m_timer, &QTimer::timeout, this, &NightlightManager::apply); + connect(&m_manualTimer, &QTimer::timeout, this, [this] { + m_manualToggle = false; + emit manualToggleChanged(); + apply(); + }); + connect(&m_startCooldown, &QTimer::timeout, this, [this] { + m_startAllowed = true; + apply(); + }); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this] { + if (!m_useNativeNightlight) return; + + connectEventSocket(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + bootstrapState(); + }); + + m_startCooldown.start(2000); + m_manualTimer.setSingleShot(true); + m_reconnectTimer.setSingleShot(true); + m_timer.start(60000); + + m_process.setStandardInputFile(QProcess::nullDevice()); + m_process.setStandardOutputFile(QProcess::nullDevice()); +} + +int NightlightManager::startTime() const { + return m_startTime; +} + +int NightlightManager::endTime() const { + return m_endTime; +} + +bool NightlightManager::manualToggle() const { + return m_manualToggle; +} + +bool NightlightManager::enabled() const { + return m_enabled; +} + +bool NightlightManager::activeAuto() const { + return m_activeAuto; +} + +int NightlightManager::temp() const { + return m_temp; +} + +double NightlightManager::fadeDuration() const { + return m_fadeDuration; +} + +bool NightlightManager::animating() const { + return m_animating; +} + +int NightlightManager::lastTemp() const { + return m_lastTemp; +} + +int NightlightManager::currentTemp() const { + return m_currentTemp; +} + +void NightlightManager::setActiveAuto(bool activeAuto) { + if (activeAuto == m_activeAuto) return; + + m_activeAuto = activeAuto; + emit activeAutoChanged(); +} + +void NightlightManager::setManualToggle(bool toggle) { + if (toggle == m_manualToggle) return; + + m_manualToggle = toggle; + emit manualToggleChanged(); + + m_manualTimer.start(60 * 60 * 1000); +} + +void NightlightManager::setEndTime(const int& time) { + if (time == m_endTime) return; + + m_endTime = time; + emit endTimeChanged(); + apply(); +} + +void NightlightManager::setStartTime(const int& time) { + if (time == m_startTime) return; + + m_startTime = time; + emit startTimeChanged(); + apply(); +} + +void NightlightManager::setTemp(const int& temp) { + if (temp == m_temp) return; + + m_temp = temp; + emit tempChanged(); + apply(); +} + +void NightlightManager::setFadeDuration(double duration) { + if (duration < 0.0) duration = 0.0; + + m_fadeDuration = duration; + emit fadeDurationChanged(); +} + +void NightlightManager::toggle(bool fast) { + if (m_enabled) { + end(fast ? 2 : m_fadeDuration); + } else { + start(fast ? 2 : m_fadeDuration); + } +} + +void NightlightManager::start(double fade) { + if (m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = true; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlSet(%1, %2)").arg(m_temp).arg(fade)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"hyprsunset", "temperature", QString::number(m_temp)}); + m_process.startDetached(); + } +} + +void NightlightManager::end(double fade) { + if (!m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = false; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", QString("hl.plugin.zshell.nlDisable(%1)").arg(fade)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments({"hyprsunset", "identity"}); + m_process.startDetached(); + } +} + +void NightlightManager::apply() { + if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; + + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime = false; + + if (m_startTime <= m_endTime) { + isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); + } else { + isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); + } + + const auto activationTime = isDarkTime ? m_startTime : m_endTime; + const auto elapsed = (currentMin - activationTime + 1440) % 1440; + const bool pastActivation = elapsed > 20; + + if (isDarkTime) { + start(pastActivation ? 2 : m_fadeDuration); + } else { + end(pastActivation ? 2 : m_fadeDuration); + } +} + +bool NightlightManager::useNativeNightlight() const { + return m_useNativeNightlight; +} + +QString NightlightManager::pluginPath() const { + return m_pluginPath; +} + +void NightlightManager::setPluginPath(const QString& path) { + if (path == m_pluginPath) return; + + m_pluginPath = path; + emit pluginPathChanged(); +} + +void NightlightManager::setUseNativeNightlight(bool native) { + if (native == m_useNativeNightlight) return; + + m_useNativeNightlight = native; + emit useNativeNightlightChanged(); + + if (native) { + disconnect(&m_eventSocket, nullptr, this, nullptr); + connect( + &m_eventSocket, + &QLocalSocket::readyRead, + this, + &NightlightManager::handleEventLines); + connect( + &m_eventSocket, + &QLocalSocket::errorOccurred, + this, + &NightlightManager::scheduleReconnect); + connectEventSocket(); + bootstrapState(); + } else { + m_reconnectTimer.stop(); + disconnect(&m_eventSocket, nullptr, this, nullptr); + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) + m_eventSocket.disconnectFromServer(); + m_socketBuffer.clear(); + m_animating = false; + emit animatingChanged(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + } +} + +QString NightlightManager::hyprlandDir() const { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) return QString(); + + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + auto dir = runtimeDir + "/hypr/" + his; + if (!QFileInfo(dir).isDir()) dir = "/tmp/hypr/" + his; + + if (!QFileInfo(dir).isDir()) return QString(); + + return dir; +} + +QString NightlightManager::eventSocketPath() const { + const auto dir = hyprlandDir(); + return dir.isEmpty() ? QString() : dir + "/.socket2.sock"; +} + +QString NightlightManager::resolvePluginPath() const { + if (!m_pluginPath.isEmpty()) return m_pluginPath; + + const QString name = QStringLiteral("libzshell-nightlight.so"); + const QStringList dirs = { + "/usr/lib/ZShell/plugins", + "/usr/local/lib/ZShell/plugins", + }; + for (const auto& dir : dirs) { + const QString candidate = dir + "/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString home = qEnvironmentVariable("HOME"); + if (!home.isEmpty()) { + const QString candidate = home + "/.local/lib/ZShell/plugins/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + return QString(); +} + +void NightlightManager::tryLoadPlugin() { + if (m_pluginLoadAttempted) return; + + const auto path = resolvePluginPath(); + if (path.isEmpty()) { + qWarning() << "NightlightManager: plugin not found; searched " + "pluginPath and /usr/lib/ZShell/plugins"; + m_pluginLoadAttempted = true; + return; + } + + m_pluginLoadAttempted = true; + m_pluginLoadedByUs = true; + qInfo() << "NightlightManager: plugin not loaded, loading" << path; + QProcess::startDetached("hyprctl", {"plugin", "load", path}); + + QTimer::singleShot(1500, this, [this] { bootstrapState(); }); +} + +void NightlightManager::bootstrapState() { + if (!m_useNativeNightlight) return; + + const auto dir = hyprlandDir(); + if (dir.isEmpty()) return; + + auto* sock = new QLocalSocket(this); + auto* watchdog = new QTimer(this); + watchdog->setSingleShot(true); + + const auto finish = [sock, watchdog]() { + watchdog->stop(); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QLocalSocket::connected, sock, [sock] { + sock->write("repl return hl.plugin.zshell.nlState()"); + sock->flush(); + }); + connect(sock, &QLocalSocket::readyRead, sock, [this, sock, finish]() { + const auto raw = sock->readAll(); + const auto doc = QJsonDocument::fromJson(raw); + finish(); + if (!doc.isObject()) { + if (m_pluginLoadAttempted) + qWarning() << "NightlightManager: plugin load failed, state " + << "query still answered:" << raw; + + tryLoadPlugin(); + return; + } + + if (m_pluginLoadedByUs) { + qInfo() << "NightlightManager: plugin was not loaded, manager " + << "loaded it; state:" << raw; + m_pluginLoadedByUs = false; + } else { + qInfo() << "NightlightManager: plugin already loaded, state:" + << raw; + } + + const auto obj = doc.object(); + updateState( + obj.value("enabled").toBool(), + obj.value("animating").toBool(), + obj.value("last").toInt(), + obj.value("current").toInt()); + }); + connect(sock, &QLocalSocket::errorOccurred, sock, finish); + connect(watchdog, &QTimer::timeout, sock, finish); + watchdog->start(2500); + + sock->connectToServer(dir + "/.socket.sock"); +} + +void NightlightManager::connectEventSocket() { + if (!m_useNativeNightlight) return; + + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) return; + + const auto path = eventSocketPath(); + if (path.isEmpty()) { + scheduleReconnect(); + return; + } + + m_socketBuffer.clear(); + m_eventSocket.connectToServer(path, QLocalSocket::ReadOnly); +} + +void NightlightManager::scheduleReconnect() { + if (!m_useNativeNightlight) return; + + m_reconnectTimer.start(RECONNECT_MS); +} + +void NightlightManager::handleEventLines() { + m_socketBuffer.append(m_eventSocket.readAll()); + + qsizetype idx = 0; + while ((idx = m_socketBuffer.indexOf('\n')) >= 0) { + const auto line = m_socketBuffer.left(idx); + m_socketBuffer.remove(0, idx + 1); + handleEventLine(line); + } +} + +void NightlightManager::handleEventLine(const QByteArray& line) { + const qsizetype sep = line.indexOf(">>"); + if (sep <= 0) return; + + if (line.left(sep) != NL_EVENT) return; + + const auto fields = line.mid(sep + 2).split(','); + if (fields.size() < 4) return; + + bool ok = false; + const auto enabled = fields.at(0).toInt(&ok); + if (!ok) return; + const auto animating = fields.at(1).toInt(&ok); + if (!ok) return; + const auto last = fields.at(2).toInt(&ok); + if (!ok) return; + const auto current = fields.at(3).toInt(&ok); + if (!ok) return; + + updateState(enabled == 1, animating == 1, last, current); +} + +void NightlightManager::updateState( + bool enabled, bool animating, int last, int current) { + if (enabled != m_enabled) { + m_enabled = enabled; + emit enabledChanged(); + } + if (animating != m_animating) { + m_animating = animating; + emit animatingChanged(); + } + if (last != m_lastTemp) { + m_lastTemp = last; + emit lastTempChanged(); + } + if (current != m_currentTemp) { + m_currentTemp = current; + emit currentTempChanged(); + } +} + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp new file mode 100644 index 0000000..c12f3ca --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class NightlightManager : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY( + int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) + Q_PROPERTY( + double fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) + Q_PROPERTY( + bool useNativeNightlight READ useNativeNightlight WRITE + setUseNativeNightlight NOTIFY useNativeNightlightChanged) + Q_PROPERTY( + QString pluginPath READ pluginPath WRITE setPluginPath NOTIFY + pluginPathChanged) + Q_PROPERTY( + bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY + activeAutoChanged) + Q_PROPERTY( + bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY + manualToggleChanged) + Q_PROPERTY(bool animating READ animating NOTIFY animatingChanged) + Q_PROPERTY(int lastTemp READ lastTemp NOTIFY lastTempChanged) + Q_PROPERTY(int currentTemp READ currentTemp NOTIFY currentTempChanged) + + public: + explicit NightlightManager(QObject* parent = nullptr); + + [[nodiscard]] int startTime() const; + [[nodiscard]] int endTime() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] int temp() const; + [[nodiscard]] double fadeDuration() const; + [[nodiscard]] bool useNativeNightlight() const; + [[nodiscard]] QString pluginPath() const; + [[nodiscard]] bool activeAuto() const; + [[nodiscard]] bool manualToggle() const; + [[nodiscard]] bool animating() const; + [[nodiscard]] int lastTemp() const; + [[nodiscard]] int currentTemp() const; + + Q_INVOKABLE void toggle(bool fast); + Q_INVOKABLE void apply(); + + void setStartTime(const int& time); + void setEndTime(const int& time); + void setTemp(const int& temp); + void setFadeDuration(double duration); + void setUseNativeNightlight(bool native); + void setPluginPath(const QString& path); + void setActiveAuto(bool activeAuto); + void setManualToggle(bool toggle); + + signals: + void enabledChanged(); + void startTimeChanged(); + void activeAutoChanged(); + void endTimeChanged(); + void tempChanged(); + void fadeDurationChanged(); + void useNativeNightlightChanged(); + void pluginPathChanged(); + void manualToggleChanged(); + void animatingChanged(); + void lastTempChanged(); + void currentTempChanged(); + + private: + int m_startTime; + int m_endTime; + bool m_enabled = false; + bool m_manualToggle = false; + bool m_activeAuto; + bool m_startAllowed = false; + bool m_initialized = false; + QTimer m_startCooldown; + int m_temp; + double m_fadeDuration = 0.4; + bool m_useNativeNightlight = false; + QString m_pluginPath; + bool m_pluginLoadAttempted = false; + bool m_pluginLoadedByUs = false; + bool m_animating = false; + int m_lastTemp = 6500; + int m_currentTemp = 6500; + QProcess m_process; + QTimer m_timer; + QTimer m_manualTimer; + QLocalSocket m_eventSocket; + QByteArray m_socketBuffer; + QTimer m_reconnectTimer; + + void start(double fade); + void end(double fade); + [[nodiscard]] QString hyprlandDir() const; + [[nodiscard]] QString eventSocketPath() const; + [[nodiscard]] QString resolvePluginPath() const; + void tryLoadPlugin(); + void bootstrapState(); + void connectEventSocket(); + void scheduleReconnect(); + void handleEventLines(); + void handleEventLine(const QByteArray& line); + void updateState(bool enabled, bool animating, int last, int current); +}; + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp new file mode 100644 index 0000000..5b926a4 --- /dev/null +++ b/Plugins/ZShell/Services/processes.cpp @@ -0,0 +1,1340 @@ +#include "processes.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcProcessModel, "ZShell.services.processes", QtInfoMsg) + +namespace ZShell::services { + +namespace { + +QVector splitWhitespace(QByteArrayView s) { + QVector out; + qsizetype i = 0; + const qsizetype n = s.size(); + while (i < n) { + while (i < n && std::isspace(static_cast(s[i]))) { + ++i; + } + const qsizetype start = i; + while (i < n && !std::isspace(static_cast(s[i]))) { + ++i; + } + if (i > start) { + out.push_back(s.mid(start, i - start)); + } + } + return out; +} + +qint64 toI64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toLongLong(); +} + +quint64 toU64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toULongLong(); +} + +template int cmpVals(const T& a, const T& b) { + return (a < b) - (a > b); +} + +// glibc hides getdents64 behind __USE_MISC; declare directly (no _GNU_SOURCE +// in this project). +extern "C" { +struct ProcDirEnt64 { + unsigned long d_ino; + off_t d_off; + unsigned short d_reclen; + unsigned char d_type; + char d_name[1]; +}; + +ssize_t getdents64(int fd, void* dirp, size_t count); +} + +// Raw open/read/close instead of QFile: QFSFileEngine issues an extra +// statx per open, and the per-file object churn adds up across ~500 +// processes per refresh. +QByteArray readProcFile(const char* path) { + const int fd = ::open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return {}; + } + QByteArray data; + char buf[4096]; + for (;;) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (n == 0) { + break; + } + data.append(buf, static_cast(n)); + } + ::close(fd); + return data; +} + +void procPath(char* out, size_t size, pid_t pid, const char* what) { + ::snprintf(out, size, "/proc/%d/%s", static_cast(pid), what); +} + +QVector listProcPids() { + QVector pids; + const int fd = ::openat(AT_FDCWD, "/proc", O_RDONLY | O_DIRECTORY); + if (fd < 0) { + return pids; + } + + std::array buf; + for (;;) { + const ssize_t n = ::getdents64( + fd, reinterpret_cast(buf.data()), buf.size()); + if (n <= 0) { + break; + } + for (ssize_t off = 0; off < n;) { + const auto* d = + reinterpret_cast(buf.data() + off); + if (d->d_type == DT_DIR) { + bool ok = false; + const long v = QByteArray(d->d_name).toLong(&ok); + if (ok && v > 0) { + pids.push_back(static_cast(v)); + } + } + off += static_cast(d->d_reclen); + } + } + ::close(fd); + return pids; +} + + +struct FuzzyText { + QString chars; + QVector classes; +}; + +constexpr int kFuzzyScoreMatch = 16; +constexpr int kFuzzyScoreGapStart = -3; +constexpr int kFuzzyScoreGapExtension = -1; +constexpr int kFuzzyBonusBoundary = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusNonWord = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusCamel123 = + kFuzzyBonusBoundary + kFuzzyScoreGapExtension; +constexpr int kFuzzyBonusConsecutive = + -(kFuzzyScoreGapStart + kFuzzyScoreGapExtension); +constexpr int kFuzzyFirstCharMultiplier = 2; + +int fuzzyCharClass(QChar c) { + if (c.isLower()) { + return 1; + } + if (c.isUpper()) { + return 2; + } + if (c.isDigit() || c.category() == QChar::Number_Other) { + return 4; + } + if (c.isLetter()) { + return 3; + } + return 0; +} + +int fuzzyBonusFor(int prevClass, int currClass) { + if (prevClass == 0 && currClass != 0) { + return kFuzzyBonusBoundary; + } + if ((prevClass == 1 && currClass == 2) || + (prevClass != 4 && currClass == 4)) { + return kFuzzyBonusCamel123; + } + if (currClass == 0) { + return kFuzzyBonusNonWord; + } + return 0; +} + +FuzzyText fuzzyPrepare(const QString& src) { + FuzzyText out; + const QString decomposed = src.normalized(QString::NormalizationForm_D); + out.chars.reserve(decomposed.size()); + out.classes.reserve(decomposed.size()); + for (int i = 0; i < decomposed.size(); ++i) { + const QChar c = decomposed.at(i); + const uint cp = c.unicode(); + if (cp >= 0x0300 && cp <= 0x036F) { + continue; + } + out.classes.append(static_cast(fuzzyCharClass(c))); + out.chars.append(c.toLower()); + } + return out; +} + +int fuzzyTermScore( + const FuzzyText& text, const QString& term, int* matchEnd = nullptr) { + const int n = static_cast(text.chars.size()); + const int m = static_cast(term.size()); + if (m == 0) { + if (matchEnd) { + *matchEnd = 0; + } + return 0; + } + if (m > n) { + return -1; + } + + int pidx = 0; + int sidx = -1; + int eidx = -1; + for (int i = 0; i < n; ++i) { + if (text.chars.at(i) == term.at(pidx)) { + if (sidx < 0) { + sidx = i; + } + ++pidx; + if (pidx == m) { + eidx = i + 1; + break; + } + } + } + if (eidx < 0) { + return -1; + } + + pidx = m - 1; + for (int i = eidx - 1; i >= sidx; --i) { + if (text.chars.at(i) == term.at(pidx)) { + --pidx; + if (pidx < 0) { + sidx = i; + break; + } + } + } + if (matchEnd) { + *matchEnd = eidx; + } + + int score = 0; + pidx = 0; + bool inGap = false; + int consecutive = 0; + int firstBonus = 0; + int prevClass = sidx > 0 ? text.classes.at(sidx - 1) : 0; + for (int i = sidx; i < eidx; ++i) { + const int cc = text.classes.at(i); + if (text.chars.at(i) == term.at(pidx)) { + score += kFuzzyScoreMatch; + int bonus = fuzzyBonusFor(prevClass, cc); + if (consecutive == 0) { + firstBonus = bonus; + } else { + if (bonus == kFuzzyBonusBoundary) { + firstBonus = bonus; + } + bonus = qMax(bonus, qMax(firstBonus, kFuzzyBonusConsecutive)); + } + score += (pidx == 0) ? bonus * kFuzzyFirstCharMultiplier : bonus; + inGap = false; + ++consecutive; + ++pidx; + } else { + score += inGap ? kFuzzyScoreGapExtension : kFuzzyScoreGapStart; + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevClass = cc; + } + return score; +} + +int fuzzyQueryScore(const FuzzyText& text, const QStringList& terms) { + int score = 0; + int from = 0; + for (const QString& term : terms) { + const FuzzyText rest{text.chars.mid(from), text.classes.mid(from)}; + int end = 0; + const int s = fuzzyTermScore(rest, term, &end); + if (s < 0) { + return -1; + } + score += s; + from += end; + } + return score; +} + +const QSet& launcherWrappers() { + static const QSet s = { + "flatpak", + "env", + "distrobox", + "distrobox-enter", + "toolbox", + "toolbox-enter", + "toolbx", + "toolbx-enter", + }; + return s; +} + +const QSet& cmdlineInterpreters() { + static const QSet s = { + "sh", + "bash", + "dash", + "zsh", + "fish", + "ash", + "python", + "python2", + "python3", + "node", + "nodejs", + "perl", + "ruby", + "lua", + "php", + "java", + "dotnet", + }; + return s; +} + +const QHash& execNameExceptions() { + static const QHash s = { + {"firefox-bin", "firefox"}, + {"soffice.bin", "libreoffice"}, + {"oosplash", "libreoffice"}, + {"gnome-terminal-server", "gnome-terminal"}, + {"chrome", "google-chrome-stable"}, + }; + return s; +} + +QString sanitizeExec(const QString& execLine, const QStringList& pathDirs) { + const QStringList tokens = + execLine.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + + bool skippingLaunchers = true; + for (const QString& rawTok : tokens) { + if (rawTok.startsWith('-') || rawTok.startsWith('%')) { + continue; + } + + QString tok = rawTok; + tok.remove('"'); + + QString resolved; + const QFileInfo fi(tok); + + if (fi.isAbsolute()) { + resolved = fi.canonicalFilePath(); + } else { + for (const QString& dir : pathDirs) { + QFileInfo cand(QDir(dir).filePath(tok)); + if (cand.exists()) { + resolved = cand.canonicalFilePath(); + break; + } + } + } + + if (resolved.isEmpty()) { + continue; + } + + const QString base = QFileInfo(resolved).fileName(); + if (skippingLaunchers && launcherWrappers().contains(base)) { + continue; + } + + skippingLaunchers = false; + + return cmdlineInterpreters().contains(base) ? QString() : resolved; + } + + return {}; +} + +} // namespace + +ProcessModel::ProcessModel(QObject* parent) : QAbstractListModel(parent) { + const long ticks = ::sysconf(_SC_CLK_TCK); + if (ticks > 0) { + mClockTicksPerSec = ticks; + } + m_currentUid = ::getuid(); +} + +int ProcessModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + return static_cast(mProcesses.size()); +} + +QVariant ProcessModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= mProcesses.size()) { + return {}; + } + + const ProcessEntry& p = mProcesses.at(index.row()); + switch (role) { + case PidRole: + return static_cast(p.pid); + case PpidRole: + return static_cast(p.ppid); + case CommandRole: + return p.command; + case CommandLineRole: + return p.commandLine; + case StateRole: + return QString(QChar::fromLatin1(p.state)); + case SleepingRole: + return p.state == 'S' || p.state == 'D'; + case CpuUsageRole: + return p.cpuPercent; + case MemoryUsageRole: + return static_cast(p.rssKb); + case ElapsedRole: + return p.elapsedSecs; + case UidRole: + return static_cast(p.uid); + case UserRole: + return p.username; + case IoReadBytesRole: + return static_cast(p.ioReadBytes); + case IoWriteBytesRole: + return static_cast(p.ioWriteBytes); + case IconNameRole: + return p.iconName; + default: + return {}; + } +} + +QHash ProcessModel::roleNames() const { + return { + {PidRole, "pid"}, + {PpidRole, "ppid"}, + {CommandRole, "command"}, + {CommandLineRole, "commandLine"}, + {StateRole, "state"}, + {SleepingRole, "sleeping"}, + {CpuUsageRole, "cpuUsage"}, + {MemoryUsageRole, "memoryUsage"}, + {ElapsedRole, "elapsed"}, + {UidRole, "uid"}, + {UserRole, "user"}, + {IoReadBytesRole, "ioReadBytes"}, + {IoWriteBytesRole, "ioWriteBytes"}, + {IconNameRole, "iconName"}, + }; +} + +int ProcessModel::count() const { + return static_cast(mProcesses.size()); +} + +void ProcessModel::refresh() { + doRefresh(); +} + +bool ProcessModel::killProcess(qint64 pid, int signal) const { + if (pid <= 0) { + return false; + } + return ::kill(static_cast(pid), signal) == 0; +} + +QString ProcessModel::searchText() const { + return m_searchText; +} + +void ProcessModel::setSearchText(const QString& text) { + if (text == m_searchText) { + return; + } + m_searchText = text; + Q_EMIT searchTextChanged(); + applyView(); +} + +bool ProcessModel::currentUserOnly() const { + return m_currentUserOnly; +} + +void ProcessModel::setCurrentUserOnly(bool enabled) { + if (enabled == m_currentUserOnly) { + return; + } + m_currentUserOnly = enabled; + Q_EMIT currentUserOnlyChanged(); + applyView(); +} + +int ProcessModel::sortField() const { + return m_sortField; +} + +void ProcessModel::setSortField(int field) { + if (field == m_sortField) { + return; + } + m_sortField = field; + Q_EMIT sortFieldChanged(); + applyView(); +} + +bool ProcessModel::sortAscending() const { + return m_sortAscending; +} + +void ProcessModel::setSortAscending(bool ascending) { + if (ascending == m_sortAscending) { + return; + } + m_sortAscending = ascending; + Q_EMIT sortAscendingChanged(); + applyView(); +} + +void ProcessModel::setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon) { + mExecPathToIcon = std::move(execPathToIcon); + mExecNameToIcon = std::move(execNameToIcon); + ++m_iconVersion; +} + +QString ProcessModel::resolveProcessExe(pid_t pid) { + char path[40]; + procPath(path, sizeof(path), pid, "exe"); + char buf[4096]; + const ssize_t n = ::readlink(path, buf, sizeof(buf) - 1); + if (n < 0) { + return {}; + } + buf[n] = '\0'; + return QString::fromLocal8Bit(buf, static_cast(n)); +} + +std::optional ProcessModel::readStat(pid_t pid) { + char path[40]; + procPath(path, sizeof(path), pid, "stat"); + const QByteArray data = readProcFile(path); + if (data.isEmpty()) { + return std::nullopt; + } + + const qsizetype openParen = data.indexOf('('); + const qsizetype closeParen = data.lastIndexOf(')'); + if (openParen < 0 || closeParen < 0 || closeParen <= openParen) { + return std::nullopt; + } + + RawStat out; + out.pid = pid; + out.comm = QString::fromLatin1( + data.mid(openParen + 1, closeParen - openParen - 1)); + + const QByteArrayView rest = + QByteArrayView(data).mid(closeParen + 1).trimmed(); + const auto fields = splitWhitespace(rest); + if (fields.size() < 20) { + return std::nullopt; + } + out.state = fields[0].isEmpty() ? '?' : fields[0][0]; + out.ppid = static_cast(toI64(fields[1])); + out.utime = toU64(fields[11]); + out.stime = toU64(fields[12]); + out.starttime = toU64(fields[19]); + return out; +} + +QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { + char path[40]; + procPath(path, sizeof(path), pid, "cmdline"); + QByteArray data = readProcFile(path); + + if (data.isEmpty()) { + return QStringLiteral("[%1]").arg(fallbackComm); + } + + while (!data.isEmpty() && data.endsWith('\0')) { + data.chop(1); + } + for (qsizetype i = 0; i < data.size(); ++i) { + if (data[i] == '\0') { + data[i] = ' '; + } + } + return QString::fromUtf8(data); +} + +ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { + char path[40]; + procPath(path, sizeof(path), pid, "status"); + const QByteArray data = readProcFile(path); + + StatusInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("VmRSS:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.rssKb = toU64(fields[1]); + } + } else if (line.startsWith("Uid:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.uid = static_cast(toU64(fields[1])); + } + } + } + return out; +} + +ProcessModel::IoInfo ProcessModel::readIo(pid_t pid) { + char path[40]; + procPath(path, sizeof(path), pid, "io"); + const QByteArray data = readProcFile(path); + + IoInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("read_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.readBytes = toU64(fields[1]); + } + } else if (line.startsWith("write_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.writeBytes = toU64(fields[1]); + } + } + } + return out; +} + +QString ProcessModel::resolveUsername(uid_t uid, QHash& cache) { + if (const auto it = cache.constFind(uid); it != cache.constEnd()) { + return it.value(); + } + + std::array buf{}; + struct passwd pwd{}; + struct passwd* result = nullptr; + + QString name; + if (::getpwuid_r(uid, &pwd, buf.data(), buf.size(), &result) == 0 && + result != nullptr) { + name = QString::fromLocal8Bit(pwd.pw_name); + } else { + name = QString::number(uid); + } + + cache.insert(uid, name); + return name; +} + +std::optional ProcessModel::readUptimeSeconds() { + const QByteArray data = readProcFile("/proc/uptime"); + if (data.isEmpty()) { + return std::nullopt; + } + + const auto fields = splitWhitespace(QByteArrayView(data)); + if (fields.isEmpty()) { + return std::nullopt; + } + bool ok = false; + const double uptime = + QByteArray(fields[0].data(), fields[0].size()).toDouble(&ok); + return ok ? std::make_optional(uptime) : std::nullopt; +} + +void ProcessModel::scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache) { + const QHash& prevPidToAll = *ctx.prevPidToAll; + const QVector& prevAll = *ctx.prevAll; + const QHash& execPathToIcon = *ctx.execPathToIcon; + const QHash& execNameToIcon = *ctx.execNameToIcon; + + for (pid_t pid : pids) { + const auto statOpt = readStat(pid); + if (!statOpt) { + continue; + } + const RawStat& stat = *statOpt; + + ProcessEntry entry; + entry.pid = stat.pid; + entry.ppid = stat.ppid; + entry.command = stat.comm; + entry.state = stat.state; + entry.starttime = stat.starttime; + + const auto prevIt = prevPidToAll.constFind(pid); + const bool samePidInstance = prevIt != prevPidToAll.constEnd() && + prevAll.at(prevIt.value()).starttime == + stat.starttime; + if (samePidInstance) { + entry.commandLine = prevAll.at(prevIt.value()).commandLine; + } else { + entry.commandLine = readCommandLine(pid, stat.comm); + } + + if (!samePidInstance || ctx.iconsChanged) { + const QString exePath = resolveProcessExe(pid); + + QString iconName; + + if (!exePath.isEmpty()) { + iconName = execPathToIcon.value(exePath); + } + + if (iconName.isEmpty()) { + QString execName = execNameExceptions().value( + QFileInfo(exePath).fileName(), + QFileInfo(exePath).fileName()); + + iconName = execNameToIcon.value(execName); + } + + if (iconName.isEmpty()) { + QString baseName = stat.comm; + const auto exIt = execNameExceptions().constFind(baseName); + if (exIt != execNameExceptions().constEnd()) { + baseName = exIt.value(); + } + iconName = execNameToIcon.value(baseName); + } + + entry.iconName = iconName; + } else { + entry.iconName = prevAll.at(prevIt.value()).iconName; + } + + const StatusInfo status = readStatus(pid); + entry.rssKb = status.rssKb; + entry.uid = status.uid; + entry.username = resolveUsername(status.uid, uidCache); + + // /proc/PID/io is only readable by root or the process owner; skip + // the open when it is guaranteed to fail. + IoInfo io; + if (ctx.selfEuid == 0 || status.uid == ctx.selfEuid) { + io = readIo(pid); + } + entry.ioReadBytes = io.readBytes; + entry.ioWriteBytes = io.writeBytes; + + entry.elapsedSecs = std::max( + 0.0, + ctx.uptime - (static_cast(stat.starttime) / + static_cast(ctx.clockTicksPerSec))); + entry.lastUtime = stat.utime; + entry.lastStime = stat.stime; + + if (samePidInstance && ctx.deltaSecs > 0.0) { + const ProcessEntry& prev = prevAll.at(prevIt.value()); + const quint64 prevTicks = prev.lastUtime + prev.lastStime; + const quint64 nowTicks = stat.utime + stat.stime; + const quint64 deltaTicks = + nowTicks > prevTicks ? nowTicks - prevTicks : 0; + entry.cpuPercent = + (static_cast(deltaTicks) / + (ctx.deltaSecs * static_cast(ctx.clockTicksPerSec))) * + 100.0; + } else { + entry.cpuPercent = 0.0; + } + + out.push_back(std::move(entry)); + } +} + +ProcessModel::ScanResult ProcessModel::scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion) { + Q_UNUSED( + pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread + + const QVector pids = listProcPids(); + + ScanContext ctx; + ctx.uptime = uptime; + ctx.deltaSecs = deltaSecs; + ctx.prevPidToAll = &prevPidToAll; + ctx.prevAll = &prevAll; + ctx.clockTicksPerSec = clockTicksPerSec; + ctx.execPathToIcon = &execPathToIcon; + ctx.execNameToIcon = &execNameToIcon; + ctx.iconsChanged = desktopEntryIconsChanged; + ctx.selfEuid = ::geteuid(); + + QVector fresh; + fresh.reserve(pids.size()); + + // Split the pid list across worker threads; a single pass over /proc is + // syscall-bound (open/read/close per file per process) and serializes + // badly on large systems. Keep one pool thread free for this function, + // which itself runs inside the global pool. + int nThreads = + qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); + if (pids.size() < 128 || nThreads < 2) { + nThreads = 1; + } + + if (nThreads <= 1) { + scanChunk(ctx, pids, fresh, uidCache); + } else { + const qsizetype per = (pids.size() + nThreads - 1) / nThreads; + QVector> chunks(nThreads); + QVector> outs(nThreads); + QVector> uidParts(nThreads); + QVector> futures; + + for (int t = 0; t < nThreads; ++t) { + const qsizetype a = static_cast(t) * per; + const qsizetype b = qMin(a + per, pids.size()); + if (a >= b) { + continue; + } + chunks[t] = pids.mid(a, b - a); + futures.append( + QtConcurrent::run([&ctx, &chunks, &outs, &uidParts, t]() { + scanChunk(ctx, chunks[t], outs[t], uidParts[t]); + })); + } + for (QFuture& f : futures) { + f.waitForFinished(); + } + + for (qsizetype t = 0; t < nThreads; ++t) { + for (ProcessEntry& e : outs[t]) { + fresh.push_back(std::move(e)); + } + for (auto it = uidParts[t].constBegin(); + it != uidParts[t].constEnd(); + ++it) { + uidCache.insert(it.key(), it.value()); + } + } + std::sort( + fresh.begin(), + fresh.end(), + [](const ProcessEntry& a, const ProcessEntry& b) { + return a.pid < b.pid; + }); + } + + ScanResult result; + result.entries = std::move(fresh); + result.newUidNames = std::move(uidCache); + result.iconVersion = iconVersion; + return result; +} + +void ProcessModel::doRefresh() { + if (mWatcher) { + mRefreshPending = true; + return; + } + + const auto uptimeOpt = readUptimeSeconds(); + if (!uptimeOpt) { + qCWarning(lcProcessModel, "failed to read /proc/uptime"); + return; + } + const double uptime = *uptimeOpt; + const double deltaSecs = mLastUptimeSecs >= 0.0 ? (uptime - mLastUptimeSecs) + : -1.0; + mPendingUptime = uptime; + + QHash prevPidToAll = mPidToAll; + QVector prevAll = mAll; + QHash uidCache = mUidToName; + QHash execPathToIcon = mExecPathToIcon; + QHash execNameToIcon = mExecNameToIcon; + const long ticks = mClockTicksPerSec; + const bool desktopEntryIconsChanged = + (m_iconVersion != m_scannedIconVersion); + + mWatcher = new QFutureWatcher(this); + QObject::connect( + mWatcher, + &QFutureWatcher::finished, + this, + &ProcessModel::onScanFinished); + + mWatcher->setFuture( + QtConcurrent::run( + &ProcessModel::scanProcesses, + uptime, + deltaSecs, + std::move(prevPidToAll), + std::move(prevAll), + std::move(uidCache), + ticks, + std::move(execPathToIcon), + std::move(execNameToIcon), + QStringList{}, + desktopEntryIconsChanged, + m_iconVersion)); +} + +void ProcessModel::onScanFinished() { + QFutureWatcher* watcher = mWatcher; + mWatcher = nullptr; + if (!watcher) { + return; + } + ScanResult result = watcher->result(); + watcher->deleteLater(); + + mAll = std::move(result.entries); + m_scannedIconVersion = result.iconVersion; + mPidToAll.clear(); + mPidToAll.reserve(mAll.size()); + for (int i = 0; i < mAll.size(); ++i) { + mPidToAll.insert(mAll.at(i).pid, i); + } + for (auto it = mFuzzyCache.begin(); it != mFuzzyCache.end();) { + if (mPidToAll.contains(it.key())) { + ++it; + } else { + it = mFuzzyCache.erase(it); + } + } + for (auto it = result.newUidNames.constBegin(); + it != result.newUidNames.constEnd(); + ++it) { + mUidToName.insert(it.key(), it.value()); + } + + mLastUptimeSecs = mPendingUptime; + applyView(); + + if (mRefreshPending) { + mRefreshPending = false; + doRefresh(); + } +} + +void ProcessModel::applyView() { + const bool searching = !m_searchText.isEmpty(); + QStringList terms; + if (searching) { + terms = m_searchText.split( + QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + for (int i = 0; i < terms.size(); ++i) { + terms[i] = fuzzyPrepare(terms.at(i)).chars; + } + } + + QVector filtered; + filtered.reserve(mAll.size()); + + for (const ProcessEntry& src : mAll) { + if (m_currentUserOnly && src.uid != m_currentUid) { + continue; + } + + ProcessEntry entry = src; + if (searching) { + // Normalizing (NFD) + lowering per process per tick is the + // expensive part of search; commands are stable for the life of + // a process instance (pid + starttime), so cache the prepared + // text. comm can still change without an exec (prctl), so + // re-prepare when the raw command differs. + FuzzyCacheEntry& fe = mFuzzyCache[src.pid]; + if (fe.starttime != src.starttime || fe.command != src.command) { + fe.starttime = src.starttime; + fe.command = src.command; + const FuzzyText prep = fuzzyPrepare(src.command); + fe.commandChars = std::move(prep.chars); + fe.commandClasses = std::move(prep.classes); + fe.hasCommandLine = !src.commandLine.isEmpty() && + src.commandLine != src.command; + if (fe.hasCommandLine) { + const FuzzyText prepCl = fuzzyPrepare(src.commandLine); + fe.commandLineChars = std::move(prepCl.chars); + fe.commandLineClasses = std::move(prepCl.classes); + } else { + fe.commandLineChars = QString(); + fe.commandLineClasses = {}; + } + } + const FuzzyText cmdFuzzy{fe.commandChars, fe.commandClasses}; + int score = fuzzyQueryScore(cmdFuzzy, terms); + if (fe.hasCommandLine) { + const FuzzyText clFuzzy{ + fe.commandLineChars, fe.commandLineClasses}; + score = qMax(score, fuzzyQueryScore(clFuzzy, terms)); + } + if (score < 0) { + continue; + } + entry.searchScore = score; + } + filtered.push_back(std::move(entry)); + } + + QVector desired; + desired.reserve(filtered.size()); + if (searching || m_sortField != 0) { + desired = filtered; + std::stable_sort( + desired.begin(), + desired.end(), + [this, searching](const ProcessEntry& a, const ProcessEntry& b) { + if (searching && a.searchScore != b.searchScore) { + return a.searchScore > b.searchScore; + } + const int c = compareEntries(a, b); + if (c != 0) { + return m_sortAscending ? c < 0 : c > 0; + } + return a.pid < b.pid; + }); + } else { + QSet shown; + shown.reserve(mProcesses.size()); + for (const ProcessEntry& e : mProcesses) { + shown.insert(e.pid); + } + QSet placed; + placed.reserve(filtered.size()); + for (const ProcessEntry& e : filtered) { + if (shown.contains(e.pid)) { + desired.push_back(e); + placed.insert(e.pid); + } + } + for (const ProcessEntry& e : filtered) { + if (!placed.contains(e.pid)) { + desired.push_back(e); + } + } + } + + converge(desired); +} + +void ProcessModel::converge(const QVector& desired) { + const qsizetype oldCount = mProcesses.size(); + + if (desired.size() == oldCount) { + bool sameOrder = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (desired.at(i).pid != mProcesses.at(i).pid) { + sameOrder = false; + break; + } + } + if (sameOrder) { + for (qsizetype i = 0; i < desired.size(); ++i) { + mProcesses[i] = desired.at(i); + } + static const QList kVolatileRoles = { + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + StateRole, + SleepingRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole}; + if (!mProcesses.isEmpty()) { + const int last = static_cast(desired.size()) - 1; + Q_EMIT dataChanged(index(0), index(last), kVolatileRoles); + } + return; + } + } + + + QSet oldPids; + oldPids.reserve(oldCount); + for (const auto& e : mProcesses) { + oldPids.insert(e.pid); + } + QSet keepPids; + keepPids.reserve(desired.size()); + QVector added; + for (const auto& e : desired) { + keepPids.insert(e.pid); + if (!oldPids.contains(e.pid)) { + added.append(&e); + } + } + + QVector removed; + for (qsizetype i = 0; i < oldCount; ++i) { + if (!keepPids.contains(mProcesses.at(i).pid)) { + removed.append(static_cast(i)); + } + } + std::sort(removed.begin(), removed.end(), [](int a, int b) { + return a > b; + }); + for (int idx : removed) { + beginRemoveRows(QModelIndex(), idx, idx); + mProcesses.removeAt(idx); + endRemoveRows(); + } + + if (!added.isEmpty()) { + const int start = static_cast(mProcesses.size()); + beginInsertRows( + QModelIndex(), start, start + static_cast(added.size()) - 1); + for (const auto* e : added) { + mProcesses.append(*e); + } + endInsertRows(); + } + + bool ordered = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (mProcesses.at(i).pid != desired.at(i).pid) { + ordered = false; + break; + } + } + if (!ordered) { + mProcesses = desired; + layoutChanged( + QList(), + QAbstractItemModel::VerticalSortHint); + } + + if (mProcesses.size() != oldCount) { + Q_EMIT countChanged(); + } +} + +int ProcessModel::compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const { + switch (m_sortField) { + case 1: + return cmpVals(a.pid, b.pid); + case 2: + return cmpVals(a.ppid, b.ppid); + case 3: + return QString::compare(a.command, b.command, Qt::CaseInsensitive); + case 4: + return QString::compare( + a.commandLine, b.commandLine, Qt::CaseInsensitive); + case 5: + return cmpVals(a.state, b.state); + case 6: + return cmpVals(isSleeping(a), isSleeping(b)); + case 7: + return cmpVals(a.cpuPercent, b.cpuPercent); + case 8: + return cmpVals(a.rssKb, b.rssKb); + case 9: + return cmpVals(a.elapsedSecs, b.elapsedSecs); + case 10: + return cmpVals(a.uid, b.uid); + case 11: + return QString::compare(a.username, b.username, Qt::CaseInsensitive); + case 12: + return cmpVals(a.ioReadBytes, b.ioReadBytes); + case 13: + return cmpVals(a.ioWriteBytes, b.ioWriteBytes); + default: + return 0; + } +} + +bool ProcessModel::isSleeping(const ProcessEntry& e) { + return e.state == 'S' || e.state == 'D'; +} + +Processes::Processes(QObject* parent) + : TickingService(parent), m_model(new ProcessModel(this)) { + QObject::connect( + m_model, &ProcessModel::countChanged, this, &Processes::countChanged); + QObject::connect( + m_model, + &ProcessModel::sortFieldChanged, + this, + &Processes::sortFieldChanged); + QObject::connect( + m_model, + &ProcessModel::sortAscendingChanged, + this, + &Processes::sortAscendingChanged); + QObject::connect( + m_model, + &ProcessModel::searchTextChanged, + this, + &Processes::searchTextChanged); + QObject::connect( + m_model, + &ProcessModel::currentUserOnlyChanged, + this, + &Processes::currentUserOnlyChanged); +} + +QAbstractItemModel* Processes::list() const { + return m_model; +} + +int Processes::count() const { + return m_model->count(); +} + +Processes::SortField Processes::sortField() const { + return static_cast(m_model->sortField()); +} + +void Processes::setSortField(SortField field) { + m_model->setSortField(static_cast(field)); +} + +bool Processes::sortAscending() const { + return m_model->sortAscending(); +} + +void Processes::setSortAscending(bool ascending) { + m_model->setSortAscending(ascending); +} + +QString Processes::searchText() const { + return m_model->searchText(); +} + +void Processes::setSearchText(const QString& text) { + m_model->setSearchText(text); +} + +bool Processes::currentUserOnly() const { + return m_model->currentUserOnly(); +} + +void Processes::setCurrentUserOnly(bool enabled) { + m_model->setCurrentUserOnly(enabled); +} + +void Processes::setDesktopEntries(const QVariantList& entries) { + QHash execPathToIcon; + QHash execNameToIcon; + execPathToIcon.reserve(entries.size()); + execNameToIcon.reserve(entries.size()); + + const QStringList pathDirs = + qEnvironmentVariable("PATH").split(':', Qt::SkipEmptyParts); + + for (const QVariant& v : entries) { + const QVariantMap m = v.toMap(); + + const QString icon = m.value(QStringLiteral("icon")).toString(); + + const QStringList command = + m.value(QStringLiteral("command")).toStringList(); + + if (icon.isEmpty()) { + continue; + } + + if (command.isEmpty()) { + continue; + } + + const QString execPath = sanitizeExec(command.join(' '), pathDirs); + + if (execPath.isEmpty()) { + continue; + } + + execPathToIcon.insert(execPath, icon); + execNameToIcon.insert(QFileInfo(execPath).fileName(), icon); + } + + m_model->setDesktopEntryIcons( + std::move(execPathToIcon), std::move(execNameToIcon)); +} + +void Processes::sortBy(SortField field) { + if (m_model->sortField() == static_cast(field)) { + m_model->setSortAscending(!m_model->sortAscending()); + } else { + m_model->setSortField(static_cast(field)); + m_model->setSortAscending(true); + } +} + +void Processes::refresh() { + m_model->refresh(); +} + +void Processes::tick() { + m_model->refresh(); +} + +bool Processes::killProcess(qint64 pid, int signal) const { + return m_model->killProcess(pid, signal); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.hpp b/Plugins/ZShell/Services/processes.hpp new file mode 100644 index 0000000..ea48f4c --- /dev/null +++ b/Plugins/ZShell/Services/processes.hpp @@ -0,0 +1,288 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +template class QFutureWatcher; + +namespace ZShell::services { + +class ProcessModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + + public: + enum Roles : quint16 { + PidRole = Qt::UserRole + 1, + PpidRole, + CommandRole, + CommandLineRole, + StateRole, + SleepingRole, + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + UidRole, + UserRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole, + }; + Q_ENUM(Roles) + + explicit ProcessModel(QObject* parent = nullptr); + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] int count() const; + + void refresh(); + bool killProcess(qint64 pid, int signal = 15) const; + void setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + [[nodiscard]] int sortField() const; + void setSortField(int field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + signals: + void countChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + + private: + struct FuzzyCacheEntry { + quint64 starttime = 0; + QString command; + QString commandChars; + QVector commandClasses; + QString commandLineChars; + QVector commandLineClasses; + bool hasCommandLine = false; + }; + + struct RawStat { + pid_t pid = 0; + pid_t ppid = 0; + QString comm; + char state = '?'; + quint64 utime = 0; + quint64 stime = 0; + quint64 starttime = 0; + }; + + struct ProcessEntry { + pid_t pid = 0; + pid_t ppid = 0; + QString command; + QString commandLine; + char state = '?'; + double cpuPercent = 0.0; + quint64 rssKb = 0; + double elapsedSecs = 0.0; + uid_t uid = 0; + QString username; + QString iconName; + quint64 ioReadBytes = 0; + quint64 ioWriteBytes = 0; + int searchScore = -1; + + quint64 lastUtime = 0; + quint64 lastStime = 0; + quint64 starttime = 0; + }; + + struct StatusInfo { + quint64 rssKb = 0; + uid_t uid = 0; + }; + + struct IoInfo { + quint64 readBytes = 0; + quint64 writeBytes = 0; + }; + + struct ScanContext { + double uptime = 0.0; + double deltaSecs = -1.0; + const QHash* prevPidToAll = nullptr; + const QVector* prevAll = nullptr; + long clockTicksPerSec = 100; + const QHash* execPathToIcon = nullptr; + const QHash* execNameToIcon = nullptr; + bool iconsChanged = false; + uid_t selfEuid = 0; + }; + + struct ScanResult { + QVector entries; + QHash newUidNames; + quint64 iconVersion = 0; + }; + + static std::optional readStat(pid_t pid); + static QString readCommandLine(pid_t pid, const QString& fallbackComm); + static StatusInfo readStatus(pid_t pid); + static IoInfo readIo(pid_t pid); + static std::optional readUptimeSeconds(); + static QString resolveUsername(uid_t uid, QHash& cache); + static QString resolveProcessExe(pid_t pid); + + static void scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache); + + static ScanResult scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion); + + void doRefresh(); + void onScanFinished(); + void applyView(); + void converge(const QVector& desired); + [[nodiscard]] int compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const; + [[nodiscard]] static bool isSleeping(const ProcessEntry& e); + + QVector mAll; + QHash mPidToAll; + QHash mFuzzyCache; + + QVector mProcesses; + QHash mUidToName; + + double mLastUptimeSecs = -1.0; + long mClockTicksPerSec = 100; + + QFutureWatcher* mWatcher = nullptr; + bool mRefreshPending = false; + double mPendingUptime = -1.0; + + QString m_searchText; + bool m_currentUserOnly = true; + uid_t m_currentUid = 0; + int m_sortField = 0; + bool m_sortAscending = true; + + QHash mExecPathToIcon; + QHash mExecNameToIcon; + + quint64 m_iconVersion = 0; + quint64 m_scannedIconVersion = 0; +}; + +class Processes : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum SortField : int { + Unsorted = 0, + Pid, + Ppid, + Command, + CommandLine, + State, + Sleeping, + CpuUsage, + MemoryUsage, + Elapsed, + Uid, + User, + IoReadBytes, + IoWriteBytes + }; + Q_ENUM(SortField) + + private: + Q_PROPERTY(QAbstractItemModel* list READ list CONSTANT) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY( + SortField sortField READ sortField WRITE setSortField NOTIFY + sortFieldChanged) + Q_PROPERTY( + bool sortAscending READ sortAscending WRITE setSortAscending NOTIFY + sortAscendingChanged) + Q_PROPERTY( + QString searchText READ searchText WRITE setSearchText NOTIFY + searchTextChanged) + Q_PROPERTY( + bool currentUserOnly READ currentUserOnly WRITE setCurrentUserOnly + NOTIFY currentUserOnlyChanged) + + public: + explicit Processes(QObject* parent = nullptr); + + [[nodiscard]] QAbstractItemModel* list() const; + + [[nodiscard]] int count() const; + + [[nodiscard]] SortField sortField() const; + void setSortField(SortField field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + Q_INVOKABLE void sortBy(SortField field); + Q_INVOKABLE void refresh(); + Q_INVOKABLE bool killProcess(qint64 pid, int signal = 15) const; + Q_INVOKABLE void setDesktopEntries(const QVariantList& entries); + + + signals: + void countChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + + protected: + void tick() override; + + private: + ProcessModel* m_model; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/storage.cpp b/Plugins/ZShell/Services/storage.cpp index 4e9d52f..5a3884a 100644 --- a/Plugins/ZShell/Services/storage.cpp +++ b/Plugins/ZShell/Services/storage.cpp @@ -5,9 +5,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -17,12 +20,27 @@ namespace ZShell::services { namespace { +// A scan that finishes quickly is not worth showing as loading. Only a scan that +// stays in flight this long is reported, so the UI does not flicker every tick. +constexpr int kLoadingDelayMs = 300; + struct Accum { quint64 usedBytes = 0; quint64 totalBytes = 0; bool hasRoot = false; }; +QThreadPool* storagePool() { + // Single thread: a scan blocked on an unreachable mount should not take a + // worker away from the global pool. + static QThreadPool* pool = [] { + auto* p = new QThreadPool(); + p->setMaxThreadCount(1); + return p; + }(); + return pool; +} + [[nodiscard]] QString sysfsRealPath(uint major, uint minor) { const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor); @@ -103,7 +121,14 @@ QStringList resolveByDevt(uint major, uint minor, int depth) { } // namespace -Storage::Storage(QObject* parent) : TickingService(parent) {} +Storage::Storage(QObject* parent) : TickingService(parent) { + m_loadingTimer.setSingleShot(true); + QObject::connect(&m_loadingTimer, &QTimer::timeout, this, [this] { + if (m_watcher) { + setLoading(true); + } + }); +} qreal Storage::percentage() const { qreal totalUsed = 0.0; @@ -115,6 +140,18 @@ qreal Storage::percentage() const { return totalSize > 0.0 ? totalUsed / totalSize : 0.0; } +bool Storage::loading() const { + return m_loading; +} + +void Storage::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + Q_EMIT loadingChanged(); +} + bool Storage::sameOrder(const QList& a, const QList& b) { if (a.size() != b.size()) { return false; @@ -162,18 +199,41 @@ DiskInfo* Storage::primaryDisk() const { bool Storage::isPseudoFs(QByteArrayView fsType) { static constexpr const char* kPseudo[] = { - "tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", - "cgroup2", "overlay", "squashfs", "devpts", "mqueue", - "ramfs", "rpc_pipefs", "autofs", "configfs", "debugfs", - "tracefs", "securityfs", "pstore", "bpf", "binfmt_misc", - "hugetlbfs", "fusectl", "efivarfs", "selinuxfs", + "tmpfs", + "devtmpfs", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "overlay", + "squashfs", + "devpts", + "mqueue", + "ramfs", + "rpc_pipefs", + "autofs", + "configfs", + "debugfs", + "tracefs", + "securityfs", + "pstore", + "bpf", + "binfmt_misc", + "hugetlbfs", + "fusectl", + "efivarfs", + "selinuxfs", + // Real FUSE mounts (rclone, sshfs, ...) are reported like any other + // volume, so only the virtual ones are excluded here. + "fuse.gvfsd-fuse", + "fuse.portal", }; for (const char* p : kPseudo) { if (fsType == QByteArrayView(p)) { return true; } } - return fsType.startsWith(QByteArrayView("fuse.")); + return false; } QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { @@ -190,18 +250,14 @@ QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { return resolveByDevt(major(st.st_rdev), minor(st.st_rdev)); } -void Storage::tick() { - const qreal prevPercentage = percentage(); - QHash byDisk; - - // Multiple mounts can share a single backing filesystem (btrfs subvolumes, - // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. - // Dedupe by source device so the filesystem only contributes once per disk. +StorageScan Storage::scanVolumes() { struct DeviceEntry { quint64 totalBytes = 0; quint64 usedBytes = 0; bool hasRoot = false; - QByteArray device; + // Mount point of the first mount seen for this device. Used as the entry + // key when the device has no backing block device (network mount). + QString mount; }; QHash byDevice; @@ -215,38 +271,120 @@ void Storage::tick() { continue; } - const QByteArray device = v.device(); const auto totalBytes = static_cast(v.bytesTotal()); const auto availBytes = static_cast(v.bytesAvailable()); const quint64 usedBytes = totalBytes > availBytes ? totalBytes - availBytes : 0; const bool isRoot = v.rootPath() == QStringLiteral("/"); - DeviceEntry& e = byDevice[device]; - e.device = device; + DeviceEntry& e = byDevice[v.device()]; e.totalBytes = totalBytes; e.usedBytes = usedBytes; e.hasRoot = e.hasRoot || isRoot; + if (e.mount.isEmpty()) { + e.mount = v.rootPath(); + } } + QHash byEntry; + + // Multiple mounts can share a single backing filesystem (btrfs subvolumes, + // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. + // Dedupe by source device so the filesystem only contributes once per disk. for (auto it = byDevice.constBegin(); it != byDevice.constEnd(); ++it) { const DeviceEntry& e = it.value(); const QStringList disks = - resolveToPhysicalDisks(QString::fromLocal8Bit(e.device)); + resolveToPhysicalDisks(QString::fromLocal8Bit(it.key())); if (disks.isEmpty()) { + // Network mounts (and any other filesystem with no backing block + // device) have no physical disk to attribute the usage to, so the + // mount itself becomes the entry. + Accum& a = byEntry[e.mount]; + a.usedBytes += e.usedBytes; + a.totalBytes += e.totalBytes; + a.hasRoot = a.hasRoot || e.hasRoot; continue; } for (const QString& d : disks) { if (d.startsWith(QStringLiteral("zram"))) { continue; } - Accum& a = byDisk[d]; + Accum& a = byEntry[d]; a.usedBytes += e.usedBytes; a.totalBytes += e.totalBytes; a.hasRoot = a.hasRoot || e.hasRoot; } } + StorageScan scan; + scan.reserve(byEntry.size()); + for (auto it = byEntry.constBegin(); it != byEntry.constEnd(); ++it) { + StorageEntry entry; + entry.mount = it.key(); + entry.usedBytes = it.value().usedBytes; + entry.totalBytes = it.value().totalBytes; + entry.hasRoot = it.value().hasRoot; + scan.append(entry); + } + + std::sort( + scan.begin(), + scan.end(), + [](const StorageEntry& a, const StorageEntry& b) { + if (a.hasRoot != b.hasRoot) { + return a.hasRoot; + } + return a.mount < b.mount; + }); + + return scan; +} + +void Storage::tick() { + if (m_watcher) { + // Still waiting on a mount that has not answered. Starting another scan + // here would just queue blocked threads behind the first. + return; + } + + beginScan(); +} + +void Storage::beginScan() { + m_loadingTimer.start(kLoadingDelayMs); + + m_watcher = new QFutureWatcher(this); + QObject::connect( + m_watcher, + &QFutureWatcher::finished, + this, + &Storage::onScanFinished); + + m_watcher->setFuture( + QtConcurrent::run(storagePool(), &Storage::scanVolumes)); +} + +void Storage::onScanFinished() { + QFutureWatcher* watcher = m_watcher; + m_watcher = nullptr; + if (!watcher) { + return; + } + StorageScan scan = watcher->result(); + watcher->deleteLater(); + + m_loadingTimer.stop(); + + // scan is already ordered (root disk first), so applying it in order keeps the + // QML model in the same order as before. + applyScan(scan); + + setLoading(false); +} + +void Storage::applyScan(const StorageScan& scan) { + const qreal prevPercentage = percentage(); + QHash existing; existing.reserve(m_disks.size()); for (DiskInfo* d : std::as_const(m_disks)) { @@ -254,32 +392,17 @@ void Storage::tick() { } QList next; - next.reserve(byDisk.size()); - for (auto it = byDisk.constBegin(); it != byDisk.constEnd(); ++it) { - if (DiskInfo* survivor = existing.take(it.key())) { - survivor->update( - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot); + next.reserve(scan.size()); + for (const StorageEntry& e : scan) { + if (DiskInfo* survivor = existing.take(e.mount)) { + survivor->update(e.usedBytes, e.totalBytes, e.hasRoot); next.append(survivor); } else { next.append(new DiskInfo( - it.key(), - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot, - this)); + e.mount, e.usedBytes, e.totalBytes, e.hasRoot, this)); } } - std::sort( - next.begin(), next.end(), [](const DiskInfo* a, const DiskInfo* b) { - if (a->hasRoot() != b->hasRoot()) { - return a->hasRoot(); - } - return a->mount() < b->mount(); - }); - bool manualCleared = false; if (DiskInfo* m = m_manualPrimaryDisk.data(); m && existing.contains(m->mount())) { diff --git a/Plugins/ZShell/Services/storage.hpp b/Plugins/ZShell/Services/storage.hpp index 0c61466..bd964ec 100644 --- a/Plugins/ZShell/Services/storage.hpp +++ b/Plugins/ZShell/Services/storage.hpp @@ -7,10 +7,27 @@ #include #include #include +#include +#include #include +#include + +template class QFutureWatcher; namespace ZShell::services { +struct StorageEntry { + // Key for the entry: sysfs block device name, or mount point for a mount with + // no backing block device (network mount). This is what DiskInfo reports as + // its mount, so the UI can show either. + QString mount; + quint64 usedBytes = 0; + quint64 totalBytes = 0; + bool hasRoot = false; +}; + +using StorageScan = QVector; + class Storage : public TickingService { Q_OBJECT QML_ELEMENT @@ -26,6 +43,7 @@ class Storage : public TickingService { Q_PROPERTY( ZShell::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) public: explicit Storage(QObject* parent = nullptr); @@ -35,12 +53,14 @@ class Storage : public TickingService { [[nodiscard]] DiskInfo* manualPrimaryDisk() const; void setManualPrimaryDisk(DiskInfo* disk); [[nodiscard]] DiskInfo* primaryDisk() const; + [[nodiscard]] bool loading() const; signals: void disksChanged(); void percentageChanged(); void manualPrimaryDiskChanged(); void primaryDiskChanged(); + void loadingChanged(); protected: void tick() override; @@ -55,8 +75,22 @@ class Storage : public TickingService { static qsizetype disksCount(QQmlListProperty* prop); static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + // Runs on a worker thread. statvfs on a mount whose backing storage is + // unreachable can block for the whole network timeout, so the scan must not + // run on the GUI thread. The result is plain data, applied on this object's + // thread once it arrives. + [[nodiscard]] static StorageScan scanVolumes(); + + void beginScan(); + void onScanFinished(); + void applyScan(const StorageScan& scan); + void setLoading(bool value); + QList m_disks; QPointer m_manualPrimaryDisk; + QFutureWatcher* m_watcher = nullptr; + QTimer m_loadingTimer; + bool m_loading = false; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 5c51cdc..d6f585e 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -1,66 +1,25 @@ #include "tickingservice.hpp" -#include -#include -#include -#include -#include -#include -#include + +#include "config/dashboard.hpp" +#include "config/rootnodes.hpp" namespace ZShell::services { TickingService::TickingService(QObject* parent) : Service(parent), m_timer(new QTimer(this)) { m_timer->setSingleShot(false); - QObject::connect(m_timer, &QTimer::timeout, this, [this] { tick(); }); + QObject::connect(m_timer, &QTimer::timeout, this, [this] { + tick(); + updateTriggered(); + }); - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); - - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval( - dashboard.value("resourceUpdateInterval").toInt(1000)); - } - }; - - reloadConfig(); - - static auto* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (!file.exists()) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = - doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval(dashboard.value("resourceUpdateInterval") - .toInt(1000)); - } - }); - }); - watcher->addPath(configPath); - } + auto* dash = ZShell::config::ConfigSingleton::instance()->dashboard(); + applyInterval(dash->resourceUpdateInterval()); + QObject::connect( + dash, + &ZShell::config::Dashboard::resourceUpdateIntervalChanged, + this, + [this, dash] { applyInterval(dash->resourceUpdateInterval()); }); } int TickingService::updateInterval() const { @@ -78,6 +37,7 @@ void TickingService::start() { void TickingService::stop() { m_running = false; m_timer->stop(); + onStopped(); } void TickingService::applyInterval(int ms) { diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index 324707f..87f5676 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -1,8 +1,9 @@ #pragma once -#include "service.hpp" #include +#include "service.hpp" + namespace ZShell::services { class TickingService : public Service { @@ -18,12 +19,14 @@ class TickingService : public Service { signals: void updateIntervalChanged(); + void updateTriggered(); protected: void start() final; void stop() final; virtual void tick() = 0; + virtual void onStopped() {} private: void applyInterval(int ms); diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp index 1012da1..28d1f5b 100644 --- a/Plugins/ZShell/Services/usagefmt.cpp +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -14,6 +14,24 @@ bool finitePositive(qreal v) { namespace ZShell::services::usagefmt { +FormatResult UsageFmt::formatKib(qreal kib) const { + if (!finitePositive(kib)) { + return {0.0, 0.0, "KiB"}; + } + + if (kib >= kGib) { + return {kib / kGib, 0.0, "GiB"}; + } + if (kib >= kMib) { + return {kib / kMib, 0.0, "MiB"}; + } + if (kib >= kKib) { + return {kib / kKib, 0.0, "KiB"}; + } + + return {kib, 0.0, "KiB"}; +} + FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { if (!finitePositive(kib) || !finitePositive(total)) { return {0.0, 0.0, "KiB"}; @@ -30,4 +48,20 @@ FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { return {kib, total, "KiB"}; } +FormatResult UsageFmt::formatBytes(qreal bytes) const { + static constexpr std::array units = { + "B", "KB", "MB", "GB", "TB"}; + + qreal value = finitePositive(bytes) ? bytes : 0.0; + const qreal k = 1024.0; + std::size_t i = 0; + + while (value >= k && i < units.size() - 1) { + value /= k; + i++; + } + + return {value, 0.0, units[i]}; +} + } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp index ca910d4..288ce2e 100644 --- a/Plugins/ZShell/Services/usagefmt.hpp +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -27,8 +28,10 @@ class UsageFmt : public QObject { QML_SINGLETON public: + Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib) const; Q_INVOKABLE [[nodiscard]] FormatResult formatKib( qreal kib, qreal total) const; + Q_INVOKABLE [[nodiscard]] FormatResult formatBytes(qreal bytes) const; }; } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Settings/CMakeLists.txt b/Plugins/ZShell/Settings/CMakeLists.txt new file mode 100644 index 0000000..eb0c6f2 --- /dev/null +++ b/Plugins/ZShell/Settings/CMakeLists.txt @@ -0,0 +1,17 @@ +qml_module(ZShell-settings + URI ZShell.Settings + INCLUDE_PREFIX settings + SOURCES + common.cpp + schema.cpp + codecs.cpp + quarantine.cpp + node.cpp + objectnode.cpp + listnode.cpp + rootnode.cpp + settingsfile.cpp + changebatcher.cpp + LIBRARIES + zshell-util +) diff --git a/Plugins/ZShell/Settings/changebatcher.cpp b/Plugins/ZShell/Settings/changebatcher.cpp new file mode 100644 index 0000000..30489a0 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.cpp @@ -0,0 +1,22 @@ +#include "changebatcher.hpp" + +namespace ZShell::settings { + +ChangeBatcher::ChangeBatcher(QObject* parent) + : QObject(parent), m_dirty(false) {} + +void ChangeBatcher::dirty() { + if (m_dirty) return; + + m_dirty = true; + QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); +} + +void ChangeBatcher::flush() { + if (!m_dirty) return; + + m_dirty = false; + emit dirtied(); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/changebatcher.hpp b/Plugins/ZShell/Settings/changebatcher.hpp new file mode 100644 index 0000000..ef60e08 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace ZShell::settings { + +class ChangeBatcher : public QObject { + Q_OBJECT + + public: + explicit ChangeBatcher(QObject* parent = nullptr); + + void dirty(); + + signals: + void dirtied(); + + private: + bool m_dirty; + + void flush(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.cpp b/Plugins/ZShell/Settings/codecs.cpp new file mode 100644 index 0000000..6e031ff --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.cpp @@ -0,0 +1,420 @@ +#include "codecs.hpp" + +#include +#include +#include + +#include + +#include "util/metaenum.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// We don't know the option here so it isn't set, it should be set by the caller +DecodeResult error(DiagnosticType::Type type, const QString& message) { + Diagnostic error; + error.type = type; + error.message = message; + return {.value = QVariant(), .error = error, .indexPath = {}}; +} + +DecodeResult mismatch(ExpectedType expected, const QJsonValue& value) { + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; +} + +DecodeResult mismatch( + const QList& expected, const QJsonValue& value) { + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; +} + +template ValueCodec* makeListCodec(const QMetaType& type) { + const auto* elementCodec = ValueCodec::codecFor( + QMetaType::fromType()); + return elementCodec ? new ListCodec(type, elementCodec) + : nullptr; +} + +// Unions are keyed by their alternatives in order, since order decides which one wins +QString unionKey(const QList& types) { + QStringList ids; + ids.reserve(types.size()); + for (const auto& type : types) + ids << QString::number(type.id()); + return ids.join(u","_s); +} + +QList expectedTypesOf( + const QList& alternatives) { + QList types; + types.reserve(alternatives.size()); + for (const auto* codec : alternatives) + types << codec->expected(); + return types; +} + +using ListFactory = ValueCodec* (*)(const QMetaType&); + +const QHash& listFactories() { + static const QHash k_factories{ + {QMetaType::fromType().id(), &makeListCodec}, + {QMetaType::fromType>().id(), + &makeListCodec>}, + }; + return k_factories; +} + +} // namespace + +ValueCodec::ValueCodec(const QMetaType& type, ExpectedType expected) + : m_type(type), m_expected(expected) {} + +QMetaType ValueCodec::type() const { + return m_type; +} + +ExpectedType ValueCodec::expected() const { + return m_expected; +} + +ValueCodec* ValueCodec::codecFor(const QMetaType& type) { + // Cache for codecs, keyed by type id + static QHash s_registry; + + // Cached lookup + if (const auto it = s_registry.constFind(type.id()); + it != s_registry.constEnd()) + return *it; + + ValueCodec* codec = nullptr; + switch (type.id()) { + case QMetaType::Bool: + codec = new BoolCodec(type); + break; + case QMetaType::Int: + codec = new IntCodec(type); + break; + case QMetaType::Double: + codec = new RealCodec(type); + break; + case QMetaType::QString: + codec = new StringCodec(type); + break; + case QMetaType::QVariantList: + codec = new VariantListCodec(type); + break; + case QMetaType::QVariantMap: + codec = new VariantMapCodec(type); + break; + default: + if (const auto factory = listFactories().constFind(type.id()); + factory != listFactories().constEnd()) + codec = (*factory)(type); + else if (util::isSupportedEnum(type)) + codec = new EnumCodec(type, util::metaEnumFor(type)); + break; + } + + // Cache codec + if (codec) s_registry.insert(type.id(), codec); + + return codec; +} + +ValueCodec* ValueCodec::unionFor(const QList& types) { + // Cache for union codecs, keyed by their alternatives + static QHash s_registry; + + if (types.size() < 2) { + qCCritical( + lcSettings, + "A union needs at least two types, got %lld", + types.size()); + return nullptr; + } + + const auto key = unionKey(types); + + // Cached lookup + if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) + return *it; + + QList alternatives; + alternatives.reserve(types.size()); + + for (const auto& type : types) { + const auto* codec = codecFor(type); + if (!codec) { + qCCritical( + lcSettings, + "No codec found for type %s, cannot build union", + type.name()); + return nullptr; + } + alternatives << codec; + } + + auto* const codec = new UnionCodec(alternatives); + s_registry.insert(key, codec); + + return codec; +} + +QJsonValue BoolCodec::encode(const QVariant& value) const { + return value.toBool(); +} + +DecodeResult BoolCodec::decode(const QJsonValue& value) const { + // 1 and "true" are not booleans + if (!value.isBool()) return mismatch(ExpectedType::Bool, value); + + return {.value = value.toBool(), .error = std::nullopt, .indexPath = {}}; +} + +QJsonValue IntCodec::encode(const QVariant& value) const { + return value.toInt(); +} + +DecodeResult IntCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) return mismatch(ExpectedType::Int, value); + + const auto num = value.toDouble(); + + double integral; + if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Expected an integer, got the real %1") + .arg(QString::number(num))); + + constexpr auto k_min = std::numeric_limits::min(); + constexpr auto k_max = std::numeric_limits::max(); + if (num < static_cast(k_min) || num > static_cast(k_max)) { + const auto message = + QStringLiteral( + "Integer %1 is out of range, expected between %2 and %3") + .arg( + QString::number(num, 'f', 0), + QString::number(k_min), + QString::number(k_max)); + return error(DiagnosticType::InvalidValue, message); + } + + return { + .value = static_cast(num), .error = std::nullopt, .indexPath = {}}; +} + +QJsonValue RealCodec::encode(const QVariant& value) const { + return value.toDouble(); +} + +DecodeResult RealCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) return mismatch(ExpectedType::Real, value); + + return { + .value = QVariant::fromValue(value.toDouble()), + .error = std::nullopt, + .indexPath = {}}; +} + +QJsonValue StringCodec::encode(const QVariant& value) const { + return value.toString(); +} + +DecodeResult StringCodec::decode(const QJsonValue& value) const { + if (!value.isString()) return mismatch(ExpectedType::String, value); + + return {.value = value.toString(), .error = std::nullopt, .indexPath = {}}; +} + +QJsonValue VariantListCodec::encode(const QVariant& value) const { + return QJsonArray::fromVariantList(value.toList()); +} + +DecodeResult VariantListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) return mismatch(ExpectedType::Array, value); + + return { + .value = value.toArray().toVariantList(), + .error = std::nullopt, + .indexPath = {}}; +} + +QJsonValue VariantMapCodec::encode(const QVariant& value) const { + return QJsonObject::fromVariantMap(value.toMap()); +} + +DecodeResult VariantMapCodec::decode(const QJsonValue& value) const { + if (!value.isObject()) return mismatch(ExpectedType::Object, value); + + return { + .value = value.toObject().toVariantMap(), + .error = std::nullopt, + .indexPath = {}}; +} + +EnumCodec::EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum) + : ValueCodec(type, ExpectedType::String), m_metaEnum(metaEnum) {} + +QJsonValue EnumCodec::encode(const QVariant& value) const { + const auto* key = util::enumKeyFor(m_metaEnum, value); + if (!key) { + qCWarning( + lcSettings, + "Cannot encode value %lld of enum %s, no such enumerator", + value.toLongLong(), + m_type.name()); + return {}; + } + + return QString::fromUtf8(key); +} + +DecodeResult EnumCodec::decode(const QJsonValue& value) const { + if (!value.isString()) return mismatch(ExpectedType::String, value); + + const auto key = value.toString(); + + for (int i = 0; i < m_metaEnum.keyCount(); ++i) { + if (QString::compare( + QString::fromUtf8(m_metaEnum.key(i)), + key, + Qt::CaseInsensitive) != 0) + continue; + + const auto raw = m_metaEnum.value(i); + QVariant decoded(m_type); + if (!QMetaType::convert( + QMetaType::fromType(), &raw, m_type, decoded.data())) { + qCCritical( + lcSettings, + "Failed to convert value %d to enum %s", + raw, + m_type.name()); + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Could not convert %1 to %2") + .arg(key, QString::fromUtf8(m_type.name()))); + } + + return {.value = decoded, .error = std::nullopt, .indexPath = {}}; + } + + QStringList options; + options.reserve(m_metaEnum.keyCount()); + for (int i = 0; i < m_metaEnum.keyCount(); ++i) + options << QString::fromUtf8(m_metaEnum.key(i)); + + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Invalid enum value %1. Expected one of %2") + .arg(key, options.join(u", "_s))); +} + +template +ListCodec::ListCodec( + const QMetaType& type, const ValueCodec* elementCodec) + : ValueCodec(type, ExpectedType::Array), m_elementCodec(elementCodec) {} + +template +QJsonValue ListCodec::encode(const QVariant& value) const { + QJsonArray array; + const auto list = value.value(); + for (const auto& element : list) + array.append(m_elementCodec->encode(QVariant::fromValue(element))); + return array; +} + +template +DecodeResult ListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) return mismatch(ExpectedType::Array, value); + + const auto array = value.toArray(); + Container list; + list.reserve(array.size()); + + for (qsizetype i = 0; i < array.size(); ++i) { + auto result = m_elementCodec->decode(array.at(i)); + + // Reject the entire list if any element is invalid + if (result.error) { + result.indexPath.prepend(i); + return result; + } + + list.append(result.value.value()); + } + + return { + .value = QVariant::fromValue(list), + .error = std::nullopt, + .indexPath = {}}; +} + +UnionCodec::UnionCodec(const QList& alternatives) + : ValueCodec( + QMetaType::fromType(), alternatives.first()->expected()) + , m_alternatives(alternatives) + , m_expectedTypes(expectedTypesOf(alternatives)) { + m_byType.reserve(alternatives.size()); + + for (const auto* codec : alternatives) + m_byType.insert(codec->type().id(), codec); +} + +QJsonValue UnionCodec::encode(const QVariant& value) const { + // An unset union is simply absent from the file + if (!value.isValid()) return QJsonValue::Undefined; + + const auto* codec = m_byType.value(value.metaType().id()); + if (!codec) { + qCWarning( + lcSettings, + "Cannot encode value of type %s, it is not one of the allowed " + "types", + value.typeName()); + return QJsonValue::Undefined; + } + + return codec->encode(value); +} + +DecodeResult UnionCodec::decode(const QJsonValue& value) const { + std::optional best; + + for (const auto* codec : m_alternatives) { + auto result = codec->decode(value); + + // The first alternative to accept the value wins + if (!result.error) return result; + + // Non-nested type mismatch just means try another alternative + if (result.indexPath.isEmpty() && + result.error->type == DiagnosticType::TypeMismatch) + continue; + + // Report the deepest error that isn't a type mismatch + if (!best || result.indexPath.size() > best->indexPath.size()) + best = std::move(result); + } + + if (best) return *best; + + // Nothing matched the shape, so report every alternative + return mismatch(m_expectedTypes, value); +} + +// Instantiated for types as needed +template class ListCodec; +template class ListCodec>; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.hpp b/Plugins/ZShell/Settings/codecs.hpp new file mode 100644 index 0000000..f86cfe3 --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "common.hpp" + +namespace ZShell::settings { + +struct DecodeResult { + QVariant value; + std::optional error; + QList + indexPath; // Index path of the failing element, outermost list first +}; + +class ValueCodec { + public: + explicit ValueCodec(const QMetaType& type, ExpectedType expected); + virtual ~ValueCodec() = default; + + // Returns the shared codec for a type, or nullptr if the type is unsupported + static ValueCodec* codecFor(const QMetaType& type); + + // Returns the shared codec for a union of types, or nullptr if any of them is unsupported + static ValueCodec* unionFor(const QList& types); + + [[nodiscard]] QMetaType type() const; + [[nodiscard]] ExpectedType expected() + const; // The JSON type this decodes from + [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; + [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; + + protected: + const QMetaType m_type; + const ExpectedType m_expected; + + Q_DISABLE_COPY_MOVE(ValueCodec) +}; + +#define CODEC(Type, Expected) \ + class Type##Codec : public ValueCodec { \ + public: \ + explicit Type##Codec(const QMetaType& type) \ + : ValueCodec(type, ExpectedType::Expected) {} \ + \ + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ + [[nodiscard]] DecodeResult decode( \ + const QJsonValue& value) const override; \ + }; + +CODEC(Bool, Bool) +CODEC(Int, Int) +CODEC(Real, Real) +CODEC(String, String) +CODEC(VariantList, Array) +CODEC(VariantMap, Object) + +#undef CODEC + +class EnumCodec : public ValueCodec { + public: + explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + + private: + const QMetaEnum m_metaEnum; +}; + +template class ListCodec : public ValueCodec { + using Value = Container::value_type; + + public: + explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + + private: + const ValueCodec* m_elementCodec; +}; + +// Decodes any one of several types, for options that accept more than one shape +class UnionCodec : public ValueCodec { + public: + explicit UnionCodec(const QList& alternatives); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + + private: + const QList + m_alternatives; // Tried in order, so the first to accept a value wins + const QList + m_expectedTypes; // Types of the alternatives, for diagnostics + QHash + m_byType; // Type id to alternative, for encoding +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.cpp b/Plugins/ZShell/Settings/common.cpp new file mode 100644 index 0000000..b7113fc --- /dev/null +++ b/Plugins/ZShell/Settings/common.cpp @@ -0,0 +1,131 @@ +#include "common.hpp" + +#include "node.hpp" + +using Qt::StringLiterals::operator""_s; + +namespace ZShell::settings { + +Q_LOGGING_CATEGORY(lcSettings, "zshell.settings", QtInfoMsg) + +WriteScope::WriteScope(Node* node, WriteOrigin origin) + : m_root(node->rootNode()), m_previous(m_root->m_writeOrigin) { + m_root->m_writeOrigin = origin; +} + +WriteScope::~WriteScope() { + m_root->m_writeOrigin = m_previous; +} + +InternalRead::InternalRead(Node* node) + : m_root(node->rootNode()), m_previous(m_root->m_internalRead) { + m_root->m_internalRead = true; +} + +InternalRead::~InternalRead() { + m_root->m_internalRead = m_previous; +} + +QString DiagnosticType::toString(Type t) { + switch (t) { + case UnknownOption: + return u"UnknownOption"_s; + case GlobalOption: + return u"GlobalOption"_s; + case TypeMismatch: + return u"TypeMismatch"_s; + case InvalidValue: + return u"InvalidValue"_s; + } + + Q_UNREACHABLE_RETURN(QString()); +} + +namespace { + +QString expectedStr(ExpectedType expected) { + switch (expected) { + case ExpectedType::Bool: + return QStringLiteral("a boolean"); + case ExpectedType::Int: + return QStringLiteral("an integer"); + case ExpectedType::Real: + return QStringLiteral("a number"); + case ExpectedType::String: + return QStringLiteral("a string"); + case ExpectedType::Array: + return QStringLiteral("an array"); + case ExpectedType::Object: + return QStringLiteral("an object"); + } + + Q_UNREACHABLE_RETURN(QString()); +} + +QString receivedStr(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::Null: + return QStringLiteral("null"); + case QJsonValue::Bool: + return QStringLiteral("a boolean"); + case QJsonValue::Double: + return QStringLiteral("a number"); + case QJsonValue::String: + return QStringLiteral("a string"); + case QJsonValue::Array: + return QStringLiteral("an array"); + case QJsonValue::Object: + return QStringLiteral("an object"); + default: + return QStringLiteral("nothing"); + } +} + +QString mismatchStr( + const QList& expected, const QJsonValue& value) { + QStringList types; + types.reserve(expected.size()); + for (const auto type : expected) + types << expectedStr(type); + const auto received = receivedStr(value); + + switch (expected.size()) { + case 2: + return QStringLiteral("Expected %1 or %2, got %3") + .arg(types[0], types[1], received); + case 3: + return QStringLiteral("Expected %1, %2 or %3, got %4") + .arg(types[0], types[1], types[2], received); + case 4: + return QStringLiteral("Expected one of: %1, %2, %3, %4; got %5") + .arg(types[0], types[1], types[2], types[3], received); + default: + // The bounds are checked in macros.hpp `unionType<...T>` + Q_UNREACHABLE_RETURN(QString()); + } +} + +} // namespace + +Diagnostic Diagnostic::mismatch( + ExpectedType expected, const QJsonValue& value, const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = QStringLiteral("Expected %1, got %2") + .arg(expectedStr(expected), receivedStr(value)), + }; +} + +Diagnostic Diagnostic::mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = mismatchStr(expected, value), + }; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.hpp b/Plugins/ZShell/Settings/common.hpp new file mode 100644 index 0000000..829de23 --- /dev/null +++ b/Plugins/ZShell/Settings/common.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +Q_DECLARE_LOGGING_CATEGORY(lcSettings) + +class Node; + +enum class WriteOrigin : quint8 { + Init, // On init + File, // From the JSON file + FileReset, // When option not present in file + Layer, // From the fallback layer + Qml, // From QML + QmlReset, // On option reset +}; + +class WriteScope { + public: + explicit WriteScope(Node* node, WriteOrigin origin); + ~WriteScope(); + + private: + Node* const m_root; + const WriteOrigin m_previous; + + Q_DISABLE_COPY_MOVE(WriteScope) +}; + +class InternalRead { + public: + explicit InternalRead(Node* node); + ~InternalRead(); + + private: + Node* const m_root; + const bool m_previous; + + Q_DISABLE_COPY_MOVE(InternalRead) +}; + +class DiagnosticType : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum Type : quint8 { + UnknownOption = 0, + GlobalOption, + TypeMismatch, + InvalidValue, + }; + Q_ENUM(Type) + + Q_INVOKABLE static QString toString(Type t); +}; + +enum class ExpectedType : quint8 { + Bool, + Int, + Real, + String, + Array, + Object, +}; + +struct Diagnostic { + Q_GADGET + QML_VALUE_TYPE(diagnostic) + + Q_PROPERTY(DiagnosticType::Type type MEMBER type) + Q_PROPERTY(QString option MEMBER option) + Q_PROPERTY(QString message MEMBER message) + + public: + DiagnosticType::Type type = DiagnosticType::UnknownOption; + QString option; + QString message; + + static Diagnostic mismatch( + ExpectedType expected, + const QJsonValue& value, + const QString& option = {}); + static Diagnostic mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option = {}); + + bool operator==(const Diagnostic& other) const = default; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/layerregistry.hpp b/Plugins/ZShell/Settings/layerregistry.hpp new file mode 100644 index 0000000..4a7cd44 --- /dev/null +++ b/Plugins/ZShell/Settings/layerregistry.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +// Derived from Node and has ctor(path, fallback, parent) +template +concept LayerType = std::derived_from && + std::constructible_from; + +template class LayerRegistry { + public: + explicit LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent); + + [[nodiscard]] QString pathFor(const QString& name) const; + [[nodiscard]] QString nameFor(T* layer) const; + [[nodiscard]] T* get( + const QString& name, + T* fallback, + bool* created = nullptr); // Created on demand + + private: + const QString m_prefix; + const QString m_suffix; + QObject* const m_parent; + QHash m_layers; +}; + +namespace detail { + +inline QString stripTrailingSlashes(QStringView str) { + while (str.endsWith(u'/')) + str = str.left(str.length() - 1); + return str.toString(); +} + +inline QString stripLeadingSlashes(QStringView str) { + while (str.startsWith(u'/')) + str = str.mid(1); + return str.toString(); +} + +} // namespace detail + +template +LayerRegistry::LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent) + : m_prefix(detail::stripTrailingSlashes(prefix)) + , m_suffix(detail::stripLeadingSlashes(suffix)) + , m_parent(parent) {} + +template +QString LayerRegistry::pathFor(const QString& name) const { + return m_prefix + u'/' + name + u'/' + m_suffix; +} + +template QString LayerRegistry::nameFor(T* layer) const { + return m_layers.key(layer); +} + +template +T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { + if (auto* const layer = m_layers.value(name)) { + if (created) *created = false; + return layer; + } + + if (created) *created = true; + + auto* const layer = new T(pathFor(name), fallback, m_parent); + m_layers.insert(name, layer); + return layer; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.cpp b/Plugins/ZShell/Settings/listnode.cpp new file mode 100644 index 0000000..cf27302 --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.cpp @@ -0,0 +1,535 @@ +#include "listnode.hpp" + +#include +#include + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString valuesKey() { + return u"values"_s; +} + +void deleteNode(Node* node) { + node->detachFallback(); + node->setParent(nullptr); + node->deleteLater(); +} + +bool isVariantListType(const QVariant& value) { + return value.typeId() == QMetaType::QVariantList || + value.typeId() == qMetaTypeId>(); +} + +QList toMaps(const QVariant& value) { + if (value.typeId() == qMetaTypeId>()) + return value.value>(); + + const auto list = value.toList(); + QList maps; + maps.reserve(list.count()); + + for (const auto& el : list) { + if (el.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map in list properties, got %s.", + el.typeName()); + continue; + } + + maps << el.toMap(); + } + + return maps; +} + +void syncNodeRecursive(Node* node, const QVariantMap& props) { + const auto& schema = node->schema(); + + for (const auto& [key, val] : props.asKeyValueRange()) { + const auto* desc = schema.get(key); + + if (!desc) { + qCCritical( + lcSettings, + "Unknown key %s in props for %s, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(node->path())); + continue; + } + + // `setValue` warns on type mismatches itself + if (!desc->isNode) { + node->setValue(key, val); + continue; + } + + auto* const child = node->value(key).value(); + + // Nested lists take their elements straight from props + if (auto* const list = qobject_cast(child)) { + if (!isVariantListType(val)) { + qCCritical( + lcSettings, + "Expected a list for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } + + list->setValue(valuesKey(), val); + continue; + } + + if (val.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } + + syncNodeRecursive(child, val.toMap()); + } +} + +} // namespace + +ListNode::ListNode(ListNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) { + if (fallback) { + // Disconnect generic fallback notify, lists use a custom one + QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); + + if (!m_globalOnly) + QObject::connect( + fallback, + &ListNode::elementsChanged, + this, + &ListNode::onFallbackListNotify); + } +} + +qsizetype ListNode::count() const { + return m_elements.count(); +} + +QVariantList ListNode::values() const { + QVariantList list; + list.reserve(m_elements.count()); + for (auto* const element : m_elements) + list << QVariant::fromValue(element); + return list; +} + +void ListNode::remove(qsizetype index) { + if (rejectGlobalMutation()) return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) { + qCWarning( + lcSettings, + "Attempted to remove invalid index %lld from list node %s, " + "ignoring.", + index, + qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + deleteNode(m_elements.takeAt(index)); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({}, {index}, {}); +} + +void ListNode::move(qsizetype from, qsizetype to) { + if (rejectGlobalMutation()) return; + + const WriteScope scope(this, WriteOrigin::Qml); + + const auto fromInvalid = !validIndex(from); + if (fromInvalid || !validIndex(to)) { + if (fromInvalid) + qCWarning( + lcSettings, + "Attempted to move invalid index %lld to index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); + else + qCWarning( + lcSettings, + "Attempted to move index %lld to invalid index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + m_elements.move(from, to); + + recordWrite(valuesKey(), true); + emit elementsChanged({}, {}, {{from, to}}); +} + +void ListNode::clear() { + if (rejectGlobalMutation()) return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (m_elements.isEmpty()) { + recordWrite(valuesKey(), false); + return; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + recordWrite(valuesKey(), true); + emit countChanged(); + + NodeChanges removed(len); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged({}, removed, {}); +} + +QString ListNode::pathFor(const QString& key) const { + return elementPath(path(), key); +} + +const Schema& ListNode::schema() const { + // Offset +1 so count is not registered (allow read only cause values is read only) + static const auto k_schema = Schema::build( + &staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); + return k_schema; +} + +QVariant ListNode::value(const QString& key) const { + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to read %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return {}; + } + + return QVariant::fromValue(m_elements); +} + +bool ListNode::setValue(const QString& key, const QVariant& value) { + return setValue(key, value, nullptr); +} + +bool ListNode::setValue( + const QString& key, const QVariant& value, QList* diagnostics) { + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to write %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return false; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + if (value.typeId() == qMetaTypeId>()) { + // On reset to fallback + const auto nodes = value.value>(); + for (auto* const node : nodes) + insertNode(m_elements.count(), node); + } else if (isVariantListType(value)) { + // From defaults or QML + const auto list = toMaps(value); + for (qsizetype i = 0; i < list.count(); ++i) + insertNode(i, list.at(i), fallbackFor(i)); + } else if (value.typeId() == QMetaType::QJsonArray) { + // From syncJson + const auto array = value.toJsonArray(); + for (const auto& json : array) + insertNode(m_elements.count(), json.toObject(), *diagnostics); + insertMissingDefaults(); + } else { + qCCritical( + lcSettings, + "Unexpected type %s for list node %s, something is wrong.", + value.typeName(), + qUtf8Printable(path())); + return false; + } + + if (!recordWrite(valuesKey(), true)) return false; + + if (len != m_elements.count()) emit countChanged(); + + NodeChanges added(m_elements.count()); + NodeChanges removed(len); + std::iota(added.begin(), added.end(), 0); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged(added, removed, {}); + + return true; +} + +void ListNode::insertMissingDefaults() { + if (fallbackNode()) return; + + const auto* desc = getDescriptor(); + if (!desc) return; + + const auto idKey = desc->identityKey(); + if (idKey.isEmpty()) return; + + if (!m_elements.isEmpty() && !m_elements.first()->schema().get(idKey)) { + qCCritical( + lcSettings, + "List %s has identityKey '%s' which its elements don't define.", + qUtf8Printable(path()), + qUtf8Printable(idKey)); + return; + } + + QHash> unmatched; + for (auto* const element : std::as_const(m_elements)) + unmatched[element->value(idKey).toString()] << element; + + Node* anchor = nullptr; + for (const auto& props : defaultValue()) { + const auto id = props.value(idKey).toString(); + if (id.isEmpty()) continue; + + auto& candidates = unmatched[id]; + if (!candidates.isEmpty()) { + anchor = candidates.takeFirst(); + continue; + } + + const auto index = anchor ? m_elements.indexOf(anchor) + 1 : 0; + anchor = insertNode(index, props, nullptr); + } +} + +void ListNode::resetToDefaults() { + if (isNested()) { + qCCritical( + lcSettings, + "List node %s has a parent list node, resetToDefaults should never " + "be called on it.", + qUtf8Printable(path())); + return; + } + + // Don't reset global only list nodes on overlays + if (fallbackNode() && m_globalOnly) return; + + const WriteScope scope(this, WriteOrigin::FileReset); + setValue( + valuesKey(), + fallbackNode() ? fallbackNode()->value(valuesKey()) + : QVariant::fromValue(defaultValue())); +} + +QJsonValue ListNode::toJson(bool sparse) const { + Q_UNUSED(sparse) // Lists can't be sparse + + QJsonArray array; + for (auto* const element : m_elements) + array << element->toJson(false); + return array; +} + +bool ListNode::syncJson(const QJsonValue& json, QList& diagnostics) { + if (!json.isArray()) { + const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only list nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; + + const WriteScope scope(this, WriteOrigin::File); + setValue(valuesKey(), json.toArray(), &diagnostics); + + return true; +} + +bool ListNode::recordWrite(const QString& key, bool changed) { + const auto wasOverride = isOverride(key); + const auto notify = Node::recordWrite(key, changed); + + // Elements inside overridden lists have no fallbacks, so detach here + if (!wasOverride && isOverride(key)) + for (auto* const element : std::as_const(m_elements)) + element->detachFallback(); + + return notify; +} + +QString ListNode::keyOf(const Node* child) const { + return QString::number(m_elements.indexOf(const_cast(child))); +} + +Node* ListNode::elementAt(qsizetype index) const { + if (!validIndex(index)) return nullptr; + return m_elements.at(index); +} + +Node* ListNode::insertElement(const QVariantMap& props, qsizetype index) { + if (rejectGlobalMutation()) return nullptr; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) // Invalid index means append + index = m_elements.count(); + + auto* const element = insertNode(index, props, nullptr); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({index}, {}, {}); + + return element; +} + +QList ListNode::defaultValue() const { + const auto* desc = getDescriptor(); + if (!desc) return {}; + return desc->defaultValue().value>(); +} + +bool ListNode::isNested() const { + return qobject_cast(parentNode()); +} + +bool ListNode::rejectGlobalMutation() const { + if (!m_globalOnly || !fallbackNode()) return false; + + qCWarning( + lcSettings, + "Attempted to mutate global list %s from an overlay layer, ignoring. " + "This should not be used, mutate global lists from the global layer " + "instead.", + qUtf8Printable(path())); + + return true; +} + +bool ListNode::validIndex(qsizetype index) const { + return index >= 0 && index < m_elements.count(); +} + +const Descriptor* ListNode::getDescriptor() const { + if (!parentNode()) { // List nodes should not be root nodes + qCCritical( + lcSettings, + "List node %s has no parent, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } + + const auto* desc = parentNode()->schema().get(key()); + + if (!desc) { + qCCritical( + lcSettings, + "List node %s is not in parent schema, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } + + return desc; +} + +Node* ListNode::fallbackFor(qsizetype index) const { + if (isOverride(valuesKey())) return nullptr; + + const auto* fallback = static_cast(fallbackNode()); + if (!fallback || !fallback->validIndex(index)) return nullptr; + + return fallback->m_elements.at(index); +} + +Node* ListNode::insertNode( + qsizetype index, const QVariantMap& props, Node* fallback) { + auto* const node = createElement(fallback); + m_elements.insert(index, node); + + const WriteScope scope(node, WriteOrigin::Init); + syncNodeRecursive(node, props); + + return node; +} + +Node* ListNode::insertNode(qsizetype index, Node* node) { + auto* const copy = createElement(node); + m_elements.insert(index, copy); + return copy; +} + +Node* ListNode::insertNode( + qsizetype index, const QJsonObject& json, QList& diagnostics) { + // Write origin will be file/file reset, which is correct. + // This function should only be called from the JSON path of setValue. + auto* const node = createElement( + nullptr); // Nodes synced from JSON get no fallback as they are overrides + m_elements.insert(index, node); + + node->syncJson(json, diagnostics); + + return node; +} + +void ListNode::onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved) { + if (isOverride(valuesKey())) return; + + const WriteScope scope(this, WriteOrigin::Layer); + + for (const auto index : removed | std::views::reverse) + deleteNode(m_elements.takeAt(index)); + for (const auto index : added) + insertNode(index, fallbackFor(index)); + for (const auto& move : moved) + m_elements.move(move.first, move.second); + + if (!recordWrite(valuesKey(), true)) return; + + if (!added.isEmpty() || !removed.isEmpty()) emit countChanged(); + emit elementsChanged(added, removed, moved); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.hpp b/Plugins/ZShell/Settings/listnode.hpp new file mode 100644 index 0000000..2fa2260 --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "node.hpp" + +namespace ZShell::settings { + +class ListNode : public Node { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) + + using NodeChanges = QList; + using MoveChanges = QList>; + + public: + explicit ListNode( + ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] qsizetype count() const; + [[nodiscard]] QVariantList values() const; + + Q_INVOKABLE void remove(qsizetype index); + Q_INVOKABLE void move(qsizetype from, qsizetype to); + Q_INVOKABLE void clear(); + + [[nodiscard]] QString pathFor(const QString& key) const override; + [[nodiscard]] const Schema& schema() const override; + [[nodiscard]] QVariant value(const QString& key) const override; + bool setValue(const QString& key, const QVariant& value) override; + bool setValue( + const QString& key, + const QVariant& value, + QList* diagnostics); + void resetToDefaults() override; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; + + signals: + void countChanged(); + void elementsChanged( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); + + protected: + bool recordWrite(const QString& key, bool changed) override; + [[nodiscard]] QString keyOf(const Node* child) const override; + + [[nodiscard]] Node* elementAt(qsizetype index) const; + [[nodiscard]] Node* insertElement( + const QVariantMap& props, qsizetype index = -1); + [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; + + private: + QList m_elements; + + [[nodiscard]] QList defaultValue() const; + [[nodiscard]] bool isNested() const; + + // Returns true if the mutation should be skipped, overlays cannot mutate global lists + [[nodiscard]] bool rejectGlobalMutation() const; + + [[nodiscard]] bool validIndex(qsizetype index) const; + [[nodiscard]] const Descriptor* getDescriptor() const; + [[nodiscard]] Node* fallbackFor(qsizetype index) const; + + void insertMissingDefaults(); + + Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); + Node* insertNode(qsizetype index, Node* node); + Node* insertNode( + qsizetype index, + const QJsonObject& json, + QList& diagnostics); + + void onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/macros.hpp b/Plugins/ZShell/Settings/macros.hpp new file mode 100644 index 0000000..b3b47fb --- /dev/null +++ b/Plugins/ZShell/Settings/macros.hpp @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include + +#include "listnode.hpp" +#include "schema.hpp" + +namespace ZShell::settings { + +inline QVariantMap vmap( + std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(key, value); + return map; +} + +template inline QList unionTypes() { + static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); + static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); + static_assert( + (!std::is_same_v && ...), + "A union cannot contain QVariant"); + + return {QMetaType::fromType()...}; +} + +namespace detail { + +template inline bool compare(const T& a, const T& b) { + return a == b; +} + +template bool compare(const T& a, const T& b) { + return qFuzzyCompare(a + T(1), b + T(1)); +} + +template +bool compare(const QList& a, const QList& b) { + if (a.size() != b.size()) return false; + + for (qsizetype i = 0; i < a.size(); ++i) + if (!compare(a.at(i), b.at(i))) return false; + return true; +} + +} // namespace detail + +} // namespace ZShell::settings + +#define DEFAULT_ARG(...) __VA_ARGS__ + +#define SETTINGS_NODE_NO_CTOR(Class, Base) \ + Q_OBJECT \ + \ + public: \ + [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ + static const auto schema = ZShell::settings::Schema::build( \ + &staticMetaObject, Base::staticMetaObject.propertyCount()); \ + return schema; \ + } \ + \ + private: \ + using Self = Class; // For use in the below macros + +#define SETTINGS_NODE(Class, Base) \ + SETTINGS_NODE_NO_CTOR(Class, Base) \ + QML_ANONYMOUS \ + \ + public: \ + explicit Class( \ + Class* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : Base(fallback, parent, globalOnly) {} \ + \ + private: + +#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ + public: \ + [[nodiscard]] Type name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + void set_##name(const Type& value) { \ + if (!true /* TODO: validation */) return; \ + \ + if (rejectInvalidWrite(QStringLiteral(#name), value)) \ + return; /* Skip writes of the wrong type */ \ + \ + if (rejectGlobalWrite(QStringLiteral(#name))) \ + return; /* Skip writes to global only keys */ \ + \ + const auto needsNotify = \ + !ZShell::settings::detail::compare(value, m_##name); \ + m_##name = value; \ + if (recordWrite(QStringLiteral(#name), needsNotify)) \ + Q_EMIT name##Changed(); \ + } \ + \ + Q_SIGNAL void name##Changed(); \ + \ + private: \ + Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = QVariant::fromValue(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); + +#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a global property on a node. Shorthand for .globalOnly = true. +#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a subobject property on a node. Subobject properties are CONSTANT. +#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = {}, .globalOnly = global}), \ + true); + +#define SETTINGS_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, false) + +#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, true) + +#define SETTINGS_LIST_TYPE(Element, Name) \ + class Name : public ZShell::settings::ListNode { \ + Q_OBJECT \ + QML_ANONYMOUS \ + \ + public: \ + explicit Name( \ + Name* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ + \ + [[nodiscard]] Q_INVOKABLE Element* at( \ + qsizetype index) const { /* Format ugh */ \ + return static_cast(elementAt(index)); \ + } \ + [[nodiscard]] Q_INVOKABLE Element* insert( \ + const QVariantMap& props, qsizetype index = -1) { \ + return static_cast(insertElement(props, index)); \ + } \ + \ + protected: \ + [[nodiscard]] ZShell::settings::Node* createElement( \ + ZShell::settings::Node* fallback) override { \ + return new Element(static_cast(fallback), this); \ + } \ + }; + +#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = \ + QVariant::fromValue>(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); + +#define SETTINGS_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) diff --git a/Plugins/ZShell/Settings/node.cpp b/Plugins/ZShell/Settings/node.cpp new file mode 100644 index 0000000..035a0f3 --- /dev/null +++ b/Plugins/ZShell/Settings/node.cpp @@ -0,0 +1,308 @@ +#include "node.hpp" + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +Node::Node(Node* fallback, QObject* parent, bool globalOnly) + : QObject(parent) + , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) + , m_rootNode(parentNode() ? parentNode()->rootNode() : this) + , m_fallbackNode(fallback) + , m_writeOrigin(WriteOrigin::Qml) + , m_internalRead(false) + , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { + if (fallback && !m_globalOnly) + QObject::connect( + fallback, &Node::optionChanged, this, &Node::onFallbackNotify); +} + +QString Node::key() const { + return parentNode() ? parentNode()->keyOf(this) : QString(); +} + +QString Node::path() const { + return parentNode() ? parentNode()->pathFor(key()) : key(); +} + +QString Node::pathFor(const QString& key) const { + const auto p = path(); + return p.isEmpty() ? key : p + u'.' + key; +} + +QString Node::elementPath(const QString& path, const QString& index) { + return path + u'[' + index + u']'; +} + +Node* Node::parentNode() const { + return qobject_cast(parent()); +} + +Node* Node::rootNode() const { + return m_rootNode; +} + +Node* Node::fallbackNode() const { + return m_fallbackNode; +} + +void Node::detachFallback() { + if (m_fallbackNode) { + QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); + m_fallbackNode = nullptr; + } + + const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); + for (auto* const child : childNodes) + child->detachFallback(); +} + +bool Node::isGlobalOnly() const { + return m_globalOnly; +} + +bool Node::isOverride(const QString& key) const { + return m_overrides.contains(key); +} + +const QSet& Node::overrides() const { + return m_overrides; +} + +bool Node::hasContent() const { + return !m_overrides.isEmpty() || m_quarantine || + std::ranges::any_of( + findChildren(Qt::FindDirectChildrenOnly), + &Node::hasContent); +} + +QVariant Node::value(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to read an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return {}; + } + + // Generated getters warn on global reads, this silences them as the warning is only for QML reads + const InternalRead guard(m_rootNode); + return metaObject()->property(desc->metaIndex).read(this); +} + +bool Node::setValue(const QString& key, const QVariant& value) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to set an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } + + if (desc->isNode) { + qCCritical( + lcSettings, + "Attempted to set node %s directly, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } + + // Type mismatch, conversion should happen before this function is called + if (rejectInvalidWrite(key, value)) return false; + + return metaObject()->property(desc->metaIndex).write(this, value); +} + +void Node::resetToDefaults() { + // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer + if (m_globalOnly && m_fallbackNode) return; + + m_quarantine.reset(); // Reset quarantine as well + + // No write scope, callers should create the scope + for (const auto& desc : schema().descriptors()) { + if (desc.isNode) + value(desc.key).value()->resetToDefaults(); + else if ( + !desc.globalOnly() || + !m_fallbackNode) // Skip resetting global options on overlays + setValue( + desc.key, + m_fallbackNode ? m_fallbackNode->value(desc.key) + : desc.defaultValue()); + } +} + +const Quarantine* Node::quarantine() const { + return m_quarantine.get(); +} + +bool Node::rejectInvalidWrite(const QString& key, const QVariant& value) const { + const auto* desc = schema().get(key); + if (!desc || desc->accepts(value.metaType())) return false; + + qCWarning( + lcSettings, + "Type mismatch for %s, expected %s got %s", + qUtf8Printable(pathFor(key)), + qUtf8Printable(desc->typeString()), + value.metaType().name()); + return true; +} + +void Node::warnGlobalRead(const QString& key) const { + if (!m_fallbackNode || m_rootNode->m_internalRead) return; + + qCWarning( + lcSettings, + "Global option %s was read from an overlay layer. " + "This should not be used, read global options from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); +} + +bool Node::rejectGlobalWrite(const QString& key) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to check a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; + + if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) + return false; + + if (origin == WriteOrigin::QmlReset) + qCWarning( + lcSettings, + "Attempted to reset global option %s from an overlay layer, " + "ignoring. " + "This should not be used, reset global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); + else + qCWarning( + lcSettings, + "Attempted to write global option %s from an overlay layer, " + "ignoring. " + "This should not be used, write global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); + + return true; +} + +void Node::warnGlobalSync(QList& diagnostics, const QString& path) { + qCWarning( + lcSettings, + "Global option definition %s found in overlay file, ignoring.", + qUtf8Printable(path)); + diagnostics << Diagnostic{ + .type = DiagnosticType::GlobalOption, + .option = path, + .message = QStringLiteral( + "Global options should not be defined in overlay files"), + }; +} + +bool Node::rejectGlobalSync(QList& diagnostics) const { + if (!m_globalOnly || !m_fallbackNode) return false; + + warnGlobalSync(diagnostics, path()); + return true; +} + +bool Node::recordWrite(const QString& key, bool changed) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to record a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; + + bool dirty = changed; + switch (origin) { + // Init does not notify or write to file + case WriteOrigin::Init: + return false; + + // File and qml both count as overrides + case WriteOrigin::File: + case WriteOrigin::Qml: + dirty |= !m_overrides.contains(key); + m_overrides << key; + break; + + // Layer is not an override, it is a sync with the fallback value + case WriteOrigin::Layer: + break; + + // Both resets clear the override + case WriteOrigin::FileReset: + case WriteOrigin::QmlReset: + dirty |= m_overrides.remove(key); + break; + } + + // User writes/reset override quarantine + if (fromUser) dirty |= removeQuarantined(key); + + // Both qml and reset write to the file (only write if dirty) + if (fromUser && dirty) m_rootNode->m_batcher->dirty(); + + if (changed) emit optionChanged(key); + + return changed; +} + +bool Node::removeQuarantined(const QString& key) { + if (!m_quarantine) return false; + + const auto removed = m_quarantine->remove(key); + if (m_quarantine->isEmpty()) m_quarantine.reset(); + + return removed; +} + +ChangeBatcher* Node::batcher() const { + return m_rootNode->m_batcher; +} + +QString Node::keyOf(const Node* child) const { + for (const auto& desc : schema().descriptors()) { + if (desc.isNode && child == value(desc.key).value()) + return desc.key; + } + + return {}; +} + +void Node::onFallbackNotify(const QString& key) { + if (m_overrides.contains(key)) return; + + // Don't mirror global options onto overlays + const auto* desc = schema().get(key); + if (desc && desc->globalOnly()) return; + + const WriteScope scope(this, WriteOrigin::Layer); + setValue(key, m_fallbackNode->value(key)); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.hpp b/Plugins/ZShell/Settings/node.hpp new file mode 100644 index 0000000..9ed05b8 --- /dev/null +++ b/Plugins/ZShell/Settings/node.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +#include "changebatcher.hpp" +#include "common.hpp" +#include "quarantine.hpp" +#include "schema.hpp" + +namespace ZShell::settings { + +class Node : public QObject { + Q_OBJECT + + public: + // Global only nodes are inherited, anything inside one is also global only + explicit Node( + Node* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] QString key() const; // The key of this in the parent node + [[nodiscard]] QString path() const; + [[nodiscard]] virtual QString pathFor(const QString& key) const; + [[nodiscard]] static QString elementPath( + const QString& path, const QString& index); + [[nodiscard]] Node* parentNode() const; + [[nodiscard]] Node* rootNode() const; + [[nodiscard]] Node* fallbackNode() const; + void + detachFallback(); // Recursive, unlinks this and its children from the fallback layer + + [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; + [[nodiscard]] bool isOverride(const QString& key) const; + [[nodiscard]] const QSet& overrides() const; + [[nodiscard]] bool hasContent() const; // Recursive + + [[nodiscard]] virtual const Schema& schema() const = 0; + + [[nodiscard]] virtual QVariant value(const QString& key) const; + virtual bool setValue( + const QString& key, + const QVariant& value); // Returns whether the write was successful or not + virtual void + resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden + + [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; + // Returns false if the entire node was rejected + virtual bool syncJson( + const QJsonValue& json, QList& diagnostics) = 0; + [[nodiscard]] const Quarantine* quarantine() const; + + signals: + void optionChanged(const QString& key); + + protected: + // Null means empty, otherwise it has content + std::unique_ptr m_quarantine; + const bool m_globalOnly; // Own flag or inherited from the parent node + + void warnGlobalRead(const QString& key) const; + // Returns true if the write should be skipped afterwards, the value is not one of the allowed types + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const QVariant& value) const; + template + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const T& value) const; + // Returns true if the write should be skipped afterwards, overlays cannot write global options + bool rejectGlobalWrite(const QString& key); + static void warnGlobalSync( + QList& diagnostics, const QString& path); + bool rejectGlobalSync(QList& diagnostics) + const; // Returns true if the sync should be rejected + // Returns true if the notify signal should be emitted + virtual bool recordWrite(const QString& key, bool changed); + + [[nodiscard]] bool removeQuarantined(const QString& key); + [[nodiscard]] ChangeBatcher* batcher() const; + + [[nodiscard]] virtual QString keyOf(const Node* child) const; + + template + [[nodiscard]] T fallbackValue( + T C::* member, std::type_identity_t defaultValue) const; + + private: + QSet m_overrides; // Overridden keys from file/qml writes + Node* const m_rootNode; + Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list + + // For root node use only + WriteOrigin m_writeOrigin; + bool m_internalRead; + ChangeBatcher* const m_batcher; + + void onFallbackNotify(const QString& key); + + friend class WriteScope; + friend class InternalRead; +}; + +template +bool Node::rejectInvalidWrite(const QString& key, const T& value) const { + Q_UNUSED(key) + Q_UNUSED(value) + return false; // Only QVariant unions can be given the wrong type +} + +template +T Node::fallbackValue( + T C::* member, std::type_identity_t defaultValue) const { + const auto* fallback = static_cast(m_fallbackNode); + return fallback ? fallback->*member : defaultValue; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.cpp b/Plugins/ZShell/Settings/objectnode.cpp new file mode 100644 index 0000000..5453989 --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.cpp @@ -0,0 +1,219 @@ +#include "objectnode.hpp" + +#include + +#include "codecs.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +ObjectNode::ObjectNode(ObjectNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) {} + +void ObjectNode::resetOption(const QString& key) { + const auto* desc = schema().get(key); + + if (!desc) { + qCWarning(lcSettings) + << "Attempted to reset unknown option" << pathFor(key); + return; + } + + // Warn and ignore if this is a global node property and we are an overlay + if (desc->isNode && (m_globalOnly || desc->globalOnly()) && + fallbackNode()) { + qCWarning( + lcSettings, + "Attempted to reset global node %s, ignoring. " + "This should not be used, reset global nodes from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); + return; + } + + const WriteScope scope(this, WriteOrigin::QmlReset); + if (desc->isNode) + value(key).value()->resetToDefaults(); + else + setValue( + key, + fallbackNode() ? fallbackNode()->value(key) : desc->defaultValue()); +} + +Descriptor ObjectNode::descriptorFor(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCWarning(lcSettings) + << "Attempted to get descriptor for unknown option" << pathFor(key); + return {}; + } + + return *desc; +} + +QJsonValue ObjectNode::toJson(bool sparse) const { + QJsonObject json; + + for (const auto& desc : schema().descriptors()) { + const auto val = value(desc.key); + + if (const auto* node = val.value()) { + if (!sparse || node->hasContent()) + json.insert(desc.key, node->toJson(sparse)); + continue; + } + + if (sparse && !isOverride(desc.key)) continue; + + if (!desc.codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not serialising %s", + desc.type.name(), + qUtf8Printable(pathFor(desc.key))); + continue; + } + + json.insert(desc.key, desc.codec->encode(val)); + } + + if (m_quarantine) return m_quarantine->apply(json); + + return json; +} + +bool ObjectNode::syncJson( + const QJsonValue& json, QList& diagnostics) { + m_quarantine.reset(); // Clear out old quarantine + + if (!json.isObject()) { + const auto d = Diagnostic::mismatch(ExpectedType::Object, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; + + const auto obj = json.toObject(); + + qCDebug(lcSettings) << "Loading JSON into" << metaObject()->className() + << "with" << obj.size() << "keys:" << obj.keys(); + + const auto visited = loadFromJson(obj, diagnostics); + resetUnvisited(visited); + + return true; +} + +void ObjectNode::quarantineKey(const QString& key, const QJsonValue& value) { + if (!m_quarantine) m_quarantine = std::make_unique(); + m_quarantine->insert(key, value); +} + +QSet ObjectNode::loadFromJson( + const QJsonObject& json, QList& diagnostics) { + const WriteScope scope(this, WriteOrigin::File); + + QSet visited; + visited.reserve(json.size()); + + // Convenience macro for quarantine then skip +#define SKIP \ + quarantineKey(key, v); \ + continue + + for (const auto [k, v] : json.asKeyValueRange()) { + const auto key = k.toString(); + const auto* desc = schema().get(key); + + if (!desc) { + const auto path = pathFor(key); + qCWarning(lcSettings) << "Unknown option" << path; + diagnostics << Diagnostic{ + .type = DiagnosticType::UnknownOption, + .option = path, + .message = QStringLiteral("Unknown option %1").arg(key), + }; + SKIP; + } + + // Recurse into child nodes + if (desc->isNode) { + qCDebug(lcSettings) << " Recursing into" << key; + auto* const node = value(key).value(); + if (node->syncJson(v, diagnostics)) + visited << key; + else + quarantineKey( + key, v); // Quarantine entire node cause sync failed + continue; + } + + if ((m_globalOnly || desc->globalOnly()) && fallbackNode()) { + warnGlobalSync(diagnostics, pathFor(key)); + SKIP; + } + + if (!desc->codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not loading %s", + desc->type.name(), + qUtf8Printable(pathFor(key))); + SKIP; + } + + auto val = desc->codec->decode(v); + if (val.error) { + auto path = pathFor(key); + for (const auto index : std::as_const(val.indexPath)) + path = elementPath(path, QString::number(index)); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(path), + qUtf8Printable(val.error->message)); + val.error->option = path; + diagnostics << *val.error; + SKIP; + } + + visited << key; + setValue(key, val.value); + } + +#undef SKIP + + return visited; +} + +void ObjectNode::resetUnvisited(const QSet& visited) { + const WriteScope scope(this, WriteOrigin::FileReset); + + for (const auto& desc : schema().descriptors()) { + if (visited.contains(desc.key)) continue; + + // Reset nodes recursively + if (desc.isNode) { + value(desc.key).value()->resetToDefaults(); + continue; + } + + // Skip global options on overlays + if ((m_globalOnly || desc.globalOnly()) && fallbackNode()) continue; + + setValue( + desc.key, + fallbackNode() ? fallbackNode()->value(desc.key) + : desc.defaultValue()); + } +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.hpp b/Plugins/ZShell/Settings/objectnode.hpp new file mode 100644 index 0000000..2aa24fd --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +class ObjectNode : public Node { + Q_OBJECT + + public: + explicit ObjectNode( + ObjectNode* fallback, + QObject* parent = nullptr, + bool globalOnly = false); + + Q_INVOKABLE void resetOption(const QString& key); + [[nodiscard]] Q_INVOKABLE Descriptor descriptorFor(const QString& key) const; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; + + private: + void quarantineKey(const QString& key, const QJsonValue& value); + + QSet loadFromJson( + const QJsonObject& json, QList& diagnostics); + void resetUnvisited(const QSet& visited); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.cpp b/Plugins/ZShell/Settings/quarantine.cpp new file mode 100644 index 0000000..889b02c --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.cpp @@ -0,0 +1,27 @@ +#include "quarantine.hpp" + +#include + +namespace ZShell::settings { + +void ObjectQuarantine::insert(const QString& key, const QJsonValue& value) { + m_quarantine.insert(key, value); +} + +bool ObjectQuarantine::remove(const QString& key) { + return m_quarantine.remove(key); +} + +bool ObjectQuarantine::isEmpty() const { + return m_quarantine.isEmpty(); +} + +QJsonValue ObjectQuarantine::apply(const QJsonValue& json) const { + auto result = json.toObject(); + for (const auto& [key, value] : m_quarantine.asKeyValueRange()) + if (!result.contains(key)) // Don't clobber existing values + result.insert(key, value); + return result; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.hpp b/Plugins/ZShell/Settings/quarantine.hpp new file mode 100644 index 0000000..2159278 --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::settings { + +class Quarantine { + public: + explicit Quarantine() = default; + virtual ~Quarantine() = default; + + virtual void insert(const QString& key, const QJsonValue& value) = 0; + [[nodiscard]] virtual bool remove(const QString& key) = 0; + [[nodiscard]] virtual bool isEmpty() const = 0; + [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; + + Q_DISABLE_COPY_MOVE(Quarantine) +}; + +class ObjectQuarantine : public Quarantine { + public: + void insert(const QString& key, const QJsonValue& value) override; + [[nodiscard]] bool remove(const QString& key) override; + [[nodiscard]] bool isEmpty() const override; + [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; + + private: + QHash m_quarantine; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.cpp b/Plugins/ZShell/Settings/rootnode.cpp new file mode 100644 index 0000000..06c10b9 --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.cpp @@ -0,0 +1,47 @@ +#include "rootnode.hpp" + +#include + +namespace ZShell::settings { + +RootNode::RootNode(const QString& path, RootNode* fallback, QObject* parent) + : ObjectNode(fallback, parent), m_file(new SettingsFile(path, this)) { + QObject::connect( + batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); + QObject::connect( + m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); + QObject::connect( + m_file, &SettingsFile::readFailed, this, [this](const QString& error) { + emit treeLoadFailed(this, error); + }); + QObject::connect( + m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { + emit treeSaveFailed(this, error); + }); +} + +void RootNode::load() { + if (m_file->load() != SettingsFile::LoadResult::Changed) reloadFromFile(); +} + +QList RootNode::diagnostics() const { + return m_diagnostics; +} + +void RootNode::reloadFromFile() { + const auto oldDiagnostics = m_diagnostics; + m_diagnostics.clear(); + + const auto data = m_file->read(); + syncJson(data ? data.value() : QJsonObject(), m_diagnostics); + + if (m_diagnostics != oldDiagnostics) emit diagnosticsChanged(); + + emit treeLoaded(this); +} + +void RootNode::saveToFile() { + m_file->write(toJson()); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.hpp b/Plugins/ZShell/Settings/rootnode.hpp new file mode 100644 index 0000000..240c0be --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "common.hpp" +#include "objectnode.hpp" +#include "settingsfile.hpp" + +namespace ZShell::settings { + +class RootNode : public ObjectNode { + Q_OBJECT + + Q_PROPERTY( + QList diagnostics READ diagnostics NOTIFY + diagnosticsChanged) + + public: + explicit RootNode( + const QString& path, RootNode* fallback, QObject* parent = nullptr); + + [[nodiscard]] QList diagnostics() const; + + void load(); + + signals: + void diagnosticsChanged(); + void treeLoaded(RootNode* self); + void treeLoadFailed(RootNode* self, const QString& error); + void treeSaveFailed(RootNode* self, const QString& error); + + private: + SettingsFile* const m_file; + QList m_diagnostics; + + void reloadFromFile(); + void saveToFile(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.cpp b/Plugins/ZShell/Settings/schema.cpp new file mode 100644 index 0000000..79ba2c4 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.cpp @@ -0,0 +1,137 @@ +#include "schema.hpp" + +#include "codecs.hpp" +#include "node.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcSchema, "zshell.settings.schema", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +namespace { + +// Descriptor annotations are stored here by Schema::annotate, which is called in the CONFIG_XXX macros. +// Schema::build then takes them from here. +QHash>& annotationCache() { + static QHash> s_cache; + return s_cache; +} + +// Union options carry their alternatives in the annotation, everything else goes by property type +const ValueCodec* resolveCodec(Descriptor& desc) { + auto& allowed = desc.annotation.allowedTypes; + + if (allowed.isEmpty()) return ValueCodec::codecFor(desc.type); + + if (desc.type.id() != QMetaType::QVariant) { + qCCritical( + lcSchema, + "Allowed types are only valid for QVariant properties, ignoring " + "them for %s", + qUtf8Printable(desc.key)); + allowed.clear(); + return ValueCodec::codecFor(desc.type); + } + + if (const auto* codec = ValueCodec::unionFor(allowed)) return codec; + + allowed.clear(); + return nullptr; +} + +bool isNodeType(const QMetaType& type) { + if (!type.flags().testFlag(QMetaType::PointerToQObject)) return false; + const auto* meta = type.metaObject(); + return meta && meta->inherits(&Node::staticMetaObject); +} + +} // namespace + +QString Descriptor::typeString() const { + if (annotation.allowedTypes.isEmpty()) + return QString::fromUtf8(type.name()); + + QStringList names; + names.reserve(annotation.allowedTypes.size()); + for (const auto& allowed : annotation.allowedTypes) + names << QString::fromUtf8(allowed.name()); + + return names.join(QStringLiteral(" | ")); +} + +bool Descriptor::accepts(const QMetaType& valueType) const { + // Unset is a valid state for QVariant options, they are simply absent from the file + if (type.id() == QMetaType::QVariant && !valueType.isValid()) return true; + + if (annotation.allowedTypes.isEmpty()) return type == valueType; + + return annotation.allowedTypes.contains(valueType); +} + +Schema Schema::build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly) { + Schema schema; + schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); + + // Descriptors are taken from cache since this should be called exactly once per class + const auto annotations = annotationCache().take(meta); + + qCDebug(lcSchema) << "Building schema for" << meta->className(); + + for (int i = baseOffset; i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + const auto isNode = isNodeType(prop.metaType()); + + // Skip read only properties (unless includeReadOnly) + if (!isNode && !includeReadOnly && !prop.isWritable()) { + qCDebug(lcSchema) << " Skipping computed property" << key; + continue; + } + + qCDebug(lcSchema) << " Adding property" << key; + + Descriptor desc{ + .key = key, + .type = prop.metaType(), + .metaIndex = i, + .isNode = isNode, + .annotation = annotations.value(key), + }; + + if (!isNode) { + desc.codec = resolveCodec(desc); + if (!desc.codec) + qCCritical( + lcSchema, + "No codec for %s of type %s, it will not be loaded or " + "saved", + qUtf8Printable(key), + desc.type.name()); + } + + schema.m_descriptors.append(std::move(desc)); + schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); + } + + return schema; +} + +void Schema::annotate( + const QMetaObject* meta, const QString& key, Annotation annotation) { + annotationCache()[meta].insert(key, std::move(annotation)); +} + +const QList& Schema::descriptors() const { + return m_descriptors; +} + +const Descriptor* Schema::get(const QString& key) const { + const auto it = m_keyToIndex.find(key); + return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.hpp b/Plugins/ZShell/Settings/schema.hpp new file mode 100644 index 0000000..8abe1f2 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +class ValueCodec; + +struct Annotation { + QVariant defaultValue; + bool globalOnly = false; + // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without + QList allowedTypes = {}; // For QVariant properties + // NOLINTNEXTLINE(readability-redundant-member-init) + QString identityKey = {}; +}; + +namespace detail { + +// Return trivially copyable types (e.g. bool) by value and others (e.g. QVariant) by const ref +template +using AnnotationReturn = std::conditional_t< + std::is_trivially_copyable_v && sizeof(T) <= sizeof(void*), + T, + const T&>; + +} // namespace detail + +#define ANNOTATION(Type, name) \ + Q_PROPERTY(Type name READ name) \ + \ + public: \ + [[nodiscard]] detail::AnnotationReturn name() const { \ + return annotation.name; \ + } \ + \ + private: + +struct Descriptor { + Q_GADGET + QML_VALUE_TYPE(descriptor) + + Q_PROPERTY(QString key MEMBER key) + Q_PROPERTY(QString type READ typeString) + Q_PROPERTY(int metaIndex MEMBER metaIndex) + Q_PROPERTY(bool isNode MEMBER isNode) + + ANNOTATION(QVariant, defaultValue) + ANNOTATION(bool, globalOnly) + ANNOTATION(QString, identityKey) + + public: + QString key; + QMetaType type; + int metaIndex; + bool isNode; + Annotation annotation; + const ValueCodec* codec = nullptr; // Null for nodes and unsupported types + + [[nodiscard]] QString typeString() const; + [[nodiscard]] bool accepts(const QMetaType& valueType) const; +}; + +#undef ANNOTATION + +class Schema { + public: + static Schema build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); + static void annotate( + const QMetaObject* meta, const QString& key, Annotation annotation); + + [[nodiscard]] const QList& descriptors() const; + [[nodiscard]] const Descriptor* get(const QString& key) const; + + private: + explicit Schema() = default; + + QList m_descriptors; + QHash m_keyToIndex; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.cpp b/Plugins/ZShell/Settings/settingsfile.cpp new file mode 100644 index 0000000..4ae07f6 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.cpp @@ -0,0 +1,250 @@ +#include "settingsfile.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + + +namespace { + +Q_LOGGING_CATEGORY(lcSettingsFile, "zshell.settings.file", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// Max retries for loads which fail due to malformed JSON, e.g. partial writes +constexpr int k_maxLoadRetries = 3; + +} // namespace + +SettingsFile::SettingsFile(QString path, QObject* parent) + : QObject(parent) + , m_path(std::move(path)) + , m_watcher(new QFileSystemWatcher(this)) + , m_saveDebounce(new QTimer(this)) + , m_loadDebounce(new QTimer(this)) + , m_loadRetries(0) { + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(500); // Save at most once every 500ms + + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms + QObject::connect( + m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); + + QObject::connect( + m_watcher, + &QFileSystemWatcher::fileChanged, + this, + &SettingsFile::onFileChanged); + QObject::connect( + m_watcher, + &QFileSystemWatcher::directoryChanged, + this, + &SettingsFile::onDirChanged); + + initWatcher(); +} + +std::optional SettingsFile::read() const { + return m_lastData; +} + +void SettingsFile::write(const QJsonValue& json) { + m_pendingWrite = json; + save(); +} + +void SettingsFile::onFileChanged() { + initWatcher(); // Re-add path in case the file was replaced + scheduleLoad(); +} + +void SettingsFile::onDirChanged() { + // Ignore if the file is already watched + if (m_watcher->files().contains(m_path)) return; + + initWatcher(); + + if (QFile::exists(m_path)) scheduleLoad(); +} + +void SettingsFile::initWatcher() { + const QFileInfo info(m_path); + const auto dir = info.absolutePath(); + + if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) + m_watcher->addPath(dir); + + if (info.exists() && !m_watcher->files().contains(m_path)) + m_watcher->addPath(m_path); +} + +void SettingsFile::scheduleLoad() { + // Ignore if a load is already pending + if (m_loadDebounce->isActive()) return; + + m_loadRetries = 0; + m_loadDebounce->start(); +} + +void SettingsFile::onLoadDebounced() { + const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; + + if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { + // Likely a partial write, retry after another debounce + ++m_loadRetries; + qCDebug( + lcSettingsFile, + "Retrying load of %s (%d/%d)", + qUtf8Printable(m_path), + m_loadRetries, + k_maxLoadRetries); + m_loadDebounce->start(); + } +} + +SettingsFile::LoadResult SettingsFile::load(bool reportErrors) { + QFile file(m_path); + + if (!file.exists()) { + if (m_lastData) { + m_lastData = std::nullopt; + emit changed(); + return LoadResult::Changed; + } + return LoadResult::Unchanged; + } + + if (!file.open(QIODevice::ReadOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for reading: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit readFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return LoadResult::Error; + } + + const auto data = file.readAll(); + file.close(); + + QJsonParseError error; + const auto doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + if (reportErrors) { + qCWarning( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + emit readFailed( + QStringLiteral("Failed to parse: %1").arg(error.errorString())); + } else { + qCDebug( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + } + return LoadResult::ParseError; + } + + const auto json = doc.isObject() ? QJsonValue(doc.object()) + : QJsonValue(doc.array()); + + if (json == m_lastData) return LoadResult::Unchanged; + + // Clear pending write, stuff loaded from file should take precedence + m_pendingWrite = std::nullopt; + + m_lastData = json; + emit changed(); + + qCDebug(lcSettingsFile) << "Read JSON from" << m_path; + qCDebug(lcSettingsFile) << " " << json; + + return LoadResult::Changed; +} + +void SettingsFile::save() { + if (!m_pendingWrite) return; + + if (m_saveDebounce->isActive()) { + // Queue save for debounce end + QObject::connect( + m_saveDebounce, + &QTimer::timeout, + this, + &SettingsFile::save, + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design + static_cast( + Qt::UniqueConnection | Qt::SingleShotConnection)); + return; + } + + // Debounce regardless of whether the save actually succeeded + m_saveDebounce->start(); + + const auto json = m_pendingWrite.value(); + const auto data = (json.isObject() ? QJsonDocument(json.toObject()) + : QJsonDocument(json.toArray())) + .toJson(); + m_pendingWrite = std::nullopt; + + const auto dir = QFileInfo(m_path).absolutePath(); + + if (!QDir().mkpath(dir)) { + qCWarning(lcSettingsFile) << "Failed to create dir" << dir; + emit writeFailed(QStringLiteral("Failed to create parent directory")); + return; + } + + // Write atomically + QSaveFile file(m_path); + + if (!file.open(QIODevice::WriteOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for writing: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return; + } + + file.write(data); + + if (!file.commit()) { + qCWarning( + lcSettingsFile, + "Failed to write %s: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to write: %1").arg(file.errorString())); + return; + } + + m_lastData = json; + qCDebug(lcSettingsFile) << "Saved" << m_path; + + initWatcher(); // The file may have just been created, or replaced by the rename +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.hpp b/Plugins/ZShell/Settings/settingsfile.hpp new file mode 100644 index 0000000..d9b82b4 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::settings { + +class SettingsFile : public QObject { + Q_OBJECT + + public: + enum class LoadResult : quint8 { Unchanged, Changed, ParseError, Error }; + + explicit SettingsFile(QString path, QObject* parent = nullptr); + + [[nodiscard]] std::optional read() const; + void write(const QJsonValue& json); + + LoadResult load(bool reportErrors = true); + + signals: + void changed(); // Data changed, not file watcher event + void readFailed(const QString& error); + void writeFailed(const QString& error); + + private: + QString m_path; + QFileSystemWatcher* m_watcher; + QTimer* m_saveDebounce; + QTimer* m_loadDebounce; + int m_loadRetries; + std::optional m_lastData; + std::optional m_pendingWrite; + + void onFileChanged(); + void onDirChanged(); + + void initWatcher(); + void scheduleLoad(); + void onLoadDebounced(); + void save(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/toaster.cpp b/Plugins/ZShell/toaster.cpp index a8aa59f..9e5e7a8 100644 --- a/Plugins/ZShell/toaster.cpp +++ b/Plugins/ZShell/toaster.cpp @@ -102,6 +102,11 @@ void Toast::unlock(QObject* sender) { Toaster::Toaster(QObject* parent) : QObject(parent) {} +Toaster* Toaster::instance() { + static Toaster toaster; + return &toaster; +} + QQmlListProperty Toaster::toasts() { return QQmlListProperty(this, &m_toasts); } diff --git a/Plugins/ZShell/toaster.hpp b/Plugins/ZShell/toaster.hpp index c2e546a..667ffab 100644 --- a/Plugins/ZShell/toaster.hpp +++ b/Plugins/ZShell/toaster.hpp @@ -67,6 +67,7 @@ class Toaster : public QObject { public: explicit Toaster(QObject* parent = nullptr); + static Toaster* instance(); [[nodiscard]] QQmlListProperty toasts(); diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index 4aef1fe..8a81a39 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -5,13 +5,17 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include "util/metaenum.hpp" + Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) namespace ZShell { @@ -171,13 +175,112 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) { return qBound(min, value, max); } -QString ZUtils::settingsIndex() { - QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json")); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qCWarning(lcZUtils) << "Failed to open embedded settings index"; - return QString(); +QString ZUtils::enumToString( + QObject* target, const QString& property, const QVariant& value) { + if (!target) { + qCWarning(lcZUtils) << "enumToString: a target is required"; + return {}; } - return QString::fromUtf8(file.readAll()); + + const auto* meta = target->metaObject(); + const auto index = meta->indexOfProperty(property.toUtf8().constData()); + if (index < 0) { + qCWarning(lcZUtils) + << "enumToString:" << target << "has no property" << property; + return {}; + } + + const auto prop = meta->property(index); + const auto metaEnum = prop.isEnumType() + ? prop.enumerator() + : util::metaEnumFor(prop.metaType()); + if (!metaEnum.isValid() || metaEnum.is64Bit()) { + qCWarning(lcZUtils) << "enumToString: property" << property << "of" + << target << "is not a supported enum"; + return {}; + } + + const auto val = value.isValid() ? value : prop.read(target); + const auto* key = util::enumKeyFor(metaEnum, val); + if (!key) { + qCWarning( + lcZUtils, + "enumToString: no enumerator of %s::%s has the value %lld", + metaEnum.scope(), + metaEnum.name(), + val.toLongLong()); + return {}; + } + + return QString::fromUtf8(key); +} + +namespace { + +template +QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + return child; + } + if (QQuickItem* const found = findChildDfs(child, match)) { + return found; + } + } + return nullptr; +} + +template +void findChildrenDfs( + QQuickItem* root, Predicate&& match, QList& out) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + out.append(child); + } + findChildrenDfs(child, match, out); + } +} + +} // namespace + +QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) { + if (!root) { + return nullptr; + } + + return findChildDfs(root, [&name](const QQuickItem* item) { + return item->objectName() == name; + }); +} + +QList ZUtils::findChildren(QQuickItem* root, const QString& name) { + QList children; + if (root) { + findChildrenDfs( + root, + [&name](const QQuickItem* item) { + return item->objectName() == name; + }, + children); + } + return children; +} + +QList ZUtils::findChildrenMatching( + QQuickItem* root, const QString& pattern) { + QList children; + if (root) { + const QRegularExpression re(pattern); + findChildrenDfs( + root, + [&re](const QQuickItem* item) { + return re.match(item->objectName()).hasMatch(); + }, + children); + } + return children; } #ifndef ZSHELL_VERSION @@ -192,4 +295,46 @@ QString ZUtils::qtVersion() const { return QStringLiteral(QT_VERSION_STR); } +QString ZUtils::gitRevision() { +#ifdef GIT_REVISION + return QStringLiteral(GIT_REVISION); +#else + return QString(); +#endif +} + +QString ZUtils::readTextFile(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QString(); + } + return QString::fromUtf8(file.readAll()); +} + +bool ZUtils::writeTextFile(const QString& path, const QString& text) { + const QFileInfo info(path); + if (!QDir().mkpath(info.absolutePath())) { + qCWarning(lcZUtils) << "Failed to create directory for" << path; + return false; + } + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qCWarning(lcZUtils) << "Failed to open" << path << "for writing"; + return false; + } + return file.write(text.toUtf8()) >= 0; +} + +QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) { + QStringList out; + QDirIterator it(dir, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + if (it.fileInfo().isFile() && path.endsWith(suffix)) { + out.append(path); + } + } + return out; +} + } // namespace ZShell diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp index 1e9b3f8..3900cc0 100644 --- a/Plugins/ZShell/zutils.hpp +++ b/Plugins/ZShell/zutils.hpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include namespace ZShell { @@ -32,7 +34,24 @@ class ZUtils : public QObject { Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); - Q_INVOKABLE static QString settingsIndex(); + Q_INVOKABLE static QString enumToString( + QObject* target, + const QString& property, + const QVariant& value = QVariant()); + + Q_INVOKABLE static QString gitRevision(); + Q_INVOKABLE static QString readTextFile(const QString& path); + Q_INVOKABLE static bool writeTextFile( + const QString& path, const QString& text); + Q_INVOKABLE static QStringList listFiles( + const QString& dir, const QString& suffix); + + Q_INVOKABLE static QQuickItem* findChild( + QQuickItem* root, const QString& name); + Q_INVOKABLE static QList findChildren( + QQuickItem* root, const QString& name); + Q_INVOKABLE static QList findChildrenMatching( + QQuickItem* root, const QString& pattern); [[nodiscard]] QString version() const; [[nodiscard]] QString qtVersion() const; diff --git a/Plugins/cmake/pch.cmake b/Plugins/cmake/pch.cmake new file mode 100644 index 0000000..814d6b0 --- /dev/null +++ b/Plugins/cmake/pch.cmake @@ -0,0 +1,15 @@ +add_library(zshell-pch INTERFACE) +target_compile_options(zshell-pch INTERFACE $<$:-Wno-null-dereference>) +target_precompile_headers(zshell-pch INTERFACE + + + + + + + + + + + +) diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake new file mode 100644 index 0000000..0b8ac52 --- /dev/null +++ b/Plugins/cmake/qml-module.cmake @@ -0,0 +1,55 @@ +message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}") + +function(qml_module arg_TARGET) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI;INCLUDE_PREFIX" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES") + + set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + + qt_add_qml_module(${arg_TARGET} + URI ${arg_URI} + SOURCES ${arg_SOURCES} + QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS} + DEPENDENCIES ${arg_DEPENDENCIES} + IMPORTS ${arg_IMPORTS} + OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS} + DEFAULT_IMPORTS ${arg_DEFAULT_IMPORTS} + ) + + qt_query_qml_module(${arg_TARGET} + URI module_uri + PLUGIN_TARGET module_plugin_target + TARGET_PATH module_target_path + QMLDIR module_qmldir + TYPEINFO module_typeinfo + ) + + message(STATUS "Created QML module: ${module_uri}") + + string(REPLACE "/" ";" uri_parts "${module_target_path}") + list(GET uri_parts 0 top_level) + set(backing_lib_dir "${INSTALL_QMLDIR}/${top_level}/lib") + set(module_dir "${INSTALL_QMLDIR}/${module_target_path}") + + install(TARGETS ${arg_TARGET} + LIBRARY DESTINATION "${backing_lib_dir}" + RUNTIME DESTINATION "${backing_lib_dir}" + ) + install(TARGETS "${module_plugin_target}" + LIBRARY DESTINATION "${module_dir}" + RUNTIME DESTINATION "${module_dir}" + ) + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + + target_link_libraries(${arg_TARGET} PRIVATE zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES}) + + if(arg_INCLUDE_PREFIX) + set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include") + file(MAKE_DIRECTORY "${include_dir}") + file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC) + target_include_directories(${arg_TARGET} PUBLIC "${include_dir}") + endif() + + file(RELATIVE_PATH plugin_to_lib "/${module_target_path}" "/${top_level}/lib") + set_property(TARGET ${module_plugin_target} APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${plugin_to_lib}") +endfunction() diff --git a/Plugins/cmake/sensorslib.cmake b/Plugins/cmake/sensorslib.cmake new file mode 100644 index 0000000..b7333a2 --- /dev/null +++ b/Plugins/cmake/sensorslib.cmake @@ -0,0 +1,10 @@ +find_library(SENSORS_LIBRARY NAMES sensors REQUIRED) +find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED) +if(NOT TARGET Sensors::Sensors) + add_library(Sensors::Sensors UNKNOWN IMPORTED) + set_target_properties(Sensors::Sensors PROPERTIES + IMPORTED_LOCATION "${SENSORS_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}" + ) +endif() + diff --git a/Plugins/cmake/wayland-protocol.cmake b/Plugins/cmake/wayland-protocol.cmake new file mode 100644 index 0000000..76b6989 --- /dev/null +++ b/Plugins/cmake/wayland-protocol.cmake @@ -0,0 +1,58 @@ +find_program(QTWAYLANDSCANNER_EXECUTABLE qtwaylandscanner REQUIRED) +find_program(WAYLAND_SCANNER_EXECUTABLE wayland-scanner REQUIRED) + +pkg_get_variable(WAYLAND_PROTOCOLS wayland-protocols pkgdatadir) +if(NOT WAYLAND_PROTOCOLS) + message(FATAL_ERROR "wayland-protocols not found") +endif() + +function(wl_proto arg_TARGET arg_PROTOCOL arg_XML_DIR) + set(out_dir "${CMAKE_BINARY_DIR}/wayland-protocols/${arg_TARGET}") + file(MAKE_DIRECTORY "${out_dir}") + + set(xml "${arg_XML_DIR}/${arg_PROTOCOL}.xml") + + add_custom_command( + OUTPUT "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} client-header "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} private-code "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + DEPENDS "${xml}" + COMMENT "Generating ${arg_PROTOCOL} protocol files" + ) + + add_custom_command( + OUTPUT "${out_dir}/qwayland-${arg_PROTOCOL}.h" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-header "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.h" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-code "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + DEPENDS "${xml}" "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMENT "Generating ${arg_PROTOCOL} client wrapper" + ) + + add_library( + ${arg_TARGET} + STATIC + "${out_dir}/wayland-${arg_PROTOCOL}.c" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + ) + set_source_files_properties( + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + PROPERTIES COMPILE_OPTIONS + "-Wno-old-style-cast;-Wno-sign-conversion;-Wno-conversion;-Wno-shadow" + ) + set_target_properties(${arg_TARGET} PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_include_directories(${arg_TARGET} PUBLIC "${out_dir}") + target_link_libraries( + ${arg_TARGET} + PUBLIC + Qt::Core + Qt::WaylandClient + Qt::WaylandClientPrivate + PkgConfig::WAYLAND_CLIENT + ) +endfunction() diff --git a/Plugins/include/util/metaenum.hpp b/Plugins/include/util/metaenum.hpp new file mode 100644 index 0000000..25f6786 --- /dev/null +++ b/Plugins/include/util/metaenum.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace util { + +inline QMetaEnum metaEnumFor(const QMetaType& type) { + const auto* meta = type.metaObject(); + if (!meta) return {}; + + auto name = QByteArray(type.name()); + if (const auto scope = name.lastIndexOf("::"); scope >= 0) + name = name.mid(scope + 2); + + return meta->enumerator(meta->indexOfEnumerator(name.constData())); +} + +inline bool isSupportedEnum(const QMetaType& type) { + if (!type.flags().testFlag(QMetaType::IsEnumeration)) return false; + + const auto metaEnum = metaEnumFor(type); + return metaEnum.isValid() && !metaEnum.is64Bit(); +} + +inline const char* enumKeyFor(const QMetaEnum& metaEnum, const QVariant& value) { + return metaEnum.valueToKey(static_cast(value.toLongLong())); +} + +} // namespace util diff --git a/README.md b/README.md index 1ca52b4..cb7b254 100644 --- a/README.md +++ b/README.md @@ -84,89 +84,11 @@ Defaults from `CMakeLists.txt`: - shell config install dir: `etc/xdg/quickshell/zshell` - greeter install dir: `etc/xdg/quickshell/zshell-greeter` -### Nix Flake +### Nix -_Note that Nix is not actively developed at this point. Things may be broken. Feel free to suggest fixes in a PR_ +Nix support has been removed. Reason being that none of us actively develop for Nix. Having a `flake.nix` in the project will give the wrong idea. -The flake exposes: - -- `packages..zshell` -- `packages..zshell-cli` - -Add it as an input in your system flake: - -```nix -{ - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - z-bar-qt = { - url = git+https://git.zach-dev.cc/zach/z-bar-qt.git - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; -} -``` - -Full `flake.nix` example (`nixosConfigurations`): - -```nix -{ - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - z-bar-qt = { - url = git+https://git.zach-dev.cc/zach/z-bar-qt.git - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = inputs@{ nixpkgs, ... }: - let - system = "x86_64-linux"; - in { - nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { - inherit system; - specialArgs = { inherit inputs; }; - modules = [ - ({ ... }: { - environment.systemPackages = [ - inputs.z-bar-qt.packages.${system}.zshell - inputs.z-bar-qt.packages.${system}.zshell-cli - ]; - }) - ./configuration.nix - ]; - }; - }; -} -``` - -Then install shell + CLI from the flake packages: - -```nix -{ pkgs, inputs, ... }: -let - system = pkgs.stdenv.hostPlatform.system; -in { - environment.systemPackages = [ - inputs.z-bar-qt.packages.${system}.zshell - inputs.z-bar-qt.packages.${system}.zshell-cli - ]; -} -``` - -If you use Home Manager, the same packages can be added to `home.packages`. - -Run shell via wrapper binary: - -```bash -zshell -``` - -CLI entrypoint: - -```bash -zshell-cli -``` +Contributions for Nix support are welcome, the caveat being that reviews of these contributions can be of lower quality. ## Running diff --git a/Services/Colors.qml b/Services/Colors.qml index 863c03f..970281d 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,7 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/Services/ShellState.qml b/Services/ShellState.qml new file mode 100644 index 0000000..7d48f4b --- /dev/null +++ b/Services/ShellState.qml @@ -0,0 +1,40 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Components +import qs.Helpers + +Singleton { + function allStates(): list { + return [...states.instances]; + } + + function anySidebarOpen(): bool { + return states.instances.some(s => s.sidebar); + } + + function forActive(): ScreenState { + const mon = Hypr.focusedMonitor; + for (const s of states.instances) + if (Hypr.monitorFor(s.modelData) === mon) + return s; + return null; + } + + function forScreen(screen: ShellScreen): ScreenState { + for (const s of states.instances) + if (s.modelData === screen) + return s; + return null; + } + + Variants { + id: states + + model: Quickshell.screens + + ScreenState { + } + } +} diff --git a/assets/shaders/crop_sdf.frag b/assets/shaders/crop_sdf.frag new file mode 100644 index 0000000..bbe2aa0 --- /dev/null +++ b/assets/shaders/crop_sdf.frag @@ -0,0 +1,40 @@ +#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; + vec4 cropBox; + vec4 borderColor; + float cornerRadius; + float borderWidth; + float pixelScale; +}; + +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; +} + +void main() { + vec2 sz = itemSize * pixelScale; + vec2 p = qt_TexCoord0 * sz; + vec4 box = cropBox * pixelScale; + float r = cornerRadius * pixelScale; + float bw = borderWidth * pixelScale; + + float dImage = sdRoundBox(p - sz * 0.5, sz * 0.5, r); + float dCrop = sdRoundBox(p - (box.xy + box.zw * 0.5), box.zw * 0.5, 0.0); + + float d = max(dImage, dCrop); + + // ramp is exactly 1 physical pixel wide + float outer = clamp(0.5 - d, 0.0, 1.0); + float inner = clamp(0.5 - (d + bw), 0.0, 1.0); + float a = outer - inner; + + fragColor = vec4(borderColor.rgb, 1.0) * (borderColor.a * a * qt_Opacity); +} diff --git a/assets/shaders/crop_sdf.frag.qsb b/assets/shaders/crop_sdf.frag.qsb new file mode 100644 index 0000000..786ccc0 Binary files /dev/null and b/assets/shaders/crop_sdf.frag.qsb differ 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/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index ed60e8b..f0c8e72 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -94,9 +94,8 @@ def list_presets( if v.accents: entry["accents"] = sorted(v.accents) entry["default_accent"] = sorted(v.accents)[0] - variants[v.id] = entry - out[meta.name] = { - "id": sid, + variants[v.id.lower()] = entry + out[sid.lower()] = { "variants": variants, } print(json.dumps({"presets": out}, indent=2)) @@ -626,8 +625,8 @@ def generate( scheme_type = config["colors"].get("schemeType", "fruit-salad") scheme = scheme or scheme_type assert isinstance(scheme, str) - config_mode = config["general"]["color"]["mode"] - smart = bool(config["general"]["color"].get("smart", False)) + config_mode = config["colors"]["mode"] + smart = bool(config["colors"].get("smart", False)) scheme_class = get_scheme_class(scheme) p_variant = "default" @@ -670,7 +669,7 @@ def generate( name = palette_obj.scheme flavor = palette_obj.variant - display_name = schemes[p_scheme].name + display_name = schemes[p_scheme].id config["colors"]["presets"] = { "name": display_name, "variant": p_variant, diff --git a/flake.lock b/flake.lock deleted file mode 100644 index bb07f24..0000000 --- a/flake.lock +++ /dev/null @@ -1,48 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "quickshell": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1770693276, - "narHash": "sha256-ngXnN5YXu+f45+QGYNN/VEBMQmcBCYGRCqwaK8cxY1s=", - "ref": "refs/heads/master", - "rev": "dacfa9de829ac7cb173825f593236bf2c21f637e", - "revCount": 735, - "type": "git", - "url": "https://git.outfoxxed.me/outfoxxed/quickshell" - }, - "original": { - "type": "git", - "url": "https://git.outfoxxed.me/outfoxxed/quickshell" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "quickshell": "quickshell" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index af98ca1..0000000 --- a/flake.nix +++ /dev/null @@ -1,71 +0,0 @@ -{ - description = "Flake for zshell"; - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - - quickshell = { - url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = { - self, - nixpkgs, - ... - } @ inputs: let - forAllSystems = fn: nixpkgs.lib.genAttrs nixpkgs.lib.platforms.linux (system: fn nixpkgs.legacyPackages.${system}); - in { - formatter = forAllSystems (pkgs: pkgs.nixfmt); - - packages = forAllSystems ( - pkgs: let - pythonEnv = pkgs.python3.withPackages (ps: [ - ps.pillow - ps.materialyoucolor - ]); - in rec { - zshell-cli = pkgs.callPackage ./nix/zshell-cli.nix {}; - - zshell = pkgs.callPackage ./nix { - rev = self.rev or self.dirtyRev; - stdenv = pkgs.clangStdenv; - - quickshell = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default.override { - withX11 = false; - withI3 = false; - }; - - app2unit = pkgs.callPackage ./nix/app2unit.nix {}; - - inherit pythonEnv zshell-cli; - }; - - default = zshell; - } - ); - - devShells = forAllSystems (pkgs: { - default = let - system = pkgs.stdenv.hostPlatform.system; - shellPkg = self.packages.${system}.zshell; - cliPkg = self.packages.${system}.zshell-cli; - in - pkgs.mkShell.override {stdenv = shellPkg.stdenv;} { - inputsFrom = [ - shellPkg - shellPkg.plugin - ]; - - packages = with pkgs; [ - shellPkg - cliPkg - - material-symbols - rubik - nerd-fonts.caskaydia-cove - ]; - }; - }); - }; -} diff --git a/nix/app2unit.nix b/nix/app2unit.nix deleted file mode 100644 index a29a66a..0000000 --- a/nix/app2unit.nix +++ /dev/null @@ -1,16 +0,0 @@ -{ - pkgs, - fetchFromGitHub, - ... -}: -pkgs.app2unit.overrideAttrs ( - final: prev: rec { - version = "1.0.3"; - src = fetchFromGitHub { - owner = "Vladimir-csp"; - repo = "app2unit"; - tag = "v${version}"; - hash = "sha256-7eEVjgs+8k+/NLteSBKgn4gPaPLHC+3Uzlmz6XB0930="; - }; - } -) diff --git a/nix/default.nix b/nix/default.nix deleted file mode 100644 index 636e6be..0000000 --- a/nix/default.nix +++ /dev/null @@ -1,146 +0,0 @@ -{ - fftw, - libcava, - rev, - lib, - stdenv, - makeWrapper, - makeFontsConf, - app2unit, - networkmanager, - swappy, - wl-clipboard, - libqalculate, - bash, - hyprland, - material-symbols, - rubik, - nerd-fonts, - qt6, - quickshell, - aubio, - pipewire, - cmake, - ninja, - pkg-config, - pythonEnv, - zshell-cli, - ddcutil, - brightnessctl, -}: -let - version = "1.0.0"; - - runtimeDeps = [ - pythonEnv - app2unit - networkmanager - swappy - wl-clipboard - libqalculate - bash - hyprland - zshell-cli - ddcutil - brightnessctl - ]; - - fontconfig = makeFontsConf { - fontDirectories = [ - material-symbols - rubik - nerd-fonts.caskaydia-cove - ]; - }; - - cmakeBuildType = "RelWithDebInfo"; - - cmakeVersionFlags = [ - (lib.cmakeFeature "VERSION" version) - (lib.cmakeFeature "GIT_REVISION" rev) - (lib.cmakeFeature "DISTRIBUTOR" "nix-flake") - ]; - - plugin = stdenv.mkDerivation { - inherit cmakeBuildType; - name = "zshell-qml-plugin"; - src = lib.fileset.toSource { - root = ./..; - fileset = lib.fileset.union ./../CMakeLists.txt ./../Plugins; - }; - - nativeBuildInputs = [ - cmake - ninja - pkg-config - ]; - buildInputs = [ - pythonEnv - qt6.qtbase - qt6.qtdeclarative - libqalculate - pipewire - aubio - libcava - fftw - ]; - - dontWrapQtApps = true; - cmakeFlags = [ - (lib.cmakeFeature "ENABLE_MODULES" "plugin") - (lib.cmakeFeature "INSTALL_QMLDIR" qt6.qtbase.qtQmlPrefix) - ] - ++ cmakeVersionFlags; - }; -in -stdenv.mkDerivation { - inherit version cmakeBuildType; - pname = "zshell"; - src = ./..; - - nativeBuildInputs = [ - cmake - ninja - makeWrapper - qt6.wrapQtAppsHook - ]; - buildInputs = [ - quickshell - plugin - qt6.qtbase - qt6.qtwayland - ]; - propagatedBuildInputs = runtimeDeps; - - cmakeFlags = [ - (lib.cmakeFeature "ENABLE_MODULES" "shell") - (lib.cmakeFeature "INSTALL_QSCONFDIR" "${placeholder "out"}/share/ZShell") - ] - ++ cmakeVersionFlags; - - prePatch = '' - substituteInPlace shell.qml \ - --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false' - ''; - - postInstall = '' - makeWrapper ${quickshell}/bin/qs $out/bin/zshell \ - --prefix PATH : "${lib.makeBinPath runtimeDeps}" \ - --set FONTCONFIG_FILE "${fontconfig}" \ - --add-flags "-p $out/share/ZShell" - - echo "$out" - mkdir -p $out/lib - ''; - - passthru = { - inherit plugin; - }; - - meta = { - description = "A very segsy desktop shell"; - homepage = "https://github.com/Zacharias-Brohn/z-bar-qt"; - license = lib.licenses.gpl3Only; - mainProgram = "zshell"; - }; -} diff --git a/nix/zshell-cli.nix b/nix/zshell-cli.nix deleted file mode 100644 index f3cc3b1..0000000 --- a/nix/zshell-cli.nix +++ /dev/null @@ -1,28 +0,0 @@ -{ - python3, - installShellFiles, -}: -python3.pkgs.buildPythonApplication { - pname = "zshell-cli"; - version = "0.1.0"; - src = ../cli; - pyproject = true; - - build-system = with python3.pkgs; [ - hatch-vcs - hatchling - ]; - - dependencies = with python3.pkgs; [ - materialyoucolor - pillow - jinja2 - typer - ]; - - pythonImportsCheck = ["zshell"]; - - nativeBuildInputs = [installShellFiles]; - - SETUPTOOLS_SCM_PRETEND_VERSION = 1; -} 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 f602e71..fcbc98b 100644 --- a/shell.qml +++ b/shell.qml @@ -3,16 +3,13 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell import Quickshell.Services.UPower import qs.Modules -import qs.Modules.Wallpaper import qs.Modules.Lock import qs.Drawers -import qs.Helpers import qs.Modules.Polkit import qs.Daemons @@ -23,33 +20,25 @@ ShellRoot { settings.watchFiles: true - Drawers { - } - - Wallpaper { - } - - AreaPicker { - } + Drawers {} Lock { id: lock } - Shortcuts { - } + Shortcuts {} IdleMonitors { lock: lock } - Polkit { - } + Polkit {} LazyLoader { activeAsync: root.laptop - component: BatteryService { - } + component: BatteryService {} } + + 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