diff --git a/Components/AnchorAnim.qml b/Components/AnchorAnim.qml new file mode 100644 index 0000000..147caf2 --- /dev/null +++ b/Components/AnchorAnim.qml @@ -0,0 +1,51 @@ +import QtQuick +import qs.Config + +AnchorAnimation { + enum Type { + StandardSmall = 0, + Standard, + StandardLarge, + StandardExtraLarge, + EmphasizedSmall, + Emphasized, + EmphasizedLarge, + EmphasizedExtraLarge, + FastSpatial, + DefaultSpatial, + SlowSpatial + } + + property int type: AnchorAnim.DefaultSpatial + + duration: { + if (type < AnchorAnim.StandardSmall || type > AnchorAnim.SlowSpatial) + return Appearance.anim.durations.expressiveDefaultSpatial; + + if (type == AnchorAnim.FastSpatial) + return Appearance.anim.durations.expressiveFastSpatial; + if (type == AnchorAnim.DefaultSpatial) + return Appearance.anim.durations.expressiveDefaultSpatial; + if (type == AnchorAnim.SlowSpatial) + return Appearance.anim.durations.large; + + const types = ["small", "normal", "large", "extraLarge"]; + const idx = type % 4; + return Appearance.anim.durations[types[idx]]; + } + easing.bezierCurve: { + if (type == AnchorAnim.FastSpatial) + return Appearance.anim.curves.expressiveFastSpatial; + if (type == AnchorAnim.DefaultSpatial) + return Appearance.anim.curves.expressiveDefaultSpatial; + if (type == AnchorAnim.SlowSpatial) + return Appearance.anim.curves.expressiveSlowSpatial; + + if (type >= AnchorAnim.StandardSmall && type <= AnchorAnim.StandardExtraLarge) + return Appearance.anim.curves.standard; + if (type >= AnchorAnim.EmphasizedSmall && type <= AnchorAnim.EmphasizedExtraLarge) + return Appearance.anim.curves.emphasized; + + return Appearance.anim.curves.expressiveDefaultSpatial; + } +} diff --git a/Components/Anim.qml b/Components/Anim.qml index e5743e5..6dc33c6 100644 --- a/Components/Anim.qml +++ b/Components/Anim.qml @@ -2,7 +2,62 @@ import QtQuick import qs.Config NumberAnimation { - duration: Appearance.anim.durations.normal - easing.bezierCurve: Appearance.anim.curves.standard - easing.type: Easing.BezierSpline + enum Type { + StandardSmall = 0, + Standard, + StandardLarge, + StandardExtraLarge, + EmphasizedSmall, + Emphasized, + EmphasizedLarge, + EmphasizedExtraLarge, + FastSpatial, + DefaultSpatial, + SlowSpatial, + FastEffects, + DefaultEffects, + SlowEffects + } + + property int type: Anim.DefaultSpatial + + duration: { + if (type < Anim.StandardSmall || type > Anim.SlowEffects) + return Appearance.anim.durations.normal; + + if (type === Anim.FastSpatial) + return Appearance.anim.durations.expressiveFastSpatial; + if (type === Anim.DefaultSpatial) + return Appearance.anim.durations.expressiveDefaultSpatial; + if (type === Anim.SlowSpatial) + return Appearance.anim.durations.large; + if (type === Anim.FastEffects) + return Appearance.anim.durations.expressiveFastEffects; + if (type === Anim.DefaultEffects) + return Appearance.anim.durations.expressiveEffects; + if (type === Anim.SlowEffects) + return Appearance.anim.durations.expressiveSlowEffects; + + const types = ["small", "normal", "large", "extraLarge"]; + const idx = type % 4; + return Appearance.anim.durations[types[idx]]; + } + easing.bezierCurve: { + if (type === Anim.FastSpatial) + return Appearance.anim.curves.expressiveFastSpatial; + if (type === Anim.DefaultSpatial) + return Appearance.anim.curves.expressiveDefaultSpatial; + if (type === Anim.SlowSpatial) + return Appearance.anim.curves.expressiveSlowSpatial; + if (type === Anim.FastEffects) + return Appearance.anim.curves.expressiveFastEffects; + if (type === Anim.DefaultEffects) + return Appearance.anim.curves.expressiveDefaultEffects; + if (type === Anim.SlowEffects) + return Appearance.anim.curves.expressiveSlowEffects; + + if (type >= Anim.EmphasizedSmall && type <= Anim.EmphasizedExtraLarge) + return Appearance.anim.curves.emphasized; + return Appearance.anim.curves.standard; + } } diff --git a/Components/BaseStyledSlider.qml b/Components/BaseStyledSlider.qml index 48c8f33..efdacad 100644 --- a/Components/BaseStyledSlider.qml +++ b/Components/BaseStyledSlider.qml @@ -12,11 +12,8 @@ Slider { readonly property bool isVertical: orientation === Qt.Vertical property real multiplier: 100 property real oldValue - - // Wrapper components can inject their own track visuals here. property Component trackContent - // Keep current behavior for existing usages. orientation: Qt.Vertical background: CustomRect { diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml new file mode 100644 index 0000000..5eb4bb7 --- /dev/null +++ b/Components/ButtonBase.qml @@ -0,0 +1,87 @@ +import QtQuick +import qs.Config + +CustomRect { + id: root + + enum ButtonType { + Filled, + Tonal, + Text + } + + property color activeColor + property color activeOnColor + property bool checked + property real checkedRadius: Appearance.rounding.medium + property real defaultRadius: Appearance.rounding.large + property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color disabledOnColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property bool fillWidth + property font font: ({ + family: Appearance.font.family.sans, + pointSize: Appearance.font.size.larger, + bold: false + }) + property real horizontalPadding: padding + readonly property alias hovered: stateLayer.containsMouse + required implicitHeight + required implicitWidth + property color inactiveColor + property color inactiveOnColor + property bool internalChecked + property bool isRound + property bool isToggle + readonly property color onColor: !enabled ? disabledOnColor : internalChecked ? activeOnColor : inactiveOnColor + property real padding + readonly property alias pressed: stateLayer.pressed + property real pressedRadius: Appearance.rounding.small + readonly property alias radiusAnim: radiusAnim + property bool radiusMorph: true + property alias shapeMorph: stateLayer.shapeMorph + property real shapeMorphExpansion: shapeMorph && pressed ? 24 : 0 + readonly property alias stateLayer: stateLayer + property int type: ButtonBase.Filled + property real verticalPadding: padding + + signal clicked + + color: type === ButtonBase.Text ? "transparent" : !enabled ? disabledColor : internalChecked ? activeColor : inactiveColor + radius: { + if (radiusMorph && pressed) + return pressedRadius; + if (internalChecked) + return checkedRadius; + if (isRound) + return (height || implicitHeight) / 2 * Math.min(1, Appearance.rounding.scale); + return defaultRadius; + } + + Behavior on radius { + Anim { + id: radiusAnim + + type: Anim.DefaultEffects + } + } + Behavior on shapeMorphExpansion { + Anim { + type: Anim.FastSpatial + } + } + + onCheckedChanged: internalChecked = checked + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColor : root.inactiveOnColor + enabled: root.enabled + + onClicked: { + if (root.isToggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } +} diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 662c571..60466e5 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -17,8 +17,8 @@ BusyIndicator { } property int animState - property color bgColour: DynamicColors.palette.m3secondaryContainer - property color fgColour: DynamicColors.palette.m3primary + property color bgColor: DynamicColors.palette.m3secondaryContainer + property color fgColor: DynamicColors.palette.m3primary property real implicitSize: Appearance.font.size.normal * 3 property real internalStrokeWidth: strokeWidth readonly property alias progress: manager.progress @@ -31,8 +31,8 @@ BusyIndicator { contentItem: CircularProgress { anchors.fill: parent - bgColour: root.bgColour - fgColour: root.fgColour + bgColor: root.bgColor + fgColor: root.fgColor padding: root.padding rotation: manager.rotation startAngle: manager.startFraction * 360 @@ -73,7 +73,6 @@ BusyIndicator { CircularIndicatorManager { id: manager - } NumberAnimation { diff --git a/Components/CircularProgress.qml b/Components/CircularProgress.qml index fa1011c..9b22cda 100644 --- a/Components/CircularProgress.qml +++ b/Components/CircularProgress.qml @@ -1,66 +1,116 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Shapes +import ZShell.Components import qs.Config -Shape { +Item { id: root - readonly property real arcRadius: (size - padding - strokeWidth) / 2 - property color bgColour: DynamicColors.palette.m3secondaryContainer - property color fgColour: DynamicColors.palette.m3primary + readonly property real arcRadius: (size - padding - strokeWidth * (1 + waveAmplitude * 2)) / 2 + property color bgColor: DynamicColors.palette.m3secondaryContainer + property real clampedVal: Math.max(1 / 360, Math.min(1, isNaN(value) ? 0 : value)) + readonly property real dotAngleRad: (startAngle + sweepAngle - gapAngle * (sweepAngle < 360 ? 0 : 1)) * Math.PI / 180 + property color fgColor: DynamicColors.palette.m3primary readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + property alias hasEndIndicator: dot.active + property real implicitSize property int padding: 0 readonly property real size: Math.min(width, height) property int spacing: Appearance.spacing.small property int startAngle: -90 - property int strokeWidth: Appearance.padding.smaller - readonly property real vValue: value || 1 / 360 + property int strokeWidth: Appearance.padding.small + property int sweepAngle: 360 + readonly property real thickness: strokeWidth * (1 + waveAmplitude) * 2 property real value + property alias waveAmplitude: wave.amplitudeMultiplier + property alias waveDuration: waveProgAnim.duration + property alias waveFrequency: wave.frequency + property bool wavePaused + property bool wavy: false - asynchronous: true - preferredRendererType: Shape.CurveRenderer + implicitHeight: implicitSize + implicitWidth: implicitSize - ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: root.bgColour - strokeWidth: root.strokeWidth + Shape { + asynchronous: true + opacity: Math.min(1, remainingArc.sweepAngle) + preferredRendererType: Shape.CurveRenderer - Behavior on strokeColor { - CAnim { - duration: Appearance.anim.durations.large + ShapePath { + capStyle: ShapePath.RoundCap + fillColor: "transparent" + strokeColor: root.bgColor + strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.strokeWidth + + Behavior on strokeColor { + CAnim { + } } - } - PathAngleArc { - centerX: root.size / 2 - centerY: root.size / 2 - radiusX: root.arcRadius - radiusY: root.arcRadius - startAngle: root.startAngle + 360 * root.vValue + root.gapAngle - sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + PathAngleArc { + id: remainingArc + + centerX: root.size / 2 + centerY: root.size / 2 + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + root.clampedVal * root.sweepAngle + root.gapAngle + sweepAngle: Math.max(1 / 360, root.sweepAngle * (1 - root.clampedVal) - root.gapAngle * (root.sweepAngle < 360 ? 1 : 2)) + } } } - ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: root.fgColour - strokeWidth: root.strokeWidth + WavyLine { + id: wave - Behavior on strokeColor { - CAnim { - duration: Appearance.anim.durations.large + amplitudeMultiplier: root.wavy ? 0.5 : 0 + anchors.fill: parent + anchors.margins: -lineWidth * amplitudeMultiplier + color: root.fgColor + frequency: 8 + fullAngle: root.sweepAngle + lineWidth: root.strokeWidth + pathType: WavyLine.Arc + radius: root.arcRadius + startAngle: root.startAngle + value: root.clampedVal + + Behavior on amplitudeMultiplier { + Anim { + type: Anim.DefaultEffects } } + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + id: waveProgAnim - PathAngleArc { - centerX: root.size / 2 - centerY: root.size / 2 - radiusX: root.arcRadius - radiusY: root.arcRadius - startAngle: root.startAngle - sweepAngle: 360 * root.vValue + duration: 2000 + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: root.wavePaused || wave.amplitudeMultiplier === 0 + running: true + to: 1 + } + } + + Loader { + id: dot + + x: root.size / 2 + root.arcRadius * Math.cos(root.dotAngleRad) - width / 2 + y: root.size / 2 + root.arcRadius * Math.sin(root.dotAngleRad) - height / 2 + + sourceComponent: CustomRect { + color: root.fgColor + implicitHeight: Math.min(1, remainingArc.sweepAngle) * Math.min(4, root.strokeWidth) + implicitWidth: Math.min(1, remainingArc.sweepAngle) * Math.min(4, root.strokeWidth) + opacity: Math.min(1, remainingArc.sweepAngle) + radius: Appearance.rounding.full } } } diff --git a/Components/CollapsibleSection.qml b/Components/CollapsibleSection.qml index 6d6fc3e..46e4753 100644 --- a/Components/CollapsibleSection.qml +++ b/Components/CollapsibleSection.qml @@ -59,15 +59,15 @@ ColumnLayout { } StateLayer { - function onClicked(): void { - root.toggleRequested(); - root.expanded = !root.expanded; - } - anchors.fill: parent color: DynamicColors.palette.m3onSurface radius: Appearance.rounding.normal showHoverBackground: false + + onClicked: { + root.toggleRequested(); + root.expanded = !root.expanded; + } } } diff --git a/Components/ColorArcPicker.qml b/Components/ColorArcPicker.qml index c909511..b200bfb 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -45,10 +45,10 @@ Item { } function syncFromPenColor() { - if (!drawing) + if (!drawing.drawingState) return; - const c = drawing.penColor; + const c = drawing.drawingState.penColor; if (c.hsvSaturation > 0) { currentHue = c.hsvHue; @@ -85,12 +85,20 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.penColor = Qt.hsva(currentHue, drawing.penColor.hsvSaturation, drawing.penColor.hsvValue, drawing.penColor.a); + drawing.drawingState.penColor = Qt.hsva(currentHue, drawing.drawingState.penColor.hsvSaturation, drawing.drawingState.penColor.hsvValue, drawing.drawingState.penColor.a); } implicitHeight: 180 implicitWidth: 220 + Behavior on currentHue { + enabled: !root.dragActive + + Anim { + type: Anim.StandardLarge + } + } + Component.onCompleted: syncFromPenColor() onCurrentHueChanged: canvas.requestPaint() onDrawingChanged: syncFromPenColor() @@ -103,7 +111,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing + target: root.drawing.drawingState } Canvas { @@ -141,6 +149,21 @@ Item { } } + CustomRect { + anchors.centerIn: parent + color: root.drawing?.drawingState.penColor + implicitHeight: implicitWidth + implicitWidth: canvas.height - root.handleSize - Appearance.padding.extraLarge * 2 + radius: Appearance.rounding.full + + Behavior on color { + enabled: false + + CAnim { + } + } + } + Item { id: handle @@ -174,7 +197,7 @@ Item { Rectangle { anchors.centerIn: parent - color: root.drawing ? root.drawing.penColor : Qt.hsla(root.currentHue, 1.0, 0.5, 1.0) + color: Qt.hsla(root.currentHue, 1.0, 0.5, 1.0) height: width radius: width / 2 width: parent.width - 12 diff --git a/Components/CustomButton.qml b/Components/CustomButton.qml index 4b572bd..b98710f 100644 --- a/Components/CustomButton.qml +++ b/Components/CustomButton.qml @@ -23,10 +23,10 @@ Button { } StateLayer { - function onClicked(): void { + radius: control.radius + + onClicked: { control.clicked(); } - - radius: control.radius } } diff --git a/Components/CustomClippingWrapperRect.qml b/Components/CustomClippingWrapperRect.qml new file mode 100644 index 0000000..0ff1995 --- /dev/null +++ b/Components/CustomClippingWrapperRect.qml @@ -0,0 +1,13 @@ +import Quickshell.Widgets +import QtQuick + +ClippingWrapperRectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim { + } + } +} diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 01a9652..41b7ba8 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -1,8 +1,10 @@ import QtQuick +import qs.Helpers Flickable { id: root + interactive: !Visibilities.getForActive().isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 51c7110..ec9b9c6 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -1,13 +1,33 @@ import QtQuick +import qs.Helpers ListView { id: root + property bool doneFakeFlick + + interactive: !Visibilities.getForActive().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()); + } + } + Anim { properties: "x,y" } } + + Timer { + interval: 10 + running: root.doneFakeFlick + + onTriggered: root.doneFakeFlick = false + } } diff --git a/Components/CustomProgressBar.qml b/Components/CustomProgressBar.qml new file mode 100644 index 0000000..b0e7a4f --- /dev/null +++ b/Components/CustomProgressBar.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Templates +import ZShell +import ZShell.Components +import ZShell.Internal +import qs.Config + +ProgressBar { + id: root + + enum IndeterminateAnimState { + Running, + Completing, + Stopped + } + + property color bgColor: DynamicColors.palette.m3secondaryContainer + property color fgColor: DynamicColors.palette.m3primary + property int indeterminateAnimState: CustomProgressBar.Stopped + property real waveAmplitude: 0.5 + property int waveDuration: 1000 + property int waveFrequency: 6 + property bool wavePaused + property bool wavy + + function toBounds(startFrac: real, endFrac: real, gapSize: real): point { + startFrac = ZUtils.clamp(startFrac, 0, 1); + endFrac = ZUtils.clamp(endFrac, 0, 1); + + // Ramp down gap size + const GAP_RAMP_DOWN_THRESHOLD = 0.01; + gapSize += height / 2; + const startGapSize = (gapSize * ZUtils.clamp(startFrac, 0, GAP_RAMP_DOWN_THRESHOLD) / GAP_RAMP_DOWN_THRESHOLD); + const endGapSize = (gapSize * (1 - ZUtils.clamp(endFrac, 1 - GAP_RAMP_DOWN_THRESHOLD, 1)) / GAP_RAMP_DOWN_THRESHOLD); + const start = width * startFrac + startGapSize; + const end = width * endFrac - endGapSize; + + return start >= end ? Qt.point(0, 0) : Qt.point(start, end); + } + + function updateIAnimState(): void { + if (indeterminate) { + manager.completeEndProgress = 0; + indeterminateAnimState = CustomProgressBar.Running; + } else if (indeterminateAnimState === CustomProgressBar.Running) { + indeterminateAnimState = CustomProgressBar.Completing; + } + } + + implicitHeight: 4 + implicitWidth: 200 + + contentItem: Loader { + anchors.fill: parent + asynchronous: true + sourceComponent: root.indeterminate || root.indeterminateAnimState !== CustomProgressBar.Stopped ? indeterminateComp : determinateComp + } + + Component.onCompleted: updateIAnimState() + onIndeterminateChanged: updateIAnimState() + + LinearIndicatorManager { + id: manager + + gap: Appearance.spacing.extraSmall + + Anim on completeEndProgress { + duration: manager.completeEndDuration + running: root.indeterminateAnimState === CustomProgressBar.Completing + to: 1 + + onFinished: { + if (root.indeterminateAnimState === CustomProgressBar.Completing) + root.indeterminateAnimState = CustomProgressBar.Stopped; + } + } + Anim on progress { + duration: manager.duration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + running: root.indeterminateAnimState !== CustomProgressBar.Stopped + to: 1 + } + } + + Component { + id: determinateComp + + Item { + Line { + id: remaining + + anchors.right: parent.right + implicitWidth: parent.width - wave.implicitWidth - Appearance.spacing.extraSmall + } + + Line { + property real implicitSize + + anchors.right: parent.right + anchors.rightMargin: (parent.height - implicitHeight) / 2 + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: implicitSize + implicitWidth: implicitSize + radius: Appearance.rounding.full + + Behavior on implicitSize { + Anim { + type: Anim.FastSpatial + } + } + + Component.onCompleted: implicitSize = Qt.binding(() => parent.width - wave.implicitWidth < parent.height ? parent.height : 4) + } + + Wave { + id: wave + + anchors.left: parent.left + + Behavior on implicitWidth { + Anim { + } + } + + Component.onCompleted: implicitWidth = Qt.binding(() => parent.width * root.visualPosition) + } + } + } + + Component { + id: indeterminateComp + + Item { + id: content + + Line { + bounds: { + const i = manager.activeIndicators[0]; // qmllint disable unresolved-type + return i ? root.toBounds(0, i.startFraction, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Line { + bounds: { + const i = manager.activeIndicators[manager.activeIndicators.length - 1]; // qmllint disable unresolved-type + return i ? root.toBounds(i.endFraction, 1, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Instantiator { + model: Math.max(manager.activeIndicators.length, 1) - 1 // qmllint disable unresolved-type + + delegate: Line { + readonly property LinearIndicatorSegment cur: manager.activeIndicators[index] // qmllint disable unresolved-type + required property int index + readonly property LinearIndicatorSegment next: manager.activeIndicators[index + 1 % manager.activeIndicators.length] // qmllint disable unresolved-type + + bounds: root.toBounds(cur.endFraction, next.startFraction, cur.gapSize / 2) + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + + Instantiator { + model: manager.activeIndicators // qmllint disable unresolved-type + + delegate: Wave { + readonly property point bounds: root.toBounds(modelData.startFraction, modelData.endFraction, modelData.gapSize / 2) + required property LinearIndicatorSegment modelData + + color: root.fgColor + implicitWidth: bounds.y - bounds.x + x: bounds.x + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + } + } + + component Line: CustomRect { + property point bounds + + anchors.verticalCenter: parent.verticalCenter + color: root.bgColor + implicitHeight: parent.height + implicitWidth: bounds.y - bounds.x + radius: Appearance.rounding.full + x: bounds.x + } + component Wave: WavyLine { + id: wave + + amplitudeMultiplier: root.wavy ? root.waveAmplitude : 0 + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + frequency: root.waveFrequency + fullLength: parent.width + implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth + lineWidth: parent.height + startX: x + + Behavior on amplitudeMultiplier { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + duration: root.waveDuration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: wave.amplitudeMultiplier === 0 || root.wavePaused + running: true + to: 1 + } + } +} diff --git a/Components/CustomRadioButton.qml b/Components/CustomRadioButton.qml index 481268b..67204d1 100644 --- a/Components/CustomRadioButton.qml +++ b/Components/CustomRadioButton.qml @@ -33,13 +33,13 @@ RadioButton { } StateLayer { - function onClicked(): void { - root.click(); - } - anchors.margins: -7 color: root.checked ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3primary z: -1 + + onClicked: { + root.click(); + } } CustomRect { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 6597f99..e938599 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,49 +1,25 @@ -import qs.Config import QtQuick import QtQuick.Templates +import qs.Helpers +import qs.Config ScrollBar { id: root - property bool _updatingFromFlickable: false - property bool _updatingFromUser: false - property bool animating + readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) + readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) required property Flickable flickable - property real nonAnimPosition + readonly property real nonAnimHeight: flickable.height / flickable.contentHeight + readonly property real nonAnimY: flickable.contentY / flickable.contentHeight + 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 real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - implicitWidth: 8 + enabled: !Visibilities.getForActive().isDrawing + implicitWidth: Appearance.padding.extraSmall * 2 - contentItem: CustomRect { - anchors.left: parent.left - anchors.right: parent.right - color: DynamicColors.palette.m3secondary - opacity: { - if (root.size === 1) - return 0; - if (fullMouse.pressed) - return 1; - if (mouse.containsMouse) - return 0.8; - if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) - return 0.6; - return 0; - } - radius: Appearance.rounding.full - - Behavior on opacity { - Anim { - } - } - - MouseArea { - id: mouse - - acceptedButtons: Qt.NoButton - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - } + contentItem: Item { } Behavior on position { enabled: !fullMouse.pressed @@ -52,58 +28,15 @@ ScrollBar { } } - Component.onCompleted: { - if (flickable) { - const contentHeight = flickable.contentHeight; - const height = flickable.height; - if (contentHeight > height) { - nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); - } - } - } onHoveredChanged: { if (hovered) - shouldBeActive = true; + shouldBeActive = hovered; else - shouldBeActive = flickable.moving; - } - - // Sync nonAnimPosition with Qt's automatic position binding - onPositionChanged: { - if (_updatingFromUser) { - _updatingFromUser = false; - return; - } - if (position === nonAnimPosition) { - animating = false; - return; - } - if (!animating && !_updatingFromFlickable && !fullMouse.pressed) { - nonAnimPosition = position; - } - } - - // Sync nonAnimPosition with flickable when not animating - Connections { - function onContentYChanged() { - if (!animating && !fullMouse.pressed) { - _updatingFromFlickable = true; - const contentHeight = flickable.contentHeight; - const height = flickable.height; - if (contentHeight > height) { - nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); - } else { - nonAnimPosition = 0; - } - _updatingFromFlickable = false; - } - } - - target: flickable + hideDelay.restart(); } Connections { - function onMovingChanged(): void { + function onMovingChanged() { if (root.flickable.moving) root.shouldBeActive = true; else @@ -113,6 +46,57 @@ ScrollBar { target: root.flickable } + CustomClippingRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.top: parent.top + implicitWidth: handle.implicitWidth + radius: Appearance.rounding.full + + CustomRect { + id: handle + + anchors.right: parent.right + color: DynamicColors.palette.m3secondary + implicitHeight: root.height * root.effectiveSize + implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Appearance.padding.extraSmall * 2 : Appearance.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: Appearance.rounding.full + y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale + + Behavior on implicitWidth { + Anim { + } + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + MouseArea { + id: mouse + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + } + } + Timer { id: hideDelay @@ -124,66 +108,55 @@ ScrollBar { CustomMouseArea { id: fullMouse - function onWheel(event: WheelEvent): void { - root.animating = true; - root._updatingFromUser = true; - let newPos = root.nonAnimPosition; - if (event.angleDelta.y > 0) - newPos = Math.max(0, root.nonAnimPosition - 0.1); - else if (event.angleDelta.y < 0) - newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1); - root.nonAnimPosition = newPos; - // Update flickable position - // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] - if (root.flickable) { - const contentHeight = root.flickable.contentHeight; - const height = root.flickable.height; - if (contentHeight > height) { - const maxContentY = contentHeight - height; - const maxPos = 1 - root.size; - const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; - root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); - } - } + 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 updateFromEvent(event) { + var posInTrack = event.y / root.height; + var thumbTop = posInTrack - pressOffset; + thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop)); + + root.flickable.contentY = contentYFromThumbTop(thumbTop); + } + + function visualThumbTop() { + const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY; + return visualPos * root.travelScale; } anchors.fill: parent + cursorShape: undefined + hoverEnabled: true preventStealing: true onPositionChanged: event => { - root._updatingFromUser = true; - const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); - root.nonAnimPosition = newPos; - // Update flickable position - // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] - if (root.flickable) { - const contentHeight = root.flickable.contentHeight; - const height = root.flickable.height; - if (contentHeight > height) { - const maxContentY = contentHeight - height; - const maxPos = 1 - root.size; - const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; - root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); - } - } + if (pressed) + updateFromEvent(event); } onPressed: event => { - root.animating = true; - root._updatingFromUser = true; - const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); - root.nonAnimPosition = newPos; - // Update flickable position - // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] - if (root.flickable) { - const contentHeight = root.flickable.contentHeight; - const height = root.flickable.height; - if (contentHeight > height) { - const maxContentY = contentHeight - height; - const maxPos = 1 - root.size; - const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; - root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); - } + var currentTop = visualThumbTop(); + var currentBottom = currentTop + root.effectiveSize; + var clickPos = event.y / root.height; + + var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom; + + if (clickedInsideThumb) { + pressOffset = clickPos - currentTop; + } else { + pressOffset = 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 311f861..f7be430 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -1,51 +1,201 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Templates +import ZShell.Components +import ZShell +import qs.Components import qs.Config Slider { id: root - property color color: DynamicColors.palette.m3primary + property bool animateWave + property color bgColor: enabled ? DynamicColors.palette.m3secondaryContainer : Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color fgColor: enabled ? DynamicColors.palette.m3primary : Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property real filledWidth + property alias inset: inset + property color insetColor: enabled ? (inset.attached ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onPrimary) : DynamicColors.palette.m3onSurface + property string insetIcon: "" + property real pos: visualPosition + property int radius: Appearance.rounding.small + property int waveDuration: 1000 + property real waveFrequency: 6 + property bool wavy + + signal interaction(v: real) + + implicitHeight: 12 + implicitWidth: 200 + + contentItem: Item { + anchors.fill: parent - background: Item { CustomRect { - anchors.bottom: parent.bottom - anchors.bottomMargin: root.implicitHeight / 6 - anchors.left: parent.left - anchors.top: parent.top - anchors.topMargin: root.implicitHeight / 6 - bottomRightRadius: root.implicitHeight / 6 - color: root.color - implicitWidth: root.handle.x - root.implicitHeight / 6 - radius: root.implicitHeight / 6 - topRightRadius: root.implicitHeight / 6 + id: remaining + + anchors.left: handle.right + anchors.leftMargin: Appearance.spacing.extraSmall + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + bottomLeftRadius: Appearance.rounding.extraSmall / 2 + color: root.bgColor + implicitHeight: parent.height * (parent.height <= 12 ? opacity : Math.min(opacity * 2, 1)) + opacity: Math.min(width, 12) / 12 + radius: root.radius + topLeftRadius: Appearance.rounding.extraSmall / 2 } CustomRect { - anchors.bottom: parent.bottom - anchors.bottomMargin: root.implicitHeight / 6 anchors.right: parent.right + anchors.rightMargin: 4 * remaining.opacity + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: 4 * remaining.opacity + implicitWidth: implicitHeight + opacity: remaining.opacity + radius: Appearance.rounding.full + } + + CustomRect { + id: handle + + anchors.left: filled.right + anchors.leftMargin: Appearance.spacing.extraSmall + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: { + const t = ZUtils.clamp((parent.height - 12) / 16, 0, 1); + const lerp = (a, b) => a + (b - a) * t; + return parent.height * (mouse.pressed ? lerp(3.5, 1.5) : lerp(3, 1.2)); + } + implicitWidth: 4 + radius: Appearance.rounding.full + + Behavior on implicitHeight { + Anim { + type: Anim.FastSpatial + } + } + } + + Loader { + id: filled + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + sourceComponent: root.wavy ? waveComp : lineComp + } + + MaterialIcon { + id: inset + + readonly property bool attached: root.width ? (root.width - handle.implicitWidth - handle.anchors.leftMargin) * root.pos < inset.paintedWidth + Appearance.spacing.extraSmall * 2 : false + property real dockT: attached ? 1 : 0 + + anchors.bottom: parent.bottom anchors.top: parent.top - anchors.topMargin: root.implicitHeight / 6 - bottomLeftRadius: root.implicitHeight / 6 - color: DynamicColors.tPalette.m3surfaceContainerHighest - implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6 - radius: root.implicitHeight / 6 - topLeftRadius: root.implicitHeight / 6 + color: root.insetColor + font.pixelSize: Appearance.font.size.smaller * 2 + text: root.insetIcon + verticalAlignment: Text.AlignVCenter + visible: !root.wavy && root.insetIcon !== "" + x: Appearance.spacing.extraSmall + dockT * (handle.x + Appearance.spacing.small) + + Behavior on dockT { + Anim { + } + } + } + + Component { + id: lineComp + + CustomRect { + bottomRightRadius: Appearance.rounding.extraSmall / 2 + color: root.fgColor + implicitHeight: root.height + implicitWidth: root.filledWidth + radius: root.radius + topRightRadius: Appearance.rounding.extraSmall / 2 + } + } + + Component { + id: waveComp + + WavyLine { + color: root.fgColor + frequency: root.waveFrequency + fullLength: root.width - handle.implicitWidth - handle.anchors.leftMargin + implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth + implicitWidth: root.filledWidth + lineWidth: root.height * 0.7 + startX: x + + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + duration: root.waveDuration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: !root.animateWave + running: true + to: 1 + } + } } } - handle: CustomRect { - anchors.verticalCenter: parent.verticalCenter - color: root.color - implicitHeight: root.implicitHeight - implicitWidth: root.implicitHeight / 4.5 - radius: Appearance.rounding.full - x: root.visualPosition * root.availableWidth - implicitWidth / 2 + Behavior on filledWidth { + id: widthBehavior - MouseArea { - acceptedButtons: Qt.NoButton - anchors.fill: parent - cursorShape: Qt.PointingHandCursor + Anim { + } + } + + Component.onCompleted: { + filledWidth = Qt.binding(() => (width - handle.implicitWidth - handle.anchors.leftMargin) * pos); + } + + Binding { + id: posBinding + + property: "pos" + target: root + value: ZUtils.clamp(mouse.pressStartPos + mouse.dragMovement, 0, 1) + when: mouse.pressed + } + + MouseArea { + id: mouse + + property real dragMovement + property real pressStartPos + property real pressStartX + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: handle.implicitHeight + preventStealing: true + + onPositionChanged: e => { + dragMovement = (e.x - pressStartX) / width; + root.interaction(posBinding.value); + } + onPressed: e => { + widthBehavior.enabled = false; + pressStartX = e.x; + pressStartPos = root.visualPosition; + } + onReleased: e => { + root.interaction(posBinding.value); + widthBehavior.enabled = true; + dragMovement = 0; } } } diff --git a/Components/CustomSpinBox.qml b/Components/CustomSpinBox.qml index 72479c8..7526242 100644 --- a/Components/CustomSpinBox.qml +++ b/Components/CustomSpinBox.qml @@ -94,7 +94,9 @@ RowLayout { StateLayer { id: upState - function onClicked(): void { + color: DynamicColors.palette.m3onPrimary + + onClicked: { let newValue = Math.min(root.max, root.value + root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; @@ -103,9 +105,6 @@ RowLayout { root.displayText = newValue.toString(); root.valueModified(newValue); } - - color: DynamicColors.palette.m3onPrimary - onPressAndHold: timer.start() onReleased: timer.stop() } @@ -128,7 +127,9 @@ RowLayout { StateLayer { id: downState - function onClicked(): void { + color: DynamicColors.palette.m3onPrimary + + onClicked: { let newValue = Math.max(root.min, root.value - root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; @@ -137,9 +138,6 @@ RowLayout { root.displayText = newValue.toString(); root.valueModified(newValue); } - - color: DynamicColors.palette.m3onPrimary - onPressAndHold: timer.start() onReleased: timer.stop() } diff --git a/Components/CustomSplitButton.qml b/Components/CustomSplitButton.qml index 3621a71..6d90166 100644 --- a/Components/CustomSplitButton.qml +++ b/Components/CustomSplitButton.qml @@ -1,7 +1,6 @@ import QtQuick import QtQuick.Layouts import qs.Config -import qs.Helpers Row { id: root @@ -13,61 +12,44 @@ Row { property alias active: menu.active property color color: type == CustomSplitButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondaryContainer - property bool disabled property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) property color disabledTextColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + readonly property alias expandBtn: expandBtn property alias expanded: menu.expanded property string fallbackIcon property string fallbackText - property real horizontalPadding: Appearance.padding.normal - property alias iconLabel: iconLabel - property alias label: label - property alias menu: menu + property real horizontalPadding: Appearance.padding.larger + readonly property alias iconLabel: iconLabel + readonly property alias label: label + readonly property alias menu: menu property alias menuItems: menu.items property bool menuOnTop - property alias stateLayer: stateLayer + property real minLeftWidth + readonly property alias stateLayer: stateLayer property color textColor: type == CustomSplitButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondaryContainer + readonly property alias textRow: textRow property int type: CustomSplitButton.Filled - property real verticalPadding: Appearance.padding.smaller + property real verticalPadding: Appearance.padding.small - function closeDropdown(): void { - SettingsDropdowns.close(menu); - } - - function openDropdown(): void { - SettingsDropdowns.open(menu, root); - } - - function toggleDropdown(): void { - SettingsDropdowns.toggle(menu, root); - } - - spacing: Math.floor(Appearance.spacing.small / 2) - - onExpandedChanged: { - if (!expanded) - SettingsDropdowns.forget(menu); - } + spacing: Math.floor(Appearance.spacing.extraSmall) CustomRect { bottomRightRadius: Appearance.rounding.small / 2 color: !root.enabled ? root.disabledColor : root.color implicitHeight: expandBtn.implicitHeight - implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 + implicitWidth: Math.max(root.minLeftWidth, textRow.implicitWidth + root.horizontalPadding * 2) radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) topRightRadius: Appearance.rounding.small / 2 StateLayer { id: stateLayer - function onClicked(): void { - root.active?.clicked(); - } - + bottomRightRadius: parent.bottomRightRadius color: root.textColor - disabled: !root.enabled - rect.bottomRightRadius: parent.bottomRightRadius - rect.topRightRadius: parent.topRightRadius + enabled: root.enabled + topRightRadius: parent.topRightRadius + + onClicked: root.active?.clicked() } RowLayout { @@ -99,7 +81,7 @@ Row { Behavior on Layout.preferredWidth { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } @@ -126,14 +108,12 @@ Row { StateLayer { id: expandStateLayer - function onClicked(): void { - root.toggleDropdown(); - } - color: root.textColor - disabled: !root.enabled + enabled: root.enabled rect.bottomLeftRadius: parent.bottomLeftRadius rect.topLeftRadius: parent.topLeftRadius + + onClicked: root.expanded = !root.expanded } MaterialIcon { @@ -154,24 +134,14 @@ Row { } } } + } - Menu { - id: menu + Menu { + id: menu - anchors.bottomMargin: Appearance.spacing.small - anchors.right: parent.right - anchors.top: parent.bottom - anchors.topMargin: Appearance.spacing.small - - states: State { - when: root.menuOnTop - - AnchorChanges { - anchors.bottom: expandBtn.top - anchors.top: undefined - target: menu - } - } - } + attachSideY: root.menuOnTop ? Menu.Top : Menu.Bottom + attachTo: expandBtn + marginY: Appearance.spacing.small * (root.menuOnTop ? -1 : 1) + thisSideY: root.menuOnTop ? Menu.Bottom : Menu.Top } } diff --git a/Components/CustomSplitButtonRow.qml b/Components/CustomSplitButtonRow.qml index 15bb182..11f0c3b 100644 --- a/Components/CustomSplitButtonRow.qml +++ b/Components/CustomSplitButtonRow.qml @@ -9,7 +9,6 @@ Item { property alias active: splitButton.active property alias buttonAlias: splitButton - property bool enabled: true property alias expanded: splitButton.expanded property int expandedZ: 100 required property string label @@ -25,7 +24,7 @@ Item { implicitHeight: row.implicitHeight + Appearance.padding.smaller * 2 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0.8 - z: root.expanded ? expandedZ : -1 + z: splitButton.menu.implicitHeight > 0 ? expandedZ : 1 Behavior on opacity { Anim { @@ -50,7 +49,6 @@ Item { color: root.enabled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.larger text: root.label - z: root.expanded ? root.expandedZ : -1 } CustomSplitButton { @@ -58,14 +56,13 @@ Item { enabled: root.enabled type: CustomSplitButton.Filled - z: root.expanded ? root.expandedZ : -1 + z: 2 menu.onItemSelected: item => { root.selected(item); - splitButton.closeDropdown(); } stateLayer.onClicked: { - splitButton.toggleDropdown(); + splitButton.expanded = !splitButton.expanded; } } } diff --git a/Components/CustomSwitch.qml b/Components/CustomSwitch.qml index 45f4246..e715f15 100644 --- a/Components/CustomSwitch.qml +++ b/Components/CustomSwitch.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Templates import QtQuick.Shapes +import QtQuick.Templates import qs.Config Switch { @@ -13,26 +13,28 @@ Switch { indicator: CustomRect { color: root.checked && root.enabled ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, root.cLayer) - implicitHeight: 13 + 7 * 2 + implicitHeight: Appearance.font.size.medium + Appearance.padding.normal * 2 implicitWidth: implicitHeight * 1.7 radius: Appearance.rounding.full CustomRect { - readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.2 : implicitHeight anchors.verticalCenter: parent.verticalCenter color: root.checked && root.enabled ? DynamicColors.palette.m3onPrimary : DynamicColors.layer(DynamicColors.palette.m3outline, root.cLayer + 1) - implicitHeight: parent.implicitHeight - 10 + implicitHeight: parent.implicitHeight - Appearance.padding.extraSmall implicitWidth: nonAnimWidth radius: Appearance.rounding.full - x: root.checked ? parent.implicitWidth - nonAnimWidth - 10 / 2 : 10 / 2 + x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.extraSmall / 2 : Appearance.padding.extraSmall / 2 Behavior on implicitWidth { Anim { + type: Anim.FastSpatial } } Behavior on x { Anim { + type: Anim.FastSpatial } } @@ -44,6 +46,7 @@ Switch { Behavior on opacity { Anim { + type: Anim.DefaultEffects } } } @@ -63,14 +66,14 @@ Switch { } property point end2: { if (root.pressed) - return Qt.point(width, height / 2); + return Qt.point(width * 0.8, height / 2); if (root.checked) return Qt.point(width * 0.85, height * 0.2); return Qt.point(width * 0.85, height * 0.15); } property point start1: { if (root.pressed) - return Qt.point(width * 0.1, height / 2); + return Qt.point(width * 0.2, height / 2); if (root.checked) return Qt.point(width * 0.15, height / 2); return Qt.point(width * 0.15, height * 0.15); @@ -88,7 +91,7 @@ Switch { anchors.centerIn: parent asynchronous: true - height: parent.implicitHeight - Appearance.padding.small * 2 + height: parent.implicitHeight - Appearance.padding.larger preferredRendererType: Shape.CurveRenderer width: height @@ -110,7 +113,7 @@ Switch { } ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + capStyle: ShapePath.RoundCap fillColor: "transparent" startX: icon.start1.x startY: icon.start1.y @@ -148,8 +151,7 @@ Switch { } component PropAnim: PropertyAnimation { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - easing.type: Easing.BezierSpline + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial } } diff --git a/Components/CustomText.qml b/Components/CustomText.qml index f4c1600..8966a79 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -27,24 +27,25 @@ Text { enabled: root.animate SequentialAnimation { - Anim { - easing.bezierCurve: MaterialEasing.standardAccel + TAnim { + target: root to: root.animateFrom + type: Anim.FastEffects } PropertyAction { } - Anim { - easing.bezierCurve: MaterialEasing.standardDecel + TAnim { + target: root to: root.animateTo + type: Anim.DefaultEffects } } } - component Anim: NumberAnimation { + component TAnim: Anim { duration: root.animateDuration / 2 - easing.type: Easing.BezierSpline properties: root.animateProp.split(",").length > 1 ? root.animateProp : "" property: root.animateProp.split(",").length === 1 ? root.animateProp : "" target: root diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 16c39a6..ae7ee94 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -7,6 +7,8 @@ import qs.Config TextField { id: root + property int cursorHeight: root.height + background: null color: DynamicColors.palette.m3onSurface cursorVisible: !readOnly diff --git a/Components/Elevation.qml b/Components/Elevation.qml index 26b8fe6..bdef51e 100644 --- a/Components/Elevation.qml +++ b/Components/Elevation.qml @@ -1,6 +1,6 @@ -import qs.Config import QtQuick import QtQuick.Effects +import qs.Config RectangularShadow { property real dp: [0, 1, 3, 6, 8, 12][level] @@ -13,6 +13,7 @@ RectangularShadow { Behavior on dp { Anim { + type: Anim.SlowEffects } } } diff --git a/Components/IconButton.qml b/Components/IconButton.qml index e121651..eef84fd 100644 --- a/Components/IconButton.qml +++ b/Components/IconButton.qml @@ -1,78 +1,45 @@ -import qs.Config import QtQuick +import qs.Config -CustomRect { +ButtonBase { id: root - enum Type { - Filled, - Tonal, - Text - } - - property color activeColour: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary - property color activeOnColour: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary - property bool checked - property bool disabled - property color disabledColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) - property color disabledOnColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) - property alias font: label.font property alias icon: label.text - property color inactiveColour: { - if (!toggle && type === IconButton.Filled) + readonly property alias label: label + + activeColor: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary + activeOnColor: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary + implicitHeight: { + const h = label.implicitHeight + padding * 2; + if (h % 2 !== 0) + return h + 1; + return h; + } + implicitWidth: implicitHeight + inactiveColor: { + if (!isToggle && type === IconButton.Filled) return DynamicColors.palette.m3primary; return type === IconButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; } - property color inactiveOnColour: { - if (!toggle && type === IconButton.Filled) + inactiveOnColor: { + if (!isToggle && type === IconButton.Filled) return DynamicColors.palette.m3onPrimary; return type === IconButton.Tonal ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant; } - property bool internalChecked - property alias label: label - property real padding: type === IconButton.Text ? 10 / 2 : 7 - property alias radiusAnim: radiusAnim - property alias stateLayer: stateLayer - property bool toggle - property int type: IconButton.Filled - - signal clicked - - color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour - implicitHeight: label.implicitHeight + padding * 2 - implicitWidth: implicitHeight - radius: internalChecked ? 6 : (implicitHeight / 2 * Math.min(1, 1)) * Appearance.rounding.scale - - Behavior on radius { - Anim { - id: radiusAnim - } - } - - onCheckedChanged: internalChecked = checked - - StateLayer { - id: stateLayer - - function onClicked(): void { - if (root.toggle) - root.internalChecked = !root.internalChecked; - root.clicked(); - } - - color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour - disabled: root.disabled - } + padding: type === IconButton.Text ? Appearance.padding.extraSmall / 2 : Appearance.padding.small MaterialIcon { id: label anchors.centerIn: parent - color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour - fill: !root.toggle || root.internalChecked ? 1 : 0 + anchors.verticalCenterOffset: 1 + color: root.onColor + fill: !root.isToggle || root.internalChecked ? 1 : 0 + font.pointSize: root.font.pointSize Behavior on fill { Anim { + type: Anim.DefaultEffects } } } diff --git a/Components/IconTextButton.qml b/Components/IconTextButton.qml new file mode 100644 index 0000000..dc20535 --- /dev/null +++ b/Components/IconTextButton.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config + +ButtonBase { + id: root + + property alias icon: iconLabel.text + readonly property alias iconLabel: iconLabel + readonly property alias label: label + property alias text: label.text + + activeColor: type === TextButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary + activeOnColor: { + if (type === TextButton.Text) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondary; + } + horizontalPadding: Appearance.padding.larger + implicitHeight: row.implicitHeight + verticalPadding * 2 + implicitWidth: row.implicitWidth + horizontalPadding * 2 + inactiveColor: { + if (!isToggle && type === TextButton.Filled) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; + } + inactiveOnColor: { + if (!isToggle && type === TextButton.Filled) + return DynamicColors.palette.m3onPrimary; + if (type === TextButton.Text) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer; + } + verticalPadding: Appearance.padding.small + + RowLayout { + id: row + + anchors.centerIn: parent + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + Layout.alignment: Qt.AlignVCenter + color: root.onColor + fill: root.internalChecked ? 1 : 0 + font: { + const f = Qt.font(root.font); + f.pointSize = Math.round(root.font.pointSize * 1.2); + f.family = "Material Symbols Rounded"; + return f; + } + + Behavior on fill { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: label + + Layout.alignment: Qt.AlignVCenter + color: root.onColor + font: root.font + } + } +} diff --git a/Components/MaterialIcon.qml b/Components/MaterialIcon.qml index 69da4e3..23df7f9 100644 --- a/Components/MaterialIcon.qml +++ b/Components/MaterialIcon.qml @@ -1,15 +1,26 @@ import qs.Config CustomText { - property real fill + id: root + + readonly property list allowed: [20, 24, 40, 48] + property real fill: 0 property int grade: DynamicColors.light ? 0 : -25 + readonly property int opsz: clampOpsz(optical) + property int optical: fontInfo.pixelSize + + 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: Appearance.font.size.larger font.variableAxes: ({ - FILL: fill.toFixed(1), + FILL: fill, GRAD: grade, - opsz: fontInfo.pixelSize, + opsz: opsz, wght: fontInfo.weight }) } diff --git a/Components/Menu.qml b/Components/Menu.qml index 8222359..bf19a84 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -2,109 +2,185 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Quickshell import qs.Config +import qs.Drawers -Elevation { +MouseArea { id: root + enum Side { + Top, + Bottom, + Left, + Right + } + property MenuItem active: items[0] ?? null + property int attachSideX: Menu.Right + property int attachSideY: Menu.Bottom + required property Item attachTo property bool expanded property list items + property real marginX + property real marginY + property int thisSideX: Menu.Right + property int thisSideY: Menu.Top signal itemSelected(item: MenuItem) - implicitHeight: root.expanded ? column.implicitHeight + Appearance.padding.small * 2 : 0 - implicitWidth: Math.max(200, column.implicitWidth) - level: 2 - opacity: root.expanded ? 1 : 0 - radius: Appearance.rounding.normal - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } + anchors.fill: parent + cursorShape: undefined + enabled: expanded + layer.enabled: opacity < 1 + opacity: expanded ? 1 : 0 + parent: { + const win = QsWindow.window; + const contentWin = win as Windows; + return contentWin ? contentWin.interactionWrapper : (win as QsWindow).contentItem; } + Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial + type: Anim.DefaultEffects } } - CustomClippingRect { - anchors.fill: parent - color: DynamicColors.palette.m3surfaceContainer - radius: parent.radius + onClicked: expanded = false + onExpandedChanged: { + const win = QsWindow.window; + const contentWin = win as Windows; + if (expanded) { + contentWin.menuRegion.x = menu.x; + contentWin.menuRegion.y = menu.y; + contentWin.menuRegion.width = menu.width; + contentWin.menuRegion.height = menu.height; + } else { + contentWin.menuRegion.x = 0; + contentWin.menuRegion.y = 0; + contentWin.menuRegion.width = 0; + contentWin.menuRegion.height = 0; + } + } - ColumnLayout { - id: column + TransformWatcher { + id: watcher - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 5 + a: root.parent + b: root.attachTo + } - Repeater { - model: root.items + Elevation { + id: menu - CustomRect { - id: item + implicitHeight: column.implicitHeight + column.anchors.margins * 2 + implicitWidth: Math.max(200, column.implicitWidth + column.anchors.margins * 2) + level: 2 + radius: Appearance.rounding.medium + x: { + watcher.transform; + const item = root.attachTo; + let off = root.attachSideX === Menu.Left ? 0 : item.width; + if (root.thisSideX === Menu.Right) + off -= width; + return item.mapToItem(root.parent, off, 0).x + root.marginX; + } + y: { + watcher.transform; + const item = root.attachTo; + let off = root.attachSideY === Menu.Top ? 0 : item.height; + if (root.thisSideY === Menu.Bottom) + off -= height; + return item.mapToItem(root.parent, 0, off).y + root.marginY; + } - readonly property bool active: modelData === root.active - required property int index - required property MenuItem modelData + transform: Scale { + origin.y: root.thisSideY === Menu.Bottom ? menu.height : 0 + yScale: root.expanded ? 1 : 0.1 - Layout.fillWidth: true - implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 - implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 + Behavior on yScale { + Anim { + } + } + } + + CustomRect { + anchors.fill: parent + color: DynamicColors.palette.m3surfaceContainerLow + radius: parent.radius + + ColumnLayout { + id: column + + anchors.fill: parent + anchors.margins: Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + Repeater { + id: repeater + + model: root.items CustomRect { - anchors.fill: parent - anchors.leftMargin: Appearance.padding.small - anchors.rightMargin: Appearance.padding.small - color: Qt.alpha(DynamicColors.palette.m3secondaryContainer, active ? 1 : 0) - radius: Appearance.rounding.normal - Appearance.padding.small + id: item + + readonly property bool active: modelData === root.active + required property int index + required property MenuItem modelData + + Layout.fillWidth: true + color: Qt.alpha(DynamicColors.palette.m3tertiaryContainer, active ? 1 : 0) + implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.larger * 2 + implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.larger * 2 + radius: Appearance.rounding.small + + Behavior on radius { + Anim { + } + } StateLayer { - function onClicked(): void { + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurface + enabled: root.expanded + + onClicked: { root.itemSelected(item.modelData); root.active = item.modelData; + item.modelData.clicked(); root.expanded = false; } - - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - disabled: !root.expanded - } - } - - RowLayout { - id: menuOptionRow - - anchors.fill: parent - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant - text: item.modelData.icon } - CustomText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - text: item.modelData.text - } + RowLayout { + id: menuOptionRow - Loader { - Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 - visible: active + anchors.fill: parent + anchors.margins: Appearance.padding.larger + spacing: Appearance.spacing.small - sourceComponent: MaterialIcon { - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - text: item.modelData.trailingIcon + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: item.modelData.icon + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurface + text: item.modelData.text + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + asynchronous: true + visible: active + + sourceComponent: MaterialIcon { + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: item.modelData.trailingIcon + } } } } diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 53dc9e3..613e810 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -1,95 +1,198 @@ -import qs.Config import QtQuick +import QtQuick.Shapes +import ZShell +import ZShell.Components +import qs.Helpers +import qs.Config MouseArea { id: root - property color color: DynamicColors.palette.m3onSurface - property bool disabled - property real radius: parent?.radius ?? 0 - property alias rect: hoverLayer + property alias bottomLeftRadius: base.bottomLeftRadius + property alias bottomRightRadius: base.bottomRightRadius + property real circleRadius + property alias color: base.color + readonly property real endRadius: { + const d1 = distSq(0, 0); + const d2 = distSq(width, 0); + const d3 = distSq(0, height); + const d4 = distSq(width, height); + return (Math.sqrt(Math.max(d1, d2, d3, d4)) + (shapeMorph ? 24 : 0)) * 1.3; + } + property real endRadiusAtPress + property bool manualPressOverride + property real pressX: width / 2 + property real pressY: height / 2 + property alias radius: base.radius + readonly property alias rect: base + property bool shapeMorph + property bool showHoverBackground: true + property real stateOpacity: containsMouse ? 0.08 : 0 + property alias topLeftRadius: base.topLeftRadius + property alias topRightRadius: base.topRightRadius - function onClicked(): void { + function clamp(r: real): real { + return Math.max(0, Math.min(r, width / 2, height / 2)); + } + + function distSq(x: real, y: real): real { + return (pressX - x) ** 2 + (pressY - y) ** 2; + } + + function press(x: real, y: real): void { + pressX = x; + pressY = y; + fadeAnim.complete(); + circleRadius = 0; + circle.opacity = 0.1; + rippleAnim.restart(); + endRadiusAtPress = endRadius; } anchors.fill: parent - cursorShape: disabled ? undefined : Qt.PointingHandCursor - enabled: !disabled + cursorShape: !enabled ? undefined : Qt.PointingHandCursor + enabled: parent.enabled && !Visibilities.getForActive().isDrawing hoverEnabled: true - onClicked: event => !disabled && onClicked(event) - onPressed: event => { - if (disabled) - return; - - rippleAnim.x = event.x; - rippleAnim.y = event.y; - - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y))); - - rippleAnim.restart(); + Behavior on stateOpacity { + Anim { + type: Anim.DefaultEffects + } } - SequentialAnimation { + onCircleRadiusChanged: { + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + fadeAnim.start(); + } + onManualPressOverrideChanged: { + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + fadeAnim.start(); + } + onPressed: e => press(e.x, e.y) + onPressedChanged: { + if (!(pressed || manualPressOverride) && !rippleAnim.running && circle.opacity > 0) + fadeAnim.start(); + } + + Anim { 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 { - easing.bezierCurve: MaterialEasing.standardDecel - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - property: "opacity" - target: ripple - to: 0 - } + alwaysRunToEnd: true + duration: Appearance.anim.durations.expressiveSlowEffects * 2 + easing.bezierCurve: Appearance.anim.curves.standard + property: "circleRadius" + target: root + to: root.endRadius } - CustomClippingRect { - id: hoverLayer + Anim { + id: fadeAnim + + property: "opacity" + target: circle + to: 0 + type: Anim.SlowEffects + } + + CustomRect { + id: base anchors.fill: parent - border.pixelAligned: false - color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0) - radius: root.radius + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 + color: DynamicColors.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 + topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 + topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 + // qmllint enable missing-property + } - CustomRect { - id: ripple + Shape { + id: circle - border.pixelAligned: false - color: root.color - opacity: 0 - radius: Appearance.rounding.full + anchors.fill: parent + opacity: 0 + preferredRendererType: Shape.CurveRenderer - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 + ShapePath { + fillColor: base.color + startX: root.clamp(base.topLeftRadius) + startY: 0 + strokeColor: "transparent" + strokeWidth: 0 + + fillGradient: RadialGradient { + centerRadius: root.circleRadius + centerX: root.pressX + centerY: root.pressY + focalX: centerX + focalY: centerY + + GradientStop { + color: Qt.alpha(base.color, 1) + position: 0 + } + + GradientStop { + color: Qt.alpha(base.color, 1) + position: ZUtils.clamp(1 - 0.2 * root.endRadius / root.circleRadius, 0.01, 0.99) + } + + GradientStop { + color: Qt.alpha(base.color, ZUtils.clamp((root.circleRadius / root.endRadius - 0.9) / 0.1, 0, 1)) + position: 1 + } + } + + PathLine { + x: root.width - root.clamp(base.topRightRadius) + y: 0 + } + + PathArc { + radiusX: root.clamp(base.topRightRadius) + radiusY: root.clamp(base.topRightRadius) + relativeX: root.clamp(base.topRightRadius) + relativeY: root.clamp(base.topRightRadius) + } + + PathLine { + x: root.width + y: root.height - root.clamp(base.bottomRightRadius) + } + + PathArc { + radiusX: root.clamp(base.bottomRightRadius) + radiusY: root.clamp(base.bottomRightRadius) + relativeX: -root.clamp(base.bottomRightRadius) + relativeY: root.clamp(base.bottomRightRadius) + } + + PathLine { + x: root.clamp(base.bottomLeftRadius) + y: root.height + } + + PathArc { + radiusX: root.clamp(base.bottomLeftRadius) + radiusY: root.clamp(base.bottomLeftRadius) + relativeX: -root.clamp(base.bottomLeftRadius) + relativeY: -root.clamp(base.bottomLeftRadius) + } + + PathLine { + x: 0 + y: root.clamp(base.topLeftRadius) + } + + PathArc { + radiusX: root.clamp(base.topLeftRadius) + radiusY: root.clamp(base.topLeftRadius) + x: root.clamp(base.topLeftRadius) + y: 0 } } } diff --git a/Components/TextButton.qml b/Components/TextButton.qml new file mode 100644 index 0000000..c016bf1 --- /dev/null +++ b/Components/TextButton.qml @@ -0,0 +1,40 @@ +import QtQuick +import qs.Config + +ButtonBase { + id: root + + readonly property alias label: label + property alias text: label.text + + activeColor: type === TextButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary + activeOnColor: { + if (type === TextButton.Text) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondary; + } + horizontalPadding: Appearance.padding.normal + implicitHeight: label.implicitHeight + verticalPadding * 2 + implicitWidth: label.implicitWidth + horizontalPadding * 2 + inactiveColor: { + if (!isToggle && type === TextButton.Filled) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; + } + inactiveOnColor: { + if (!isToggle && type === TextButton.Filled) + return DynamicColors.palette.m3onPrimary; + if (type === TextButton.Text) + return DynamicColors.palette.m3primary; + return type === TextButton.Filled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer; + } + verticalPadding: Appearance.padding.small + + CustomText { + id: label + + anchors.centerIn: parent + color: root.onColor + font: root.font + } +} diff --git a/Config/Appearance.qml b/Config/Appearance.qml index f78ffc1..7be277e 100644 --- a/Config/Appearance.qml +++ b/Config/Appearance.qml @@ -7,8 +7,6 @@ Singleton { readonly property AppearanceConf.Deform deform: Config.appearance.deform readonly property AppearanceConf.FontStuff font: Config.appearance.font readonly property AppearanceConf.Padding padding: Config.appearance.padding - // Literally just here to shorten accessing stuff :woe: - // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Conf.appearance.xxx` readonly property AppearanceConf.Rounding rounding: Config.appearance.rounding readonly property AppearanceConf.Spacing spacing: Config.appearance.spacing readonly property AppearanceConf.Transparency transparency: Config.appearance.transparency diff --git a/Config/AppearanceConf.qml b/Config/AppearanceConf.qml index 60bab0f..e2f3d07 100644 --- a/Config/AppearanceConf.qml +++ b/Config/AppearanceConf.qml @@ -28,9 +28,13 @@ JsonObject { property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list expressiveDefaultEffects: [0.34, 0.8, 0.34, 1, 1, 1] property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + property list expressiveFastEffects: [0.31, 0.94, 0.34, 1, 1, 1] property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list expressiveSlowEffects: [0.34, 0.88, 0.34, 1, 1, 1] + property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] property list standard: [0.2, 0, 0, 1, 1, 1] property list standardAccel: [0.3, 0, 1, 1, 1, 1] property list standardDecel: [0, 0, 0, 1, 1, 1] @@ -38,7 +42,9 @@ JsonObject { component AnimDurations: JsonObject { property int expressiveDefaultSpatial: 500 * scale property int expressiveEffects: 200 * scale + property int expressiveFastEffects: 150 * scale property int expressiveFastSpatial: 350 * scale + property int expressiveSlowEffects: 300 * scale property int extraLarge: 1000 * scale property int large: 600 * scale property int normal: 400 * scale @@ -57,7 +63,8 @@ JsonObject { component FontSize: JsonObject { property int extraLarge: 28 * scale property int large: 18 * scale - property int larger: 15 * scale + property int larger: 16 * scale + property int medium: 14 * scale property int normal: 13 * scale property real scale: 1 property int small: 11 * scale @@ -70,23 +77,30 @@ JsonObject { } } component Padding: JsonObject { - property int large: 15 * scale - property int larger: 13 * scale - property int normal: 9 * scale + property int extraLarge: 28 * scale + property int extraLargeIncreased: 32 * scale + property int extraSmall: 4 * scale + property int large: 16 * scale + property int largeIncreased: 20 * scale + property int larger: 12 * scale + property int normal: 8 * scale property real scale: 1 property int small: 5 * scale property int smaller: 7 * scale property int smallest: 2 * scale } component Rounding: JsonObject { + property int extraSmall: 4 * scale property int full: 1000 * scale property int large: 24 * scale + property int medium: 16 * scale property int normal: 18 * scale property real scale: 1 property int small: 12 * scale property int smallest: 8 * scale } component Spacing: JsonObject { + property int extraSmall: 4 * scale property int large: 20 * scale property int larger: 16 * scale property int normal: 12 * scale diff --git a/Config/ClipboardConfig.qml b/Config/ClipboardConfig.qml new file mode 100644 index 0000000..4867301 --- /dev/null +++ b/Config/ClipboardConfig.qml @@ -0,0 +1,15 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int maxEntriesShown: 10 + property Sizes sizes: Sizes { + } + + component Sizes: JsonObject { + property int itemHeight: 60 + property int minPreviewWidth: 200 + property int previewWidth: 500 + property int width: 500 + } +} diff --git a/Config/Config.qml b/Config/Config.qml index 272e6b2..44d07da 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -13,6 +13,7 @@ Singleton { property alias appearance: adapter.appearance property alias background: adapter.background property alias barConfig: adapter.barConfig + property alias clipboard: adapter.clipboard property alias colors: adapter.colors property alias dashboard: adapter.dashboard property alias dock: adapter.dock @@ -116,6 +117,19 @@ Singleton { }; } + function serializeClipboard(): var { + return { + enabled: clipboard.enabled, + maxEntriesShown: clipboard.maxEntriesShown, + sizes: { + width: clipboard.sizes.width, + previewWidth: clipboard.sizes.previewWidth, + minPreviewWidth: clipboard.sizes.minPreviewWidth, + itemHeight: clipboard.sizes.itemHeight + } + }; + } + function serializeColors(): var { return { schemeType: colors.schemeType, @@ -143,7 +157,8 @@ Singleton { launcher: serializeLauncher(), colors: serializeColors(), dock: serializeDock(), - screenshot: serializeScreenshot() + screenshot: serializeScreenshot(), + clipboard: serializeClipboard() }; } @@ -173,7 +188,7 @@ Singleton { resourceProgessThickness: dashboard.sizes.resourceProgessThickness, weatherWidth: dashboard.sizes.weatherWidth, mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize, - mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize, + mediaVisualizerSize: dashboard.sizes.mediaVisualizerSize, resourceSize: dashboard.sizes.resourceSize } }; @@ -194,6 +209,7 @@ Singleton { return { logo: general.logo, wallpaperPath: general.wallpaperPath, + showOverFullscreen: general.showOverFullscreen, desktopIcons: general.desktopIcons, dateFormat: general.dateFormat, color: { @@ -315,6 +331,7 @@ Singleton { weatherLocation: services.weatherLocation, updates: services.updates, useFahrenheit: services.useFahrenheit, + minBrightness: services.minBrightness, ddcutilService: services.ddcutilService, useTwelveHourClock: services.useTwelveHourClock, gpuType: services.gpuType, @@ -447,6 +464,8 @@ Singleton { } property BarConfig barConfig: BarConfig { } + property ClipboardConfig clipboard: ClipboardConfig { + } property Colors colors: Colors { } property DashboardConfig dashboard: DashboardConfig { diff --git a/Config/DashboardConfig.qml b/Config/DashboardConfig.qml index 0b6a610..e0286bb 100644 --- a/Config/DashboardConfig.qml +++ b/Config/DashboardConfig.qml @@ -25,7 +25,7 @@ JsonObject { readonly property int mediaCoverArtSize: 150 readonly property int mediaProgressSweep: 180 readonly property int mediaProgressThickness: 8 - readonly property int mediaVisualiserSize: 80 + readonly property int mediaVisualizerSize: 200 readonly property int mediaWidth: 200 readonly property int resourceProgessThickness: 10 readonly property int resourceSize: 200 diff --git a/Config/General.qml b/Config/General.qml index 45d3513..2223ad7 100644 --- a/Config/General.qml +++ b/Config/General.qml @@ -13,11 +13,16 @@ JsonObject { property Idle idle: Idle { } property string logo: "" + property bool showOverFullscreen: true property string wallpaperPath: Quickshell.env("HOME") + "/Pictures/Wallpapers" component Apps: JsonObject { + property list archiver: ["ark"] property list audio: ["pavucontrol"] + property list document: ["libreoffice"] + property list editor: ["kate"] property list explorer: ["dolphin"] + property list image: ["imv"] property list playback: ["mpv"] property list terminal: ["kitty"] } diff --git a/Config/Services.qml b/Config/Services.qml index 4711120..9645d1d 100644 --- a/Config/Services.qml +++ b/Config/Services.qml @@ -8,6 +8,7 @@ JsonObject { property string defaultPlayer: "Spotify" property string gpuType: "" property real maxVolume: 1.0 + property real minBrightness: 0.01 property list playerAliases: [ { "from": "com.github.th_ch.youtube_music", diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 00fa168..34b81ec 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -215,6 +215,10 @@ Singleton { notif.hasActionIcons = notif.notification.hasActionIcons; } + function onHintsChanged(): void { + notif.hints = notif.notification.hints; + } + function onImageChanged(): void { notif.imageSource = notif.notification.image || ""; notif.image = notif.imageSource; @@ -237,6 +241,7 @@ Singleton { } property real expireTimeout: 5 property bool hasActionIcons + property var hints property string image property string imageSource property var locks: new Set() @@ -333,6 +338,7 @@ Singleton { appName = notification.appName; imageSource = notification.image || ""; image = imageSource; + hints = notification.hints; expireTimeout = notification.expireTimeout; urgency = notification.urgency; resident = notification.resident; diff --git a/Drawers/Drawers.qml b/Drawers/Drawers.qml new file mode 100644 index 0000000..b026775 --- /dev/null +++ b/Drawers/Drawers.qml @@ -0,0 +1,25 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Variants { + model: Quickshell.screens + + Scope { + id: scope + + required property ShellScreen modelData + + Exclusions { + bar: content.bar + screen: scope.modelData + } + + Windows { + id: content + + screen: scope.modelData + } + } +} diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index 399b11f..9b45c31 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -1,36 +1,30 @@ -import QtQuick +pragma ComponentBehavior: Bound -Canvas { +import Quickshell +import QtQuick +import ZShell.Internal + +Item { id: root - property color penColor: "white" - property real penWidth: 4 - property var points: [] + readonly property alias content: contentLoader.item + readonly property PersistentProperties drawingState: PersistentProperties { + property color penColor: "white" + property int penWidth: 4 - function clear(): void { - var ctx = getContext('2d'); - root.points = []; - ctx.reset(); - root.requestPaint(); + reloadableId: "drawingState" } + required property PersistentProperties visibilities - renderStrategy: Canvas.Cooperative + Loader { + id: contentLoader - onPaint: { - if (points.length < 2) - return; - var ctx = root.getContext('2d'); - ctx.save(); - ctx.lineWidth = root.penWidth; - ctx.strokeStyle = root.penColor; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - for (var i = 1; i < points.length; i++) - ctx.lineTo(points[i].x, points[i].y); - ctx.stroke(); - points = points.slice(points.length - 2); - ctx.restore(); + active: root.visibilities.isDrawing + anchors.fill: parent + + sourceComponent: StrokeCanvas { + penColor: root.drawingState.penColor + penWidth: root.drawingState.penWidth + } } } diff --git a/Drawers/DrawingInput.qml b/Drawers/DrawingInput.qml deleted file mode 100644 index ffa37b2..0000000 --- a/Drawers/DrawingInput.qml +++ /dev/null @@ -1,60 +0,0 @@ -import Quickshell -import QtQuick -import qs.Components -import qs.Config - -CustomMouseArea { - id: root - - required property var bar - required property Drawing drawing - required property Panels panels - required property var popout - required property PersistentProperties visibilities - - function inLeftPanel(panel: Item, x: real, y: real): bool { - return x < panel.x + panel.width + Config.barConfig.border && withinPanelHeight(panel, x, y); - } - - function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = panel.y + bar.implicitHeight; - return y >= panelY && y <= panelY + panel.height; - } - - acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: z > 0 - hoverEnabled: true - visible: root.visibilities.isDrawing - - onPositionChanged: event => { - const x = event.x; - const y = event.y; - if (root.visibilities.isDrawing && (event.buttons & Qt.LeftButton)) { - root.drawing.points.push(Qt.point(x, y)); - root.drawing.requestPaint(); - return; - } - - if (!(event.buttons & Qt.LeftButton) && root.inLeftPanel(root.popout, x, y)) { - root.z = -2; - root.panels.drawing.expanded = true; - } - } - onPressed: event => { - const x = event.x; - const y = event.y; - - if (root.visibilities.isDrawing && (event.buttons & Qt.LeftButton)) { - root.panels.drawing.expanded = false; - root.drawing.points.push(Qt.point(x, y)); - root.drawing.requestPaint(); - return; - } - - if (event.buttons & Qt.RightButton) - root.drawing.clear(); - } - onReleased: { - root.drawing.points = []; - } -} diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index ab7a246..6051d5a 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -9,9 +9,9 @@ Item { id: root required property Item bar + required property real borderThickness property bool dashboardShortcutActive required property Drawing drawing - required property DrawingInput input property bool osdShortcutActive required property Panels panels required property BarPopouts.Wrapper popouts @@ -48,7 +48,7 @@ Item { } function withinPanelWidth(panel: Item, x: real, y: real): bool { - const panelX = panel.x; + const panelX = panel.x + root.borderThickness; return x >= panelX && x <= panelX + panel.width; } @@ -59,7 +59,8 @@ Item { cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined dragThreshold: 0 - grabPermissions: PointerHandler.CanTakeOverFromHandlersOfDifferentType | PointerHandler.ApprovesTakeOverByAnything + enabled: !root.visibilities.isDrawing + grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything maximumPointCount: 1 minimumPointCount: 1 target: null @@ -74,7 +75,7 @@ Item { const dragX = x - centroid.pressPosition.x; const dragY = y - centroid.pressPosition.y; - if (centroid.pressPosition.y >= root.screen.height - Config.barConfig.border && dragY < -200) + if (centroid.pressPosition.y >= root.screen.height - Config.barConfig.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -200) root.visibilities.launcher = true; if (root.singleGestureTriggered) @@ -90,7 +91,10 @@ Item { } } - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.bar.implicitHeight) + if (centroid.pressPosition.y > root.screen.height - Config.barConfig.border && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) + root.visibilities.clipboard = true; + + if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.bar.implicitHeight && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) if (dragY < -10) { root.visibilities.dock = true; root.singleGestureTriggered = true; @@ -113,9 +117,52 @@ Item { } } + PointHandler { + id: drawingHandler + + 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) + + onActiveChanged: { + if (!active) { + setInitialPoint = false; + root.drawing.content.endStroke(); + } else { + root.panels.drawing.collapse(); + } + } + onPointChanged: { + if (!active) + return; + const x = point.position.x; + const y = point.position.y; + const origX = point.pressPosition.x; + const origY = point.pressPosition.y; + + if (point.pressedButtons & Qt.RightButton) { + root.drawing.content.clear(); + return; + } + if (x === 0 && y === 0 && origX === 0 && origY === 0) + return; + + if (!setInitialPoint) { + setInitialPoint = true; + root.drawing.content.beginStroke(origX, origY); + return; + } + + root.drawing.content.appendPoint(x, y); + } + } + HoverHandler { id: hoverHandler + cursorShape: root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined + onHoveredChanged: { if (!hovered) { if (!root.osdShortcutActive) { @@ -135,9 +182,15 @@ Item { const x = point.position.x; const y = point.position.y; - if (root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, x, y)) { - root.input.z = 2; - root.panels.drawing.expanded = false; + if (root.visibilities.isDrawing) { + if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) { + root.panels.drawing.expand(); + root.drawing.content.hideHover(); + return; + } + + root.drawing.content.showHover(x, y); + return; } if (!root.visibilities.bar && Config.barConfig.autoHide && y < root.bar.implicitHeight) diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 5c84747..d76e533 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -13,17 +13,20 @@ import qs.Modules.Resources as Resources import qs.Modules.Settings as Settings import qs.Modules.Drawing as Drawing import qs.Modules.Dock as Dock +import qs.Modules.Clipboard as Clipboard import qs.Config Item { id: root required property Item bar + required property real borderThickness + readonly property alias clipboard: clipboard readonly property alias dashboard: dashboard readonly property alias dashboardWrapper: dashboardWrapper readonly property alias dock: dock readonly property alias drawing: drawing - required property Canvas drawingItem + required property var drawingItem readonly property alias launcher: launcher readonly property alias notifications: notifications readonly property alias osd: osd @@ -41,7 +44,7 @@ Item { required property PersistentProperties visibilities anchors.fill: parent - anchors.margins: Config.barConfig.border + anchors.margins: borderThickness anchors.topMargin: bar.implicitHeight Item { @@ -97,6 +100,7 @@ Item { id: popouts anchors.top: parent.top + borderThickness: root.borderThickness screen: root.screen } @@ -207,4 +211,13 @@ Item { screen: root.screen visibilities: root.visibilities } + + Clipboard.Wrapper { + id: clipboard + + anchors.bottom: parent.bottom + anchors.left: parent.left + screen: root.screen + visibilities: root.visibilities + } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index f417c43..bfcb512 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -9,397 +9,460 @@ import Quickshell.Hyprland import ZShell.Blobs import qs.Daemons import qs.Components -import qs.Modules import qs.Modules.Bar import qs.Config import qs.Helpers import qs.Drawers -Variants { - model: Quickshell.screens +CustomWindow { + id: root - Scope { - id: scope + readonly property alias bar: bar + readonly property real borderLayoutThickness: hasFullscreen ? 0 : Config.barConfig.border + readonly property real borderRounding: Config.barConfig.rounding * (1 - fsTransitionProg) + readonly property real borderThickness: Config.barConfig.border * (1 - fsTransitionProg) + readonly property int dragMaskPadding: { + if (focusGrab.active) + return 0; - required property var modelData + if (monitor?.lastIpcObject.specialWorkspace?.name || monitor?.activeWorkspace.lastIpcObject.windows > 0) + return 0; - Exclusions { - bar: bar - screen: scope.modelData + return 100; + } + property real fsTransitionProg: hasFullscreen ? 1 : 0 + readonly property bool hasFullscreen: { + if (hasSpecialWorkspace) { + const specialName = monitor?.lastIpcObject.specialWorkspace?.name; + if (!specialName) + return false; + const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); + return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + return hasFullscreenOnNormalWs; + } + readonly property bool hasFullscreenOnNormalWs: monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false + readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0 + readonly property alias interactionWrapper: interactions + readonly property alias menuRegion: menuPopoutRegion + readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) + property var root: Quickshell.shellDir + readonly property real sdfBorderOffset: 2 * fsTransitionProg + readonly property real shadowOpacity: 0.7 * (1 - fsTransitionProg) + property color surfaceColor: DynamicColors.tPalette.m3surface + + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? 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 ? emptyRegion : region) + name: "Bar" + + Behavior on fsTransitionProg { + Anim { + } + } + Behavior on surfaceColor { + CAnim { + } + } + + contentItem.Keys.onEscapePressed: { + if (Config.barConfig.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; + 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; + panels.popouts.hasCurrent = false; + } + + Region { + id: menuPopoutRegion + + intersection: Intersection.Subtract + } + + Region { + id: emptyRegion + + height: panels.notifications.height + width: panels.notifications.width + x: panels.notifications.x + root.borderThickness + y: panels.notifications.y + bar.implicitHeight + } + + Region { + id: region + + height: root.height - bar.implicitHeight - root.borderThickness - root.dragMaskPadding * 2 + intersection: Intersection.Xor + regions: [...popoutRegions.instances, menuPopoutRegion] + width: root.width - root.borderThickness * 2 - root.dragMaskPadding * 2 + x: root.borderThickness + root.dragMaskPadding + y: bar.implicitHeight + root.dragMaskPadding + } + + anchors { + bottom: true + left: true + right: true + top: true + } + + Variants { + id: popoutRegions + + model: panels.children + + Region { + required property Item modelData + + height: modelData.height + intersection: Intersection.Subtract + width: modelData.width + x: modelData.x + root.borderThickness + y: modelData.y + bar.implicitHeight + } + } + + HyprlandFocusGrab { + id: focusGrab + + active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu")) + 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; + 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) + } + + IpcHandler { + function toggleLauncher(fix: string): void { + visibilities.launcher = !visibilities.launcher; } - CustomWindow { - id: win + target: "visibilities" + } - readonly property bool hasFullscreen: Hypr.monitorFor(screen)?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) - property var root: Quickshell.shellDir + Binding { + property: "bar" + target: visibilities + value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.barConfig.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered + when: Config.barConfig.autoHide + } - WlrLayershell.exclusionMode: ExclusionMode.Ignore - // WlrLayershell.keyboardFocus: visibilities.dock || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.resources ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - color: "transparent" - contentItem.focus: true - mask: visibilities.isDrawing ? null : region - name: "Bar" - screen: scope.modelData + Item { + id: surface - contentItem.Keys.onEscapePressed: { - if (Config.barConfig.autoHide) - visibilities.bar = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - } - onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - } + anchors.fill: parent + layer.enabled: true + opacity: root.surfaceColor.a - Region { - id: region + layer.effect: MultiEffect { + blurMax: 32 + shadowColor: Qt.alpha(DynamicColors.palette.m3shadow, Math.max(0, root.shadowOpacity)) + shadowEnabled: true + } - height: win.height - bar.implicitHeight - Config.barConfig.border - intersection: Intersection.Xor - regions: popoutRegions.instances - width: win.width - Config.barConfig.border * 2 - x: Config.barConfig.border - y: bar.implicitHeight - } + BlobGroup { + id: blobGroup - anchors { - bottom: true - left: true - right: true - top: true - } + color: root.surfaceColor + smoothing: Config.barConfig.smoothing + } - Variants { - id: popoutRegions + BlobInvertedRect { + anchors.fill: parent + anchors.margins: -50 + borderBottom: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderLeft: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderRight: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderTop: bar.implicitHeight - anchors.margins - root.sdfBorderOffset + group: blobGroup + radius: root.borderRounding + } - model: panels.children + PanelBg { + id: dashBg - Region { - required property Item modelData + property real extraHeight: 0.2 - height: modelData.height - intersection: Intersection.Subtract - width: modelData.width - x: modelData.x + Config.barConfig.border - y: modelData.y + bar.implicitHeight + deformAmount: 0.06 + implicitHeight: panels.dashboard.height * (1 + extraHeight) + implicitWidth: panels.dashboard.width + panel: panels.dashboardWrapper + radius: Appearance.rounding.normal + x: panels.dashboardWrapper.x + panels.dashboard.x + root.borderThickness + y: panels.dashboardWrapper.y + panels.dashboard.y + bar.implicitHeight - panels.dashboard.height * extraHeight + } + + PanelBg { + id: launcherBg + + property real extraHeight: 0.2 + + deformAmount: 0.06 + implicitHeight: panels.launcher.height * (1 + extraHeight) + panel: panels.launcher + radius: Appearance.rounding.smallest + 5 + y: panels.launcher.y + bar.implicitHeight + } + + PanelBg { + id: sidebarBg + + bottomLeftRadius: 0 + deformAmount: 0.04 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [utilsBg] + implicitHeight: panel.height * (1 / rawDeformMatrix.m22) + 2 + panel: panels.sidebar + } + + PanelBg { + id: osdBg + + deformAmount: 0.1 + implicitHeight: panels.osd.height + implicitWidth: panels.osd.width + panel: panels.osdWrapper + radius: 20 + x: panels.osdWrapper.x + panels.osd.x + root.borderThickness + y: panels.osdWrapper.y + panels.osd.y + bar.implicitHeight + } + + PanelBg { + id: notifsBg + + panel: panels.notifications + radius: Appearance.rounding.normal + } + + PanelBg { + id: utilsBg + + deformAmount: 0.1 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg] + panel: panels.utilities + topLeftRadius: 0 + } + + PanelBg { + id: popoutBg + + property real extraHeight: 0.2 + + deformAmount: panels.popouts.currentName.startsWith("traymenu") ? 0.15 : 0.08 + implicitHeight: panels.popouts.height * (1 + extraHeight) + implicitWidth: panels.popouts.width + panel: panels.popoutsWrapper + radius: panels.popouts.current?.panelRadius ?? Appearance.rounding.normal + x: panels.popoutsWrapper.x + panels.popouts.x + root.borderThickness + y: panels.popoutsWrapper.y + panels.popouts.y + bar.implicitHeight - panels.popouts.height * extraHeight + + Behavior on extraHeight { + Anim { } } + } - HyprlandFocusGrab { - id: focusGrab + PanelBg { + id: resourcesBg - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu")) - windows: [win] + deformAmount: 0.05 + implicitHeight: panels.resources.height + implicitWidth: panels.resources.width + panel: panels.resourcesWrapper + radius: Appearance.rounding.large + x: panels.resourcesWrapper.x + panels.resources.x + root.borderThickness + y: panels.resourcesWrapper.y + panels.resources.y + bar.implicitHeight + } - onCleared: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.dock = false; - panels.popouts.hasCurrent = false; - } + PanelBg { + id: settingsBg + + property real extraHeight: 0.2 + + deformAmount: 0.03 + implicitHeight: panels.settings.height * (1 + extraHeight) + implicitWidth: panels.settings.width + panel: panels.settings + radius: Appearance.rounding.large + topLeftRadius: Appearance.rounding.large + Appearance.padding.smaller + topRightRadius: Appearance.rounding.large + Appearance.padding.smaller + x: panels.settingsWrapper.x + panels.settings.x + root.borderThickness + y: panels.settingsWrapper.y + panels.settings.y + bar.implicitHeight - panels.settings.height * extraHeight + } + + PanelBg { + id: dockBg + + deformAmount: 0.08 + panel: panels.dock + radius: Appearance.rounding.normal + } + + PanelBg { + id: drawingBg + + deformAmount: 0.08 + panel: panels.drawing + radius: Appearance.rounding.normal + } + + PanelBg { + id: clipboardBg + + deformAmount: 0.03 + panel: panels.clipboard + radius: 29 + } + } + + Drawing { + id: drawing + + anchors.fill: parent + layer.enabled: true + visibilities: visibilities + 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 { + id: interactions + + anchors.fill: parent + bar: bar + borderThickness: root.borderLayoutThickness + drawing: drawing + enabled: true + panels: panels + popouts: panels.popouts + screen: root.screen + visibilities: visibilities + + Panels { + id: panels + + bar: bar + borderThickness: root.borderThickness + drawingItem: drawing + screen: root.screen + visibilities: visibilities + + clipboard.transform: Matrix4x4 { + matrix: clipboardBg.deformMatrix } - - PersistentProperties { - id: visibilities - - property bool bar - 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(scope.modelData, this) + dashboard.transform: Matrix4x4 { + matrix: dashBg.deformMatrix } - - IpcHandler { - function toggleLauncher(fix: string): void { - visibilities.launcher = !visibilities.launcher; - } - - target: "visibilities" + dock.transform: Matrix4x4 { + matrix: dockBg.deformMatrix } - - Binding { - property: "bar" - target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.barConfig.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered - when: Config.barConfig.autoHide + launcher.transform: Matrix4x4 { + matrix: launcherBg.deformMatrix } - - Item { - anchors.fill: parent - layer.enabled: true - opacity: Appearance.transparency.enabled ? DynamicColors.transparency.base : 1 - - layer.effect: MultiEffect { - blurMax: 32 - shadowColor: Qt.alpha(DynamicColors.palette.m3shadow, 1) - shadowEnabled: true - } - - BlobGroup { - id: blobGroup - - color: DynamicColors.palette.m3surface - smoothing: Config.barConfig.smoothing - - Behavior on color { - CAnim { - } - } - } - - BlobInvertedRect { - anchors.fill: parent - anchors.margins: -50 - borderBottom: Config.barConfig.border - anchors.margins - borderLeft: Config.barConfig.border - anchors.margins - borderRight: Config.barConfig.border - anchors.margins - borderTop: bar.implicitHeight - anchors.margins - group: blobGroup - radius: Config.barConfig.rounding - } - - PanelBg { - id: dashBg - - property real extraHeight: 0.2 - - deformAmount: 0.06 - implicitHeight: panels.dashboard.height * (1 + extraHeight) - implicitWidth: panels.dashboard.width - panel: panels.dashboardWrapper - radius: Appearance.rounding.normal - x: panels.dashboardWrapper.x + panels.dashboard.x + Config.barConfig.border - y: panels.dashboardWrapper.y + panels.dashboard.y + bar.implicitHeight - panels.dashboard.height * extraHeight - } - - PanelBg { - id: launcherBg - - property real extraHeight: 0.2 - - deformAmount: 0.06 - implicitHeight: panels.launcher.height * (1 + extraHeight) - panel: panels.launcher - radius: Appearance.rounding.smallest + 5 - y: panels.launcher.y + bar.implicitHeight - } - - PanelBg { - id: sidebarBg - - bottomLeftRadius: 0 - deformAmount: 0.04 - exclude: panels.sidebar.offsetScale > 0.08 ? [] : [utilsBg] - implicitHeight: panel.height * (1 / rawDeformMatrix.m22) + 2 - panel: panels.sidebar - } - - PanelBg { - id: osdBg - - deformAmount: 0.1 - implicitHeight: panels.osd.height - implicitWidth: panels.osd.width - panel: panels.osdWrapper - radius: 20 - x: panels.osdWrapper.x + panels.osd.x + Config.barConfig.border - y: panels.osdWrapper.y + panels.osd.y + bar.implicitHeight - } - - PanelBg { - id: notifsBg - - panel: panels.notifications - radius: Appearance.rounding.normal - } - - PanelBg { - id: utilsBg - - deformAmount: panels.sidebar.visible ? (0.1) : (0.1) - exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg] - panel: panels.utilities - topLeftRadius: 0 - } - - PanelBg { - id: popoutBg - - property real extraHeight: panels.popouts.isDetached ? 0 : 0.2 - - deformAmount: panels.popouts.isDetached ? 0.05 : panels.popouts.hasCurrent ? 0.15 : 0.1 - implicitHeight: panels.popouts.height * (1 + extraHeight) - implicitWidth: panels.popouts.width - panel: panels.popoutsWrapper - radius: (panels.popouts.currentName.startsWith("audio") || panels.popouts.currentName.startsWith("updates")) ? Appearance.rounding.normal : 20 * Appearance.rounding.scale - x: panels.popoutsWrapper.x + panels.popouts.x + Config.barConfig.border - y: panels.popoutsWrapper.y + panels.popouts.y + bar.implicitHeight - panels.popouts.height * extraHeight - - Behavior on extraHeight { - Anim { - } - } - } - - PanelBg { - id: resourcesBg - - deformAmount: 0.05 - implicitHeight: panels.resources.height - implicitWidth: panels.resources.width - panel: panels.resourcesWrapper - radius: Appearance.rounding.normal - x: panels.resourcesWrapper.x + panels.resources.x + Config.barConfig.border - y: panels.resourcesWrapper.y + panels.resources.y + bar.implicitHeight - } - - PanelBg { - id: settingsBg - - property real extraHeight: 0.2 - - deformAmount: 0.03 - implicitHeight: panels.settings.height * (1 + extraHeight) - implicitWidth: panels.settings.width - panel: panels.settings - radius: Appearance.rounding.large - topLeftRadius: Appearance.rounding.large + Appearance.padding.smaller - topRightRadius: Appearance.rounding.large + Appearance.padding.smaller - x: panels.settingsWrapper.x + panels.settings.x + Config.barConfig.border - y: panels.settingsWrapper.y + panels.settings.y + bar.implicitHeight - panels.settings.height * extraHeight - } - - PanelBg { - id: dockBg - - deformAmount: 0.08 - panel: panels.dock - radius: Appearance.rounding.normal - } - - PanelBg { - id: drawingBg - - deformAmount: 0.08 - panel: panels.drawing - radius: Appearance.rounding.normal - } + notifications.transform: Matrix4x4 { + matrix: notifsBg.deformMatrix } - - Loader { - id: drawingLoader - - active: visibilities.isDrawing - anchors.fill: parent - z: 2 - - sourceComponent: Drawing { - id: drawing - } + osd.transform: Matrix4x4 { + matrix: osdBg.deformMatrix } - - Loader { - id: inputLoader - - active: visibilities.isDrawing - anchors.fill: parent - z: 2 - - sourceComponent: DrawingInput { - id: input - - bar: bar - drawing: drawingLoader.item - panels: panels - popout: panels.drawing - visibilities: visibilities - } + popouts.transform: Matrix4x4 { + matrix: popoutBg.deformMatrix } - - Interactions { - id: mouseArea - - anchors.fill: parent - bar: bar - drawing: drawingLoader.item - enabled: true - input: inputLoader.item - panels: panels - popouts: panels.popouts - screen: scope.modelData - visibilities: visibilities - z: 1 - - Panels { - id: panels - - bar: bar - drawingItem: drawingLoader.item - screen: scope.modelData - visibilities: visibilities - - dashboard.transform: Matrix4x4 { - matrix: dashBg.deformMatrix - } - dock.transform: Matrix4x4 { - matrix: dockBg.deformMatrix - } - launcher.transform: Matrix4x4 { - matrix: launcherBg.deformMatrix - } - notifications.transform: Matrix4x4 { - matrix: notifsBg.deformMatrix - } - osd.transform: Matrix4x4 { - matrix: osdBg.deformMatrix - } - popouts.transform: Matrix4x4 { - matrix: popoutBg.deformMatrix - } - resources.transform: Matrix4x4 { - matrix: resourcesBg.deformMatrix - } - settings.transform: Matrix4x4 { - matrix: settingsBg.deformMatrix - } - sidebar.transform: Matrix4x4 { - matrix: sidebarBg.deformMatrix - } - utilities.transform: Matrix4x4 { - matrix: utilsBg.deformMatrix - } - } - - BarLoader { - id: bar - - anchors.left: parent.left - anchors.right: parent.right - popouts: panels.popouts - popoutsWrapper: panels.popoutsWrapper - screen: scope.modelData - visibilities: visibilities - } + resources.transform: Matrix4x4 { + matrix: resourcesBg.deformMatrix } + settings.transform: Matrix4x4 { + matrix: settingsBg.deformMatrix + } + sidebar.transform: Matrix4x4 { + matrix: sidebarBg.deformMatrix + } + utilities.transform: Matrix4x4 { + matrix: utilsBg.deformMatrix + } + } + + BarLoader { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + enabled: !visibilities.isDrawing + fullscreen: root.hasFullscreen + popouts: panels.popouts + popoutsWrapper: panels.popoutsWrapper + screen: root.screen + visibilities: visibilities } } @@ -412,7 +475,7 @@ Variants { implicitHeight: panel.height implicitWidth: panel.width radius: Appearance.rounding.smallest - x: panel.x + Config.barConfig.border + x: panel.x + root.borderThickness y: panel.y + bar.implicitHeight } } diff --git a/Greeter/Center.qml b/Greeter/Center.qml index 3c9711a..713aa5f 100644 --- a/Greeter/Center.qml +++ b/Greeter/Center.qml @@ -118,12 +118,12 @@ ColumnLayout { } StateLayer { - function onClicked(): void { - parent.forceActiveFocus(); - } - cursorShape: Qt.IBeamCursor hoverEnabled: false + + onClicked: { + parent.forceActiveFocus(); + } } RowLayout { @@ -179,11 +179,11 @@ ColumnLayout { radius: Appearance.rounding.full StateLayer { - function onClicked(): void { + color: root.greeter.buffer && !root.greeter.launching ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + + onClicked: { root.greeter.submit(); } - - color: root.greeter.buffer && !root.greeter.launching ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface } MaterialIcon { diff --git a/Greeter/Components/Anim.qml b/Greeter/Components/Anim.qml index 242354f..0b02612 100644 --- a/Greeter/Components/Anim.qml +++ b/Greeter/Components/Anim.qml @@ -2,7 +2,62 @@ import QtQuick import qs.Config NumberAnimation { - duration: MaterialEasing.standardTime - easing.bezierCurve: MaterialEasing.standard - easing.type: Easing.BezierSpline + enum Type { + StandardSmall = 0, + Standard, + StandardLarge, + StandardExtraLarge, + EmphasizedSmall, + Emphasized, + EmphasizedLarge, + EmphasizedExtraLarge, + FastSpatial, + DefaultSpatial, + SlowSpatial, + FastEffects, + DefaultEffects, + SlowEffects + } + + property int type: Anim.DefaultSpatial + + duration: { + if (type < Anim.StandardSmall || type > Anim.SlowEffects) + return Appearance.anim.durations.normal; + + if (type === Anim.FastSpatial) + return Appearance.anim.durations.expressiveFastSpatial; + if (type === Anim.DefaultSpatial) + return Appearance.anim.durations.expressiveDefaultSpatial; + if (type === Anim.SlowSpatial) + return Appearance.anim.durations.large; + if (type === Anim.FastEffects) + return Appearance.anim.durations.expressiveFastEffects; + if (type === Anim.DefaultEffects) + return Appearance.anim.durations.expressiveEffects; + if (type === Anim.SlowEffects) + return Appearance.anim.durations.expressiveSlowEffects; + + const types = ["small", "normal", "large", "extraLarge"]; + const idx = type % 4; // 0-7 are the 4 standard types + return Appearance.anim.durations[types[idx]]; + } + easing.bezierCurve: { + if (type === Anim.FastSpatial) + return Appearance.anim.curves.expressiveFastSpatial; + if (type === Anim.DefaultSpatial) + return Appearance.anim.curves.expressiveDefaultSpatial; + if (type === Anim.SlowSpatial) + return Appearance.anim.curves.expressiveSlowSpatial; + if (type === Anim.FastEffects) + return Appearance.anim.curves.expressiveFastEffects; + if (type === Anim.DefaultEffects) + return Appearance.anim.curves.expressiveDefaultEffects; + if (type === Anim.SlowEffects) + return Appearance.anim.curves.expressiveSlowEffects; + + if (type >= Anim.EmphasizedSmall && type <= Anim.EmphasizedExtraLarge) + return Appearance.anim.curves.emphasized; + return Appearance.anim.curves.standard; + } } diff --git a/Greeter/Components/CollapsibleSection.qml b/Greeter/Components/CollapsibleSection.qml index 6d6fc3e..46e4753 100644 --- a/Greeter/Components/CollapsibleSection.qml +++ b/Greeter/Components/CollapsibleSection.qml @@ -59,15 +59,15 @@ ColumnLayout { } StateLayer { - function onClicked(): void { - root.toggleRequested(); - root.expanded = !root.expanded; - } - anchors.fill: parent color: DynamicColors.palette.m3onSurface radius: Appearance.rounding.normal showHoverBackground: false + + onClicked: { + root.toggleRequested(); + root.expanded = !root.expanded; + } } } diff --git a/Greeter/Components/CustomButton.qml b/Greeter/Components/CustomButton.qml index 4b572bd..b98710f 100644 --- a/Greeter/Components/CustomButton.qml +++ b/Greeter/Components/CustomButton.qml @@ -23,10 +23,10 @@ Button { } StateLayer { - function onClicked(): void { + radius: control.radius + + onClicked: { control.clicked(); } - - radius: control.radius } } diff --git a/Greeter/Components/CustomRadioButton.qml b/Greeter/Components/CustomRadioButton.qml index 481268b..67204d1 100644 --- a/Greeter/Components/CustomRadioButton.qml +++ b/Greeter/Components/CustomRadioButton.qml @@ -33,13 +33,13 @@ RadioButton { } StateLayer { - function onClicked(): void { - root.click(); - } - anchors.margins: -7 color: root.checked ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3primary z: -1 + + onClicked: { + root.click(); + } } CustomRect { diff --git a/Greeter/Components/CustomSlider.qml b/Greeter/Components/CustomSlider.qml index 32c8f69..3041698 100644 --- a/Greeter/Components/CustomSlider.qml +++ b/Greeter/Components/CustomSlider.qml @@ -1,45 +1,174 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Templates +import ZShell.Components +import ZShell +import qs.Components import qs.Config Slider { id: root - background: Item { + property bool animateWave + property color bgColor: enabled ? DynamicColors.palette.m3secondaryContainer : Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color fgColor: enabled ? DynamicColors.palette.m3primary : Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property real filledWidth + property real pos: visualPosition + property int waveDuration: 1000 + property real waveFrequency: 6 + property bool wavy + + signal interaction(v: real) + + implicitHeight: 12 + implicitWidth: 200 + + contentItem: Item { + anchors.fill: parent + CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - bottomRightRadius: root.implicitHeight / 6 - color: DynamicColors.palette.m3primary - implicitWidth: root.handle.x - root.implicitHeight / 2 - radius: Appearance.rounding.full - topRightRadius: root.implicitHeight / 6 + id: remaining + + anchors.left: handle.right + anchors.leftMargin: Appearance.spacing.extraSmall + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + bottomLeftRadius: Appearance.rounding.extraSmall / 2 + color: root.bgColor + implicitHeight: parent.height * (parent.height <= 12 ? opacity : Math.min(opacity * 2, 1)) + opacity: Math.min(width, 12) / 12 + radius: Appearance.rounding.small + topLeftRadius: Appearance.rounding.extraSmall / 2 } CustomRect { - anchors.bottom: parent.bottom anchors.right: parent.right - anchors.top: parent.top - bottomLeftRadius: root.implicitHeight / 6 - color: DynamicColors.tPalette.m3surfaceContainer - implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 2 + anchors.rightMargin: 4 * remaining.opacity + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: 4 * remaining.opacity + implicitWidth: implicitHeight + opacity: remaining.opacity radius: Appearance.rounding.full - topLeftRadius: root.implicitHeight / 6 + } + + CustomRect { + id: handle + + anchors.left: filled.right + anchors.leftMargin: Appearance.spacing.extraSmall + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: { + const mult = parent.height <= 12 ? 3 : 1.2; + const pressMult = parent.height <= 12 ? 4 : 1.5; + return parent.height * (mouse.pressed ? pressMult : mult); + } + implicitWidth: 4 + radius: Appearance.rounding.full + + Behavior on implicitHeight { + Anim { + type: Anim.FastSpatial + } + } + } + + Loader { + id: filled + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + sourceComponent: root.wavy ? waveComp : lineComp + } + + Component { + id: lineComp + + CustomRect { + bottomRightRadius: Appearance.rounding.extraSmall / 2 + color: root.fgColor + implicitHeight: root.height + implicitWidth: root.filledWidth + radius: Appearance.rounding.small + topRightRadius: Appearance.rounding.extraSmall / 2 + } + } + + Component { + id: waveComp + + WavyLine { + color: root.fgColor + frequency: root.waveFrequency + fullLength: root.width - handle.implicitWidth - handle.anchors.leftMargin + implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth + implicitWidth: root.filledWidth + lineWidth: root.height * 0.7 + startX: x + + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + duration: root.waveDuration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: !root.animateWave + running: true + to: 1 + } + } } } - handle: CustomRect { - anchors.verticalCenter: parent.verticalCenter - color: DynamicColors.palette.m3primary - implicitHeight: 15 - implicitWidth: 5 - radius: Appearance.rounding.full - x: root.visualPosition * root.availableWidth - implicitWidth / 2 + Behavior on filledWidth { + id: widthBehavior - MouseArea { - acceptedButtons: Qt.NoButton - anchors.fill: parent - cursorShape: Qt.PointingHandCursor + Anim { + } + } + + Component.onCompleted: filledWidth = Qt.binding(() => (width - handle.implicitWidth - handle.anchors.leftMargin) * pos) + + Binding { + id: posBinding + + property: "pos" + target: root + value: ZUtils.clamp(mouse.pressStartPos + mouse.dragMovement, 0, 1) + when: mouse.pressed + } + + MouseArea { + id: mouse + + property real dragMovement + property real pressStartPos + property real pressStartX + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: handle.implicitHeight + preventStealing: true + + onPositionChanged: e => { + dragMovement = (e.x - pressStartX) / width; + root.interaction(posBinding.value); + } + onPressed: e => { + widthBehavior.enabled = false; + pressStartX = e.x; + pressStartPos = root.visualPosition; + } + onReleased: e => { + root.interaction(posBinding.value); + widthBehavior.enabled = true; + dragMovement = 0; } } } diff --git a/Greeter/Components/CustomSpinBox.qml b/Greeter/Components/CustomSpinBox.qml index 5a4245b..7526242 100644 --- a/Greeter/Components/CustomSpinBox.qml +++ b/Greeter/Components/CustomSpinBox.qml @@ -28,6 +28,7 @@ RowLayout { CustomTextField { id: textField + color: root.enabled ? DynamicColors.palette.m3onSurface : Qt.alpha(DynamicColors.palette.m3onSurface, 0.5) implicitHeight: upButton.implicitHeight inputMethodHints: Qt.ImhFormattedNumbersOnly leftPadding: Appearance.padding.normal @@ -36,7 +37,7 @@ RowLayout { text: root.isEditing ? text : root.displayText background: CustomRect { - color: DynamicColors.tPalette.m3surfaceContainerHigh + color: root.enabled ? DynamicColors.tPalette.m3surfaceContainerHigh : DynamicColors.tPalette.m3surfaceContainerLow implicitWidth: 100 radius: Appearance.rounding.full } @@ -85,7 +86,7 @@ RowLayout { CustomRect { id: upButton - color: DynamicColors.palette.m3primary + color: root.enabled ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 1) implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 implicitWidth: implicitHeight radius: Appearance.rounding.full @@ -93,7 +94,9 @@ RowLayout { StateLayer { id: upState - function onClicked(): void { + color: DynamicColors.palette.m3onPrimary + + onClicked: { let newValue = Math.min(root.max, root.value + root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; @@ -102,9 +105,6 @@ RowLayout { root.displayText = newValue.toString(); root.valueModified(newValue); } - - color: DynamicColors.palette.m3onPrimary - onPressAndHold: timer.start() onReleased: timer.stop() } @@ -113,13 +113,13 @@ RowLayout { id: upIcon anchors.centerIn: parent - color: DynamicColors.palette.m3onPrimary + color: root.enabled ? DynamicColors.palette.m3onPrimary : Qt.alpha(DynamicColors.palette.m3onSurface, 0.5) text: "keyboard_arrow_up" } } CustomRect { - color: DynamicColors.palette.m3primary + color: root.enabled ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 1) implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2 implicitWidth: implicitHeight radius: Appearance.rounding.full @@ -127,7 +127,9 @@ RowLayout { StateLayer { id: downState - function onClicked(): void { + color: DynamicColors.palette.m3onPrimary + + onClicked: { let newValue = Math.max(root.min, root.value - root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; @@ -136,9 +138,6 @@ RowLayout { root.displayText = newValue.toString(); root.valueModified(newValue); } - - color: DynamicColors.palette.m3onPrimary - onPressAndHold: timer.start() onReleased: timer.stop() } @@ -147,7 +146,7 @@ RowLayout { id: downIcon anchors.centerIn: parent - color: DynamicColors.palette.m3onPrimary + color: root.enabled ? DynamicColors.palette.m3onPrimary : Qt.alpha(DynamicColors.palette.m3onSurface, 0.5) text: "keyboard_arrow_down" } } diff --git a/Greeter/Components/CustomSplitButton.qml b/Greeter/Components/CustomSplitButton.qml index 6f9ad95..76364cc 100644 --- a/Greeter/Components/CustomSplitButton.qml +++ b/Greeter/Components/CustomSplitButton.qml @@ -1,7 +1,6 @@ import QtQuick import QtQuick.Layouts import qs.Config -import qs.Helpers Row { id: root @@ -12,66 +11,46 @@ Row { } property alias active: menu.active - property color color: type == CustomSplitButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondaryContainer + property color colour: type == CustomSplitButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondaryContainer property bool disabled - property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) - property color disabledTextColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property color disabledColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color disabledTextColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + readonly property alias expandBtn: expandBtn property alias expanded: menu.expanded property string fallbackIcon property string fallbackText - property real horizontalPadding: Appearance.padding.normal - property alias iconLabel: iconLabel - property alias label: label - property alias menu: menu + property real horizontalPadding: Appearance.padding.larger + readonly property alias iconLabel: iconLabel + readonly property alias label: label + readonly property alias menu: menu property alias menuItems: menu.items property bool menuOnTop - property alias stateLayer: stateLayer - property color textColor: type == CustomSplitButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondaryContainer + property real minLeftWidth + readonly property alias stateLayer: stateLayer + property color textColour: type == CustomSplitButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondaryContainer + readonly property alias textRow: textRow property int type: CustomSplitButton.Filled - property real verticalPadding: Appearance.padding.smaller + property real verticalPadding: Appearance.padding.small - function closeDropdown(): void { - SettingsDropdowns.close(menu); - } - - function openDropdown(): void { - if (root.disabled) - return; - SettingsDropdowns.open(menu, root); - } - - function toggleDropdown(): void { - if (root.disabled) - return; - SettingsDropdowns.toggle(menu, root); - } - - spacing: Math.floor(Appearance.spacing.small / 2) - - onExpandedChanged: { - if (!expanded) - SettingsDropdowns.forget(menu); - } + spacing: Math.floor(Appearance.spacing.extraSmall) CustomRect { bottomRightRadius: Appearance.rounding.small / 2 - color: root.disabled ? root.disabledColor : root.color + color: root.disabled ? root.disabledColour : root.colour implicitHeight: expandBtn.implicitHeight - implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 + implicitWidth: Math.max(root.minLeftWidth, textRow.implicitWidth + root.horizontalPadding * 2) radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) topRightRadius: Appearance.rounding.small / 2 StateLayer { id: stateLayer - function onClicked(): void { - root.active?.clicked(); - } - - color: root.textColor + bottomRightRadius: parent.bottomRightRadius + color: root.textColour disabled: root.disabled - rect.bottomRightRadius: parent.bottomRightRadius - rect.topRightRadius: parent.topRightRadius + topRightRadius: parent.topRightRadius + + onClicked: root.active?.clicked() } RowLayout { @@ -86,7 +65,7 @@ Row { Layout.alignment: Qt.AlignVCenter animate: true - color: root.disabled ? root.disabledTextColor : root.textColor + color: root.disabled ? root.disabledTextColour : root.textColour fill: 1 text: root.active?.activeIcon ?? root.fallbackIcon } @@ -98,12 +77,12 @@ Row { Layout.preferredWidth: implicitWidth animate: true clip: true - color: root.disabled ? root.disabledTextColor : root.textColor + color: root.disabled ? root.disabledTextColour : root.textColour text: root.active?.activeText ?? root.fallbackText Behavior on Layout.preferredWidth { Anim { - easing.bezierCurve: Appearance.anim.curves.emphasized + type: Anim.Emphasized } } } @@ -116,7 +95,7 @@ Row { property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2 bottomLeftRadius: rad - color: root.disabled ? root.disabledColor : root.color + color: root.disabled ? root.disabledColour : root.colour implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2 implicitWidth: implicitHeight radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) @@ -130,14 +109,12 @@ Row { StateLayer { id: expandStateLayer - function onClicked(): void { - root.toggleDropdown(); - } - - color: root.textColor + color: root.textColour disabled: root.disabled rect.bottomLeftRadius: parent.bottomLeftRadius rect.topLeftRadius: parent.topLeftRadius + + onClicked: root.expanded = !root.expanded } MaterialIcon { @@ -145,7 +122,7 @@ Row { anchors.centerIn: parent anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4) - color: root.disabled ? root.disabledTextColor : root.textColor + color: root.disabled ? root.disabledTextColour : root.textColour rotation: root.expanded ? 180 : 0 text: "expand_more" @@ -158,24 +135,14 @@ Row { } } } + } - Menu { - id: menu + Menu { + id: menu - anchors.bottomMargin: Appearance.spacing.small - anchors.right: parent.right - anchors.top: parent.bottom - anchors.topMargin: Appearance.spacing.small - - states: State { - when: root.menuOnTop - - AnchorChanges { - anchors.bottom: expandBtn.top - anchors.top: undefined - target: menu - } - } - } + attachSideY: root.menuOnTop ? Menu.Top : Menu.Bottom + attachTo: expandBtn + marginY: Appearance.spacing.small * (root.menuOnTop ? -1 : 1) + thisSideY: root.menuOnTop ? Menu.Bottom : Menu.Top } } diff --git a/Greeter/Components/CustomSplitButtonRow.qml b/Greeter/Components/CustomSplitButtonRow.qml index 491a8ed..11f0c3b 100644 --- a/Greeter/Components/CustomSplitButtonRow.qml +++ b/Greeter/Components/CustomSplitButtonRow.qml @@ -8,19 +8,32 @@ Item { id: root property alias active: splitButton.active - property bool enabled: true + property alias buttonAlias: splitButton property alias expanded: splitButton.expanded property int expandedZ: 100 required property string label property alias menuItems: splitButton.menuItems + property bool shouldBeActive: true property alias type: splitButton.type signal selected(item: MenuItem) - Layout.fillWidth: true - Layout.preferredHeight: row.implicitHeight + Appearance.padding.smaller * 2 + anchors.left: parent.left + anchors.right: parent.right clip: false - z: root.expanded ? expandedZ : -1 + implicitHeight: row.implicitHeight + Appearance.padding.smaller * 2 + opacity: shouldBeActive ? 1 : 0 + scale: shouldBeActive ? 1 : 0.8 + z: splitButton.menu.implicitHeight > 0 ? expandedZ : 1 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } RowLayout { id: row @@ -36,7 +49,6 @@ Item { color: root.enabled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.larger text: root.label - z: root.expanded ? root.expandedZ : -1 } CustomSplitButton { @@ -44,14 +56,13 @@ Item { enabled: root.enabled type: CustomSplitButton.Filled - z: root.expanded ? root.expandedZ : -1 + z: 2 menu.onItemSelected: item => { root.selected(item); - splitButton.closeDropdown(); } stateLayer.onClicked: { - splitButton.toggleDropdown(); + splitButton.expanded = !splitButton.expanded; } } } diff --git a/Greeter/Components/CustomSwitch.qml b/Greeter/Components/CustomSwitch.qml index aa6e069..e715f15 100644 --- a/Greeter/Components/CustomSwitch.qml +++ b/Greeter/Components/CustomSwitch.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Templates import QtQuick.Shapes +import QtQuick.Templates import qs.Config Switch { @@ -12,38 +12,41 @@ Switch { implicitWidth: implicitIndicatorWidth indicator: CustomRect { - color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, root.cLayer) - implicitHeight: 13 + 7 * 2 + color: root.checked && root.enabled ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, root.cLayer) + implicitHeight: Appearance.font.size.medium + Appearance.padding.normal * 2 implicitWidth: implicitHeight * 1.7 radius: Appearance.rounding.full CustomRect { - readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.2 : implicitHeight anchors.verticalCenter: parent.verticalCenter - color: root.checked ? DynamicColors.palette.m3onPrimary : DynamicColors.layer(DynamicColors.palette.m3outline, root.cLayer + 1) - implicitHeight: parent.implicitHeight - 10 + color: root.checked && root.enabled ? DynamicColors.palette.m3onPrimary : DynamicColors.layer(DynamicColors.palette.m3outline, root.cLayer + 1) + implicitHeight: parent.implicitHeight - Appearance.padding.extraSmall implicitWidth: nonAnimWidth radius: Appearance.rounding.full - x: root.checked ? parent.implicitWidth - nonAnimWidth - 10 / 2 : 10 / 2 + x: root.checked ? parent.implicitWidth - nonAnimWidth - Appearance.padding.extraSmall / 2 : Appearance.padding.extraSmall / 2 Behavior on implicitWidth { Anim { + type: Anim.FastSpatial } } Behavior on x { Anim { + type: Anim.FastSpatial } } CustomRect { anchors.fill: parent - color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + color: root.checked && root.enabled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0 radius: parent.radius Behavior on opacity { Anim { + type: Anim.DefaultEffects } } } @@ -63,14 +66,14 @@ Switch { } property point end2: { if (root.pressed) - return Qt.point(width, height / 2); + return Qt.point(width * 0.8, height / 2); if (root.checked) return Qt.point(width * 0.85, height * 0.2); return Qt.point(width * 0.85, height * 0.15); } property point start1: { if (root.pressed) - return Qt.point(width * 0.1, height / 2); + return Qt.point(width * 0.2, height / 2); if (root.checked) return Qt.point(width * 0.15, height / 2); return Qt.point(width * 0.15, height * 0.15); @@ -88,7 +91,7 @@ Switch { anchors.centerIn: parent asynchronous: true - height: parent.implicitHeight - Appearance.padding.small * 2 + height: parent.implicitHeight - Appearance.padding.larger preferredRendererType: Shape.CurveRenderer width: height @@ -110,11 +113,11 @@ Switch { } ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + capStyle: ShapePath.RoundCap fillColor: "transparent" startX: icon.start1.x startY: icon.start1.y - strokeColor: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3surfaceContainerHighest + strokeColor: root.checked && root.enabled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3surfaceContainerHighest strokeWidth: Appearance.font.size.larger * 0.15 Behavior on strokeColor { @@ -148,8 +151,7 @@ Switch { } component PropAnim: PropertyAnimation { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - easing.type: Easing.BezierSpline + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial } } diff --git a/Greeter/Components/CustomText.qml b/Greeter/Components/CustomText.qml index cff8b2d..f4c1600 100644 --- a/Greeter/Components/CustomText.qml +++ b/Greeter/Components/CustomText.qml @@ -15,6 +15,7 @@ Text { color: DynamicColors.palette.m3onSurface font.family: Appearance.font.family.sans font.pointSize: Appearance.font.size.normal + linkColor: DynamicColors.palette.m3onPrimaryFixedVariant renderType: Text.NativeRendering textFormat: Text.PlainText diff --git a/Greeter/Components/Elevation.qml b/Greeter/Components/Elevation.qml index 26b8fe6..bdef51e 100644 --- a/Greeter/Components/Elevation.qml +++ b/Greeter/Components/Elevation.qml @@ -1,6 +1,6 @@ -import qs.Config import QtQuick import QtQuick.Effects +import qs.Config RectangularShadow { property real dp: [0, 1, 3, 6, 8, 12][level] @@ -13,6 +13,7 @@ RectangularShadow { Behavior on dp { Anim { + type: Anim.SlowEffects } } } diff --git a/Greeter/Components/HoverIconButton.qml b/Greeter/Components/HoverIconButton.qml new file mode 100644 index 0000000..cd17ad0 --- /dev/null +++ b/Greeter/Components/HoverIconButton.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Controls +import qs.Config + +IconButton { + id: root + + required property bool shouldBeVisible + + opacity: 0 + scale: 0 + visible: root.scale > 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + Behavior on scale { + Anim { + } + } + + onShouldBeVisibleChanged: { + if (root.shouldBeVisible) { + root.opacity = 1; + root.scale = 1; + } else { + root.opacity = 0; + root.scale = 0; + } + } +} diff --git a/Greeter/Components/IconButton.qml b/Greeter/Components/IconButton.qml index e55289a..22314f7 100644 --- a/Greeter/Components/IconButton.qml +++ b/Greeter/Components/IconButton.qml @@ -41,12 +41,11 @@ CustomRect { color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour implicitHeight: label.implicitHeight + padding * 2 implicitWidth: implicitHeight - radius: internalChecked ? 6 : implicitHeight / 2 * Math.min(1, 1) + radius: internalChecked ? 6 : (implicitHeight / 2 * Math.min(1, 1)) * Appearance.rounding.scale Behavior on radius { Anim { id: radiusAnim - } } @@ -55,14 +54,14 @@ CustomRect { StateLayer { id: stateLayer - function onClicked(): void { + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + disabled: root.disabled + + onClicked: { if (root.toggle) root.internalChecked = !root.internalChecked; root.clicked(); } - - color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour - disabled: root.disabled } MaterialIcon { diff --git a/Greeter/Components/MarqueeText.qml b/Greeter/Components/MarqueeText.qml index 925b409..427d381 100644 --- a/Greeter/Components/MarqueeText.qml +++ b/Greeter/Components/MarqueeText.qml @@ -102,6 +102,7 @@ Item { animate: root.animate animateProp: "opacity" color: root.color + font.pointSize: elideText.font.pointSize text: elideText.text } @@ -111,6 +112,7 @@ Item { animate: root.animate animateProp: "opacity" color: root.color + font.pointSize: elideText.font.pointSize text: t1.text x: t1.width + root.gap } diff --git a/Greeter/Components/Menu.qml b/Greeter/Components/Menu.qml index 8222359..a633a9c 100644 --- a/Greeter/Components/Menu.qml +++ b/Greeter/Components/Menu.qml @@ -2,109 +2,169 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Quickshell import qs.Config +import qs.Drawers -Elevation { +MouseArea { id: root + enum Side { + Top, + Bottom, + Left, + Right + } + property MenuItem active: items[0] ?? null + property int attachSideX: Menu.Right + property int attachSideY: Menu.Bottom + required property Item attachTo property bool expanded property list items + property real marginX + property real marginY + property int thisSideX: Menu.Right + property int thisSideY: Menu.Top signal itemSelected(item: MenuItem) - implicitHeight: root.expanded ? column.implicitHeight + Appearance.padding.small * 2 : 0 - implicitWidth: Math.max(200, column.implicitWidth) - level: 2 - opacity: root.expanded ? 1 : 0 - radius: Appearance.rounding.normal - - Behavior on implicitHeight { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } + anchors.fill: parent + enabled: expanded + layer.enabled: opacity < 1 + opacity: expanded ? 1 : 0 + parent: { + const win = QsWindow.window; + const contentWin = win as Windows; + return contentWin ? contentWin.interactionWrapper : (win as QsWindow).contentItem; } + Behavior on opacity { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial + type: Anim.DefaultEffects } } - CustomClippingRect { - anchors.fill: parent - color: DynamicColors.palette.m3surfaceContainer - radius: parent.radius + onClicked: expanded = false - ColumnLayout { - id: column + TransformWatcher { + id: watcher - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 5 + a: root.parent + b: root.attachTo + } - Repeater { - model: root.items + Elevation { + id: menu - CustomRect { - id: item + implicitHeight: column.implicitHeight + column.anchors.margins * 2 + implicitWidth: Math.max(200, column.implicitWidth + column.anchors.margins * 2) + level: 2 + radius: Appearance.rounding.medium + x: { + watcher.transform; + const item = root.attachTo; + let off = root.attachSideX === Menu.Left ? 0 : item.width; + if (root.thisSideX === Menu.Right) + off -= width; + return item.mapToItem(root.parent, off, 0).x + root.marginX; + } + y: { + watcher.transform; + const item = root.attachTo; + let off = root.attachSideY === Menu.Top ? 0 : item.height; + if (root.thisSideY === Menu.Bottom) + off -= height; + return item.mapToItem(root.parent, 0, off).y + root.marginY; + } - readonly property bool active: modelData === root.active - required property int index - required property MenuItem modelData + transform: Scale { + origin.y: root.thisSideY === Menu.Bottom ? menu.height : 0 + yScale: root.expanded ? 1 : 0.1 - Layout.fillWidth: true - implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 - implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 + Behavior on yScale { + Anim { + } + } + } + + CustomRect { + anchors.fill: parent + color: DynamicColors.palette.m3surfaceContainerLow + radius: parent.radius + + ColumnLayout { + id: column + + anchors.fill: parent + anchors.margins: Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + Repeater { + id: repeater + + model: root.items CustomRect { - anchors.fill: parent - anchors.leftMargin: Appearance.padding.small - anchors.rightMargin: Appearance.padding.small - color: Qt.alpha(DynamicColors.palette.m3secondaryContainer, active ? 1 : 0) - radius: Appearance.rounding.normal - Appearance.padding.small + id: item + + readonly property bool active: modelData === root.active + required property int index + required property MenuItem modelData + + Layout.fillWidth: true + color: Qt.alpha(DynamicColors.palette.m3tertiaryContainer, active ? 1 : 0) + implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.larger * 2 + implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.larger * 2 + radius: Appearance.rounding.small + + Behavior on radius { + Anim { + } + } StateLayer { - function onClicked(): void { + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurface + disabled: !root.expanded + + onClicked: { root.itemSelected(item.modelData); root.active = item.modelData; + item.modelData.clicked(); root.expanded = false; } - - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - disabled: !root.expanded - } - } - - RowLayout { - id: menuOptionRow - - anchors.fill: parent - anchors.margins: Appearance.padding.normal - spacing: Appearance.spacing.small - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant - text: item.modelData.icon } - CustomText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - text: item.modelData.text - } + RowLayout { + id: menuOptionRow - Loader { - Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 - visible: active + anchors.fill: parent + anchors.margins: Appearance.padding.larger + spacing: Appearance.spacing.small - sourceComponent: MaterialIcon { - color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface - text: item.modelData.trailingIcon + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: item.modelData.icon + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurface + text: item.modelData.text + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + asynchronous: true + visible: active + + sourceComponent: MaterialIcon { + color: item.active ? DynamicColors.palette.m3onTertiaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: item.modelData.trailingIcon + } } } } diff --git a/Greeter/Components/PathViewMenu.qml b/Greeter/Components/PathViewMenu.qml index 898dd4b..111b236 100644 --- a/Greeter/Components/PathViewMenu.qml +++ b/Greeter/Components/PathViewMenu.qml @@ -29,6 +29,7 @@ Elevation { level: root.expanded ? 2 : 0 radius: itemHeight / 2 visible: implicitHeight > 0 + z: root.expanded ? 100 : 0 Behavior on implicitHeight { Anim { @@ -68,6 +69,7 @@ Elevation { anchors.fill: parent color: DynamicColors.palette.m3surfaceContainer radius: parent.radius + z: root.z // Main visible spinner: normal/outside text color PathView { diff --git a/Greeter/Components/StateLayer.qml b/Greeter/Components/StateLayer.qml index 53dc9e3..aa41297 100644 --- a/Greeter/Components/StateLayer.qml +++ b/Greeter/Components/StateLayer.qml @@ -1,15 +1,53 @@ -import qs.Config import QtQuick +import QtQuick.Shapes +import ZShell +import ZShell.Components +import qs.Helpers +import qs.Config MouseArea { id: root - property color color: DynamicColors.palette.m3onSurface + property alias bottomLeftRadius: base.bottomLeftRadius + property alias bottomRightRadius: base.bottomRightRadius + property real circleRadius + property alias color: base.color property bool disabled - property real radius: parent?.radius ?? 0 - property alias rect: hoverLayer + readonly property real endRadius: { + const d1 = distSq(0, 0); + const d2 = distSq(width, 0); + const d3 = distSq(0, height); + const d4 = distSq(width, height); + return (Math.sqrt(Math.max(d1, d2, d3, d4)) + (shapeMorph ? 24 : 0)) * 1.3; + } + property real endRadiusAtPress + property bool manualPressOverride + property real pressX: width / 2 + property real pressY: height / 2 + property alias radius: base.radius + readonly property alias rect: base + property bool shapeMorph + property bool showHoverBackground: true + property real stateOpacity: containsMouse ? 0.08 : 0 + property alias topLeftRadius: base.topLeftRadius + property alias topRightRadius: base.topRightRadius - function onClicked(): void { + function clamp(r: real): real { + return Math.max(0, Math.min(r, width / 2, height / 2)); + } + + function distSq(x: real, y: real): real { + return (pressX - x) ** 2 + (pressY - y) ** 2; + } + + function press(x: real, y: real): void { + pressX = x; + pressY = y; + fadeAnim.complete(); + circleRadius = 0; + circle.opacity = 0.1; + rippleAnim.restart(); + endRadiusAtPress = endRadius; } anchors.fill: parent @@ -17,79 +55,146 @@ MouseArea { enabled: !disabled hoverEnabled: true - onClicked: event => !disabled && onClicked(event) - onPressed: event => { - if (disabled) - return; - - rippleAnim.x = event.x; - rippleAnim.y = event.y; - - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y))); - - rippleAnim.restart(); + Behavior on stateOpacity { + Anim { + type: Anim.DefaultEffects + } } - SequentialAnimation { + onCircleRadiusChanged: { + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + fadeAnim.start(); + } + onClicked: event => !disabled && onClicked(event) + onManualPressOverrideChanged: { + if (!(pressed || manualPressOverride) && circleRadius > endRadiusAtPress * 0.99 && !fadeAnim.running) + fadeAnim.start(); + } + onPressed: e => press(e.x, e.y) + onPressedChanged: { + if (!(pressed || manualPressOverride) && !rippleAnim.running && circle.opacity > 0) + fadeAnim.start(); + } + + Anim { 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 { - easing.bezierCurve: MaterialEasing.standardDecel - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - property: "opacity" - target: ripple - to: 0 - } + alwaysRunToEnd: true + duration: Appearance.anim.durations.expressiveSlowEffects * 2 + easing.bezierCurve: Appearance.anim.curves.standard + property: "circleRadius" + target: root + to: root.endRadius } - CustomClippingRect { - id: hoverLayer + Anim { + id: fadeAnim + + property: "opacity" + target: circle + to: 0 + type: Anim.SlowEffects + } + + CustomRect { + id: base anchors.fill: parent - border.pixelAligned: false - color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0) - radius: root.radius + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 + color: DynamicColors.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 + topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 + topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 + // qmllint enable missing-property + } - CustomRect { - id: ripple + Shape { + id: circle - border.pixelAligned: false - color: root.color - opacity: 0 - radius: Appearance.rounding.full + anchors.fill: parent + opacity: 0 + preferredRendererType: Shape.CurveRenderer - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 + ShapePath { + fillColor: base.color + startX: root.clamp(base.topLeftRadius) + startY: 0 + strokeColor: "transparent" + strokeWidth: 0 + + fillGradient: RadialGradient { + centerRadius: root.circleRadius + centerX: root.pressX + centerY: root.pressY + focalX: centerX + focalY: centerY + + GradientStop { + color: Qt.alpha(base.color, 1) + position: 0 + } + + GradientStop { + color: Qt.alpha(base.color, 1) + position: ZUtils.clamp(1 - 0.2 * root.endRadius / root.circleRadius, 0.01, 0.99) + } + + GradientStop { + color: Qt.alpha(base.color, ZUtils.clamp((root.circleRadius / root.endRadius - 0.9) / 0.1, 0, 1)) + position: 1 + } + } + + PathLine { + x: root.width - root.clamp(base.topRightRadius) + y: 0 + } + + PathArc { + radiusX: root.clamp(base.topRightRadius) + radiusY: root.clamp(base.topRightRadius) + relativeX: root.clamp(base.topRightRadius) + relativeY: root.clamp(base.topRightRadius) + } + + PathLine { + x: root.width + y: root.height - root.clamp(base.bottomRightRadius) + } + + PathArc { + radiusX: root.clamp(base.bottomRightRadius) + radiusY: root.clamp(base.bottomRightRadius) + relativeX: -root.clamp(base.bottomRightRadius) + relativeY: root.clamp(base.bottomRightRadius) + } + + PathLine { + x: root.clamp(base.bottomLeftRadius) + y: root.height + } + + PathArc { + radiusX: root.clamp(base.bottomLeftRadius) + radiusY: root.clamp(base.bottomLeftRadius) + relativeX: -root.clamp(base.bottomLeftRadius) + relativeY: -root.clamp(base.bottomLeftRadius) + } + + PathLine { + x: 0 + y: root.clamp(base.topLeftRadius) + } + + PathArc { + radiusX: root.clamp(base.topLeftRadius) + radiusY: root.clamp(base.topLeftRadius) + x: root.clamp(base.topLeftRadius) + y: 0 } } } diff --git a/Greeter/Config/Appearance.qml b/Greeter/Config/Appearance.qml index d2bf19e..7be277e 100644 --- a/Greeter/Config/Appearance.qml +++ b/Greeter/Config/Appearance.qml @@ -4,10 +4,9 @@ import Quickshell Singleton { readonly property AppearanceConf.Anim anim: Config.appearance.anim + readonly property AppearanceConf.Deform deform: Config.appearance.deform readonly property AppearanceConf.FontStuff font: Config.appearance.font readonly property AppearanceConf.Padding padding: Config.appearance.padding - // Literally just here to shorten accessing stuff :woe: - // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Conf.appearance.xxx` readonly property AppearanceConf.Rounding rounding: Config.appearance.rounding readonly property AppearanceConf.Spacing spacing: Config.appearance.spacing readonly property AppearanceConf.Transparency transparency: Config.appearance.transparency diff --git a/Greeter/Config/AppearanceConf.qml b/Greeter/Config/AppearanceConf.qml index 60c648c..859e0a3 100644 --- a/Greeter/Config/AppearanceConf.qml +++ b/Greeter/Config/AppearanceConf.qml @@ -3,6 +3,8 @@ import Quickshell.Io JsonObject { property Anim anim: Anim { } + property Deform deform: Deform { + } property FontStuff font: FontStuff { } property Padding padding: Padding { @@ -26,9 +28,13 @@ JsonObject { property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list expressiveDefaultEffects: [0.34, 0.8, 0.34, 1, 1, 1] property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + property list expressiveFastEffects: [0.31, 0.94, 0.34, 1, 1, 1] property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list expressiveSlowEffects: [0.34, 0.88, 0.34, 1, 1, 1] + property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] property list standard: [0.2, 0, 0, 1, 1, 1] property list standardAccel: [0.3, 0, 1, 1, 1, 1] property list standardDecel: [0, 0, 0, 1, 1, 1] @@ -36,13 +42,18 @@ JsonObject { component AnimDurations: JsonObject { property int expressiveDefaultSpatial: 500 * scale property int expressiveEffects: 200 * scale + property int expressiveFastEffects: 150 * scale property int expressiveFastSpatial: 350 * scale + property int expressiveSlowEffects: 300 * scale property int extraLarge: 1000 * scale property int large: 600 * scale property int normal: 400 * scale property real scale: 1 property int small: 200 * scale } + component Deform: JsonObject { + property real scale: 1 + } component FontFamily: JsonObject { property string clock: "Rubik" property string material: "Material Symbols Rounded" @@ -52,7 +63,8 @@ JsonObject { component FontSize: JsonObject { property int extraLarge: 28 * scale property int large: 18 * scale - property int larger: 15 * scale + property int larger: 16 * scale + property int medium: 14 * scale property int normal: 13 * scale property real scale: 1 property int small: 11 * scale @@ -65,28 +77,33 @@ JsonObject { } } component Padding: JsonObject { - property int large: 15 * scale + property int extraLargeIncreased: 32 * scale + property int extraSmall: 4 * scale + property int large: 16 * scale property int larger: 12 * scale - property int normal: 10 * scale + property int normal: 8 * scale property real scale: 1 property int small: 5 * scale property int smaller: 7 * scale property int smallest: 2 * scale } component Rounding: JsonObject { + property int extraSmall: 4 * scale property int full: 1000 * scale - property int large: 25 * scale - property int normal: 17 * scale + property int large: 24 * scale + property int medium: 16 * scale + property int normal: 18 * scale property real scale: 1 property int small: 12 * scale property int smallest: 8 * scale } component Spacing: JsonObject { + property int extraSmall: 4 * scale property int large: 20 * scale - property int larger: 15 * scale + property int larger: 16 * scale property int normal: 12 * scale property real scale: 1 - property int small: 7 * scale + property int small: 8 * scale property int smaller: 10 * scale } component Transparency: JsonObject { diff --git a/Greeter/Config/BackgroundConfig.qml b/Greeter/Config/BackgroundConfig.qml index bcae021..10e7691 100644 --- a/Greeter/Config/BackgroundConfig.qml +++ b/Greeter/Config/BackgroundConfig.qml @@ -4,4 +4,11 @@ import qs.Config JsonObject { property bool enabled: true property int wallFadeDuration: MaterialEasing.standardTime + property real alignX: 0.5 + property real alignY: 0.5 + property real zoom: 1.0 + property real sourceClipX: 0 + property real sourceClipY: 0 + property real sourceClipW: 0 + property real sourceClipH: 0 } diff --git a/Greeter/Config/BarConfig.qml b/Greeter/Config/BarConfig.qml index 7b39915..0f489ef 100644 --- a/Greeter/Config/BarConfig.qml +++ b/Greeter/Config/BarConfig.qml @@ -8,10 +8,6 @@ JsonObject { id: "workspaces", enabled: true }, - { - id: "audio", - enabled: true - }, { id: "media", enabled: true @@ -24,10 +20,6 @@ JsonObject { id: "updates", enabled: true }, - { - id: "dash", - enabled: true - }, { id: "spacer", enabled: true @@ -41,12 +33,12 @@ JsonObject { enabled: true }, { - id: "tray", + id: "hyprsunset", enabled: true }, { - id: "upower", - enabled: false + id: "tray", + enabled: true }, { id: "network", @@ -62,9 +54,13 @@ JsonObject { }, ] property int height: 34 + property bool hideWhenNotif: false property Popouts popouts: Popouts { } property int rounding: 8 + property int smoothing: 32 + property Tray tray: Tray { + } component Popouts: JsonObject { property bool activeWindow: true @@ -75,4 +71,7 @@ JsonObject { property bool tray: true property bool upower: true } + component Tray: JsonObject { + property int trayIconSize: 24 + } } diff --git a/Greeter/Config/Colors.qml b/Greeter/Config/Colors.qml index 287ea02..3b99070 100644 --- a/Greeter/Config/Colors.qml +++ b/Greeter/Config/Colors.qml @@ -1,5 +1,13 @@ import Quickshell.Io JsonObject { + property Presets presets: Presets { + } property string schemeType: "vibrant" + + component Presets: JsonObject { + property string accent: "" + property string name: "" + property string variant: "" + } } diff --git a/Greeter/Config/Config.qml b/Greeter/Config/Config.qml index fad254a..decd07c 100644 --- a/Greeter/Config/Config.qml +++ b/Greeter/Config/Config.qml @@ -4,8 +4,6 @@ import Quickshell import Quickshell.Io import ZShell import QtQuick -import qs.Helpers -import qs.Paths Singleton { id: root @@ -23,6 +21,7 @@ Singleton { property alias osd: adapter.osd property alias overview: adapter.overview property bool recentlySaved: false + property alias screenshot: adapter.screenshot property alias services: adapter.services property alias sidebar: adapter.sidebar property alias utilities: adapter.utilities @@ -48,6 +47,9 @@ Singleton { padding: { scale: appearance.padding.scale }, + deform: { + scale: appearance.deform.scale + }, font: { family: { sans: appearance.font.family.sans, @@ -77,16 +79,28 @@ Singleton { function serializeBackground(): var { return { wallFadeDuration: background.wallFadeDuration, - enabled: background.enabled + enabled: background.enabled, + alignX: background.alignX, + sourceClipX: background.sourceClipX, + sourceClipY: background.sourceClipY, + sourceClipW: background.sourceClipW, + sourceClipH: background.sourceClipH, + alignY: background.alignY, + zoom: background.zoom }; } function serializeBar(): var { return { autoHide: barConfig.autoHide, + hideWhenNotif: barConfig.hideWhenNotif, rounding: barConfig.rounding, border: barConfig.border, + smoothing: barConfig.smoothing, height: barConfig.height, + tray: { + trayIconSize: barConfig.tray.trayIconSize + }, popouts: { tray: barConfig.popouts.tray, audio: barConfig.popouts.audio, @@ -102,7 +116,12 @@ Singleton { function serializeColors(): var { return { - schemeType: colors.schemeType + schemeType: colors.schemeType, + presets: { + name: colors.presets.name, + variant: colors.presets.variant, + accent: colors.presets.accent + } }; } @@ -121,7 +140,8 @@ Singleton { background: serializeBackground(), launcher: serializeLauncher(), colors: serializeColors(), - dock: serializeDock() + dock: serializeDock(), + screenshot: serializeScreenshot() }; } @@ -172,11 +192,16 @@ Singleton { return { logo: general.logo, wallpaperPath: general.wallpaperPath, - username: general.username, desktopIcons: general.desktopIcons, + dateFormat: general.dateFormat, color: { mode: general.color.mode, smart: general.color.smart, + scheduleDark: general.color.scheduleDark, + scheduleHyprsunset: general.color.scheduleHyprsunset, + scheduleHyprsunsetStart: general.color.scheduleHyprsunsetStart, + hyprsunsetTemp: general.color.hyprsunsetTemp, + scheduleHyprsunsetEnd: general.color.scheduleHyprsunsetEnd, schemeGeneration: general.color.schemeGeneration, scheduleDarkStart: general.color.scheduleDarkStart, scheduleDarkEnd: general.color.scheduleDarkEnd, @@ -190,6 +215,10 @@ Singleton { }, idle: { timeouts: general.idle.timeouts + }, + battery: { + popupThresholds: general.battery.popupThresholds, + critPerc: general.battery.critPerc } }; } @@ -198,6 +227,7 @@ Singleton { return { maxAppsShown: launcher.maxAppsShown, maxWallpapers: launcher.maxWallpapers, + uwsm: launcher.uwsm, actionPrefix: launcher.actionPrefix, specialPrefix: launcher.specialPrefix, useFuzzy: { @@ -221,6 +251,8 @@ Singleton { return { recolorLogo: lock.recolorLogo, enableFprint: lock.enableFprint, + showNotifContent: lock.showNotifContent, + showNotifIcon: lock.showNotifIcon, maxFprintTries: lock.maxFprintTries, blurAmount: lock.blurAmount, sizes: { @@ -262,9 +294,24 @@ Singleton { }; } + function serializeScreenshot(): var { + return { + enable_pp: screenshot.enable_pp, + mode: screenshot.mode, + radius: screenshot.radius, + shadow: screenshot.shadow, + rounding: screenshot.rounding, + shadow_blur: screenshot.shadow_blur, + shadow_color: screenshot.shadow_color, + shadow_offset_x: screenshot.shadow_offset_x, + shadow_offset_y: screenshot.shadow_offset_y + }; + } + function serializeServices(): var { return { weatherLocation: services.weatherLocation, + updates: services.updates, useFahrenheit: services.useFahrenheit, ddcutilService: services.ddcutilService, useTwelveHourClock: services.useTwelveHourClock, @@ -317,7 +364,6 @@ Singleton { ElapsedTimer { id: timer - } Timer { @@ -415,6 +461,8 @@ Singleton { } property Overview overview: Overview { } + property Screenshot screenshot: Screenshot { + } property Services services: Services { } property SidebarConfig sidebar: SidebarConfig { diff --git a/Greeter/Config/DynamicColors.qml b/Greeter/Config/DynamicColors.qml index fc7cd10..2b2be28 100644 --- a/Greeter/Config/DynamicColors.qml +++ b/Greeter/Config/DynamicColors.qml @@ -29,9 +29,10 @@ Singleton { readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { - const luminance = getLuminance(c); + const initLuminance = getLuminance(c); + const luminance = Math.max(initLuminance, 0.001); - const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5)); + const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (0.2 + 0.3 * (1 - transparency.base)) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5)); const scale = (luminance + offset) / luminance; const r = Math.max(0, Math.min(1, c.r * scale)); const g = Math.max(0, Math.min(1, c.g * scale)); @@ -84,6 +85,10 @@ Singleton { Config.save(); } + function swapRG(c: color): color { + return Qt.rgba(c.g, c.r, c.b, c.a); + } + FileView { path: "/etc/zshell-greeter/scheme.json" watchChanges: true @@ -95,69 +100,9 @@ Singleton { ImageAnalyser { id: analyser - source: WallpaperPath.currentWallpaperPath + source: WallpaperPath.lockscreenBg } - component M3MaccchiatoPalette: QtObject { - property color m3background: "#131317" - property color m3error: "#ffb4ab" - property color m3errorContainer: "#93000a" - property color m3inverseOnSurface: "#303034" - property color m3inversePrimary: "#525b92" - property color m3inverseSurface: "#e4e1e7" - property color m3neutral_paletteKeyColor: "#77767b" - property color m3neutral_variant_paletteKeyColor: "#767680" - property color m3onBackground: "#e4e1e7" - property color m3onError: "#690005" - property color m3onErrorContainer: "#ffdad6" - property color m3onPrimary: "#232c60" - property color m3onPrimaryContainer: "#ffffff" - property color m3onPrimaryFixed: "#0b154b" - property color m3onPrimaryFixedVariant: "#3a4378" - property color m3onSecondary: "#2c2f44" - property color m3onSecondaryContainer: "#b1b3ce" - property color m3onSecondaryFixed: "#171a2e" - property color m3onSecondaryFixedVariant: "#42455c" - property color m3onSuccess: "#213528" - property color m3onSuccessContainer: "#D1E9D6" - property color m3onSurface: "#e4e1e7" - property color m3onSurfaceVariant: "#c6c5d1" - property color m3onTertiary: "#4c1f48" - property color m3onTertiaryContainer: "#000000" - property color m3onTertiaryFixed: "#340831" - property color m3onTertiaryFixedVariant: "#66365f" - property color m3outline: "#90909a" - property color m3outlineVariant: "#46464f" - property color m3primary: "#bac3ff" - property color m3primaryContainer: "#6a73ac" - property color m3primaryFixed: "#dee0ff" - property color m3primaryFixedDim: "#bac3ff" - property color m3primary_paletteKeyColor: "#6a73ac" - property color m3scrim: "#000000" - property color m3secondary: "#c3c5e0" - property color m3secondaryContainer: "#42455c" - property color m3secondaryFixed: "#dfe1fd" - property color m3secondaryFixedDim: "#c3c5e0" - property color m3secondary_paletteKeyColor: "#72758e" - property color m3shadow: "#000000" - property color m3success: "#B5CCBA" - property color m3successContainer: "#374B3E" - property color m3surface: "#131317" - property color m3surfaceBright: "#39393d" - property color m3surfaceContainer: "#1f1f23" - property color m3surfaceContainerHigh: "#2a2a2e" - property color m3surfaceContainerHighest: "#353438" - property color m3surfaceContainerLow: "#1b1b1f" - property color m3surfaceContainerLowest: "#0e0e12" - property color m3surfaceDim: "#131317" - property color m3surfaceTint: "#bac3ff" - property color m3surfaceVariant: "#46464f" - property color m3tertiary: "#f1b3e5" - property color m3tertiaryContainer: "#b77ead" - property color m3tertiaryFixed: "#ffd7f4" - property color m3tertiaryFixedDim: "#f1b3e5" - property color m3tertiary_paletteKeyColor: "#9b6592" - } component M3Palette: QtObject { property color m3background: "#191114" property color m3error: "#ffb4ab" @@ -279,8 +224,11 @@ Singleton { readonly property color m3tertiary_paletteKeyColor: root.layer(root.palette.m3tertiary_paletteKeyColor) } component Transparency: QtObject { - readonly property real base: Appearance.transparency.base - (root.light ? 0.1 : 0) + readonly property real base: Math.max(0, Math.min(1, Appearance.transparency.base - (root.light ? 0.1 : 0))) readonly property bool enabled: Appearance.transparency.enabled readonly property real layers: Appearance.transparency.layers + + onBaseChanged: debounceTimer.restart() + onEnabledChanged: debounceTimer.restart() } } diff --git a/Greeter/Config/General.qml b/Greeter/Config/General.qml index 453c2f3..45d3513 100644 --- a/Greeter/Config/General.qml +++ b/Greeter/Config/General.qml @@ -4,13 +4,15 @@ import Quickshell JsonObject { property Apps apps: Apps { } + property Battery battery: Battery { + } property Color color: Color { } + property string dateFormat: "ddd d MMM - hh:mm:ss" property bool desktopIcons: false property Idle idle: Idle { } property string logo: "" - property string username: "" property string wallpaperPath: Quickshell.env("HOME") + "/Pictures/Wallpapers" component Apps: JsonObject { @@ -19,11 +21,27 @@ JsonObject { property list playback: ["mpv"] property list terminal: ["kitty"] } + component Battery: JsonObject { + property int critPerc: 5 + property list popupThresholds: [ + { + perc: 20, + name: qsTr("Low battery"), + message: qsTr("Battery is low"), + icon: "battery_android_frame_2" + }, + ] + } component Color: JsonObject { + property int hyprsunsetTemp: 5000 property string mode: "dark" property bool neovimColors: false + property bool scheduleDark: false property int scheduleDarkEnd: 0 property int scheduleDarkStart: 0 + property bool scheduleHyprsunset: false + property int scheduleHyprsunsetEnd: 0 + property int scheduleHyprsunsetStart: 0 property bool schemeGeneration: true property bool smart: false } diff --git a/Greeter/Config/Launcher.qml b/Greeter/Config/Launcher.qml index a195868..5ab58cc 100644 --- a/Greeter/Config/Launcher.qml +++ b/Greeter/Config/Launcher.qml @@ -91,6 +91,7 @@ JsonObject { property string specialPrefix: "@" property UseFuzzy useFuzzy: UseFuzzy { } + property bool uwsm: true component Sizes: JsonObject { property int itemHeight: 50 diff --git a/Greeter/Config/LockConf.qml b/Greeter/Config/LockConf.qml index e377459..87a4f4e 100644 --- a/Greeter/Config/LockConf.qml +++ b/Greeter/Config/LockConf.qml @@ -5,6 +5,8 @@ JsonObject { property bool enableFprint: true property int maxFprintTries: 3 property bool recolorLogo: false + property bool showNotifContent: false + property bool showNotifIcon: true property Sizes sizes: Sizes { } diff --git a/Greeter/Config/Screenshot.qml b/Greeter/Config/Screenshot.qml new file mode 100644 index 0000000..cd6dd49 --- /dev/null +++ b/Greeter/Config/Screenshot.qml @@ -0,0 +1,13 @@ +import Quickshell.Io + +JsonObject { + property bool enable_pp: true + property string mode: "manual" + property real radius: 12.0 + property bool rounding: false + property bool shadow: true + property real shadow_blur: 22.0 + property list shadow_color: [0, 0, 0, 160] + property real shadow_offset_x: 5.0 + property real shadow_offset_y: 5.0 +} diff --git a/Greeter/Config/Services.qml b/Greeter/Config/Services.qml index e091fa2..4711120 100644 --- a/Greeter/Config/Services.qml +++ b/Greeter/Config/Services.qml @@ -14,6 +14,7 @@ JsonObject { "to": "YT Music" } ] + property bool updates: true property bool useFahrenheit: false property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") property int visualizerBars: 30 diff --git a/Greeter/SessionDock.qml b/Greeter/SessionDock.qml index 54eaff4..b4914ec 100644 --- a/Greeter/SessionDock.qml +++ b/Greeter/SessionDock.qml @@ -83,7 +83,7 @@ ColumnLayout { radius: Appearance.rounding.normal - Appearance.padding.smaller StateLayer { - function onClicked(): void { + onClicked: { root.greeter.sessionIndex = index; } } diff --git a/Greeter/UserDock.qml b/Greeter/UserDock.qml index c9f067a..dd4d4c7 100644 --- a/Greeter/UserDock.qml +++ b/Greeter/UserDock.qml @@ -84,7 +84,7 @@ ColumnLayout { radius: Appearance.rounding.normal - Appearance.padding.smaller StateLayer { - function onClicked(): void { + onClicked: { root.greeter.selectUser(modelData.username); } } diff --git a/Helpers/AppSearch.qml b/Helpers/AppSearch.qml index 400b3d9..feb863d 100644 --- a/Helpers/AppSearch.qml +++ b/Helpers/AppSearch.qml @@ -1,19 +1,13 @@ pragma Singleton import Quickshell -import Quickshell.Io -import "../scripts/levendist.js" as Levendist +import qs.Helpers import "../scripts/fuzzysort.js" as Fuzzy -import qs.Config Singleton { id: root - readonly property list list: Array.from(DesktopEntries.applications.values).filter((app, index, self) => index === self.findIndex(t => (t.id === app.id))) - readonly property var preppedIcons: list.map(a => ({ - name: Fuzzy.prepare(`${a.icon} `), - entry: a - })) + readonly property list list: Array.from(DesktopEntries.applications.values) readonly property var preppedNames: list.map(a => ({ name: Fuzzy.prepare(`${a.name} `), entry: a @@ -36,8 +30,7 @@ Singleton { "replace": "system-lock-screen" } ] - readonly property real scoreGapThreshold: 0.1 - readonly property real scoreThreshold: 0.6 + property real scoreThreshold: 0.2 property var substitutions: ({ "code-url-handler": "visual-studio-code", "Code": "visual-studio-code", @@ -45,64 +38,27 @@ Singleton { "pavucontrol-qt": "pavucontrol", "wps": "wps-office2019-kprometheus", "wpsoffice": "wps-office2019-kprometheus", - "footclient": "foot" + "footclient": "foot", + "zen": "zen-browser" }) - function bestFuzzyEntry(search: string, preppedList: list, key: string): var { - const results = Fuzzy.go(search, preppedList, { - key: key, - threshold: root.scoreThreshold, - limit: 2 + signal reload + + function fuzzyQuery(search: string): var { + return Fuzzy.go(search, preppedNames, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry; }); - - if (!results || results.length === 0) - return null; - - const best = results[0]; - const second = results.length > 1 ? results[1] : null; - - if (second && (best.score - second.score) < root.scoreGapThreshold) - return null; - - return best.obj.entry; - } - - function fuzzyQuery(search: string, preppedList: list): var { - const entry = bestFuzzyEntry(search, preppedList, "name"); - return entry ? [entry] : []; - } - - function getKebabNormalizedAppName(str: string): string { - return str.toLowerCase().replace(/\s+/g, "-"); - } - - function getReverseDomainNameAppName(str: string): string { - return str.split('.').slice(-1)[0]; - } - - function getUndescoreToKebabAppName(str: string): string { - return str.toLowerCase().replace(/_/g, "-"); } function guessIcon(str) { if (!str || str.length == 0) return "image-missing"; - if (iconExists(str)) - return str; - - const entry = DesktopEntries.byId(str); - if (entry) - return entry.icon; - - const heuristicEntry = DesktopEntries.heuristicLookup(str); - if (heuristicEntry) - return heuristicEntry.icon; - if (substitutions[str]) return substitutions[str]; - if (substitutions[str.toLowerCase()]) - return substitutions[str.toLowerCase()]; for (let i = 0; i < regexSubstitutions.length; i++) { const substitution = regexSubstitutions[i]; @@ -111,35 +67,25 @@ Singleton { return replacedName; } - const lowercased = str.toLowerCase(); - if (iconExists(lowercased)) - return lowercased; + if (iconExists(str)) + return str; - const reverseDomainNameAppName = getReverseDomainNameAppName(str); - if (iconExists(reverseDomainNameAppName)) - return reverseDomainNameAppName; + let guessStr = str; + guessStr = str.split('.').slice(-1)[0].toLowerCase(); + if (iconExists(guessStr)) + return guessStr; + guessStr = str.toLowerCase().replace(/\s+/g, "-"); + if (iconExists(guessStr)) + return guessStr; + const searchResults = root.fuzzyQuery(str); + if (searchResults.length > 0) { + const firstEntry = searchResults[0]; + guessStr = firstEntry.icon; + if (iconExists(guessStr)) + return guessStr; + } - const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase(); - if (iconExists(lowercasedDomainNameAppName)) - return lowercasedDomainNameAppName; - - const kebabNormalizedGuess = getKebabNormalizedAppName(str); - if (iconExists(kebabNormalizedGuess)) - return kebabNormalizedGuess; - - const undescoreToKebabGuess = getUndescoreToKebabAppName(str); - if (iconExists(undescoreToKebabGuess)) - return undescoreToKebabGuess; - - const iconSearchResult = fuzzyQuery(str, preppedIcons); - if (iconSearchResult && iconExists(iconSearchResult.icon)) - return iconSearchResult.icon; - - const nameSearchResult = root.fuzzyQuery(str, preppedNames); - if (nameSearchResult && iconExists(nameSearchResult.icon)) - return nameSearchResult.icon; - - return "application-x-executable"; + return str; } function iconExists(iconName) { diff --git a/Helpers/Battery.qml b/Helpers/Battery.qml index 2360386..1ffb61f 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -29,4 +29,6 @@ Singleton { readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery readonly property bool onBattery: UPower.onBattery readonly property bool ready: UPower.displayDevice.ready + readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty + readonly property real timeToFull: UPower.displayDevice.timeToFull } diff --git a/Helpers/Brightness.qml b/Helpers/Brightness.qml index 5e2e7fb..f7b3a8b 100644 --- a/Helpers/Brightness.qml +++ b/Helpers/Brightness.qml @@ -232,7 +232,7 @@ Singleton { } function setBrightness(value: real): void { - value = Math.max(0, Math.min(1, value)); + value = Math.max(Config.services.minBrightness, Math.min(1, value)); const rounded = Math.round(value * 100); if (Math.round(brightness * 100) === rounded) return; diff --git a/Helpers/ClipHistory.qml b/Helpers/ClipHistory.qml new file mode 100644 index 0000000..fe38a37 --- /dev/null +++ b/Helpers/ClipHistory.qml @@ -0,0 +1,249 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Config +import "../scripts/fuzzysort.js" as Fuzzy + +Singleton { + id: root + + property string cliphistBinary: "cliphist" + property string currentEntry: "" + property list entries: [] + property real pasteDelay: 0.05 + readonly property var preparedEntries: entries.map(a => ({ + name: Fuzzy.prepare(displayText(a)), + entry: a + })) + property string previewImageFile: "/tmp/qs-cliphist-preview.img" + property string previewImageSource: "" + property bool previewIsImage: false + property list previewText: [] + property int previewToken: 0 + property real scoreThreshold: 0.2 + + function copy(entry): void { + Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]); + } + + function deleteEntry(entry): void { + deleteProc.deleteEntry(entry); + } + + function displayText(entry): string { + return entry.replace(/^\s*\d+\s+/, ""); + } + + function entryIsImage(entry): bool { + return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry)); + } + + function escapeHtml(str): string { + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } + + function fuzzyQuery(search: string): var { + if (search.trim() === "") { + return entries; + } + return Fuzzy.go(search, preparedEntries, { + all: true, + key: "name" + }).map(r => { + return r.obj.entry; + }); + } + + function paste(entry): void { + Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]); + } + + function processPreviewLines(text: string): var { + if (text === "") + return []; + const lines = text.split("\n"); + let minIndent = Infinity; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*)/); + const indent = m ? m[1].length : 0; + if (lines[i].trim().length > 0 && indent < minIndent) + minIndent = indent; + } + if (minIndent === Infinity) + minIndent = 0; + const result = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const indentMatch = line.match(/^(\s*)/); + const indentLen = indentMatch ? indentMatch[1].length : 0; + const stripped = line.slice(Math.min(minIndent, indentLen)); + result.push({ + line: i + 1, + text: stripped + }); + } + return result; + } + + function refresh(): void { + readProc.buffer = []; + readProc.running = true; + } + + function refreshPreview(): void { + previewToken += 1; + const token = previewToken; + + if (!currentEntry) { + previewText = []; + previewImageSource = ""; + previewIsImage = false; + return; + } + + previewImageSource = ""; + previewIsImage = entryIsImage(currentEntry); + + if (previewIsImage) { + previewImageProc.token = token; + previewImageProc.running = true; + } else { + previewTextProc.token = token; + previewTextProc.running = true; + } + } + + function shellSingleQuoteEscape(str): string { + return String(str).replace(/'/g, "'\\''"); + } + + function wipe(): void { + wipeProc.running = true; + } + + Process { + id: deleteProc + + property string entry: "" + + function deleteEntry(entry) { + deleteProc.entry = entry; + deleteProc.running = true; + deleteProc.entry = ""; + } + + command: ["bash", "-c", `echo '${root.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`] + + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + Process { + id: wipeProc + + command: [root.cliphistBinary, "wipe"] + + onExited: (exitCode, exitStatus) => { + root.refresh(); + } + } + + Connections { + function onClipboardTextChanged() { + delayedUpdateTimer.restart(); + } + + target: Quickshell + } + + Timer { + id: delayedUpdateTimer + + interval: 50 + repeat: false + + onTriggered: { + root.refresh(); + } + } + + Process { + id: previewTextProc + + property int token: 0 + + command: ["bash", "-c", ` + printf '%s' '${root.shellSingleQuoteEscape(root.currentEntry)}' | ${root.cliphistBinary} decode + `] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (previewTextProc.token !== root.previewToken) + return; + root.previewText = root.processPreviewLines(this.text); + } + } + } + + Process { + id: previewImageProc + + property int token: 0 + + command: ["bash", "-c", ` + set -euo pipefail + tmp='${root.shellSingleQuoteEscape(root.previewImageFile)}' + printf '%s' '${root.shellSingleQuoteEscape(root.currentEntry)}' | ${root.cliphistBinary} decode > "$tmp" + `] + running: false + + onExited: (exitCode, exitStatus) => { + if (token !== root.previewToken) + return; + if (exitCode !== 0) { + console.error("[Cliphist] image preview failed", exitCode, exitStatus); + return; + } + + root.previewImageSource = ""; + Qt.callLater(() => { + root.previewImageSource = `file://${root.previewImageFile}`; + }); + } + } + + Process { + id: readProc + + property list buffer: [] + + command: [root.cliphistBinary, "list"] + + stdout: SplitParser { + onRead: line => { + readProc.buffer.push(line); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + root.entries = readProc.buffer; + } else { + console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus); + } + } + } + + IpcHandler { + function update(): void { + root.refresh(); + } + + target: "cliphistService" + } +} diff --git a/Helpers/ModeScheduler.qml b/Helpers/ModeScheduler.qml index 4aa01ef..5a5815c 100644 --- a/Helpers/ModeScheduler.qml +++ b/Helpers/ModeScheduler.qml @@ -42,11 +42,24 @@ Singleton { function checkStartup() { if (!root.enabled) return; + var now = new Date(); - if (now.getHours() >= darkStart || now.getHours() < darkEnd) { - applyDarkMode(); + var nowMinutes = now.getHours() * 60 + now.getMinutes(); + + var isDarkTime; + + if (root.darkStart <= root.darkEnd) { + isDarkTime = (nowMinutes >= root.darkStart && nowMinutes < root.darkEnd); } else { - applyLightMode(); + isDarkTime = (nowMinutes >= root.darkStart || nowMinutes < root.darkEnd); + } + + if (isDarkTime) { + if (DynamicColors.light) + root.applyDarkMode(); + } else { + if (!DynamicColors.light) + root.applyLightMode(); } } @@ -60,8 +73,19 @@ Singleton { onTriggered: { if (!root.enabled) return; + var now = new Date(); - if (now.getHours() >= root.darkStart || now.getHours() < root.darkEnd) { + var nowMinutes = now.getHours() * 60 + now.getMinutes(); + + var isDarkTime; + + if (root.darkStart <= root.darkEnd) { + isDarkTime = (nowMinutes >= root.darkStart && nowMinutes < root.darkEnd); + } else { + isDarkTime = (nowMinutes >= root.darkStart || nowMinutes < root.darkEnd); + } + + if (isDarkTime) { if (DynamicColors.light) root.applyDarkMode(); } else { diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml index 51957da..f206d2c 100644 --- a/Helpers/NetworkUsage.qml +++ b/Helpers/NetworkUsage.qml @@ -39,6 +39,21 @@ Singleton { 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)) { @@ -156,10 +171,15 @@ Singleton { Timer { interval: Config.dashboard.resourceUpdateInterval repeat: true - running: root.refCount > 0 + running: true triggeredOnStart: true onTriggered: { + if (root.refCount <= 0) { + root.resetSampling(); + return; + } + netDevFile.reload(); const content = netDevFile.text(); if (!content) diff --git a/Helpers/SystemUsage.qml b/Helpers/SystemUsage.qml deleted file mode 100644 index da9ab18..0000000 --- a/Helpers/SystemUsage.qml +++ /dev/null @@ -1,464 +0,0 @@ -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick -import qs.Config - -Singleton { - id: root - - property string autoGpuType: "NONE" - property string cpuName: "" - property real cpuPerc - property real cpuTemp - - // Individual disks: Array of { mount, used, total, free, perc } - property var disks: [] - property real gpuMemTotal: 0 - property real gpuMemUsed - property string gpuName - property real gpuPerc - property real gpuTemp - readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType - property real lastCpuIdle - property real lastCpuTotal - readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 - property real memTotal - property real memUsed - property int refCount - readonly property real storagePerc: { - let totalUsed = 0; - let totalSize = 0; - for (const disk of disks) { - totalUsed += disk.used; - totalSize += disk.total; - } - return totalSize > 0 ? totalUsed / totalSize : 0; - } - - function cleanCpuName(name: string): string { - return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim(); - } - - function cleanGpuName(name: string): string { - return name.replace(/NVIDIA GeForce /gi, "").replace(/NVIDIA /gi, "").replace(/AMD Radeon /gi, "").replace(/AMD /gi, "").replace(/Intel /gi, "").replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim(); - } - - function formatKib(kib: real): var { - const mib = 1024; - const gib = 1024 ** 2; - const tib = 1024 ** 3; - - if (kib >= tib) - return { - value: kib / tib, - unit: "TiB" - }; - if (kib >= gib) - return { - value: kib / gib, - unit: "GiB" - }; - if (kib >= mib) - return { - value: kib / mib, - unit: "MiB" - }; - return { - value: kib, - unit: "KiB" - }; - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval - repeat: true - running: root.refCount > 0 - triggeredOnStart: true - - onTriggered: { - stat.reload(); - meminfo.reload(); - if (root.gpuType === "GENERIC") - gpuUsage.running = true; - - if (root.gpuType === "GENERIC" && root.gpuMemTotal === 0) - oneshotMemAmd.running = true; - } - } - - Timer { - interval: 60000 * 120 - repeat: true - running: true - triggeredOnStart: true - - onTriggered: { - storage.running = true; - } - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval * 5 - repeat: true - running: root.refCount > 0 - triggeredOnStart: true - - onTriggered: { - sensors.running = true; - } - } - - FileView { - id: cpuinfoInit - - path: "/proc/cpuinfo" - - onLoaded: { - const nameMatch = text().match(/model name\s*:\s*(.+)/); - if (nameMatch) - root.cpuName = root.cleanCpuName(nameMatch[1]); - } - } - - FileView { - id: stat - - path: "/proc/stat" - - onLoaded: { - const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); - if (data) { - const stats = data.slice(1).map(n => parseInt(n, 10)); - const total = stats.reduce((a, b) => a + b, 0); - const idle = stats[3] + (stats[4] ?? 0); - - const totalDiff = total - root.lastCpuTotal; - const idleDiff = idle - root.lastCpuIdle; - const newCpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; - - root.lastCpuTotal = total; - root.lastCpuIdle = idle; - - if (Math.abs(newCpuPerc - root.cpuPerc) >= 0.01) - root.cpuPerc = newCpuPerc; - } - } - } - - FileView { - id: meminfo - - path: "/proc/meminfo" - - onLoaded: { - const data = text(); - const total = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; - const used = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; - - if (root.memTotal !== total) - root.memTotal = total; - - if (Math.abs(used - root.memUsed) >= 16384) - root.memUsed = used; - } - } - - Process { - id: storage - - command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"] - - stdout: StdioCollector { - onStreamFinished: { - const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal } - const lines = text.trim().split("\n"); - - for (const line of lines) { - if (line.trim() === "") - continue; - const nameMatch = line.match(/NAME="([^"]+)"/); - const sizeMatch = line.match(/SIZE="([^"]+)"/); - const typeMatch = line.match(/TYPE="([^"]+)"/); - const fsusedMatch = line.match(/FSUSED="([^"]*)"/); - const fssizeMatch = line.match(/FSSIZE="([^"]*)"/); - - if (!nameMatch || !typeMatch) - continue; - - const name = nameMatch[1]; - const type = typeMatch[1]; - const size = parseInt(sizeMatch?.[1] || "0", 10); - const fsused = parseInt(fsusedMatch?.[1] || "0", 10); - const fssize = parseInt(fssizeMatch?.[1] || "0", 10); - - if (type === "disk") { - // Skip zram (swap) devices - if (name.startsWith("zram")) - continue; - - // Initialize disk entry - if (!diskMap[name]) { - diskMap[name] = { - name: name, - totalSize: size, - used: 0, - fsTotal: 0 - }; - } - } else if (type === "part") { - // Find parent disk (remove trailing numbers/p+numbers) - let parentDisk = name.replace(/p?\d+$/, ""); - // For nvme devices like nvme0n1p1, parent is nvme0n1 - if (name.match(/nvme\d+n\d+p\d+/)) - parentDisk = name.replace(/p\d+$/, ""); - - // Aggregate partition usage to parent disk - if (diskMap[parentDisk]) { - diskMap[parentDisk].used += fsused; - diskMap[parentDisk].fsTotal += fssize; - } - } - } - - const diskList = []; - let totalUsed = 0; - let totalSize = 0; - - for (const diskName of Object.keys(diskMap).sort()) { - const disk = diskMap[diskName]; - // Use filesystem total if available, otherwise use disk size - const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize; - const used = disk.used; - const perc = total > 0 ? used / total : 0; - - // Convert bytes to KiB for consistency with formatKib - diskList.push({ - mount: disk.name // Using 'mount' property for compatibility - , - used: used / 1024, - total: total / 1024, - free: (total - used) / 1024, - perc: perc - }); - - totalUsed += used; - totalSize += total; - } - - root.disks = diskList; - } - } - } - - Process { - id: gpuNameDetect - - command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || lspci 2>/dev/null | grep -i 'vga\\|3d\\|display' | head -1"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - const output = text.trim(); - if (!output) - return; - - // Check if it's from nvidia-smi (clean GPU name) - if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) { - root.gpuName = root.cleanGpuName(output); - } else { - // Parse lspci output: extract name from brackets or after colon - const bracketMatch = output.match(/\[([^\]]+)\]/); - if (bracketMatch) { - root.gpuName = root.cleanGpuName(bracketMatch[1]); - } else { - const colonMatch = output.match(/:\s*(.+)/); - if (colonMatch) - root.gpuName = root.cleanGpuName(colonMatch[1]); - } - } - } - } - } - - Process { - id: gpuTypeCheck - - command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; 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"] - running: !Config.services.gpuType - - stdout: StdioCollector { - onStreamFinished: root.autoGpuType = text.trim() - } - } - - Process { - id: oneshotMem - - command: ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"] - running: root.gpuType === "NVIDIA" && root.gpuMemTotal === 0 - - stdout: StdioCollector { - onStreamFinished: { - root.gpuMemTotal = Number(this.text.trim()); - oneshotMem.running = false; - } - } - } - - Process { - id: oneshotMemAmd - - command: ["sh", "-c", "cat /sys/class/drm/card*/device/mem_info_vram_total"] - running: root.gpuType === "GENERIC" && root.gpuMemTotal === 0 - - stdout: StdioCollector { - onStreamFinished: { - const values = text.trim().split("\n").map(v => parseInt(v, 10)).filter(v => Number.isFinite(v)); - - if (values.length > 0) { - const totalBytes = values.reduce((a, b) => a + b, 0); - root.gpuMemTotal = totalBytes / (1024 * 1024); - } - - oneshotMemAmd.running = false; - } - } - } - - Process { - id: gpuUsageNvidia - - command: ["/usr/bin/nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu,memory.used", "--format=csv,noheader,nounits", "-lms", "1000"] - running: root.refCount > 0 && root.gpuType === "NVIDIA" - - stdout: SplitParser { - onRead: data => { - const parts = String(data).trim().split(/\s*,\s*/); - if (parts.length < 3) - return; - - const usageRaw = parseInt(parts[0], 10); - const tempRaw = parseInt(parts[1], 10); - const memRaw = parseInt(parts[2], 10); - - if (!Number.isFinite(usageRaw) || !Number.isFinite(tempRaw) || !Number.isFinite(memRaw)) - return; - - const newGpuPerc = Math.max(0, Math.min(1, usageRaw / 100)); - const newGpuTemp = tempRaw; - const newGpuMemUsed = root.gpuMemTotal > 0 ? Math.max(0, Math.min(1, memRaw / root.gpuMemTotal)) : 0; - - // Only publish meaningful changes to avoid needless binding churn / repaints - if (Math.abs(root.gpuPerc - newGpuPerc) >= 0.01) - root.gpuPerc = newGpuPerc; - - if (Math.abs(root.gpuTemp - newGpuTemp) >= 1) - root.gpuTemp = newGpuTemp; - - if (Math.abs(root.gpuMemUsed - newGpuMemUsed) >= 0.01) - root.gpuMemUsed = newGpuMemUsed; - } - } - } - - Process { - id: gpuUsage - - command: root.gpuType === "GENERIC" ? ["sh", "-c", "paste -d ' ' /sys/class/drm/card*/device/gpu_busy_percent /sys/class/drm/card*/device/mem_info_vram_used"] : ["echo"] - - stdout: StdioCollector { - onStreamFinished: { - if (root.gpuType === "GENERIC") { - const lines = text.trim().split("\n"); - - let percSum = 0; - let memSum = 0; - let count = 0; - - for (const line of lines) { - const parts = line.trim().split(/\s+/); - if (parts.length < 2) - continue; - - const gpuBusy = parseInt(parts[0], 10); - const memUsed = parseInt(parts[1], 10); - - if (!Number.isFinite(gpuBusy) || !Number.isFinite(memUsed)) - continue; - - percSum += gpuBusy; - memSum += memUsed; - count++; - } - - if (count > 0) { - // GPU usage % - root.gpuPerc = (percSum / count) / 100; - - // VRAM usage (bytes → MiB → normalized) - const memUsedMiB = memSum / (1024 * 1024); - - const newGpuMemUsed = root.gpuMemTotal > 0 ? Math.max(0, Math.min(1, memUsedMiB / root.gpuMemTotal)) : 0; - - if (Math.abs(root.gpuMemUsed - newGpuMemUsed) >= 0.01) - root.gpuMemUsed = newGpuMemUsed; - } - } else { - root.gpuPerc = 0; - root.gpuTemp = 0; - } - } - } - } - - Process { - id: sensors - - command: ["sensors"] - environment: ({ - LANG: "C.UTF-8", - LC_ALL: "C.UTF-8" - }) - - stdout: StdioCollector { - onStreamFinished: { - let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); - if (!cpuTemp) - // If AMD Tdie pattern failed, try fallback on Tctl - cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); - - if (cpuTemp && Math.abs(parseFloat(cpuTemp[1]) - root.cpuTemp) >= 0.5) - root.cpuTemp = parseFloat(cpuTemp[1]); - - if (root.gpuType !== "GENERIC") - return; - - let eligible = false; - let sum = 0; - let count = 0; - - for (const line of text.trim().split("\n")) { - if (line === "Adapter: PCI adapter") - eligible = true; - else if (line === "") - eligible = false; - else if (eligible) { - let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); - if (!match) - // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) - match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); - - if (match) { - sum += parseFloat(match[2]); - count++; - } - } - } - - root.gpuTemp = count > 0 ? sum / count : 0; - } - } - } -} diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index c2609ad..e31ba1d 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -32,33 +32,26 @@ Searcher { showPreview = true; } - function setCrop(screen: string, rect: rect, scaledRect: rect, zoom: real): void { - let updated = Object.assign({}, root.crops); - + function setCrop(screen: string, rect: rect, zoom: real): void { if (zoom <= 0) zoom = 1.0; else if (zoom > 5.0) zoom = 5.0; - updated[screen] = { + root.crops[screen] = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, - scaledX: scaledRect.x, - scaledY: scaledRect.y, - scaledWidth: scaledRect.width, - scaledHeight: scaledRect.height, zoom: zoom }; - - root.crops = updated; + // root.crops = updated; } function setWallpaper(path: string): void { actualCurrent = path; WallpaperPath.currentWallpaperPath = path; - Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), Qt.rect(0, 0, 0, 0), 1.0)); + 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}`]); @@ -91,7 +84,7 @@ Searcher { path: `${Paths.state}/wallpaper-crops.json` watchChanges: true - onAdapterUpdated: writeAdapter() + onAdapterUpdated: cropWriteDelay.restart() onFileChanged: reload() JsonAdapter { @@ -101,6 +94,16 @@ Searcher { } } + Timer { + id: cropWriteDelay + + interval: 100 + repeat: false + running: false + + onTriggered: monitorCrops.writeAdapter() + } + FileSystemModel { id: wallpapers diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 9ac299b..a2dd682 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -18,6 +18,7 @@ RowLayout { required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen + required property bool fullscreen readonly property int vPadding: 6 required property PersistentProperties visibilities @@ -91,6 +92,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: HyprsunsetWidget { + visible: !root.fullscreen } } } @@ -109,6 +111,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: Workspaces { screen: root.screen + visible: !root.fullscreen } } } @@ -120,6 +123,7 @@ RowLayout { sourceComponent: TrayWidget { loader: root popouts: root.popouts + visible: !root.fullscreen } } } @@ -130,6 +134,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: Resources { visibilities: root.visibilities + visible: !root.fullscreen } } } @@ -139,6 +144,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: UpdatesWidget { + visible: !root.fullscreen } } } @@ -150,6 +156,7 @@ RowLayout { sourceComponent: NotifBell { popouts: root.popouts visibilities: root.visibilities + visible: !root.fullscreen } } } @@ -162,6 +169,7 @@ RowLayout { loader: root popouts: root.popouts visibilities: root.visibilities + visible: !root.fullscreen } } } @@ -172,6 +180,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: WindowTitle { bar: root + visible: !root.fullscreen } } } @@ -190,6 +199,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: MediaWidget { + visible: !root.fullscreen } } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index b5e0d75..3fd00e1 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -17,12 +17,13 @@ Item { readonly property int contentHeight: Config.barConfig.height + padding * 2 readonly property int exclusiveZone: Config.barConfig.autoHide ? Config.barConfig.border : contentHeight + required property bool fullscreen property bool isHovered readonly property int padding: Math.max(Appearance.padding.smaller, Config.barConfig.border) required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen - readonly property bool shouldBeVisible: (!Config.barConfig.autoHide || visibilities.bar || isHovered) + readonly property bool shouldBeVisible: !fullscreen && (!Config.barConfig.autoHide || visibilities.bar || isHovered) readonly property int vPadding: 6 required property PersistentProperties visibilities @@ -30,7 +31,7 @@ Item { content.item?.checkPopout(x); } - implicitHeight: Config.barConfig.border + implicitHeight: fullscreen ? 0 : Config.barConfig.border visible: height > Config.barConfig.border states: State { @@ -75,6 +76,7 @@ Item { anchors.right: parent.right sourceComponent: Bar { + fullscreen: root.fullscreen height: root.contentHeight popouts: root.popouts popoutsWrapper: root.popoutsWrapper diff --git a/Modules/ClipWrapper.qml b/Modules/ClipWrapper.qml index 6b735f5..10fbd82 100644 --- a/Modules/ClipWrapper.qml +++ b/Modules/ClipWrapper.qml @@ -8,6 +8,7 @@ import qs.Config Item { id: root + required property real borderThickness readonly property alias content: content property real offsetScale: y > 0 || content.hasCurrent ? 0 : 1 required property ShellScreen screen @@ -17,21 +18,15 @@ Item { implicitWidth: content.implicitWidth visible: width > 0 && height > 0 x: { - if (content.isDetached) - return (parent.width - content.nonAnimWidth) / 2; - - const off = content.currentCenter - Config.barConfig.border - content.nonAnimWidth / 2; + const off = content.currentCenter - borderThickness - content.nonAnimWidth / 2; const diff = parent.width - Math.floor(off + content.nonAnimWidth); if (diff < 0) return off + diff; return Math.floor(Math.max(off, 0)); } - y: content.isDetached ? (parent.height - content.nonAnimHeight) / 2 : 0 Behavior on offsetScale { Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Behavior on x { diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml new file mode 100644 index 0000000..8df9f84 --- /dev/null +++ b/Modules/Clipboard/Content.qml @@ -0,0 +1,357 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +Item { + id: root + + readonly property int itemHeight: Config.clipboard.sizes.itemHeight + required property ShellScreen screen + required property PersistentProperties visibilities + + implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing + implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin + + Component.onCompleted: { + if (ClipHistory.entries.length === 0) + ClipHistory.refresh(); + searchField.forceActiveFocus(); + } + + CustomClippingRect { + id: search + + anchors.left: parent.left + anchors.right: entries.right + anchors.top: parent.top + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: 50 + radius: Appearance.rounding.full + + MaterialIcon { + id: searchIcon + + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.verticalCenter: parent.verticalCenter + text: "search" + } + + CustomTextField { + id: searchField + + anchors.bottom: parent.bottom + anchors.left: searchIcon.right + anchors.leftMargin: Appearance.spacing.small + anchors.right: parent.right + anchors.top: parent.top + color: DynamicColors.palette.m3onSurface + placeholderText: "Search clipboard history..." + + Keys.onDownPressed: view.incrementCurrentIndex() + Keys.onUpPressed: view.decrementCurrentIndex() + onAccepted: { + ClipHistory.copy(view.currentItem.modelData); + root.visibilities.clipboard = false; + } + } + } + + CustomClippingRect { + id: preview + + anchors.bottom: parent.bottom + anchors.left: entries.right + anchors.leftMargin: Appearance.spacing.normal + anchors.top: parent.top + color: DynamicColors.tPalette.m3surfaceContainer + implicitWidth: ClipHistory.previewIsImage ? Math.max(Math.min(imagePreview.sourceSize.width + Appearance.padding.large * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) : Math.max(Math.min(textPreviewColumn.width + textPreviewColumn.anchors.margins * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) + radius: 25 + + Behavior on implicitWidth { + Anim { + } + } + + Column { + id: textPreviewColumn + + anchors.left: parent.left + anchors.leftMargin: 0 + anchors.margins: Appearance.padding.normal + anchors.top: parent.top + visible: !ClipHistory.previewIsImage + + Repeater { + id: processedLines + + model: ClipHistory.previewText + + Row { + id: lineRow + + required property int index + required property var modelData + + spacing: Appearance.spacing.normal + + CustomRect { + color: lineRow.index % 2 ? DynamicColors.tPalette.m3surfaceContainer : "transparent" + implicitHeight: lineText.paintedHeight + Appearance.padding.extraSmall * 2 + implicitWidth: 50 + + CustomText { + id: number + + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: lineRow.modelData.line + } + } + + Repeater { + model: lineRow.modelData.text.split("\t").slice(1) + + RowLayout { + height: lineText.height + width: lineText.height + Appearance.spacing.extraSmall + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + CustomRect { + anchors.centerIn: parent + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: parent.height / 8 + implicitWidth: parent.height / 8 + radius: Appearance.rounding.full + } + } + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + CustomRect { + anchors.centerIn: parent + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: parent.height / 8 + implicitWidth: parent.height / 8 + radius: Appearance.rounding.full + } + } + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + CustomRect { + anchors.centerIn: parent + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: parent.height / 8 + implicitWidth: parent.height / 8 + radius: Appearance.rounding.full + } + } + } + } + + CustomText { + id: lineText + + color: DynamicColors.palette.m3onSurface + font.family: ClipHistory.previewIsCode ? Appearance.font.family.mono : Appearance.font.family.sans + height: lineText.paintedHeight + Appearance.padding.extraSmall * 2 + text: lineRow.modelData.text.trim() + verticalAlignment: Text.AlignVCenter + wrapMode: Text.Wrap + } + } + } + } + + Image { + id: imagePreview + + anchors.centerIn: parent + asynchronous: true + cache: false + fillMode: Image.PreserveAspectFit + mipmap: true + retainWhileLoading: true + smooth: true + source: ClipHistory.previewImageSource + visible: ClipHistory.previewIsImage && ClipHistory.previewImageSource !== "" + width: Math.min(sourceSize.width, Config.clipboard.sizes.previewWidth) + } + } + + Item { + id: entries + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: search.bottom + anchors.topMargin: Appearance.spacing.normal + implicitWidth: Config.clipboard.sizes.width + + CustomListView { + id: view + + anchors.fill: parent + cacheBuffer: (root.itemHeight + spacing) * 2 + highlightFollowsCurrentItem: false + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: 0 + preferredHighlightEnd: height + spacing: Appearance.spacing.normal + + CustomScrollBar.vertical: CustomScrollBar { + flickable: view + minimumSize: 0.1 + } + delegate: CustomRect { + id: clipItem + + readonly property bool isImage: ClipHistory.entryIsImage(modelData) + required property string modelData + + implicitHeight: root.itemHeight + implicitWidth: view.width + radius: textLayer.pressed ? (Appearance.rounding.small / 2) : Appearance.rounding.small + + Behavior on radius { + Anim { + type: Anim.FastEffects + } + } + + RowLayout { + anchors.fill: parent + spacing: Appearance.spacing.small + + CustomClippingRect { + id: textRect + + Layout.fillHeight: true + Layout.fillWidth: true + + Item { + id: textWrapper + + anchors.fill: parent + layer.enabled: true + + layer.effect: OpacityMask { + maskSource: fadeMask + } + + MaterialIcon { + id: icon + + anchors.left: parent.left + anchors.margins: Appearance.padding.normal + anchors.verticalCenter: parent.verticalCenter + font.pointSize: Appearance.font.size.large + text: clipItem.isImage ? "image" : "text_fields" + } + + CustomText { + id: text + + anchors.left: icon.right + anchors.margins: Appearance.spacing.normal + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + text: clipItem.isImage ? qsTr("Image") : ClipHistory.displayText(clipItem.modelData) + } + } + + CustomRect { + id: fadeMask + + anchors.fill: parent + 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 + } + } + } + } + + IconButton { + Layout.fillHeight: true + Layout.margins: Appearance.padding.normal + Layout.preferredWidth: height + icon: "delete" + inactiveColor: Qt.alpha(DynamicColors.palette.m3error, 0.8) + inactiveOnColor: DynamicColors.palette.m3onError + isToggle: false + + onClicked: ClipHistory.deleteEntry(clipItem.modelData) + } + } + + StateLayer { + id: textLayer + + onClicked: ClipHistory.copy(clipItem.modelData) + } + } + highlight: CustomRect { + color: DynamicColors.palette.m3onSurface + implicitHeight: view.currentItem?.height ?? 0 + implicitWidth: view.width + opacity: 0.08 + radius: Appearance.rounding.small + y: view.currentItem?.y ?? 0 + + Behavior on y { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.expressiveEffects + } + } + } + model: ScriptModel { + values: ClipHistory.fuzzyQuery(searchField.text) + } + + onCurrentItemChanged: { + if (!currentItem) + return; + + ClipHistory.currentEntry = currentItem.modelData; + ClipHistory.refreshPreview(); + } + onVisibleChanged: currentIndex = 0 + + CustomClippingWrapperRect { + anchors.fill: parent + child: view.contentItem + radius: Appearance.rounding.small + } + } + } +} diff --git a/Modules/Clipboard/Wrapper.qml b/Modules/Clipboard/Wrapper.qml new file mode 100644 index 0000000..fff295e --- /dev/null +++ b/Modules/Clipboard/Wrapper.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import qs.Components +import qs.Config + +Item { + id: root + + 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 + + anchors.bottomMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: content.implicitWidth + Appearance.padding.normal * 2 || 400 + opacity: 1 - offsetScale + visible: offsetScale < 1 + + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Loader { + id: content + + active: root.shouldBeActive || root.visible + anchors.centerIn: parent + + sourceComponent: Content { + screen: root.screen + visibilities: root.visibilities + } + } +} diff --git a/Modules/Clock.qml b/Modules/Clock.qml index 67ff432..ccbd8cd 100644 --- a/Modules/Clock.qml +++ b/Modules/Clock.qml @@ -34,6 +34,7 @@ CustomRect { StateLayer { acceptedButtons: Qt.LeftButton + color: root.visibilities.dashboard ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface onClicked: { root.visibilities.dashboard = !root.visibilities.dashboard; diff --git a/Modules/Content.qml b/Modules/Content.qml index f3035d3..f48932c 100644 --- a/Modules/Content.qml +++ b/Modules/Content.qml @@ -17,8 +17,8 @@ Item { readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null required property PopoutState popouts - implicitHeight: (currentPopout?.implicitHeight ?? 0) + 5 * 2 - implicitWidth: (currentPopout?.implicitWidth ?? 0) + 5 * 2 + implicitHeight: (currentPopout?.implicitHeight ?? 0) + Appearance.padding.small * 2 + implicitWidth: (currentPopout?.implicitWidth ?? 0) + Appearance.padding.small * 2 Item { id: content @@ -28,7 +28,7 @@ Item { Popout { name: "audio" - sourceComponent: AudioPopup { + sourceComponent: AudioPopout { wrapper: root.wrapper } } @@ -73,7 +73,6 @@ Item { name: "upower" sourceComponent: UPowerPopout { - wrapper: root.popouts } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 867004e..4678257 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -10,9 +10,9 @@ import qs.Components Item { id: root + required property PersistentProperties dashState readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - required property PersistentProperties state required property PersistentProperties visibilities implicitHeight: nonAnimHeight @@ -59,7 +59,7 @@ Item { index: 0 sourceComponent: Dash { - state: root.state + dashState: root.dashState visibilities: root.visibilities } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index d7a83f8..56066cb 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -10,9 +10,9 @@ import qs.Modules.Dashboard.Dash GridLayout { id: root + required property PersistentProperties dashState readonly property bool dashboardVisible: visibilities.dashboard property int radius: Appearance.rounding.smallest - required property PersistentProperties state required property PersistentProperties visibilities columnSpacing: Appearance.spacing.smaller @@ -60,7 +60,7 @@ GridLayout { User { id: user - state: root.state + dashState: root.dashState } } @@ -86,7 +86,7 @@ GridLayout { Calendar { id: calendar - state: root.state + dashState: root.dashState } } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index dcb9c3c..37a283b 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -1,25 +1,50 @@ pragma ComponentBehavior: Bound +import Quickshell import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Components -import qs.Helpers import qs.Config -import qs.Modules CustomMouseArea { id: root - readonly property int currMonth: state.currentDate.getMonth() - readonly property int currYear: state.currentDate.getFullYear() - required property var state + property int activeGrid: 1 + required property PersistentProperties dashState + property bool initialized: false + property int month1 + property int month2 + readonly property int realCurrMonth: dashState.currentDate.getMonth() + readonly property int realCurrYear: dashState.currentDate.getFullYear() + property int year1 + property int year2 + + function handleDateChange() { + const currM = activeGrid === 1 ? month1 : month2; + const currY = activeGrid === 1 ? year1 : year2; + if (realCurrMonth !== currM || realCurrYear !== currY) { + monthChangeAnim.direction = (realCurrYear > currY || (realCurrYear === currY && realCurrMonth > currM)) ? -1 : 1; + + if (activeGrid === 1) { + month2 = realCurrMonth; + year2 = realCurrYear; + activeGrid = 2; + } else { + month1 = realCurrMonth; + year1 = realCurrYear; + activeGrid = 1; + } + if (initialized) + monthChangeAnim.restart(); + } + } function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) - root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); + root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); else if (event.angleDelta.y < 0) - root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); + root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); } acceptedButtons: Qt.MiddleButton @@ -27,102 +52,160 @@ CustomMouseArea { anchors.right: parent.right implicitHeight: inner.implicitHeight + inner.anchors.margins * 2 - onClicked: root.state.currentDate = new Date() + Component.onCompleted: { + month1 = realCurrMonth; + year1 = realCurrYear; + month2 = realCurrMonth; + year2 = realCurrYear; + + initialized = true; + } + onClicked: root.dashState.currentDate = new Date() + onRealCurrMonthChanged: handleDateChange() + onRealCurrYearChanged: handleDateChange() + + SequentialAnimation { + id: monthChangeAnim + + property int direction: 0 + + ScriptAction { + script: { + if (activeGrid === 1) { + titleTranslate1.x = -monthChangeAnim.direction * titleClip.width; + grid1Translate.x = -monthChangeAnim.direction * gridClip.width; + titleTranslate2.x = 0; + grid2Translate.x = 0; + } else { + titleTranslate2.x = -monthChangeAnim.direction * titleClip.width; + grid2Translate.x = -monthChangeAnim.direction * gridClip.width; + titleTranslate1.x = 0; + grid1Translate.x = 0; + } + } + } + + ParallelAnimation { + Anim { + property: "x" + target: titleTranslate1 + to: root.activeGrid === 1 ? 0 : monthChangeAnim.direction * titleClip.width + type: Anim.DefaultSpatial + } + + Anim { + property: "x" + target: grid1Translate + to: root.activeGrid === 1 ? 0 : monthChangeAnim.direction * gridClip.width + type: Anim.DefaultSpatial + } + + Anim { + property: "x" + target: titleTranslate2 + to: root.activeGrid === 2 ? 0 : monthChangeAnim.direction * titleClip.width + type: Anim.DefaultSpatial + } + + Anim { + property: "x" + target: grid2Translate + to: root.activeGrid === 2 ? 0 : monthChangeAnim.direction * gridClip.width + type: Anim.DefaultSpatial + } + } + } ColumnLayout { id: inner anchors.fill: parent anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.small + spacing: Appearance.spacing.extraSmall RowLayout { id: monthNavigationRow Layout.fillWidth: true - spacing: Appearance.spacing.small + spacing: Appearance.spacing.extraSmall - Item { - implicitHeight: prevMonthText.implicitHeight + Appearance.padding.small * 2 - implicitWidth: implicitHeight + IconButton { + icon: "chevron_left" + padding: Appearance.padding.small + type: IconButton.Text - StateLayer { - id: prevMonthStateLayer - - function onClicked(): void { - root.state.currentDate = new Date(root.currYear, root.currMonth - 1, 1); - } - - radius: Appearance.rounding.full - } - - MaterialIcon { - id: prevMonthText - - anchors.centerIn: parent - color: DynamicColors.palette.m3tertiary - font.pointSize: Appearance.font.size.normal - font.weight: 700 - text: "chevron_left" - } + onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) } Item { + Layout.fillHeight: true Layout.fillWidth: true - implicitHeight: monthYearDisplay.implicitHeight + Appearance.padding.small * 2 - implicitWidth: monthYearDisplay.implicitWidth + Appearance.padding.small * 2 + implicitHeight: monthYearDisplay1.implicitHeight + Appearance.padding.extraSmall * 2 + implicitWidth: monthYearDisplay1.implicitWidth + Appearance.padding.large * 2 StateLayer { - function onClicked(): void { - root.state.currentDate = new Date(); + color: DynamicColors.palette.m3primary + enabled: { + const now = new Date(); + return root.realCurrMonth !== now.getMonth() || root.realCurrYear !== now.getFullYear(); + } + radius: pressed ? Appearance.rounding.small : Appearance.rounding.large + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } } - anchors.fill: monthYearDisplay - anchors.leftMargin: -Appearance.padding.normal - anchors.margins: -Appearance.padding.small - anchors.rightMargin: -Appearance.padding.normal - disabled: { - const now = new Date(); - return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); - } - radius: Appearance.rounding.full + onClicked: root.dashState.currentDate = new Date() } - CustomText { - id: monthYearDisplay + Item { + id: titleClip - anchors.centerIn: parent - color: DynamicColors.palette.m3primary - font.capitalization: Font.Capitalize - font.pointSize: Appearance.font.size.normal - font.weight: 500 - text: grid.title + anchors.fill: parent + clip: true + + CustomText { + id: monthYearDisplay1 + + anchors.centerIn: parent + // qmllint enable missing-property + color: DynamicColors.palette.m3primary + + // qmllint disable missing-property + text: grid1.item ? grid1.item.title : "" + visible: root.activeGrid === 1 || monthChangeAnim.running + + transform: Translate { + id: titleTranslate1 + } + } + + CustomText { + id: monthYearDisplay2 + + anchors.centerIn: parent + // qmllint enable missing-property + color: DynamicColors.palette.m3primary + + // qmllint disable missing-property + text: grid2.item ? grid2.item.title : "" + visible: root.activeGrid === 2 || monthChangeAnim.running + + transform: Translate { + id: titleTranslate2 + } + } } } - Item { - implicitHeight: nextMonthText.implicitHeight + Appearance.padding.small * 2 - implicitWidth: implicitHeight + IconButton { + icon: "chevron_right" + padding: Appearance.padding.small + type: IconButton.Text - StateLayer { - id: nextMonthStateLayer - - function onClicked(): void { - root.state.currentDate = new Date(root.currYear, root.currMonth + 1, 1); - } - - radius: Appearance.rounding.full - } - - MaterialIcon { - id: nextMonthText - - anchors.centerIn: parent - color: DynamicColors.palette.m3tertiary - font.pointSize: Appearance.font.size.normal - font.weight: 700 - text: "chevron_right" - } + onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) } } @@ -130,109 +213,170 @@ CustomMouseArea { id: daysRow Layout.fillWidth: true - locale: grid.locale + locale: Qt.locale() delegate: CustomText { required property var model - color: (model.day === 0) ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3onSurfaceVariant - font.weight: 500 + color: (model.day === 0) ? DynamicColors.palette.m3tertiary : DynamicColors.palette.m3onSurface horizontalAlignment: Text.AlignHCenter - text: model.shortName + text: Qt.locale("en_US").standaloneDayName(model.day, Locale.ShortFormat) } } Item { + id: gridClip + Layout.fillWidth: true - implicitHeight: grid.implicitHeight + clip: true + implicitHeight: grid1.implicitHeight - MonthGrid { - id: grid + Component { + id: gridComp - anchors.fill: parent - locale: Qt.locale("en_SE") - month: root.currMonth - spacing: 3 - year: root.currYear + Item { + id: internalGridContainer - delegate: Item { - id: dayItem + property int month + property alias title: internalGrid.title + property int year - required property var model + implicitHeight: internalGrid.implicitHeight - implicitHeight: text.implicitHeight + Appearance.padding.small * 2 - implicitWidth: implicitHeight + MonthGrid { + id: internalGrid - CustomText { - id: text + anchors.fill: parent + locale: daysRow.locale + month: internalGridContainer.month + spacing: 3 + title: `${Qt.locale("en_US").standaloneMonthName(month, Locale.LongFormat)} ${year}` + year: internalGridContainer.year - anchors.centerIn: parent - color: { - const dayOfWeek = dayItem.model.date.getUTCDay(); - if (dayOfWeek === 6) - return DynamicColors.palette.m3secondary; + delegate: Item { + id: dayItem - return DynamicColors.palette.m3onSurfaceVariant; + required property var model + + implicitHeight: text.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: implicitHeight + + CustomText { + id: text + + anchors.centerIn: parent + color: { + const dayOfWeek = dayItem.model.date.getDay(); + if (dayOfWeek === 0) + return DynamicColors.palette.m3tertiary; + + return DynamicColors.palette.m3onSurfaceVariant; + } + horizontalAlignment: Text.AlignHCenter + opacity: dayItem.model.today || dayItem.model.month === internalGrid.month ? 1 : 0.4 + text: internalGrid.locale.toString(dayItem.model.day) + } + } + } + + CustomRect { + id: todayIndicator + + property Item today + readonly property Item todayItem: internalGrid.contentItem.children.find(c => c.model.today) ?? null + + clip: true + color: DynamicColors.palette.m3primary + implicitHeight: width + implicitWidth: today ? (Math.max(today.implicitWidth, today.implicitHeight) - Appearance.padding.extraSmall) : 0 + opacity: todayItem ? 1 : 0 + radius: Appearance.rounding.full + scale: todayItem ? 1 : 0.7 + x: today ? today.x + (today.width - implicitWidth) / 2 : 0 + y: today ? today.y + Appearance.padding.extraSmall / 2 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on scale { + Anim { + type: Anim.FastSpatial + } + } + Behavior on x { + Anim { + } + } + Behavior on y { + Anim { + } + } + + onTodayItemChanged: { + if (todayItem) + today = todayItem; + } + + Coloriser { + colorizationColor: DynamicColors.palette.m3onPrimary + implicitHeight: internalGrid.height + implicitWidth: internalGrid.width + source: internalGrid + sourceColor: DynamicColors.palette.m3onSurface + x: -todayIndicator.x + y: -todayIndicator.y } - font.pointSize: Appearance.font.size.normal - font.weight: 500 - horizontalAlignment: Text.AlignHCenter - opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 - text: grid.locale.toString(dayItem.model.day) } } } - CustomRect { - id: todayIndicator + Loader { + id: grid1 - property Item today - readonly property Item todayItem: grid.contentItem.children.find(c => c.model.today) ?? null + anchors.fill: parent + sourceComponent: gridComp + visible: root.activeGrid === 1 || monthChangeAnim.running - clip: true - color: DynamicColors.palette.m3primary - implicitHeight: today?.implicitHeight ?? 0 - implicitWidth: today?.implicitWidth ?? 0 - opacity: todayItem ? 1 : 0 - radius: Appearance.rounding.full - scale: todayItem ? 1 : 0.7 - x: today ? today.x + (today.width - implicitWidth) / 2 : 0 - y: today?.y ?? 0 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - Behavior on x { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - Behavior on y { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } + transform: Translate { + id: grid1Translate } - onTodayItemChanged: { - if (todayItem) - today = todayItem; + Binding { + property: "month" + target: grid1.item + value: root.month1 } - Coloriser { - colorizationColor: DynamicColors.palette.m3onPrimary - implicitHeight: grid.height - implicitWidth: grid.width - source: grid - sourceColor: DynamicColors.palette.m3onSurface - x: -todayIndicator.x - y: -todayIndicator.y + Binding { + property: "year" + target: grid1.item + value: root.year1 + } + } + + Loader { + id: grid2 + + anchors.fill: parent + sourceComponent: gridComp + visible: root.activeGrid === 2 || monthChangeAnim.running + + transform: Translate { + id: grid2Translate + } + + Binding { + property: "month" + target: grid2.item + value: root.month2 + } + + Binding { + property: "year" + target: grid2.item + value: root.year2 } } } diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml index 4b47b81..3dba6e8 100644 --- a/Modules/Dashboard/Dash/Media.qml +++ b/Modules/Dashboard/Dash/Media.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import QtQuick.Effects import ZShell.Services import qs.Daemons import qs.Components @@ -15,7 +16,7 @@ Item { property real playerProgress: { const active = Players.active; - return active?.length ? active.position / active.length : 0; + return active?.length ? (active.position % active.length) / active.length : 0; } property int rowHeight: Appearance.padding.large + Config.dashboard.sizes.mediaProgressThickness + Appearance.spacing.small @@ -45,49 +46,85 @@ Item { Shape { id: visualizer - readonly property real barW: Math.max(0, (width - gap * (bars - 1)) / bars) readonly property int bars: Config.services.visualizerBars - property color color: DynamicColors.palette.m3primary - readonly property real gap: Appearance.spacing.small + property color color: DynamicColors.palette.m3tertiary + property color fillColor: Qt.alpha(color, 0.25) anchors.fill: layout + anchors.leftMargin: -(shape.strokeWidth / 2) + layer.enabled: true asynchronous: true - data: visualizerBars.instances preferredRendererType: Shape.CurveRenderer - } - - Variants { - id: visualizerBars - - model: Array.from({ - length: Config.services.visualizerBars - }, (_, i) => i) ShapePath { - id: visualizerBar - - readonly property real magnitude: value * Config.dashboard.sizes.mediaVisualiserSize - required property int modelData - readonly property real value: Math.max(1e-3, Audio.cava.values[modelData]) - - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - startX: (visualizer.barW / 2) + modelData * (visualizer.barW + visualizer.gap) - startY: layout.y + layout.height + id: shape strokeColor: visualizer.color - strokeWidth: visualizer.barW + fillColor: visualizer.fillColor + strokeWidth: 2 - Behavior on strokeColor { - CAnim { + PathSvg { + path: shape.curvePath + } + + property string curvePath: { + const values = Audio.cava.values; + const n = values.length; + + if (n < 2) + return ""; + + const h = layout.height + shape.strokeWidth / 2; + const w = layout.width + shape.strokeWidth; + + function x(i) { + return i * w / (n - 1); } - } - PathLine { - relativeX: 0 - relativeY: -visualizerBar.magnitude + function y(i) { + return h - values[i] * Config.dashboard.sizes.mediaVisualizerSize; + } + + 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(DynamicColors.palette.m3shadow, 0.2) + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: visualizer + } + } + Shape { preferredRendererType: Shape.CurveRenderer @@ -295,12 +332,12 @@ Item { StateLayer { id: controlState - function onClicked(): void { + color: control.canUse ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] + enabled: control.canUse + + onClicked: { control.onClicked(); } - - color: control.canUse ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] - disabled: !control.canUse // radius: Appearance.rounding.full } diff --git a/Modules/Dashboard/Dash/Resources.qml b/Modules/Dashboard/Dash/Resources.qml index cc34a52..12c4096 100644 --- a/Modules/Dashboard/Dash/Resources.qml +++ b/Modules/Dashboard/Dash/Resources.qml @@ -1,92 +1,89 @@ import QtQuick +import QtQuick.Layouts +import ZShell.Services import qs.Components import qs.Helpers import qs.Config -Row { +Item { id: root anchors.bottom: parent.bottom anchors.top: parent.top - padding: Appearance.padding.large - spacing: Appearance.spacing.large + implicitWidth: layout.implicitWidth + layout.anchors.margins * 4 - Ref { - service: SystemUsage + ServiceRef { + service: Storage } - Resource { - color: DynamicColors.palette.m3primary - icon: "memory" - value: SystemUsage.cpuPerc + ServiceRef { + service: Memory } - Resource { - color: DynamicColors.palette.m3secondary - icon: "memory_alt" - value: SystemUsage.memPerc + ServiceRef { + service: Cpu } - Resource { - color: DynamicColors.palette.m3tertiary - icon: "gamepad" - value: SystemUsage.gpuPerc + ServiceRef { + service: Gpu } - Resource { - color: DynamicColors.palette.m3primary - icon: "host" - value: SystemUsage.gpuMemUsed - } - - Resource { - color: DynamicColors.palette.m3secondary - icon: "hard_disk" - value: SystemUsage.storagePerc - } - - component Resource: Item { - id: res - - required property color color - required property string icon - required property real value + ColumnLayout { + id: layout anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Appearance.padding.large anchors.top: parent.top - implicitWidth: icon.implicitWidth + spacing: Appearance.spacing.normal - Behavior on value { - Anim { - duration: Appearance.anim.durations.large - } + Resource { + fgColor: DynamicColors.palette.m3primary + icon: "memory" + value: Cpu.percentage } - CustomRect { - anchors.bottom: icon.top - anchors.bottomMargin: Appearance.spacing.small - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - implicitWidth: Config.dashboard.sizes.resourceProgessThickness - radius: Appearance.rounding.full + Resource { + fgColor: DynamicColors.palette.m3secondary + icon: "memory_alt" + value: Memory.percentage + } - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - color: res.color - implicitHeight: res.value * parent.height - radius: Appearance.rounding.full + Resource { + fgColor: DynamicColors.palette.m3tertiary + icon: "gamepad" + value: Gpu.percentage + } + + Resource { + fgColor: DynamicColors.palette.m3primary + icon: "host" + value: Gpu.memoryUsed / Gpu.memoryTotal + } + + Resource { + fgColor: DynamicColors.palette.m3secondary + icon: "hard_disk" + value: Storage.percentage + } + } + + component Resource: CircularProgress { + id: res + + required property string icon + + Layout.fillHeight: true + implicitSize: height + + Behavior on clampedVal { + Anim { } } MaterialIcon { - id: icon - - anchors.bottom: parent.bottom - color: res.color + anchors.centerIn: parent + color: res.fgColor text: res.icon } } diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 80ee719..8109fb3 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -9,7 +9,7 @@ import QtQuick Row { id: root - required property PersistentProperties state + required property PersistentProperties dashState padding: 20 spacing: 12 diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index 791a31c..26da1e5 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -32,7 +32,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter sourceComponent: Content { - state: root.dashState + dashState: root.dashState visibilities: root.visibilities } } diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml index e32cf9e..78d5c8c 100644 --- a/Modules/DesktopIcons/DesktopIconContextMenu.qml +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -6,227 +6,264 @@ import qs.Components import qs.Config Item { - id: contextMenu + id: contextMenu - anchors.fill: parent - z: 999 - visible: false + property real menuX: 0 + property real menuY: 0 + property var targetAppEntry: null + property string targetFilePath: "" + property bool targetIsDir: false + property var targetPaths: [] - property string targetFilePath: "" - property bool targetIsDir: false - property var targetAppEntry: null + signal openFileRequested(string path, bool isDir) + signal renameRequested(string path) - property var targetPaths: [] + function close() { + visible = false; + } - signal openFileRequested(string path, bool isDir) - signal renameRequested(string path) + function openAt(mouseX, mouseY, path, isDir, appEnt, parentW, parentH, selectionArray) { + targetFilePath = path; + targetIsDir = isDir; + targetAppEntry = appEnt; - property real menuX: 0 - property real menuY: 0 + targetPaths = (selectionArray && selectionArray.length > 0) ? selectionArray : [path]; - CustomClippingRect { - id: popupBackground - readonly property real padding: Appearance.padding.small + menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)); + menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)); - x: contextMenu.menuX - y: contextMenu.menuY + visible = true; + } - color: DynamicColors.tPalette.m3surface - radius: Appearance.rounding.normal + anchors.fill: parent + visible: false + z: 999 - implicitWidth: menuLayout.implicitWidth + padding * 2 - implicitHeight: menuLayout.implicitHeight + padding * 2 + CustomClippingRect { + id: popupBackground - Behavior on opacity { Anim {} } - opacity: contextMenu.visible ? 1 : 0 + readonly property real padding: Appearance.padding.small - ColumnLayout { - id: menuLayout - anchors.centerIn: parent - spacing: 0 + color: DynamicColors.tPalette.m3surface + implicitHeight: menuLayout.implicitHeight + padding * 2 + implicitWidth: menuLayout.implicitWidth + padding * 2 + opacity: contextMenu.visible ? 1 : 0 + radius: Appearance.rounding.normal + x: contextMenu.menuX + y: contextMenu.menuY - CustomRect { - Layout.preferredWidth: 160 - radius: popupBackground.radius - popupBackground.padding - implicitHeight: openRow.implicitHeight + Appearance.padding.small * 2 + Behavior on opacity { + Anim { + } + } - RowLayout { - id: openRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Appearance.padding.smaller + ColumnLayout { + id: menuLayout - MaterialIcon { text: "open_in_new"; font.pointSize: 20 } - CustomText { text: "Open"; Layout.fillWidth: true } - } + anchors.centerIn: parent + spacing: 0 - StateLayer { - anchors.fill: parent + CustomRect { + Layout.preferredWidth: 160 + implicitHeight: openRow.implicitHeight + Appearance.padding.small * 2 + radius: popupBackground.radius - popupBackground.padding - onClicked: { - for (let i = 0; i < contextMenu.targetPaths.length; i++) { - let p = contextMenu.targetPaths[i]; - if (p === contextMenu.targetFilePath) { - if (p.endsWith(".desktop") && contextMenu.targetAppEntry) contextMenu.targetAppEntry.execute() - else contextMenu.openFileRequested(p, contextMenu.targetIsDir) - } else { - Quickshell.execDetached(["xdg-open", p]) - } - } - contextMenu.close() - } - } - } + RowLayout { + id: openRow - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: openWithRow.implicitHeight + Appearance.padding.small * 2 + anchors.fill: parent + anchors.leftMargin: Appearance.padding.smaller + spacing: 8 - RowLayout { - id: openWithRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Appearance.padding.smaller + MaterialIcon { + font.pointSize: 20 + text: "open_in_new" + } - MaterialIcon { text: contextMenu.targetIsDir ? "terminal" : "apps"; font.pointSize: 20 } - CustomText { text: contextMenu.targetIsDir ? "Open in terminal" : "Open with..."; Layout.fillWidth: true } - } + CustomText { + Layout.fillWidth: true + text: "Open" + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent + + onClicked: { + for (let i = 0; i < contextMenu.targetPaths.length; i++) { + let p = contextMenu.targetPaths[i]; + if (p === contextMenu.targetFilePath) { + if (p.endsWith(".desktop") && contextMenu.targetAppEntry) + contextMenu.targetAppEntry.execute(); + else + contextMenu.openFileRequested(p, contextMenu.targetIsDir); + } else { + Quickshell.execDetached(["xdg-open", p]); + } + } + contextMenu.close(); + } + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: openWithRow.implicitHeight + Appearance.padding.small * 2 + radius: popupBackground.radius - popupBackground.padding + + RowLayout { + id: openWithRow + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.smaller + spacing: 8 + + MaterialIcon { + font.pointSize: 20 + text: contextMenu.targetIsDir ? "terminal" : "apps" + } + + CustomText { + Layout.fillWidth: true + text: contextMenu.targetIsDir ? "Open in terminal" : "Open with..." + } + } + + StateLayer { + anchors.fill: parent onClicked: { if (contextMenu.targetIsDir) { - Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", contextMenu.targetFilePath]) + Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", contextMenu.targetFilePath]); } else { - Quickshell.execDetached(["xdg-open", contextMenu.targetFilePath]) + Quickshell.execDetached(["xdg-open", contextMenu.targetFilePath]); } - contextMenu.close() + contextMenu.close(); } - } - } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: DynamicColors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.bottomMargin: 4 + Layout.fillWidth: true + Layout.topMargin: 4 + color: DynamicColors.palette.m3outlineVariant + implicitHeight: 1 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: copyPathRow.implicitHeight + Appearance.padding.small * 2 + CustomRect { + Layout.fillWidth: true + implicitHeight: copyPathRow.implicitHeight + Appearance.padding.small * 2 + radius: popupBackground.radius - popupBackground.padding - RowLayout { - id: copyPathRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Appearance.padding.smaller + RowLayout { + id: copyPathRow - MaterialIcon { text: "content_copy"; font.pointSize: 20 } - CustomText { text: "Copy path"; Layout.fillWidth: true } - } + anchors.fill: parent + anchors.leftMargin: Appearance.padding.smaller + spacing: 8 - StateLayer { - anchors.fill: parent + MaterialIcon { + font.pointSize: 20 + text: "content_copy" + } - onClicked: { - Quickshell.execDetached(["wl-copy", contextMenu.targetPaths.join("\n")]) - contextMenu.close() - } - } - } + CustomText { + Layout.fillWidth: true + text: "Copy path" + } + } - CustomRect { - Layout.fillWidth: true - visible: contextMenu.targetPaths.length === 1 - radius: popupBackground.radius - popupBackground.padding - implicitHeight: renameRow.implicitHeight + Appearance.padding.small * 2 + StateLayer { + anchors.fill: parent - RowLayout { - id: renameRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Appearance.padding.smaller + onClicked: { + Quickshell.execDetached(["wl-copy", contextMenu.targetPaths.join("\n")]); + contextMenu.close(); + } + } + } - MaterialIcon { text: "edit"; font.pointSize: 20 } - CustomText { text: "Rename"; Layout.fillWidth: true } - } + CustomRect { + Layout.fillWidth: true + implicitHeight: renameRow.implicitHeight + Appearance.padding.small * 2 + radius: popupBackground.radius - popupBackground.padding + visible: contextMenu.targetPaths.length === 1 - StateLayer { - anchors.fill: parent + RowLayout { + id: renameRow - onClicked: { - contextMenu.renameRequested(contextMenu.targetFilePath) - contextMenu.close() - } - } - } + anchors.fill: parent + anchors.leftMargin: Appearance.padding.smaller + spacing: 8 - Rectangle { - Layout.fillWidth: true - implicitHeight: 1 - color: DynamicColors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + MaterialIcon { + font.pointSize: 20 + text: "edit" + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: deleteRow.implicitHeight + Appearance.padding.small * 2 + CustomText { + Layout.fillWidth: true + text: "Rename" + } + } - RowLayout { - id: deleteRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Appearance.padding.smaller + StateLayer { + anchors.fill: parent - MaterialIcon { - text: "delete" - font.pointSize: 20 - color: deleteButton.hovered ? DynamicColors.palette.m3onError : DynamicColors.palette.m3error - } + onClicked: { + contextMenu.renameRequested(contextMenu.targetFilePath); + contextMenu.close(); + } + } + } - CustomText { - text: "Move to trash" - Layout.fillWidth: true - color: deleteButton.hovered ? DynamicColors.palette.m3onError : DynamicColors.palette.m3error - } - } + Rectangle { + Layout.bottomMargin: 4 + Layout.fillWidth: true + Layout.topMargin: 4 + color: DynamicColors.palette.m3outlineVariant + implicitHeight: 1 + } - StateLayer { - id: deleteButton - anchors.fill: parent - color: DynamicColors.tPalette.m3error + CustomRect { + Layout.fillWidth: true + implicitHeight: deleteRow.implicitHeight + Appearance.padding.small * 2 + radius: popupBackground.radius - popupBackground.padding - onClicked: { - let cmd = ["gio", "trash"].concat(contextMenu.targetPaths) - Quickshell.execDetached(cmd) - contextMenu.close() - } - } - } - } - } + RowLayout { + id: deleteRow - function openAt(mouseX, mouseY, path, isDir, appEnt, parentW, parentH, selectionArray) { - targetFilePath = path - targetIsDir = isDir - targetAppEntry = appEnt + anchors.fill: parent + anchors.leftMargin: Appearance.padding.smaller + spacing: 8 - targetPaths = (selectionArray && selectionArray.length > 0) ? selectionArray : [path] + MaterialIcon { + color: deleteButton.hovered ? DynamicColors.palette.m3onError : DynamicColors.palette.m3error + font.pointSize: 20 + text: "delete" + } - menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)) - menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)) + CustomText { + Layout.fillWidth: true + color: deleteButton.hovered ? DynamicColors.palette.m3onError : DynamicColors.palette.m3error + text: "Move to trash" + } + } - visible = true - } + StateLayer { + id: deleteButton - function close() { - visible = false - } + anchors.fill: parent + color: DynamicColors.tPalette.m3error + + onClicked: { + let cmd = ["gio", "trash"].concat(contextMenu.targetPaths); + Quickshell.execDetached(cmd); + contextMenu.close(); + } + } + } + } + } } diff --git a/Modules/DesktopIcons/DesktopIconDelegate.qml b/Modules/DesktopIcons/DesktopIconDelegate.qml index 674a005..4a9d51a 100644 --- a/Modules/DesktopIcons/DesktopIconDelegate.qml +++ b/Modules/DesktopIcons/DesktopIconDelegate.qml @@ -6,16 +6,19 @@ import qs.Components import qs.Helpers Item { - id: delegateRoot + id: root property var appEntry: fileName.endsWith(".desktop") ? DesktopEntries.byId(DesktopUtils.getAppId(fileName)) : null - property bool fileIsDir: model.isDir - property string fileName: model.fileName - property string filePath: model.filePath - property int gridX: model.gridX - property int gridY: model.gridY + required property var contextMenu + property bool fileIsDir: modelData.isDir + property string fileName: modelData.fileName + property string filePath: modelData.filePath + property int gridX: modelData.gridX + property int gridY: modelData.gridY + required property Item iconsRoot property bool isSnapping: snapAnimX.running || snapAnimY.running property bool lassoActive + required property var modelData property string resolvedIcon: { if (fileName.endsWith(".desktop")) { if (appEntry && appEntry.icon && appEntry.icon !== "") @@ -29,8 +32,8 @@ Item { } function compensateAndSnap(absVisX, absVisY) { - dragContainer.x = absVisX - delegateRoot.x; - dragContainer.y = absVisY - delegateRoot.y; + dragContainer.x = absVisX - root.x; + dragContainer.y = absVisY - root.y; snapAnimX.start(); snapAnimY.start(); } @@ -43,19 +46,19 @@ Item { return dragContainer.y; } - height: root.cellHeight - width: root.cellWidth - x: gridX * root.cellWidth - y: gridY * root.cellHeight + height: root.iconsRoot.cellHeight + width: root.iconsRoot.cellWidth + x: gridX * root.iconsRoot.cellWidth + y: gridY * root.iconsRoot.cellHeight Behavior on x { - enabled: !mouseArea.drag.active && !isSnapping && !root.selectedIcons.includes(filePath) + enabled: !mouseArea.drag.active && !root.isSnapping && !root.iconsRoot.selectedIcons.includes(root.filePath) Anim { } } Behavior on y { - enabled: !mouseArea.drag.active && !isSnapping && !root.selectedIcons.includes(filePath) + enabled: !mouseArea.drag.active && !root.isSnapping && !root.iconsRoot.selectedIcons.includes(root.filePath) Anim { } @@ -78,8 +81,8 @@ Item { } } transform: Translate { - x: (root.selectedIcons.includes(filePath) && root.dragLeader !== "" && root.dragLeader !== filePath) ? root.groupDragX : 0 - y: (root.selectedIcons.includes(filePath) && root.dragLeader !== "" && root.dragLeader !== filePath) ? root.groupDragY : 0 + x: (root.iconsRoot.selectedIcons.includes(root.filePath) && root.iconsRoot.dragLeader !== "" && root.iconsRoot.dragLeader !== root.filePath) ? root.iconsRoot.groupDragX : 0 + y: (root.iconsRoot.selectedIcons.includes(root.filePath) && root.iconsRoot.dragLeader !== "" && root.iconsRoot.dragLeader !== root.filePath) ? root.iconsRoot.groupDragY : 0 } transitions: Transition { Anim { @@ -88,14 +91,14 @@ Item { onXChanged: { if (mouseArea.drag.active) { - root.dragLeader = filePath; - root.groupDragX = x; + root.iconsRoot.dragLeader = root.filePath; + root.iconsRoot.groupDragX = x; } } onYChanged: { if (mouseArea.drag.active) { - root.dragLeader = filePath; - root.groupDragY = y; + root.iconsRoot.dragLeader = root.filePath; + root.iconsRoot.groupDragY = y; } } @@ -127,10 +130,10 @@ Item { anchors.horizontalCenter: parent.horizontalCenter implicitSize: 48 source: { - if (delegateRoot.resolvedIcon.startsWith("file://") || delegateRoot.resolvedIcon.startsWith("/")) { - return delegateRoot.resolvedIcon; + if (root.resolvedIcon.startsWith("file://") || root.resolvedIcon.startsWith("/")) { + return root.resolvedIcon; } else { - return Quickshell.iconPath(delegateRoot.resolvedIcon, fileIsDir ? "folder" : "text-x-generic"); + return Quickshell.iconPath(root.resolvedIcon, root.fileIsDir ? "folder" : "text-x-generic"); } } } @@ -147,7 +150,7 @@ Item { maximumLineCount: 2 style: Text.Outline styleColor: "black" - text: (appEntry && appEntry.name !== "") ? appEntry.name : fileName + text: (root.appEntry && root.appEntry.name !== "") ? root.appEntry.name : root.fileName visible: !renameLoader.active wrapMode: Text.Wrap } @@ -155,7 +158,7 @@ Item { Loader { id: renameLoader - active: root.editingFilePath === filePath + active: root.iconsRoot.editingFilePath === root.filePath anchors.centerIn: parent height: 24 width: 110 @@ -165,7 +168,7 @@ Item { anchors.margins: 2 color: "white" horizontalAlignment: Text.AlignHCenter - text: fileName + text: root.fileName wrapMode: Text.Wrap Component.onCompleted: { @@ -174,22 +177,22 @@ Item { } Keys.onPressed: function (event) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - if (text.trim() !== "" && text !== fileName) { + if (text.trim() !== "" && text !== root.fileName) { let newName = text.trim(); - let newPath = filePath.substring(0, filePath.lastIndexOf('/') + 1) + newName; + let newPath = root.filePath.substring(0, root.filePath.lastIndexOf('/') + 1) + newName; - Quickshell.execDetached(["mv", filePath, newPath]); + Quickshell.execDetached(["mv", root.filePath, newPath]); } - root.editingFilePath = ""; + root.iconsRoot.editingFilePath = ""; event.accepted = true; } else if (event.key === Qt.Key_Escape) { - root.editingFilePath = ""; + root.iconsRoot.editingFilePath = ""; event.accepted = true; } } onActiveFocusChanged: { - if (!activeFocus && root.editingFilePath === filePath) { - root.editingFilePath = ""; + if (!activeFocus && root.iconsRoot.editingFilePath === root.filePath) { + root.iconsRoot.editingFilePath = ""; } } } @@ -201,7 +204,7 @@ Item { anchors.fill: parent anchors.margins: 4 color: "white" - opacity: root.selectedIcons.includes(filePath) ? 0.2 : 0.0 + opacity: root.iconsRoot.selectedIcons.includes(root.filePath) ? 0.2 : 0.0 radius: Appearance.rounding.smallest Behavior on opacity { @@ -215,45 +218,45 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton anchors.fill: parent - cursorShape: root.lassoActive ? undefined : Qt.PointingHandCursor + cursorShape: root.iconsRoot.lassoActive ? undefined : Qt.PointingHandCursor drag.target: dragContainer hoverEnabled: true onClicked: mouse => { - root.forceActiveFocus(); + root.iconsRoot.forceActiveFocus(); if (mouse.button === Qt.RightButton) { - if (!root.selectedIcons.includes(filePath)) { - root.selectedIcons = [filePath]; + if (!root.iconsRoot.selectedIcons.includes(root.filePath)) { + root.iconsRoot.selectedIcons = [root.filePath]; } - let pos = mapToItem(root, mouse.x, mouse.y); - root.contextMenu.openAt(pos.x, pos.y, filePath, fileIsDir, appEntry, root.width, root.height, root.selectedIcons); + let pos = mapToItem(root.iconsRoot, mouse.x, mouse.y); + root.contextMenu.openAt(pos.x, pos.y, root.filePath, root.fileIsDir, root.appEntry, root.iconsRoot.width, root.iconsRoot.height, root.iconsRoot.selectedIcons); } else { - root.selectedIcons = [filePath]; + root.iconsRoot.selectedIcons = [root.filePath]; root.contextMenu.close(); } } onDoubleClicked: mouse => { if (mouse.button === Qt.LeftButton) { - if (filePath.endsWith(".desktop") && appEntry) - appEntry.execute(); + if (root.filePath.endsWith(".desktop") && root.appEntry) + root.appEntry.execute(); else - root.exec(filePath, fileIsDir); + root.iconsRoot.exec(root.filePath, root.fileIsDir); } } onPressed: mouse => { - if (mouse.button === Qt.LeftButton && !root.selectedIcons.includes(filePath)) { - root.selectedIcons = [filePath]; + if (mouse.button === Qt.LeftButton && !root.iconsRoot.selectedIcons.includes(root.filePath)) { + root.iconsRoot.selectedIcons = [root.filePath]; } } onReleased: { if (drag.active) { - let absoluteX = delegateRoot.x + dragContainer.x; - let absoluteY = delegateRoot.y + dragContainer.y; - let snapX = Math.max(0, Math.round(absoluteX / root.cellWidth)); - let snapY = Math.max(0, Math.round(absoluteY / root.cellHeight)); + let absoluteX = root.x + dragContainer.x; + let absoluteY = root.y + dragContainer.y; + let snapX = Math.max(0, Math.round(absoluteX / root.iconsRoot.cellWidth)); + let snapY = Math.max(0, Math.round(absoluteY / root.iconsRoot.cellHeight)); - root.performMassDrop(filePath, snapX, snapY); + root.iconsRoot.performMassDrop(root.filePath, snapX, snapY); } } diff --git a/Modules/DesktopIcons/DesktopIcons.qml b/Modules/DesktopIcons/DesktopIcons.qml index d5272a3..c06f402 100644 --- a/Modules/DesktopIcons/DesktopIcons.qml +++ b/Modules/DesktopIcons/DesktopIcons.qml @@ -1,11 +1,12 @@ +pragma ComponentBehavior: Bound + import QtQuick import Quickshell -import qs.Modules +import ZShell.Services import qs.Helpers import qs.Config import qs.Components import qs.Paths -import ZShell.Services Item { id: root @@ -23,7 +24,33 @@ Item { property real startY: 0 function exec(filePath, isDir) { - const cmd = ["xdg-open", filePath]; + let type = DesktopUtils.getFileType(filePath, isDir); + let cmd = []; + switch (type) { + case "image": + cmd = [Config.general.apps.image, filePath]; + break; + case "video": + cmd = [Config.general.apps.playback, filePath]; + break; + case "audio": + cmd = [Config.general.apps.audio, filePath]; + break; + case "archive": + cmd = [Config.general.apps.archiver, filePath]; + break; + case "directory": + cmd = [Config.general.apps.explorer, filePath]; + break; + case "text": + cmd = [Config.general.apps.editor, filePath]; + break; + case "document": + cmd = [Config.general.apps.document, filePath]; + break; + default: + cmd = ["xdg-open", filePath]; + } Quickshell.execDetached(cmd); } @@ -57,6 +84,7 @@ Item { root.groupDragY = 0; } + anchors.fill: parent focus: true Keys.onPressed: event => { @@ -67,6 +95,8 @@ Item { DesktopModel { id: desktopModel + rows: Math.max(1, Math.floor(gridArea.height / root.cellHeight)) + Component.onCompleted: loadDirectory(FileUtils.trimFileProtocol(Paths.desktop)) } @@ -133,10 +163,10 @@ Item { lasso.width = Math.abs(mouse.x - root.startX); lasso.height = Math.abs(mouse.y - root.startY); - let minCol = Math.floor((lasso.x - gridArea.x) / cellWidth); - let maxCol = Math.floor((lasso.x + lasso.width - gridArea.x) / cellWidth); - let minRow = Math.floor((lasso.y - gridArea.y) / cellHeight); - let maxRow = Math.floor((lasso.y + lasso.height - gridArea.y) / cellHeight); + let minCol = Math.floor((lasso.x - gridArea.x) / root.cellWidth); + let maxCol = Math.floor((lasso.x + lasso.width - gridArea.x) / root.cellWidth); + let minRow = Math.floor((lasso.y - gridArea.y) / root.cellHeight); + let maxRow = Math.floor((lasso.y + lasso.height - gridArea.y) / root.cellHeight); let newSelection = []; for (let i = 0; i < gridArea.children.length; i++) { @@ -158,10 +188,10 @@ Item { } else { bgContextMenu.close(); root.selectedIcons = []; - root.startX = Math.floor(mouse.x); - root.startY = Math.floor(mouse.y); - lasso.x = Math.floor(mouse.x); - lasso.y = Math.floor(mouse.y); + root.startX = mouse.x; + root.startY = mouse.y; + lasso.x = mouse.x; + lasso.y = mouse.y; lasso.width = 0; lasso.height = 0; lasso.showLasso(); @@ -178,15 +208,15 @@ Item { anchors.fill: parent anchors.margins: 20 anchors.topMargin: 40 - visible: true Repeater { model: desktopModel delegate: DesktopIconDelegate { - property int itemIndex: index + required property int index - lassoActive: root.lassoActive + contextMenu: desktopMenu + iconsRoot: root } } } @@ -202,6 +232,5 @@ Item { BackgroundContextMenu { id: bgContextMenu - } } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 98c4caf..6ed0190 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -1,33 +1,35 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts +import ZShell.Components import qs.Config import qs.Components Item { id: root - readonly property var colors: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#a855f7", "#ec4899", "#ffffff", "#000000"] - required property Canvas drawing + 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 Wrapper wrapper function syncFromPenColor() { if (!drawing) return; if (!saturationSlider.pressed) - saturationSlider.value = drawing.penColor.hsvSaturation; + saturationSlider.value = drawing.drawingState.penColor.hsvSaturation; if (!brightnessSlider.pressed) - brightnessSlider.value = drawing.penColor.hsvValue; + brightnessSlider.value = drawing.drawingState.penColor.hsvValue; } function updatePenColorFromHsv() { if (!drawing) return; - drawing.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.penColor.a); + drawing.drawingState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.drawingState.penColor.a); } implicitHeight: column.height + Appearance.padding.larger * 2 @@ -40,7 +42,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing + target: root.drawing.drawingState } Column { @@ -58,13 +60,14 @@ Item { GradientSlider { id: saturationSlider + anchors.left: parent.left + anchors.right: parent.right brightness: brightnessSlider.value channel: "saturation" from: 0 hue: huePicker.currentHue icon: "\ue40a" implicitHeight: 30 - implicitWidth: palette.width orientation: Qt.Horizontal to: 1 @@ -74,12 +77,13 @@ Item { GradientSlider { id: brightnessSlider + anchors.left: parent.left + anchors.right: parent.right channel: "brightness" from: 0 hue: huePicker.currentHue icon: "\ue1ac" implicitHeight: 30 - implicitWidth: palette.width orientation: Qt.Horizontal saturation: saturationSlider.value to: 1 @@ -87,65 +91,117 @@ Item { onMoved: root.updatePenColorFromHsv() } - GridLayout { - id: palette + ButtonRow { + id: row1 anchors.left: parent.left anchors.right: parent.right - columns: 5 - rowSpacing: 8 - rows: 2 + spacing: Appearance.spacing.normal Repeater { - model: root.colors + model: root.colors1 - delegate: Item { - id: colorCircle + delegate: ColorButton { + row: row1 + } + } + } - required property color modelData - readonly property bool selected: Qt.colorEqual(root.drawing.penColor, modelData) + ButtonRow { + id: row2 - Layout.fillWidth: true - height: 28 + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal - CustomRect { - anchors.centerIn: parent - border.color: Qt.rgba(0, 0, 0, 0.25) - border.width: Qt.colorEqual(modelData, "#ffffff") ? 1 : 0 - color: colorCircle.modelData - height: 20 - radius: width / 2 - width: 20 - } + Repeater { + model: root.colors2 - CustomRect { - anchors.centerIn: parent - border.color: selected ? "#ffffff" : Qt.rgba(1, 1, 1, 0.28) - border.width: selected ? 3 : 1 - color: "transparent" - height: parent.height - radius: width / 2 - width: parent.height - - StateLayer { - onClicked: root.drawing.penColor = colorCircle.modelData - } - } + delegate: ColorButton { + row: row2 } } } FilledSlider { + anchors.left: parent.left + anchors.right: parent.right from: 1 icon: "border_color" implicitHeight: 30 - implicitWidth: palette.width multiplier: 1 orientation: Qt.Horizontal to: 45 - value: root.drawing.penWidth + value: root.drawing.drawingState.penWidth - onMoved: root.drawing.penWidth = value + onMoved: root.drawing.drawingState.penWidth = value + } + + ButtonRow { + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.small + + IconTextButton { + fillWidth: true + font.pointSize: Appearance.font.size.normal + icon: "close" + inactiveColor: DynamicColors.palette.m3error + inactiveOnColor: DynamicColors.palette.m3onError + isRound: true + shapeMorph: true + text: "Exit" + + onClicked: root.visibilities.isDrawing = false + } + + IconTextButton { + fillWidth: true + font.pointSize: Appearance.font.size.normal + icon: "ink_eraser" + inactiveColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: DynamicColors.palette.m3onSurfaceVariant + isRound: true + shapeMorph: true + text: "Clear" + + onClicked: root.drawing.content.clear() + } } } + + IconButton { + anchors.margins: Appearance.padding.normal + anchors.right: parent.right + anchors.top: parent.top + checked: root.wrapper.pinned + icon: "keep" + isToggle: true + shapeMorph: true + + onClicked: { + root.wrapper.togglePinned(); + } + } + + component ColorButton: IconButton { + id: colorButton + + readonly property real buttonSize: (row.width - row.spacing * 4) / 5 + required property color modelData + required property ButtonRow row + + fillWidth: false + font.pointSize: Appearance.font.size.normal + icon: "" + implicitHeight: buttonSize + implicitWidth: buttonSize + inactiveColor: modelData + inactiveOnColor: DynamicColors.on(modelData) + isRound: true + shapeMorph: true + shapeMorphExpansion: pressed ? 12 : 0 + + onClicked: root.drawing.drawingState.penColor = modelData + } } diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index 6376d30..cbc56ae 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -10,13 +10,36 @@ import qs.Daemons Item { id: root - required property Canvas drawing + required property var drawing property bool expanded: true property real offsetScale: shouldBeActive ? 0 : 1 + property bool pinned: false required property ShellScreen screen readonly property bool shouldBeActive: visibilities.isDrawing required property var visibilities + function collapse(): void { + if (pinned) + return; + + expanded = false; + } + + function expand(): void { + expanded = true; + } + + function toggleExpanded(): void { + if (pinned) + return; + + expanded = !expanded; + } + + function togglePinned(): void { + pinned = !pinned; + } + anchors.leftMargin: (-implicitWidth - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: root.expanded ? content.implicitWidth : icon.implicitWidth @@ -44,7 +67,7 @@ Item { Loader { id: icon - active: root.shouldBeActive || root.visible + active: (root.shouldBeActive || root.visible) && opacity > 0 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter asynchronous: true @@ -63,7 +86,7 @@ Item { Loader { id: content - active: root.shouldBeActive || root.visible + active: (root.shouldBeActive || root.visible) && opacity > 0 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter asynchronous: true @@ -76,6 +99,7 @@ Item { sourceComponent: Content { drawing: root.drawing visibilities: root.visibilities + wrapper: root } } } diff --git a/Modules/HyprsunsetWidget.qml b/Modules/HyprsunsetWidget.qml index 620b73a..eef2218 100644 --- a/Modules/HyprsunsetWidget.qml +++ b/Modules/HyprsunsetWidget.qml @@ -24,6 +24,12 @@ CustomRect { anchors.centerIn: parent animate: true color: root.tempEnabled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + fill: root.tempEnabled ? 1 : 0 text: root.tempEnabled ? "lightbulb" : "light_off" + + Behavior on fill { + Anim { + } + } } } diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index 068265d..f2da657 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -15,11 +15,11 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - function onClicked(): void { + radius: Appearance.rounding.smallest + + onClicked: { root.modelData?.onClicked(root.list); } - - radius: Appearance.rounding.smallest } Item { diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index b9be7b5..b553649 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -18,12 +18,12 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - function onClicked(): void { + radius: Appearance.rounding.smallest + + onClicked: { Apps.launch(root.modelData); root.visibilities.launcher = false; } - - radius: Appearance.rounding.smallest } Item { diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 32008f2..1b1f077 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -23,11 +23,11 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - function onClicked(): void { + radius: Appearance.rounding.smallest + + onClicked: { root.onClicked(); } - - radius: Appearance.rounding.smallest } RowLayout { @@ -76,12 +76,12 @@ Item { StateLayer { id: stateLayer - function onClicked(): void { + color: DynamicColors.palette.m3onTertiary + + onClicked: { Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); root.list.visibilities.launcher = false; } - - color: DynamicColors.palette.m3onTertiary } CustomText { diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index 42f47a0..0351199 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -14,11 +14,11 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - function onClicked(): void { + radius: Appearance.rounding.smallest + + onClicked: { root.modelData?.onClicked(root.list); } - - radius: Appearance.rounding.smallest } Item { diff --git a/Modules/Launcher/Items/WallpaperItem.qml b/Modules/Launcher/Items/WallpaperItem.qml index 65d9418..024633c 100644 --- a/Modules/Launcher/Items/WallpaperItem.qml +++ b/Modules/Launcher/Items/WallpaperItem.qml @@ -33,12 +33,12 @@ Item { } StateLayer { - function onClicked(): void { + radius: Appearance.rounding.normal + + onClicked: { Wallpapers.setWallpaper(root.modelData.path); root.visibilities.launcher = false; } - - radius: Appearance.rounding.normal } Elevation { diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml index a336503..4507bae 100644 --- a/Modules/Lock/Center.qml +++ b/Modules/Lock/Center.qml @@ -108,12 +108,12 @@ ColumnLayout { } StateLayer { - function onClicked(): void { - parent.forceActiveFocus(); - } - cursorShape: Qt.IBeamCursor hoverEnabled: false + + onClicked: { + parent.forceActiveFocus(); + } } RowLayout { @@ -167,11 +167,11 @@ ColumnLayout { radius: Appearance.rounding.full StateLayer { - function onClicked(): void { + color: root.lock.pam.buffer ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + + onClicked: { root.lock.pam.passwd.start(); } - - color: root.lock.pam.buffer ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface } MaterialIcon { diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index f47ecf7..4349bf8 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -178,11 +178,11 @@ Item { StateLayer { id: controlState - function onClicked(): void { + color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] + + onClicked: { control.onClicked(); } - - color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] } MaterialIcon { diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 4c0302c..e79628b 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -161,11 +161,11 @@ CustomRect { } StateLayer { - function onClicked(): void { + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + + onClicked: { root.expanded = !root.expanded; } - - color: root.urgency === "critical" ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface } RowLayout { diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index 060c717..dfb1bba 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import qs.Modules +import ZShell.Services import qs.Components import qs.Helpers import qs.Config @@ -16,30 +16,34 @@ GridLayout { rowSpacing: Appearance.spacing.large rows: 1 - Ref { - service: SystemUsage + ServiceRef { + service: Memory + } + + ServiceRef { + service: Cpu } Resource { Layout.bottomMargin: Appearance.padding.large Layout.topMargin: Appearance.padding.large - colour: DynamicColors.palette.m3primary + fgColor: DynamicColors.palette.m3primary icon: "memory" - value: SystemUsage.cpuPerc + value: Cpu.percentage } Resource { Layout.bottomMargin: Appearance.padding.large Layout.topMargin: Appearance.padding.large - colour: DynamicColors.palette.m3secondary + fgColor: DynamicColors.palette.m3secondary icon: "memory_alt" - value: SystemUsage.memPerc + value: Memory.percentage } component Resource: CustomRect { id: res - required property color colour + required property color fgColor required property string icon required property real value @@ -58,8 +62,8 @@ GridLayout { id: circ anchors.fill: parent - bgColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) - fgColour: res.colour + bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) + fgColor: res.fgColor padding: Appearance.padding.large * 3 strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal value: res.value @@ -69,7 +73,7 @@ GridLayout { id: icon anchors.centerIn: parent - color: res.colour + color: res.fgColor font.pointSize: (circ.arcRadius * 0.7) || 1 font.weight: 600 text: res.icon diff --git a/Modules/NotifBell.qml b/Modules/NotifBell.qml index e59f4f0..4dd85f2 100644 --- a/Modules/NotifBell.qml +++ b/Modules/NotifBell.qml @@ -21,6 +21,7 @@ CustomRect { anchors.centerIn: parent color: root.visibilities.sidebar ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + fill: root.visibilities.sidebar ? 1 : 0 font.family: "Material Symbols Rounded" font.pointSize: Appearance.font.size.larger text: HasNotifications.hasNotifications ? "\uf4fe" : "\ue7f4" @@ -29,10 +30,14 @@ CustomRect { CAnim { } } + Behavior on fill { + Anim { + } + } } StateLayer { - cursorShape: Qt.PointingHandCursor + color: root.visibilities.sidebar ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface onClicked: { root.visibilities.sidebar = !root.visibilities.sidebar; diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index ac53731..71ddb2d 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import qs.Components import qs.Config import qs.Daemons @@ -20,28 +22,26 @@ Item { if (count === 0) return 0; - let height = (count - 1) * 8; + let height = (count - 1) * Appearance.spacing.small; for (let i = 0; i < count; i++) - height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (visibilities && panels) { - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) - return 0; + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) + return 0; - if (visibilities.osd) { - const h = panels.osd.y - 8 * 2 - padding * 2; - if (height > h) - height = h; - } - - if (visibilities.session) { - const h = panels.session.y - 8 * 2 - padding * 2; - if (height > h) - height = h; - } + if (visibilities.osd) { + const h = panels.osd.y - Appearance.spacing.small * 2 - padding * 2; + if (height > h) + height = h; } - return Math.min((QsWindow.window?.screen?.height ?? 0) - 1 * 2, height + padding * 2); + if (visibilities.session) { + const h = panels.session.y - Appearance.spacing.small * 2 - padding * 2; + if (height > h) + height = h; + } + + return Math.min(((QsWindow.window as QsWindow)?.screen?.height ?? 0) - 1 * 2, height + padding * 2); } implicitWidth: Config.notifs.sizes.width + padding * 2 @@ -60,83 +60,11 @@ Item { id: list anchors.fill: parent - cacheBuffer: QsWindow.window?.screen.height ?? 0 + cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0 orientation: Qt.Vertical spacing: 0 - delegate: Item { - id: wrapper - - property int idx - required property int index - required property NotifServer.Notif modelData - readonly property alias nonAnimHeight: notif.nonAnimHeight - - implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.small) - implicitWidth: notif.implicitWidth - - ListView.onRemove: removeAnim.start() - onIndexChanged: { - if (index !== -1) - idx = index; - } - - SequentialAnimation { - id: removeAnim - - PropertyAction { - property: "ListView.delayRemove" - target: wrapper - value: true - } - - PropertyAction { - property: "enabled" - target: wrapper - value: false - } - - PropertyAction { - property: "implicitHeight" - target: wrapper - value: 0 - } - - PropertyAction { - property: "z" - target: wrapper - value: 1 - } - - Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - property: "x" - target: notif - to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2 - } - - PropertyAction { - property: "ListView.delayRemove" - target: wrapper - value: false - } - } - - ClippingRectangle { - anchors.top: parent.top - anchors.topMargin: wrapper.idx === 0 ? 0 : 8 - color: "transparent" - implicitHeight: notif.implicitHeight - implicitWidth: notif.implicitWidth - radius: Appearance.rounding.smallest / 2 - - Notification { - id: notif - - modelData: wrapper.modelData - } - } + delegate: NotifWrapper { } displaced: Transition { Anim { @@ -158,4 +86,79 @@ Item { duration: Appearance.anim.durations.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } + component NotifWrapper: Item { + id: wrapper + + property int idx + required property int index + required property NotifServer.Notif modelData + readonly property alias nonAnimHeight: notif.nonAnimHeight + + implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.small) + implicitWidth: notif.implicitWidth + + ListView.onRemove: removeAnim.start() + onIndexChanged: { + if (index !== -1) + idx = index; + } + + SequentialAnimation { + id: removeAnim + + PropertyAction { + property: "ListView.delayRemove" + target: wrapper + value: true + } + + PropertyAction { + property: "enabled" + target: wrapper + value: false + } + + PropertyAction { + property: "implicitHeight" + target: wrapper + value: 0 + } + + PropertyAction { + property: "z" + target: wrapper + value: 1 + } + + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + property: "x" + target: notif + to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2 + } + + PropertyAction { + property: "ListView.delayRemove" + target: wrapper + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : 8 + color: "transparent" + implicitHeight: notif.implicitHeight + implicitWidth: notif.implicitWidth + radius: Appearance.rounding.small + + Notification { + id: notif + + implicitWidth: root.implicitWidth - root.padding * 2 + modelData: wrapper.modelData + } + } + } } diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index f50561a..fd4e9e0 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -1,15 +1,14 @@ pragma ComponentBehavior: Bound -import qs.Components -import qs.Config -import qs.Modules +import QtQuick +import QtQuick.Shapes +import Quickshell +import Quickshell.Services.Notifications +import ZShell.Components import qs.Daemons import qs.Helpers -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Notifications -import QtQuick -import QtQuick.Layouts +import qs.Config +import qs.Components CustomRect { id: root @@ -18,17 +17,16 @@ CustomRect { readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property bool hasImage: modelData.image.length > 0 required property NotifServer.Notif modelData - readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 + readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Appearance.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondaryContainer : DynamicColors.tPalette.m3surfaceContainer implicitHeight: inner.implicitHeight - implicitWidth: Config.notifs.sizes.width - radius: 6 - x: Config.notifs.sizes.width + radius: Appearance.rounding.small + x: implicitWidth Behavior on x { Anim { - easing.bezierCurve: MaterialEasing.expressiveEffects + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel } } @@ -54,7 +52,7 @@ CustomRect { return; const actions = root.modelData.actions; - if (actions?.length === 1) + if (actions.length === 1) actions[0].invoke(); } onEntered: root.modelData.timer.stop() @@ -79,7 +77,7 @@ CustomRect { if (!containsMouse) root.modelData.timer.start(); - if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) + if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) root.x = 0; else root.modelData.popup = false; @@ -89,15 +87,13 @@ CustomRect { id: inner anchors.left: parent.left - anchors.margins: 8 + anchors.margins: Appearance.padding.normal anchors.right: parent.right anchors.top: parent.top implicitHeight: root.nonAnimHeight Behavior on implicitHeight { Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects } } @@ -112,7 +108,8 @@ CustomRect { visible: root.hasImage || root.hasAppIcon width: Config.notifs.sizes.image - sourceComponent: ClippingRectangle { + sourceComponent: CustomClippingRect { + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) : DynamicColors.palette.m3secondaryContainer implicitHeight: Config.notifs.sizes.image implicitWidth: Config.notifs.sizes.image radius: Appearance.rounding.full @@ -122,8 +119,11 @@ CustomRect { asynchronous: true cache: false fillMode: Image.PreserveAspectCrop - mipmap: true source: Qt.resolvedUrl(root.modelData.image) + sourceSize: { + const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } } } } @@ -153,8 +153,9 @@ CustomRect { height: Math.round(parent.width * 0.6) width: Math.round(parent.width * 0.6) - sourceComponent: CustomIcon { + sourceComponent: ColoredIcon { anchors.fill: parent + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer layer.enabled: root.modelData.appIcon.endsWith("symbolic") source: Quickshell.iconPath(root.modelData.appIcon) } @@ -163,34 +164,67 @@ CustomRect { Loader { active: !root.hasAppIcon anchors.centerIn: parent - anchors.horizontalCenterOffset: -18 * 0.02 - anchors.verticalCenterOffset: 18 * 0.02 + anchors.verticalCenterOffset: 1 asynchronous: true sourceComponent: MaterialIcon { color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer - font.pointSize: 18 text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) } } } } + Shape { + id: progressIndicator + + anchors.centerIn: appIcon + height: appIcon.implicitHeight + progressShape.strokeWidth * 2 + preferredRendererType: Shape.CurveRenderer + width: appIcon.implicitWidth + progressShape.strokeWidth * 2 + + ShapePath { + id: progressShape + + capStyle: ShapePath.RoundCap + fillColor: "transparent" + strokeColor: DynamicColors.palette.m3primary + strokeWidth: 2 + + PathAngleArc { + id: progressArc + + centerX: progressIndicator.width / 2 + centerY: progressIndicator.height / 2 + radiusX: progressIndicator.width / 2 - Appearance.padding.extraSmall / 2 + radiusY: progressIndicator.height / 2 - Appearance.padding.extraSmall / 2 + startAngle: -90 + sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 + + Behavior on sweepAngle { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } + } + } + } + CustomText { id: appName anchors.left: image.right - anchors.leftMargin: 10 + anchors.leftMargin: Appearance.spacing.small anchors.top: parent.top animate: true color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 maximumLineCount: 1 opacity: root.expanded ? 1 : 0 text: appNameMetrics.elidedText Behavior on opacity { Anim { + type: Anim.DefaultEffects } } } @@ -199,9 +233,8 @@ CustomRect { id: appNameMetrics elide: Text.ElideRight - elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 - font.family: appName.font.family - font.pointSize: appName.font.pointSize + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3 + font: appName.font text: root.modelData.appName } @@ -209,9 +242,10 @@ CustomRect { id: summary anchors.left: image.right - anchors.leftMargin: 10 + anchors.leftMargin: Appearance.spacing.small anchors.top: parent.top animate: true + font.pointSize: Appearance.font.size.small height: implicitHeight maximumLineCount: 1 text: summaryMetrics.elidedText @@ -225,6 +259,9 @@ CustomRect { when: root.expanded PropertyChanges { + body.anchors.topMargin: Appearance.spacing.extraSmall + bodyPreview.anchors.topMargin: Appearance.spacing.extraSmall + summary.anchors.topMargin: Appearance.spacing.extraSmall summary.maximumLineCount: undefined } @@ -239,10 +276,11 @@ CustomRect { target: summary } - AnchorAnimation { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - easing.type: Easing.BezierSpline + Anim { + property: "topMargin" + } + + AnchorAnim { } } } @@ -251,9 +289,8 @@ CustomRect { id: summaryMetrics elide: Text.ElideRight - elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 - font.family: summary.font.family - font.pointSize: summary.font.pointSize + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3 + font: summary.font text: root.modelData.summary } @@ -261,10 +298,9 @@ CustomRect { id: timeSep anchors.left: summary.right - anchors.leftMargin: 7 + anchors.leftMargin: Appearance.spacing.small anchors.top: parent.top color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 text: "•" states: State { @@ -277,10 +313,7 @@ CustomRect { } } transitions: Transition { - AnchorAnimation { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - easing.type: Easing.BezierSpline + AnchorAnim { } } } @@ -289,11 +322,11 @@ CustomRect { id: time anchors.left: timeSep.right - anchors.leftMargin: 7 + anchors.leftMargin: Appearance.spacing.small anchors.top: parent.top animate: true color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 + font.pointSize: Appearance.font.size.small horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr } @@ -303,25 +336,33 @@ CustomRect { anchors.right: parent.right anchors.top: parent.top - implicitHeight: expandIcon.height - implicitWidth: expandIcon.height + anchors.topMargin: -Appearance.padding.extraSmall + implicitHeight: expandIcon.implicitHeight + implicitWidth: expandIcon.implicitHeight StateLayer { - function onClicked() { - root.expanded = !root.expanded; - } - color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface radius: Appearance.rounding.full + + onClicked: root.expanded = !root.expanded } MaterialIcon { id: expandIcon anchors.centerIn: parent - animate: true - font.pointSize: 13 - text: root.expanded ? "expand_less" : "expand_more" + anchors.verticalCenterOffset: root.expanded ? -1 : 1 + rotation: root.expanded ? 180 : 0 + text: "expand_more" + + Behavior on anchors.verticalCenterOffset { + Anim { + } + } + Behavior on rotation { + Anim { + } + } } } @@ -330,17 +371,18 @@ CustomRect { anchors.left: summary.left anchors.right: expandBtn.left - anchors.rightMargin: 7 + anchors.rightMargin: Appearance.spacing.small anchors.top: summary.bottom animate: true color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 + font.pointSize: Appearance.font.size.small opacity: root.expanded ? 0 : 1 text: bodyPreviewMetrics.elidedText textFormat: Text.MarkdownText Behavior on opacity { Anim { + type: Anim.DefaultEffects } } } @@ -350,8 +392,7 @@ CustomRect { elide: Text.ElideRight elideWidth: bodyPreview.width - font.family: bodyPreview.font.family - font.pointSize: bodyPreview.font.pointSize + font: bodyPreview.font text: root.modelData.body } @@ -360,11 +401,10 @@ CustomRect { anchors.left: summary.left anchors.right: expandBtn.left - anchors.rightMargin: 7 + anchors.rightMargin: Appearance.spacing.small anchors.top: summary.bottom animate: true color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 height: text ? implicitHeight : 0 opacity: root.expanded ? 1 : 0 text: root.modelData.body @@ -373,6 +413,7 @@ CustomRect { Behavior on opacity { Anim { + type: Anim.DefaultEffects } } @@ -385,83 +426,89 @@ CustomRect { } } - RowLayout { + ButtonRow { id: actions - anchors.horizontalCenter: parent.horizontalCenter + anchors.left: parent.left + anchors.right: parent.right anchors.top: body.bottom - anchors.topMargin: 7 + anchors.topMargin: Appearance.spacing.small opacity: root.expanded ? 1 : 0 - spacing: 10 + spacing: Appearance.spacing.extraSmall Behavior on opacity { Anim { + type: Anim.DefaultEffects } } - Action { - modelData: QtObject { - readonly property string text: qsTr("Close") + IconButton { + enabled: root.expanded + fillWidth: root.modelData.actions.length === 0 + icon: "close" + inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant + isRound: true + padding: Appearance.padding.extraSmall + shapeMorph: true - function invoke(): void { - root.modelData.close(); - } - } + onClicked: root.modelData.close() } Repeater { model: root.modelData.actions - delegate: Component { - Action { + TextButton { + required property var modelData + + enabled: root.expanded + fillWidth: true + font: { + const f = Qt.font(font); + f.pointSize = Appearance.font.size.small; + return f; } + implicitWidth: label.implicitWidth + inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant + isRound: true + label.anchors.centerIn: undefined + label.anchors.left: left + label.anchors.margins: Appearance.padding.normal + label.anchors.right: right + label.anchors.verticalCenter: verticalCenter + label.elide: Text.ElideRight + label.horizontalAlignment: Text.AlignHCenter + shapeMorph: true + text: modelData.text + + onClicked: modelData.invoke() + } + } + + IconButton { + enabled: root.expanded + fillWidth: root.modelData.actions.length === 0 + icon: copyTimer.running ? "inventory" : "content_copy" + inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant + isRound: true + label.animate: true + padding: Appearance.padding.extraSmall + shapeMorph: true + + onClicked: { + Quickshell.clipboardText = root.modelData.body; + copyTimer.restart(); + } + + Timer { + id: copyTimer + + interval: 3000 } } } } } - - component Action: CustomRect { - id: action - - required property var modelData - - Layout.preferredHeight: actionText.height + 4 * 2 - Layout.preferredWidth: actionText.width + 8 * 2 - color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - implicitHeight: actionText.height + 4 * 2 - implicitWidth: actionText.width + 8 * 2 - radius: Appearance.rounding.full - - StateLayer { - function onClicked(): void { - action.modelData.invoke(); - } - - color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurface - radius: Appearance.rounding.full - } - - CustomText { - id: actionText - - anchors.centerIn: parent - color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant - font.pointSize: 10 - text: actionTextMetrics.elidedText - } - - TextMetrics { - id: actionTextMetrics - - elide: Text.ElideRight - elideWidth: { - const numActions = root.modelData.actions.length + 1; - return (inner.width - actions.spacing * (numActions - 1)) / numActions - 8 * 2; - } - font.family: actionText.font.family - font.pointSize: actionText.font.pointSize - text: action.modelData.text - } - } } diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 888a5c4..6b3e7da 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -13,7 +13,7 @@ Item { id: layout anchors.fill: parent - spacing: 8 + spacing: Appearance.spacing.small CustomRect { Layout.fillHeight: true @@ -29,7 +29,7 @@ Item { CustomRect { Layout.fillWidth: true - Layout.topMargin: 8 - layout.spacing + Layout.topMargin: Appearance.padding.normal - layout.spacing color: DynamicColors.tPalette.m3outlineVariant implicitHeight: 1 } diff --git a/Modules/Notifications/Sidebar/NotifActionList.qml b/Modules/Notifications/Sidebar/NotifActionList.qml index 02ba10e..4d58aea 100644 --- a/Modules/Notifications/Sidebar/NotifActionList.qml +++ b/Modules/Notifications/Sidebar/NotifActionList.qml @@ -78,7 +78,7 @@ Item { StateLayer { id: actionStateLayer - function onClicked(): void { + onClicked: { if (action.modelData.isClose) { root.notif.close(); } else if (action.modelData.isCopy) { diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 8eb2ede..d7b45a1 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -17,7 +17,7 @@ Item { required property var visibilities anchors.fill: parent - anchors.margins: 8 + anchors.margins: Appearance.padding.normal Component.onCompleted: NotifServer.list.forEach(n => n.popup = false) @@ -156,7 +156,7 @@ Item { Loader { active: opacity > 0 anchors.bottom: parent.bottom - anchors.margins: 8 + anchors.margins: Appearance.padding.normal anchors.right: parent.right opacity: root.notifCount > 0 ? 1 : 0 scale: root.notifCount > 0 ? 1 : 0.5 diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index 753944d..c816a62 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -171,11 +171,11 @@ CustomRect { radius: Appearance.rounding.full StateLayer { - function onClicked(): void { + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + + onClicked: { root.toggleExpand(!root.expanded); } - - color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface } RowLayout { diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 7d7c832..16d1f3c 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -72,7 +72,7 @@ CustomRect { CustomSplitButton { active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] - disabled: Recorder.running + enabled: !Recorder.running menuItems: [ MenuItem { @@ -267,8 +267,8 @@ CustomRect { checked: Recorder.paused font.pointSize: Appearance.font.size.large icon: Recorder.paused ? "play_arrow" : "pause" + isToggle: true label.animate: true - toggle: true type: IconButton.Tonal onClicked: { @@ -280,8 +280,8 @@ CustomRect { IconButton { font.pointSize: Appearance.font.size.large icon: "stop" - inactiveColour: DynamicColors.palette.m3error - inactiveOnColour: DynamicColors.palette.m3onError + inactiveColor: DynamicColors.palette.m3error + inactiveOnColor: DynamicColors.palette.m3onError onClicked: Recorder.stop() } diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index 13c3fb5..82af970 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -2,9 +2,9 @@ import Quickshell.Bluetooth import Quickshell.Networking as QSNetwork import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Config -import qs.Modules import qs.Helpers import qs.Daemons @@ -19,74 +19,69 @@ CustomRect { implicitHeight: layout.implicitHeight + 18 * 2 radius: Appearance.rounding.smallest - ColumnLayout { + ButtonRow { id: layout anchors.fill: parent anchors.margins: 18 - spacing: 10 + spacing: Appearance.spacing.extraSmall - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: 7 + Toggle { + checked: Network.wifiEnabled + icon: Network.wifiEnabled ? "wifi" : "wifi_off" + visible: QSNetwork.Networking.devices.values.some(n => n.type === QSNetwork.DeviceType.Wifi) - Toggle { - checked: Network.wifiEnabled - icon: Network.wifiEnabled ? "wifi" : "wifi_off" - visible: QSNetwork.Networking.devices.values.some(n => n.type === QSNetwork.DeviceType.Wifi) + onClicked: Network.toggleWifi() + } - onClicked: Network.toggleWifi() + Toggle { + id: toggle + + checked: !NotifServer.dnd + icon: NotifServer.dnd ? "notifications_off" : "notifications" + + onClicked: NotifServer.dnd = !NotifServer.dnd + } + + Toggle { + checked: !Audio.sourceMuted + icon: Audio.sourceMuted ? "mic_off" : "mic" + + onClicked: { + const audio = Audio.source?.audio; + if (audio) + audio.muted = !audio.muted; } + } - Toggle { - id: toggle + Toggle { + checked: !Audio.muted + icon: Audio.muted ? "volume_off" : "volume_up" - checked: !NotifServer.dnd - icon: NotifServer.dnd ? "notifications_off" : "notifications" - - onClicked: NotifServer.dnd = !NotifServer.dnd + onClicked: { + const audio = Audio.sink?.audio; + if (audio) + audio.muted = !audio.muted; } + } - Toggle { - checked: !Audio.sourceMuted - icon: Audio.sourceMuted ? "mic_off" : "mic" + Toggle { + checked: Bluetooth.defaultAdapter?.enabled ?? false + icon: Bluetooth.defaultAdapter?.enabled ? "bluetooth" : "bluetooth_disabled" + visible: Bluetooth.defaultAdapter ?? false - onClicked: { - const audio = Audio.source?.audio; - if (audio) - audio.muted = !audio.muted; - } + onClicked: { + const adapter = Bluetooth.defaultAdapter; + if (adapter) + adapter.enabled = !adapter.enabled; } + } - Toggle { - checked: !Audio.muted - icon: Audio.muted ? "volume_off" : "volume_up" + Toggle { + checked: GameMode.enabled + icon: GameMode.enabled ? "videogame_asset" : "videogame_asset_off" - onClicked: { - const audio = Audio.sink?.audio; - if (audio) - audio.muted = !audio.muted; - } - } - - Toggle { - checked: Bluetooth.defaultAdapter?.enabled ?? false - icon: Bluetooth.defaultAdapter?.enabled ? "bluetooth" : "bluetooth_disabled" - visible: Bluetooth.defaultAdapter ?? false - - onClicked: { - const adapter = Bluetooth.defaultAdapter; - if (adapter) - adapter.enabled = !adapter.enabled; - } - } - - Toggle { - checked: GameMode.enabled - icon: GameMode.enabled ? "videogame_asset" : "videogame_asset_off" - - onClicked: GameMode.enabled = !GameMode.enabled - } + onClicked: GameMode.enabled = !GameMode.enabled } } @@ -99,19 +94,9 @@ CustomRect { } component Toggle: IconButton { - Layout.fillWidth: true - Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? 18 : internalChecked ? 7 : 0) - inactiveColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) - radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8 - radiusAnim.duration: MaterialEasing.expressiveEffectsTime - radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects - toggle: true - - Behavior on Layout.preferredWidth { - Anim { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - } - } + fillWidth: true + isRound: true + isToggle: true + shapeMorph: true } } diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index 5c2f6b4..7f53293 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -10,7 +10,7 @@ Item { required property var visibilities implicitHeight: content.implicitHeight - implicitWidth: Math.max(sidebarPanel.width * (1 - sidebarPanel.offsetScale), content.implicitWidth) + implicitWidth: content.implicitWidth visible: height > 0 Content { diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index ef5ce62..fca3fc2 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -97,7 +97,9 @@ Item { FilledSlider { anchors.fill: parent + from: Config.services.minBrightness icon: `brightness_${(Math.round(value * 6) + 1)}` + to: 1.0 value: root.brightness onPressedChanged: { diff --git a/Modules/Resources.qml b/Modules/Resources.qml index 2334666..94805d8 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import QtQuick.Layouts +import ZShell.Services import qs.Helpers import qs.Modules import qs.Config @@ -16,10 +17,12 @@ CustomRect { clip: true color: visibilities.resources ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer implicitHeight: Config.barConfig.height + Appearance.padding.smallest * 2 - implicitWidth: rowLayout.implicitWidth + Appearance.padding.normal * 2 + implicitWidth: rowLayout.implicitWidth + Appearance.padding.larger * 2 radius: Appearance.rounding.full StateLayer { + color: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + onClicked: root.visibilities.resources = !root.visibilities.resources } @@ -27,11 +30,20 @@ CustomRect { id: rowLayout anchors.centerIn: parent + anchors.horizontalCenterOffset: -2 implicitHeight: root.implicitHeight spacing: Appearance.spacing.smaller - Ref { - service: SystemUsage + ServiceRef { + service: Gpu + } + + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory } Resource { @@ -39,8 +51,8 @@ CustomRect { Layout.fillHeight: true icon: "memory" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3primary - percentage: SystemUsage.cpuPerc + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary + percentage: Cpu.percentage warningThreshold: 95 } @@ -48,8 +60,8 @@ CustomRect { Layout.fillHeight: true icon: "memory_alt" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3secondaryContainer : DynamicColors.palette.m3secondary - percentage: SystemUsage.memPerc + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3secondary + percentage: Memory.percentage warningThreshold: 80 } @@ -57,16 +69,16 @@ CustomRect { Layout.fillHeight: true icon: "gamepad" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3tertiaryContainer : DynamicColors.palette.m3tertiary - percentage: SystemUsage.gpuPerc + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3tertiary + percentage: Gpu.percentage } Resource { Layout.fillHeight: true icon: "developer_board" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3primary - percentage: SystemUsage.gpuMemUsed + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary + percentage: Gpu.memoryUsed / Gpu.memoryTotal } } } diff --git a/Modules/Resources/Cards/BatteryTank.qml b/Modules/Resources/Cards/BatteryTank.qml new file mode 100644 index 0000000..0e97349 --- /dev/null +++ b/Modules/Resources/Cards/BatteryTank.qml @@ -0,0 +1,134 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.UPower +import qs.Components +import qs.Config + +CustomClippingRect { + id: root + + property real animPerc: UPower.displayDevice.percentage + + color: DynamicColors.palette.m3secondaryContainer + implicitWidth: 120 + radius: Appearance.rounding.large + + Behavior on animPerc { + Anim { + } + } + + Contents { + id: layout + + accentColor: DynamicColors.palette.m3primary + anchors.fill: parent + anchors.margins: Appearance.padding.larger + subTextColor: DynamicColors.palette.m3onSurfaceVariant + textColor: DynamicColors.palette.m3onSurface + } + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + clip: true + color: DynamicColors.palette.m3secondary + implicitHeight: parent.height * root.animPerc + radius: Appearance.rounding.extraSmall + + Contents { + accentColor: DynamicColors.palette.m3primaryContainer + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: layout.anchors.margins + anchors.right: parent.right + height: layout.height + subTextColor: DynamicColors.palette.m3secondaryContainer + textColor: DynamicColors.palette.m3onSecondary + } + } + + component Contents: ColumnLayout { + id: contents + + required property color accentColor + readonly property bool charging: [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state) + required property color subTextColor + required property color textColor + + spacing: 0 + + MaterialIcon { + Layout.leftMargin: -Appearance.padding.extraSmall + color: contents.accentColor + text: "battery_full" + } + + CustomText { + Layout.fillWidth: true + color: contents.textColor + text: qsTr("Battery") + } + + Item { + Layout.fillHeight: true + } + + CustomText { + Layout.alignment: Qt.AlignRight + animate: true + color: contents.subTextColor + text: { + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return qsTr("Full"); + + if (contents.charging) + return qsTr("Charging"); + + const s = UPower.displayDevice.timeToEmpty; + if (s === 0) + return qsTr("..."); + + const hr = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + if (hr > 0) + return `${hr}h ${min}m`; + + return `${min}m`; + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + Layout.bottomMargin: -Appearance.padding.small + Layout.rightMargin: -Appearance.padding.extraSmall + Layout.topMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + MaterialIcon { + color: contents.accentColor + fill: 1 + opacity: contents.charging ? 1 : 0 + scale: contents.charging ? 1 : 0 + text: "bolt" + + Behavior on opacity { + Anim { + type: Anim.FastEffects + } + } + Behavior on scale { + Anim { + type: Anim.FastSpatial + } + } + } + + CustomText { + color: contents.accentColor + text: `${Math.round(UPower.displayDevice.percentage * 100)}%` + } + } + } +} diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml new file mode 100644 index 0000000..efb23fa --- /dev/null +++ b/Modules/Resources/Cards/HeroCard.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +CustomClippingRect { + id: root + + required property color accent + required property string icon + required property string label + required property string subLabel + required property real temperature + required property real usage + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: Math.max(tempProg.implicitHeight + detailsRow.implicitHeight + Appearance.spacing.large, usageColumn.implicitHeight + usageLabel.implicitHeight) + Appearance.padding.large * 2 + implicitWidth: 450 + radius: Appearance.rounding.large - Appearance.padding.normal + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + color: Qt.alpha(root.accent, 0.05) + implicitWidth: parent.width * root.usage + + Behavior on implicitWidth { + Anim { + } + } + } + + CircularProgress { + id: tempProg + + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.top: parent.top + fgColor: root.accent + implicitSize: Math.max(icon.implicitWidth, icon.implicitHeight) + Appearance.padding.larger * 2 + spacing: Appearance.spacing.extraSmall + strokeWidth: Appearance.padding.extraSmall + value: root.usage + + Behavior on clampedVal { + Anim { + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + color: root.accent + text: root.icon + } + } + + ColumnLayout { + anchors.left: tempProg.right + anchors.margins: Appearance.spacing.large + anchors.right: usageColumn.left + anchors.verticalCenter: tempProg.verticalCenter + spacing: Appearance.spacing.extraSmall + + CustomText { + color: root.accent + text: root.label + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + text: root.subLabel + } + } + + ColumnLayout { + id: detailsRow + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Appearance.padding.largeIncreased + spacing: Appearance.spacing.extraSmall + + RowLayout { + Layout.leftMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.08) + color: root.temperature > 90 ? DynamicColors.palette.m3error : root.accent + fill: 1 + text: root.temperature > 90 ? "thermometer_alert" : "thermometer" + } + + CustomText { + text: `${Math.ceil(root.temperature)}°${"C"}` + } + } + + CustomProgressBar { + fgColor: root.accent + implicitHeight: Appearance.padding.small + indeterminate: isNaN(root.usage) || isNaN(root.temperature) + value: root.temperature / 100 + } + } + + Column { + id: usageColumn + + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: usageLabel + + anchors.right: parent.right + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: qsTr("Usage") + } + + CustomText { + anchors.right: parent.right + color: root.accent + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" + } + } +} diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml new file mode 100644 index 0000000..1f8c2fd --- /dev/null +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Services +import qs.Components +import qs.Config + +CustomRect { + id: root + + readonly property color accent: DynamicColors.palette.m3tertiary + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + Appearance.padding.extraLargeIncreased * 2 + radius: Appearance.rounding.medium + + ServiceRef { + service: Memory + } + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Appearance.spacing.extraSmall + + RowLayout { + Layout.leftMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.small + + MaterialIcon { + color: root.accent + fill: 1 + text: "memory_alt" + } + + CustomText { + text: qsTr("Memory") + } + } + + CircularProgress { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.large + fgColor: root.accent + implicitSize: usageColumn.implicitHeight + thickness + Appearance.padding.largeIncreased * 2 + startAngle: -225 + sweepAngle: 270 + value: Memory.percentage + + Behavior on clampedVal { + Anim { + } + } + + ColumnLayout { + id: usageColumn + + anchors.centerIn: parent + anchors.verticalCenterOffset: Appearance.padding.extraSmall + spacing: 0 + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: root.accent + font.pointSize: Appearance.font.size.large + text: Math.round(Memory.percentage * 100) + "%" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Used") + } + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const fmt = UsageFmt.formatKib(Memory.used, Memory.total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + } + } +} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml new file mode 100644 index 0000000..b3bbebc --- /dev/null +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -0,0 +1,198 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Internal +import qs.Modules.Resources +import qs.Helpers +import qs.Components +import qs.Config + +CustomRect { + id: root + + required property Wrapper wrapper + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: 220 + implicitWidth: 300 + radius: Appearance.rounding.large - Appearance.padding.normal + + Ref { + service: NetworkUsage + } + + ColumnLayout { + id: layout + + anchors.bottomMargin: Appearance.padding.larger + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: 0 + + RowLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3primary + text: "swap_vert" + } + + CustomText { + text: qsTr("Network") + } + } + + Item { + Layout.bottomMargin: Appearance.spacing.small + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.larger + + SparklineItem { + id: sparkline + + property bool initialized: false + property real smoothMax: targetMax + property real targetMax: 1024 + + anchors.fill: parent + historyLength: NetworkUsage.historyLength + line1: NetworkUsage.uploadBuffer // qmllint disable missing-type + line1Color: DynamicColors.palette.m3secondary + line1FillAlpha: 0.15 + line2: NetworkUsage.downloadBuffer // qmllint disable missing-type + line2Color: DynamicColors.palette.m3tertiary + line2FillAlpha: 0.2 + maxValue: smoothMax + slideProgress: 1 + + Behavior on smoothMax { + enabled: sparkline.initialized + + Anim { + } + } + + Component.onCompleted: { + sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); + + sparkline.smoothMax = Qt.binding(() => sparkline.targetMax); + + sparkline.initialized = true; + } + + Connections { + function onValuesChanged(): void { + sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); + slideAnim.restart(); + } + + target: NetworkUsage.downloadBuffer + } + + 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: DynamicColors.palette.m3outline + text: qsTr("Collecting data...") + visible: NetworkUsage.downloadBuffer.count < 2 + } + } + + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3tertiary + text: "download" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Download") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3tertiary + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + } + } + + // Upload row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3secondary + text: "upload" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Upload") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3secondary + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + } + } + + // Session totals + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + text: "history" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Total") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: { + const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); + const up = NetworkUsage.formatBytesTotal(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 new file mode 100644 index 0000000..1a58283 --- /dev/null +++ b/Modules/Resources/Cards/StorageCard.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Services +import qs.Components +import qs.Config + +CustomRect { + id: root + + readonly property color accent: DynamicColors.palette.m3secondary + readonly property real percentage: Storage.primaryDisk?.perc ?? 0 + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 + radius: Appearance.rounding.large - Appearance.padding.normal + + ServiceRef { + service: Storage + } + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + RowLayout { + id: row + + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.large + + CircularProgress { + fgColor: root.accent + implicitSize: usageColumn.implicitHeight + thickness + Appearance.padding.large * 2 + startAngle: -225 + sweepAngle: 270 + value: root.percentage + + Behavior on clampedVal { + Anim { + } + } + + ColumnLayout { + id: usageColumn + + anchors.centerIn: parent + spacing: 0 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: root.accent + font.pointSize: Appearance.font.size.medium + text: "hard_drive" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: root.accent + font.pointSize: Appearance.font.size.large + text: Math.round(root.percentage * 100) + "%" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Used") + } + } + } + + ColumnLayout { + spacing: Appearance.spacing.extraSmall + + CustomText { + text: qsTr("Storage") + } + + CustomText { + color: root.accent + text: { + if (!Storage.primaryDisk) + return qsTr("No disks detected"); + + const fmt = UsageFmt.formatKib(Storage.primaryDisk.used, Storage.primaryDisk.total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + } + } + } + + CustomSplitButton { + Layout.alignment: Qt.AlignHCenter + active: menuItems.find(m => m.modelData === Storage.primaryDisk) ?? menuItems[0] ?? null + enabled: Storage.disks.length + fallbackIcon: "storage" + fallbackText: qsTr("No disks") + menuItems: disks.instances + minLeftWidth: row.implicitWidth * 0.6 + type: CustomSplitButton.Tonal + + menu.onItemSelected: item => Storage.manualPrimaryDisk = (item as DiskItem).modelData + + Variants { + id: disks + + model: Storage.disks + + DiskItem { + } + } + } + } + + component DiskItem: MenuItem { + required property var modelData + + activeIcon: "storage" + icon: modelData === Storage.primaryDisk ? "check" : "" + text: modelData.mount + } +} diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml index 78b3d89..55bcff1 100644 --- a/Modules/Resources/Content.qml +++ b/Modules/Resources/Content.qml @@ -1,42 +1,27 @@ -import Quickshell import QtQuick -import QtQuick.Controls import QtQuick.Layouts -import Quickshell.Services.UPower -import ZShell.Internal -import qs.Components +import ZShell.Services +import qs.Modules.Resources.Cards import qs.Helpers +import qs.Components import qs.Config Item { id: root - readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 - readonly property real nonAnimHeight: content.implicitHeight + Appearance.padding.normal * 2 - readonly property real nonAnimWidth: Math.max(minWidth, content.implicitWidth) + Appearance.padding.normal * 2 - required property real padding - required property PersistentProperties visibilities + required property Wrapper wrapper - function displayTemp(temp: real): string { - return `${Math.ceil(temp)}°C`; - } - - implicitHeight: nonAnimHeight - implicitWidth: nonAnimWidth + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: content.implicitWidth RowLayout { id: content anchors.left: parent.left - anchors.leftMargin: root.padding + anchors.margins: Appearance.padding.normal anchors.right: parent.right - anchors.rightMargin: root.padding anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.normal - - Ref { - service: SystemUsage - } + spacing: Appearance.spacing.larger ColumnLayout { id: mainColumn @@ -45,748 +30,94 @@ Item { spacing: Appearance.spacing.normal RowLayout { - Layout.fillWidth: true spacing: Appearance.spacing.normal - visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") + visible: cpuCard.active || gpuCard.active - HeroCard { - Layout.fillWidth: true - Layout.minimumWidth: 400 - Layout.preferredHeight: 150 - accentColor: DynamicColors.palette.m3primary - icon: "memory" - mainLabel: qsTr("Usage") - mainValue: `${Math.round(SystemUsage.cpuPerc * 100)}%` - secondaryLabel: qsTr("Temp") - secondaryValue: root.displayTemp(SystemUsage.cpuTemp) - temperature: SystemUsage.cpuTemp - title: SystemUsage.cpuName ? `CPU - ${SystemUsage.cpuName}` : qsTr("CPU") - usage: SystemUsage.cpuPerc - visible: Config.dashboard.performance.showCpu + WrappedLoader { + id: cpuCard + + active: Config.dashboard.performance.showCpu + + sourceComponent: HeroCard { + accent: DynamicColors.palette.m3primary + icon: "memory" + label: qsTr("CPU") + subLabel: Cpu.name + temperature: Cpu.temperature + usage: Cpu.percentage + + ServiceRef { + service: Cpu + } + } } - HeroCard { - Layout.fillWidth: true - Layout.minimumWidth: 400 - Layout.preferredHeight: 150 - accentColor: DynamicColors.palette.m3secondary - icon: "desktop_windows" - mainLabel: qsTr("Usage") - mainValue: `${Math.round(SystemUsage.gpuPerc * 100)}%` - secondaryLabel: qsTr("Temp") - secondaryValue: root.displayTemp(SystemUsage.gpuTemp) - temperature: SystemUsage.gpuTemp - title: SystemUsage.gpuName ? `GPU - ${SystemUsage.gpuName}` : qsTr("GPU") - usage: SystemUsage.gpuPerc - visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" + WrappedLoader { + id: gpuCard + + active: Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None + + sourceComponent: HeroCard { + accent: DynamicColors.palette.m3secondary + icon: "desktop_windows" + label: qsTr("GPU") + subLabel: Gpu.name + temperature: Gpu.temperature + usage: Gpu.percentage + + ServiceRef { + service: Gpu + } + } } } RowLayout { - Layout.fillWidth: true spacing: Appearance.spacing.normal - visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork + visible: storageCard.active || networkCard.active || memoryCard.active - GaugeCard { - Layout.fillWidth: !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork - Layout.minimumWidth: 250 - Layout.preferredHeight: 220 - accentColor: DynamicColors.palette.m3tertiary - icon: "memory_alt" - percentage: SystemUsage.memPerc - subtitle: { - const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed); - const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal); - return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; + WrappedLoader { + id: storageCard + + active: Config.dashboard.performance.showStorage + + sourceComponent: StorageCard { } - title: qsTr("Memory") - visible: Config.dashboard.performance.showMemory } - StorageGaugeCard { - Layout.fillWidth: !Config.dashboard.performance.showNetwork - Layout.minimumWidth: 250 - Layout.preferredHeight: 220 - visible: Config.dashboard.performance.showStorage + WrappedLoader { + id: memoryCard + + active: Config.dashboard.performance.showMemory + + sourceComponent: MemoryCard { + } } - NetworkCard { - Layout.fillWidth: true - Layout.minimumWidth: 200 - Layout.preferredHeight: 220 - visible: Config.dashboard.performance.showNetwork + WrappedLoader { + id: networkCard + + active: Config.dashboard.performance.showNetwork + + sourceComponent: NetworkCard { + wrapper: root.wrapper + } } } } - BatteryTank { - Layout.preferredHeight: mainColumn.implicitHeight - Layout.preferredWidth: 120 - visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery + WrappedLoader { + Layout.fillWidth: false + active: Battery.isLaptop && Config.dashboard.performance.showBattery + + sourceComponent: BatteryTank { + } } } - component BatteryTank: CustomClippingRect { - id: batteryTank - - property color accentColor: DynamicColors.palette.m3primary - property real animatedPercentage: 0 - property bool isCharging: UPower.displayDevice.state === UPowerDeviceState.Charging - property real percentage: UPower.displayDevice.percentage - - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - Appearance.padding.normal - - Behavior on animatedPercentage { - Anim { - duration: Appearance.anim.durations.large - } - } - - Component.onCompleted: animatedPercentage = percentage - onPercentageChanged: animatedPercentage = percentage - - // Background Fill - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - color: Qt.alpha(batteryTank.accentColor, 0.15) - height: parent.height * batteryTank.animatedPercentage - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.small - - // Header Section - ColumnLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.small - - MaterialIcon { - color: batteryTank.accentColor - font.pointSize: Appearance.font.size.large - text: { - if (!UPower.displayDevice.isLaptopBattery) { - if (PowerProfiles.profile === PowerProfile.PowerSaver) - return "energy_savings_leaf"; - - if (PowerProfiles.profile === PowerProfile.Performance) - return "rocket_launch"; - - return "balance"; - } - if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) - return "battery_full"; - - const perc = UPower.displayDevice.percentage; - const charging = [UPowerDeviceState.Charging, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state); - if (perc >= 0.99) - return "battery_full"; - - let level = Math.floor(perc * 7); - if (charging && (level === 4 || level === 1)) - level--; - - return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; - } - } - - CustomText { - Layout.fillWidth: true - color: DynamicColors.palette.m3onSurface - font.pointSize: Appearance.font.size.normal - text: qsTr("Battery") - } - } - - Item { - Layout.fillHeight: true - } - - // Bottom Info Section - ColumnLayout { - Layout.fillWidth: true - spacing: -4 - - CustomText { - Layout.alignment: Qt.AlignRight - color: batteryTank.accentColor - font.pointSize: Appearance.font.size.extraLarge - font.weight: Font.Medium - text: `${Math.round(batteryTank.percentage * 100)}%` - } - - CustomText { - Layout.alignment: Qt.AlignRight - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.smaller - text: { - if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) - return qsTr("Full"); - - if (batteryTank.isCharging) - return qsTr("Charging"); - - const s = UPower.displayDevice.timeToEmpty; - if (s === 0) - return qsTr("..."); - - const hr = Math.floor(s / 3600); - const min = Math.floor((s % 3600) / 60); - if (hr > 0) - return `${hr}h ${min}m`; - - return `${min}m`; - } - } - } - } - } - component CardHeader: RowLayout { - property color accentColor: DynamicColors.palette.m3primary - property string icon - property string title - + component WrappedLoader: Loader { + Layout.fillHeight: true Layout.fillWidth: true - spacing: Appearance.spacing.small - - MaterialIcon { - color: parent.accentColor - fill: 1 - font.pointSize: Appearance.spacing.large - text: parent.icon - } - - CustomText { - Layout.fillWidth: true - elide: Text.ElideRight - font.pointSize: Appearance.font.size.normal - text: parent.title - } - } - component GaugeCard: CustomRect { - id: gaugeCard - - property color accentColor: DynamicColors.palette.m3primary - property real animatedPercentage: 0 - readonly property real arcStartAngle: 0.75 * Math.PI - readonly property real arcSweep: 1.5 * Math.PI - property string icon - property real percentage: 0 - property string subtitle - property string title - - clip: true - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - Appearance.padding.normal - - Behavior on animatedPercentage { - Anim { - duration: Appearance.anim.durations.large - } - } - - Component.onCompleted: animatedPercentage = percentage - onPercentageChanged: animatedPercentage = percentage - - ColumnLayout { - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.smaller - - CardHeader { - accentColor: gaugeCard.accentColor - icon: gaugeCard.icon - title: gaugeCard.title - } - - Item { - Layout.fillHeight: true - Layout.fillWidth: true - - ArcGauge { - accentColor: gaugeCard.accentColor - anchors.centerIn: parent - height: width - percentage: gaugeCard.animatedPercentage - startAngle: gaugeCard.arcStartAngle - sweepAngle: gaugeCard.arcSweep - trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - width: Math.min(parent.width, parent.height) - } - - CustomText { - anchors.centerIn: parent - color: gaugeCard.accentColor - font.pointSize: Appearance.font.size.extraLarge - font.weight: Font.Medium - text: `${Math.round(gaugeCard.percentage * 100)}%` - } - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.smaller - text: gaugeCard.subtitle - } - } - } - component HeroCard: CustomClippingRect { - id: heroCard - - property color accentColor: DynamicColors.palette.m3primary - property real animatedTemp: 0 - property real animatedUsage: 0 - property string icon - property string mainLabel - property string mainValue - readonly property real maxTemp: 100 - property string secondaryLabel - property string secondaryValue - readonly property real tempProgress: Math.min(1, Math.max(0, temperature / maxTemp)) - property real temperature: 0 - property string title - property real usage: 0 - - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - Appearance.padding.normal - - Behavior on animatedTemp { - Anim { - duration: Appearance.anim.durations.large - } - } - Behavior on animatedUsage { - Anim { - duration: Appearance.anim.durations.large - } - } - - Component.onCompleted: { - animatedUsage = usage; - animatedTemp = tempProgress; - } - onTempProgressChanged: animatedTemp = tempProgress - onUsageChanged: animatedUsage = usage - - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: Qt.alpha(heroCard.accentColor, 0.15) - implicitWidth: parent.width * heroCard.animatedUsage - } - - CardHeader { - accentColor: heroCard.accentColor - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.large - anchors.top: parent.top - anchors.topMargin: Math.round(Appearance.padding.large * 1.2) - icon: heroCard.icon - title: heroCard.title - width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Appearance.spacing.normal - } - - Column { - anchors.bottom: parent.bottom - anchors.bottomMargin: Math.round(Appearance.padding.large * 1.3) - anchors.left: parent.left - anchors.margins: Math.round(Appearance.padding.large * 1.2) - anchors.right: parent.right - spacing: Appearance.spacing.small - - Row { - spacing: Appearance.spacing.small - - CustomText { - font.pointSize: Appearance.font.size.normal - font.weight: Font.Medium - text: heroCard.secondaryValue - } - - CustomText { - anchors.baseline: parent.children[0].baseline - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: heroCard.secondaryLabel - } - } - - ProgressBar { - bgColor: Qt.alpha(heroCard.accentColor, 0.2) - fgColor: heroCard.accentColor - implicitHeight: 6 - implicitWidth: parent.width * 0.5 - value: heroCard.tempProgress - } - } - - Column { - id: usageColumn - - anchors.margins: Appearance.padding.large - anchors.right: parent.right - anchors.rightMargin: 32 - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - CustomText { - id: usageLabel - - anchors.right: parent.right - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - text: heroCard.mainLabel - } - - CustomText { - anchors.right: parent.right - color: heroCard.accentColor - font.pointSize: Appearance.font.size.extraLarge - font.weight: Font.Medium - text: heroCard.mainValue - } - } - } - component NetworkCard: CustomRect { - id: networkCard - - property color accentColor: DynamicColors.palette.m3primary - - clip: true - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - Appearance.padding.normal - - Ref { - service: NetworkUsage - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.small - - CardHeader { - accentColor: networkCard.accentColor - icon: "swap_vert" - title: qsTr("Network") - } - - // Sparkline graph - Item { - Layout.fillHeight: true - Layout.fillWidth: true - - SparklineItem { - id: sparkline - - property real smoothMax: targetMax - property real targetMax: 1024 - - anchors.fill: parent - historyLength: NetworkUsage.historyLength - line1: NetworkUsage.uploadBuffer // qmllint disable missing-type - line1Color: DynamicColors.palette.m3secondary - line1FillAlpha: 0.15 - line2: NetworkUsage.downloadBuffer // qmllint disable missing-type - line2Color: DynamicColors.palette.m3tertiary - line2FillAlpha: 0.2 - maxValue: smoothMax - - Behavior on smoothMax { - Anim { - duration: Appearance.anim.durations.large - } - } - - Connections { - function onValuesChanged(): void { - sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); - slideAnim.restart(); - } - - target: NetworkUsage.downloadBuffer - } - - NumberAnimation { - id: slideAnim - - duration: Config.dashboard.resourceUpdateInterval - from: 0 - property: "slideProgress" - target: sparkline - to: 1 - } - } - - // "No data" placeholder - CustomText { - anchors.centerIn: parent - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - opacity: 0.6 - text: qsTr("Collecting data...") - visible: NetworkUsage.downloadBuffer.count < 2 - } - } - - // Download row - RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - - MaterialIcon { - color: DynamicColors.palette.m3tertiary - font.pointSize: Appearance.font.size.normal - text: "download" - } - - CustomText { - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: qsTr("Download") - } - - Item { - Layout.fillWidth: true - } - - CustomText { - color: DynamicColors.palette.m3tertiary - font.pointSize: Appearance.font.size.normal - font.weight: Font.Medium - text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; - } - } - } - - // Upload row - RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - - MaterialIcon { - color: DynamicColors.palette.m3secondary - font.pointSize: Appearance.font.size.normal - text: "upload" - } - - CustomText { - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: qsTr("Upload") - } - - Item { - Layout.fillWidth: true - } - - CustomText { - color: DynamicColors.palette.m3secondary - font.pointSize: Appearance.font.size.normal - font.weight: Font.Medium - text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; - } - } - } - - // Session totals - RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - - MaterialIcon { - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - text: "history" - } - - CustomText { - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: qsTr("Total") - } - - Item { - Layout.fillWidth: true - } - - CustomText { - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small - text: { - const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); - const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); - return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; - } - } - } - } - } - component ProgressBar: CustomRect { - id: progressBar - - property real animatedValue: 0 - property color bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - property color fgColor: DynamicColors.palette.m3primary - property real value: 0 - - color: bgColor - radius: Appearance.rounding.full - - Behavior on animatedValue { - Anim { - duration: Appearance.anim.durations.large - } - } - - Component.onCompleted: animatedValue = value - onValueChanged: animatedValue = value - - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: progressBar.fgColor - radius: Appearance.rounding.full - width: parent.width * progressBar.animatedValue - } - } - component StorageGaugeCard: CustomRect { - id: storageGaugeCard - - property color accentColor: DynamicColors.palette.m3secondary - property real animatedPercentage: 0 - readonly property real arcStartAngle: 0.75 * Math.PI - readonly property real arcSweep: 1.5 * Math.PI - readonly property var currentDisk: SystemUsage.disks.length > 0 ? SystemUsage.disks[currentDiskIndex] : null - property int currentDiskIndex: 0 - property int diskCount: 0 - - clip: true - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.large - Appearance.padding.normal - - Behavior on animatedPercentage { - Anim { - duration: Appearance.anim.durations.large - } - } - - Component.onCompleted: { - diskCount = SystemUsage.disks.length; - if (currentDisk) - animatedPercentage = currentDisk.perc; - } - onCurrentDiskChanged: { - if (currentDisk) - animatedPercentage = currentDisk.perc; - } - - // Update diskCount and animatedPercentage when disks data changes - Connections { - function onDisksChanged() { - if (SystemUsage.disks.length !== storageGaugeCard.diskCount) - storageGaugeCard.diskCount = SystemUsage.disks.length; - - // Update animated percentage when disk data refreshes - if (storageGaugeCard.currentDisk) - storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc; - } - - target: SystemUsage - } - - MouseArea { - anchors.fill: parent - - onWheel: wheel => { - if (wheel.angleDelta.y > 0) - storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex - 1 + storageGaugeCard.diskCount) % storageGaugeCard.diskCount; - else if (wheel.angleDelta.y < 0) - storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex + 1) % storageGaugeCard.diskCount; - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Appearance.padding.large - spacing: Appearance.spacing.smaller - - CardHeader { - accentColor: storageGaugeCard.accentColor - icon: "hard_disk" - title: { - const base = qsTr("Storage"); - if (!storageGaugeCard.currentDisk) - return base; - - return `${base} - ${storageGaugeCard.currentDisk.mount}`; - } - - // Scroll hint icon - MaterialIcon { - ToolTip.delay: 500 - ToolTip.text: qsTr("Scroll to switch disks") - ToolTip.visible: hintHover.hovered - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.normal - opacity: 0.7 - text: "unfold_more" - visible: storageGaugeCard.diskCount > 1 - - HoverHandler { - id: hintHover - } - } - } - - Item { - Layout.fillHeight: true - Layout.fillWidth: true - - ArcGauge { - accentColor: storageGaugeCard.accentColor - anchors.centerIn: parent - height: width - percentage: storageGaugeCard.animatedPercentage - startAngle: storageGaugeCard.arcStartAngle - sweepAngle: storageGaugeCard.arcSweep - trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - width: Math.min(parent.width, parent.height) - } - - CustomText { - anchors.centerIn: parent - color: storageGaugeCard.accentColor - font.pointSize: Appearance.font.size.extraLarge - font.weight: Font.Medium - text: storageGaugeCard.currentDisk ? `${Math.round(storageGaugeCard.currentDisk.perc * 100)}%` : "—" - } - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.smaller - text: { - if (!storageGaugeCard.currentDisk) - return "—"; - - const usedFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.used); - const totalFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.total); - return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; - } - } - } + visible: active } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 7a277f5..7c196e6 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -34,8 +34,7 @@ Item { anchors.centerIn: parent sourceComponent: Content { - padding: Appearance.padding.normal - visibilities: root.visibilities + wrapper: root } } } diff --git a/Modules/Settings/Categories.qml b/Modules/Settings/Categories.qml index df9875a..87454c5 100644 --- a/Modules/Settings/Categories.qml +++ b/Modules/Settings/Categories.qml @@ -18,103 +18,142 @@ Item { // Function to select category by key function selectCategory(categoryKey: string) { - for (let i = 0; i < listModel.count; i++) { - if (listModel.get(i).key === categoryKey) { - clayout.currentIndex = i; - root.content.currentCategory = categoryKey; + for (let i = 0; i < appearanceCats.count; i++) { + const item = appearanceCats.get(i); + if (item.key === categoryKey) { + appearanceView.currentIndex = i; + root.content.setCategory(item.key, item._index); + return; + } + } + for (let i = 0; i < systemCats.count; i++) { + const item = systemCats.get(i); + if (item.key === categoryKey) { + sysView.currentIndex = i; + root.content.setCategory(item.key, item._index); + return; + } + } + for (let i = 0; i < panelCats.count; i++) { + const item = panelCats.get(i); + if (item.key === categoryKey) { + panelsView.currentIndex = i; + root.content.setCategory(item.key, item._index); return; } } } - implicitHeight: searchBar.implicitHeight + Appearance.spacing.smaller + clayout.contentHeight + Appearance.padding.smaller * 2 - implicitWidth: clayout.contentWidth + Appearance.padding.smaller * 2 + implicitHeight: searchBar.implicitHeight + Appearance.spacing.smaller + clayout.contentHeight + clayout.anchors.margins * 2 + implicitWidth: clayout.contentWidth + clayout.anchors.margins * 2 ListModel { - id: listModel + id: systemCats ListElement { + _index: 0 icon: "settings" key: "general" name: "General" } ListElement { - icon: "wallpaper" - key: "wallpaper" - name: "Wallpaper" - } - - ListElement { - icon: "settop_component" - key: "bar" - name: "Bar" - } - - ListElement { - icon: "lock" - key: "lockscreen" - name: "Lockscreen" - } - - ListElement { + _index: 1 icon: "build_circle" key: "services" name: "Services" } ListElement { + _index: 2 icon: "notifications" key: "notifications" name: "Notifications" } ListElement { - icon: "view_sidebar" - key: "sidebar" - name: "Sidebar" - } - - ListElement { + _index: 3 icon: "handyman" key: "utilities" name: "Utilities" } ListElement { + _index: 4 + icon: "cached" + key: "updates" + name: "Updates" + } + } + + ListModel { + id: panelCats + + ListElement { + _index: 5 + icon: "settop_component" + key: "bar" + name: "Bar" + } + + ListElement { + _index: 6 + icon: "lock" + key: "lockscreen" + name: "Lockscreen" + } + + ListElement { + _index: 7 + icon: "view_sidebar" + key: "sidebar" + name: "Sidebar" + } + + ListElement { + _index: 8 icon: "dashboard" key: "dashboard" name: "Dashboard" } ListElement { - icon: "colors" - key: "appearance" - name: "Appearance" - } - - ListElement { + _index: 9 icon: "display_settings" key: "osd" name: "On screen display" } ListElement { + _index: 10 icon: "rocket_launch" key: "launcher" name: "Launcher" } + } + + ListModel { + id: appearanceCats ListElement { + _index: 11 + icon: "wallpaper" + key: "wallpaper" + name: "Wallpaper" + } + + ListElement { + _index: 12 icon: "screenshot_region" key: "screenshot" name: "Screenshot" } ListElement { - icon: "cached" - key: "updates" - name: "Updates" + _index: 13 + icon: "colors" + key: "appearance" + name: "Appearance" } } @@ -123,34 +162,113 @@ Item { color: DynamicColors.tPalette.m3surfaceContainer radius: Appearance.rounding.normal - CustomListView { + CustomFlickable { id: clayout - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.margins: Appearance.padding.smaller - anchors.top: parent.top - boundsBehavior: Flickable.StopAtBounds + anchors.fill: parent + anchors.margins: Appearance.padding.extraSmall + contentHeight: contentItem.childrenRect.height contentWidth: contentItem.childrenRect.width - highlightFollowsCurrentItem: false - implicitWidth: contentItem.childrenRect.width - model: listModel - spacing: 5 + flickableDirection: Flickable.VerticalFlick - delegate: Category { - } - highlight: CustomRect { - color: DynamicColors.palette.m3primary - implicitHeight: clayout.currentItem?.implicitHeight ?? 0 - implicitWidth: clayout.width - radius: Appearance.rounding.normal - Appearance.padding.smaller - y: clayout.currentItem?.y ?? 0 + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.normal - Behavior on y { - Anim { - duration: Appearance.anim.durations.small - easing.bezierCurve: Appearance.anim.curves.expressiveEffects + CustomListView { + id: sysView + + Layout.fillWidth: true + Layout.preferredHeight: contentItem.childrenRect.height + Layout.preferredWidth: contentItem.childrenRect.width + boundsBehavior: Flickable.StopAtBounds + highlightFollowsCurrentItem: false + interactive: false + model: systemCats + spacing: 2 + + delegate: Category { + view: sysView } + // highlight: CustomRect { + // color: DynamicColors.palette.m3primary + // implicitHeight: sysView.currentItem?.implicitHeight ?? 0 + // implicitWidth: sysView.width + // radius: Appearance.rounding.normal - Appearance.padding.smaller + // y: sysView.currentItem?.y ?? 0 + // + // Behavior on y { + // Anim { + // duration: Appearance.anim.durations.small + // easing.bezierCurve: Appearance.anim.curves.expressiveEffects + // } + // } + // } + } + + CustomListView { + id: panelsView + + Layout.fillWidth: true + Layout.preferredHeight: contentItem.childrenRect.height + Layout.preferredWidth: contentItem.childrenRect.width + boundsBehavior: Flickable.StopAtBounds + contentWidth: contentItem.childrenRect.width + highlightFollowsCurrentItem: false + interactive: false + model: panelCats + spacing: 2 + + delegate: Category { + view: panelsView + } + // highlight: CustomRect { + // color: DynamicColors.palette.m3primary + // implicitHeight: panelsView.currentItem?.implicitHeight ?? 0 + // implicitWidth: panelsView.width + // radius: Appearance.rounding.normal - Appearance.padding.smaller + // y: panelsView.currentItem?.y ?? 0 + // + // Behavior on y { + // Anim { + // duration: Appearance.anim.durations.small + // easing.bezierCurve: Appearance.anim.curves.expressiveEffects + // } + // } + // } + } + + CustomListView { + id: appearanceView + + Layout.fillWidth: true + Layout.preferredHeight: contentItem.childrenRect.height + Layout.preferredWidth: contentItem.childrenRect.width + boundsBehavior: Flickable.StopAtBounds + contentWidth: contentItem.childrenRect.width + highlightFollowsCurrentItem: false + interactive: false + model: appearanceCats + spacing: 2 + + delegate: Category { + view: appearanceView + } + // highlight: CustomRect { + // color: DynamicColors.palette.m3primary + // implicitHeight: appearanceView.currentItem?.implicitHeight ?? 0 + // implicitWidth: appearanceView.width + // radius: Appearance.rounding.normal - Appearance.padding.smaller + // y: appearanceView.currentItem?.y ?? 0 + // + // Behavior on y { + // Anim { + // duration: Appearance.anim.durations.small + // easing.bezierCurve: Appearance.anim.curves.expressiveEffects + // } + // } + // } } } } @@ -159,14 +277,44 @@ Item { component Category: CustomRect { id: categoryItem + required property int _index + readonly property bool first: index === 0 required property string icon required property int index required property string key + readonly property bool last: index === view.model.count - 1 required property string name + readonly property bool selected: key === root.content.currentCategory + required property ListView view - implicitHeight: 42 - implicitWidth: 250 - radius: Appearance.rounding.normal - Appearance.padding.smaller + bottomLeftRadius: layer.pressed ? Appearance.rounding.smallest : selected || last ? Appearance.rounding.normal - clayout.anchors.margins : Appearance.rounding.extraSmall + bottomRightRadius: layer.pressed ? Appearance.rounding.smallest : selected || last ? Appearance.rounding.normal - clayout.anchors.margins : Appearance.rounding.extraSmall + color: selected ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer + implicitHeight: 50 + implicitWidth: 256 + topLeftRadius: layer.pressed ? Appearance.rounding.smallest : selected || first ? Appearance.rounding.normal - clayout.anchors.margins : Appearance.rounding.extraSmall + topRightRadius: layer.pressed ? Appearance.rounding.smallest : selected || first ? Appearance.rounding.normal - clayout.anchors.margins : Appearance.rounding.extraSmall + + Behavior on bottomLeftRadius { + Anim { + type: Anim.FastEffects + } + } + Behavior on bottomRightRadius { + Anim { + type: Anim.FastEffects + } + } + Behavior on topLeftRadius { + Anim { + type: Anim.FastEffects + } + } + Behavior on topRightRadius { + Anim { + type: Anim.FastEffects + } + } RowLayout { id: layout @@ -181,9 +329,10 @@ Item { Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter Layout.fillHeight: true + Layout.leftMargin: Appearance.padding.small Layout.preferredWidth: icon.contentWidth - color: categoryItem.index === clayout.currentIndex ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - fill: categoryItem.index === clayout.currentIndex ? 1 : 0 + color: categoryItem.key === root.content.currentCategory ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + fill: categoryItem.key === root.content.currentCategory ? 1 : 0 font.pointSize: Appearance.font.size.small * 2 text: categoryItem.icon verticalAlignment: Text.AlignVCenter @@ -201,7 +350,7 @@ Item { Layout.fillHeight: true Layout.fillWidth: true Layout.leftMargin: Appearance.spacing.normal - color: categoryItem.index === clayout.currentIndex ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + color: categoryItem.key === root.content.currentCategory ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface text: categoryItem.name verticalAlignment: Text.AlignVCenter } @@ -210,9 +359,11 @@ Item { StateLayer { id: layer + color: categoryItem.key === root.content.currentCategory ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + onClicked: { - root.content.currentCategory = categoryItem.key; - clayout.currentIndex = categoryItem.index; + root.content.setCategory(categoryItem.key, categoryItem._index); + categoryItem.view.currentIndex = categoryItem.index; } } } diff --git a/Modules/Settings/Categories/General.qml b/Modules/Settings/Categories/General.qml index 59ba5d2..89d9bc7 100644 --- a/Modules/Settings/Categories/General.qml +++ b/Modules/Settings/Categories/General.qml @@ -1,6 +1,7 @@ import Quickshell import QtQuick import QtQuick.Layouts +import QtQuick.Controls import qs.Modules.Settings.Controls import qs.Config import qs.Components @@ -62,6 +63,7 @@ SettingsPage { SettingsSection { sectionId: "Color" + z: 1 SettingsHeader { name: "Color" @@ -105,7 +107,6 @@ SettingsPage { active: root.schemeTypeItem(menuItems, Config.colors.schemeType) enabled: Config.general.color.schemeGeneration label: qsTr("Scheme type") - z: 2 menuItems: [ MenuItem { @@ -231,26 +232,37 @@ SettingsPage { shouldBeActive: Config.general.color.schemeGeneration ? 1 : 0 } - SettingSpinner { + TimeInput { name: "Schedule dark mode" object: Config.general.color - settings: ["scheduleDarkStart", "scheduleDarkEnd", "scheduleDark"] - shouldBeActive: Config.general.color.schemeGeneration ? 1 : 0 + settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"] + shouldBeActive: Config.general.color.schemeGeneration } Separator { } - SettingHyprSpinner { + HyprTimeInput { name: "Schedule Hyprsunset" object: Config.general.color - settings: ["scheduleHyprsunsetStart", "scheduleHyprsunsetEnd", "scheduleHyprsunset", "hyprsunsetTemp"] + settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd", "hyprsunsetTemp"] + } + + Separator { + } + + SettingSpinBox { + max: 20000 + min: 1000 + name: "Hyprsunset temperature" + object: Config.general.color + setting: "hyprsunsetTemp" + step: 200 } } SettingsSection { sectionId: "Default Apps" - z: -1 SettingsHeader { name: "Default Apps" diff --git a/Modules/Settings/Categories/Services.qml b/Modules/Settings/Categories/Services.qml index d730c74..ef7bb7a 100644 --- a/Modules/Settings/Categories/Services.qml +++ b/Modules/Settings/Categories/Services.qml @@ -92,6 +92,18 @@ SettingsPage { Separator { } + SettingSpinBox { + max: 1.0 + min: 0 + name: "Minimum brightness" + object: Config.services + setting: "minBrightness" + step: 0.01 + } + + Separator { + } + SettingSpinBox { max: 5 min: 0 @@ -115,7 +127,7 @@ SettingsPage { SettingSpinBox { min: 1 - name: "Visualizer bars" + name: "Visualizer resolution" object: Config.services setting: "visualizerBars" step: 1 diff --git a/Modules/Settings/Categories/SystemUpdates.qml b/Modules/Settings/Categories/SystemUpdates.qml index dfe3850..dd01d70 100644 --- a/Modules/Settings/Categories/SystemUpdates.qml +++ b/Modules/Settings/Categories/SystemUpdates.qml @@ -70,7 +70,7 @@ CustomClippingRect { StateLayer { color: DynamicColors.palette.m3onPrimary - disabled: Updates.updating + enabled: !Updates.updating onClicked: Updates.performSystemUpdate() } diff --git a/Modules/Settings/Categories/Utilities.qml b/Modules/Settings/Categories/Utilities.qml index a40af60..2e5fee7 100644 --- a/Modules/Settings/Categories/Utilities.qml +++ b/Modules/Settings/Categories/Utilities.qml @@ -46,6 +46,75 @@ SettingsPage { } } + SettingsSection { + sectionId: "Clipboard" + + SettingsHeader { + name: "Clipboard" + } + + SettingSwitch { + name: "Enable clipboard history viewer" + object: Config.clipboard + setting: "enabled" + } + + Separator { + } + + SettingSpinBox { + max: 20 + min: 1 + name: "Max entries visible" + object: Config.clipboard + setting: "maxEntriesShown" + } + + Separator { + } + + SettingSpinBox { + max: 80 + min: 30 + name: "Entry height" + object: Config.clipboard.sizes + setting: "itemHeight" + } + + Separator { + } + + SettingSpinBox { + max: 700 + min: 300 + name: "Entry width" + object: Config.clipboard.sizes + setting: "width" + } + + Separator { + } + + SettingSpinBox { + max: 1800 + min: 50 + name: "Minimum preview width" + object: Config.clipboard.sizes + setting: "minPreviewWidth" + } + + Separator { + } + + SettingSpinBox { + max: 1800 + min: 50 + name: "Maximum preview width" + object: Config.clipboard.sizes + setting: "previewWidth" + } + } + SettingsSection { sectionId: "Toasts" diff --git a/Modules/Settings/Content.qml b/Modules/Settings/Content.qml index 81d33cf..dcca906 100644 --- a/Modules/Settings/Content.qml +++ b/Modules/Settings/Content.qml @@ -14,6 +14,8 @@ Item { id: root property string currentCategory: "general" + property int currentIndex: 0 + property int lastIndex: 0 readonly property real nonAnimHeight: Math.floor(screen.height / 1.5) + viewWrapper.anchors.margins * 2 readonly property real nonAnimWidth: view.implicitWidth + Math.floor(screen.width / 2) + viewWrapper.anchors.margins * 2 property string pendingSection: "" @@ -27,8 +29,9 @@ Item { scrollTimer.restart(); } - function selectCategory(categoryKey: string) { - layout.selectCategory(categoryKey); + function setCategory(category: string, index: int): void { + currentIndex = index; + currentCategory = category; } implicitHeight: nonAnimHeight @@ -50,35 +53,88 @@ Item { Connections { function onCurrentCategoryChanged() { - stack.pop(); - if (currentCategory === "general") - stack.push(general); - else if (currentCategory === "wallpaper") - stack.push(background); - else if (currentCategory === "bar") - stack.push(bar); - else if (currentCategory === "appearance") - stack.push(appearance); - else if (currentCategory === "lockscreen") - stack.push(lockscreen); - else if (currentCategory === "services") - stack.push(services); - else if (currentCategory === "notifications") - stack.push(notifications); - else if (currentCategory === "sidebar") - stack.push(sidebar); - else if (currentCategory === "utilities") - stack.push(utilities); - else if (currentCategory === "dashboard") - stack.push(dashboard); - else if (currentCategory === "osd") - stack.push(osd); - else if (currentCategory === "launcher") - stack.push(launcher); - else if (currentCategory === "screenshot") - stack.push(screenshot); - else if (currentCategory === "updates") - stack.push(updates); + if (root.currentCategory === "general") { + stack.replaceCurrentItem(general, [], StackView.PopTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "wallpaper") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(background, [], StackView.PopTransition); + else + stack.replaceCurrentItem(background, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "bar") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(bar, [], StackView.PopTransition); + else + stack.replaceCurrentItem(bar, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "appearance") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(appearance, [], StackView.PopTransition); + else + stack.replaceCurrentItem(appearance, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "lockscreen") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(lockscreen, [], StackView.PopTransition); + else + stack.replaceCurrentItem(lockscreen, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "services") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(services, [], StackView.PopTransition); + else + stack.replaceCurrentItem(services, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "notifications") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(notifications, [], StackView.PopTransition); + else + stack.replaceCurrentItem(notifications, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "sidebar") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(sidebar, [], StackView.PopTransition); + else + stack.replaceCurrentItem(sidebar, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "utilities") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(utilities, [], StackView.PopTransition); + else + stack.replaceCurrentItem(utilities, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "dashboard") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(dashboard, [], StackView.PopTransition); + else + stack.replaceCurrentItem(dashboard, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "osd") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(osd, [], StackView.PopTransition); + else + stack.replaceCurrentItem(osd, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "launcher") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(launcher, [], StackView.PopTransition); + else + stack.replaceCurrentItem(launcher, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "screenshot") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(screenshot, [], StackView.PopTransition); + else + stack.replaceCurrentItem(screenshot, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } else if (root.currentCategory === "updates") { + if (root.currentIndex < root.lastIndex) + stack.replaceCurrentItem(updates, [], StackView.PopTransition); + else + stack.replaceCurrentItem(updates, [], StackView.PushTransition); + root.lastIndex = root.currentIndex; + } } target: root @@ -99,7 +155,7 @@ Item { anchors.top: parent.top onSettingSelected: (category, section, settingName) => { - root.selectCategory(category); + layout.selectCategory(category); root.scrollToSetting(section, settingName); } } @@ -141,8 +197,102 @@ Item { id: stack anchors.fill: parent - anchors.margins: Appearance.padding.smaller initialItem: general + + popEnter: Transition { + SequentialAnimation { + Anim { + duration: 0 + from: 0 + property: "opacity" + to: 0 + } + + PauseAnimation { + duration: Appearance.anim.durations.expressiveEffects + } + + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 0 + property: "opacity" + to: 1 + } + + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: -50 + property: "y" + to: 0 + } + } + } + } + popExit: Transition { + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 1 + property: "opacity" + to: 0 + } + + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 0 + property: "y" + to: 50 + } + } + } + pushEnter: Transition { + SequentialAnimation { + Anim { + duration: 0 + from: 0 + property: "opacity" + to: 0 + } + + PauseAnimation { + duration: Appearance.anim.durations.expressiveEffects + } + + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 0 + property: "opacity" + to: 1 + } + + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 50 + property: "y" + to: 0 + } + } + } + } + pushExit: Transition { + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 1 + property: "opacity" + to: 0 + } + + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + from: 0 + property: "y" + to: -50 + } + } + } } } } diff --git a/Modules/Settings/Controls/HyprTimeInput.qml b/Modules/Settings/Controls/HyprTimeInput.qml new file mode 100644 index 0000000..661fda9 --- /dev/null +++ b/Modules/Settings/Controls/HyprTimeInput.qml @@ -0,0 +1,660 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config +import qs.Components +import qs.Helpers + +Item { + id: root + + readonly property string endTime: { + var d = new Date(0, 0, 0, 0, 0, 0, 0); + d.setMinutes(object[settings[2]]); + return Qt.formatTime(d, "hh:mm AP"); + } + readonly property bool highlighted: SettingsHighlight.highlightedSetting === name + required property string name + required property var object + required property list settings + property bool shouldBeActive: true + readonly property string startTime: { + var d = new Date(0, 0, 0, 0, 0, 0, 0); + d.setMinutes(object[settings[1]]); + return Qt.formatTime(d, "hh:mm AP"); + } + + function commitChoice(choice: int, setting: string): void { + root.object[setting] = choice; + Config.save(); + Hyprsunset.checkStartup(); + } + + function convertHour(timeValue: int): int { + return Math.floor(timeValue / 60); + } + + function convertMinute(timeValue: int): int { + return timeValue % 60; + } + + function convertToMinutes(hour: int, minute: int): int { + return hour * 60 + minute; + } + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: shouldBeActive ? row.implicitHeight + Appearance.padding.smaller * 2 : 0 + opacity: shouldBeActive ? 1 : 0 + scale: shouldBeActive ? 1 : 0.8 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + Behavior on y { + Anim { + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -Appearance.padding.smaller + color: DynamicColors.palette.m3primaryContainer + opacity: root.highlighted ? 0.5 : 0 + radius: Appearance.rounding.small + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + ColumnLayout { + Layout.fillHeight: true + Layout.fillWidth: true + + CustomText { + id: text + + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: true + font.pointSize: Appearance.font.size.larger + text: root.name + } + + CustomText { + Layout.alignment: Qt.AlignLeft + Layout.preferredWidth: Math.min(contentWidth, optionLayout.x - Appearance.spacing.normal) + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(root.startTime).arg(root.endTime) + wrapMode: Text.WordWrap + } + } + + ColumnLayout { + id: optionLayout + + Layout.fillHeight: true + Layout.fillWidth: true + + RowLayout { + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") + } + + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") + } + + CustomText { + Layout.alignment: Qt.AlignLeft | Qt.AlignHCenter + text: qsTr("Enabled: ") + } + + CustomSwitch { + id: enabledSwitch + + Layout.alignment: Qt.AlignRight | Qt.AlignHCenter + checked: root.object[root.settings[0]] + + onToggled: { + root.object[root.settings[0]] = checked; + Config.save(); + } + } + } + + RowLayout { + Layout.fillHeight: true + + CustomRect { + id: startHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(startHourField.text), parseInt(startMinuteField.text)), root.settings[1]); + } + 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: Appearance.font.size.extraLarge + text: ":" + } + + CustomRect { + id: startMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startMinuteField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(startHourField.text), parseInt(startMinuteField.text)), root.settings[1]); + } + 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: Appearance.spacing.large + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(endHourField.text), parseInt(endMinuteField.text)), root.settings[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: Appearance.font.size.extraLarge + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + id: endMinuteField + + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(endHourField.text), parseInt(endMinuteField.text)), root.settings[2]); + } + onTextEdited: { + if (endMinuteField.text === "") + 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; + } + } + } + } + + RowLayout { + Layout.fillWidth: true + + CustomText { + id: startHour + + Layout.preferredWidth: startSeparator.x + startSeparator.width + text: qsTr("Hour") + } + + CustomText { + id: startMinute + + Layout.preferredWidth: spacer.x + spacer.width - x + text: qsTr("Minute") + } + + CustomText { + Layout.preferredWidth: endSeparator.x + endSeparator.width - x + text: qsTr("Hour") + } + + CustomText { + text: qsTr("Minute") + } + } + } + } +} diff --git a/Modules/Settings/Controls/SettingBarEntryList.qml b/Modules/Settings/Controls/SettingBarEntryList.qml index 06c6338..6f29eea 100644 --- a/Modules/Settings/Controls/SettingBarEntryList.qml +++ b/Modules/Settings/Controls/SettingBarEntryList.qml @@ -221,11 +221,11 @@ Item { font: Appearance.font.family.sans // icon: root.iconForId(modelData.entry.id) icon: root.labelForId(modelData.entry.id) - inactiveColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + inactiveColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + isToggle: true radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8 radiusAnim.duration: MaterialEasing.expressiveEffectsTime radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects - toggle: true visible: !["spacer", "upower", "dash", "audio"].some(prefix => modelData.entry.id.startsWith(prefix)) Behavior on Layout.preferredWidth { diff --git a/Modules/Settings/Controls/SettingsPage.qml b/Modules/Settings/Controls/SettingsPage.qml index 1bd46b7..e80cb54 100644 --- a/Modules/Settings/Controls/SettingsPage.qml +++ b/Modules/Settings/Controls/SettingsPage.qml @@ -1,25 +1,21 @@ import QtQuick -import QtQuick.Layouts +import QtQuick.Controls import qs.Components import qs.Config import qs.Helpers -CustomClippingRect { +Item { id: root default property alias contentData: clayout.data - // Find and scroll to a section by its sectionId, then highlight a specific setting function scrollToSectionAndHighlight(sectionId: string, settingName: string): bool { - // Find the section with matching sectionId for (let i = 0; i < clayout.children.length; i++) { const section = clayout.children[i]; if (section.sectionId === sectionId) { - // Scroll to the section with some padding const targetY = section.y - Appearance.padding.normal; flickable.contentY = Math.max(0, Math.min(targetY, flickable.contentHeight - flickable.height)); - // Use the singleton to highlight the setting SettingsHighlight.highlight(settingName); return true; } @@ -27,14 +23,15 @@ CustomClippingRect { return false; } - radius: Appearance.rounding.normal - Appearance.padding.smaller - CustomFlickable { id: flickable anchors.fill: parent + // for future: + // anchors.leftMargin: Appearance.padding.extraLarge + // anchors.rightMargin: Appearance.padding.extraLarge clip: true - contentHeight: clayout.implicitHeight + contentHeight: clayout.implicitHeight + clayout.anchors.margins * 2 CustomScrollBar.vertical: CustomScrollBar { flickable: flickable @@ -64,7 +61,9 @@ CustomClippingRect { id: clayout anchors.left: parent.left + anchors.margins: Appearance.padding.extraSmall anchors.right: parent.right + anchors.top: parent.top spacing: Appearance.spacing.small // move: Transition { diff --git a/Modules/Settings/Controls/SettingsSection.qml b/Modules/Settings/Controls/SettingsSection.qml index 10976bb..5ed5d81 100644 --- a/Modules/Settings/Controls/SettingsSection.qml +++ b/Modules/Settings/Controls/SettingsSection.qml @@ -14,7 +14,7 @@ CustomRect { anchors.right: parent.right color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: layout.height + contentPadding * 2 - radius: Appearance.rounding.normal - Appearance.padding.smaller + radius: Appearance.rounding.normal - Appearance.padding.extraSmall Behavior on implicitHeight { Anim { diff --git a/Modules/Settings/Controls/SpinnerButton.qml b/Modules/Settings/Controls/SpinnerButton.qml index af95494..7aedaa1 100644 --- a/Modules/Settings/Controls/SpinnerButton.qml +++ b/Modules/Settings/Controls/SpinnerButton.qml @@ -44,11 +44,11 @@ CustomRect { } StateLayer { - function onClicked(): void { + visible: root.enabled + + onClicked: { SettingsDropdowns.toggle(menu, root); } - - visible: root.enabled } PathViewMenu { diff --git a/Modules/Settings/Controls/TimeInput.qml b/Modules/Settings/Controls/TimeInput.qml new file mode 100644 index 0000000..10c937d --- /dev/null +++ b/Modules/Settings/Controls/TimeInput.qml @@ -0,0 +1,661 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config +import qs.Components +import qs.Helpers + +Item { + id: root + + readonly property string endTime: { + var d = new Date(0, 0, 0, 0, 0, 0, 0); + d.setMinutes(object[settings[2]]); + return Qt.formatTime(d, "hh:mm AP"); + } + readonly property bool highlighted: SettingsHighlight.highlightedSetting === name + required property string name + required property var object + required property list settings + property bool shouldBeActive: true + readonly property string startTime: { + var d = new Date(0, 0, 0, 0, 0, 0, 0); + d.setMinutes(object[settings[1]]); + return Qt.formatTime(d, "hh:mm AP"); + } + + function commitChoice(choice: int, setting: string): void { + root.object[setting] = choice; + Config.save(); + ModeScheduler.checkStartup(); + } + + function convertHour(timeValue: int): int { + return Math.floor(timeValue / 60); + } + + function convertMinute(timeValue: int): int { + return timeValue % 60; + } + + function convertToMinutes(hour: int, minute: int): int { + return hour * 60 + minute; + } + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: shouldBeActive ? row.implicitHeight + Appearance.padding.smaller * 2 : 0 + opacity: shouldBeActive ? 1 : 0 + scale: shouldBeActive ? 1 : 0.8 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + Behavior on y { + Anim { + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -Appearance.padding.smaller + color: DynamicColors.palette.m3primaryContainer + opacity: root.highlighted ? 0.5 : 0 + radius: Appearance.rounding.small + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.normal + } + } + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + ColumnLayout { + Layout.fillHeight: true + Layout.fillWidth: true + + CustomText { + id: text + + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: true + font.pointSize: Appearance.font.size.larger + text: root.name + } + + CustomText { + Layout.alignment: Qt.AlignLeft + Layout.preferredWidth: Math.min(contentWidth, optionLayout.x - Appearance.spacing.normal) + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(root.startTime).arg(root.endTime) + wrapMode: Text.WordWrap + } + } + + ColumnLayout { + id: optionLayout + + Layout.fillHeight: true + Layout.fillWidth: true + + RowLayout { + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") + } + + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") + } + + CustomText { + Layout.alignment: Qt.AlignLeft | Qt.AlignHCenter + text: qsTr("Enabled: ") + } + + CustomSwitch { + id: enabledSwitch + + Layout.alignment: Qt.AlignRight | Qt.AlignHCenter + checked: root.object[root.settings[0]] + + onToggled: { + root.object[root.settings[0]] = checked; + Config.save(); + ModeScheduler.checkStartup(); + } + } + } + + RowLayout { + Layout.fillHeight: true + + CustomRect { + id: startHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(startHourField.text), parseInt(startMinuteField.text)), root.settings[1]); + } + 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: Appearance.font.size.extraLarge + text: ":" + } + + CustomRect { + id: startMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startMinuteField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(startHourField.text), parseInt(startMinuteField.text)), root.settings[1]); + } + 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: Appearance.spacing.large + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(endHourField.text), parseInt(endMinuteField.text)), root.settings[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: Appearance.font.size.extraLarge + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? DynamicColors.palette.m3onPrimaryContainer : DynamicColors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Appearance.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim { + } + } + } + + CustomTextField { + id: endMinuteField + + 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 ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3onSurface + cursorHeight: height - Appearance.padding.normal * 2 + 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(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onEditingFinished: { + root.commitChoice(root.convertToMinutes(parseInt(endHourField.text), parseInt(endMinuteField.text)), root.settings[2]); + } + onTextEdited: { + if (endMinuteField.text === "") + 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; + } + } + } + } + + RowLayout { + Layout.fillWidth: true + + CustomText { + id: startHour + + Layout.preferredWidth: startSeparator.x + startSeparator.width + text: qsTr("Hour") + } + + CustomText { + id: startMinute + + Layout.preferredWidth: spacer.x + spacer.width - x + text: qsTr("Minute") + } + + CustomText { + Layout.preferredWidth: endSeparator.x + endSeparator.width - x + text: qsTr("Hour") + } + + CustomText { + text: qsTr("Minute") + } + } + } + } +} diff --git a/Modules/Settings/Controls/WallpaperCropper.qml b/Modules/Settings/Controls/WallpaperCropper.qml index f3da6f1..b0888b1 100644 --- a/Modules/Settings/Controls/WallpaperCropper.qml +++ b/Modules/Settings/Controls/WallpaperCropper.qml @@ -94,7 +94,7 @@ Item { const finalRect = Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent); // We just pass the percentages directly to the backend - Wallpapers.setCrop(delegate.modelData.name, finalRect, finalRect, cropRect.zoom); + Wallpapers.setCrop(delegate.modelData.name, finalRect, cropRect.zoom); } function zoomClipRect(zoom: real): void { @@ -131,24 +131,22 @@ Item { anchors.left: parent.left anchors.right: parent.right implicitHeight: 30 - spacing: Appearance.spacing.large - - CustomText { - text: qsTr("Crop scale") - } CustomSlider { id: zoomSlider Layout.fillWidth: true - Layout.preferredHeight: 30 + Layout.leftMargin: Appearance.padding.normal + Layout.preferredHeight: Appearance.padding.larger * 3 + Layout.rightMargin: Appearance.padding.normal from: 1.0 - implicitHeight: 30 + implicitHeight: Appearance.padding.larger * 3 + insetIcon: "crop" to: 5.0 value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 - onMoved: { - delegate.zoomClipRect(value); + onInteraction: value => { + delegate.zoomClipRect(1 + (value * 4)); wrapper.changesMade = true; } } @@ -167,7 +165,7 @@ Item { anchors.top: parent.top asynchronous: true fillMode: Image.PreserveAspectFit - // retainWhileLoading: true + retainWhileLoading: true source: Wallpapers.current sourceSize.height: parent.height sourceSize.width: parent.width @@ -202,7 +200,7 @@ Item { Loader { id: cropRectLoader - active: scaledImg.paintedWidth > 0 && scaledImg.status == Image.Ready + active: scaledImg.paintedWidth > 0 sourceComponent: Component { CustomRect { diff --git a/Modules/Settings/Controls/WallpaperGrid.qml b/Modules/Settings/Controls/WallpaperGrid.qml index c66ef00..9c91fad 100644 --- a/Modules/Settings/Controls/WallpaperGrid.qml +++ b/Modules/Settings/Controls/WallpaperGrid.qml @@ -131,16 +131,16 @@ GridView { } StateLayer { - function onClicked(): void { - Wallpapers.setWallpaper(modelData.path); - } - anchors.bottomMargin: itemMargin anchors.fill: parent anchors.leftMargin: itemMargin anchors.rightMargin: itemMargin anchors.topMargin: itemMargin radius: itemRadius + + onClicked: { + Wallpapers.setWallpaper(modelData.path); + } } } Behavior on opacity { diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index 0a57d75..68c6662 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -59,4 +59,13 @@ Scope { visibilities.settings = !visibilities.settings; } } + + CustomShortcut { + name: "toggle-clipboard" + + onPressed: { + const visibilities = Visibilities.getForActive(); + visibilities.clipboard = !visibilities.clipboard; + } + } } diff --git a/Modules/SysTray/Popouts/AudioPopup.qml b/Modules/SysTray/Popouts/AudioPopout.qml similarity index 50% rename from Modules/SysTray/Popouts/AudioPopup.qml rename to Modules/SysTray/Popouts/AudioPopout.qml index 9d2d92b..4dd9fb1 100644 --- a/Modules/SysTray/Popouts/AudioPopup.qml +++ b/Modules/SysTray/Popouts/AudioPopout.qml @@ -19,7 +19,7 @@ Item { required property var wrapper implicitHeight: vol.implicitHeight + Appearance.padding.small * 2 - implicitWidth: 400 + Appearance.padding.small * 2 + implicitWidth: 500 + Appearance.padding.small * 2 CustomRect { anchors.left: parent.left @@ -52,56 +52,16 @@ Item { CustomRect { Layout.fillWidth: true - Layout.preferredHeight: 50 + Appearance.spacing.smaller * 2 + Layout.preferredHeight: 65 + Appearance.spacing.smaller * 2 Layout.topMargin: root.topMargin color: DynamicColors.tPalette.m3surfaceContainer radius: root.rounding - Item { - id: sinkIcon - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.normal - anchors.top: parent.top - implicitWidth: childrenRect.width - - CustomRect { - anchors.centerIn: parent - color: Audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: 40 - implicitWidth: 40 - radius: Appearance.rounding.full - - MaterialIcon { - anchors.alignWhenCentered: false - anchors.centerIn: parent - animate: true - color: Audio.muted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - font.pointSize: 22 - text: Audio.muted ? "volume_off" : "volume_up" - } - - StateLayer { - color: Audio.muted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - - onClicked: { - const audio = Audio.sink?.audio; - if (audio) - audio.muted = !audio.muted; - } - } - } - } - ColumnLayout { - anchors.bottom: parent.bottom - anchors.bottomMargin: Appearance.padding.smallest - anchors.left: sinkIcon.right - anchors.leftMargin: Appearance.spacing.normal - anchors.right: parent.right - anchors.rightMargin: Appearance.padding.large - anchors.top: parent.top + anchors.bottomMargin: Appearance.padding.smaller + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger anchors.topMargin: Appearance.padding.smaller RowLayout { @@ -111,34 +71,53 @@ Item { CustomText { Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft Layout.fillWidth: true - text: "Output Volume" + text: "Output volume" } CustomText { Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + color: Qt.alpha(DynamicColors.palette.m3onSurface, 0.7) font.bold: true text: qsTr("%1").arg(Audio.muted ? qsTr("Muted") : `${Math.round(Audio.volume * 100)}%`) } } - CustomMouseArea { - Layout.bottomMargin: 5 - Layout.fillHeight: true - Layout.fillWidth: true + RowLayout { + spacing: Appearance.spacing.large - CustomSlider { - anchors.left: parent.left - anchors.right: parent.right - color: Audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: parent.height - value: Audio.volume + CustomMouseArea { + Layout.bottomMargin: Appearance.padding.normal + Layout.fillWidth: true + Layout.preferredHeight: Appearance.padding.larger * 3 - Behavior on value { - Anim { + CustomSlider { + anchors.left: parent.left + anchors.right: parent.right + bgColor: Audio.muted ? Qt.alpha(DynamicColors.palette.m3errorContainer, 0.5) : DynamicColors.palette.m3secondaryContainer + fgColor: Audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary + implicitHeight: parent.height + insetColor: Audio.muted ? (inset.attached ? DynamicColors.palette.m3onErrorContainer : DynamicColors.palette.m3onError) : (inset.attached ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onPrimary) + insetIcon: Audio.muted || Audio.volume < 0.001 ? "volume_off" : "volume_up" + value: Audio.volume + + Behavior on value { + Anim { + } } - } - onMoved: Audio.setVolume(value) + onInteraction: value => Audio.setVolume(value) + } + } + + CustomSwitch { + Layout.bottomMargin: Appearance.padding.normal + checked: !Audio.muted + + onToggled: { + const audio = Audio.sink?.audio; + if (audio) + audio.muted = !audio.muted; + } } } } @@ -146,7 +125,7 @@ Item { CustomClippingRect { Layout.fillWidth: true - Layout.preferredHeight: 50 + Appearance.spacing.smaller * 2 + Layout.preferredHeight: 65 + Appearance.spacing.smaller * 2 Layout.topMargin: root.topMargin color: DynamicColors.tPalette.m3surfaceContainer radius: root.rounding @@ -173,51 +152,11 @@ Item { } } - Item { - id: sourceIcon - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.normal - anchors.top: parent.top - implicitWidth: childrenRect.width - - CustomRect { - anchors.centerIn: parent - color: Audio.sourceMuted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: 40 - implicitWidth: 40 - radius: Appearance.rounding.full - - MaterialIcon { - anchors.alignWhenCentered: false - anchors.centerIn: parent - animate: true - color: Audio.sourceMuted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - font.pointSize: 22 - text: Audio.sourceMuted ? "mic_off" : "mic" - } - - StateLayer { - color: Audio.sourceMuted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - - onClicked: { - const audio = Audio.source?.audio; - if (audio) - audio.muted = !audio.muted; - } - } - } - } - ColumnLayout { - anchors.bottom: parent.bottom - anchors.bottomMargin: Appearance.padding.smallest - anchors.left: sourceIcon.right - anchors.leftMargin: Appearance.spacing.normal - anchors.right: parent.right - anchors.rightMargin: Appearance.padding.large - anchors.top: parent.top + anchors.bottomMargin: Appearance.padding.smaller + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger anchors.topMargin: Appearance.padding.smaller RowLayout { @@ -227,34 +166,53 @@ Item { CustomText { Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft Layout.fillWidth: true - text: "Input Volume" + text: "Input volume" } CustomText { Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + color: Qt.alpha(DynamicColors.palette.m3onSurface, 0.7) font.bold: true text: qsTr("%1").arg(Audio.sourceMuted ? qsTr("Muted") : `${Math.round(Audio.sourceVolume * 100)}%`) } } - CustomMouseArea { - Layout.bottomMargin: 5 - Layout.fillHeight: true - Layout.fillWidth: true + RowLayout { + spacing: Appearance.spacing.large - CustomSlider { - anchors.left: parent.left - anchors.right: parent.right - color: Audio.sourceMuted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: parent.height - value: Audio.sourceVolume + CustomMouseArea { + Layout.bottomMargin: Appearance.padding.normal + Layout.fillWidth: true + Layout.preferredHeight: Appearance.padding.larger * 3 - Behavior on value { - Anim { + CustomSlider { + anchors.left: parent.left + anchors.right: parent.right + bgColor: Audio.sourceMuted ? Qt.alpha(DynamicColors.palette.m3errorContainer, 0.5) : DynamicColors.palette.m3secondaryContainer + fgColor: Audio.sourceMuted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary + implicitHeight: parent.height + insetColor: Audio.sourceMuted ? (inset.attached ? DynamicColors.palette.m3onErrorContainer : DynamicColors.palette.m3onError) : (inset.attached ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onPrimary) + insetIcon: Audio.sourceMuted || Audio.sourceVolume < 0.001 ? "mic_off" : "mic" + value: Audio.sourceVolume + + Behavior on value { + Anim { + } } - } - onMoved: Audio.setSourceVolume(value) + onInteraction: value => Audio.setSourceVolume(value) + } + } + + CustomSwitch { + Layout.bottomMargin: Appearance.padding.normal + checked: !Audio.sourceMuted + + onToggled: { + const audio = Audio.source?.audio; + if (audio) + audio.muted = !audio.muted; + } } } } @@ -280,7 +238,7 @@ Item { required property var modelData Layout.fillWidth: true - Layout.preferredHeight: 50 + Appearance.spacing.smaller * 2 + Layout.preferredHeight: 65 + Appearance.spacing.smaller * 2 Layout.topMargin: root.topMargin color: DynamicColors.tPalette.m3surfaceContainer radius: root.rounding @@ -307,43 +265,6 @@ Item { } } - Item { - id: appBoxIcon - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.normal - anchors.top: parent.top - implicitWidth: childrenRect.width - - CustomRect { - anchors.centerIn: parent - color: appBox.modelData.audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: 40 - implicitWidth: 40 - radius: Appearance.rounding.full - - MaterialIcon { - id: icon - - anchors.centerIn: parent - animate: true - color: appBox.modelData.audio.muted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - font.pointSize: 22 - text: appBox.modelData.audio.muted ? "volume_off" : "volume_up" - } - - StateLayer { - color: appBox.modelData.audio.muted ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onPrimary - radius: Appearance.rounding.full - - onClicked: { - appBox.modelData.audio.muted = !appBox.modelData.audio.muted; - } - } - } - } - TextMetrics { id: metrics @@ -353,13 +274,10 @@ Item { } ColumnLayout { - anchors.bottom: parent.bottom - anchors.bottomMargin: Appearance.padding.smallest - anchors.left: appBoxIcon.right - anchors.leftMargin: Appearance.spacing.normal - anchors.right: parent.right - anchors.rightMargin: Appearance.padding.large - anchors.top: parent.top + anchors.bottomMargin: Appearance.padding.smaller + anchors.fill: parent + anchors.leftMargin: Appearance.padding.larger + anchors.rightMargin: Appearance.padding.larger anchors.topMargin: Appearance.padding.smaller RowLayout { @@ -375,25 +293,45 @@ Item { CustomText { Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + color: Qt.alpha(DynamicColors.palette.m3onSurface, 0.7) font.bold: true text: qsTr("%1").arg(appBox.modelData.audio.muted ? qsTr("Muted") : `${Math.round(appBox.modelData.audio.volume * 100)}%`) } } - CustomMouseArea { - Layout.bottomMargin: 5 - Layout.fillHeight: true - Layout.fillWidth: true + RowLayout { + spacing: Appearance.spacing.large - CustomSlider { - anchors.left: parent.left - anchors.right: parent.right - color: appBox.modelData.audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary - implicitHeight: parent.height - value: appBox.modelData.audio.volume + CustomMouseArea { + Layout.bottomMargin: Appearance.padding.normal + Layout.fillWidth: true + Layout.preferredHeight: Appearance.padding.larger * 3 - onMoved: { - Audio.setStreamVolume(appBox.modelData, value); + CustomSlider { + anchors.left: parent.left + anchors.right: parent.right + bgColor: appBox.modelData.audio.muted ? Qt.alpha(DynamicColors.palette.m3errorContainer, 0.5) : DynamicColors.palette.m3secondaryContainer + fgColor: appBox.modelData.audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary + implicitHeight: parent.height + insetColor: appBox.modelData.audio.muted ? (inset.attached ? DynamicColors.palette.m3onErrorContainer : DynamicColors.palette.m3onError) : (inset.attached ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onPrimary) + insetIcon: appBox.modelData.audio.muted || appBox.modelData.audio.volume < 0.001 ? "volume_off" : "volume_up" + value: appBox.modelData.audio.volume + + Behavior on value { + Anim { + } + } + + onInteraction: value => Audio.setStreamVolume(appBox.modelData, value) + } + } + + CustomSwitch { + Layout.bottomMargin: Appearance.padding.normal + checked: !appBox.modelData.audio.muted + + onToggled: { + appBox.modelData.audio.muted = !appBox.modelData.audio.muted; } } } diff --git a/Modules/SysTray/Popouts/TrayMenuPopout.qml b/Modules/SysTray/Popouts/TrayMenuPopout.qml index 908b2f6..c212261 100644 --- a/Modules/SysTray/Popouts/TrayMenuPopout.qml +++ b/Modules/SysTray/Popouts/TrayMenuPopout.qml @@ -13,6 +13,8 @@ StackView { id: root property int biggestWidth: 0 + readonly property int itemHeight: 30 + readonly property int panelRadius: ((itemHeight / 2) + Appearance.padding.small) * Appearance.rounding.scale required property PopoutState popouts property int rootWidth: 0 required property QsMenuHandle trayItem @@ -100,10 +102,13 @@ StackView { asynchronous: true sourceComponent: Item { - implicitHeight: 30 + implicitHeight: root.itemHeight StateLayer { - function onClicked(): void { + enabled: item.modelData.enabled + radius: item.radius + + onClicked: { const entry = item.modelData; if (entry.hasChildren) { root.rootWidth = root.biggestWidth; @@ -117,9 +122,6 @@ StackView { root.popouts.hasCurrent = false; } } - - disabled: !item.modelData.enabled - radius: item.radius } Loader { @@ -217,13 +219,13 @@ StackView { radius: Appearance.rounding.full StateLayer { - function onClicked(): void { + color: DynamicColors.palette.m3onSecondaryContainer + radius: parent.radius + + onClicked: { root.pop(); root.biggestWidth = root.rootWidth; } - - color: DynamicColors.palette.m3onSecondaryContainer - radius: parent.radius } } diff --git a/Modules/SysTray/Popouts/UPowerPopout.qml b/Modules/SysTray/Popouts/UPowerPopout.qml index 775e47f..4ed3c7c 100644 --- a/Modules/SysTray/Popouts/UPowerPopout.qml +++ b/Modules/SysTray/Popouts/UPowerPopout.qml @@ -3,16 +3,97 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell.Services.UPower import qs.Config +import qs.Helpers import qs.Components -import qs.Modules -Item { +Column { id: root - required property var wrapper + readonly property int panelRadius: ((profiles.height / 2) + Appearance.padding.small) * Appearance.rounding.scale - implicitHeight: profiles.implicitHeight - implicitWidth: profiles.implicitWidth + spacing: Appearance.spacing.normal + + Loader { + active: Battery.isLaptop + + CustomText { + text: qsTr("Remaining: %1%").arg(Math.round(UPower.displayDevice.percentage * 100)) + } + } + + CustomText { + function formatSeconds(s: int, fallback: string): string { + const day = Math.floor(s / 86400); + const hr = Math.floor(s / 3600) % 60; + const min = Math.floor(s / 60) % 60; + + let comps = []; + if (day > 0) + comps.push(`${day} days`); + if (hr > 0) + comps.push(`${hr} hours`); + if (min > 0) + comps.push(`${min} mins`); + + return comps.join(", ") || fallback; + } + + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + text: Battery.isLaptop ? qsTr("Time %1: %2").arg(Battery.onBattery ? "remaining" : "until charged").arg(Battery.onBattery ? formatSeconds(Battery.timeToEmpty, "Calculating...") : formatSeconds(Battery.timeToFull, "Fully charged!")) : qsTr("Power profile: %1").arg(PowerProfile.toString(PowerProfiles.profile)) + } + + Loader { + active: PowerProfiles.degradationReason !== PerformanceDegradationReason.None + anchors.horizontalCenter: parent.horizontalCenter + asynchronous: true + height: active ? ((item as Item)?.implicitHeight ?? 0) : 0 + + sourceComponent: CustomRect { + color: DynamicColors.palette.m3error + implicitHeight: child.implicitHeight + Appearance.padding.large + implicitWidth: child.implicitWidth + Appearance.padding.larger * 2 + radius: Appearance.rounding.large + + Column { + id: child + + anchors.centerIn: parent + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Appearance.spacing.small + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + color: DynamicColors.palette.m3onError + text: "warning" + } + + CustomText { + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3onError + font.family: Appearance.font.family.mono + text: qsTr("Performance Degraded") + } + + MaterialIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -font.pointSize / 10 + color: DynamicColors.palette.m3onError + text: "warning" + } + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + color: DynamicColors.palette.m3onError + text: qsTr("Reason: %1").arg(PerformanceDegradationReason.toString(PowerProfiles.degradationReason)) + } + } + } + } CustomRect { id: profiles @@ -28,10 +109,9 @@ Item { anchors.horizontalCenter: parent.horizontalCenter color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Math.max(saver.implicitHeight, balance.implicitHeight, perf.implicitHeight) + 5 * 2 + saverLabel.contentHeight - implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + 8 * 2 + saverLabel.contentWidth - // color: "transparent" - radius: (20 - Appearance.padding.small) * Appearance.rounding.scale + implicitHeight: indicator.height + Appearance.padding.extraSmall * 2 + implicitWidth: saver.implicitHeight + balance.implicitHeight + perf.implicitHeight + Appearance.padding.larger * 2 + Appearance.spacing.small * 8 + radius: Appearance.rounding.full CustomRect { id: indicator @@ -64,10 +144,7 @@ Item { } ] transitions: Transition { - AnchorAnimation { - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - easing.type: Easing.BezierSpline + AnchorAnim { } } } @@ -76,62 +153,28 @@ Item { id: saver anchors.left: parent.left - anchors.leftMargin: 25 - anchors.top: parent.top - anchors.topMargin: 8 - icon: "nest_eco_leaf" + anchors.leftMargin: Appearance.padding.extraSmall + anchors.verticalCenter: parent.verticalCenter + icon: "energy_savings_leaf" profile: PowerProfile.PowerSaver - text: "Power Saver" - } - - CustomText { - id: saverLabel - - anchors.horizontalCenter: saver.horizontalCenter - anchors.top: saver.bottom - font.bold: true - text: saver.text } Profile { id: balance - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 8 - icon: "power_settings_new" + anchors.centerIn: parent + icon: "balance" profile: PowerProfile.Balanced - text: "Balanced" - } - - CustomText { - id: balanceLabel - - anchors.horizontalCenter: balance.horizontalCenter - anchors.top: balance.bottom - font.bold: true - text: balance.text } Profile { id: perf anchors.right: parent.right - anchors.rightMargin: 25 - anchors.top: parent.top - anchors.topMargin: 8 + anchors.rightMargin: Appearance.padding.extraSmall + anchors.verticalCenter: parent.verticalCenter icon: "bolt" profile: PowerProfile.Performance - text: "Performance" - } - - CustomText { - id: perfLabel - - anchors.horizontalCenter: perf.horizontalCenter - anchors.top: perf.bottom - font.bold: true - text: perf.text } } @@ -147,18 +190,17 @@ Item { component Profile: Item { required property string icon required property int profile - required property string text - implicitHeight: icon.implicitHeight + 5 * 2 - implicitWidth: icon.implicitHeight + 5 * 2 + implicitHeight: icon.implicitHeight + Appearance.padding.small + implicitWidth: icon.implicitHeight + Appearance.padding.small StateLayer { - function onClicked(): void { - PowerProfiles.profile = parent.profile; - } - color: profiles.current === parent.icon ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface radius: Appearance.rounding.full + + onClicked: { + PowerProfiles.profile = parent.profile; + } } MaterialIcon { @@ -168,10 +210,12 @@ Item { color: profiles.current === text ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface fill: profiles.current === text ? 1 : 0 font.pointSize: Appearance.font.size.large * 2 + grade: DynamicColors.light ? 0 : (text === "balance" ? -25 : 0) text: parent.icon Behavior on fill { Anim { + type: Anim.DefaultEffects } } } diff --git a/Modules/SysTray/TrayItem.qml b/Modules/SysTray/TrayItem.qml index 5f3b246..fbbd1fc 100644 --- a/Modules/SysTray/TrayItem.qml +++ b/Modules/SysTray/TrayItem.qml @@ -45,14 +45,15 @@ Item { CustomRect { anchors.fill: parent anchors.margins: 3 - color: root.current ? DynamicColors.palette.m3primary : "transparent" + color: Config.general.color.scheduleDark && root.current ? DynamicColors.palette.m3primary : "transparent" radius: Appearance.rounding.full StateLayer { acceptedButtons: Qt.LeftButton | Qt.RightButton anchors.fill: parent + color: Config.general.color.scheduleDark && root.current ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - onClicked: { + onClicked: mouse => { if (mouse.button === Qt.LeftButton) { root.item.activate(); console.log(icon.source + "\n" + root.item.id); diff --git a/Modules/SysTray/TrayWidget.qml b/Modules/SysTray/TrayWidget.qml index 2d3a768..b3864c5 100644 --- a/Modules/SysTray/TrayWidget.qml +++ b/Modules/SysTray/TrayWidget.qml @@ -2,12 +2,10 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import Quickshell import Quickshell.Services.SystemTray import qs.Components import qs.Config import qs.Modules.SysTray.Widgets -import qs.Modules.SysTray.Popouts import qs.Modules RowLayout { @@ -17,11 +15,37 @@ RowLayout { required property RowLayout loader required property Wrapper popouts + function closestRowChild(row, x) { + let child = row.childAt(x, row.height / 2); + if (child) + return child; + + let closest = null; + let closestDistance = Infinity; + + for (let i = 0; i < row.children.length; ++i) { + let c = row.children[i]; + + if (!c.visible || c.width <= 0) + continue; + + let centerX = c.x + c.width / 2; + let dist = Math.abs(x - centerX); + + if (dist < closestDistance) { + closestDistance = dist; + closest = c; + } + } + + return closest; + } + function getHoveredSubItem(localX, localY) { let modPos = mapToItem(sysTrayMod, localX, localY); if (sysTrayMod.contains(Qt.point(modPos.x, modPos.y))) { let modRowPos = sysTrayMod.mapToItem(sysModRow, modPos.x, modPos.y); - let child = sysModRow.childAt(modRowPos.x, modRowPos.y); + let child = closestRowChild(sysModRow, modRowPos.x); if (child) { if (child.objectName === "audioWidget" && Config.barConfig.popouts.audio) return { diff --git a/Modules/SysTray/Widgets/AudioWidget.qml b/Modules/SysTray/Widgets/AudioWidget.qml index c124acd..202812b 100644 --- a/Modules/SysTray/Widgets/AudioWidget.qml +++ b/Modules/SysTray/Widgets/AudioWidget.qml @@ -14,6 +14,8 @@ RowLayout { property color textColor: DynamicColors.palette.m3onSurface MaterialIcon { + id: mic + Layout.alignment: Qt.AlignVCenter animate: true color: (Audio.sourceMuted ?? false) ? DynamicColors.palette.m3error : root.textColor diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index 6bd0587..b3d4f7e 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -102,20 +102,16 @@ Item { active: !Battery.isLaptop anchors.centerIn: parent - sourceComponent: RowLayout { - id: upowerIcon - - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - animate: true - fill: 1 - text: { - if (PowerProfiles.profile === PowerProfile.PowerSaver) - return "nest_eco_leaf"; - if (PowerProfiles.profile === PowerProfile.Performance) - return "bolt"; - return "power_settings_new"; - } + sourceComponent: MaterialIcon { + Layout.alignment: Qt.AlignVCenter + animate: true + fill: 1 + text: { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + if (PowerProfiles.profile === PowerProfile.Performance) + return "bolt"; + return "balance"; } } } diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml index 1c8656a..abe2c35 100644 --- a/Modules/Wallpaper/WallBackground.qml +++ b/Modules/Wallpaper/WallBackground.qml @@ -11,82 +11,98 @@ import ZShell.Internal Item { id: root + property bool completed + property real cropHeight: displayData.height ?? 1.0 + property real cropWidth: displayData.width ?? 1.0 + property real cropX: displayData.x ?? 0.0 + property real cropY: displayData.y ?? 0.0 + property WallpaperImage current + 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 property string source: Wallpapers.current - function refreshData(): void { - Hyprland.refreshMonitors(); - let scale = Hyprland.monitorFor(root.screen).scale; - if (scale <= 0) - scale = 1.0; // Fallback to avoid zeroes on initialization - - if (root.screen.width > 0 && root.screen.height > 0) { - img.screenResolution = Qt.size(root.screen.width * scale, root.screen.height * scale); - } - - const displayData = Wallpapers.getCrop(root.screen.name); - - if (displayData) { - img.cropX = displayData.x !== undefined ? displayData.x : 0.0; - img.cropY = displayData.y !== undefined ? displayData.y : 0.0; - img.cropWidth = (displayData.width !== undefined && displayData.width > 0) ? displayData.width : 1.0; - img.cropHeight = (displayData.height !== undefined && displayData.height > 0) ? displayData.height : 1.0; - } - } - anchors.fill: parent - Component.onCompleted: root.refreshData() + Component.onCompleted: { + Hyprland.refreshMonitors(); - Connections { - function onHeightChanged() { - root.refreshData(); - } - - function onWidthChanged() { - root.refreshData(); - } - - target: root.screen + if (source) + Qt.callLater(() => { + current = imgComp.createObject(this, { + source + }); + completed = true; + }); + } + onSourceChanged: { + if (!source) + current = null; + else + current = imgComp.createObject(this, { + source: source + }); } - WallpaperImage { - id: img + Component { + id: imgComp - anchors.fill: parent - source: root.source + WallpaperImage { + id: img - Behavior on cropHeight { - Anim { - } - } - Behavior on cropWidth { - Anim { - } - } - Behavior on cropX { - Anim { - } - } - Behavior on cropY { - Anim { - } - } - Behavior on zoom { - Anim { - } - } + anchors.fill: parent + cropHeight: root.cropHeight + cropWidth: root.cropWidth + cropX: root.cropX + cropY: root.cropY + opacity: 0 + screenResolution: root.screenResolution + source: root.source - Connections { - function onAdapterUpdated(): void { - root.refreshData(); + Behavior on cropHeight { + Anim { + id: heightAnim + } + } + Behavior on cropWidth { + Anim { + id: widthAnim + } + } + Behavior on cropX { + Anim { + id: xAnim + } + } + Behavior on cropY { + Anim { + id: yAnim + } + } + Anim on opacity { + id: anim + + from: 0 + running: false + to: 1 + type: Anim.SlowEffects } - function onLoaded(): void { - root.refreshData(); + onStatusChanged: { + if (status === Image.Ready) { + anim.start(); + } } - target: Wallpapers.monitorCrops + Timer { + id: destroyTimer + + interval: anim.duration * 2 + running: root.current !== img && root.current?.status === Image.Ready + + onTriggered: Qt.callLater(() => img.destroy()) + } } } } diff --git a/Modules/Wrapper.qml b/Modules/Wrapper.qml index b45c609..776585e 100644 --- a/Modules/Wrapper.qml +++ b/Modules/Wrapper.qml @@ -27,7 +27,6 @@ Item { detachedMode = ""; } - focus: hasCurrent implicitHeight: nonAnimHeight implicitWidth: nonAnimWidth diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index f691bc9..2457613 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,14 +1,25 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus) +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) @@ -45,6 +56,7 @@ qml_module(ZShell requests.hpp requests.cpp toaster.hpp toaster.cpp qalculator.hpp qalculator.cpp + zutils.hpp zutils.cpp LIBRARIES Qt::Gui Qt::Quick diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index f34e7fb..3edbffc 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -2,6 +2,8 @@ qml_module(ZShell-components URI ZShell.Components SOURCES lazylistview.hpp lazylistview.cpp + wavyline.hpp wavyline.cpp + buttonrow.hpp buttonrow.cpp LIBRARIES Qt::Quick ) diff --git a/Plugins/ZShell/Components/buttonrow.cpp b/Plugins/ZShell/Components/buttonrow.cpp new file mode 100644 index 0000000..a56ad33 --- /dev/null +++ b/Plugins/ZShell/Components/buttonrow.cpp @@ -0,0 +1,132 @@ +#include "buttonrow.hpp" + +namespace ZShell::components { + +ButtonRow::ButtonRow(QQuickItem* parent) + : QQuickItem(parent) + , m_dirty(false) + , m_spacing(0.0) { + setFlag(QQuickItem::ItemHasContents, true); + QObject::connect(this, &ButtonRow::widthChanged, this, &ButtonRow::invalidate); +} + +qreal ButtonRow::spacing() const { + return m_spacing; +} + +void ButtonRow::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing + 1.0, spacing + 1.0)) + return; + + m_spacing = spacing; + emit spacingChanged(); + + invalidate(); +} + +void ButtonRow::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData& data) { + if (change == QQuickItem::ItemChildAddedChange) { + auto* const child = data.item; + QObject::connect(child, &QQuickItem::implicitWidthChanged, this, &ButtonRow::invalidate); + QObject::connect(child, &QQuickItem::implicitHeightChanged, this, &ButtonRow::invalidate); + QObject::connect(child, &QQuickItem::visibleChanged, this, &ButtonRow::invalidate); + + const auto* childMeta = child->metaObject(); + const auto morphSignalIdx = childMeta->indexOfSignal("shapeMorphExpansionChanged()"); + if (morphSignalIdx != -1) + QObject::connect(child, childMeta->method(morphSignalIdx), this, + metaObject()->method(metaObject()->indexOfSlot("invalidate()"))); + + invalidate(); + } else if (change == QQuickItem::ItemChildRemovedChange) { + QObject::disconnect(data.item, nullptr, this, nullptr); + invalidate(); + } + + QQuickItem::itemChange(change, data); +} + +void ButtonRow::updatePolish() { + if (m_dirty) { + relayout(); + m_dirty = false; + } +} + +void ButtonRow::invalidate() { + m_dirty = true; + polish(); +} + +void ButtonRow::relayout() { + const auto allChildren = childItems(); + + QList validChildren; + for (auto* const child : allChildren) { + if (child->isVisible() && !child->inherits("QQuickRepeater")) + validChildren.append(child); + } + const auto nChildren = validChildren.size(); + const auto totalSpacing = static_cast(nChildren - 1) * m_spacing; + + qreal reservedWidth = 0; + qreal unreservedWidth = 0; + int fillWidthCount = 0; + qreal maxHeight = 0; + for (auto* const child : validChildren) { + maxHeight = qMax(maxHeight, child->implicitHeight()); + + const auto prop = child->property("fillWidth"); + if (!prop.isValid()) + continue; + + if (prop.toBool()) { + fillWidthCount++; + unreservedWidth += child->implicitWidth(); + } else { + reservedWidth += child->implicitWidth(); + } + } + + if (fillWidthCount == 0) + fillWidthCount = 1; // Avoid divide by 0 + + const auto widthPerItem = (width() - totalSpacing - reservedWidth) / static_cast(fillWidthCount); + + QList baseWidths; + baseWidths.reserve(nChildren); + for (auto* const child : validChildren) + baseWidths.append(child->property("fillWidth").toBool() ? widthPerItem : child->implicitWidth()); + + qreal accX = 0; + for (int i = 0; i < nChildren; ++i) { + auto* const child = validChildren[i]; + + // clang-format off + auto prevExtraWidth = i > 0 ? getMorphExpansion(validChildren[i - 1]) : 0.0; + auto nextExtraWidth = i < nChildren - 1 ? getMorphExpansion(validChildren[i + 1]) : 0.0; + // clang-format on + + // Items at edges push by full amount, items in middle push by half + if (i > 1) + prevExtraWidth /= 2; + if (i < nChildren - 2) + nextExtraWidth /= 2; + + child->setWidth(baseWidths[i] + getMorphExpansion(child) - prevExtraWidth - nextExtraWidth); + child->setHeight(maxHeight); + + child->setX(accX); + child->setY(0); + accX += child->width() + m_spacing; + } + + setImplicitWidth(reservedWidth + unreservedWidth + totalSpacing); + setImplicitHeight(maxHeight); +} + +qreal ButtonRow::getMorphExpansion(const QQuickItem* item) { + return item->property("shapeMorphExpansion").toReal(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/buttonrow.hpp b/Plugins/ZShell/Components/buttonrow.hpp new file mode 100644 index 0000000..4c5f13d --- /dev/null +++ b/Plugins/ZShell/Components/buttonrow.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include + +namespace ZShell::components { + +class ButtonRow : public QQuickItem { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + +public: +explicit ButtonRow(QQuickItem* parent = nullptr); + +[[nodiscard]] qreal spacing() const; +void setSpacing(qreal spacing); + +signals: +void spacingChanged(); + +protected: +void itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData& data) override; +void updatePolish() override; + +private slots: +void invalidate(); + +private: +void relayout(); +static qreal getMorphExpansion(const QQuickItem* item); + +bool m_dirty; +qreal m_spacing; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/wavyline.cpp b/Plugins/ZShell/Components/wavyline.cpp new file mode 100644 index 0000000..757b7a2 --- /dev/null +++ b/Plugins/ZShell/Components/wavyline.cpp @@ -0,0 +1,255 @@ +#include "wavyline.hpp" + +#include +#include + +namespace ZShell::controls { + +WavyLine::WavyLine(QQuickItem* parent) + : QQuickPaintedItem(parent) + , m_lineWidth(4) + , m_amplitudeMultiplier(0.5) + , m_frequency(6) + , m_startX(0) + , m_fullLength(0) + , m_color(Qt::white) + , m_waveProgress(0) + , m_pathType(Linear) + , m_startAngle(0) + , m_fullAngle(360) + , m_radius(-1) + , m_value(1) + , m_startAngleRad(0) + , m_fullAngleRad(2 * M_PI) { + setAntialiasing(true); +} + +int WavyLine::lineWidth() const { + return m_lineWidth; +} + +void WavyLine::setLineWidth(int lineWidth) { + if (m_lineWidth != lineWidth) { + m_lineWidth = lineWidth; + emit lineWidthChanged(); + update(); + } +} + +qreal WavyLine::amplitudeMultiplier() const { + return m_amplitudeMultiplier; +} + +void WavyLine::setAmplitudeMultiplier(qreal amplitudeMultiplier) { + if (!qFuzzyCompare(m_amplitudeMultiplier + 1.0, amplitudeMultiplier + 1.0)) { + m_amplitudeMultiplier = amplitudeMultiplier; + emit amplitudeMultiplierChanged(); + update(); + } +} + +int WavyLine::frequency() const { + return m_frequency; +} + +void WavyLine::setFrequency(int frequency) { + if (m_frequency != frequency) { + m_frequency = frequency; + emit frequencyChanged(); + update(); + } +} + +qreal WavyLine::startX() const { + return m_startX; +} + +void WavyLine::setStartX(qreal startX) { + if (!qFuzzyCompare(m_startX + 1.0, startX + 1.0)) { + m_startX = startX; + emit startXChanged(); + update(); + } +} + +qreal WavyLine::fullLength() const { + return m_fullLength; +} + +void WavyLine::setFullLength(qreal fullLength) { + if (!qFuzzyCompare(m_fullLength + 1.0, fullLength + 1.0)) { + m_fullLength = fullLength; + emit fullLengthChanged(); + update(); + } +} + +QColor WavyLine::color() const { + return m_color; +} + +void WavyLine::setColor(const QColor& color) { + if (m_color != color) { + m_color = color; + emit colorChanged(); + update(); + } +} + +qreal WavyLine::waveProgress() const { + return m_waveProgress; +} + +void WavyLine::setWaveProgress(qreal progress) { + if (!qFuzzyCompare(m_waveProgress + 1.0, progress + 1.0)) { + m_waveProgress = progress; + emit waveProgressChanged(); + update(); + } +} + +WavyLine::PathType WavyLine::pathType() const { + return m_pathType; +} + +void WavyLine::setPathType(PathType pathType) { + if (m_pathType != pathType) { + m_pathType = pathType; + emit pathTypeChanged(); + update(); + } +} + +qreal WavyLine::startAngle() const { + return m_startAngle; +} + +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(); + } +} + +qreal WavyLine::fullAngle() const { + return m_fullAngle; +} + +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(); + } +} + +qreal WavyLine::radius() const { + return m_radius; +} + +void WavyLine::setRadius(qreal radius) { + if (!qFuzzyCompare(m_radius + 1.0, radius + 1.0)) { + m_radius = radius; + emit radiusChanged(); + update(); + } +} + +qreal WavyLine::value() const { + return m_value; +} + +void WavyLine::setValue(qreal value) { + if (!qFuzzyCompare(m_value + 1.0, value + 1.0)) { + m_value = value; + emit valueChanged(); + update(); + } +} + +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 new file mode 100644 index 0000000..e9fab48 --- /dev/null +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +namespace ZShell::controls { + +class WavyLine : public QQuickPaintedItem { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(int lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged FINAL) +Q_PROPERTY(qreal amplitudeMultiplier READ amplitudeMultiplier WRITE setAmplitudeMultiplier NOTIFY + amplitudeMultiplierChanged FINAL) +Q_PROPERTY(int frequency READ frequency WRITE setFrequency NOTIFY frequencyChanged FINAL) +Q_PROPERTY(qreal startX READ startX WRITE setStartX NOTIFY startXChanged FINAL) +Q_PROPERTY(qreal fullLength READ fullLength WRITE setFullLength NOTIFY fullLengthChanged FINAL) +Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged FINAL) +Q_PROPERTY(qreal waveProgress READ waveProgress WRITE setWaveProgress NOTIFY waveProgressChanged FINAL) +Q_PROPERTY(PathType pathType READ pathType WRITE setPathType NOTIFY pathTypeChanged FINAL) +Q_PROPERTY(qreal startAngle READ startAngle WRITE setStartAngle NOTIFY startAngleChanged FINAL) +Q_PROPERTY(qreal fullAngle READ fullAngle WRITE setFullAngle NOTIFY fullAngleChanged FINAL) +Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged FINAL) +Q_PROPERTY(qreal value READ value WRITE setValue NOTIFY valueChanged FINAL) + +public: +enum PathType { + Linear, + Arc +}; +Q_ENUM(PathType) + +explicit WavyLine(QQuickItem* parent = nullptr); + +[[nodiscard]] int lineWidth() const; +void setLineWidth(int lineWidth); + +[[nodiscard]] qreal amplitudeMultiplier() const; +void setAmplitudeMultiplier(qreal amplitudeMultiplier); + +[[nodiscard]] int frequency() const; +void setFrequency(int frequency); + +[[nodiscard]] qreal startX() const; +void setStartX(qreal startX); + +[[nodiscard]] qreal fullLength() const; +void setFullLength(qreal fullLength); + +[[nodiscard]] QColor color() const; +void setColor(const QColor& color); + +[[nodiscard]] qreal waveProgress() const; +void setWaveProgress(qreal progress); + +[[nodiscard]] PathType pathType() const; +void setPathType(PathType pathType); + +[[nodiscard]] qreal startAngle() const; +void setStartAngle(qreal startAngle); + +[[nodiscard]] qreal fullAngle() const; +void setFullAngle(qreal fullAngle); + +[[nodiscard]] qreal radius() const; +void setRadius(qreal radius); + +[[nodiscard]] qreal value() const; +void setValue(qreal value); + +void paint(QPainter* painter) override; + +signals: +void lineWidthChanged(); +void amplitudeMultiplierChanged(); +void frequencyChanged(); +void startXChanged(); +void fullLengthChanged(); +void colorChanged(); +void waveProgressChanged(); +void pathTypeChanged(); +void startAngleChanged(); +void fullAngleChanged(); +void radiusChanged(); +void valueChanged(); + +private: +void paintLinear(QPainter* painter); +void paintArc(QPainter* painter); + +int m_lineWidth; +qreal m_amplitudeMultiplier; +int m_frequency; +qreal m_startX; +qreal m_fullLength; +QColor m_color; +qreal m_waveProgress; +PathType m_pathType; +qreal m_startAngle; +qreal m_fullAngle; +qreal m_radius; +qreal m_value; +qreal m_startAngleRad; +qreal m_fullAngleRad; +}; + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 93f1ca9..0726068 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,20 +1,25 @@ qml_module(ZShell-internal - URI ZShell.Internal - SOURCES - hyprextras.hpp hyprextras.cpp - hyprdevices.hpp hyprdevices.cpp - cachingimagemanager.hpp cachingimagemanager.cpp + URI ZShell.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 lidwatcher.hpp lidwatcher.cpp - LIBRARIES - Qt::Gui - Qt::Quick - Qt::Concurrent - Qt::Core + visualizerbars.hpp visualizerbars.cpp + linearindicatormanager.hpp linearindicatormanager.cpp + strokecanvasitem.hpp strokecanvasitem.cpp + strokecanvasrenderer.hpp strokecanvasrenderer.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Core Qt::Network Qt::DBus + Qt::CanvasPainter ) diff --git a/Plugins/ZShell/Internal/linearindicatormanager.cpp b/Plugins/ZShell/Internal/linearindicatormanager.cpp new file mode 100644 index 0000000..655ed80 --- /dev/null +++ b/Plugins/ZShell/Internal/linearindicatormanager.cpp @@ -0,0 +1,118 @@ +#include "linearindicatormanager.hpp" + +#include + +namespace { + +constexpr int TOTAL_DURATION_IN_MS = 1800; +constexpr std::array DURATION_TO_MOVE_SEGMENT_ENDS = { 533, 567, 850, 750 }; +constexpr std::array DELAY_TO_MOVE_SEGMENT_ENDS = { 1267, 1000, 333, 0 }; + +QEasingCurve curve(const QPointF& c1, const QPointF& c2) { + QEasingCurve curve(QEasingCurve::BezierSpline); + curve.addCubicBezierSegment(c1, c2, { 1.0, 1.0 }); + return curve; +} + +qreal getFractionInRange(qreal playtime, int start, int duration) { + const auto fraction = static_cast(playtime - start) / duration; + return std::clamp(fraction, 0.0, 1.0); +} + +} // namespace + +namespace ZShell::controls { + +LinearIndicatorSegment::LinearIndicatorSegment(int gap, QObject* parent) + : QObject(parent) + , m_startFraction(0) + , m_endFraction(0) + , m_gapSize(gap) { +} + +qreal LinearIndicatorSegment::startFraction() const { + return m_startFraction; +} + +qreal LinearIndicatorSegment::endFraction() const { + return m_endFraction; +} + +int LinearIndicatorSegment::gapSize() const { + return m_gapSize; +} + +LinearIndicatorManager::LinearIndicatorManager(QObject* parent) + : QObject(parent) + , m_interpolators({ + curve({ 0.2, 0.0 }, { 0.8, 1.0 }), + curve({ 0.4, 0.0 }, { 1.0, 1.0 }), + curve({ 0.0, 0.0 }, { 0.65, 1.0 }), + curve({ 0.1, 0.0 }, { 0.45, 1.0 }), + }) + , m_progress(0) + , m_completeEndProgress(0) + , m_gap(4) + , m_activeIndicators({ + new LinearIndicatorSegment(m_gap, this), + new LinearIndicatorSegment(m_gap, this), + }) { + for (auto el : m_activeIndicators) + QObject::connect(this, &LinearIndicatorManager::updated, el, &LinearIndicatorSegment::updated); +} + +QList LinearIndicatorManager::activeIndicators() const { + return { m_activeIndicators.cbegin(), m_activeIndicators.cend() }; +} + +qreal LinearIndicatorManager::progress() const { + return m_progress; +} + +qreal LinearIndicatorManager::completeEndProgress() const { + return m_completeEndProgress; +} + +int LinearIndicatorManager::gap() const { + return m_gap; +} + +void LinearIndicatorManager::setGap(int gap) { + m_gap = gap; + for (auto el : m_activeIndicators) + el->m_gapSize = m_gap; + update(m_progress); +} + +int LinearIndicatorManager::duration() const { + return TOTAL_DURATION_IN_MS; +} + +int LinearIndicatorManager::completeEndDuration() const { + return TOTAL_DURATION_IN_MS; +} + +void LinearIndicatorManager::update(qreal progress) { + const auto playtime = progress * TOTAL_DURATION_IN_MS; + for (size_t i = 0; i < SEGMENTS; i++) { + const auto di = i * 2; + auto* const indicator = m_activeIndicators[i]; + + auto fraction = getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di], DURATION_TO_MOVE_SEGMENT_ENDS[di]); + indicator->m_startFraction = std::clamp(m_interpolators[di].valueForProgress(fraction), 0.0, 1.0); + + fraction = + getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di + 1], DURATION_TO_MOVE_SEGMENT_ENDS[di + 1]); + indicator->m_endFraction = std::clamp(m_interpolators[di + 1].valueForProgress(fraction), 0.0, 1.0); + } + + m_progress = progress; + emit updated(); +} + +void LinearIndicatorManager::updateCompleteEndProgress(qreal progress) { + m_completeEndProgress = progress; + update(m_progress); +} + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/linearindicatormanager.hpp b/Plugins/ZShell/Internal/linearindicatormanager.hpp new file mode 100644 index 0000000..dd2f48c --- /dev/null +++ b/Plugins/ZShell/Internal/linearindicatormanager.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::controls { + +class LinearIndicatorManager; + +class LinearIndicatorSegment : public QObject { +Q_OBJECT +QML_ELEMENT + QML_UNCREATABLE("LinearIndicatorSegments can only be retrieved from a " + "LinearIndicatorManager.") + +Q_PROPERTY(qreal startFraction READ startFraction NOTIFY updated FINAL) +Q_PROPERTY(qreal endFraction READ endFraction NOTIFY updated FINAL) +Q_PROPERTY(int gapSize READ gapSize NOTIFY updated FINAL) + +public: +explicit LinearIndicatorSegment(int gap, QObject* parent = nullptr); + +qreal startFraction() const; +qreal endFraction() const; +int gapSize() const; + +signals: +void updated(); + +private: +qreal m_startFraction; +qreal m_endFraction; +int m_gapSize; + +friend LinearIndicatorManager; +}; + +class LinearIndicatorManager : public QObject { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY( + QList activeIndicators READ activeIndicators CONSTANT FINAL) + +Q_PROPERTY(qreal progress READ progress WRITE update NOTIFY updated FINAL) +Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE updateCompleteEndProgress NOTIFY updated FINAL) +Q_PROPERTY(int gap READ gap WRITE setGap NOTIFY updated FINAL) + +Q_PROPERTY(qreal duration READ duration CONSTANT FINAL) +Q_PROPERTY(qreal completeEndDuration READ completeEndDuration CONSTANT FINAL) + +public: +explicit LinearIndicatorManager(QObject* parent = nullptr); + +QList activeIndicators() const; + +qreal progress() const; +qreal completeEndProgress() const; + +int gap() const; +void setGap(int gap); + +int duration() const; +int completeEndDuration() const; + +void update(qreal progress); +void updateCompleteEndProgress(qreal progress); + +signals: +void updated(); + +private: +static constexpr int SEGMENTS = 2; + +std::array m_interpolators; +qreal m_progress; +qreal m_completeEndProgress; +int m_gap; + +std::array m_activeIndicators; +}; + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp index d61592f..830926f 100644 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ b/Plugins/ZShell/Internal/sparklineitem.cpp @@ -1,5 +1,6 @@ #include "sparklineitem.hpp" +#include #include #include #include @@ -33,19 +34,45 @@ void SparklineItem::drawLine(QPainter* painter, CircularBuffer* buffer, const QC 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; - // Build line path - QPainterPath linePath; - linePath.moveTo(startX, h - (buffer->at(0) / m_maxValue) * h); - for (int i = 1; i < len; ++i) { + 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 y = h - (buffer->at(i) / m_maxValue) * h; - linePath.lineTo(x, y); + 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); } - // Stroke the line QPen pen(color, m_lineWidth); pen.setCapStyle(Qt::RoundCap); pen.setJoinStyle(Qt::RoundJoin); @@ -53,10 +80,9 @@ void SparklineItem::drawLine(QPainter* painter, CircularBuffer* buffer, const QC painter->setBrush(Qt::NoBrush); painter->drawPath(linePath); - // Fill under the line QPainterPath fillPath = linePath; - fillPath.lineTo(startX + (len - 1) * stepX, h); - fillPath.lineTo(startX, h); + fillPath.lineTo(startX + (len - 1) * stepX, fillBottom); + fillPath.lineTo(startX, fillBottom); fillPath.closeSubpath(); QColor fillColor = color; diff --git a/Plugins/ZShell/Internal/stroke.hpp b/Plugins/ZShell/Internal/stroke.hpp new file mode 100644 index 0000000..8f9bd1d --- /dev/null +++ b/Plugins/ZShell/Internal/stroke.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::internal { + +struct Stroke { + QVector points; + QCanvasPath path; + QColor color; + float width; + int groupId = -1; + bool isSinglePoint = false; +}; + +}; diff --git a/Plugins/ZShell/Internal/strokecanvasitem.cpp b/Plugins/ZShell/Internal/strokecanvasitem.cpp new file mode 100644 index 0000000..41295c8 --- /dev/null +++ b/Plugins/ZShell/Internal/strokecanvasitem.cpp @@ -0,0 +1,193 @@ +#include "strokecanvasitem.hpp" +#include "strokecanvasrenderer.hpp" +#include +#include +#include + +namespace ZShell::internal { + +StrokeCanvasItem::StrokeCanvasItem(QQuickItem *parent) : QCanvasPainterItem(parent) { + setFillColor(Qt::transparent); + setAlphaBlending(true); +} + +QCanvasPainterItemRenderer *StrokeCanvasItem::createItemRenderer() const { + return new StrokeCanvasRenderer; +} + +static bool shouldAddPoint( + const QVector &points, + const QPointF &p, + qreal minDistance) +{ + if (points.isEmpty()) + return true; + + const QPointF delta = p - points.last(); + + return QPointF::dotProduct(delta, delta) + >= minDistance * minDistance; +} + +static QCanvasPath buildStrokePath(const QVector &points, float width) { + QCanvasPath path; + + if (points.size() == 1) { + path.circle(points[0], width * 0.5f); + return path; + } + + auto catmullToBezier = []( + const QPointF &p0, const QPointF &p1, + const QPointF &p2, const QPointF &p3, + float tension, + QPointF &cp1, QPointF &cp2) + { + cp1 = p1 + (p2 - p0) * tension / 3.0f; + cp2 = p2 - (p3 - p1) * tension / 3.0f; + }; + + const float tension = 0.5f; + path.moveTo(points[0]); + + for (int i = 0; i < points.size() - 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, points.size() - 1)]; + + QPointF cp1, cp2; + catmullToBezier(p0, p1, p2, p3, tension, cp1, cp2); + path.bezierCurveTo(cp1, cp2, p2); + } + + return path; +} + +void StrokeCanvasItem::setPenColor(const QColor &color) { + if (m_penColor == color) + return; + + m_penColor = color; + update(); + + emit penColorChanged(); +} + +void StrokeCanvasItem::setHoverVisible(bool visible) { + if (m_hoverVisible == visible) + return; + + m_hoverVisible = visible; + update(); + + emit hoverVisibleChanged(); +} + +void StrokeCanvasItem::setHoverPoint(const QPointF &point) { + if (m_hoverPoint == point) + return; + + m_hoverPoint = point; + update(); + + emit hoverPointChanged(); +} + +void StrokeCanvasItem::showHover(qreal x, qreal y) { + const QPointF newPoint{x, y}; + const bool pointChanged = (m_hoverPoint != newPoint); + const bool visibleChanged = !m_hoverVisible; + + + if (pointChanged || visibleChanged) { + m_hoverVisible = true; + m_hoverPoint = newPoint; + if (pointChanged) emit hoverPointChanged(); + if (visibleChanged) emit hoverVisibleChanged(); + update(); + } +} + +void StrokeCanvasItem::hideHover() { + if (!m_hoverVisible) + return; + + m_hoverVisible = false; + update(); + + emit hoverVisibleChanged(); +} + +void StrokeCanvasItem::setPenWidth(float width) { + if (qFuzzyCompare(m_penWidth, width)) + return; + + m_penWidth = width; + update(); + + emit penWidthChanged(); +} + +void StrokeCanvasItem::beginStroke(qreal x, qreal y) { + m_isDrawing = true; + m_currentStroke.points.clear(); + m_currentStroke.points.append({x, y}); + + m_currentStroke.color = m_penColor; + m_currentStroke.width = m_penWidth; + + update(); +} + +void StrokeCanvasItem::appendPoint(qreal x, qreal y) { + const QPointF incoming{x, y}; + + if (!shouldAddPoint(m_currentStroke.points, incoming, 2.0)) + return; + + QPointF smoothed; + if (m_currentStroke.points.isEmpty()) { + smoothed = incoming; + } else { + const QPointF last = m_currentStroke.points.last(); + const QPointF delta = incoming - last; + const qreal dist = std::sqrt(QPointF::dotProduct(delta, delta)); + + constexpr qreal minDist = 6.0; + constexpr qreal maxDist = 20.0; + constexpr qreal minAlpha = 0.1; + constexpr qreal maxAlpha = 0.3; + + const qreal t = std::clamp((dist - minDist) / (maxDist - minDist), 0.0, 1.0); + const qreal alpha = minAlpha + t * (maxAlpha - minAlpha); + + smoothed = last * (1.0 - alpha) + incoming * alpha; + } + + m_currentStroke.points.append(smoothed); + update(); +} + +void StrokeCanvasItem::endStroke() { + m_isDrawing = false; + if (m_currentStroke.points.isEmpty()) + return; + + m_currentStroke.isSinglePoint = (m_currentStroke.points.size() == 1); + m_currentStroke.path = buildStrokePath(m_currentStroke.points, m_currentStroke.width); + m_currentStroke.groupId = m_nextGroupId++; + m_currentStroke.points.clear(); + m_strokes.append(m_currentStroke); + m_currentStroke = {}; + + update(); +} + +void StrokeCanvasItem::clear() { + m_strokes.clear(); + m_currentStroke = {}; + update(); +} + +}; diff --git a/Plugins/ZShell/Internal/strokecanvasitem.hpp b/Plugins/ZShell/Internal/strokecanvasitem.hpp new file mode 100644 index 0000000..cf40da7 --- /dev/null +++ b/Plugins/ZShell/Internal/strokecanvasitem.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "stroke.hpp" + +namespace ZShell::internal { + +class StrokeCanvasRenderer; + +class StrokeCanvasItem : public QCanvasPainterItem { +Q_OBJECT + +QML_NAMED_ELEMENT(StrokeCanvas) + +Q_PROPERTY(QColor penColor READ penColor WRITE setPenColor NOTIFY penColorChanged) +Q_PROPERTY(bool hoverVisible READ hoverVisible WRITE setHoverVisible NOTIFY hoverVisibleChanged) +Q_PROPERTY(QPointF hoverPoint READ hoverPoint WRITE setHoverPoint NOTIFY hoverPointChanged) +Q_PROPERTY(qreal penWidth READ penWidth WRITE setPenWidth NOTIFY penWidthChanged) + +public: +explicit StrokeCanvasItem(QQuickItem *parent = nullptr); + +[[nodiscard]] bool hoverVisible() const { + return m_hoverVisible; +} +[[nodiscard]] QPointF hoverPoint() const { + return m_hoverPoint; +} + +void setHoverVisible(bool visible); +void setHoverPoint(const QPointF &point); + +Q_INVOKABLE void showHover(qreal x, qreal y); +Q_INVOKABLE void hideHover(); + +[[nodiscard]] QColor penColor() const { + return m_penColor; +} +[[nodiscard]] float penWidth() const { + return m_penWidth; +} + +void setPenColor(const QColor &color); +void setPenWidth(float width); + +Q_INVOKABLE void clear(); + +Q_INVOKABLE void beginStroke(qreal x, qreal y); +Q_INVOKABLE void appendPoint(qreal x, qreal y); +Q_INVOKABLE void endStroke(); + +signals: +void penColorChanged(); +void penWidthChanged(); +void hoverVisibleChanged(); +void hoverPointChanged(); + +protected: +[[nodiscard]] QCanvasPainterItemRenderer *createItemRenderer() const override; + +private: +friend class StrokeCanvasRenderer; + +bool m_hoverVisible = false; +QPointF m_hoverPoint; +QColor m_penColor = Qt::white; +float m_penWidth = 4.f; +bool m_isDrawing = false; + +int m_nextGroupId = 0; +QVector m_strokes; +Stroke m_currentStroke; +}; + +}; diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp new file mode 100644 index 0000000..a648f9a --- /dev/null +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -0,0 +1,183 @@ +#include "strokecanvasrenderer.hpp" +#include "strokecanvasitem.hpp" +#include + +namespace ZShell::internal { + +static void drawStroke( + QCanvasPainter *painter, + const QVector &points, + const QColor &color, + float width) { + + if (points.isEmpty()) + return; + + painter->setStrokeStyle(color); + painter->setFillStyle(color); + painter->setLineWidth(width); + painter->setLineCap(QCanvasPainter::LineCap::Round); + painter->setLineJoin(QCanvasPainter::LineJoin::Round); + + if (points.size() == 1) { + painter->beginPath(); + painter->circle(points.front(), width * 0.5f); + painter->fill(); + return; + } + + auto catmullToBezier = []( + const QPointF &p0, const QPointF &p1, + const QPointF &p2, const QPointF &p3, + float tension, + QPointF &cp1, QPointF &cp2) + { + cp1 = p1 + (p2 - p0) * tension / 3.0f; + cp2 = p2 - (p3 - p1) * tension / 3.0f; + }; + + const float tension = 0.5f; // increase toward 1.0 for tighter curves + + painter->beginPath(); + painter->moveTo(points[0]); + + for (int i = 0; i < points.size() - 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, points.size() - 1)]; + + QPointF cp1, cp2; + catmullToBezier(p0, p1, p2, p3, tension, cp1, cp2); + painter->bezierCurveTo(cp1, cp2, p2); + } + + painter->stroke(); +} + +static void drawHoverCursor( + QCanvasPainter *painter, + const QPointF &point, + float penWidth, + QColor penColor, + bool isDrawing) +{ + const float radius = penWidth * 0.5f; + + if (isDrawing) { + painter->setFillStyle(penColor); + painter->beginPath(); + painter->circle(point, radius); + painter->fill(); + } + + const float lineWidth = 1.5f; + const float crosshairSize = 6.0f; + const bool useDashes = penWidth > 10.0f; + + auto drawOutline = [&](const QColor &color, float width) { + painter->setStrokeStyle(color); + painter->setLineWidth(width); + painter->setLineCap(QCanvasPainter::LineCap::Round); + + + if (useDashes) { + const int dashCount = 12; + const float fullAngle = 2.0f * M_PI; + const float dashAngle = fullAngle / dashCount * 0.5f; + const float gapAngle = fullAngle / dashCount * 0.5f; + + float angle = 0.0f; + for (int i = 0; i < dashCount; ++i) { + painter->beginPath(); + painter->arc(point, radius, angle, angle + dashAngle, + QCanvasPainter::PathWinding::ClockWise, + QCanvasPainter::PathConnection::NotConnected); + painter->stroke(); + angle += dashAngle + gapAngle; + } + } else { + painter->beginPath(); + painter->circle(point, radius); + painter->stroke(); + } + }; + + auto drawCrosshair = [&](const QColor &color, float width) { + painter->setStrokeStyle(color); + painter->setLineWidth(width); + painter->setLineCap(QCanvasPainter::LineCap::Round); + + const float inner = radius + 3.0f; + const float outer = radius + 3.0f + crosshairSize; + + painter->beginPath(); + painter->moveTo(point + QPointF(0, -outer)); + painter->lineTo(point + QPointF(0, -inner)); + painter->moveTo(point + QPointF(0, outer)); + painter->lineTo(point + QPointF(0, inner)); + painter->moveTo(point + QPointF(-outer, 0)); + painter->lineTo(point + QPointF(-inner, 0)); + painter->moveTo(point + QPointF( outer, 0)); + painter->lineTo(point + QPointF( inner, 0)); + painter->stroke(); + }; + + drawOutline(QColor(0, 0, 0, 160), lineWidth + 1.0f); + drawCrosshair(QColor(0, 0, 0, 160), lineWidth + 1.0f); + + drawOutline(QColor(255, 255, 255, 220), lineWidth); + drawCrosshair(QColor(255, 255, 255, 220), lineWidth); +} + +void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem *item) { + auto *canvas = static_cast(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()]); + + 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(); + } + + m_currentStroke = canvas->m_currentStroke; + + m_hoverVisible = canvas->m_hoverVisible; + m_hoverPoint = canvas->m_hoverPoint; + m_isDrawing = canvas->m_isDrawing; +} + +void StrokeCanvasRenderer::paint(QCanvasPainter *painter) { + for (int id : m_pendingGroupRemovals) + painter->removePathGroup(id); + m_pendingGroupRemovals.clear(); + + painter->clearRect(0, 0, width(), height()); + + for (const auto &stroke : m_strokes) { + painter->setStrokeStyle(stroke.color); + painter->setFillStyle(stroke.color); + painter->setLineWidth(stroke.width); + painter->setLineCap(QCanvasPainter::LineCap::Round); + painter->setLineJoin(QCanvasPainter::LineJoin::Round); + + if (stroke.isSinglePoint) { + painter->fill(stroke.path, stroke.groupId); + } else { + painter->stroke(stroke.path, stroke.groupId); + } + } + + drawStroke(painter, m_currentStroke.points, m_currentStroke.color, m_currentStroke.width); + + if (m_hoverVisible) + drawHoverCursor(painter, m_hoverPoint, m_penWidth, m_penColor, m_isDrawing); +} + +}; diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.hpp b/Plugins/ZShell/Internal/strokecanvasrenderer.hpp new file mode 100644 index 0000000..74ff1f6 --- /dev/null +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include "stroke.hpp" + +namespace ZShell::internal { + +class StrokeCanvasRenderer final : public QCanvasPainterItemRenderer { + +public: +void synchronizeData(QCanvasPainterItem *item) override; +void paint(QCanvasPainter *painter) override; + +private: +QColor m_penColor; +float m_penWidth = 4.f; +bool m_hoverVisible = false; +QPointF m_hoverPoint; +bool m_isDrawing = false; + +QVector m_strokes; +Stroke m_currentStroke; +QVector m_pendingGroupRemovals; + +}; + +}; diff --git a/Plugins/ZShell/Internal/visualizerbars.cpp b/Plugins/ZShell/Internal/visualizerbars.cpp new file mode 100644 index 0000000..fb77d70 --- /dev/null +++ b/Plugins/ZShell/Internal/visualizerbars.cpp @@ -0,0 +1,198 @@ +#include "visualizerbars.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::internal { + +VisualizerBars::VisualizerBars(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +void VisualizerBars::advance(qreal dt) { + if (m_displayValues.isEmpty() || m_settled) + return; + + // dt is in seconds (from FrameAnimation.frameTime), convert to ms + const qreal dtMs = dt * 1000.0; + const qreal tau = m_animationDuration / 3.0; + const qreal alpha = 1.0 - std::exp(-dtMs / tau); + + bool allSettled = true; + + for (qsizetype i = 0; i < m_displayValues.size(); ++i) { + const double diff = m_targetValues[i] - m_displayValues[i]; + + if (std::abs(diff) > 0.001) { + m_displayValues[i] += diff * alpha; + allSettled = false; + } else { + m_displayValues[i] = m_targetValues[i]; + } + } + + update(); + + if (allSettled && !m_settled) { + m_settled = true; + emit settledChanged(); + } +} + +void VisualizerBars::paint(QPainter* painter) { + if (m_displayValues.isEmpty()) + return; + + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + + const qreal h = height(); + const qreal maxBarHeight = h * 0.4; + + QLinearGradient gradient(0, h - maxBarHeight, 0, h); + gradient.setColorAt(0, m_primaryColor); + gradient.setColorAt(1, m_secondaryColor); + painter->setBrush(gradient); + + drawSide(painter, false); + drawSide(painter, true); +} + +void VisualizerBars::drawSide(QPainter* painter, bool rightSide) { + const qreal w = width(); + const qreal h = height(); + const auto count = m_displayValues.size(); + + if (count == 0) + return; + + const qreal sideWidth = w * 0.4; + const qreal slotWidth = sideWidth / static_cast(count); + const qreal barWidth = slotWidth - m_spacing; + + if (barWidth <= 0) + return; + + const qreal sideOffset = rightSide ? w * 0.6 : 0; + const qreal maxBarHeight = h * 0.4; + + for (qsizetype i = 0; i < count; ++i) { + const qsizetype valueIndex = rightSide ? i : (count - i - 1); + const qreal value = std::clamp(m_displayValues[valueIndex], 0.0, 1.0); + const qreal barHeight = value * maxBarHeight; + + if (barHeight <= 0) + continue; + + const qreal x = static_cast(i) * slotWidth + sideOffset; + const qreal y = h - barHeight; + const qreal r = std::min({ m_rounding, barWidth / 2.0, barHeight }); + + QPainterPath path; + path.moveTo(x, h); + path.lineTo(x, y + r); + + if (r > 0) { + path.arcTo(x, y, r * 2, r * 2, 180, -90); + path.lineTo(x + barWidth - r, y); + path.arcTo(x + barWidth - r * 2, y, r * 2, r * 2, 90, -90); + } else { + path.lineTo(x, y); + path.lineTo(x + barWidth, y); + } + + path.lineTo(x + barWidth, h); + path.closeSubpath(); + + painter->drawPath(path); + } +} + +QVector VisualizerBars::values() const { + return m_targetValues; +} + +void VisualizerBars::setValues(const QVector& values) { + m_targetValues = values; + + if (m_displayValues.size() != values.size()) { + m_displayValues.resize(values.size(), 0.0); + } + + if (m_settled) { + m_settled = false; + emit settledChanged(); + } + + emit valuesChanged(); +} + +bool VisualizerBars::settled() const { + return m_settled; +} + +QColor VisualizerBars::primaryColor() const { + return m_primaryColor; +} + +void VisualizerBars::setPrimaryColor(const QColor& color) { + if (m_primaryColor == color) + return; + m_primaryColor = color; + emit primaryColorChanged(); + update(); +} + +QColor VisualizerBars::secondaryColor() const { + return m_secondaryColor; +} + +void VisualizerBars::setSecondaryColor(const QColor& color) { + if (m_secondaryColor == color) + return; + m_secondaryColor = color; + emit secondaryColorChanged(); + update(); +} + +qreal VisualizerBars::rounding() const { + return m_rounding; +} + +void VisualizerBars::setRounding(qreal rounding) { + if (qFuzzyCompare(m_rounding, rounding)) + return; + m_rounding = rounding; + emit roundingChanged(); + update(); +} + +qreal VisualizerBars::spacing() const { + return m_spacing; +} + +void VisualizerBars::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + update(); +} + +int VisualizerBars::animationDuration() const { + return m_animationDuration; +} + +void VisualizerBars::setAnimationDuration(int duration) { + if (m_animationDuration == duration) + return; + m_animationDuration = duration; + emit animationDurationChanged(); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/visualizerbars.hpp b/Plugins/ZShell/Internal/visualizerbars.hpp new file mode 100644 index 0000000..64651cd --- /dev/null +++ b/Plugins/ZShell/Internal/visualizerbars.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::internal { + +class VisualizerBars : public QQuickPaintedItem { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(QVector values READ values WRITE setValues NOTIFY valuesChanged) +Q_PROPERTY(QColor primaryColor READ primaryColor WRITE setPrimaryColor NOTIFY primaryColorChanged) +Q_PROPERTY(QColor secondaryColor READ secondaryColor WRITE setSecondaryColor NOTIFY secondaryColorChanged) +Q_PROPERTY(qreal rounding READ rounding WRITE setRounding NOTIFY roundingChanged) +Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) +Q_PROPERTY(int animationDuration READ animationDuration WRITE setAnimationDuration NOTIFY animationDurationChanged) +Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) + +public: +explicit VisualizerBars(QQuickItem* parent = nullptr); + +void paint(QPainter* painter) override; + +Q_INVOKABLE void advance(qreal dt); + +[[nodiscard]] QVector values() const; +void setValues(const QVector& values); + +[[nodiscard]] QColor primaryColor() const; +void setPrimaryColor(const QColor& color); + +[[nodiscard]] QColor secondaryColor() const; +void setSecondaryColor(const QColor& color); + +[[nodiscard]] qreal rounding() const; +void setRounding(qreal rounding); + +[[nodiscard]] qreal spacing() const; +void setSpacing(qreal spacing); + +[[nodiscard]] int animationDuration() const; +void setAnimationDuration(int duration); + +[[nodiscard]] bool settled() const; + +signals: +void valuesChanged(); +void primaryColorChanged(); +void secondaryColorChanged(); +void roundingChanged(); +void spacingChanged(); +void animationDurationChanged(); +void settledChanged(); + +private: +void drawSide(QPainter* painter, bool rightSide); + +QVector m_targetValues; +QVector m_displayValues; +QColor m_primaryColor; +QColor m_secondaryColor; +qreal m_rounding = 0.0; +qreal m_spacing = 0.0; +int m_animationDuration = 200; +bool m_settled = true; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Internal/wallpaperimage.cpp index ebe27e2..c48aab4 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Internal/wallpaperimage.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace ZShell::internal { @@ -21,10 +22,20 @@ WallpaperImage::~WallpaperImage() { if (m_texture) delete m_texture; } +void WallpaperImage::setStatus(const Status s) { + if (m_status == s) + return; + + m_status = s; + emit statusChanged(); +} + void WallpaperImage::setSource(const QUrl &source) { if (m_source == source) return; m_source = source; emit sourceChanged(); + + setStatus(Loading); loadImage(); } @@ -57,28 +68,27 @@ void WallpaperImage::setCropY(qreal y) { } void WallpaperImage::setCropWidth(qreal w) { - if (w <= 0.0) w = 1.0; - if (qFuzzyCompare(m_cropWidth, w)) return; - m_cropWidth = w; - emit cropWidthChanged(); - update(); + if (w <= 0.0) w = 1.0; + if (qFuzzyCompare(m_cropWidth, w)) return; + m_cropWidth = w; + emit cropWidthChanged(); + update(); } void WallpaperImage::setCropHeight(qreal h) { - if (h <= 0.0) h = 1.0; - if (qFuzzyCompare(m_cropHeight, h)) return; - m_cropHeight = h; - emit cropHeightChanged(); - update(); + if (h <= 0.0) h = 1.0; + if (qFuzzyCompare(m_cropHeight, h)) return; + m_cropHeight = h; + emit cropHeightChanged(); + update(); } QString WallpaperImage::getCacheFilePath() const { if (m_source.isEmpty() || m_screenResolution.isEmpty()) return QString(); - QString cachePath = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + "/zshell/imagecache"; + QString cachePath = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + "/zshell/imagecache"; QDir().mkpath(cachePath); - // Hash the source URL + resolution QString id = m_source.toString() + "_" + QString::number(m_screenResolution.width()) + "x" + QString::number(m_screenResolution.height()); QByteArray hash = QCryptographicHash::hash(id.toUtf8(), QCryptographicHash::Md5).toHex(); @@ -86,52 +96,53 @@ QString WallpaperImage::getCacheFilePath() const { } void WallpaperImage::loadImage() { - if (m_source.isEmpty()) return; + if (m_source.isEmpty()) { + setStatus(Null); + return; + } - QString cacheFile = getCacheFilePath(); - QString sourceFile = m_source.isLocalFile() ? m_source.toLocalFile() : m_source.toString(); - - // Qt resource path correction if passed as a standard URL string - if (sourceFile.startsWith("qrc:/")) { - sourceFile = sourceFile.mid(3); // Converts "qrc:/" to ":/" - } - - QSize targetRes = m_screenResolution; + QString cacheFile = getCacheFilePath(); + QString sourceFile = m_source.isLocalFile() ? m_source.toLocalFile() : m_source.toString(); - // Run off the main thread to avoid blocking the UI - QFuture future = QtConcurrent::run([sourceFile, cacheFile, targetRes]() -> QImage { - if (!targetRes.isEmpty() && !cacheFile.isEmpty() && QFileInfo::exists(cacheFile)) { - QImage cached(cacheFile); - if (!cached.isNull()) return cached; - } + if (sourceFile.startsWith("qrc:/")) { + sourceFile = sourceFile.mid(3); + } - QImage original(sourceFile); - if (original.isNull()) return QImage(); + QSize targetRes = m_screenResolution; - if (targetRes.isEmpty()) { - // Screen resolution not set yet by QML, return the unscaled original for now to prevent a black screen - return original; - } + QFuture future = QtConcurrent::run([sourceFile, cacheFile, targetRes]() -> QImage { + if (!targetRes.isEmpty() && !cacheFile.isEmpty() && QFileInfo::exists(cacheFile)) { + QImage cached(cacheFile); + if (!cached.isNull()) return cached; + } - // Check if original is strictly larger than screen resolution - if (original.width() > targetRes.width() || original.height() > targetRes.height()) { - QImage scaled = original.scaled(targetRes, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG"); - return scaled; - } + QImage original(sourceFile); + if (original.isNull()) return QImage(); - // Otherwise just cache and return the original - if (!cacheFile.isEmpty()) original.save(cacheFile, "PNG"); - return original; - }); + if (targetRes.isEmpty()) { + return original; + } - m_imageWatcher.setFuture(future); + if (original.width() > targetRes.width() || original.height() > targetRes.height()) { + QImage scaled = original.scaled(targetRes, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG"); + return scaled; + } + + if (!cacheFile.isEmpty()) original.save(cacheFile, "PNG"); + return original; + }); + + m_imageWatcher.setFuture(future); } void WallpaperImage::handleImageLoaded() { m_image = m_imageWatcher.result(); + + setStatus(m_image.isNull() ? Error : Ready); + m_textureDirty = true; - update(); // Request redraw + update(); } QSGNode *WallpaperImage::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { @@ -148,7 +159,7 @@ QSGNode *WallpaperImage::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData * if (m_textureDirty) { if (m_texture) delete m_texture; - m_texture = window()->createTextureFromImage(m_image, QQuickWindow::TextureHasAlphaChannel); + m_texture = window()->createTextureFromImage(m_image, QQuickWindow::TextureHasAlphaChannel); m_textureDirty = false; } @@ -157,40 +168,61 @@ QSGNode *WallpaperImage::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData * node->setRect(boundingRect()); node->setFiltering(QSGTexture::Linear); - qreal cW = m_cropWidth / m_zoom; - qreal cH = m_cropHeight / m_zoom; + qreal cW = m_cropWidth / m_zoom; + qreal cH = m_cropHeight / m_zoom; - QRectF reqRect( - m_cropX * m_texture->textureSize().width(), - m_cropY * m_texture->textureSize().height(), - cW * m_texture->textureSize().width(), - cH * m_texture->textureSize().height() - ); + QRectF reqRect( + m_cropX * m_texture->textureSize().width(), + m_cropY * m_texture->textureSize().height(), + cW * m_texture->textureSize().width(), + cH * m_texture->textureSize().height() + ); - QRectF bounds = boundingRect(); - if (bounds.isEmpty() || reqRect.isEmpty()) return node; + QRectF bounds = boundingRect(); + if (bounds.isEmpty() || reqRect.isEmpty()) return node; - qreal targetRatio = bounds.width() / bounds.height(); - qreal reqRatio = reqRect.width() / reqRect.height(); + qreal targetRatio = bounds.width() / bounds.height(); + qreal reqRatio = reqRect.width() / reqRect.height(); - QRectF sourceRect = reqRect; + QRectF sourceRect = reqRect; - // Force 'PreserveAspectCrop' behavior on the requested region - if (reqRatio > targetRatio) { - // Requested region is too wide, center-crop the sides - qreal newWidth = reqRect.height() * targetRatio; - qreal xOffset = (reqRect.width() - newWidth) / 2.0; - sourceRect.setX(reqRect.x() + xOffset); - sourceRect.setWidth(newWidth); - } else if (reqRatio < targetRatio) { - // Requested region is too tall, center-crop the top/bottom - qreal newHeight = reqRect.width() / targetRatio; - qreal yOffset = (reqRect.height() - newHeight) / 2.0; - sourceRect.setY(reqRect.y() + yOffset); - sourceRect.setHeight(newHeight); - } + if (reqRatio > targetRatio) { + qreal newWidth = reqRect.height() * targetRatio; + qreal xOffset = (reqRect.width() - newWidth) / 2.0; + sourceRect.setX(reqRect.x() + xOffset); + sourceRect.setWidth(newWidth); + } else if (reqRatio < targetRatio) { + qreal newHeight = reqRect.width() / targetRatio; + qreal yOffset = (reqRect.height() - newHeight) / 2.0; + sourceRect.setY(reqRect.y() + yOffset); + sourceRect.setHeight(newHeight); + } - node->setSourceRect(sourceRect); + QRectF normalizedActual( + sourceRect.x() / m_texture->textureSize().width(), + sourceRect.y() / m_texture->textureSize().height(), + sourceRect.width() / m_texture->textureSize().width(), + sourceRect.height() / m_texture->textureSize().height() + ); + + bool changed = false; + + auto updateIfChanged = [&](qreal &dst, qreal value) { + if (!qFuzzyCompare(dst, value)) { + dst = value; + changed = true; + } + }; + + updateIfChanged(m_actualCropX, normalizedActual.x()); + updateIfChanged(m_actualCropY, normalizedActual.y()); + updateIfChanged(m_actualCropWidth, normalizedActual.width()); + updateIfChanged(m_actualCropHeight, normalizedActual.height()); + + if (changed) + emit actualCropChanged(); + + node->setSourceRect(sourceRect); } return node; diff --git a/Plugins/ZShell/Internal/wallpaperimage.hpp b/Plugins/ZShell/Internal/wallpaperimage.hpp index 04cb4ed..3c00fe4 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.hpp +++ b/Plugins/ZShell/Internal/wallpaperimage.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace ZShell::internal { @@ -15,6 +16,12 @@ QML_NAMED_ELEMENT(WallpaperImage) Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged) Q_PROPERTY(QSize screenResolution READ screenResolution WRITE setScreenResolution NOTIFY screenResolutionChanged) Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) +Q_PROPERTY(Status status READ status NOTIFY statusChanged) + +Q_PROPERTY(qreal actualCropX READ actualCropX NOTIFY actualCropChanged) +Q_PROPERTY(qreal actualCropY READ actualCropY NOTIFY actualCropChanged) +Q_PROPERTY(qreal actualCropWidth READ actualCropWidth NOTIFY actualCropChanged) +Q_PROPERTY(qreal actualCropHeight READ actualCropHeight NOTIFY actualCropChanged) Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged) Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged) @@ -25,6 +32,18 @@ public: explicit WallpaperImage(QQuickItem *parent = nullptr); ~WallpaperImage() override; +enum Status { + Null, + Ready, + Loading, + Error +}; +Q_ENUM(Status) + +Status status() const { + return m_status; +} + QUrl source() const { return m_source; } @@ -40,6 +59,22 @@ qreal zoom() const { } void setZoom(qreal zoom); +qreal actualCropX() const { + return m_actualCropX; +} + +qreal actualCropY() const { + return m_actualCropY; +} + +qreal actualCropWidth() const { + return m_actualCropWidth; +} + +qreal actualCropHeight() const { + return m_actualCropHeight; +} + qreal cropX() const { return m_cropX; } @@ -67,20 +102,29 @@ signals: void sourceChanged(); void screenResolutionChanged(); void zoomChanged(); +void actualCropChanged(); void cropXChanged(); void cropYChanged(); void cropWidthChanged(); void cropHeightChanged(); +void statusChanged(); private: void loadImage(); void handleImageLoaded(); QString getCacheFilePath() const; +void setStatus(const Status s); +Status m_status = Null; QUrl m_source; QSize m_screenResolution; qreal m_zoom = 1.0; +qreal m_actualCropX = 0.0; +qreal m_actualCropY = 0.0; +qreal m_actualCropWidth = 1.0; +qreal m_actualCropHeight = 1.0; + qreal m_cropX = 0.0; qreal m_cropY = 0.0; qreal m_cropWidth = 1.0; diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index f45ecdf..7d1d2ff 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -1,19 +1,28 @@ qml_module(ZShell-services URI ZShell.Services - SOURCES - service.hpp service.cpp - serviceref.hpp serviceref.cpp - beattracker.hpp beattracker.cpp - audiocollector.hpp audiocollector.cpp - audioprovider.hpp audioprovider.cpp - cavaprovider.hpp cavaprovider.cpp + SOURCES + service.hpp service.cpp + serviceref.hpp serviceref.cpp + beattracker.hpp beattracker.cpp + audiocollector.hpp audiocollector.cpp + audioprovider.hpp audioprovider.cpp + cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp hyprsunsetmanager.hpp hyprsunsetmanager.cpp - LIBRARIES + tickingservice.hpp tickingservice.cpp + sensorslib.hpp sensorslib.cpp + usagefmt.hpp usagefmt.cpp + cpu.hpp cpu.cpp + gpu.hpp gpu.cpp + memory.hpp memory.cpp + diskinfo.hpp diskinfo.cpp + storage.hpp storage.cpp + LIBRARIES Qt6::Core Qt6::Qml - PkgConfig::Pipewire - PkgConfig::Aubio - PkgConfig::Cava + PkgConfig::Pipewire + PkgConfig::Aubio + PkgConfig::Cava + Sensors::Sensors ) diff --git a/Plugins/ZShell/Services/cavaprovider.cpp b/Plugins/ZShell/Services/cavaprovider.cpp index 8715f39..ec64db2 100644 --- a/Plugins/ZShell/Services/cavaprovider.cpp +++ b/Plugins/ZShell/Services/cavaprovider.cpp @@ -36,7 +36,7 @@ void CavaProcessor::process() { QVector values(m_bars); for(int i = 0; i < m_bars; ++i) { - values[i] = std::clamp(m_out[i], 0.0, 1.0); + values[i] = m_out[i]; } // Left to right pass diff --git a/Plugins/ZShell/Services/cpu.cpp b/Plugins/ZShell/Services/cpu.cpp new file mode 100644 index 0000000..c04f356 --- /dev/null +++ b/Plugins/ZShell/Services/cpu.cpp @@ -0,0 +1,117 @@ +#include "cpu.hpp" + +#include "sensorslib.hpp" + +#include +#include +#include + +namespace ZShell::services { + +Cpu::Cpu(QObject* parent) + : TickingService(parent) { + readNameOnce(); +} + +QString Cpu::name() const { + return m_name; +} + +qreal Cpu::percentage() const { + return m_percentage; +} + +qreal Cpu::temperature() const { + return m_temperature; +} + +void Cpu::tick() { + if (!m_nameLoaded) { + readNameOnce(); + } + refreshPercentage(); + refreshTemperature(); +} + +void Cpu::readNameOnce() { + QFile f(QStringLiteral("/proc/cpuinfo")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression re(QStringLiteral("model name\\s*:\\s*(.+)")); + const auto match = re.match(QString::fromLatin1(data)); + if (!match.hasMatch()) { + return; + } + + const QString cleaned = cleanName(match.captured(1)); + m_nameLoaded = true; + if (cleaned == m_name) { + return; + } + m_name = cleaned; + Q_EMIT nameChanged(); +} + +void Cpu::refreshPercentage() { + QFile f(QStringLiteral("/proc/stat")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression re( + QStringLiteral("^cpu\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)")); + const auto match = re.match(QString::fromLatin1(data)); + if (!match.hasMatch()) { + return; + } + + quint64 total = 0; + quint64 idle = 0; + for (int i = 1; i <= 7; ++i) { + const quint64 v = match.captured(i).toULongLong(); + total += v; + if (i == 4 || i == 5) { + idle += v; + } + } + + const quint64 totalDiff = total > m_lastTotal ? total - m_lastTotal : 0; + const quint64 idleDiff = idle > m_lastIdle ? idle - m_lastIdle : 0; + const qreal newPerc = totalDiff > 0 ? 1.0 - static_cast(idleDiff) / static_cast(totalDiff) : 0.0; + + m_lastTotal = total; + m_lastIdle = idle; + + if (std::abs(newPerc - m_percentage) > 0.0001) { + m_percentage = newPerc; + Q_EMIT percentageChanged(); + } +} + +void Cpu::refreshTemperature() { + const auto t = sensorslib::cpuPackageTemp(); + const qreal newTemp = t.value_or(0.0); + if (std::abs(newTemp - m_temperature) > 0.05) { + m_temperature = newTemp; + Q_EMIT temperatureChanged(); + } +} + +QString Cpu::cleanName(QString s) { + static const QRegularExpression noise( + QStringLiteral("\\(R\\)|\\(TM\\)|CPU|\\d+(?:th|nd|rd|st) Gen |Core |Processor"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression spaces(QStringLiteral("\\s+")); + + s.replace(noise, QString()); + s.replace(spaces, QStringLiteral(" ")); + return s.trimmed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/cpu.hpp b/Plugins/ZShell/Services/cpu.hpp new file mode 100644 index 0000000..eb3909c --- /dev/null +++ b/Plugins/ZShell/Services/cpu.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +namespace ZShell::services { + +class Cpu : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(QString name READ name NOTIFY nameChanged) +Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) +Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) + +public: +explicit Cpu(QObject* parent = nullptr); + +[[nodiscard]] QString name() const; +[[nodiscard]] qreal percentage() const; +[[nodiscard]] qreal temperature() const; + +signals: +void nameChanged(); +void percentageChanged(); +void temperatureChanged(); + +protected: +void tick() override; + +private: +void readNameOnce(); +void refreshPercentage(); +void refreshTemperature(); + +[[nodiscard]] static QString cleanName(QString s); + +QString m_name; +qreal m_percentage = 0.0; +qreal m_temperature = 0.0; +quint64 m_lastIdle = 0; +quint64 m_lastTotal = 0; +bool m_nameLoaded = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/desktopmodel.cpp b/Plugins/ZShell/Services/desktopmodel.cpp index a75cd98..2fa55c7 100644 --- a/Plugins/ZShell/Services/desktopmodel.cpp +++ b/Plugins/ZShell/Services/desktopmodel.cpp @@ -2,6 +2,7 @@ #include "desktopstatemanager.hpp" #include #include +#include namespace ZShell::services { @@ -37,7 +38,29 @@ QHash DesktopModel::roleNames() const { return roles; } +QPoint DesktopModel::getEmptySpot(const QSet &occupied) const { + for (int x = 0; ; ++x) { + for (int y = 0; y < m_rows; ++y) { + QString key = QString::number(x) + "," + QString::number(y); + if (!occupied.contains(key)) { + return QPoint(x, y); + } + } + } +} + void DesktopModel::loadDirectory(const QString &path) { + m_watchedPath = path; + + if (!m_watcher.directories().isEmpty()) + m_watcher.removePaths(m_watcher.directories()); + + m_watcher.addPath(path); + + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, + this, &DesktopModel::onDirectoryChanged, + Qt::UniqueConnection); + beginResetModel(); m_items.clear(); @@ -48,6 +71,14 @@ void DesktopModel::loadDirectory(const QString &path) { DesktopStateManager sm; QVariantMap savedLayout = sm.getLayout(); + QSet occupied; + for (const QFileInfo &fileInfo : list) { + if (savedLayout.contains(fileInfo.fileName())) { + QVariantMap pos = savedLayout[fileInfo.fileName()].toMap(); + occupied.insert(QString::number(pos["x"].toInt()) + "," + QString::number(pos["y"].toInt())); + } + } + for (const QFileInfo &fileInfo : list) { DesktopItem item; item.fileName = fileInfo.fileName(); @@ -59,15 +90,20 @@ void DesktopModel::loadDirectory(const QString &path) { item.gridX = pos["x"].toInt(); item.gridY = pos["y"].toInt(); } else { - // TODO: make getEmptySpot in C++ and call it here to get the initial position for new icons - item.gridX = 0; - item.gridY = 0; + QPoint spot = getEmptySpot(occupied); + item.gridX = spot.x(); + item.gridY = spot.y(); + occupied.insert(QString::number(item.gridX) + "," + QString::number(item.gridY)); } m_items.append(item); } endResetModel(); } +void DesktopModel::onDirectoryChanged() { + loadDirectory(m_watchedPath); +} + void DesktopModel::moveIcon(int index, int newX, int newY) { if (index < 0 || index >= m_items.size()) return; @@ -183,4 +219,4 @@ void DesktopModel::massMove(const QVariantList& selectedPathsList, const QString saveCurrentLayout(); } -} // namespace ZShell::services +}; diff --git a/Plugins/ZShell/Services/desktopmodel.hpp b/Plugins/ZShell/Services/desktopmodel.hpp index dcaa363..286b40a 100644 --- a/Plugins/ZShell/Services/desktopmodel.hpp +++ b/Plugins/ZShell/Services/desktopmodel.hpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include namespace ZShell::services { @@ -39,9 +39,27 @@ Q_INVOKABLE void loadDirectory(const QString &path); Q_INVOKABLE void moveIcon(int index, int newX, int newY); Q_INVOKABLE void massMove(const QVariantList &selectedPathsList, const QString &leaderPath, int targetX, int targetY, int maxCol, int maxRow); +Q_PROPERTY(int rows READ rows WRITE setRows NOTIFY rowsChanged) + +public: +[[nodiscard]] int rows() const { + return m_rows; +} +void setRows(int r) { + if (m_rows != r) { m_rows = r; emit rowsChanged(); } +} + +signals: +void rowsChanged(); + private: +int m_rows = 1; QList m_items; +QString m_watchedPath; +QFileSystemWatcher m_watcher; void saveCurrentLayout(); +[[nodiscard]] QPoint getEmptySpot(const QSet &occupied) const; +void onDirectoryChanged(); }; -} // namespace ZShell::Services +}; diff --git a/Plugins/ZShell/Services/desktopstatemanager.cpp b/Plugins/ZShell/Services/desktopstatemanager.cpp index 55f9c70..820a1e6 100644 --- a/Plugins/ZShell/Services/desktopstatemanager.cpp +++ b/Plugins/ZShell/Services/desktopstatemanager.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace ZShell::services { @@ -12,7 +13,7 @@ DesktopStateManager::DesktopStateManager(QObject *parent) : QObject(parent) { } QString DesktopStateManager::getConfigFilePath() const { - QString configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/sleex"; + QString configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/zshell"; QDir dir(configDir); if (!dir.exists()) { dir.mkpath("."); @@ -29,7 +30,7 @@ void DesktopStateManager::saveLayout(const QVariantMap& layout) { file.write(doc.toJson(QJsonDocument::Indented)); file.close(); } else { - qWarning() << "Sleex: Impossible de sauvegarder le layout du bureau dans" << getConfigFilePath(); + qWarning() << "zshell: Cannot save desktop layout to" << getConfigFilePath(); } } diff --git a/Plugins/ZShell/Services/diskinfo.cpp b/Plugins/ZShell/Services/diskinfo.cpp new file mode 100644 index 0000000..aca9bb8 --- /dev/null +++ b/Plugins/ZShell/Services/diskinfo.cpp @@ -0,0 +1,68 @@ +#include "diskinfo.hpp" + +namespace ZShell::services { + +namespace { + +constexpr qreal kKib = 1024.0; + +} // namespace + +DiskInfo::DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent) + : QObject(parent) + , m_mount(std::move(mount)) + , m_usedBytes(usedBytes) + , m_totalBytes(totalBytes) + , m_hasRoot(hasRoot) { +} + +QString DiskInfo::mount() const { + return m_mount; +} + +qreal DiskInfo::used() const { + return static_cast(m_usedBytes) / kKib; +} + +qreal DiskInfo::total() const { + return static_cast(m_totalBytes) / kKib; +} + +qreal DiskInfo::free() const { + const quint64 freeBytes = m_totalBytes > m_usedBytes ? m_totalBytes - m_usedBytes : 0; + return static_cast(freeBytes) / kKib; +} + +qreal DiskInfo::perc() const { + return m_totalBytes > 0 ? static_cast(m_usedBytes) / static_cast(m_totalBytes) : 0.0; +} + +bool DiskInfo::hasRoot() const { + return m_hasRoot; +} + +void DiskInfo::update(quint64 usedBytes, quint64 totalBytes, bool hasRoot) { + const bool usedDiff = usedBytes != m_usedBytes; + const bool totalDiff = totalBytes != m_totalBytes; + const bool rootDiff = hasRoot != m_hasRoot; + + m_usedBytes = usedBytes; + m_totalBytes = totalBytes; + m_hasRoot = hasRoot; + + if (usedDiff) { + Q_EMIT usedChanged(); + } + if (totalDiff) { + Q_EMIT totalChanged(); + } + if (usedDiff || totalDiff) { + Q_EMIT freeChanged(); + Q_EMIT percChanged(); + } + if (rootDiff) { + Q_EMIT hasRootChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/diskinfo.hpp b/Plugins/ZShell/Services/diskinfo.hpp new file mode 100644 index 0000000..34acdff --- /dev/null +++ b/Plugins/ZShell/Services/diskinfo.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace ZShell::services { + +class DiskInfo : public QObject { +Q_OBJECT +QML_ELEMENT + QML_UNCREATABLE("DiskInfo is created by DiskUsage") + +Q_PROPERTY(QString mount READ mount 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) +Q_PROPERTY(qreal perc READ perc NOTIFY percChanged) +Q_PROPERTY(bool hasRoot READ hasRoot NOTIFY hasRootChanged) + +public: +DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent = nullptr); + +[[nodiscard]] QString mount() const; +[[nodiscard]] qreal used() const; +[[nodiscard]] qreal total() const; +[[nodiscard]] qreal free() const; +[[nodiscard]] qreal perc() const; +[[nodiscard]] bool hasRoot() const; + +void update(quint64 usedBytes, quint64 totalBytes, bool hasRoot); + +signals: +void usedChanged(); +void totalChanged(); +void freeChanged(); +void percChanged(); +void hasRootChanged(); + +private: +QString m_mount; +quint64 m_usedBytes; +quint64 m_totalBytes; +bool m_hasRoot; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp new file mode 100644 index 0000000..9a236cd --- /dev/null +++ b/Plugins/ZShell/Services/gpu.cpp @@ -0,0 +1,332 @@ +#include "gpu.hpp" + +#include "sensorslib.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +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"; + +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"; + +} // namespace + +Gpu::Gpu(QObject* parent) + : TickingService(parent) { + QString configPath = QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); + + 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())); + } + } + }; + + reloadConfig(); + + 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); + } + + // 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; +} + +QString Gpu::name() const { + return m_name; +} + +qreal Gpu::percentage() const { + return m_percentage; +} + +qreal Gpu::temperature() const { + return m_temperature; +} + +qreal Gpu::memoryUsed() const { + return m_memoryUsed; +} + +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(); + } +} + +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::setName(QString value) { + if (value == m_name) { + return; + } + m_name = std::move(value); + Q_EMIT nameChanged(); +} + +void Gpu::setMemoryUsed(qreal value) { + if (m_memoryUsed == value) return; + m_memoryUsed = value; + Q_EMIT memoryUsedChanged(); +} + +void Gpu::setMemoryTotal(qreal value) { + if (m_memoryTotal == value) return; + m_memoryTotal = value; + Q_EMIT memoryTotalChanged(); +} + +void Gpu::tick() { + const Type t = type(); + if (t == Generic) { + readGenericUsage(); + readGpuTemperature(); + } else if (t == 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(); + } + } +} + +void Gpu::detectTypeOnce() { + if (m_typeProc) { + return; + } + 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) { + return; + } + m_nameProc = new QProcess(this); + QObject::connect(m_nameProc, &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; + }); + m_nameProc->start(QStringLiteral("sh"), { QStringLiteral("-c"), QString::fromLatin1(kNameDetectScript) }); +} + +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; + ++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(); + } +} + +void Gpu::startNvidiaUsage() { + if (m_nvidiaProc) { + return; + } + m_nvidiaProc = new QProcess(this); + QObject::connect(m_nvidiaProc, &QProcess::readyReadStandardOutput, this, [this]() { + while (m_nvidiaProc->canReadLine()) { + const QByteArray out = m_nvidiaProc->readLine(); + const QString output = QString::fromUtf8(out).trimmed(); + if (output.isEmpty()) + continue; + + const QList parts = output.split(','); + if (parts.size() < 4) + return; + + bool ok1 = false; + bool ok2 = false; + bool ok3 = false; + bool ok4 = false; + const qreal usage = parts.at(0).trimmed().toDouble(&ok1) / 100.0; + const qreal temp = parts.at(1).trimmed().toDouble(&ok2); + const qreal memUsed = parts.at(2).trimmed().toDouble(&ok3); + const qreal memTotal = parts.at(3).trimmed().toDouble(&ok4); + + if (ok1 && std::abs(usage - m_percentage) > 0.0001) { + m_percentage = usage; + Q_EMIT percentageChanged(); + } + if (ok2 && std::abs(temp - m_temperature) > 0.05) { + m_temperature = temp; + Q_EMIT temperatureChanged(); + } + if (ok3) { + setMemoryUsed(memUsed); + } + if (ok4) { + setMemoryTotal(memTotal); + } + } + }); + + QObject::connect(m_nvidiaProc, &QProcess::finished, this, [this]() { + m_nvidiaProc->deleteLater(); + m_nvidiaProc = nullptr; + }); + + QObject::connect(m_nvidiaProc, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + m_nvidiaProc->deleteLater(); + m_nvidiaProc = nullptr; + }); + + m_nvidiaProc->start(QStringLiteral("nvidia-smi"), { QStringLiteral("--query-gpu=utilization.gpu,temperature.gpu,memory.used,memory.total"), + QStringLiteral("--format=csv,noheader,nounits"), + QStringLiteral("-lms"), + QString::number(updateInterval()) }); +} + +void Gpu::readGpuTemperature() { + const auto t = sensorslib::gpuPciAverageTemp(); + const qreal newTemp = t.value_or(0.0); + if (std::abs(newTemp - m_temperature) > 0.05) { + m_temperature = newTemp; + Q_EMIT temperatureChanged(); + } +} + +Gpu::Type Gpu::parseType(const QString& s) { + const QString u = s.trimmed().toUpper(); + if (u.isEmpty()) { + return Auto; + } + if (u == QStringLiteral("NVIDIA")) { + return Nvidia; + } + 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(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/gpu.hpp b/Plugins/ZShell/Services/gpu.hpp new file mode 100644 index 0000000..e8cf84d --- /dev/null +++ b/Plugins/ZShell/Services/gpu.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include "tickingservice.hpp" + +#include +#include + +namespace ZShell::services { + +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(QString name READ name NOTIFY nameChanged) +Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) +Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) +Q_PROPERTY(qreal memoryUsed READ memoryUsed NOTIFY memoryUsedChanged) +Q_PROPERTY(qreal memoryTotal READ memoryTotal NOTIFY memoryTotalChanged) + +public: +explicit Gpu(QObject* parent = nullptr); + +[[nodiscard]] Type type() const; +[[nodiscard]] Type userType() const; +[[nodiscard]] Type autoType() const; +[[nodiscard]] QString name() const; +[[nodiscard]] qreal percentage() const; +[[nodiscard]] qreal temperature() const; +[[nodiscard]] qreal memoryUsed() const; +[[nodiscard]] qreal memoryTotal() const; + +signals: +void typeChanged(); +void userTypeChanged(); +void autoTypeChanged(); +void nameChanged(); +void percentageChanged(); +void temperatureChanged(); +void memoryUsedChanged(); +void memoryTotalChanged(); + +protected: +void tick() override; + +private: +void detectTypeOnce(); +void detectNameOnce(); +void readGenericUsage(); +void startNvidiaUsage(); +void readGpuTemperature(); + +void setUserType(Type value); +void setAutoType(Type value); +void setName(QString value); +void setMemoryUsed(qreal value); +void setMemoryTotal(qreal value); + +[[nodiscard]] static Type parseType(const QString& s); +[[nodiscard]] static QString cleanName(QString s); + +Type m_userType = Auto; +Type m_autoType = None; +QString m_name; +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; +QProcess* m_nvidiaProc = nullptr; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp index c413efa..40aa81f 100644 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ b/Plugins/ZShell/Services/hyprsunsetmanager.cpp @@ -135,9 +135,17 @@ void HyprsunsetManager::apply() { if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - const auto current = QTime::currentTime().hour(); + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime; - if (current >= m_startTime || current < m_endTime) { + 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(); diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp new file mode 100644 index 0000000..50f5f77 --- /dev/null +++ b/Plugins/ZShell/Services/memory.cpp @@ -0,0 +1,59 @@ +#include "memory.hpp" + +#include +#include + +namespace ZShell::services { + +Memory::Memory(QObject* parent) + : TickingService(parent) { +} + +qreal Memory::used() const { + return m_used; +} + +qreal Memory::total() const { + return m_total; +} + +qreal Memory::percentage() const { + return m_total > 0.0 ? m_used / m_total : 0.0; +} + +void Memory::tick() { + QFile f(QStringLiteral("/proc/meminfo")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression reTotal(QStringLiteral("MemTotal: *(\\d+)")); + static const QRegularExpression reAvail(QStringLiteral("MemAvailable: *(\\d+)")); + const QString text = QString::fromLatin1(data); + + const auto totalMatch = reTotal.match(text); + const auto availMatch = reAvail.match(text); + if (!totalMatch.hasMatch() || !availMatch.hasMatch()) { + return; + } + + const quint64 totalKib = totalMatch.captured(1).toULongLong(); + const quint64 availKib = availMatch.captured(1).toULongLong(); + if (totalKib == 0) { + return; + } + const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0; + + if (totalKib == m_lastTotal && usedKib == m_lastUsed) { + return; + } + m_lastTotal = totalKib; + m_lastUsed = usedKib; + m_total = static_cast(totalKib); + m_used = static_cast(usedKib); + Q_EMIT changed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/memory.hpp b/Plugins/ZShell/Services/memory.hpp new file mode 100644 index 0000000..f5d0fb6 --- /dev/null +++ b/Plugins/ZShell/Services/memory.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "tickingservice.hpp" + +#include +#include + +namespace ZShell::services { + +class Memory : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(qreal used READ used NOTIFY changed) +Q_PROPERTY(qreal total READ total NOTIFY changed) +Q_PROPERTY(qreal percentage READ percentage NOTIFY changed) + +public: +explicit Memory(QObject* parent = nullptr); + +[[nodiscard]] qreal used() const; +[[nodiscard]] qreal total() const; +[[nodiscard]] qreal percentage() const; + +signals: +void changed(); + +protected: +void tick() override; + +private: +qreal m_used = 0.0; +qreal m_total = 1.0; +quint64 m_lastUsed = 0; +quint64 m_lastTotal = 0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/sensorslib.cpp b/Plugins/ZShell/Services/sensorslib.cpp new file mode 100644 index 0000000..995434a --- /dev/null +++ b/Plugins/ZShell/Services/sensorslib.cpp @@ -0,0 +1,166 @@ +#include "sensorslib.hpp" + +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcSensorsLib, "ZShell.services.sensorslib", QtInfoMsg) + +namespace ZShell::services::sensorslib { + +namespace { + +std::atomic g_initOk{ false }; +std::once_flag g_initFlag; + +void doInit() { + if (sensors_init(nullptr) != 0) { + qCWarning(lcSensorsLib, "sensors_init failed"); + g_initOk.store(false, std::memory_order_release); + return; + } + g_initOk.store(true, std::memory_order_release); + std::atexit([] { + if (g_initOk.load(std::memory_order_acquire)) { + sensors_cleanup(); + } + }); +} + +[[nodiscard]] std::optional readTempInput(const sensors_chip_name* chip, const sensors_feature* feat) { + const sensors_subfeature* sf = sensors_get_subfeature(chip, feat, SENSORS_SUBFEATURE_TEMP_INPUT); + if (!sf) { + return std::nullopt; + } + double value = 0.0; + if (sensors_get_value(chip, sf->number, &value) != 0) { + return std::nullopt; + } + return value; +} + +[[nodiscard]] QByteArray featureLabel(const sensors_chip_name* chip, const sensors_feature* feat) { + char* raw = sensors_get_label(chip, feat); + if (!raw) { + return {}; + } + QByteArray out(raw); + std::free(raw); + return out; +} + +bool labelEquals(const QByteArray& label, const char* literal) { + return label == QByteArrayView(literal); +} + +bool labelStartsWith(const QByteArray& label, const char* prefix) { + const auto n = std::strlen(prefix); + return static_cast(label.size()) >= n && std::memcmp(label.constData(), prefix, n) == 0; +} + +} // namespace + +void ensureInit() { + std::call_once(g_initFlag, doInit); +} + +std::optional cpuPackageTemp() { + ensureInit(); + if (!g_initOk.load(std::memory_order_acquire)) { + return std::nullopt; + } + + std::optional primary; // Package id N / Tdie + std::optional fallback; // Tctl + + int chipNr = 0; + while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) { + int featNr = 0; + while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) { + if (feat->type != SENSORS_FEATURE_TEMP) { + continue; + } + const QByteArray label = featureLabel(chip, feat); + if (label.isEmpty()) { + continue; + } + + if (labelStartsWith(label, "Package id ") || labelEquals(label, "Tdie")) { + if (auto v = readTempInput(chip, feat)) { + primary = v; + } + } else if (labelEquals(label, "Tctl")) { + if (auto v = readTempInput(chip, feat)) { + fallback = v; + } + } + } + } + + return primary.has_value() ? primary : fallback; +} + +std::optional gpuPciAverageTemp() { + ensureInit(); + if (!g_initOk.load(std::memory_order_acquire)) { + return std::nullopt; + } + + double sumPrimary = 0.0; + int countPrimary = 0; + double sumFallback = 0.0; + int countFallback = 0; + + int chipNr = 0; + while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) { + if (chip->bus.type != SENSORS_BUS_TYPE_PCI) { + continue; + } + + int featNr = 0; + while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) { + if (feat->type != SENSORS_FEATURE_TEMP) { + continue; + } + const QByteArray label = featureLabel(chip, feat); + if (label.isEmpty()) { + continue; + } + + const bool tempIndexed = labelStartsWith(label, "temp") && label.size() > 4 && + std::isdigit(static_cast(label[4])); + const bool isPrimary = tempIndexed || labelEquals(label, "GPU core") || labelEquals(label, "edge"); + const bool isFallback = labelEquals(label, "junction") || labelEquals(label, "mem"); + + if (!isPrimary && !isFallback) { + continue; + } + + const auto v = readTempInput(chip, feat); + if (!v) { + continue; + } + if (isPrimary) { + sumPrimary += *v; + ++countPrimary; + } else { + sumFallback += *v; + ++countFallback; + } + } + } + + if (countPrimary > 0) { + return sumPrimary / countPrimary; + } + if (countFallback > 0) { + return sumFallback / countFallback; + } + return std::nullopt; +} + +} // namespace ZShell::services::sensorslib diff --git a/Plugins/ZShell/Services/sensorslib.hpp b/Plugins/ZShell/Services/sensorslib.hpp new file mode 100644 index 0000000..ebb603d --- /dev/null +++ b/Plugins/ZShell/Services/sensorslib.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace ZShell::services::sensorslib { + +void ensureInit(); + +[[nodiscard]] std::optional cpuPackageTemp(); +[[nodiscard]] std::optional gpuPciAverageTemp(); + +} // namespace ZShell::services::sensorslib diff --git a/Plugins/ZShell/Services/storage.cpp b/Plugins/ZShell/Services/storage.cpp new file mode 100644 index 0000000..f9973c6 --- /dev/null +++ b/Plugins/ZShell/Services/storage.cpp @@ -0,0 +1,313 @@ +#include "storage.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcStorage, "ZShell.services.storage", QtInfoMsg) + +namespace ZShell::services { + +namespace { + +struct Accum { + quint64 usedBytes = 0; + quint64 totalBytes = 0; + bool hasRoot = false; +}; + +[[nodiscard]] QString sysfsRealPath(uint major, uint minor) { + const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor); + const QString resolved = QFileInfo(link).canonicalFilePath(); + return resolved; +} + +[[nodiscard]] bool readDevtFromSysfs(const QString& sysfsBlockDir, uint& major, uint& minor) { + QFile f(sysfsBlockDir + QStringLiteral("/dev")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + const QByteArray line = f.readLine().trimmed(); + f.close(); + + const qsizetype colon = line.indexOf(':'); + if (colon <= 0) { + return false; + } + bool okM = false; + bool okN = false; + major = line.left(colon).toUInt(&okM); + minor = line.mid(colon + 1).toUInt(&okN); + return okM && okN; +} + +QStringList resolveByDevt(uint major, uint minor, int depth = 0); + +QStringList resolveAtNode(const QString& node, int depth) { + if (node.isEmpty() || depth > 8) { + return {}; + } + + const QFileInfo nodeInfo(node); + if (!nodeInfo.exists() || !nodeInfo.isDir()) { + return {}; + } + + if (QFileInfo::exists(node + QStringLiteral("/partition"))) { + const QString diskNode = nodeInfo.path(); + return { QFileInfo(diskNode).fileName() }; + } + + const QDir slavesDir(node + QStringLiteral("/slaves")); + if (slavesDir.exists()) { + const QStringList slaves = slavesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + if (!slaves.isEmpty()) { + QStringList out; + for (const QString& slave : slaves) { + uint sm = 0; + uint sn = 0; + const QString slaveDir = QStringLiteral("/sys/class/block/") + slave; + if (!readDevtFromSysfs(slaveDir, sm, sn)) { + continue; + } + const auto devs = resolveByDevt(sm, sn, depth + 1); + for (const QString& d : devs) { + if (!out.contains(d)) { + out.append(d); + } + } + } + return out; + } + } + + return { nodeInfo.fileName() }; +} + +QStringList resolveByDevt(uint major, uint minor, int depth) { + return resolveAtNode(sysfsRealPath(major, minor), depth); +} + +} // namespace + +Storage::Storage(QObject* parent) + : TickingService(parent) { +} + +qreal Storage::percentage() const { + qreal totalUsed = 0.0; + qreal totalSize = 0.0; + for (const DiskInfo* d : m_disks) { + totalUsed += d->used(); + totalSize += d->total(); + } + return totalSize > 0.0 ? totalUsed / totalSize : 0.0; +} + +bool Storage::sameOrder(const QList& a, const QList& b) { + if (a.size() != b.size()) { + return false; + } + for (qsizetype i = 0; i < a.size(); ++i) { + if (a.at(i) != b.at(i)) { + return false; + } + } + return true; +} + +QQmlListProperty Storage::disksProp() { + return QQmlListProperty(this, nullptr, &Storage::disksCount, &Storage::disksAt); +} + +qsizetype Storage::disksCount(QQmlListProperty* prop) { + return static_cast(prop->object)->m_disks.size(); +} + +DiskInfo* Storage::disksAt(QQmlListProperty* prop, qsizetype i) { + return static_cast(prop->object)->m_disks.at(i); +} + +DiskInfo* Storage::manualPrimaryDisk() const { + return m_manualPrimaryDisk.data(); +} + +void Storage::setManualPrimaryDisk(DiskInfo* disk) { + if (m_manualPrimaryDisk.data() == disk) { + return; + } + m_manualPrimaryDisk = disk; + Q_EMIT manualPrimaryDiskChanged(); + Q_EMIT primaryDiskChanged(); +} + +DiskInfo* Storage::primaryDisk() const { + if (auto* m = m_manualPrimaryDisk.data()) { + return m; + } + return m_disks.isEmpty() ? nullptr : m_disks.first(); +} + +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", + }; + for (const char* p : kPseudo) { + if (fsType == QByteArrayView(p)) { + return true; + } + } + return fsType.startsWith(QByteArrayView("fuse.")); +} + +QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { + if (devicePath.isEmpty() || !devicePath.startsWith(QLatin1Char('/'))) { + return {}; + } + struct stat st {}; + if (::stat(devicePath.toLocal8Bit().constData(), &st) != 0) { + return {}; + } + if (!S_ISBLK(st.st_mode)) { + return {}; + } + 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. + struct DeviceEntry { + quint64 totalBytes = 0; + quint64 usedBytes = 0; + bool hasRoot = false; + QByteArray device; + }; + + QHash byDevice; + + const auto mountedVols = QStorageInfo::mountedVolumes(); + for (const QStorageInfo& v : mountedVols) { + if (!v.isReady() || !v.isValid() || v.bytesTotal() <= 0) { + continue; + } + if (isPseudoFs(QByteArrayView(v.fileSystemType()))) { + 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; + e.totalBytes = totalBytes; + e.usedBytes = usedBytes; + e.hasRoot = e.hasRoot || isRoot; + } + + for (auto it = byDevice.constBegin(); it != byDevice.constEnd(); ++it) { + const DeviceEntry& e = it.value(); + const QStringList disks = resolveToPhysicalDisks(QString::fromLocal8Bit(e.device)); + if (disks.isEmpty()) { + continue; + } + for (const QString& d : disks) { + if (d.startsWith(QStringLiteral("zram"))) { + continue; + } + Accum& a = byDisk[d]; + a.usedBytes += e.usedBytes; + a.totalBytes += e.totalBytes; + a.hasRoot = a.hasRoot || e.hasRoot; + } + } + + QHash existing; + existing.reserve(m_disks.size()); + for (DiskInfo* d : std::as_const(m_disks)) { + existing.insert(d->mount(), d); + } + + 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.append(survivor); + } else { + next.append(new DiskInfo(it.key(), it.value().usedBytes, it.value().totalBytes, it.value().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())) { + m_manualPrimaryDisk.clear(); + manualCleared = true; + } + for (DiskInfo* stale : std::as_const(existing)) { + stale->deleteLater(); + } + + const bool listChanged = !sameOrder(m_disks, next); + DiskInfo* prevPrimary = primaryDisk(); + m_disks = next; + + if (listChanged) { + Q_EMIT disksChanged(); + } + if (std::abs(percentage() - prevPercentage) > 0.0001) { + Q_EMIT percentageChanged(); + } + if (manualCleared) { + Q_EMIT manualPrimaryDiskChanged(); + } + if (primaryDisk() != prevPrimary) { + Q_EMIT primaryDiskChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/storage.hpp b/Plugins/ZShell/Services/storage.hpp new file mode 100644 index 0000000..2138c04 --- /dev/null +++ b/Plugins/ZShell/Services/storage.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "diskinfo.hpp" +#include "tickingservice.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class Storage : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) +Q_PROPERTY(QQmlListProperty disks READ disksProp NOTIFY disksChanged) +Q_PROPERTY(ZShell::services::DiskInfo* manualPrimaryDisk READ manualPrimaryDisk WRITE setManualPrimaryDisk NOTIFY + manualPrimaryDiskChanged) +Q_PROPERTY(ZShell::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged) + +public: +explicit Storage(QObject* parent = nullptr); + +[[nodiscard]] qreal percentage() const; +[[nodiscard]] QQmlListProperty disksProp(); +[[nodiscard]] DiskInfo* manualPrimaryDisk() const; +void setManualPrimaryDisk(DiskInfo* disk); +[[nodiscard]] DiskInfo* primaryDisk() const; + +signals: +void disksChanged(); +void percentageChanged(); +void manualPrimaryDiskChanged(); +void primaryDiskChanged(); + +protected: +void tick() override; + +private: +[[nodiscard]] static QStringList resolveToPhysicalDisks(const QString& devicePath); +[[nodiscard]] static bool isPseudoFs(QByteArrayView fsType); +[[nodiscard]] static bool sameOrder(const QList& a, const QList& b); + +static qsizetype disksCount(QQmlListProperty* prop); +static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + +QList m_disks; +QPointer m_manualPrimaryDisk; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp new file mode 100644 index 0000000..4485d86 --- /dev/null +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -0,0 +1,85 @@ +#include "tickingservice.hpp" +#include +#include +#include +#include +#include +#include +#include + +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(); + }); + + QString configPath = QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); + + auto reloadConfig = [this, configPath]() { + QFile file(configPath); + if (file.open(QIODevice::ReadOnly)) { + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isNull()) { + 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()) { + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isNull()) { + QJsonObject dashboard = doc.object().value("dashboard").toObject(); + if (dashboard.contains("resourceUpdateInterval")) { + applyInterval(dashboard.value("resourceUpdateInterval").toInt(1000)); + } + } + } + }); + }); + watcher->addPath(configPath); + } +} + +int TickingService::updateInterval() const { + return m_interval; +} + +void TickingService::start() { + m_running = true; + if (m_interval > 0) { + m_timer->start(m_interval); + } + tick(); +} + +void TickingService::stop() { + m_running = false; + m_timer->stop(); +} + +void TickingService::applyInterval(int ms) { + if (ms <= 0 || ms == m_interval) { + return; + } + m_interval = ms; + if (m_running) { + m_timer->start(m_interval); + } + Q_EMIT updateIntervalChanged(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp new file mode 100644 index 0000000..95124eb --- /dev/null +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "service.hpp" +#include + +namespace ZShell::services { + +class TickingService : public Service { +Q_OBJECT + +Q_PROPERTY(int updateInterval READ updateInterval NOTIFY updateIntervalChanged) + +public: +explicit TickingService(QObject* parent = nullptr); + +[[nodiscard]] int updateInterval() const; + +signals: +void updateIntervalChanged(); + +protected: +void start() final; +void stop() final; + +virtual void tick() = 0; + +private: +void applyInterval(int ms); + +QTimer* m_timer; +int m_interval = 1000; +bool m_running = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp new file mode 100644 index 0000000..e455fe8 --- /dev/null +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -0,0 +1,33 @@ +#include "usagefmt.hpp" + +namespace { + +constexpr qreal kKib = 1024.0; +constexpr qreal kMib = kKib * 1024.0; +constexpr qreal kGib = kMib * 1024.0; + +bool finitePositive(qreal v) { + return std::isfinite(v) && v >= 0.0; +} + +} // namespace + +namespace ZShell::services::usagefmt { + +FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { + if (!finitePositive(kib) || !finitePositive(total)) { + return { 0.0, 0.0, "KiB" }; + } + if (total >= kGib) { + return { kib / kGib, total / kGib, "TiB" }; + } + if (total >= kMib) { + return { kib / kMib, total / kMib, "GiB" }; + } + if (total >= kKib) { + return { kib / kKib, total / kKib, "MiB" }; + } + return { kib, total, "KiB" }; +} + +} // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp new file mode 100644 index 0000000..97d38b7 --- /dev/null +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::services::usagefmt { + +struct FormatResult { + Q_GADGET + QML_ANONYMOUS + + Q_PROPERTY(qreal value MEMBER value CONSTANT) + Q_PROPERTY(qreal total MEMBER total CONSTANT) + Q_PROPERTY(QString unit MEMBER unit CONSTANT) + +public: + qreal value; + qreal total; + QString unit; +}; + +class UsageFmt : public QObject { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +public: +Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib, qreal total) const; +}; + +} // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp new file mode 100644 index 0000000..c26bd0f --- /dev/null +++ b/Plugins/ZShell/zutils.cpp @@ -0,0 +1,145 @@ +#include "zutils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) + +namespace ZShell { + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path) { + this->saveItem(target, path, QRect(), QJSValue(), QJSValue()); +} + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) { + this->saveItem(target, path, rect, QJSValue(), QJSValue()); +} + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) { + this->saveItem(target, path, QRect(), onSaved, QJSValue()); +} + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) { + this->saveItem(target, path, QRect(), onSaved, onFailed); +} + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) { + this->saveItem(target, path, rect, onSaved, QJSValue()); +} + +void ZUtils::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) { + if (!target) { + qCWarning(lcZUtils) << "saveItem: a target is required"; + return; + } + + if (!path.isLocalFile()) { + qCWarning(lcZUtils) << "saveItem:" << path << "is not a local file"; + return; + } + + if (!target->window()) { + qCWarning(lcZUtils) << "saveItem: unable to save target" << target << "without a window"; + return; + } + + auto scaledRect = rect; + const qreal scale = target->window()->devicePixelRatio(); + if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) { + scaledRect = + QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect(); + } + + const QSharedPointer grabResult = target->grabToImage(); + + QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this, + [grabResult, scaledRect, path, onSaved, onFailed, this]() { + const auto future = QtConcurrent::run([=]() { + QImage image = grabResult->image(); + + if (scaledRect.isValid()) { + image = image.copy(scaledRect); + } + + const QString file = path.toLocalFile(); + const QString parent = QFileInfo(file).absolutePath(); + return QDir().mkpath(parent) && image.save(file); + }); + + auto* watcher = new QFutureWatcher(this); + auto* engine = qmlEngine(this); + + QObject::connect(watcher, &QFutureWatcher::finished, this, [=]() { + if (watcher->result()) { + if (onSaved.isCallable()) { + QJSValueList args = { QJSValue(path.toLocalFile()) }; + if (engine) { + args << engine->toScriptValue(QVariant::fromValue(path)); + } + onSaved.call(args); + } + } else { + qCWarning(lcZUtils) << "saveItem: failed to save" << path; + if (onFailed.isCallable()) { + if (engine) { + onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) }); + } else { + onFailed.call(); + } + } + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + }); +} + +bool ZUtils::copyFile(const QUrl& source, const QUrl& target, bool overwrite) { + if (!source.isLocalFile()) { + qCWarning(lcZUtils) << "copyFile: source" << source << "is not a local file"; + return false; + } + if (!target.isLocalFile()) { + qCWarning(lcZUtils) << "copyFile: target" << target << "is not a local file"; + return false; + } + + if (overwrite && QFile::exists(target.toLocalFile())) { + if (!QFile::remove(target.toLocalFile())) { + qCWarning(lcZUtils) << "copyFile: overwrite was specified but failed to remove" << target.toLocalFile(); + return false; + } + } + + return QFile::copy(source.toLocalFile(), target.toLocalFile()); +} + +bool ZUtils::deleteFile(const QUrl& path) { + if (!path.isLocalFile()) { + qCWarning(lcZUtils) << "deleteFile: path" << path << "is not a local file"; + return false; + } + + return QFile::remove(path.toLocalFile()); +} + +QString ZUtils::toLocalFile(const QUrl& url) { + if (!url.isLocalFile()) { + qCWarning(lcZUtils) << "toLocalFile: given url is not a local file" << url; + return QString(); + } + + return url.toLocalFile(); +} + +qreal ZUtils::clamp(qreal value, qreal min, qreal max) { + return qBound(min, value, max); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp new file mode 100644 index 0000000..fdc413d --- /dev/null +++ b/Plugins/ZShell/zutils.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace ZShell { + +class ZUtils : public QObject { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +public: +// clang-format off +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path); +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect); +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved); +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed); +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved); +Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed); +// clang-format on + +Q_INVOKABLE static bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true); +Q_INVOKABLE static bool deleteFile(const QUrl& path); +Q_INVOKABLE static QString toLocalFile(const QUrl& url); + +Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); +}; + +} // namespace ZShell diff --git a/scripts/SettingsIndex.mjs b/scripts/SettingsIndex.mjs index ba5eaed..319853d 100644 --- a/scripts/SettingsIndex.mjs +++ b/scripts/SettingsIndex.mjs @@ -414,6 +414,13 @@ export const settingsIndex = [ section: "Media", keywords: ["screen", "step", "increment"], }, + { + name: "Minimum brightness", + category: "services", + categoryName: "Services", + section: "Media", + keywords: ["brightness", "minimum", "screen"], + }, { name: "Max volume", category: "services", @@ -563,6 +570,49 @@ export const settingsIndex = [ section: "Utilities", keywords: ["notification", "size", "width"], }, + // Clipboard section + { + name: "Enable clipboard history viewer", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["enable", "clipboard"], + }, + { + name: "Max entries visible", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["clipboard", "max"], + }, + { + name: "Entry height", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["height", "entry"], + }, + { + name: "Entry width", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["width", "entry"], + }, + { + name: "Minimum preview width", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["minimum", "preview", "width"], + }, + { + name: "Maximum preview width", + category: "utilities", + categoryName: "Utilities", + section: "Clipboard", + keywords: ["maximum", "preview", "width"], + }, // Toasts section { name: "Config loaded", diff --git a/scripts/update.sh b/scripts/update.sh deleted file mode 100755 index 3108bdb..0000000 --- a/scripts/update.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -OS="arch" -if [[ $(ls ./tmp) ]]; then - exec mkdir ./tmp -fi - -cd ./tmp - -main() { - local OPTARG OPTIND opt - while getopts "arch:nix:" opt; do - case "$opt" in - arch) OS=$OPTARG ;; - nix) OS=$OPTARG ;; - *) fatal 'bad option' ;; - esac - done - - if [[ $OS = "arch" ]]; then - exec yay -Sy - elif [[ $OS = "nix" ]]; then - exec nixos-rebuild build --flake $HOME/Gits/NixOS/#nixos - PKGS=$(exec nix store diff-closures /run/current-system ./result) - fi -} - -main "$@" diff --git a/shell.qml b/shell.qml index c5c5b7f..f602e71 100644 --- a/shell.qml +++ b/shell.qml @@ -1,9 +1,10 @@ //@ pragma UseQApplication -//@ pragma Env QSG_RENDER_LOOP=threaded -// @ pragma Env QSG_RHI_BACKEND=vulkan -//@ pragma Env QSG_NO_VSYNC=1 -//@ pragma Env QS_NO_RELOAD_POPUP=1 -//@ pragma Env QT_SCALE_FACTOR_ROUNDING_POLICY=Round +//@ pragma DefaultEnv QSG_RENDER_LOOP=threaded +// @ 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 @@ -22,7 +23,7 @@ ShellRoot { settings.watchFiles: true - Windows { + Drawers { } Wallpaper {