diff --git a/.gersemirc b/.gersemirc new file mode 100644 index 0000000..3b565c1 --- /dev/null +++ b/.gersemirc @@ -0,0 +1,3 @@ +indent: tabs +list_expansion: favour-expansion +definitions: [Plugins/cmake/qml-module.cmake, Plugins/gersemi/qt-add-shaders.cmake] diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 462b867..3edbdac 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -51,7 +51,7 @@ CustomRect { if (internalChecked) return checkedRadius; if (isRound) - return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale); + return (height ?? implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale); return defaultRadius; } diff --git a/Components/CircularProgress.qml b/Components/CircularProgress.qml index 59b1bf5..3e85179 100644 --- a/Components/CircularProgress.qml +++ b/Components/CircularProgress.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Shapes +import ZShell import ZShell.Components import ZShell.Config import qs.Services @@ -9,21 +10,50 @@ import qs.Services Item { id: root - readonly property real arcRadius: (size - padding - strokeWidth * (1 + waveAmplitude * 2)) / 2 + // Ring geometry, quantized in the item's own device pixel space and never + // against its position: the parent layout places the item on the device + // grid, so these values cannot change when the item moves (no jitter), and + // the ring stays crisp wherever it lands. + // Device-pixel center of the ring: an even stroke width wants a pixel + // boundary, an odd one a pixel center. Floored, matching + // WavyLineRenderer::snapCenterDev, so the wave and this background arc + // always land on the same center even when the box's device size is odd. + readonly property real centerDev: Math.floor( + sizeDev / 2 - strokeParity / 2 + ) + strokeParity / 2 + readonly property real centerX: centerDev / dpr + readonly property real centerY: centerDev / dpr + // Whole device pixels, and one pixel of slack on the outer edge: the ring's + // antialias feather needs somewhere to fade, otherwise it is cut flat at + // the top/left/right/bottom of the item rect. + readonly property real arcRadius: Math.max( + 1, + Math.floor( + (sizeDev - Math.round(padding * dpr) - strokeDev * (1 + waveAmplitude * 2) - 2) / 2 + ) + ) / dpr property color bgColor: Colors.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 + readonly property real dpr: ZUtils.dprFor(root) property color fgColor: Colors.palette.m3primary - readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + readonly property real gapAngle: ((spacing + strokeLogical) / (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) + // The item's own size in whole device pixels. Never adjusted upwards: the + // texture is exactly this wide, so ring values derived from it cannot spill + // past the item's bounds. + readonly property real size: sizeDev / dpr + readonly property real sizeDev: Math.round(Math.min(width, height) * dpr) property int spacing: Tokens.spacing.small + readonly property real strokeDev: Math.max(1, Math.round(strokeWidth * dpr)) + readonly property real strokeLogical: strokeDev / dpr + readonly property real strokeParity: strokeDev % 2 property int startAngle: -90 property int strokeWidth: Tokens.padding.extraSmall property int sweepAngle: 360 - readonly property real thickness: strokeWidth * (1 + waveAmplitude) * 2 + readonly property real thickness: strokeLogical * (1 + waveAmplitude) * 2 property real value property alias waveAmplitude: wave.amplitudeMultiplier property alias waveDuration: waveProgAnim.duration @@ -43,7 +73,7 @@ Item { capStyle: ShapePath.RoundCap fillColor: "transparent" strokeColor: root.bgColor - strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.strokeWidth + strokeWidth: Math.max(1, Math.round(Math.min(1, remainingArc.sweepAngle) * root.strokeDev)) / root.dpr Behavior on strokeColor { CAnim { @@ -53,8 +83,8 @@ Item { PathAngleArc { id: remainingArc - centerX: root.size / 2 - centerY: root.size / 2 + centerX: root.centerX + centerY: root.centerY radiusX: root.arcRadius radiusY: root.arcRadius startAngle: root.startAngle + root.clampedVal * root.sweepAngle + root.gapAngle @@ -68,11 +98,12 @@ Item { amplitudeMultiplier: root.wavy ? 0.5 : 0 anchors.fill: parent - anchors.margins: -lineWidth * amplitudeMultiplier + anchors.margins: -Math.round(lineWidth * amplitudeMultiplier * root.dpr) / root.dpr color: root.fgColor frequency: 8 fullAngle: root.sweepAngle lineWidth: root.strokeWidth + objectName: "wave" pathType: WavyLine.Arc radius: root.arcRadius startAngle: root.startAngle @@ -103,8 +134,11 @@ Item { 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 + // Rounded in local device pixels: the dot's own size is a whole even + // device count, so an integral local position puts its center on the grid + // whatever the ring's angle, and moving the gauge moves the dot with it. + x: Math.round((root.centerX + root.arcRadius * Math.cos(root.dotAngleRad) - width / 2) * root.dpr) / root.dpr + y: Math.round((root.centerY + root.arcRadius * Math.sin(root.dotAngleRad) - height / 2) * root.dpr) / root.dpr sourceComponent: CustomRect { color: root.fgColor diff --git a/Components/CustomProgressBar.qml b/Components/CustomProgressBar.qml index c9666f5..65c4fac 100644 --- a/Components/CustomProgressBar.qml +++ b/Components/CustomProgressBar.qml @@ -96,9 +96,12 @@ ProgressBar { anchors.right: parent.right implicitWidth: parent.width - wave.implicitWidth - Tokens.spacing.extraSmall + anchors.top: parent.top + anchors.bottom: parent.bottom } Line { + id: dot property real implicitSize anchors.right: parent.right @@ -124,8 +127,7 @@ ProgressBar { anchors.left: parent.left Behavior on implicitWidth { - Anim { - } + Anim {} } Component.onCompleted: implicitWidth = Qt.binding(() => parent.width * root.visualPosition) @@ -197,7 +199,6 @@ ProgressBar { component Line: CustomRect { property point bounds - anchors.verticalCenter: parent.verticalCenter color: root.bgColor implicitHeight: parent.height implicitWidth: bounds.y - bounds.x @@ -222,8 +223,7 @@ ProgressBar { } } Behavior on color { - CAnim { - } + CAnim {} } Anim on waveProgress { duration: root.waveDuration diff --git a/Components/CustomSlider.qml b/Components/CustomSlider.qml index 5acd92b..dd3db9b 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -2,10 +2,10 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Templates -import ZShell.Components import ZShell -import qs.Components +import ZShell.Components import ZShell.Config +import qs.Components import qs.Services Slider { @@ -28,7 +28,7 @@ Slider { signal interaction(v: real) - implicitHeight: 12 + implicitHeight: ZUtils.snap(12, root) implicitWidth: 200 contentItem: Item { @@ -123,7 +123,7 @@ Slider { bottomRightRadius: Tokens.rounding.extraSmall / 2 color: root.fgColor implicitHeight: root.height - implicitWidth: root.filledWidth + implicitWidth: ZUtils.snap(root.filledWidth, root) radius: root.radius topRightRadius: Tokens.rounding.extraSmall / 2 } @@ -137,8 +137,8 @@ Slider { frequency: root.waveFrequency fullLength: root.width - handle.implicitWidth - handle.anchors.leftMargin implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth - implicitWidth: root.filledWidth - lineWidth: root.height * 0.7 + implicitWidth: ZUtils.snap(root.filledWidth, root) + lineWidth: ZUtils.snap(root.height * 0.7, root) startX: x Behavior on color { diff --git a/Components/IconButton.qml b/Components/IconButton.qml index 3bdfc48..bf46b3d 100644 --- a/Components/IconButton.qml +++ b/Components/IconButton.qml @@ -1,4 +1,5 @@ import QtQuick +import ZShell import ZShell.Config import qs.Services @@ -15,9 +16,9 @@ ButtonBase { const h = label.implicitHeight + padding * 2; if (h % 2 !== 0) return h + 1; - return h; + return ZUtils.snap(h, root); } - implicitWidth: implicitHeight + implicitWidth: ZUtils.snap(implicitHeight, root) inactiveColor: { if (!isToggle && type === IconButton.Filled) return Colors.palette.m3primary; diff --git a/Components/IconTextButton.qml b/Components/IconTextButton.qml index fb568f6..ed2b5d2 100644 --- a/Components/IconTextButton.qml +++ b/Components/IconTextButton.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Services @@ -34,10 +35,11 @@ ButtonBase { } verticalPadding: Tokens.padding.extraSmall - RowLayout { + SnapLayout { id: row anchors.centerIn: parent + spacing: Tokens.spacing.small MaterialIcon { diff --git a/Components/KineticWheelScroller.qml b/Components/KineticWheelScroller.qml index 483b421..b845b26 100644 --- a/Components/KineticWheelScroller.qml +++ b/Components/KineticWheelScroller.qml @@ -421,7 +421,6 @@ Item { var now = Date.now(); var isTouchpad = !isMouseWheel(wheel) && (wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0)); - console.log("wheel", wheel.angleDelta.y, wheel.pixelDelta.y, wheel.phase); if (!isTouchpad) { velocity = 0; lastWheelTimestamp = 0; diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml index c16a1a6..81a8954 100644 --- a/Components/LineGraph.qml +++ b/Components/LineGraph.qml @@ -1,6 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick +import QtCanvas2D +import ZShell Item { id: root @@ -36,54 +38,77 @@ Item { property real fillAlpha: 0.15 property real lineWidth: 2 property real markerDotRadius: 4 - property real labelFontSize: 10 + property real labelFontSize: 8 - Canvas { + Canvas2D { id: canvas anchors.fill: parent - renderStrategy: Canvas.Immediate + + alphaBlending: true + fillColor: "transparent" onWidthChanged: requestPaint() onHeightChanged: requestPaint() + function snapV(v) { + return ZUtils.snap(v, canvas); + } + + // A value is plottable only if it is a real number. Everything else - + // null, undefined, NaN, an unparsable string from the API - is a hole, so + // the series splits there. Non-finite coordinates must never reach the + // path: one NaN lineTo makes the canvas ignore the rest of the curve, and + // the fill then runs from whatever y it last had straight down. + function numeric(v) { + if (typeof v === "number") + return Number.isFinite(v) ? v : null; + if (typeof v === "string") { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; + } + function strokeCurve(ctx, pts) { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); - if (root.curveSmoothing && pts.length > 2) { - const k = 1 / 6; - const n = pts.length; + const dpr = ZUtils.dprFor(canvas); + for (let i = 0; i < pts.length - 1; i++) { + const p1 = pts[i]; + const p2 = pts[i + 1]; + const [c1x, c1y, c2x, c2y] = segmentControls(pts, i); - for (let i = 0; i < n - 1; i++) { - const p0 = pts[Math.max(i - 1, 0)]; - const p1 = pts[i]; - const p2 = pts[i + 1]; - const p3 = pts[Math.min(i + 2, n - 1)]; - - let c1x = p1.x + (p2.x - p0.x) * k; - let c1y = p1.y + (p2.y - p0.y) * k; - let c2x = p2.x - (p3.x - p1.x) * k; - let c2y = p2.y - (p3.y - p1.y) * k; - - const minY = Math.min(p1.y, p2.y); - const maxY = Math.max(p1.y, p2.y); - c1y = Math.max(minY, Math.min(maxY, c1y)); - c2y = Math.max(minY, Math.min(maxY, c2y)); - - ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); - } - } else { - for (let i = 0; i < pts.length - 1; i++) { - const p0 = pts[i]; - const p1 = pts[i + 1]; - const cx = (p0.x + p1.x) * 0.5; - ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); + const firstCol = Math.ceil(p1.x * dpr) + 1; + const lastCol = Math.floor(p2.x * dpr) - 1; + for (let col = firstCol; col <= lastCol; ++col) { + const x = col / dpr; + ctx.lineTo(x, bezierY(p1, c1x, c1y, c2x, c2y, p2, x)); } + + ctx.lineTo(p2.x, p2.y); } } - // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve + function bezierY(p1, c1x, c1y, c2x, c2y, p2, targetX) { + let tlo = 0; + let thi = 1; + for (let it = 0; it < 16; it++) { + const tm = (tlo + thi) / 2; + const u = 1 - tm; + const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; + if (x < targetX) + tlo = tm; + else + thi = tm; + } + + const t = (tlo + thi) / 2; + const u = 1 - t; + return u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; + } + function segmentControls(pts, i) { const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; @@ -109,7 +134,6 @@ Item { return [cx, p1.y, cx, p2.y]; } - // Point on the curve at a given x (x is monotonic per segment) function pointAtX(pts, targetX) { let j = pts.length - 2; for (let i = 0; i < pts.length - 1; i++) { @@ -138,10 +162,16 @@ Item { const t = (tlo + thi) / 2; const u = 1 - t; const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; - return { x: targetX, y }; + return { + x: targetX, + y + }; + } + + function cutPoint(pts, targetX) { + return pointAtX(pts, targetX); } - // Part of the curve left/right of a cut x, including the cut point function cutLeft(pts, targetX) { if (targetX <= pts[0].x) return []; @@ -158,7 +188,7 @@ Item { const left = pts.slice(0, j + 1); if (targetX > pts[j].x) - left.push(pointAtX(pts, targetX)); + left.push(cutPoint(pts, targetX)); return left; } @@ -184,17 +214,20 @@ Item { onPaint: { const ctx = getContext("2d"); + if (!ctx) + return; ctx.reset(); + ctx.clearRect(0, 0, width, height); const W = width; const H = height; if (W < 2 || H < 2) return; - const padLeft = root.labelFontSize * 3.4; - const padRight = 2; - const padTop = 2; - const padBottom = root.labelFontSize + 8; + const padLeft = root.labelFontSize * 5; + const padRight = 0; + const padTop = 0; + const padBottom = root.labelFontSize + 14; const plot = { x: padLeft, y: padTop, @@ -204,8 +237,6 @@ Item { if (plot.w < 2 || plot.h < 2) return; - // Auto-fit: NaN yMin/yMax derive from the data, snapped to the - // horizontal step grid, honoring minHorizontalLines let lo = root.yMin; let hi = root.yMax; const loAuto = Number.isNaN(lo); @@ -214,7 +245,8 @@ Item { if (loAuto || hiAuto) { let dmin = Infinity; let dmax = -Infinity; - for (const v of root.values) { + for (const value of root.values) { + const v = numeric(value); if (v == null) continue; if (v < dmin) @@ -256,7 +288,7 @@ Item { if (range <= 0 || root.xSteps < 1) return; - let series = root.values; + let series = root.values.map(v => numeric(v)); if (root.smoothing > 0 && series.length > 0) { const radius = Math.round(root.smoothing); const raw = series; @@ -279,11 +311,17 @@ Item { const xAt = i => plot.x + (i / root.xSteps) * plot.w; const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; - const crisp = v => Math.floor(v) + 0.5; - // 1. Grid lines + const dpr = ZUtils.dprFor(canvas); + const devW = w => Math.max(1, Math.round(w * dpr)); + const snapLine = (v, w) => { + const dw = devW(w); + return (Math.round(v * dpr - dw / 2) + dw / 2) / dpr; + }; + const gridW = devW(1) / dpr; + ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); - ctx.lineWidth = 1; + ctx.lineWidth = gridW; if (root.verticalLineStep > 0) { for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { @@ -292,7 +330,7 @@ Item { if (i === root.xSteps && !root.lastXLine) continue; - const x = crisp(xAt(i)); + const x = snapLine(xAt(i), 1); ctx.beginPath(); ctx.moveTo(x, plot.y); ctx.lineTo(x, H); @@ -300,12 +338,11 @@ Item { } } - // Lines extend down into yPaddingBottom, snapped to the grid const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); if (root.horizontalLineStep > 0) { for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { - const y = crisp(yAt(v)); + const y = snapLine(yAt(v), 1); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(plot.x + plot.w, y); @@ -313,9 +350,8 @@ Item { } } - // 2. Labels ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); - ctx.font = root.labelFontSize + "px sans-serif"; + ctx.font = root.labelFontSize + "pt 'sans-serif'"; ctx.textAlign = "left"; ctx.textBaseline = "top"; @@ -328,7 +364,7 @@ Item { const text = root.xLabel(i); if (text) - ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); + ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 8); } } @@ -341,7 +377,6 @@ Item { } } - // 3. Curve, split into segments at null gaps const segments = []; let current = null; for (let i = 0; i < series.length; i++) { @@ -352,7 +387,7 @@ Item { continue; } const pt = { - x: xAt(i), + x: snapV(xAt(i)), y: yAt(series[i]) }; if (!current) @@ -369,8 +404,6 @@ Item { const markerPx = hasMarker ? xAt(root.markerX) : 0; let gapPx = 0; if (hasMarker) { - // markerGap is measured from the dot edge, perpendicular to the - // curve; project onto x via the local curve slope let slope = 0; const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) @@ -382,7 +415,6 @@ Item { if (seg.length < 2) continue; - // Fill stays continuous under the marker ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); strokeCurve(ctx, seg); ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); @@ -390,7 +422,6 @@ Item { ctx.closePath(); ctx.fill(); - // Stroke has a gap around the marker const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; for (const pts of pieces) { @@ -398,24 +429,32 @@ Item { continue; ctx.strokeStyle = root.lineColor; - ctx.lineWidth = root.lineWidth; + ctx.lineWidth = ZUtils.snap(root.lineWidth, root); strokeCurve(ctx, pts); ctx.stroke(); } } - // 4. Marker: dashed vertical + dot if (root.markerX >= 0 && root.markerX <= root.xSteps) { const x = markerPx; - ctx.setLineDash([4, 4]); ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(crisp(x), plot.y); - ctx.lineTo(crisp(x), H); - ctx.stroke(); - ctx.setLineDash([]); + ctx.lineWidth = gridW; + const lineX = snapLine(x, 1); + let dashY = plot.y; + let dashOn = true; + + while (dashY < H) { + const dashEnd = Math.min(dashY + 4, H); + if (dashOn) { + ctx.beginPath(); + ctx.moveTo(lineX, snapV(dashY)); + ctx.lineTo(lineX, snapV(dashEnd)); + ctx.stroke(); + } + dashY = dashEnd; + dashOn = !dashOn; + } const i0 = Math.floor(root.markerX); const v0 = series[i0]; @@ -430,7 +469,7 @@ Item { if (y != null) { ctx.fillStyle = root.markerColor; ctx.beginPath(); - ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); + ctx.circle(snapV(x), y, root.markerDotRadius); ctx.fill(); } } diff --git a/Components/Menu.qml b/Components/Menu.qml index 441b4eb..30ee943 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import ZShell.Config +import ZShell.Components import qs.Drawers import qs.Services @@ -110,12 +111,14 @@ MouseArea { color: Colors.palette.m3surfaceContainerLow radius: parent.radius - ColumnLayout { + SnapLayout { id: column anchors.fill: parent anchors.margins: Tokens.padding.extraSmall + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical Repeater { id: repeater @@ -151,11 +154,12 @@ MouseArea { } } - RowLayout { + SnapLayout { id: menuOptionRow anchors.fill: parent anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small MaterialIcon { diff --git a/Components/Notifs/NotifActions.qml b/Components/Notifs/NotifActions.qml new file mode 100644 index 0000000..12533f2 --- /dev/null +++ b/Components/Notifs/NotifActions.qml @@ -0,0 +1,120 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Widgets +import ZShell.Components +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +ButtonRow { + id: root + + required property NotificationRow notif + required property color buttonColour + + readonly property color fillColour: notif.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : buttonColour + readonly property color buttonTextColour: notif.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant + + function invoke(action: var): void { + action.invoke(); + if (!notif.resident) + notif.dismiss(); + } + + spacing: Tokens.spacing.extraSmall + + IconButton { + isRound: true + shapeMorph: true + fillWidth: root.notif.actions.length === 0 + inactiveColor: root.fillColour + inactiveOnColor: root.buttonTextColour + icon: "close" + padding: Tokens.padding.extraSmall + onClicked: root.notif.dismiss() + } + + Repeater { + model: root.notif.actions + + delegate: root.notif.hasActionIcons ? iconAction : textAction + } + + IconButton { + isRound: true + shapeMorph: true + fillWidth: root.notif.actions.length === 0 + inactiveColor: root.fillColour + inactiveOnColor: root.buttonTextColour + icon: copyTimer.running ? "inventory" : "content_copy" + padding: Tokens.padding.extraSmall + onClicked: { + Quickshell.clipboardText = root.notif.body; + copyTimer.restart(); + } + label.animate: true + + Timer { + id: copyTimer + + interval: 3000 + } + } + + Component { + id: textAction + + TextButton { + required property var modelData + + isRound: true + shapeMorph: true + fillWidth: true + inactiveColor: root.fillColour + inactiveOnColor: root.buttonTextColour + text: modelData.text + onClicked: root.invoke(modelData) + + label.horizontalAlignment: Text.AlignHCenter + label.anchors.left: left + label.anchors.right: right + label.anchors.verticalCenter: verticalCenter + label.anchors.centerIn: undefined + label.anchors.margins: Tokens.padding.medium + label.elide: Text.ElideRight + } + } + + Component { + id: iconAction + + ButtonBase { + id: button + + required property var modelData + + isRound: true + shapeMorph: true + fillWidth: true + padding: Tokens.padding.extraSmall + inactiveColor: root.fillColour + inactiveOnColor: root.buttonTextColour + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + padding * 2 + onClicked: root.invoke(modelData) + + IconImage { + id: icon + + anchors.centerIn: parent + implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3) + asynchronous: true + source: Quickshell.iconPath(button.modelData.identifier) + } + } + } +} diff --git a/Components/Notifs/NotifAvatar.qml b/Components/Notifs/NotifAvatar.qml new file mode 100644 index 0000000..c3809af --- /dev/null +++ b/Components/Notifs/NotifAvatar.qml @@ -0,0 +1,201 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import Quickshell +import ZShell +import M3Shapes +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services +import Quickshell.Services.Notifications + +Item { + id: root + + required property var owner + required property string image + required property string icon + required property int urgency + required property string summary + required property color lowFillColour + property real progress: 0 + property string shownImage + property string shownIcon + + readonly property color fillColour: urgency === NotificationUrgency.Critical ? Colors.palette.m3primary : urgency === NotificationUrgency.Low ? lowFillColour : Colors.palette.m3secondaryContainer + readonly property color iconColour: urgency === NotificationUrgency.Critical ? Colors.palette.m3onPrimary : urgency === NotificationUrgency.Low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSecondaryContainer + + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + // Keeping the previously shown url avoids replaying the fade when a row + // swaps between its raw source and the cached blob. + onImageChanged: { + if (image && !owner.isSameImage(shownImage)) + shownImage = image; + } + onIconChanged: { + if (icon && !owner.isSameAppIcon(shownIcon)) + shownIcon = icon; + } + Component.onCompleted: { + shownImage = image; + shownIcon = icon; + } + + Component { + id: imageComp + + FadeImage { + source: Qt.resolvedUrl(root.shownImage) + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + fadeInLargeAnim: Anim.DefaultEffects + } + } + + Component { + id: appIconComp + + FadeIcon { + size: Math.round(Config.notifs.sizes.image * 0.6) + } + } + + Component { + id: materialIconComp + + MaterialIcon { + animate: true + text: Icons.getNotifIcon(root.summary, root.urgency) + color: root.iconColour + fontStyle: Tokens.font.icon.medium + } + } + + MaterialShape { + id: bgShape + + anchors.fill: parent + shape: contentLoader.sourceComponent === materialIconComp && root.urgency === NotificationUrgency.Critical ? MaterialShape.SoftBurst : MaterialShape.Circle + color: Qt.alpha(root.fillColour, 1) + opacity: root.fillColour.a + + Behavior on color { + CAnim {} + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Item { + anchors.fill: parent + + Loader { + id: contentLoader + + anchors.centerIn: parent + anchors.verticalCenterOffset: sourceComponent === materialIconComp ? 1 : 0 + asynchronous: true + sourceComponent: root.image ? imageComp : root.icon ? appIconComp : materialIconComp + } + } + + Loader { + asynchronous: true + anchors.right: parent.right + anchors.bottom: parent.bottom + active: opacity > 0 + opacity: root.icon && root.image ? 1 : 0 + + sourceComponent: CustomRect { + implicitWidth: Config.notifs.sizes.badge + implicitHeight: Config.notifs.sizes.badge + + color: root.fillColour + radius: Tokens.rounding.full + + FadeIcon { + anchors.centerIn: parent + size: Math.round(Config.notifs.sizes.badge * 0.6) + } + } + + Behavior on opacity { + Anim { + type: root.icon && root.image ? Anim.DefaultEffects : Anim.FastEffects + } + } + } + + Loader { + asynchronous: true + anchors.fill: parent + active: root.progress > 0 + + sourceComponent: Shape { + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: progressShape + + property real sweep: root.progress * 360 + property real spacing: root.urgency === NotificationUrgency.Critical ? Tokens.spacing.extraSmall : 0 + + capStyle: ShapePath.RoundCap + fillColor: "transparent" + strokeWidth: 2 + strokeColor: Colors.palette.m3primary + + PathPolyline { + function pointAt(angle: real): point { + const dist = bgShape.distanceAtAngle(angle) + progressShape.strokeWidth / 2 + progressShape.spacing; + const rad = angle * Math.PI / 180; + return Qt.point(root.width / 2 + dist * Math.sin(rad), root.height / 2 - dist * Math.cos(rad)); + } + + path: { + bgShape.morphProgress; // mapToItem-free dependency: re-run when the shape morphs + const points = []; + for (let angle = 0; angle < progressShape.sweep; angle += 3) + points.push(pointAt(angle)); + points.push(pointAt(progressShape.sweep)); + return points; + } + } + + Behavior on sweep { + Anim { + easing: Tokens.anim.emphasizedDecel + } + } + + Behavior on spacing { + Anim {} + } + + Behavior on strokeColor { + CAnim {} + } + } + } + } + + component FadeIcon: FadeImage { + id: fadeIcon + + required property int size + + width: size + height: size + source: Quickshell.iconPath(root.shownIcon) + fillMode: Image.PreserveAspectFit + fadeInLargeAnim: Anim.DefaultEffects + } +} diff --git a/Components/Notifs/NotifGroupCard.qml b/Components/Notifs/NotifGroupCard.qml new file mode 100644 index 0000000..4a7225d --- /dev/null +++ b/Components/Notifs/NotifGroupCard.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import ZShell.Components +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +CustomRect { + id: root + + required property NotificationGroup group + required property Flickable container + required property bool expanded + property bool expandable: true + property int baseSurface: 0 + property int baseLayer: baseSurface + + readonly property list notifs: [...group.activeNotifs] + readonly property bool single: notifs.length === 1 + readonly property alias rowPressed: notifList.rowPressed + readonly property list surfaces: [Colors.palette.m3surfaceContainer, Colors.palette.m3surfaceContainerHigh, Colors.palette.m3surfaceContainerHighest] + readonly property int nonAnimHeight: { + const headerHeight = header.implicitHeight + (expanded ? Tokens.spacing.extraSmall : 0); + const columnHeight = headerHeight + notifList.layoutHeight; + return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.medium * 2); + } + + signal requestExpand(expand: bool) + signal swiped(notif: NotificationRow) + signal linkActivated(notif: NotificationRow) + + function surface(offset: int, layer: int): color { + return Colors.layer(surfaces[Math.min(baseSurface + offset, surfaces.length - 1)], baseLayer + layer); + } + + Component.onCompleted: group.lock(this) + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: nonAnimHeight + + clip: true + radius: Tokens.rounding.large + color: group.urgency === NotificationUrgency.Critical && (single || !expanded) ? Colors.palette.m3secondaryContainer : surface(0, 1) + + Behavior on implicitHeight { + Anim {} + } + + SnapLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.medium + + objectName: "notif-content" + orientation: Qt.Horizontal + spacing: Tokens.spacing.medium + + NotifAvatar { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.topMargin: root.expanded && !root.single ? header.implicitHeight + Tokens.spacing.extraSmall + Tokens.padding.extraSmall : 0 + owner: root.expanded ? root.notifs[0] ?? root.group : root.group + image: root.expanded ? root.notifs[0]?.image ?? "" : root.group.image + icon: root.expanded ? root.notifs[0]?.appIcon ?? "" : root.group.appIcon + urgency: root.expanded ? root.notifs[0]?.urgency ?? NotificationUrgency.Normal : root.group.urgency + lowFillColour: root.surface(2, 2) + summary: root.notifs[0]?.summary ?? "" + progress: (root.notifs[0]?.progress ?? -1) / 100 + + Behavior on Layout.topMargin { + Anim {} + } + } + + Column { + Layout.fillWidth: true + spacing: root.expanded ? Tokens.spacing.extraSmall : 0 + + Behavior on spacing { + Anim {} + } + + NotifGroupHeader { + id: header + + anchors.left: parent.left + anchors.right: parent.right + appName: root.group.appName + single: root.single + timeStr: root.notifs[0]?.timeStr ?? "" + count: root.notifs.length + urgency: root.group.urgency + pillColour: root.surface(1, 2) + expandable: root.expandable + expanded: root.expanded + onToggle: root.requestExpand(!root.expanded) + } + + NotifGroupList { + id: notifList + + notifs: root.notifs + expanded: root.expanded + single: root.single + headerHeight: header.implicitHeight + headerTitleWidth: header.titleWidth + expandable: root.expandable + container: root.container + itemColour: root.surface(1, 2) + avatarColour: root.surface(2, 2) + buttonColour: root.surface(2, 3) + onRequestExpand: expand => root.requestExpand(expand) + onSwiped: notif => root.swiped(notif) + onLinkActivated: notif => root.linkActivated(notif) + } + } + } +} diff --git a/Components/Notifs/NotifGroupHeader.qml b/Components/Notifs/NotifGroupHeader.qml new file mode 100644 index 0000000..f5677c8 --- /dev/null +++ b/Components/Notifs/NotifGroupHeader.qml @@ -0,0 +1,156 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Services.Notifications +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property string appName + required property bool single + required property string timeStr + required property int count + required property int urgency + required property color pillColour + required property bool expandable + required property bool expanded + readonly property alias titleWidth: appName.width + + readonly property color expandBtnTextColour: urgency === NotificationUrgency.Critical && !single ? Colors.palette.m3onPrimary : Colors.palette.m3onSurfaceVariant + + signal toggle + + implicitHeight: Math.max(appName.implicitHeight, time.implicitHeight, pill.implicitHeight) + objectName: "notif-header" + + CustomText { + id: appName + + anchors.left: parent.left + anchors.right: time.left + anchors.rightMargin: Tokens.spacing.small + anchors.verticalCenter: parent.verticalCenter + + opacity: root.single && !root.expanded ? 0 : 1 + text: root.appName + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.body.small + elide: Text.ElideRight + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: time + + anchors.right: pill.left + anchors.rightMargin: Tokens.spacing.small + anchors.verticalCenter: parent.verticalCenter + + animate: true + text: root.timeStr + color: { + const col = root.urgency === NotificationUrgency.Critical && (root.single || !root.expanded) ? Colors.palette.m3secondary : Colors.palette.m3outline; + return root.expanded && !root.single ? Qt.alpha(col, 0) : col; + } + font: Tokens.font.body.small + } + + CustomRect { + id: pill + + property real horizontalPadding: root.single ? implicitHeight - expandIcon.implicitWidth : Tokens.padding.large + property real countSpan: root.single ? 0 : groupCount.implicitWidth + Tokens.spacing.extraSmall + property real iconInset: root.single ? 0 : Tokens.padding.extraSmall / 2 + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + enabled: root.expandable + // StateLayer inherits this through its parent.enabled binding, so a + // non-expandable group gets no hover, ripple, or cursor. + + implicitWidth: horizontalPadding + countSpan + (root.expandable ? expandIcon.implicitWidth : 0) + implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall + + color: { + const c = root.urgency === NotificationUrgency.Critical ? Colors.palette.m3primary : root.pillColour; + return root.single ? Qt.alpha(c, 0) : c; + } + radius: Tokens.rounding.full + + StateLayer { + color: root.expandBtnTextColour + onClicked: root.toggle() + } + + Item { + anchors.left: parent.left + anchors.leftMargin: pill.horizontalPadding / 2 + pill.iconInset + anchors.verticalCenter: parent.verticalCenter + + objectName: "notif-expand" + + implicitWidth: groupCount.implicitWidth + implicitHeight: groupCount.implicitHeight + opacity: root.single ? 0 : 1 + + CustomText { + id: groupCount + + animate: true + text: root.count + color: root.expandBtnTextColour + font: Tokens.font.body.small + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + MaterialIcon { + id: expandIcon + + anchors.right: parent.right + anchors.rightMargin: pill.horizontalPadding / 2 - pill.iconInset + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: root.expanded && !root.single ? -Tokens.padding.extraSmall / 2 : 0 + + visible: root.expandable + text: "expand_more" + color: root.expandBtnTextColour + rotation: root.expanded ? 180 : 0 + + Behavior on rotation { + Anim {} + } + + Behavior on anchors.verticalCenterOffset { + Anim {} + } + } + + Behavior on horizontalPadding { + Anim {} + } + + Behavior on countSpan { + Anim {} + } + + Behavior on iconInset { + Anim {} + } + } +} diff --git a/Components/Notifs/NotifGroupList.qml b/Components/Notifs/NotifGroupList.qml new file mode 100644 index 0000000..9c11ea6 --- /dev/null +++ b/Components/Notifs/NotifGroupList.qml @@ -0,0 +1,152 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import ZShell.Components +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +LazyListView { + id: root + + required property list notifs + required property bool expanded + required property bool expandable + required property bool single + required property Flickable container + property real headerHeight + property real headerTitleWidth + required property color itemColour + required property color avatarColour + required property color buttonColour + readonly property bool rowPressed: pressedRows > 0 + property int pressedRows + + signal requestExpand(expand: bool) + signal swiped(notif: NotificationRow) + signal linkActivated(notif: NotificationRow) + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: contentHeight + + spacing: Math.round(Tokens.spacing.extraSmall) + asynchronous: true + + readyDelay: 1 + cacheBuffer: 400 + removeDuration: Tokens.anim.durations.normal + + useCustomViewport: true + viewport: { + tWatcher.transform; // mapToItem is not reactive so use this to trigger updates + return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height); + } + + model: ScriptModel { + // Spread, not `as Array`: `notifs` is a list property, + // and reading it yields a list reference that ScriptModel cannot assign. + values: root.expanded ? [...root.notifs] : [...root.notifs].slice(0, Config.notifs.groupPreviewNum); + } + + delegate: Component { + NotifSwipeArea { + id: notif + + required property int index + required property NotificationRow modelData + + Component.onCompleted: modelData.ref(this) + Component.onDestruction: { + if (pressed) + root.pressedRows--; + } + + LazyListView.preferredHeight: LazyListView.removing ? 0 : notifInner.nonAnimHeight + LazyListView.visibleHeight: LazyListView.removing ? 0 : notifInner.implicitHeight + implicitHeight: notifInner.implicitHeight + + opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 + scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1 + + expanded: root.expanded + expandable: root.expandable + dragExpand: false + hoveringLink: !!notifInner.body?.hoveredLink + enabled: root.expandable && root.expanded && !LazyListView.removing + acceptedButtons: root.single ? Qt.NoButton : Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onPressedChanged: root.pressedRows += pressed ? 1 : -1 + onRequestExpand: expand => root.requestExpand(expand) + onSwiped: root.swiped(modelData) + onDismissed: modelData.dismiss() + + Connections { + function onClosedChanged(): void { + if (notif.modelData.closed) + closeAnim.start(); + } + + target: notif.modelData + } + + ParallelAnimation { + id: closeAnim + + Anim { + type: Anim.DefaultEffects + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "x" + to: notif.x >= 0 ? notif.width : -notif.width + } + } + + NotifItem { + id: notifInner + + anchors.fill: parent + modelData: notif.modelData + expanded: root.expanded + single: root.single + ready: notif.LazyListView.ready + headerHeight: root.headerHeight + headerTitleWidth: root.headerTitleWidth + showAvatar: notif.index > 0 + expandedColour: root.itemColour + avatarColour: root.avatarColour + buttonColour: root.buttonColour + onLinkActivated: root.linkActivated(notif.modelData) + } + + Behavior on y { + enabled: notif.LazyListView.ready + + Anim {} + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim {} + } + } + } + + TransformWatcher { + id: tWatcher + + a: root.container.contentItem + b: root + } +} diff --git a/Components/Notifs/NotifItem.qml b/Components/Notifs/NotifItem.qml new file mode 100644 index 0000000..3e6aa7f --- /dev/null +++ b/Components/Notifs/NotifItem.qml @@ -0,0 +1,287 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Notifications +import ZShell.Components +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +CustomRect { + id: root + + required property NotificationRow modelData + required property bool expanded + required property color expandedColour + required property color avatarColour + required property color buttonColour + property bool showAvatar: true + property bool single: false + property bool ready: true + property real headerHeight + property real headerTitleWidth + + readonly property bool critical: modelData.urgency === NotificationUrgency.Critical + readonly property bool low: modelData.urgency === NotificationUrgency.Low + readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null + readonly property real padding: single ? 0 : Tokens.padding.medium + readonly property real headerSummaryOffset: -(headerHeight + summaryHeightMetrics.height) / 2 + readonly property real inset: expanded ? padding : 0 + readonly property real summaryOffset: single && !expanded ? headerSummaryOffset : inset + readonly property real nonAnimHeight: { + if (!expanded) + return summaryHeightMetrics.height; + const contentHeight = summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + padding * 2; + return single ? contentHeight : Math.max(contentHeight, Config.notifs.sizes.image + Tokens.padding.extraSmall * 2); + } + + signal linkActivated + + implicitHeight: nonAnimHeight + + radius: Tokens.rounding.medium + color: { + const c = root.critical ? Colors.palette.m3secondaryContainer : root.expandedColour; + return expanded && !single ? c : Qt.alpha(c, 0); + } + + TextMetrics { + id: summaryHeightMetrics + + font: dummySummary.font + text: " " // Use this height to prevent weird characters from changing the line height + } + + TextMetrics { + id: summaryMetrics + + font: dummySummary.font + text: root.modelData.summary + elide: Text.ElideRight + elideWidth: root.single ? root.headerTitleWidth : root.width + } + + WrappedLoader { + id: avatar + + shouldBeActive: root.expanded && root.showAvatar + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Tokens.padding.extraSmall + anchors.leftMargin: -(Config.notifs.sizes.image + Tokens.spacing.medium) + + sourceComponent: NotifAvatar { + owner: root.modelData + image: root.modelData.image + icon: root.modelData.appIcon + urgency: root.modelData.urgency + lowFillColour: root.avatarColour + summary: root.modelData.summary + progress: root.modelData.progress / 100 + } + } + + WrappedLoader { + id: compactSummary + + shouldBeActive: !root.expanded + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: root.inset + anchors.topMargin: root.summaryOffset + + sourceComponent: CustomText { + animate: root.ready + text: summaryMetrics.elidedText + color: root.critical ? Colors.palette.m3onSecondaryContainer : root.low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSurface + } + + Behavior on anchors.leftMargin { + Anim {} + } + + Behavior on anchors.topMargin { + Anim {} + } + } + + CustomText { + id: dummySummary + + anchors.top: parent.top + anchors.left: parent.left + + visible: false + text: root.modelData.summary + } + + WrappedLoader { + id: compactBody + + shouldBeActive: !root.expanded + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root.inset + (root.single ? 0 : dummySummary.width + Tokens.spacing.small) + anchors.topMargin: root.single && root.expanded ? summary.implicitHeight + expandedContent.anchors.topMargin : root.inset + + sourceComponent: CustomText { + text: root.modelData.body.replace(/\n/g, " ") + color: root.critical ? Colors.palette.m3secondary : root.low ? Colors.palette.m3outline : Colors.palette.m3onSurfaceVariant + elide: Text.ElideRight + } + + Behavior on anchors.leftMargin { + Anim {} + } + + Behavior on anchors.topMargin { + Anim {} + } + } + + WrappedLoader { + id: summary + + shouldBeActive: root.expanded + anchors.top: parent.top + anchors.left: parent.left + anchors.leftMargin: root.inset + anchors.topMargin: root.summaryOffset + width: root.width - root.padding * 2 - timeStr.implicitWidth - Tokens.spacing.small + + sourceComponent: CustomText { + text: root.modelData.summary + color: root.critical ? Colors.palette.m3onSecondaryContainer : root.low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSurface + wrapMode: Text.WordWrap + } + + Behavior on anchors.leftMargin { + Anim {} + } + + Behavior on anchors.topMargin { + Anim {} + } + } + + WrappedLoader { + id: timeStr + + shouldBeActive: root.expanded && !root.single + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: root.inset + + sourceComponent: CustomText { + animate: true + text: root.modelData.timeStr + color: root.critical ? Colors.palette.m3secondary : Colors.palette.m3outline + font: Tokens.font.body.small + } + + Behavior on anchors.margins { + Anim {} + } + } + + WrappedLoader { + id: expandedContent + + shouldBeActive: root.expanded + anchors.top: summary.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: root.inset + anchors.topMargin: Tokens.spacing.extraSmall + + sourceComponent: ExpandedBody {} + + Behavior on anchors.margins { + Anim {} + } + } + + Behavior on implicitHeight { + Anim {} + } + + component ExpandedBody: SnapLayout { + readonly property alias body: bodyText + + orientation: Qt.Vertical + spacing: Tokens.spacing.medium + + CustomText { + id: bodyText + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") + color: root.critical ? Colors.palette.m3secondary : root.low ? Colors.palette.m3outline : Colors.palette.m3onSurfaceVariant + wrapMode: Text.WordWrap + + onLinkActivated: link => { + Qt.openUrlExternally(link); + root.linkActivated(); + } + } + + NotifActions { + Layout.fillWidth: true + notif: root.modelData + buttonColour: root.buttonColour + } + } + component WrappedLoader: Loader { + id: comp + + required property bool shouldBeActive + + active: false + opacity: 0 + + // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set + states: State { + name: "active" + when: comp.shouldBeActive + + PropertyChanges { + comp.opacity: 1 + comp.active: true + } + } + transitions: [ + Transition { + from: "" + to: "active" + + SequentialAnimation { + PropertyAction { + property: "active" + } + Anim { + type: Anim.DefaultEffects + property: "opacity" + } + } + }, + Transition { + from: "active" + to: "" + + SequentialAnimation { + Anim { + type: Anim.DefaultEffects + property: "opacity" + } + PropertyAction { + property: "active" + } + } + } + ] + } +} diff --git a/Components/Notifs/NotifSwipeArea.qml b/Components/Notifs/NotifSwipeArea.qml new file mode 100644 index 0000000..ab05c89 --- /dev/null +++ b/Components/Notifs/NotifSwipeArea.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Components + +MouseArea { + id: root + + required property bool expanded + property bool expandable: true + property bool dragExpand: expandable + property bool hoveringLink + property int startY + property real lastX + property real lastMoveTime + property real velocity + + signal swiped + signal dismissed + signal requestExpand(expand: bool) + + hoverEnabled: true + cursorShape: hoveringLink ? Qt.PointingHandCursor : pressedButtons === Qt.LeftButton ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: dragExpand + + drag.target: pressedButtons === Qt.LeftButton ? this : null + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + lastX = x; + lastMoveTime = Date.now(); + velocity = 0; + if (event.button === Qt.RightButton && expandable) + requestExpand(!expanded); + else if (event.button === Qt.MiddleButton) + dismissed(); + } + onPositionChanged: event => { + if (pressedButtons !== Qt.LeftButton) + return; + + const now = Date.now(); + const dt = now - lastMoveTime; + if (dt > 0) { + velocity = (velocity + (x - lastX) / dt * 1000) / 2; + lastX = x; + lastMoveTime = now; + } + + if (dragExpand) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + requestExpand(diffY > 0); + } + } + onReleased: { + const flung = Date.now() - lastMoveTime < 100 && Math.sign(velocity) === Math.sign(x) && Math.abs(velocity) > Config.notifs.clearVelocity; + if (!flung && Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + swiped(); + } + + Behavior on x { + Anim {} + } +} diff --git a/Components/SplitButton.qml b/Components/SplitButton.qml index 9f54950..5b0c0f8 100644 --- a/Components/SplitButton.qml +++ b/Components/SplitButton.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Services @@ -53,7 +54,7 @@ Row { onClicked: root.active?.clicked() } - RowLayout { + SnapLayout { id: textRow anchors.centerIn: parent diff --git a/Components/Toast/ToastItem.qml b/Components/Toast/ToastItem.qml index 254e20c..4c2feb0 100644 --- a/Components/Toast/ToastItem.qml +++ b/Components/Toast/ToastItem.qml @@ -1,4 +1,5 @@ import ZShell +import ZShell.Components import QtQuick import QtQuick.Layouts import qs.Components @@ -47,7 +48,7 @@ CustomRect { z: -1 } - RowLayout { + SnapLayout { id: layout anchors.fill: parent @@ -88,9 +89,11 @@ CustomRect { } } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: 0 + orientation: Qt.Vertical CustomText { id: title diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 483a923..db57449 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -16,7 +16,9 @@ Singleton { readonly property var appCooldownMap: new Map() property alias dnd: props.dnd + readonly property alias groups: store.groups property alias list: store.rows + readonly property alias popupGroups: store.popupGroups readonly property list notClosed: store.rows.filter(n => !n.closed) readonly property list popups: store.rows.filter(n => n.popup) diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index ac27301..523d9a1 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -4,12 +4,16 @@ import Quickshell import QtQuick import ZShell.Internal import qs.Components +import qs.Services Item { id: root readonly property alias content: contentLoader.item + required property ScreenState screenState + property rect holeRect + property real holeRadius: 0 Loader { id: contentLoader @@ -18,6 +22,9 @@ Item { anchors.fill: parent sourceComponent: StrokeCanvas { + anchors.fill: parent + hole: root.holeRect + holeRadius: root.holeRadius penColor: root.screenState.penColor penWidth: root.screenState.penWidth } diff --git a/Drawers/DrawingInput.qml b/Drawers/DrawingInput.qml new file mode 100644 index 0000000..1e7ece6 --- /dev/null +++ b/Drawers/DrawingInput.qml @@ -0,0 +1,95 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components + +CustomMouseArea { + id: root + + required property real borderThickness + required property Drawing drawing + required property Panels panels + required property ScreenState screenState + required property EdgeGeometry geometry + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = panel.y + geometry.insetTop(borderThickness); + return y >= panelY && y <= panelY + panel.height; + } + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.BlankCursor + enabled: z > 0 + hoverEnabled: true + focus: root.screenState.isDrawing + z: 2 + + Binding { + target: root.drawing.content + property: "hoverPoint" + value: Qt.point(root.mouseX, root.mouseY) + } + + Keys.onPressed: e => { + const mod = e.modifiers; + const key = e.key; + + if ((key == Qt.Key_Z) && (mod & Qt.ControlModifier) && (mod & Qt.ShiftModifier)) { + root.drawing.content.redo(); + e.accepted = true; + } else if ((key == Qt.Key_Z) && (mod & Qt.ControlModifier)) { + root.drawing.content.undoLastGroup(); + e.accepted = true; + } + } + + onPositionChanged: event => { + const x = event.x; + const y = event.y; + + if (!drawing.content.hoverVisible) + drawing.content.showHover(); + + if (event.buttons & Qt.LeftButton) { + drawing.content.appendPoint(x, y); + return; + } + + if (!(event.buttons & Qt.LeftButton) && inLeftPanel(panels.drawing, x, y)) { + z = -2; + panels.drawing.expand(); + drawing.content.hideHover(); + } + } + onPressed: event => { + const x = event.x; + const y = event.y; + + panels.drawing.collapse(); + + if (event.buttons & Qt.LeftButton) { + panels.drawing.collapse(); + drawing.content.beginStroke(x, y); + return; + } + + if (event.button & Qt.RightButton) { + drawing.content.clear(); + return; + } + } + onReleased: event => { + const x = event.x; + const y = event.y; + + if (event.button & Qt.LeftButton) { + drawing.content.endStroke(); + drawing.content.showHover(); + } + } +} diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 4e0d6a5..a0a0c6d 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -1,25 +1,33 @@ import Quickshell import QtQuick import ZShell.Config +import ZShell.Internal import qs.Components import qs.Helpers import qs.Modules.Bar.Popouts -Item { +CustomMouseArea { id: root + required property Drawing drawing + required property DrawingInput input + required property Item bar required property real borderThickness - property bool dashboardShortcutActive - required property Drawing drawing required property EdgeGeometry geometry - property bool osdShortcutActive + required property Panels panels required property Wrapper popouts + required property ShellScreen screen + required property ScreenState screenState + + property bool dashboardShortcutActive + property point dragStart + property bool osdShortcutActive + property bool setInitialPoint: false property bool singleGestureTriggered: false property bool utilitiesShortcutActive - required property ScreenState screenState function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -56,124 +64,116 @@ Item { return x >= panelX && x <= panelX + panel.width; } + acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons anchors.fill: parent + hoverEnabled: true - DragHandler { - id: singleHandler - - cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined - dragThreshold: 0 - enabled: !root.screenState.isDrawing - grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything - maximumPointCount: 1 - minimumPointCount: 1 - target: null - - onActiveChanged: { - if (!active) - root.singleGestureTriggered = false; - } - onCentroidChanged: { - const x = centroid.position.x; - const y = centroid.position.y; - const pressX = centroid.pressPosition.x; - const pressY = centroid.pressPosition.y; - const dragX = x - pressX; - const dragY = y - pressY; - - if (pressY >= root.screen.height - Config.bar.border && pressX > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) - root.screenState.launcher = true; - - if (root.singleGestureTriggered) - return; - - const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY); - - if (inDashboard && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { - if (dragY > Config.dashboard.dragThreshold) { - root.screenState.dashboard = true; - root.singleGestureTriggered = true; - } else if (dragY < -Config.dashboard.dragThreshold) { - root.screenState.dashboard = false; - root.singleGestureTriggered = true; - } + onContainsMouseChanged: { + if (!containsMouse) { + if (!osdShortcutActive) { + screenState.osd = false; + panels.osd.hovered = false; } - if (root.geometry.insetTop(root.borderThickness) > pressY && (Config.dashboard.showOnHover || !inDashboard)) { - if (dragY > 20) { - root.screenState.settings = true; - root.singleGestureTriggered = true; - } else if (dragY < -20) { - root.screenState.settings = false; - root.singleGestureTriggered = true; - } + if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { + popouts.hasCurrent = false; } - if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) - root.screenState.clipboard = true; + if (Config.bar.autoHide || bar.fullscreen) + bar.isHovered = false; - if (!Config.dock.hoverToReveal && pressY > root.screen.height - root.geometry.insetTop(root.borderThickness) && pressX > root.screen.width / 5 && !root.screenState.launcher) - if (dragY < -10) { - root.screenState.dock = true; - root.singleGestureTriggered = true; - } - - if (pressX > root.screen.width - Config.bar.border && pressY < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { - root.screenState.sidebar = true; - root.singleGestureTriggered = true; - } - - if (pressX >= root.screen.width - Config.bar.border && pressY > (root.screen.height / 2) && dragX < -20) { - Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); - root.singleGestureTriggered = true; - } - - if (pressX <= Config.bar.border && dragX > 20) { - Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); - root.singleGestureTriggered = true; - } + if (screenState.dashboard && Config.dashboard.showOnHover) + screenState.dashboard = false; } } + onPositionChanged: event => { + const x = event.x; + const y = event.y; + const dragX = x - dragStart.x; + const dragY = y - dragStart.y; - PointHandler { - id: drawingHandler + if (screenState.isDrawing && !inLeftPanel(panels.drawing, x, y)) { + input.z = 2; + panels.drawing.collapse(); + } - property bool setInitialPoint: false + if (root.bar.fullscreen && !root.bar.isHovered) + unhideTimer.start(); - acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; - onActiveChanged: { - if (!active) { - setInitialPoint = false; - root.drawing.content.endStroke(); - } else { - root.panels.drawing.collapse(); + const entryAt = bar.entryAt(geometry.axisPos(x, y)); + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow"); + + if (!dashboardShortcutActive) { + screenState.dashboard = showDashboard; + } else if (showDashboard) { + dashboardShortcutActive = false; + } + + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) + screenState.dashboard = true; + else if (dragY < -Config.dashboard.dragThreshold) + screenState.dashboard = false; + } + + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) { + if (dragY > 20) { + screenState.settings = true; + } else if (dragY < -20) { + screenState.settings = false; } } - 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 (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50) + screenState.clipboard = true; + + if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher) + if (dragY < -10) + screenState.dock = true; + + if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + + if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); + + if (pressed && dragStart.x <= Config.bar.border && dragX > 20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); + + if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true)) + bar.isHovered = true; + + if (panels.sidebar.offsetScale === 1) { + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; } - if (x === 0 && y === 0 && origX === 0 && origY === 0) - return; + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); - if (!setInitialPoint) { - setInitialPoint = true; - root.drawing.content.beginStroke(origX, origY); - return; + if (!osdShortcutActive) { + screenState.osd = showOsd; + panels.osd.hovered = showOsd; + } else if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; } - - root.drawing.content.appendPoint(x, y); } + + if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y)) + screenState.dock = true; + + if (geometry.barContains(x, y)) + bar.checkPopout(geometry.axisPos(x, y)); + } + onPressed: event => { + dragStart = Qt.point(event.x, event.y); } Timer { @@ -184,98 +184,20 @@ Item { running: false onTriggered: { - if (hoverHandler.hovered) + if (root.containsMouse) root.bar.isHovered = true; } } - HoverHandler { - id: hoverHandler - - cursorShape: root.screenState.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined - - onHoveredChanged: { - if (!hovered) { - if (!root.osdShortcutActive) { - root.screenState.osd = false; - root.panels.osd.hovered = false; - } - - if (!root.popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { - root.popouts.hasCurrent = false; - } - - if (Config.bar.autoHide || root.bar.fullscreen) - root.bar.isHovered = false; - - if (root.screenState.dashboard && Config.dashboard.showOnHover) - root.screenState.dashboard = false; - } - } - onPointChanged: { - const x = point.position.x; - const y = point.position.y; - - if (root.screenState.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.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) - root.bar.isHovered = true; - - if (root.bar.fullscreen && !root.bar.isHovered) - unhideTimer.start(); - - if (root.panels.sidebar.offsetScale === 1) { - const showOsd = root.inRightPanel(root.panels.osdWrapper, x, y); - - if (showOsd) { - root.osdShortcutActive = false; - root.panels.osd.hovered = true; - } - } else { - const outOfSidebar = x < root.width - root.panels.sidebar.width * (1 - root.panels.sidebar.offsetScale); - const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y); - - if (!root.osdShortcutActive) { - root.screenState.osd = showOsd; - root.panels.osd.hovered = showOsd; - } else if (showOsd) { - root.osdShortcutActive = false; - root.panels.osd.hovered = true; - } - } - - if (Config.dock.enable && Config.dock.hoverToReveal && !root.screenState.dock && !root.screenState.launcher && root.inBottomPanel(root.panels.dock, x, y)) - root.screenState.dock = true; - - const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, x, y); - - if (Config.dashboard.showOnHover) - root.screenState.dashboard = inDashboard; - - if (root.geometry.barContains(x, y)) - root.bar.checkPopout(root.geometry.axisPos(x, y)); - } - } - Connections { - function onDashboardChanged() { + function onDashboardChanged(): void { if (root.screenState.dashboard) { - const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY); if (!inDashboardArea) { root.dashboardShortcutActive = true; } - if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x) - root.screenState.launcher = false; + root.screenState.launcher = false; root.screenState.settings = false; root.screenState.sidebar = false; root.screenState.resources = false; @@ -285,12 +207,15 @@ Item { } } - function onIsDrawingChanged() { - if (!root.screenState.isDrawing) - root.drawing.clear(); + function onIsDrawingChanged(): void { + if (root.screenState.isDrawing) { + root.drawing.content.showHover(); + } else { + root.drawing.content.clear(); + } } - function onLauncherChanged() { + function onLauncherChanged(): void { if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; @@ -305,16 +230,13 @@ Item { } if (root.screenState.launcher) { - if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) { - root.screenState.dashboard = false; - } - + root.screenState.dashboard = false; root.screenState.dock = false; root.screenState.settings = false; } } - function onOsdChanged() { + function onOsdChanged(): void { if (root.screenState.osd) { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { @@ -325,7 +247,7 @@ Item { } } - function onResourcesChanged() { + function onResourcesChanged(): void { if (root.screenState.resources) { if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) root.popouts.hasCurrent = false; @@ -335,7 +257,7 @@ Item { } } - function onSettingsChanged() { + function onSettingsChanged(): void { if (root.screenState.settings) { root.screenState.resources = false; root.screenState.dashboard = false; @@ -345,9 +267,10 @@ Item { } } - function onSidebarChanged() { + function onSidebarChanged(): void { if (root.screenState.sidebar) { - root.screenState.dashboard = false; + if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x) + root.screenState.dashboard = false; root.screenState.settings = false; root.popouts.hasCurrent = false; } diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml deleted file mode 100644 index e11bf02..0000000 --- a/Drawers/Interactions2.qml +++ /dev/null @@ -1,315 +0,0 @@ -import Quickshell -import QtQuick -import ZShell.Config -import ZShell.Internal -import qs.Components -import qs.Helpers -import qs.Modules.Bar.Popouts - -CustomMouseArea { - id: root - - required property Item bar - required property real borderThickness - property bool dashboardShortcutActive - property point dragStart - required property Drawing drawing - required property EdgeGeometry geometry - property bool osdShortcutActive - required property Panels panels - required property Wrapper popouts - required property ShellScreen screen - required property ScreenState screenState - property bool setInitialPoint: false - property bool singleGestureTriggered: false - property bool utilitiesShortcutActive - - function inBottomPanel(panel: Item, x: real, y: real): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); - return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y); - } - - function inLeftPanel(panel: Item, x: real, y: real): bool { - return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); - } - - function inPopoutArea(x: real, y: real): bool { - const panel = panels.popoutsWrapper; - if (!geometry.horizontal) - return inLeftPanel(panel, x, y); - return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y); - } - - function inRightPanel(panel: Item, x: real, y: real): bool { - return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); - } - - function inTopPanel(panel: Item, x: real, y: real): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); - return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y); - } - - function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = panel.y + geometry.insetTop(borderThickness); - return y >= panelY && y <= panelY + panel.height; - } - - function withinPanelWidth(panel: Item, x: real, y: real): bool { - const panelX = panel.x + geometry.insetLeft(borderThickness); - return x >= panelX && x <= panelX + panel.width; - } - - acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons - anchors.fill: parent - hoverEnabled: true - - onContainsMouseChanged: { - if (!containsMouse) { - if (!osdShortcutActive) { - screenState.osd = false; - panels.osd.hovered = false; - } - - if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { - popouts.hasCurrent = false; - } - - if (Config.bar.autoHide || bar.fullscreen) - bar.isHovered = false; - - if (screenState.dashboard && Config.dashboard.showOnHover) - screenState.dashboard = false; - } - } - onPositionChanged: event => { - const x = event.x; - const y = event.y; - const dragX = x - dragStart.x; - const dragY = y - dragStart.y; - - if (screenState.isDrawing) { - if (pressed) { - drawing.content.appendPoint(x, y); - } else { - if (inLeftPanel(panels.drawing, x, y)) { - if (!(pressedButtons & Qt.LeftButton)) { - panels.drawing.expand(); - drawing.content.hideHover(); - Cursor.clearOverride(); - } - } else { - Cursor.setOverride(Qt.BlankCursor); - drawing.content.showHover(x, y); - } - } - if (drawing.content.hoverVisible) - drawing.content.showHover(x, y); - return; - } - - if (root.bar.fullscreen && !root.bar.isHovered) - unhideTimer.start(); - - if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) - screenState.launcher = true; - - const entryAt = bar.entryAt(geometry.axisPos(x, y)); - const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow"); - - if (!dashboardShortcutActive) { - screenState.dashboard = showDashboard; - } else if (showDashboard) { - dashboardShortcutActive = false; - } - - if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) { - if (dragY > Config.dashboard.dragThreshold) - screenState.dashboard = true; - else if (dragY < -Config.dashboard.dragThreshold) - screenState.dashboard = false; - } - - if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) { - if (dragY > 20) { - screenState.settings = true; - } else if (dragY < -20) { - screenState.settings = false; - } - } - - if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50) - screenState.clipboard = true; - - if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher) - if (dragY < -10) - screenState.dock = true; - - if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold) - screenState.sidebar = true; - - if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20) - Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); - - if (pressed && dragStart.x <= Config.bar.border && dragX > 20) - Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); - - if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true)) - bar.isHovered = true; - - if (panels.sidebar.offsetScale === 1) { - const showOsd = inRightPanel(panels.osdWrapper, x, y); - - if (showOsd) { - osdShortcutActive = false; - panels.osd.hovered = true; - } - } else { - const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); - const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); - - if (!osdShortcutActive) { - screenState.osd = showOsd; - panels.osd.hovered = showOsd; - } else if (showOsd) { - osdShortcutActive = false; - panels.osd.hovered = true; - } - } - - if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y)) - screenState.dock = true; - - if (geometry.barContains(x, y)) - bar.checkPopout(geometry.axisPos(x, y)); - } - onPressed: event => { - dragStart = Qt.point(event.x, event.y); - - if (screenState.isDrawing) { - if (event.button & Qt.RightButton) { - drawing.content.clear(); - return; - } - - if (!inLeftPanel(panels.drawing, mouseX, mouseY)) - drawing.content.beginStroke(event.x, event.y); - } - } - onPressedChanged: { - if (!pressed) { - if (screenState.isDrawing) { - setInitialPoint = false; - drawing.content.endStroke(); - if (!inLeftPanel(panels.drawing, mouseX, mouseY)) - drawing.content.showHover(mouseX, mouseY); - } - } else { - if (screenState.isDrawing) { - panels.drawing.collapse(); - } - } - } - - Timer { - id: unhideTimer - - interval: Config.bar.revealDelay - repeat: false - running: false - - onTriggered: { - if (root.containsMouse) - root.bar.isHovered = true; - } - } - - Connections { - function onDashboardChanged(): void { - if (root.screenState.dashboard) { - const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY); - if (!inDashboardArea) { - root.dashboardShortcutActive = true; - } - - root.screenState.launcher = false; - root.screenState.settings = false; - root.screenState.sidebar = false; - root.screenState.resources = false; - root.popouts.hasCurrent = false; - } else { - root.dashboardShortcutActive = false; - } - } - - function onIsDrawingChanged(): void { - if (root.screenState.isDrawing) { - Cursor.setOverride(Qt.BlankCursor); - } else { - Cursor.clearOverride(); - } - } - - function onLauncherChanged(): void { - if (!root.screenState.launcher) { - root.dashboardShortcutActive = false; - root.osdShortcutActive = false; - root.utilitiesShortcutActive = false; - - const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); - - if (!inOsdArea) { - root.screenState.osd = false; - root.panels.osd.hovered = false; - } - } - - if (root.screenState.launcher) { - root.screenState.dashboard = false; - root.screenState.dock = false; - root.screenState.settings = false; - } - } - - function onOsdChanged(): void { - if (root.screenState.osd) { - const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); - if (!inOsdArea) { - root.osdShortcutActive = true; - } - } else { - root.osdShortcutActive = false; - } - } - - function onResourcesChanged(): void { - if (root.screenState.resources) { - if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) - root.popouts.hasCurrent = false; - - root.screenState.settings = false; - root.screenState.dashboard = false; - } - } - - function onSettingsChanged(): void { - if (root.screenState.settings) { - root.screenState.resources = false; - root.screenState.dashboard = false; - root.screenState.sidebar = false; - root.panels.popouts.hasCurrent = false; - root.screenState.launcher = false; - } - } - - function onSidebarChanged(): void { - if (root.screenState.sidebar) { - if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x) - root.screenState.dashboard = false; - root.screenState.settings = false; - root.popouts.hasCurrent = false; - } - } - - target: root.screenState - } -} diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 46fcda3..eb11cfe 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -1,5 +1,7 @@ import Quickshell import QtQuick +import ZShell.Config +import ZShell import qs.Components import qs.Modules.Bar.Popouts as Popouts import qs.Modules.Notifications as Notifications @@ -14,7 +16,6 @@ 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 ZShell.Config Item { id: root @@ -73,8 +74,11 @@ Item { id: osdWrapper anchors.right: parent.right - anchors.rightMargin: sidebar.width * (1 - sidebar.offsetScale) - anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: (sidebar.width - 5) * (1 - sidebar.offsetScale) + + y: ZUtils.snap((parent.height - implicitHeight) / 2, root) + + // anchors.verticalCenter: parent.verticalCenter clip: sidebar.visible implicitHeight: osd.implicitHeight implicitWidth: osd.implicitWidth * (1 - osd.offsetScale) @@ -83,7 +87,7 @@ Item { id: osd anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + screen: root.screen screenState: root.screenState sidebarOrSessionVisible: sidebar.visible @@ -124,13 +128,15 @@ Item { panels: root screenState: root.screenState sidebarPanel: sidebar + topPadding: Math.max(root.anchors.topMargin, root.anchors.bottomMargin) } Launcher.Wrapper { id: launcher + x: ZUtils.snap((parent.width - implicitWidth) / 2, root) + anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen screenState: root.screenState @@ -150,8 +156,10 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + + x: ZUtils.snap((parent.width - implicitWidth) / 2, dashboardWrapper) + clip: offsetScale > 0 implicitHeight: dashboard.implicitHeight * (1 - offsetScale) implicitWidth: dashboard.implicitWidth @@ -176,7 +184,7 @@ Item { anchors.bottom: utilities.top anchors.right: parent.right - anchors.top: notifications.bottom + anchors.top: parent.top panels: root screenState: root.screenState } @@ -190,8 +198,11 @@ Item { Settings.Wrapper { id: settings - anchors.centerIn: parent - anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale + x: ZUtils.snap((parent.width - implicitWidth) / 2, settings) + y: ZUtils.snap((parent.height - implicitHeight) / 2 - (implicitHeight + 5 + (parent.height - implicitHeight) / 2) * offsetScale, settings) + + // anchors.centerIn: parent + // anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale panels: root screen: root.screen screenState: root.screenState diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 97f5b7b..d0a0212 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -2,16 +2,14 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls -import QtQuick.Effects import Quickshell -import Quickshell.Io import Quickshell.Wayland import Quickshell.Hyprland import ZShell.Blobs +import ZShell.Config import qs.Daemons import qs.Components import qs.Modules.Bar -import ZShell.Config import qs.Helpers import qs.Drawers import qs.Services @@ -56,7 +54,7 @@ CustomWindow { property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar || screenState.isDrawing ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" mask: screenState.isDrawing || screenState.screenshot ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) @@ -192,7 +190,7 @@ CustomWindow { anchors.fill: parent color: root.surfaceColor shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity)) - shadowRadius: 18 + shadowRadius: 10 smoothing: Config.bar.smoothing } @@ -248,7 +246,6 @@ CustomWindow { atRestOverride: panels.osd.offsetScale < 0.99 deformAmount: 0.1 - implicitHeight: panels.osd.height implicitWidth: panels.osd.width panel: panels.osdWrapper radius: 20 @@ -260,7 +257,7 @@ CustomWindow { atRestOverride: panel.implicitHeight > 0 panel: panels.notifications - radius: Tokens.rounding.large + radius: Tokens.rounding.large + Tokens.padding.small } PanelBg { @@ -308,7 +305,7 @@ CustomWindow { PanelBg { id: settingsBg - deformAmount: 0.03 + deformAmount: 0.01 panel: panels.settings radius: Tokens.rounding.largeIncreased + Tokens.padding.small } @@ -347,12 +344,31 @@ CustomWindow { Drawing { id: drawing + readonly property int extraWidth: drawingBg.radius * 2 + anchors.fill: parent + holeRect: Qt.rect(root.borderThickness + panels.drawing.x - extraWidth, panels.drawing.y + bar.implicitHeight, panels.drawing.implicitWidth + extraWidth, panels.drawing.implicitHeight) + holeRadius: drawingBg.radius screenState: root.screenState z: 2 } - Interactions2 { + LazyLoader { + id: lazyLoader + + activeAsync: root.screenState.isDrawing + + component: DrawingInput { + borderThickness: root.borderLayoutThickness + drawing: drawing + panels: panels + screenState: root.screenState + geometry: geometry + parent: root.contentItem + } + } + + Interactions { id: interactions anchors.fill: parent @@ -365,6 +381,8 @@ CustomWindow { popouts: panels.popouts screen: root.screen screenState: root.screenState + input: lazyLoader.item + z: 1 Panels { id: panels @@ -442,6 +460,7 @@ CustomWindow { parent: root.contentItem screen: root.screen screenState: root.screenState + z: 4 } } diff --git a/Greeter/Center.qml b/Greeter/Center.qml index 845b2fc..d7bb578 100644 --- a/Greeter/Center.qml +++ b/Greeter/Center.qml @@ -3,11 +3,12 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Components import qs.Helpers import qs.Services -ColumnLayout { +SnapLayout { id: root readonly property real centerScale: Math.min(1, screenHeight / 1440) @@ -18,9 +19,11 @@ ColumnLayout { Layout.fillHeight: true Layout.fillWidth: false Layout.preferredWidth: centerWidth + + orientation: Qt.Vertical spacing: Tokens.spacing.largeIncreased * 2 - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignHCenter spacing: Tokens.spacing.small @@ -116,7 +119,7 @@ ColumnLayout { } } - RowLayout { + SnapLayout { id: input anchors.fill: parent diff --git a/Greeter/Content.qml b/Greeter/Content.qml index 78104b1..094008b 100644 --- a/Greeter/Content.qml +++ b/Greeter/Content.qml @@ -1,11 +1,12 @@ import QtQuick import QtQuick.Layouts +import ZShell.Config +import ZShell.Components import qs.Components import qs.Helpers -import ZShell.Config import qs.Services -RowLayout { +SnapLayout { id: root required property var greeter diff --git a/Greeter/InputField.qml b/Greeter/InputField.qml index 89b46ab..9bb5744 100644 --- a/Greeter/InputField.qml +++ b/Greeter/InputField.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services diff --git a/Greeter/SessionDock.qml b/Greeter/SessionDock.qml index a1b2b72..d1b33e2 100644 --- a/Greeter/SessionDock.qml +++ b/Greeter/SessionDock.qml @@ -3,17 +3,20 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Components import qs.Services -ColumnLayout { +SnapLayout { id: root required property var greeter anchors.fill: parent anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.large + orientation: Qt.Vertical CustomText { Layout.fillWidth: true @@ -37,8 +40,9 @@ ColumnLayout { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { spacing: Tokens.spacing.largeIncreased + orientation: Qt.Vertical MaterialIcon { Layout.alignment: Qt.AlignHCenter @@ -84,7 +88,7 @@ ColumnLayout { } } - RowLayout { + SnapLayout { id: row anchors.fill: parent @@ -97,9 +101,11 @@ ColumnLayout { text: modelData.kind === "x11" ? "tv" : "desktop_windows" } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: Tokens.spacing.small / 2 + orientation: Qt.Vertical CustomText { Layout.fillWidth: true diff --git a/Greeter/UserDock.qml b/Greeter/UserDock.qml index d8d3085..db7a6e9 100644 --- a/Greeter/UserDock.qml +++ b/Greeter/UserDock.qml @@ -3,18 +3,21 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Components import qs.Helpers import qs.Services -ColumnLayout { +SnapLayout { id: root required property var greeter anchors.fill: parent anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.large + orientation: Qt.Vertical CustomText { Layout.fillWidth: true @@ -38,8 +41,9 @@ ColumnLayout { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { spacing: Tokens.spacing.largeIncreased + orientation: Qt.Vertical MaterialIcon { Layout.alignment: Qt.AlignHCenter @@ -86,7 +90,7 @@ ColumnLayout { } } - RowLayout { + SnapLayout { id: row anchors.fill: parent @@ -112,9 +116,11 @@ ColumnLayout { } } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: Tokens.spacing.small / 2 + orientation: Qt.Vertical CustomText { Layout.fillWidth: true diff --git a/Helpers/Picker/AnnotationToolbar.qml b/Helpers/Picker/AnnotationToolbar.qml index fb6b673..e9219de 100755 --- a/Helpers/Picker/AnnotationToolbar.qml +++ b/Helpers/Picker/AnnotationToolbar.qml @@ -53,7 +53,7 @@ CustomRect { radius: Tokens.rounding.full width: row.implicitWidth + Tokens.padding.small * 2 - RowLayout { + SnapLayout { id: row anchors.centerIn: parent diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 07ef70c..83a95c2 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,11 +4,13 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts +import ZShell -GridLayout { +SnapLayout { id: root required property bool fullscreen @@ -22,7 +24,7 @@ GridLayout { function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); - return horizontal ? Math.floor(c.x) : Math.floor(c.y); + return horizontal ? ZUtils.snap(c.x, item) : ZUtils.snap(c.y, item); } function checkPopout(pos: real): void { @@ -74,9 +76,47 @@ GridLayout { return ch?.entryId ?? ""; } - columnSpacing: Tokens.spacing.small - columns: horizontal ? -1 : 1 - rowSpacing: Tokens.spacing.small + readonly property var sectionRects: { + const count = repeater.count; // dependency: re-run when items are added/removed + const names = ["start", "middle", "end"]; // constant ids by position + const bounds = [null]; + let spacers = 0; + + for (let i = 0; i < count; i++) { + const it = repeater.itemAt(i) as EntryWrapper; + if (!it) + continue; + + if (it.entryId === "spacer") { + bounds.push(null); + spacers++; + continue; + } + + const l = it.x, t = it.y, r = it.x + it.width, b = it.y + it.height; + const cur = bounds[spacers]; + + bounds[spacers] = cur ? { + l: Math.min(cur.l, l), + t: Math.min(cur.t, t), + r: Math.max(cur.r, r), + b: Math.max(cur.b, b) + } : { + l, + t, + r, + b + }; + } + + return bounds.map((c, n) => ({ + id: names[n] ?? `section${n}`, + rect: c ? Qt.rect(c.l, c.t, c.r - c.l, c.b - c.t) : Qt.rect(0, 0, 0, 0) + })); + } + + spacing: Tokens.spacing.small + orientation: horizontal ? Qt.Horizontal : Qt.Vertical Repeater { id: repeater @@ -228,16 +268,26 @@ GridLayout { } component EntryWrapper: Item { + readonly property int section: { + let n = 0; + for (let i = 0; i < index; i++) { + if (repeater.model.values[i].id === "spacer") + n++; + } + return n; + } + readonly property string entryId: modelData.id required property int index - default property Item item required property var modelData + default property Item item + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter Layout.bottomMargin: !root.horizontal && index === repeater.count - 1 ? root.vPadding : 0 - Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && root.horizontal) ? -root.rowSpacing + Tokens.spacing.extraSmall : 0 + Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && root.horizontal) ? -root.spacing + Tokens.spacing.extraSmall : 0 Layout.rightMargin: root.horizontal && index === repeater.count - 1 ? root.vPadding : 0 - Layout.topMargin: !root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && !root.horizontal) ? -root.columnSpacing + Tokens.spacing.extraSmall : 0 + Layout.topMargin: !root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && !root.horizontal) ? -root.spacing + Tokens.spacing.extraSmall : 0 children: item implicitHeight: item?.implicitHeight ?? 0 implicitWidth: item?.implicitWidth ?? 0 diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 82ff8e8..248a555 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -9,6 +9,7 @@ import qs.Components Item { id: root + readonly property var sections: (content.item as Bar)?.sectionRects ?? [] readonly property int clampedExtent: Math.max(Config.bar.border, extent) readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.sizes.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentThickness diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 9d3c4a8..64d009b 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Modules.Bar.Popouts import qs.Modules.Bar.Components.Common import qs.Helpers @@ -43,7 +44,7 @@ WidgetBase { timeTokens: timeTokens }; } - required property GridLayout loader + required property SnapLayout loader required property Wrapper popouts readonly property int roundedWidth: Math.round(timeText.implicitWidth) readonly property int evenWidth: roundedWidth % 2 === 0 ? roundedWidth : roundedWidth + 1 @@ -69,7 +70,8 @@ WidgetBase { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: verticalColumn anchors.centerIn: parent diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 542dc0a..88b4ee1 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Bar.Components.Common diff --git a/Modules/Bar/Components/NetworkWidget.qml b/Modules/Bar/Components/NetworkWidget.qml index 53463ee..e69c888 100644 --- a/Modules/Bar/Components/NetworkWidget.qml +++ b/Modules/Bar/Components/NetworkWidget.qml @@ -1,6 +1,7 @@ import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Modules @@ -11,7 +12,7 @@ Item { anchors.top: parent.top implicitWidth: layout.implicitWidth - RowLayout { + SnapLayout { id: layout anchors.bottom: parent.bottom diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index 20b0816..5892391 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import qs.Components import ZShell.Config diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index c22435d..28fa27f 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import QtQuick.Layouts +import ZShell.Components import ZShell.Config import ZShell.Services import qs.Services diff --git a/Modules/Bar/Components/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml index 43e1d30..feb6db9 100644 --- a/Modules/Bar/Components/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Helpers import qs.Modules.Bar.Components.StatusIcons import qs.Modules.Bar.Components.Common @@ -59,7 +60,7 @@ WidgetBase { Anim {} } - GridLayout { + SnapLayout { id: grid anchors.bottomMargin: root.horizontal ? 0 : Tokens.padding.medium @@ -67,9 +68,8 @@ WidgetBase { anchors.leftMargin: root.horizontal ? Tokens.padding.extraSmall : 0 anchors.rightMargin: root.horizontal ? Tokens.padding.extraSmall : 0 anchors.topMargin: root.horizontal ? 0 : Tokens.padding.extraSmall - columns: root.horizontal ? -1 : 1 - columnSpacing: root.spacing - rowSpacing: root.spacing + orientation: root.horizontal ? Qt.Horizontal : Qt.Vertical + spacing: root.horizontal ? Tokens.spacing.extraSmall : -2 Repeater { model: ScriptModel { diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index d4fd37c..b9aeeb9 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Daemons import qs.Components diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index e31d696..a42dbc3 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Daemons import qs.Components diff --git a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml index 48c1b71..d94d1ca 100644 --- a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -1,6 +1,7 @@ import Quickshell.Services.UPower import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Modules.Bar.Components.Common import qs.Components diff --git a/Modules/Bar/Components/Tray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml index 453bef4..6913e7b 100644 --- a/Modules/Bar/Components/Tray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -2,10 +2,11 @@ import QtQuick.Layouts import QtQuick import Quickshell import Quickshell.Services.SystemTray +import ZShell.Config +import ZShell.Components import qs.Helpers import qs.Modules.Bar.Popouts import qs.Components -import ZShell.Config import qs.Services Item { @@ -15,7 +16,7 @@ Item { readonly property real dpr: Hypr.monitorFor(loader?.screen).scale required property int ind required property SystemTrayItem item - required property GridLayout loader + required property SnapLayout loader required property Wrapper popouts function resolveIcon(app: string, icon: string): string { diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index 242234f..0ab11f9 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import Quickshell.Services.SystemTray import ZShell.Config +import ZShell.Components import qs.Components import qs.Modules.Bar.Components.Common import qs.Modules.Bar.Components.Tray @@ -15,7 +16,7 @@ WidgetBase { readonly property alias items: repeater readonly property alias layout: sysGrid - required property GridLayout loader + required property SnapLayout loader readonly property int padding: Tokens.padding.extraSmall required property Wrapper popouts readonly property real size: horizontal ? sysGrid.implicitWidth + sysGrid.anchors.leftMargin + sysGrid.anchors.rightMargin : sysGrid.implicitHeight + sysGrid.anchors.topMargin + sysGrid.anchors.bottomMargin @@ -28,7 +29,7 @@ WidgetBase { radius: Tokens.rounding.full topRightRadius: horizontal ? Tokens.rounding.small / 2 : Tokens.rounding.full - GridLayout { + SnapLayout { id: sysGrid anchors.bottomMargin: root.horizontal ? 0 : Tokens.padding.extraSmall @@ -36,9 +37,8 @@ WidgetBase { anchors.leftMargin: root.horizontal ? Tokens.padding.extraSmall : 0 anchors.rightMargin: root.horizontal ? Tokens.padding.extraSmall : 0 anchors.topMargin: root.horizontal ? 0 : Tokens.padding.extraSmall - columns: root.horizontal ? -1 : 1 - rowSpacing: -2 - columnSpacing: Tokens.spacing.extraSmall + orientation: root.horizontal ? Qt.Horizontal : Qt.Vertical + spacing: root.horizontal ? Tokens.spacing.extraSmall : -2 Repeater { id: repeater diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml index a44c0de..101e87e 100644 --- a/Modules/Bar/Components/UpdatesWidget.qml +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Bar.Components.Common diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml index 7515093..3c3a0a8 100644 --- a/Modules/Bar/Components/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -17,7 +17,7 @@ Item { readonly property int maxSize: { const otherModules = bar.children.filter(c => c.enabled && c.entryId && c.item !== this && c.entryId !== "spacer"); const otherSize = otherModules.reduce((acc, curr) => acc + (horizontal ? (curr.item?.nonAnimWidth ?? curr.width ?? 0) : (curr.item.nonAnimHeight ?? curr.height ?? 0)), 0); - return horizontal ? (bar.width - otherSize - bar.columnSpacing * (bar.children.length - 1) - bar.vPadding * 2) : (bar.height - otherSize - bar.rowSpacing * (bar.children.length - 1) - bar.vPadding * 4); + return horizontal ? (bar.width - otherSize - bar.spacing * (bar.children.length - 1) - bar.vPadding * 2) : (bar.height - otherSize - bar.spacing * (bar.children.length - 1) - bar.vPadding * 4); } clip: true diff --git a/Modules/Bar/Popouts/AudioPopout.qml b/Modules/Bar/Popouts/AudioPopout.qml index 023bd68..5ec5f66 100644 --- a/Modules/Bar/Popouts/AudioPopout.qml +++ b/Modules/Bar/Popouts/AudioPopout.qml @@ -5,6 +5,7 @@ import Quickshell.Services.Pipewire import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import ZShell.Components import QtQuick.Controls import ZShell.Config import qs.Components @@ -46,7 +47,8 @@ Item { anchors.verticalCenter: parent.verticalCenter } - component VolumesTab: ColumnLayout { + component VolumesTab: SnapLayout { +orientation: Qt.Vertical spacing: 12 CustomRect { @@ -56,14 +58,15 @@ Item { color: Colors.tPalette.m3surfaceContainer radius: root.rounding - vol.anchors.margins - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.bottomMargin: Tokens.padding.small anchors.fill: parent anchors.leftMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small anchors.topMargin: Tokens.padding.small - RowLayout { + SnapLayout { Layout.fillHeight: true Layout.fillWidth: true @@ -81,7 +84,7 @@ Item { } } - RowLayout { + SnapLayout { spacing: Tokens.spacing.largeIncreased CustomMouseArea { @@ -153,14 +156,15 @@ Item { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.bottomMargin: Tokens.padding.small anchors.fill: parent anchors.leftMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small anchors.topMargin: Tokens.padding.small - RowLayout { + SnapLayout { Layout.fillHeight: true Layout.fillWidth: true @@ -178,7 +182,7 @@ Item { } } - RowLayout { + SnapLayout { spacing: Tokens.spacing.largeIncreased CustomMouseArea { @@ -276,14 +280,15 @@ Item { text: Audio.getStreamName(appBox.modelData) } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.bottomMargin: Tokens.padding.small anchors.fill: parent anchors.leftMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small anchors.topMargin: Tokens.padding.small - RowLayout { + SnapLayout { Layout.fillHeight: true Layout.fillWidth: true @@ -302,7 +307,7 @@ Item { } } - RowLayout { + SnapLayout { spacing: Tokens.spacing.largeIncreased CustomMouseArea { diff --git a/Modules/Bar/Popouts/ClipWrapper.qml b/Modules/Bar/Popouts/ClipWrapper.qml index b9471f5..078de91 100644 --- a/Modules/Bar/Popouts/ClipWrapper.qml +++ b/Modules/Bar/Popouts/ClipWrapper.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import qs.Components import ZShell.Config +import ZShell Item { id: root @@ -36,7 +37,7 @@ Item { const diff = parent.width - Math.floor(off + content.nonAnimWidth); if (diff < 0) return off + diff; - return Math.floor(Math.max(off, 0)); + return ZUtils.snap(Math.max(off, 0), root); } y: { if (horizontal) @@ -46,12 +47,11 @@ Item { const diff = parent.height - Math.floor(off + content.nonAnimHeight); if (diff < 0) return off + diff; - return Math.max(off, 0); + return ZUtils.snap(Math.max(off, 0), root); } Behavior on offsetScale { - Anim { - } + Anim {} } Behavior on x { enabled: !root.horizontal || root.offsetScale < 1 diff --git a/Modules/Bar/Popouts/Content.qml b/Modules/Bar/Popouts/Content.qml index 2ad7fa3..14e6885 100644 --- a/Modules/Bar/Popouts/Content.qml +++ b/Modules/Bar/Popouts/Content.qml @@ -13,6 +13,7 @@ Item { readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null required property bool horizontal required property PopoutState popouts + required property Wrapper wrapper implicitHeight: (currentPopout?.implicitHeight ?? 0) + Tokens.padding.smaller * 2 implicitWidth: (currentPopout?.implicitWidth ?? 0) + Tokens.padding.smaller * 2 @@ -98,10 +99,10 @@ Item { readonly property bool shouldBeActive: root.popouts.currentName === name active: false - anchors.centerIn: opacity === 1 ? undefined : parent + anchors.centerIn: parent anchors.horizontalCenter: parent.horizontalCenter - anchors.top: opacity === 1 ? parent.top : undefined - anchors.topMargin: Tokens.padding.smaller + // anchors.top: opacity === 1 ? parent.top : undefined + // anchors.topMargin: Tokens.padding.smaller opacity: 0 scale: 0.8 diff --git a/Modules/Bar/Popouts/NetworkPopout.qml b/Modules/Bar/Popouts/NetworkPopout.qml index f955d0f..79e2ab1 100644 --- a/Modules/Bar/Popouts/NetworkPopout.qml +++ b/Modules/Bar/Popouts/NetworkPopout.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Networking import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Modules @@ -14,7 +15,8 @@ Item { required property var wrapper - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout spacing: 8 diff --git a/Modules/Bar/Popouts/TrayMenuPopout.qml b/Modules/Bar/Popouts/TrayMenuPopout.qml index 4410c6f..9c43eb9 100644 --- a/Modules/Bar/Popouts/TrayMenuPopout.qml +++ b/Modules/Bar/Popouts/TrayMenuPopout.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import ZShell.Components import QtQuick.Controls import QtQuick.Effects import qs.Components @@ -15,7 +16,7 @@ StackView { id: root readonly property int itemHeight: 30 - readonly property int panelRadius: ((itemHeight / 2) + Tokens.padding.extraSmall) * Tokens.rounding.scale + readonly property int panelRadius: ((itemHeight / 2) + Tokens.padding.smaller) * Tokens.rounding.scale required property PopoutState popouts property int rootWidth: 0 required property QsMenuHandle trayItem @@ -26,20 +27,15 @@ StackView { initialItem: SubMenu { handle: root.trayItem } - popEnter: NoAnim { - } - popExit: NoAnim { - } - pushEnter: NoAnim { - } - pushExit: NoAnim { - } + popEnter: NoAnim {} + popExit: NoAnim {} + pushEnter: NoAnim {} + pushExit: NoAnim {} Component { id: subMenuComp - SubMenu { - } + SubMenu {} } component NoAnim: Transition { @@ -47,8 +43,9 @@ StackView { duration: 0 } } - component SubMenu: ColumnLayout { + component SubMenu: SnapLayout { id: menu + orientation: Qt.Vertical required property QsMenuHandle handle property bool isSubMenu @@ -59,12 +56,10 @@ StackView { spacing: Tokens.spacing.extraSmall Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Component.onCompleted: shown = true diff --git a/Modules/Bar/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml index 09c94e1..6fcbcbc 100644 --- a/Modules/Bar/Popouts/UPowerPopout.qml +++ b/Modules/Bar/Popouts/UPowerPopout.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell.Services.UPower import ZShell.Config import qs.Modules.Bar.Components.Common @@ -35,7 +36,7 @@ Column { Repeater { model: Battery.allPeripherals - RowLayout { + SnapLayout { id: layout required property var modelData diff --git a/Modules/Bar/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index 3217de7..54b6d3c 100644 --- a/Modules/Bar/Popouts/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import ZShell.Config import qs.Components @@ -86,7 +87,7 @@ CustomRect { implicitWidth: 600 radius: root.radius - Tokens.padding.extraSmall - RowLayout { + SnapLayout { anchors.fill: parent anchors.leftMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small @@ -96,7 +97,8 @@ CustomRect { text: "package_2" } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true CustomText { @@ -114,7 +116,7 @@ CustomRect { } } - RowLayout { + SnapLayout { Layout.fillHeight: true Layout.preferredWidth: 300 diff --git a/Modules/Bar/Popouts/Wrapper.qml b/Modules/Bar/Popouts/Wrapper.qml index 92f2833..23c64f4 100644 --- a/Modules/Bar/Popouts/Wrapper.qml +++ b/Modules/Bar/Popouts/Wrapper.qml @@ -2,6 +2,7 @@ import Quickshell import QtQuick import qs.Components import ZShell.Config +import ZShell Item { id: root @@ -26,8 +27,8 @@ Item { detachedMode = ""; } - implicitHeight: nonAnimHeight - implicitWidth: nonAnimWidth + implicitHeight: ZUtils.snap(nonAnimHeight, root) + implicitWidth: ZUtils.snap(nonAnimWidth, root) Behavior on implicitHeight { enabled: root.offsetScale < 1 @@ -57,6 +58,7 @@ Item { shouldBeActive: root.hasCurrent sourceComponent: Content { + wrapper: root horizontal: root.horizontal popouts: popoutState } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index e287e86..b77b1cc 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Helpers import ZShell.Config @@ -111,7 +112,7 @@ Item { Repeater { model: lineRow.modelData.text.split("\t").slice(1) - RowLayout { + SnapLayout { height: lineText.height width: lineText.height + Tokens.spacing.extraSmall @@ -225,7 +226,7 @@ Item { } } - RowLayout { + SnapLayout { anchors.fill: parent spacing: Tokens.spacing.small diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 69b0c2f..8e89eb7 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import ZShell import qs.Components @@ -39,8 +40,8 @@ Item { ]; return allTabs.filter(tab => tab.enabled); } - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin - readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: ZUtils.snap(tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin, root) + readonly property real nonAnimWidth: ZUtils.snap(view.implicitWidth + viewWrapper.anchors.margins * 2, root) required property ScreenState screenState implicitHeight: nonAnimHeight @@ -93,8 +94,8 @@ Item { } anchors.fill: parent - implicitHeight: currentItem?.implicitHeight ?? 0 - implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.height ?? currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.width ?? currentItem?.implicitWidth ?? 0 Repeater { id: repeater diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index 4f61d07..a63f4a9 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -1,104 +1,82 @@ import Quickshell import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Config +import ZShell.Components import qs.Helpers import qs.Components import qs.Modules -import ZShell.Config import qs.Modules.Dashboard.Dash import qs.Services -GridLayout { +SnapLayout { +orientation: Qt.Vertical id: root readonly property bool dashboardVisible: screenState.dashboard required property int radius required property ScreenState screenState - columnSpacing: Tokens.spacing.small - rowSpacing: Tokens.spacing.small + spacing: ZUtils.snap(Tokens.spacing.small, root) - ParallelAnimation { - id: openAnim + height: ZUtils.snap(implicitHeight, root) + width: ZUtils.snap(implicitWidth, root) - Anim { - property: "opacity" - target: root - to: 1 - } - - Anim { - property: "scale" - target: root - to: 1 - } - } - - ParallelAnimation { - id: closeAnim - - Anim { - property: "opacity" - target: root - to: 0 - } - - Anim { - property: "scale" - target: root - to: 0.9 - } - } - - Rect { - Layout.column: 2 - Layout.columnSpan: 3 - Layout.preferredHeight: user.implicitHeight - Layout.preferredWidth: user.implicitWidth - radius: root.radius - - User { - id: user - - screenState: root.screenState - } - } - - Rect { - Layout.columnSpan: 2 - Layout.fillHeight: true - Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth - Layout.row: 0 - radius: root.radius - - Weather {} - } - - Rect { - Layout.column: 0 - Layout.columnSpan: 3 + SnapLayout { Layout.fillWidth: true - Layout.preferredHeight: calendar.implicitHeight - Layout.row: 1 - radius: root.radius + spacing: parent.spacing - Calendar { - id: calendar + Rect { + id: weatherRect + Layout.fillHeight: true + Layout.preferredWidth: ZUtils.snap(Tokens.sizes.dashboard.weatherWidth, root) + radius: root.radius - screenState: root.screenState + Weather {} + } + + Rect { + id: userRect + Layout.preferredHeight: ZUtils.snap(user.implicitHeight, root) + Layout.preferredWidth: ZUtils.snap(user.implicitWidth, root) + radius: root.radius + + User { + id: user + + screenState: root.screenState + } } } - Rect { - Layout.column: 3 - Layout.columnSpan: 2 - Layout.fillHeight: true - Layout.preferredWidth: resources.implicitWidth - Layout.row: 1 - radius: root.radius + SnapLayout { + Layout.fillWidth: true + spacing: parent.spacing - Resources { - id: resources + Rect { + id: calendarRect + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: ZUtils.snap(calendar.implicitHeight, root) + radius: root.radius + + Calendar { + id: calendar + + screenState: root.screenState + } + } + + Rect { + id: resourcesRect + Layout.fillHeight: true + implicitWidth: ZUtils.snap(resources.implicitWidth, root) + radius: root.radius + + Resources { + id: resources + } } } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index 9d198ef..c1e2ea8 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -117,14 +118,15 @@ CustomMouseArea { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: inner anchors.fill: parent anchors.margins: Tokens.padding.large spacing: Tokens.spacing.extraSmall - RowLayout { + SnapLayout { id: monthNavigationRow Layout.fillWidth: true diff --git a/Modules/Dashboard/Dash/Resources.qml b/Modules/Dashboard/Dash/Resources.qml index 9b4a716..d73494f 100644 --- a/Modules/Dashboard/Dash/Resources.qml +++ b/Modules/Dashboard/Dash/Resources.qml @@ -1,5 +1,7 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell import ZShell.Services import ZShell.Config import qs.Components @@ -8,9 +10,8 @@ import qs.Services Item { id: root - anchors.bottom: parent.bottom - anchors.top: parent.top - implicitWidth: layout.implicitWidth + layout.anchors.margins * 4 + anchors.fill: parent + implicitWidth: ZUtils.snap(layout.implicitWidth + layout.anchors.margins * 2, root) ServiceRef { service: Storage @@ -28,13 +29,16 @@ Item { service: Gpu } - ColumnLayout { + SnapLayout { id: layout + orientation: Qt.Vertical anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Tokens.padding.large anchors.top: parent.top + + x: ZUtils.snap((root.width - implicitWidth) / 2, root) + spacing: Tokens.spacing.medium Resource { @@ -73,12 +77,10 @@ Item { required property string icon - Layout.fillHeight: true - implicitSize: height + implicitSize: (root.height - layout.anchors.margins * 2 - layout.spacing * 4) / 5 Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml index 311114d..b37054c 100644 --- a/Modules/Dashboard/Media.qml +++ b/Modules/Dashboard/Media.qml @@ -1,6 +1,8 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import M3Shapes +import ZShell import ZShell.Config import qs.Modules.Dashboard.Media import qs.Components @@ -13,8 +15,8 @@ Item { property ScreenState screenState required property int radius - implicitWidth: 1000 - implicitHeight: 320 + implicitWidth: ZUtils.snap(1000, root) + implicitHeight: ZUtils.snap(320, root) ClipRect { anchors.fill: parent @@ -25,7 +27,7 @@ Item { } } - RowLayout { + SnapLayout { anchors.fill: parent anchors.margins: Tokens.padding.large spacing: Tokens.spacing.extraLarge @@ -94,7 +96,8 @@ Item { active: opacity > 0 opacity: 0 - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { +orientation: Qt.Vertical spacing: Tokens.spacing.small MaterialShape { @@ -140,11 +143,12 @@ Item { asynchronous: true active: opacity > 0 - sourceComponent: RowLayout { + sourceComponent: SnapLayout { spacing: Tokens.spacing.extraLarge Details { Layout.fillWidth: true + Layout.fillHeight: true } LyricsAndSelector { diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml index 2d6c6c9..8b4490f 100644 --- a/Modules/Dashboard/Media/Details.qml +++ b/Modules/Dashboard/Media/Details.qml @@ -1,13 +1,14 @@ import QtQuick import QtQuick.Layouts import Quickshell.Services.Mpris +import ZShell import ZShell.Components import ZShell.Config import qs.Components import qs.Services import qs.Helpers -ColumnLayout { +Item { id: root readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 @@ -25,46 +26,187 @@ ColumnLayout { return `${mins}:${secs}`; } - spacing: Tokens.spacing.extraSmall + implicitHeight: ZUtils.snap(layout.implicitHeight, root) + implicitWidth: layout.implicitWidth - Timer { - running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: Players.active?.positionChanged() - } + SnapLayout { +orientation: Qt.Vertical + id: layout + anchors.left: parent.left + anchors.right: parent.right + y: ZUtils.snap((parent.height - implicitHeight) / 2, root) - CustomText { - Layout.fillWidth: true - text: Players.active?.trackTitle ?? "" - font: Tokens.font.title.large - elide: Text.ElideRight - animate: true - } + spacing: ZUtils.snap(Tokens.spacing.extraSmall, root) - CustomText { - Layout.fillWidth: true - text: Players.active?.trackArtist || qsTr("Unknown artist") - color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.title.medium - elide: Text.ElideRight - animate: true - } + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } - CustomText { - Layout.fillWidth: true - text: Players.active?.trackAlbum || qsTr("Unknown album") - color: Colors.palette.m3secondary - font: Tokens.font.title.medium - elide: Text.ElideRight - animate: true - } + CustomText { + id: title + Layout.fillWidth: true + Layout.preferredHeight: ZUtils.snapCeil(implicitHeight, root) + text: Players.active?.trackTitle ?? "" + font: Tokens.font.title.large + elide: Text.ElideRight + animate: true + } - RowLayout { - Layout.topMargin: Tokens.spacing.extraLargeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.small + CustomText { + id: artist + Layout.fillWidth: true + Layout.preferredHeight: ZUtils.snapCeil(implicitHeight, root) + text: Players.active?.trackArtist || qsTr("Unknown artist") + color: Colors.palette.m3onSurfaceVariant + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + CustomText { + id: album + Layout.fillWidth: true + Layout.preferredHeight: ZUtils.snapCeil(implicitHeight, root) + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colors.palette.m3secondary + font: Tokens.font.title.medium + elide: Text.ElideRight + animate: true + } + + SnapLayout { + id: sliderLayout + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: ZUtils.snap(Tokens.spacing.small, root) + + CustomText { + id: positionLabel + + Layout.preferredWidth: ZUtils.snapCeil(timeMetrics.width, root) + Layout.preferredHeight: positionSlider.implicitHeight + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + Layout.preferredHeight: ZUtils.snap(implicitHeight, root) + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: ZUtils.snapCeil(timeMetrics.width, root) + Layout.preferredHeight: positionSlider.implicitHeight + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal + icon: "skip_previous" + isRound: true + shapeMorph: true + font: Tokens.font.icon.large + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font: Tokens.font.icon.large + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font: Tokens.font.icon.large + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) + } + } TextMetrics { id: timeMetrics @@ -72,123 +214,5 @@ ColumnLayout { text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" font: Tokens.font.label.medium } - - CustomText { - id: positionLabel - - Layout.preferredWidth: timeMetrics.width - text: root.lengthStr(Players.active?.position ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - - CustomSlider { - id: positionSlider - - Layout.fillWidth: true - value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 - enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength - wavy: true - animateWave: Players.active?.isPlaying ?? false - waveFrequency: 5 - waveDuration: 2000 - interactionOnMove: false - onInteraction: value => { - const active = Players.active; - if (active?.canSeek && active?.positionSupported) - active.position = value * active.length; - } - - Binding { - target: positionLabel - property: "text" - value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) - when: positionSlider.dragging - } - } - - CustomText { - Layout.preferredWidth: timeMetrics.width - text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - } - - ButtonRow { - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - - IconButton { - type: IconButton.Tonal - icon: "shuffle" - isRound: true - shapeMorph: true - checked: Players.active?.shuffle ?? false - font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() - enabled: Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - implicitWidth: Math.round(implicitHeight * 0.9) - } - - IconButton { - id: previousBtn - - type: IconButton.Tonal - icon: "skip_previous" - isRound: true - shapeMorph: true - font: Tokens.font.icon.large - enabled: Players.active?.canGoPrevious - onClicked: Players.active?.previous() - } - - IconButton { - id: playPauseBtn - - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - isRound: true - shapeMorph: true - fillWidth: true - checked: Players.active?.isPlaying ?? false - font: Tokens.font.icon.large - enabled: Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - } - - IconButton { - id: nextBtn - - type: IconButton.Tonal - icon: "skip_next" - isRound: true - shapeMorph: true - font: Tokens.font.icon.large - enabled: Players.active?.canGoNext - onClicked: Players.active?.next() - } - - IconButton { - type: IconButton.Tonal - icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" - isRound: true - shapeMorph: true - checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() - enabled: Players.active?.loopSupported - onClicked: { - const state = Players.active.loopState; - if (state === MprisLoopState.None) - Players.active.loopState = MprisLoopState.Track; - else if (state === MprisLoopState.Track) - Players.active.loopState = MprisLoopState.Playlist; - else - Players.active.loopState = MprisLoopState.None; - } - implicitWidth: Math.round(implicitHeight * 0.9) - } } } diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml index cdec3dc..56b612b 100644 --- a/Modules/Dashboard/Media/LyricList.qml +++ b/Modules/Dashboard/Media/LyricList.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Effects import QtQuick.Layouts +import ZShell.Components import ZShell.Config import ZShell.Services import qs.Components @@ -157,7 +158,8 @@ Item { active: opacity > 0 opacity: 0 - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { +orientation: Qt.Vertical spacing: Tokens.spacing.large CustomRect { @@ -198,7 +200,8 @@ Item { active: opacity > 0 opacity: 0 - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { +orientation: Qt.Vertical spacing: Tokens.spacing.small MaterialIcon { diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml index 328cc5b..8abf990 100644 --- a/Modules/Dashboard/Media/LyricsAndSelector.qml +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Services.Mpris import ZShell.Config @@ -8,14 +9,15 @@ import qs.Helpers import qs.Services Item { - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.fill: parent anchors.leftMargin: Tokens.padding.medium spacing: Tokens.spacing.medium - RowLayout { + SnapLayout { Layout.bottomMargin: -Tokens.spacing.medium spacing: Tokens.spacing.medium z: 1 diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml index cc15f41..e36cad6 100644 --- a/Modules/Dashboard/Media/LyricsInfo.qml +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell import ZShell.Blobs import ZShell.Config @@ -16,7 +17,7 @@ Item { implicitWidth: btn.implicitWidth * 0.9 implicitHeight: btn.implicitHeight * 0.9 - BlobGroup { + BlobGroup2 { id: blobGroup color: Colors.palette.m3surfaceContainerHighest @@ -28,7 +29,7 @@ Item { } } - BlobRect { + BlobRect2 { id: btnRect anchors.fill: parent @@ -41,7 +42,7 @@ Item { } } - BlobRect { + BlobRect2 { id: rect anchors.right: parent.right @@ -141,8 +142,9 @@ Item { } ] - ColumnLayout { + SnapLayout { id: layout + orientation: Qt.Vertical anchors.centerIn: parent spacing: Tokens.spacing.extraSmall diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml index 307d4da..8182528 100644 --- a/Modules/Dashboard/Processes/ProcessesHeader.qml +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import ZShell.Config import ZShell.Internal diff --git a/Modules/Dashboard/Processes/ProcessesList.qml b/Modules/Dashboard/Processes/ProcessesList.qml index d64b047..48b441f 100644 --- a/Modules/Dashboard/Processes/ProcessesList.qml +++ b/Modules/Dashboard/Processes/ProcessesList.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import ZShell.Config @@ -38,7 +39,7 @@ CustomListView { id: rowHover } - RowLayout { + SnapLayout { id: rowLayout anchors.left: parent.left diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index afaedce..5c311e8 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -23,7 +23,7 @@ Item { service: Memory } - RowLayout { + SnapLayout { id: layout anchors.fill: parent spacing: Tokens.spacing.medium @@ -60,13 +60,14 @@ Item { Layout.fillHeight: true implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: mainLayout anchors.fill: parent anchors.margins: Tokens.padding.large spacing: 0 - RowLayout { + SnapLayout { spacing: Tokens.spacing.small MaterialIcon { @@ -131,7 +132,8 @@ Item { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: infoColumn anchors.right: parent.right anchors.bottom: parent.bottom diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index 56234fa..c2a1aef 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -4,6 +4,8 @@ import QtQuick import QtQuick.Layouts import QtQuick.Templates import Quickshell +import ZShell +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -16,9 +18,17 @@ Item { required property var tabs readonly property alias count: bar.count - readonly property int tabIndicatorSpacing: Tokens.spacing.small + readonly property int tabIndicatorSpacing: ZUtils.snap(Tokens.spacing.small, root) - implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + function snapLayoutHeight(v) { + const dpr = ZUtils.dprFor(root); + let h = Math.ceil(v); + while (Math.abs(h * dpr - Math.round(h * dpr)) > 1e-6) + ++h; + return h; + } + + implicitHeight: ZUtils.snap(bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight, root) TabBar { id: bar @@ -31,9 +41,11 @@ Item { currentIndex: root.screenState.dashboardTab onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex - implicitHeight: contentHeight + topPadding: 0 + bottomPadding: 0 + implicitHeight: implicitContentHeight background: null - contentItem: RowLayout { + contentItem: SnapLayout { spacing: 0 Repeater { @@ -116,10 +128,13 @@ Item { readonly property bool current: TabBar.tabBar.currentItem === this Layout.fillWidth: true - Layout.preferredWidth: 1 // Uniform width across all tabs + Layout.fillHeight: true + Layout.preferredWidth: 1 implicitWidth: implicitContentWidth implicitHeight: implicitContentHeight background: null + topPadding: 0 + bottomPadding: 0 contentItem: CustomMouseArea { id: mouse @@ -131,8 +146,8 @@ Item { root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); } - implicitWidth: Math.max(icon.width, label.width) - implicitHeight: icon.height + label.height + implicitWidth: Math.max(icon.implicitWidth, label.implicitWidth) + implicitHeight: snapLayout.implicitHeight hoverEnabled: true cursorShape: Qt.PointingHandCursor @@ -146,39 +161,49 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - implicitHeight: parent.height + root.tabIndicatorSpacing * 2 + anchors.alignWhenCentered: false + implicitHeight: ZUtils.snap(parent.height + Tokens.sizes.dashboard.tabIndicatorSpacing * 2, stateLayer) radius: Tokens.rounding.medium color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - onClicked: root.screenState.dashboardTab = tab.TabBar.index - } - - MaterialIcon { - id: icon - - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: label.top - - text: tab.iconName - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant - fill: tab.current ? 1 : 0 - fontStyle: Tokens.font.icon.medium - - Behavior on fill { - Anim { - type: Anim.DefaultEffects - } + onClicked: { + root.screenState.dashboardTab = tab.TabBar.index; } } - CustomText { - id: label + SnapLayout { + id: snapLayout + anchors.fill: parent + spacing: 0 + orientation: Qt.Vertical - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom + MaterialIcon { + id: icon - text: tab.text - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + text: tab.iconName + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + fontStyle: Tokens.font.icon.medium + + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + + Behavior on fill { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: label + + text: tab.text + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + } } } } diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml index f0ac43d..ca33e80 100644 --- a/Modules/Dashboard/Weather/Graphs.qml +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -1,11 +1,13 @@ import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Components import ZShell.Config import qs.Components import qs.Services import qs.Helpers -RowLayout { +SnapLayout { id: root spacing: Tokens.spacing.largeIncreased @@ -20,7 +22,7 @@ RowLayout { readonly property real markerX: { const d = root.now; - return d.getHours() + d.getMinutes() / 60; + return ZUtils.snap(d.getHours() + d.getMinutes() / 60, root); } function formatHour(h) { @@ -39,11 +41,13 @@ RowLayout { onTriggered: root.now = new Date() } - ColumnLayout { + SnapLayout { + orientation: Qt.Vertical Layout.fillWidth: true + Layout.fillHeight: true Layout.preferredWidth: root.width / 2 - RowLayout { + SnapLayout { Layout.fillWidth: true MaterialIcon { @@ -67,11 +71,11 @@ RowLayout { id: tempGraph anchors.fill: parent - values: root.forecastToday.map(e => e.tempC) + values: root.forecastToday.map(e => e?.tempC ?? null) verticalLineStep: 3 xSteps: 24 horizontalLineStep: 10 - minHorizontalLines: 4 + minHorizontalLines: 3 yPaddingTop: horizontalLineStep yPaddingBottom: horizontalLineStep xLabel: i => root.formatHour(i) @@ -90,11 +94,13 @@ RowLayout { } } - ColumnLayout { + SnapLayout { + orientation: Qt.Vertical Layout.fillWidth: true + Layout.fillHeight: true Layout.preferredWidth: root.width / 2 - RowLayout { + SnapLayout { Layout.fillWidth: true MaterialIcon { @@ -126,7 +132,7 @@ RowLayout { yPaddingTop: 20 verticalLineStep: 3 lastXLine: false - horizontalLineStep: 20 + horizontalLineStep: 30 xLabel: i => root.formatHour(i) yLabel: v => v + "%" diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml index 7069abb..b40592f 100644 --- a/Modules/Dashboard/WeatherTab.qml +++ b/Modules/Dashboard/WeatherTab.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell.Components import qs.Components import qs.Modules.Dashboard.Weather import qs.Services @@ -18,13 +19,14 @@ Item { implicitHeight: layout.implicitHeight Component.onCompleted: Weather.reload() - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.fill: parent spacing: Tokens.spacing.medium - RowLayout { + SnapLayout { Layout.leftMargin: Tokens.padding.large Layout.rightMargin: Tokens.padding.large Layout.fillWidth: true @@ -82,7 +84,7 @@ Item { } } - RowLayout { + SnapLayout { Layout.fillWidth: true spacing: Tokens.spacing.medium @@ -121,7 +123,9 @@ Item { color: Colors.palette.m3onSurface } - RowLayout { + SnapLayout { + id: forecastLayout + Layout.fillWidth: true spacing: Tokens.spacing.medium @@ -137,12 +141,14 @@ Item { required property var modelData Layout.fillWidth: true + Layout.fillHeight: true implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 radius: root.radius color: Colors.tPalette.m3surfaceContainer - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: forecastItemColumn anchors.centerIn: parent @@ -196,7 +202,8 @@ Item { property color colour Layout.fillWidth: true - Layout.preferredHeight: 60 + Layout.fillHeight: true + implicitHeight: 60 radius: Tokens.rounding.medium color: Colors.tPalette.m3surfaceContainer @@ -230,7 +237,7 @@ Item { } } - component WeatherStat: RowLayout { + component WeatherStat: SnapLayout { id: weatherStat property string icon diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 92a3347..05b7a38 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import Quickshell.Hyprland @@ -43,7 +44,8 @@ Item { } opacity: root.visible ? 1 : 0 - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: menuLayout anchors.centerIn: parent spacing: 0 @@ -53,7 +55,7 @@ Item { radius: popupBackground.radius - popupBackground.padding implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { + SnapLayout { id: openTerminalRow spacing: 8 anchors.fill: parent @@ -92,7 +94,7 @@ Item { radius: popupBackground.radius - popupBackground.padding implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { + SnapLayout { id: settingsRow spacing: 8 anchors.fill: parent @@ -132,7 +134,7 @@ Item { radius: popupBackground.radius - popupBackground.padding implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { + SnapLayout { id: logoutRow spacing: 8 anchors.fill: parent @@ -171,7 +173,7 @@ Item { // radius: popupBackground.radius - popupBackground.padding // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 // - // RowLayout { + // SnapLayout { // id: desktopIconsRow // spacing: 8 // anchors.fill: parent diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml index ea043f1..7a5c6b7 100644 --- a/Modules/DesktopIcons/DesktopIconContextMenu.qml +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -3,6 +3,7 @@ import QtQuick.Layouts import Quickshell import Quickshell.Widgets import qs.Components +import ZShell.Components import ZShell.Config import qs.Services @@ -57,22 +58,25 @@ Item { Anim {} } - ColumnLayout { + SnapLayout { id: menuLayout anchors.centerIn: parent + spacing: 0 + orientation: Qt.Vertical CustomRect { Layout.preferredWidth: 160 implicitHeight: openRow.implicitHeight + Tokens.padding.extraSmall * 2 radius: popupBackground.radius - popupBackground.padding - RowLayout { + SnapLayout { id: openRow anchors.fill: parent anchors.leftMargin: Tokens.padding.small + spacing: 8 MaterialIcon { @@ -111,11 +115,12 @@ Item { implicitHeight: openWithRow.implicitHeight + Tokens.padding.extraSmall * 2 radius: popupBackground.radius - popupBackground.padding - RowLayout { + SnapLayout { id: openWithRow anchors.fill: parent anchors.leftMargin: Tokens.padding.small + spacing: 8 MaterialIcon { @@ -156,11 +161,12 @@ Item { implicitHeight: copyPathRow.implicitHeight + Tokens.padding.extraSmall * 2 radius: popupBackground.radius - popupBackground.padding - RowLayout { + SnapLayout { id: copyPathRow anchors.fill: parent anchors.leftMargin: Tokens.padding.small + spacing: 8 MaterialIcon { @@ -190,11 +196,12 @@ Item { radius: popupBackground.radius - popupBackground.padding visible: contextMenu.targetPaths.length === 1 - RowLayout { + SnapLayout { id: renameRow anchors.fill: parent anchors.leftMargin: Tokens.padding.small + spacing: 8 MaterialIcon { @@ -231,11 +238,12 @@ Item { implicitHeight: deleteRow.implicitHeight + Tokens.padding.extraSmall * 2 radius: popupBackground.radius - popupBackground.padding - RowLayout { + SnapLayout { id: deleteRow anchors.fill: parent anchors.leftMargin: Tokens.padding.small + spacing: 8 MaterialIcon { diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index d6ca2f0..3a5db6d 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Widgets import qs.Components import qs.Helpers +import ZShell.Components import ZShell.Config import qs.Services @@ -29,7 +30,10 @@ CustomRect { active: !isSeparator anchors.centerIn: parent - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + IconImage { id: icon @@ -38,8 +42,9 @@ CustomRect { source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel?.appId), "image-missing") } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignHCenter + spacing: 3 Repeater { diff --git a/Modules/Dock/Parts/DockSeparator.qml b/Modules/Dock/Parts/DockSeparator.qml index fac6f04..b13870f 100644 --- a/Modules/Dock/Parts/DockSeparator.qml +++ b/Modules/Dock/Parts/DockSeparator.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 5ff38f6..50712f2 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import ZShell import ZShell.Config import qs.Components import qs.Services @@ -52,7 +53,7 @@ CustomListView { highlightFollowsCurrentItem: false highlightRangeMode: ListView.ApplyRange - implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing + implicitHeight: (Tokens.sizes.launcher.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing orientation: Qt.Vertical preferredHighlightBegin: 0 preferredHighlightEnd: height @@ -97,6 +98,7 @@ CustomListView { } } highlight: CustomRect { + id: hl color: Colors.palette.m3onSurface implicitHeight: root.currentItem?.implicitHeight ?? 0 implicitWidth: root.width diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index 3db4299..ecb5d10 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -22,7 +22,7 @@ CarouselView { delegateProperties: ({ screenState: root.screenState }) - focalWidth: Config.launcher.sizes.wallpaperWidth + focalWidth: Tokens.sizes.launcher.wallpaperWidth glideDuration: 250 glideEasingType: Easing.OutCubic implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.small @@ -41,8 +41,7 @@ CarouselView { tileHeight: focalWidth / 16 * 9 delegate: Component { - WallpaperTile { - } + WallpaperTile {} } Component.onDestruction: Wallpapers.stopPreview() diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 4db1508..9e86c49 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -2,17 +2,18 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import ZShell +import ZShell.Config import qs.Modules.Launcher.Services import qs.Components import qs.Helpers -import ZShell.Config import qs.Services Item { id: root required property real maxHeight - readonly property int padding: Tokens.padding.largeIncreased / 4 + readonly property int padding: ZUtils.snap(Tokens.padding.medium / 2, root) required property var panels readonly property int rounding: Tokens.rounding.largeIncreased required property ScreenState screenState diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 54093de..48f1919 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -2,11 +2,12 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import ZShell +import ZShell.Config import qs.Paths import qs.Modules import qs.Components import qs.Helpers -import ZShell.Config import qs.Services Item { @@ -71,7 +72,7 @@ Item { PropertyChanges { appList.active: true root.implicitHeight: Math.min(root.maxHeight, appList.implicitHeight > 0 ? appList.implicitHeight : empty.implicitHeight) - root.implicitWidth: Config.launcher.sizes.itemWidth + root.implicitWidth: Tokens.sizes.launcher.itemWidth } AnchorChanges { @@ -83,8 +84,8 @@ Item { name: "wallpapers" PropertyChanges { - root.implicitHeight: Config.launcher.sizes.wallpaperHeight - root.implicitWidth: Math.max(Config.launcher.sizes.itemWidth * 1.2, wallpaperList.implicitWidth) + root.implicitHeight: Tokens.sizes.launcher.wallpaperHeight + root.implicitWidth: Math.max(Tokens.sizes.launcher.itemWidth * 1.2, wallpaperList.implicitWidth) wallpaperList.active: true } } diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index 30f0004..774e7bc 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -13,7 +13,7 @@ Item { anchors.left: parent?.left anchors.right: parent?.right - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: Tokens.sizes.launcher.itemHeight StateLayer { radius: Tokens.rounding.medium diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index c93a8e5..fa29ecc 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -1,10 +1,13 @@ import Quickshell import Quickshell.Widgets import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell import qs.Modules.Launcher.Services import qs.Components import qs.Helpers -import ZShell.Config import qs.Modules import qs.Services @@ -16,53 +19,72 @@ Item { anchors.left: parent?.left anchors.right: parent?.right - implicitHeight: Config.launcher.sizes.itemHeight + implicitHeight: ZUtils.snap(Tokens.sizes.launcher.itemHeight, root) StateLayer { radius: Tokens.rounding.medium onClicked: { - Apps.launch(root.modelData); - root.screenState.launcher = false; + // Apps.launch(root.modelData); + // root.screenState.launcher = false; + + ZUtils.logPlacement(icon, "icon"); + ZUtils.logPlacement(name, "name"); + ZUtils.logPlacement(favoriteIcon, "favoriteIcon"); + ZUtils.logPlacement(comment, "comment"); + ZUtils.logPlacement(text, "text"); + ZUtils.logPlacement(mainLayout, "main"); } } - Item { + SnapLayout { + id: mainLayout anchors.fill: parent anchors.leftMargin: Tokens.padding.medium - anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium IconImage { id: icon - anchors.verticalCenter: parent.verticalCenter - implicitSize: parent.height + implicitSize: parent.height - Tokens.padding.small * 2 source: Quickshell.iconPath(root.modelData?.icon, "image-missing") } - Item { - anchors.left: icon.right - anchors.leftMargin: Tokens.spacing.medium - anchors.verticalCenter: icon.verticalCenter - implicitHeight: name.implicitHeight + comment.implicitHeight - implicitWidth: parent.width - icon.width + SnapLayout { + id: text + + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: Tokens.padding.extraSmall + Layout.bottomMargin: Tokens.padding.extraSmall + + orientation: Qt.Vertical + spacing: 0 CustomText { id: name + Layout.fillHeight: true + Layout.fillWidth: true + text: root.modelData?.name ?? "" + verticalAlignment: CustomText.AlignVCenter + horizontalAlignment: CustomText.AlignLeft } CustomText { id: comment - anchors.top: name.bottom + Layout.fillHeight: true + Layout.fillWidth: true + color: Colors.palette.m3outline elide: Text.ElideRight font: Tokens.font.body.small text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" - width: root.width - icon.width - Tokens.rounding.medium - Tokens.padding.medium * 2 + verticalAlignment: CustomText.AlignVCenter + horizontalAlignment: CustomText.AlignLeft } } @@ -70,14 +92,16 @@ Item { id: favoriteIcon active: root.modelData && Strings.testRegexList(Config.launcher.favoriteApps, root.modelData.id) - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + Layout.fillHeight: true + asynchronous: true sourceComponent: MaterialIcon { color: Colors.palette.m3primary fill: 1 text: "favorite" + + verticalAlignment: MaterialIcon.AlignVCenter } } } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index c52d71a..6f3d394 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -2,6 +2,7 @@ import ZShell import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Modules import qs.Helpers @@ -31,7 +32,7 @@ Item { } } - RowLayout { + SnapLayout { anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.right: parent.right diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml index c682ef2..d9409b1 100644 --- a/Modules/Lock/Center.qml +++ b/Modules/Lock/Center.qml @@ -2,14 +2,15 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Config +import ZShell.Components import qs.Paths import qs.Components import qs.Helpers -import ZShell.Config import qs.Modules import qs.Services -ColumnLayout { +SnapLayout { id: root readonly property real centerScale: Math.min(1, (lock.screen?.height ?? 1440) / 1440) @@ -19,10 +20,13 @@ ColumnLayout { Layout.fillHeight: true Layout.fillWidth: false Layout.preferredWidth: centerWidth + + orientation: Qt.Vertical spacing: Tokens.spacing.largeIncreased * 2 - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small CustomText { @@ -109,11 +113,12 @@ ColumnLayout { } } - RowLayout { + SnapLayout { id: input anchors.fill: parent anchors.margins: Tokens.padding.extraSmall + spacing: Tokens.spacing.medium Item { diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index 0fd7c3b..a1140c7 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -1,8 +1,9 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config import qs.Components import qs.Helpers -import ZShell.Config import qs.Services Item { @@ -13,15 +14,18 @@ Item { implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth - RowLayout { + SnapLayout { id: layout anchors.fill: parent + spacing: Tokens.spacing.largeIncreased * 2 - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: Tokens.spacing.medium + orientation: Qt.Vertical WeatherInfo { id: weather @@ -50,8 +54,8 @@ Item { } NotifDock { - Layout.fillHeight: true Layout.fillWidth: true + Layout.fillHeight: true lock: root.lock } } diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index c7ac527..49398a1 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Modules import qs.Components import qs.Helpers diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index 645101f..a6d3de7 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -48,14 +48,16 @@ Item { } } - ColumnLayout { + SnapLayout { id: layout anchors.left: parent.left anchors.margins: Tokens.padding.extraLarge anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical CustomText { Layout.fillWidth: true diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 427ae8b..bdc57be 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -4,10 +4,13 @@ import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import ZShell.Services import qs.Modules import qs.Components +import qs.Components.Notifs import qs.Helpers -import ZShell.Config import qs.Daemons import qs.Services @@ -21,9 +24,11 @@ CustomRect { radius: Tokens.rounding.small topRightRadius: Tokens.rounding.largeIncreased - ColumnLayout { + SnapLayout { anchors.fill: parent anchors.margins: Tokens.padding.large + + orientation: Qt.Vertical spacing: Tokens.spacing.small CustomText { @@ -31,7 +36,7 @@ CustomRect { color: Colors.palette.m3outline elide: Text.ElideRight font: Tokens.font.mono.builders.small.weight(Font.Medium).build() - text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") + text: NotifServer.notClosed.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.notClosed.length).arg(NotifServer.notClosed.length === 1 ? "" : "s") : qsTr("Notifications") } Item { @@ -41,28 +46,26 @@ CustomRect { Loader { active: opacity > 0 anchors.centerIn: parent - opacity: NotifServer.list.length > 0 ? 0 : 1 + opacity: NotifServer.notClosed.length > 0 ? 0 : 1 Behavior on opacity { Anim { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: ColumnLayout { - spacing: Tokens.spacing.largeIncreased - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3outlineVariant - font: Tokens.font.headline.builders.small.width(90).build() - text: qsTr("No Notifications") - } + sourceComponent: CustomText { + color: Colors.palette.m3outlineVariant + font: Tokens.font.headline.builders.small.width(90).build() + text: qsTr("No Notifications") } } CustomListView { + id: list + anchors.fill: parent clip: true + orientation: Qt.Vertical spacing: Tokens.spacing.small add: Transition { @@ -80,7 +83,7 @@ CustomRect { to: 1 } } - delegate: NotifGroup {} + delegate: NotifRow {} displaced: Transition { Anim { properties: "opacity,scale" @@ -94,10 +97,7 @@ CustomRect { } } model: ScriptModel { - values: { - const list = NotifServer.notClosed.map(n => [n.appName, null]); - return [...new Map(list).keys()]; - } + values: [...NotifServer.groups].filter(g => g.count > 0) } move: Transition { Anim { @@ -125,4 +125,51 @@ CustomRect { } } } + + component NotifRow: Item { + id: row + + required property NotificationGroup modelData + property bool expanded: false + + function dismissAll(): void { + for (const n of [...modelData.activeNotifs]) + n.dismiss(); + } + + implicitHeight: card.implicitHeight + implicitWidth: list.width + + NotifSwipeArea { + id: area + + implicitHeight: card.implicitHeight + implicitWidth: row.implicitWidth + + expanded: row.expanded + onDismissed: row.dismissAll() + onRequestExpand: expand => row.expanded = expand + onSwiped: row.dismissAll() + + Item { + anchors.top: parent.top + + implicitHeight: card.implicitHeight + implicitWidth: row.implicitWidth + + clip: true + + NotifGroupCard { + id: card + + baseLayer: 1 + container: list + expanded: row.expanded + group: row.modelData + onRequestExpand: expand => row.expanded = expand + onSwiped: notif => notif.dismiss() + } + } + } + } } diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml deleted file mode 100644 index 2619ae1..0000000 --- a/Modules/Lock/NotifGroup.qml +++ /dev/null @@ -1,316 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Notifications -import QtQuick -import QtQuick.Layouts -import ZShell.Services -import qs.Modules -import qs.Components -import qs.Helpers -import ZShell.Config -import qs.Daemons -import qs.Services - -CustomRect { - id: root - - readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" - property bool expanded - readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" - required property string modelData - readonly property list notifs: NotifServer.list.filter(notif => notif.appName === modelData) - readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low" - - anchors.left: parent?.left - anchors.right: parent?.right - clip: true - color: root.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - implicitHeight: content.implicitHeight + Tokens.padding.small * 2 - radius: Tokens.rounding.medium - - Behavior on implicitHeight { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } - } - - RowLayout { - id: content - - anchors.left: parent.left - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium - - Item { - Layout.alignment: Qt.AlignLeft | Qt.AlignTop - implicitHeight: Config.notifs.sizes.image - implicitWidth: Config.notifs.sizes.image - - Component { - id: imageComp - - Image { - asynchronous: true - cache: false - fillMode: Image.PreserveAspectCrop - height: Config.notifs.sizes.image - source: Qt.resolvedUrl(root.image) - visible: Config.lock.showNotifIcon - width: Config.notifs.sizes.image - } - } - - Component { - id: appIconComp - - ColoredIcon { - color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - implicitSize: Math.round(Config.notifs.sizes.image * 0.6) - layer.enabled: root.appIcon.endsWith("symbolic") - source: Quickshell.iconPath(root.appIcon) - } - } - - Component { - id: materialIconComp - - MaterialIcon { - color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - fontStyle: Tokens.font.icon.large - text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) - } - } - - ClipRect { - anchors.fill: parent - color: root.urgency === "critical" ? Colors.palette.m3error : root.urgency === "low" ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) : Colors.palette.m3secondaryContainer - radius: Tokens.rounding.full - - Loader { - anchors.centerIn: parent - sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp - } - } - - Loader { - active: root.appIcon && root.image - anchors.bottom: parent.bottom - anchors.right: parent.right - - sourceComponent: CustomRect { - color: root.urgency === "critical" ? Colors.palette.m3error : root.urgency === "low" ? Colors.palette.m3surfaceContainerHighest : Colors.palette.m3secondaryContainer - implicitHeight: Config.notifs.sizes.badge - implicitWidth: Config.notifs.sizes.badge - radius: Tokens.rounding.full - - ColoredIcon { - anchors.centerIn: parent - color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) - layer.enabled: root.appIcon.endsWith("symbolic") - source: Quickshell.iconPath(root.appIcon) - } - } - } - } - - ColumnLayout { - Layout.bottomMargin: -Tokens.padding.extraSmall / 2 - (root.expanded ? 0 : spacing) - Layout.fillWidth: true - Layout.topMargin: -Tokens.padding.extraSmall - spacing: Math.round(Tokens.spacing.small / 2) - - RowLayout { - Layout.bottomMargin: -parent.spacing - Layout.fillWidth: true - spacing: Tokens.spacing.small - - CustomText { - Layout.fillWidth: true - color: Colors.palette.m3onSurfaceVariant - elide: Text.ElideRight - font: Tokens.font.body.small - text: root.modelData - } - - CustomText { - animate: true - color: Colors.palette.m3outline - font: Tokens.font.body.small - text: root.notifs[0]?.timeStr ?? "" - } - - CustomRect { - Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0 - color: root.urgency === "critical" ? Colors.palette.m3error : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall - implicitWidth: expandBtn.implicitWidth + Tokens.padding.small * 2 - opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0 - radius: Tokens.rounding.full - - Behavior on Layout.preferredWidth { - Anim {} - } - Behavior on opacity { - Anim {} - } - - StateLayer { - color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - - onClicked: { - root.expanded = !root.expanded; - } - } - - RowLayout { - id: expandBtn - - anchors.centerIn: parent - spacing: Tokens.spacing.small / 2 - - CustomText { - id: groupCount - - Layout.leftMargin: Tokens.padding.extraSmall / 2 - animate: true - color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - font: Tokens.font.body.small - text: root.notifs.length - } - - MaterialIcon { - Layout.rightMargin: -Tokens.padding.extraSmall / 2 - animate: true - color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - text: root.expanded ? "expand_less" : "expand_more" - } - } - } - } - - Repeater { - model: ScriptModel { - values: root.notifs.slice(0, Config.notifs.groupPreviewNum) - } - - NotifLine { - id: notif - - ParallelAnimation { - running: true - - Anim { - from: 0 - property: "opacity" - target: notif - to: 1 - } - - Anim { - from: 0.7 - property: "scale" - target: notif - to: 1 - } - - Anim { - from: 0 - property: "preferredHeight" - target: notif.Layout - to: notif.implicitHeight - } - } - - ParallelAnimation { - running: notif.modelData.closed - - onFinished: notif.modelData.unlock(notif) - - Anim { - property: "opacity" - target: notif - to: 0 - } - - Anim { - property: "scale" - target: notif - to: 0.7 - } - - Anim { - property: "preferredHeight" - target: notif.Layout - to: 0 - } - } - } - } - - Loader { - Layout.fillWidth: true - Layout.preferredHeight: root.expanded ? implicitHeight : 0 - active: opacity > 0 - opacity: root.expanded ? 1 : 0 - - Behavior on opacity { - Anim {} - } - sourceComponent: ColumnLayout { - Repeater { - model: ScriptModel { - values: root.notifs.slice(Config.notifs.groupPreviewNum) - } - - NotifLine {} - } - } - } - } - } - - component NotifLine: CustomText { - id: notifLine - - required property NotificationRow modelData - - Layout.fillWidth: true - color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface - text: { - if (!Config.lock.showNotifContent) - return "Unlock to view"; - const summary = modelData.summary.replace(/\n/g, " "); - const body = modelData.body.replace(/\n/g, " "); - const color = root.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3outline; - - if (metrics.text === metrics.elidedText) - return `${summary} ${body}`; - - const t = metrics.elidedText.length - 3; - if (t < summary.length) - return `${summary.slice(0, t)}...`; - - return `${summary} ${body.slice(0, t - summary.length)}...`; - } - textFormat: Text.MarkdownText - - Component.onCompleted: modelData.lock(this) - Component.onDestruction: modelData.unlock(this) - - TextMetrics { - id: metrics - - elide: Text.ElideRight - elideWidth: notifLine.width - font.family: notifLine.font.family - font.pointSize: notifLine.font.pointSize - text: `${notifLine.modelData.summary} ${notifLine.modelData.body}`.replace(/\n/g, " ") - } - } -} diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index 7ce6c90..04524c0 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import M3Shapes import ZShell.Services import ZShell.Config +import ZShell.Components import qs.Components import qs.Effects import qs.Services @@ -33,11 +34,12 @@ CustomRect { service: Storage } - RowLayout { + SnapLayout { id: layout anchors.fill: parent anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.largeIncreased Resource { diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index 57ae1b3..1923fe9 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -1,11 +1,13 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Helpers import qs.Services -ColumnLayout { +SnapLayout { +orientation: Qt.Vertical id: root required property int rootHeight @@ -20,7 +22,7 @@ ColumnLayout { text: Weather.description } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignHCenter spacing: Tokens.spacing.small diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 263d90e..54b26aa 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import M3Shapes import ZShell import qs.Components @@ -14,7 +15,7 @@ CustomRect { implicitHeight: header.anchors.margins + header.implicitHeight + Tokens.spacing.small + layout.implicitHeight + layout.anchors.bottomMargin radius: Tokens.rounding.small - RowLayout { + SnapLayout { id: header anchors.left: parent.left @@ -55,7 +56,8 @@ CustomRect { orientation: VerticalFadeListView.Horizontal spacing: Tokens.spacing.medium - delegate: ColumnLayout { + delegate: SnapLayout { +orientation: Qt.Vertical id: hour readonly property bool current: ListView.view.currentItem === this diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 1ee7ff9..4fc48a3 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -1,19 +1,23 @@ pragma ComponentBehavior: Bound import Quickshell -import Quickshell.Widgets import QtQuick -import ZShell.Config +import ZShell import ZShell.Components +import ZShell.Config import ZShell.Services import qs.Components +import qs.Components.Notifs import qs.Daemons import qs.Helpers Item { id: root - readonly property int padding: Tokens.padding.smaller + readonly property int padding: Tokens.padding.small + readonly property int clamped: ZUtils.clamp(padding - Config.bar.border, 0, padding) + + required property int topPadding required property Item panels required property ScreenState screenState @@ -21,13 +25,10 @@ Item { anchors.right: parent.right anchors.top: parent.top implicitHeight: { - const count = list.count; - if (count === 0) + if (list.count === 0) return 0; - let height = (count - 1) * Tokens.spacing.small; - for (let i = 0; i < count; i++) - height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; + let height = list.layoutHeight; if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x) return 0; @@ -41,7 +42,7 @@ Item { height = h; } - return Math.min(((QsWindow.window as QsWindow)?.screen?.height ?? 0) - 1 * 2, height + padding * 2); + return Math.min(((QsWindow.window as QsWindow)?.screen?.height ?? 0) + padding - clamped * 2 - topPadding, height + padding * 2); } implicitWidth: Config.notifs.sizes.width + padding * 2 @@ -54,36 +55,55 @@ Item { anchors.fill: parent anchors.margins: root.padding clip: true - // radius: Tokens.rounding.large - root.padding - CustomListView { - id: list + KineticFlickable { + id: view anchors.fill: parent - cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0 - orientation: Qt.Vertical - spacing: 0 - delegate: NotifWrapper {} - displaced: Transition { - Anim { - property: "y" - } - } - model: ScriptModel { - values: { - const popups = NotifServer.popups.filter(n => !n.closed); + contentHeight: list.implicitHeight + contentWidth: width + flickableDirection: Flickable.VerticalFlick - if (!Config.notifs.showInFullscreen) { - if (Hypr.monitorFor(root.panels.screen).activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false) - return []; + LazyListView { + id: list + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: contentHeight + + spacing: Tokens.spacing.medium + readyDelay: 1 + cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0 + removeDuration: Tokens.anim.durations.normal + + useCustomViewport: true + viewport: Qt.rect(0, view.contentY, width, view.height) + + model: ScriptModel { + values: { + if (!Config.notifs.showInFullscreen) { + if (Hypr.monitorFor(root.panels.screen).activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false) + return []; + } + return [...NotifServer.popupGroups]; } - return popups; } - } - move: Transition { + + delegate: NotifWrapper {} + + onViewportAdjustNeeded: d => { + if (contentYAnim.running) + contentYAnim.complete(); + contentYAnim.to = Math.max(0, view.contentY + d); + contentYAnim.start(); + } + Anim { - property: "y" + id: contentYAnim + + target: view + property: "contentY" } } } @@ -96,74 +116,106 @@ Item { component NotifWrapper: Item { id: wrapper - property int idx - required property int index - required property NotificationRow modelData - readonly property alias nonAnimHeight: notif.nonAnimHeight + required property NotificationGroup modelData + property bool expanded: Config.notifs.openExpanded + readonly property bool holding: area.containsMouse || area.pressed || card.rowPressed - implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small) - implicitWidth: notif.implicitWidth - - ListView.onRemove: removeAnim.start() - onIndexChanged: { - if (index !== -1) - idx = index; + function forEachPopup(fn: var): void { + for (const n of [...card.notifs]) + fn(n); } - 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 { - 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 - } + function holdPopups(): void { + forEachPopup(n => n.holdPopup()); } - CustomRect { + LazyListView.trackViewport: !expanded && card.nonAnimHeight < card.implicitHeight + LazyListView.preferredHeight: LazyListView.removing ? 0 : card.nonAnimHeight + LazyListView.visibleHeight: LazyListView.removing ? 0 : card.implicitHeight + LazyListView.onRemovingChanged: { + if (!wrapper.LazyListView.removing) + return; + + slideOut.to = (area.x >= 0 ? root.implicitWidth : -root.implicitWidth) * 2; + slideOut.start(); + } + + implicitWidth: area.implicitWidth + implicitHeight: card.implicitHeight + + enabled: !LazyListView.removing + z: LazyListView.removing ? 1 : 0 + + onHoldingChanged: { + if (holding) + holdPopups(); + else + forEachPopup(n => n.releasePopup()); + } + + Connections { + function onNotifsChanged(): void { + if (wrapper.holding) + wrapper.holdPopups(); + } + + target: wrapper.modelData + } + + Anim { + id: slideOut + + target: area + property: "x" + duration: Tokens.anim.durations.normal + easing: Tokens.anim.emphasized + } + + Item { anchors.top: parent.top - anchors.topMargin: wrapper.idx === 0 ? 0 : 8 - color: "transparent" - implicitHeight: notif.implicitHeight - implicitWidth: notif.implicitWidth - radius: Tokens.rounding.small - Notification { - id: notif + implicitWidth: area.implicitWidth + implicitHeight: card.implicitHeight + clip: true + + NotifSwipeArea { + id: area implicitWidth: root.implicitWidth - root.padding * 2 - modelData: wrapper.modelData + implicitHeight: card.implicitHeight + x: wrapper.LazyListView.adding ? implicitWidth : 0 + + expanded: wrapper.expanded + + onRequestExpand: expand => wrapper.expanded = expand + onSwiped: wrapper.forEachPopup(n => n.dismissPopup()) + onDismissed: wrapper.forEachPopup(n => n.dismiss()) + onClicked: event => { + if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton || card.notifs.length !== 1) + return; + + const actions = card.notifs[0].actions; + if (actions.length === 1) + actions[0].invoke(); + } + + NotifGroupCard { + id: card + + group: wrapper.modelData + container: view + expanded: wrapper.expanded + onRequestExpand: expand => wrapper.expanded = expand + onSwiped: notif => notif.dismissPopup() + onLinkActivated: notif => notif.dismissPopup() + } } } + + Behavior on y { + enabled: wrapper.LazyListView.ready + + Anim {} + } } } diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml deleted file mode 100644 index bd8cf9b..0000000 --- a/Modules/Notifications/Notification.qml +++ /dev/null @@ -1,515 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Shapes -import Quickshell -import Quickshell.Services.Notifications -import ZShell.Components -import ZShell.Services -import qs.Helpers -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - property bool expanded: Config.notifs.openExpanded - readonly property bool hasAppIcon: modelData.appIcon.length > 0 - readonly property bool hasImage: modelData.image.length > 0 - required property NotificationRow modelData - readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 - - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer - implicitHeight: inner.implicitHeight - radius: Tokens.rounding.large - Tokens.padding.smaller - x: implicitWidth - - Behavior on x { - Anim { - easing: Tokens.anim.emphasizedDecel - } - } - - Component.onCompleted: { - x = 0; - modelData.lock(this); - } - Component.onDestruction: modelData.unlock(this) - - MouseArea { - property int startY - - acceptedButtons: Qt.LeftButton | Qt.MiddleButton - anchors.fill: parent - cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined - drag.axis: Drag.XAxis - drag.target: parent - hoverEnabled: true - preventStealing: true - - onClicked: event => { - if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton) - return; - - const actions = root.modelData.actions; - if (actions.length === 1) - actions[0].invoke(); - } - onEntered: root.modelData.pausePopup() - onExited: { - if (!pressed) - root.modelData.resumePopup(); - } - onPositionChanged: event => { - if (pressed) { - const diffY = event.y - startY; - if (Math.abs(diffY) > Config.notifs.expandThreshold) - root.expanded = diffY > 0; - } - } - onPressed: event => { - root.modelData.pausePopup(); - startY = event.y; - if (event.button === Qt.MiddleButton) - root.modelData.close(); - } - onReleased: event => { - if (!containsMouse) - root.modelData.resumePopup(); - - if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) - root.x = 0; - else - root.modelData.popup = false; - } - - Item { - id: inner - - anchors.left: parent.left - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - implicitHeight: root.nonAnimHeight - - Behavior on implicitHeight { - Anim {} - } - - Loader { - id: image - - active: root.hasImage - anchors.left: parent.left - anchors.top: parent.top - asynchronous: true - height: Config.notifs.sizes.image - visible: root.hasImage || root.hasAppIcon - width: Config.notifs.sizes.image - - sourceComponent: ClipRect { - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) : Colors.palette.m3secondaryContainer - implicitHeight: Config.notifs.sizes.image - implicitWidth: Config.notifs.sizes.image - radius: Tokens.rounding.full - - Image { - anchors.fill: parent - asynchronous: true - cache: false - fillMode: Image.PreserveAspectCrop - source: Qt.resolvedUrl(root.modelData.image) - sourceSize: { - const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); - return Qt.size(size, size); - } - } - } - } - - Loader { - id: appIcon - - active: root.hasAppIcon || !root.hasImage - anchors.bottom: root.hasImage ? image.bottom : undefined - anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter - anchors.right: root.hasImage ? image.right : undefined - anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter - asynchronous: true - - sourceComponent: CustomRect { - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) : Colors.palette.m3secondaryContainer - implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image - implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image - radius: Tokens.rounding.full - - Loader { - id: icon - - active: root.hasAppIcon - anchors.centerIn: parent - asynchronous: true - height: Math.round(parent.width * 0.6) - width: Math.round(parent.width * 0.6) - - sourceComponent: ColoredIcon { - anchors.fill: parent - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - layer.enabled: root.modelData.appIcon.endsWith("symbolic") - source: Quickshell.iconPath(root.modelData.appIcon) - } - } - - Loader { - active: !root.hasAppIcon - anchors.centerIn: parent - anchors.verticalCenterOffset: 1 - asynchronous: true - - sourceComponent: MaterialIcon { - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) - fontStyle: Tokens.font.icon.medium - } - } - } - } - - 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: Colors.palette.m3primary - strokeWidth: 2 - - PathAngleArc { - id: progressArc - - centerX: progressIndicator.width / 2 - centerY: progressIndicator.height / 2 - radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2 - radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2 - startAngle: -90 - sweepAngle: (root.modelData.progress / 100) * 360 - - Behavior on sweepAngle { - Anim { - easing: Tokens.anim.emphasizedDecel - } - } - } - } - } - - CustomText { - id: appName - - anchors.left: image.right - anchors.leftMargin: Tokens.spacing.small - anchors.top: parent.top - animate: true - color: Colors.palette.m3onSurfaceVariant - maximumLineCount: 1 - opacity: root.expanded ? 1 : 0 - text: appNameMetrics.elidedText - font: Tokens.font.label.medium - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - } - - TextMetrics { - id: appNameMetrics - - elide: Text.ElideRight - elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Tokens.spacing.small * 3 - font: appName.font - text: root.modelData.appName - } - - CustomText { - id: summary - - anchors.left: image.right - anchors.leftMargin: Tokens.spacing.small - anchors.top: parent.top - animate: true - height: implicitHeight - maximumLineCount: 1 - text: summaryMetrics.elidedText - - Behavior on height { - Anim {} - } - states: State { - name: "expanded" - when: root.expanded - - PropertyChanges { - body.anchors.topMargin: Tokens.spacing.extraSmall - bodyPreview.anchors.topMargin: Tokens.spacing.extraSmall - summary.anchors.topMargin: Tokens.spacing.extraSmall - summary.maximumLineCount: undefined - } - - AnchorChanges { - anchors.top: appName.bottom - target: summary - } - } - transitions: Transition { - PropertyAction { - property: "maximumLineCount" - target: summary - } - - Anim { - property: "topMargin" - } - - AnchorAnim {} - } - } - - TextMetrics { - id: summaryMetrics - - elide: Text.ElideRight - elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Tokens.spacing.small * 3 - font: summary.font - text: root.modelData.summary - } - - CustomText { - id: timeSep - - anchors.left: summary.right - anchors.leftMargin: Tokens.spacing.small - anchors.top: parent.top - color: Colors.palette.m3onSurfaceVariant - text: "•" - font: Tokens.font.body.small - - states: State { - name: "expanded" - when: root.expanded - - AnchorChanges { - anchors.left: appName.right - target: timeSep - } - } - transitions: Transition { - AnchorAnim {} - } - } - - CustomText { - id: time - - anchors.left: timeSep.right - anchors.leftMargin: Tokens.spacing.small - anchors.top: parent.top - animate: true - color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.small - horizontalAlignment: Text.AlignLeft - text: root.modelData.timeStr - } - - Item { - id: expandBtn - - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: -Tokens.padding.extraSmall - implicitHeight: expandIcon.implicitHeight - implicitWidth: expandIcon.implicitHeight - - StateLayer { - color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface - radius: Tokens.rounding.full - - onClicked: root.expanded = !root.expanded - } - - MaterialIcon { - id: expandIcon - - anchors.centerIn: parent - anchors.verticalCenterOffset: root.expanded ? -1 : 1 - rotation: root.expanded ? 180 : 0 - text: "expand_more" - fontStyle: Tokens.font.icon.medium - - Behavior on anchors.verticalCenterOffset { - Anim {} - } - Behavior on rotation { - Anim {} - } - } - } - - CustomText { - id: bodyPreview - - anchors.left: summary.left - anchors.right: expandBtn.left - anchors.rightMargin: Tokens.spacing.small - anchors.top: summary.bottom - animate: true - color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.small - opacity: root.expanded ? 0 : 1 - text: bodyPreviewMetrics.elidedText - textFormat: Text.MarkdownText - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - } - - TextMetrics { - id: bodyPreviewMetrics - - elide: Text.ElideRight - elideWidth: bodyPreview.width - font: bodyPreview.font - text: root.modelData.body - } - - CustomText { - id: body - - anchors.left: summary.left - anchors.right: expandBtn.left - anchors.rightMargin: Tokens.spacing.small - anchors.top: summary.bottom - animate: true - color: Colors.palette.m3onSurfaceVariant - height: text ? implicitHeight : 0 - font: Tokens.font.body.medium - opacity: root.expanded ? 1 : 0 - text: root.modelData.body - textFormat: Text.MarkdownText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - - onLinkActivated: link => { - if (!root.expanded) - return; - - Quickshell.execDetached(["app2unit", "-O", "--", link]); - root.modelData.popup = false; - } - } - - ButtonRow { - id: actions - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: body.bottom - anchors.topMargin: Tokens.spacing.small - opacity: root.expanded ? 1 : 0 - spacing: Tokens.spacing.extraSmall - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - - IconButton { - enabled: root.expanded - fillWidth: root.modelData.actions.length === 0 - icon: "close" - font: Tokens.font.icon.builders.small.size(14).build() - inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant - isRound: true - padding: Tokens.padding.extraSmall - shapeMorph: true - - onClicked: root.modelData.close() - } - - Repeater { - model: root.modelData.actions - - TextButton { - required property var modelData - - enabled: root.expanded - fillWidth: true - implicitWidth: label.implicitWidth - inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant - isRound: true - label.anchors.centerIn: undefined - label.anchors.left: left - label.anchors.margins: Tokens.padding.small - label.anchors.right: right - label.anchors.verticalCenter: verticalCenter - label.elide: Text.ElideRight - label.horizontalAlignment: Text.AlignHCenter - shapeMorph: true - text: modelData.text - - onClicked: { - if (modelData.invokable) - modelData.invoke(); - else if (!root.modelData.resident) - root.modelData.close(); - } - } - } - - IconButton { - enabled: root.expanded - fillWidth: root.modelData.actions.length === 0 - icon: copyTimer.running ? "inventory" : "content_copy" - inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant - isRound: true - label.animate: true - padding: Tokens.padding.extraSmall - shapeMorph: true - font: Tokens.font.icon.builders.small.size(14).build() - - onClicked: { - Quickshell.clipboardText = root.modelData.body; - copyTimer.restart(); - } - - Timer { - id: copyTimer - - interval: 3000 - } - } - } - } - } -} diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index e010b09..f621d17 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -8,6 +8,7 @@ Item { required property Item panels required property Item sidebarPanel required property ScreenState screenState + required property int topPadding implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth @@ -18,5 +19,6 @@ Item { panels: root.panels screenState: root.screenState + topPadding: root.topPadding } } diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index e1ac3d9..944d45c 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -2,9 +2,11 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Config +import ZShell.Components import qs.Components import qs.Helpers -import ZShell.Config import qs.Daemons import qs.Services @@ -19,14 +21,17 @@ Item { required property ScreenState screenState required property real volume - implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 - implicitWidth: layout.implicitWidth + Tokens.padding.smaller * 2 + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 - ColumnLayout { + SnapLayout { id: layout - anchors.centerIn: parent + anchors.fill: parent + anchors.margins: Tokens.padding.extraSmall + spacing: Tokens.spacing.medium + orientation: Qt.Vertical // Speaker volume CustomMouseArea { @@ -37,8 +42,8 @@ Item { Audio.decrementVolume(); } - implicitHeight: Config.osd.sizes.sliderHeight - implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: ZUtils.snap(Config.osd.sizes.sliderHeight, root) + implicitWidth: ZUtils.snap(Config.osd.sizes.sliderWidth, root) FilledSlider { anchors.fill: parent @@ -63,8 +68,8 @@ Item { Audio.decrementSourceVolume(); } - implicitHeight: Config.osd.sizes.sliderHeight - implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: ZUtils.snap(Config.osd.sizes.sliderHeight, root) + implicitWidth: ZUtils.snap(Config.osd.sizes.sliderWidth, root) FilledSlider { anchors.fill: parent @@ -93,8 +98,8 @@ Item { monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); } - implicitHeight: Config.osd.sizes.sliderHeight - implicitWidth: Config.osd.sizes.sliderWidth + implicitHeight: ZUtils.snap(Config.osd.sizes.sliderHeight, root) + implicitWidth: ZUtils.snap(Config.osd.sizes.sliderWidth, root) FilledSlider { anchors.fill: parent @@ -131,8 +136,7 @@ Item { } } Behavior on opacity { - Anim { - } + Anim {} } } } diff --git a/Modules/Osd/Wrapper.qml b/Modules/Osd/Wrapper.qml index e388beb..6628925 100644 --- a/Modules/Osd/Wrapper.qml +++ b/Modules/Osd/Wrapper.qml @@ -2,9 +2,10 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import ZShell +import ZShell.Config import qs.Components import qs.Helpers -import ZShell.Config import qs.Daemons Item { @@ -17,7 +18,7 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled) - property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 + property real sidebarOffset: sidebarOrSessionVisible ? 0 : 0 required property bool sidebarOrSessionVisible property bool sourceMuted property real sourceVolume @@ -30,8 +31,8 @@ Item { } anchors.rightMargin: (-implicitWidth - 5 - sidebarOffset) * offsetScale - implicitHeight: content.implicitHeight - implicitWidth: content.implicitWidth + implicitHeight: ZUtils.snap(content.implicitHeight, root) + implicitWidth: ZUtils.snap(content.implicitWidth, root) opacity: 1 - offsetScale visible: offsetScale < 1 @@ -98,8 +99,7 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter + anchors.fill: parent sourceComponent: Content { brightness: root.brightness diff --git a/Modules/Polkit/Polkit.qml b/Modules/Polkit/Polkit.qml index c598cd5..10e3006 100644 --- a/Modules/Polkit/Polkit.qml +++ b/Modules/Polkit/Polkit.qml @@ -83,13 +83,16 @@ Scope { opacity: 0 radius: Tokens.rounding.small * 2 - ColumnLayout { + SnapLayout { id: layout anchors.fill: parent anchors.margins: Tokens.padding.medium - RowLayout { + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + + SnapLayout { id: contentRow spacing: 24 @@ -120,12 +123,15 @@ Scope { } } - ColumnLayout { + SnapLayout { id: contentColumn Layout.fillHeight: true Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + CustomText { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) @@ -210,13 +216,15 @@ Scope { } } - ColumnLayout { + SnapLayout { id: textDetailsColumn anchors.fill: parent anchors.margins: Tokens.padding.small opacity: 0 + spacing: Tokens.spacing.small + orientation: Qt.Vertical CustomText { text: `actionId: ${polkitAgent.flow?.actionId}` @@ -230,8 +238,9 @@ Scope { } } - RowLayout { + SnapLayout { Layout.preferredWidth: contentRow.implicitWidth + spacing: 8 IconButton { diff --git a/Modules/Resources/Cards/BatteryTank.qml b/Modules/Resources/Cards/BatteryTank.qml index 7c26d63..4d8ed33 100644 --- a/Modules/Resources/Cards/BatteryTank.qml +++ b/Modules/Resources/Cards/BatteryTank.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell.Services.UPower import ZShell.Config import qs.Components @@ -50,7 +51,8 @@ ClipRect { } } - component Contents: ColumnLayout { + component Contents: SnapLayout { +orientation: Qt.Vertical id: contents required property color accentColor @@ -100,7 +102,7 @@ ClipRect { } } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignRight Layout.bottomMargin: -Tokens.padding.extraSmall Layout.rightMargin: -Tokens.padding.extraSmall diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml index d5f8a87..8889cc3 100644 --- a/Modules/Resources/Cards/HeroCard.qml +++ b/Modules/Resources/Cards/HeroCard.qml @@ -1,5 +1,7 @@ import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -15,10 +17,12 @@ CustomRect { required property real usage color: Colors.tPalette.m3surfaceContainer - implicitHeight: Math.max(tempProg.implicitHeight + detailsRow.implicitHeight + Tokens.spacing.largeIncreased, usageColumn.implicitHeight + usageLabel.implicitHeight) + Tokens.padding.large * 2 - implicitWidth: 450 + implicitHeight: ZUtils.snap(Math.max(tempProg.implicitHeight + detailsRow.implicitHeight + Tokens.spacing.largeIncreased, usageColumn.implicitHeight + usageLabel.implicitHeight) + Tokens.padding.large * 2, root) + implicitWidth: ZUtils.snapCeil(450, root) radius: Tokens.rounding.largeIncreased + objectName: "card-rect" + ClipRect { anchors.fill: parent radius: parent.radius @@ -43,7 +47,7 @@ CustomRect { anchors.margins: Tokens.padding.large anchors.top: parent.top fgColor: root.accent - implicitSize: Math.max(icon.implicitWidth, icon.implicitHeight) + Tokens.padding.small * 2 + implicitSize: ZUtils.snap(Math.max(icon.implicitWidth, icon.implicitHeight) + Tokens.padding.small * 2, root) spacing: Tokens.spacing.extraSmall strokeWidth: Tokens.padding.extraSmall value: root.usage @@ -62,12 +66,14 @@ CustomRect { } } - ColumnLayout { + SnapLayout { anchors.left: tempProg.right anchors.margins: Tokens.spacing.largeIncreased anchors.right: usageColumn.left anchors.verticalCenter: tempProg.verticalCenter + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical CustomText { color: root.accent @@ -82,15 +88,19 @@ CustomRect { } } - ColumnLayout { + SnapLayout { id: detailsRow anchors.bottom: parent.bottom anchors.left: parent.left anchors.margins: Tokens.padding.largeIncreased - spacing: Tokens.spacing.extraSmall - RowLayout { + objectName: "progress-layout" + + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + + SnapLayout { Layout.leftMargin: -Tokens.padding.extraSmall spacing: Tokens.spacing.extraSmall @@ -114,26 +124,28 @@ CustomRect { } } - Column { + SnapLayout { id: usageColumn anchors.margins: Tokens.padding.large anchors.right: parent.right - anchors.rightMargin: 32 + anchors.rightMargin: Tokens.padding.extraLarge anchors.verticalCenter: parent.verticalCenter + spacing: 0 + orientation: Qt.Vertical CustomText { id: usageLabel - anchors.right: parent.right + Layout.alignment: Qt.AlignRight color: Colors.palette.m3onSurfaceVariant font: Tokens.font.body.medium text: qsTr("Usage") } CustomText { - anchors.right: parent.right + Layout.alignment: Qt.AlignRight color: root.accent font: Tokens.font.headline.builders.small.width(50).build() text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index c2f8041..d1d2c6b 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -1,5 +1,7 @@ import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Components import ZShell.Services import ZShell.Config import qs.Components @@ -10,34 +12,39 @@ CustomRect { readonly property color accent: Colors.palette.m3tertiary + required property real perc + required property int used + required property int total + required property string label + required property string icon + Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 implicitWidth: layout.implicitWidth + Tokens.padding.extraLargeIncreased * 2 radius: Tokens.rounding.largeIncreased - ServiceRef { - service: Memory - } - - ColumnLayout { + SnapLayout { id: layout - anchors.centerIn: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + orientation: Qt.Vertical spacing: Tokens.spacing.extraSmall - RowLayout { - Layout.leftMargin: -Tokens.padding.extraSmall + SnapLayout { + Layout.alignment: Qt.AlignHCenter spacing: Tokens.spacing.small MaterialIcon { color: root.accent fill: 1 - text: "memory_alt" + text: root.icon } CustomText { - text: qsTr("Memory") + text: root.label } } @@ -45,18 +52,18 @@ CustomRect { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.largeIncreased fgColor: root.accent - implicitSize: usageColumn.implicitHeight + thickness + Tokens.padding.largeIncreased * 2 + implicitSize: ZUtils.snap(usageColumn.implicitHeight + thickness + Tokens.padding.largeIncreased * 2, root) startAngle: -225 sweepAngle: 270 - value: Memory.percentage + value: root.perc Behavior on clampedVal { - Anim { - } + Anim {} } - ColumnLayout { + SnapLayout { id: usageColumn + orientation: Qt.Vertical anchors.centerIn: parent anchors.verticalCenterOffset: Tokens.padding.extraSmall @@ -66,7 +73,7 @@ CustomRect { Layout.alignment: Qt.AlignHCenter color: root.accent font: Tokens.font.body.large - text: Math.round(Memory.percentage * 100) + "%" + text: Math.round(root.perc * 100) + "%" } CustomText { @@ -80,7 +87,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter text: { - const fmt = UsageFmt.formatKib(Memory.used, Memory.total); + const fmt = UsageFmt.formatKib(root.used, root.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 index ea6474c..61ccd27 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -18,15 +18,17 @@ CustomRect { service: NetworkUsage } - ColumnLayout { + SnapLayout { id: layout anchors.bottomMargin: Tokens.padding.small anchors.fill: parent anchors.margins: Tokens.padding.large - spacing: 0 - RowLayout { + spacing: 0 + orientation: Qt.Vertical + + SnapLayout { spacing: Tokens.spacing.small MaterialIcon { @@ -49,7 +51,7 @@ CustomRect { id: sparkline property bool initialized: false - property real smoothMax: targetMax + property real smoothMax: targetMax * 1.6 property real targetMax: 1024 anchors.fill: parent @@ -72,7 +74,7 @@ CustomRect { Component.onCompleted: { sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); - sparkline.smoothMax = Qt.binding(() => sparkline.targetMax); + sparkline.smoothMax = Qt.binding(() => sparkline.targetMax * 1.6); sparkline.initialized = true; } @@ -108,8 +110,11 @@ CustomRect { } // Download row - RowLayout { + SnapLayout { Layout.fillWidth: true + Layout.maximumWidth: root.width > root.implicitWidth * 2 ? root.width / 2 : -1 + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.small MaterialIcon { @@ -136,8 +141,11 @@ CustomRect { } // Upload row - RowLayout { + SnapLayout { Layout.fillWidth: true + Layout.maximumWidth: root.width > root.implicitWidth * 2 ? root.width / 2 : -1 + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.small MaterialIcon { @@ -164,8 +172,11 @@ CustomRect { } // Session totals - RowLayout { + SnapLayout { Layout.fillWidth: true + Layout.maximumWidth: root.width > root.implicitWidth * 2 ? root.width / 2 : -1 + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.small MaterialIcon { diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index a1a1e57..ad8f1ba 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -1,6 +1,8 @@ import QtQuick import QtQuick.Layouts import Quickshell +import ZShell +import ZShell.Components import ZShell.Services import ZShell.Config import qs.Components @@ -21,23 +23,26 @@ CustomRect { service: Storage } - ColumnLayout { + SnapLayout { id: layout anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - spacing: 0 - RowLayout { + spacing: 0 + orientation: Qt.Vertical + + SnapLayout { id: row Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.largeIncreased CircularProgress { fgColor: root.accent - implicitSize: usageColumn.implicitHeight + thickness + Tokens.padding.large * 2 + implicitSize: ZUtils.snap(usageColumn.implicitHeight + thickness + Tokens.padding.large * 2, root) startAngle: -225 sweepAngle: 270 value: root.percentage @@ -46,7 +51,8 @@ CustomRect { Anim {} } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: usageColumn anchors.centerIn: parent @@ -74,8 +80,9 @@ CustomRect { } } - ColumnLayout { + SnapLayout { spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical CustomText { text: qsTr("Storage") diff --git a/Modules/Resources/Cards/VramCard.qml b/Modules/Resources/Cards/VramCard.qml deleted file mode 100644 index 034f00f..0000000 --- a/Modules/Resources/Cards/VramCard.qml +++ /dev/null @@ -1,89 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import ZShell.Services -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property color accent: Colors.palette.m3tertiary - - Layout.fillWidth: true - color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 - implicitWidth: layout.implicitWidth + Tokens.padding.extraLargeIncreased - radius: Tokens.rounding.largeIncreased - - ServiceRef { - service: Gpu - } - - ColumnLayout { - id: layout - - anchors.centerIn: parent - spacing: Tokens.spacing.extraSmall - - RowLayout { - Layout.leftMargin: -Tokens.padding.extraSmall - spacing: Tokens.spacing.small - - MaterialIcon { - color: root.accent - fill: 1 - text: "memory_alt" - } - - CustomText { - text: qsTr("Video memory") - } - } - - CircularProgress { - id: circularIndicator - - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Tokens.spacing.largeIncreased - fgColor: root.accent - implicitSize: usageColumn.implicitHeight + thickness + Tokens.padding.largeIncreased * 2 - startAngle: -225 - sweepAngle: 270 - value: Gpu.memoryUsed / Gpu.memoryTotal - - Behavior on clampedVal { - Anim {} - } - - ColumnLayout { - id: usageColumn - - anchors.centerIn: parent - anchors.verticalCenterOffset: Tokens.padding.extraSmall - spacing: 0 - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: root.accent - font: Tokens.font.body.large - text: Math.round(circularIndicator.value * 100) + "%" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3onSurfaceVariant - text: qsTr("Used") - } - } - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - text: { - const fmt = UsageFmt.formatKib(Gpu.memoryUsed, Gpu.memoryTotal); - return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; - } - } - } -} diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml index 53909d5..a813746 100644 --- a/Modules/Resources/Content.qml +++ b/Modules/Resources/Content.qml @@ -1,7 +1,10 @@ import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Components import ZShell.Services import ZShell.Config +import qs.Components import qs.Modules.Resources.Cards import qs.Helpers import qs.Services @@ -11,25 +14,25 @@ Item { required property Wrapper wrapper - implicitHeight: content.implicitHeight + Tokens.padding.small * 2 - implicitWidth: content.implicitWidth + implicitHeight: ZUtils.snap(content.implicitHeight + Tokens.padding.small * 2, root) + implicitWidth: ZUtils.snap(content.implicitWidth, root) - RowLayout { + SnapLayout { id: content - anchors.left: parent.left + anchors.fill: parent anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter spacing: Tokens.spacing.large - ColumnLayout { + SnapLayout { id: mainColumn Layout.fillWidth: true spacing: Tokens.spacing.medium + orientation: Qt.Vertical - RowLayout { + SnapLayout { + id: hero spacing: Tokens.spacing.medium visible: cpuCard.active || gpuCard.active @@ -72,7 +75,10 @@ Item { } } - RowLayout { + SnapLayout { + id: card + + Layout.fillWidth: true spacing: Tokens.spacing.medium visible: storageCard.active || memoryCard.active || vramCard.active || networkCard1.active @@ -81,8 +87,7 @@ Item { active: Config.dashboard.performance.showStorage - sourceComponent: StorageCard { - } + sourceComponent: StorageCard {} } WrappedLoader { @@ -91,6 +96,15 @@ Item { active: Config.dashboard.performance.showMemory sourceComponent: MemoryCard { + used: Memory.used + total: Memory.total + perc: Memory.percentage + label: qsTr("Memory") + icon: "memory_alt" + + ServiceRef { + service: Memory + } } } @@ -99,7 +113,12 @@ Item { active: Config.dashboard.performance.showVram - sourceComponent: VramCard { + sourceComponent: MemoryCard { + used: Gpu.memoryUsed + total: Gpu.memoryTotal + perc: Gpu.memoryUsed / Gpu.memoryTotal + label: qsTr("Video memory") + icon: "memory_alt" } } @@ -108,8 +127,7 @@ Item { active: Config.dashboard.performance.showNetwork && !vramCard.active - sourceComponent: NetworkCard { - } + sourceComponent: NetworkCard {} } } @@ -118,8 +136,7 @@ Item { active: Config.dashboard.performance.showNetwork && vramCard.active - sourceComponent: NetworkCard { - } + sourceComponent: NetworkCard {} } } @@ -127,8 +144,7 @@ Item { Layout.fillWidth: false active: Battery.isLaptop && Config.dashboard.performance.showBattery - sourceComponent: BatteryTank { - } + sourceComponent: BatteryTank {} } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 99adbf0..f7e7d6e 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -28,8 +28,7 @@ Item { Behavior on offsetScale { Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial + type: Anim.DefaultSpatial } } diff --git a/Modules/Settings/Common/AudioDeviceList.qml b/Modules/Settings/Common/AudioDeviceList.qml index d0d21bb..dcbefe4 100644 --- a/Modules/Settings/Common/AudioDeviceList.qml +++ b/Modules/Settings/Common/AudioDeviceList.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Services.Pipewire import qs.Components @@ -39,7 +40,7 @@ ItemList { onClicked: root.selected(device.modelData) } - RowLayout { + SnapLayout { id: deviceLayout anchors.fill: parent diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index f06d808..3f446b1 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Blobs import qs.Components import ZShell.Config @@ -181,7 +182,8 @@ Item { onWheel: event => event.accepted = true - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.bottomMargin: Tokens.padding.largeIncreased anchors.fill: parent anchors.margins: Tokens.padding.extraLarge @@ -222,7 +224,7 @@ Item { } } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignRight spacing: Tokens.spacing.extraSmall diff --git a/Modules/Settings/Common/InfoRow.qml b/Modules/Settings/Common/InfoRow.qml index 63f510d..53c8cf9 100644 --- a/Modules/Settings/Common/InfoRow.qml +++ b/Modules/Settings/Common/InfoRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -29,7 +30,7 @@ ConnectedRect { } } - RowLayout { + SnapLayout { id: rowLayout anchors.fill: parent @@ -44,7 +45,8 @@ ConnectedRect { visible: root.leadingComponent } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Common/ItemList.qml b/Modules/Settings/Common/ItemList.qml index d32f678..cf2c951 100644 --- a/Modules/Settings/Common/ItemList.qml +++ b/Modules/Settings/Common/ItemList.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -38,7 +39,8 @@ ConnectedRect { type: Anim.DefaultEffects } } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { +orientation: Qt.Vertical spacing: Tokens.spacing.extraSmall MaterialIcon { diff --git a/Modules/Settings/Common/ListEditor.qml b/Modules/Settings/Common/ListEditor.qml index 9685068..712b0d4 100644 --- a/Modules/Settings/Common/ListEditor.qml +++ b/Modules/Settings/Common/ListEditor.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import ZShell.Config import qs.Components @@ -265,7 +266,7 @@ ListView { } } - RowLayout { + SnapLayout { id: row anchors.fill: parent diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index ef43398..bf0fe35 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -2,12 +2,13 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Modules.Settings import qs.Components import ZShell.Config import qs.Services -ColumnLayout { +SnapLayout { id: root property bool animateScroll: false @@ -67,6 +68,7 @@ ColumnLayout { } spacing: Tokens.spacing.largeIncreased + orientation: Qt.Vertical Component.onCompleted: applySearchAnchor() @@ -118,7 +120,7 @@ ColumnLayout { onClicked: focus = true - RowLayout { + SnapLayout { id: header spacing: Tokens.spacing.largeIncreased diff --git a/Modules/Settings/Common/PopupRow.qml b/Modules/Settings/Common/PopupRow.qml index 0b3a22a..8233bc4 100644 --- a/Modules/Settings/Common/PopupRow.qml +++ b/Modules/Settings/Common/PopupRow.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import qs.Components import ZShell.Config @@ -27,7 +28,7 @@ ConnectedRect { onClicked: popup.open = true } - RowLayout { + SnapLayout { id: navLayout anchors.fill: parent @@ -43,7 +44,8 @@ ConnectedRect { fontStyle: Tokens.font.icon.medium } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 419fae8..4dfea88 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -30,7 +31,7 @@ ConnectedRect { onClicked: e => root.clicked(e) } - RowLayout { + SnapLayout { id: row anchors.left: parent.left diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml index 4bfc086..4de753c 100644 --- a/Modules/Settings/Common/SearchPopup.qml +++ b/Modules/Settings/Common/SearchPopup.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -19,7 +20,8 @@ DialogRowButton { separateContent: true content: Component { - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical VerticalFadeListView { bottomMargin: Tokens.padding.large Layout.fillHeight: true diff --git a/Modules/Settings/Common/SectionHeader.qml b/Modules/Settings/Common/SectionHeader.qml index b826399..f0be9e4 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -1,7 +1,8 @@ import QtQuick import QtQuick.Layouts -import qs.Components +import ZShell.Components import ZShell.Config +import qs.Components import qs.Services CustomText { @@ -10,7 +11,7 @@ CustomText { Layout.bottomMargin: Tokens.spacing.extraSmall Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall - Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0) + Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as SnapLayout).spacing ?? 0) color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight font: Tokens.font.label.medium diff --git a/Modules/Settings/Common/SelectRow.qml b/Modules/Settings/Common/SelectRow.qml index ce97cad..5c5de7d 100644 --- a/Modules/Settings/Common/SelectRow.qml +++ b/Modules/Settings/Common/SelectRow.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -22,7 +23,7 @@ ConnectedRect { implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 z: splitButton.expanded ? 1 : 0 - RowLayout { + SnapLayout { id: rowLayout anchors.fill: parent @@ -31,7 +32,8 @@ ConnectedRect { anchors.rightMargin: Tokens.padding.largeIncreased spacing: Tokens.spacing.small - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Common/SliderRow.qml b/Modules/Settings/Common/SliderRow.qml index 10c69b1..da5bc4d 100644 --- a/Modules/Settings/Common/SliderRow.qml +++ b/Modules/Settings/Common/SliderRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -19,7 +20,7 @@ ConnectedRect { Layout.fillWidth: true implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins + rowLayout.anchors.topMargin - RowLayout { + SnapLayout { id: rowLayout anchors.fill: parent @@ -27,11 +28,12 @@ ConnectedRect { anchors.topMargin: Tokens.padding.large spacing: Tokens.spacing.small - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: Tokens.spacing.small - RowLayout { + SnapLayout { Layout.fillWidth: true spacing: Tokens.spacing.small diff --git a/Modules/Settings/Common/SpinRow.qml b/Modules/Settings/Common/SpinRow.qml index 429d4d1..66278c7 100644 --- a/Modules/Settings/Common/SpinRow.qml +++ b/Modules/Settings/Common/SpinRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services @@ -21,7 +22,7 @@ ConnectedRect { Layout.fillWidth: true implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 - RowLayout { + SnapLayout { id: rowLayout anchors.fill: parent @@ -30,7 +31,8 @@ ConnectedRect { anchors.rightMargin: Tokens.padding.largeIncreased spacing: Tokens.spacing.small - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml index 32ec8a4..f1814ed 100644 --- a/Modules/Settings/Common/TextFieldRow.qml +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -35,7 +36,7 @@ ConnectedRect { Layout.fillWidth: true implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium + Math.max(0, Tokens.padding.large - input.verticalPadding) * 2 - RowLayout { + SnapLayout { id: rowLayout anchors.left: parent.left @@ -45,7 +46,8 @@ ConnectedRect { anchors.rightMargin: Tokens.padding.largeIncreased spacing: Tokens.spacing.medium - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml index 701b2ed..07d153b 100644 --- a/Modules/Settings/Common/TimeDialogRow.qml +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Blobs import qs.Components import ZShell.Config @@ -185,7 +186,8 @@ Item { implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2 implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: innerLayout anchors.bottomMargin: Tokens.padding.largeIncreased anchors.fill: parent @@ -206,7 +208,7 @@ Item { sourceComponent: root.content } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignRight spacing: Tokens.spacing.extraSmall diff --git a/Modules/Settings/Common/TimeDialogToggle.qml b/Modules/Settings/Common/TimeDialogToggle.qml index 9d75041..0c0899b 100644 --- a/Modules/Settings/Common/TimeDialogToggle.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index f01100f..3575b5f 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -6,7 +6,8 @@ import qs.Components import qs.Helpers import qs.Services -ColumnLayout { +SnapLayout { +orientation: Qt.Vertical id: root required property int start @@ -28,7 +29,7 @@ ColumnLayout { spacing: Tokens.spacing.medium - RowLayout { + SnapLayout { CustomRect { id: startHourRect @@ -470,7 +471,7 @@ ColumnLayout { } } - RowLayout { + SnapLayout { CustomText { Layout.preferredWidth: spacer.x + spacer.width text: qsTr("Start") diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 2eea3f5..a9d809c 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Services diff --git a/Modules/Settings/Common/WallItem.qml b/Modules/Settings/Common/WallItem.qml index c4f9c57..fbdf4bf 100644 --- a/Modules/Settings/Common/WallItem.qml +++ b/Modules/Settings/Common/WallItem.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import qs.Components import ZShell.Config @@ -19,7 +20,8 @@ Item { Layout.fillWidth: true implicitHeight: layout.implicitHeight - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.fill: parent diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index aa0c5b6..775acf9 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -164,7 +164,7 @@ Item { } } - RowLayout { + SnapLayout { id: sliderLayout anchors.bottom: parent.bottom diff --git a/Modules/Settings/NavPane.qml b/Modules/Settings/NavPane.qml index 820e3e3..b5ea63a 100644 --- a/Modules/Settings/NavPane.qml +++ b/Modules/Settings/NavPane.qml @@ -1,11 +1,13 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import qs.Modules.Settings.NavPane import ZShell.Config import qs.Services -ColumnLayout { +SnapLayout { +orientation: Qt.Vertical id: root required property SettingsState sState diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index a73378d..e8f76a8 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import qs.Components import ZShell.Config @@ -48,7 +49,8 @@ VerticalFadeFlickable { onTapped: root.focus = true } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: content anchors.left: parent.left @@ -98,7 +100,7 @@ VerticalFadeFlickable { onClicked: root.sState.currentPageIdx = item.index } - RowLayout { + SnapLayout { id: layout anchors.fill: parent @@ -124,7 +126,8 @@ VerticalFadeFlickable { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 @@ -179,7 +182,8 @@ VerticalFadeFlickable { values: root.groups } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: group required property int index @@ -188,7 +192,7 @@ VerticalFadeFlickable { spacing: Tokens.spacing.small width: resultList.width - RowLayout { + SnapLayout { Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall spacing: Tokens.spacing.small @@ -265,7 +269,8 @@ VerticalFadeFlickable { RadiusBehavior on topLeftRadius {} RadiusBehavior on topRightRadius {} - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: resultLayout anchors.fill: parent diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 591e7e1..2cc5603 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -2,6 +2,7 @@ pragma Singleton import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Components import ZShell.Config import qs.Modules.Settings.Common @@ -158,7 +159,8 @@ QtObject { component PlaceholderComp: Item { property SettingsState sState // To avoid the warning from non-existent property - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.centerIn: parent spacing: Tokens.padding.extraSmall diff --git a/Modules/Settings/Pages/AboutPage.qml b/Modules/Settings/Pages/AboutPage.qml index 53bc19e..ca31d30 100644 --- a/Modules/Settings/Pages/AboutPage.qml +++ b/Modules/Settings/Pages/AboutPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell.Io import ZShell import qs.Modules.Settings.Common @@ -17,7 +18,8 @@ PageBase { title: qsTr("About") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 @@ -38,7 +40,8 @@ PageBase { implicitHeight: hero.implicitHeight + Tokens.padding.extraLarge * 2 last: true - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: hero anchors.centerIn: parent diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index 228ffab..4e16fd5 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import qs.Components @@ -16,10 +17,13 @@ PageBase { isSubPage: true title: qsTr("All apps") - ColumnLayout { + SnapLayout { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + orientation: Qt.Vertical + width: root.cappedWidth Repeater { @@ -45,13 +49,14 @@ PageBase { } } - RowLayout { + SnapLayout { id: appRow anchors.fill: parent anchors.leftMargin: Tokens.padding.largeIncreased anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium IconImage { @@ -60,8 +65,10 @@ PageBase { source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + + orientation: Qt.Vertical spacing: 0 CustomText { diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index 4f549da..ef653f5 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import qs.Components @@ -32,14 +33,15 @@ PageBase { sState.closeSubPage(); } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 width: root.cappedWidth // Header - RowLayout { + SnapLayout { Layout.bottomMargin: Tokens.spacing.largeIncreased Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall @@ -51,7 +53,8 @@ PageBase { source: Quickshell.iconPath(root.app?.icon, "image-missing") } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: Tokens.spacing.extraSmall / 2 @@ -139,7 +142,7 @@ PageBase { Layout.fillWidth: true implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 - RowLayout { + SnapLayout { id: rowLayout anchors.fill: parent diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index 32206a0..3015ebc 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import ZShell @@ -16,7 +17,8 @@ PageBase { title: qsTr("Apps") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 @@ -134,7 +136,7 @@ PageBase { row.selected(modelData); } - RowLayout { + SnapLayout { id: itemLayout anchors.fill: parent @@ -147,7 +149,8 @@ PageBase { source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Pages/Audio/AppVolumes.qml b/Modules/Settings/Pages/Audio/AppVolumes.qml index 9d59da6..835310f 100644 --- a/Modules/Settings/Pages/Audio/AppVolumes.qml +++ b/Modules/Settings/Pages/Audio/AppVolumes.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Services.Pipewire import qs.Modules.Settings.Common @@ -17,7 +18,8 @@ PageBase { isSubPage: true title: qsTr("App volumes") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/AudioPage.qml b/Modules/Settings/Pages/AudioPage.qml index 7c6e0f7..a0bb150 100644 --- a/Modules/Settings/Pages/AudioPage.qml +++ b/Modules/Settings/Pages/AudioPage.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import qs.Daemons import ZShell.Config import qs.Components @@ -14,7 +15,8 @@ PageBase { title: qsTr("Audio") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 @@ -93,7 +95,7 @@ PageBase { onClicked: root.sState.openSubPage(1) } - RowLayout { + SnapLayout { id: appLayout anchors.fill: parent @@ -107,7 +109,8 @@ PageBase { text: "tune" } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 diff --git a/Modules/Settings/Pages/Panels/Bar/BarClock.qml b/Modules/Settings/Pages/Panels/Bar/BarClock.qml index 63461bd..6f0606e 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarClock.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarClock.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -38,7 +39,8 @@ PageBase { isSubPage: true title: qsTr("Clock") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index 7b0aea9..3de630d 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -1,4 +1,5 @@ import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Modules.Settings.Common @@ -14,7 +15,8 @@ PageBase { isSubPage: true title: qsTr("System icons") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/Bar/BarTray.qml b/Modules/Settings/Pages/Panels/Bar/BarTray.qml index a4a7696..8d72b46 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarTray.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarTray.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Modules.Settings.Common @@ -10,7 +11,8 @@ PageBase { isSubPage: true title: qsTr("Tray") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 8f6ddb6..da8b716 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -11,7 +12,8 @@ PageBase { isSubPage: true title: qsTr("Taskbar") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index 12d7d3e..112b5d4 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -12,7 +13,8 @@ PageBase { isSubPage: true title: qsTr("Dashboard") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/LauncherPanel.qml b/Modules/Settings/Pages/Panels/LauncherPanel.qml index 3cc41f3..4521efa 100644 --- a/Modules/Settings/Pages/Panels/LauncherPanel.qml +++ b/Modules/Settings/Pages/Panels/LauncherPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -12,7 +13,8 @@ PageBase { isSubPage: true title: qsTr("Launcher") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/ResourcesPanel.qml b/Modules/Settings/Pages/Panels/ResourcesPanel.qml index 3638a48..b29bb92 100644 --- a/Modules/Settings/Pages/Panels/ResourcesPanel.qml +++ b/Modules/Settings/Pages/Panels/ResourcesPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -12,7 +13,8 @@ PageBase { isSubPage: true title: qsTr("Resources") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml index 9eda863..734fa1f 100644 --- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -59,7 +60,8 @@ PageBase { isSubPage: true title: qsTr("Sidebar") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index 8de209b..7d6c645 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -22,7 +23,8 @@ PageBase { isSubPage: true title: qsTr("Sidebar") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/PanelsPage.qml b/Modules/Settings/Pages/PanelsPage.qml index 6a16a5b..c49db99 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -1,4 +1,5 @@ -import QtQuick.Layouts +import QtQuick +import ZShell.Components import ZShell.Config import qs.Modules.Settings.Common @@ -7,9 +8,12 @@ PageBase { title: qsTr("Panels") - ColumnLayout { + SnapLayout { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + + orientation: Qt.Vertical + spacing: Tokens.spacing.extraSmall / 2 width: root.cappedWidth diff --git a/Modules/Settings/Pages/ScreenshotPage.qml b/Modules/Settings/Pages/ScreenshotPage.qml index 2a08a2d..59df081 100644 --- a/Modules/Settings/Pages/ScreenshotPage.qml +++ b/Modules/Settings/Pages/ScreenshotPage.qml @@ -14,7 +14,8 @@ PageBase { title: qsTr("Screenshots") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Services/NotificationsPage.qml b/Modules/Settings/Pages/Services/NotificationsPage.qml index 75e4387..71f44b0 100644 --- a/Modules/Settings/Pages/Services/NotificationsPage.qml +++ b/Modules/Settings/Pages/Services/NotificationsPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Modules.Settings.Common @@ -10,7 +11,8 @@ PageBase { isSubPage: true title: qsTr("Notifications") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index e636fb5..2d2db80 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import ZShell.Services import qs.Components @@ -28,7 +29,8 @@ PageBase { title: qsTr("Services") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml index be09d14..ae138b0 100644 --- a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Io import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Paths import qs.Services @@ -59,7 +60,8 @@ PageBase { isSubPage: true title: qsTr("Colors & font") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index f0b0abd..e1f7f78 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Models import qs.Paths import qs.Helpers @@ -16,7 +17,8 @@ PageBase { isSubPage: true title: qsTr("Wallpapers") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top @@ -76,7 +78,8 @@ PageBase { implicitHeight: noWallsLayout.implicitHeight + Tokens.padding.extraLarge * 3 radius: Tokens.rounding.largeIncreased - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: noWallsLayout anchors.centerIn: parent diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index fe9747f..b48c936 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -51,7 +51,8 @@ PageBase { title: qsTr("Wallpaper & style") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.largeIncreased diff --git a/Modules/Sidebar/Chat/ChatContent.qml b/Modules/Sidebar/Chat/ChatContent.qml index f20e602..c9b38ec 100644 --- a/Modules/Sidebar/Chat/ChatContent.qml +++ b/Modules/Sidebar/Chat/ChatContent.qml @@ -148,7 +148,7 @@ Item { target.contentY = 0 } - RowLayout { + SnapLayout { id: header anchors.left: parent.left diff --git a/Modules/Sidebar/Chat/ChatList.qml b/Modules/Sidebar/Chat/ChatList.qml index a575bf2..2890fe2 100644 --- a/Modules/Sidebar/Chat/ChatList.qml +++ b/Modules/Sidebar/Chat/ChatList.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import ZShell.Components import qs.Modules.Sidebar.Chat.Content import qs.Services import qs.Components @@ -165,13 +166,16 @@ Item { } } - ColumnLayout { + SnapLayout { id: fabMenu anchors.bottom: fabRoot.top anchors.right: fabRoot.right anchors.bottomMargin: Tokens.padding.medium + orientation: Qt.Vertical + spacing: Tokens.spacing.small + Repeater { id: fabRep @@ -277,7 +281,7 @@ Item { } ] - RowLayout { + SnapLayout { id: fabMenuItemInner anchors.centerIn: parent diff --git a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml index 13ecf6c..3ebc60c 100644 --- a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml +++ b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -26,11 +26,13 @@ Item { border.width: 1 border.color: Colors.palette.m3outlineVariant - ColumnLayout { + SnapLayout { id: approvalLayout anchors.fill: parent anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small + orientation: Qt.Vertical CustomText { id: requestHeader @@ -187,19 +189,22 @@ Item { } } - ColumnLayout { + SnapLayout { id: toolList anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + Repeater { id: repeater model: root.current.pendingToolCalls?.slice(0, 4) ?? [] - delegate: RowLayout { + delegate: SnapLayout { id: toolText required property var modelData @@ -304,7 +309,7 @@ Item { Anim {} } - RowLayout { + SnapLayout { id: collapsedRow anchors.top: parent.top @@ -312,6 +317,8 @@ Item { anchors.right: parent.right anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.extraSmall + IconButton { id: toolExpandBtn diff --git a/Modules/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Sidebar/Chat/Content/CodeBlockView.qml index a101553..64ebbf0 100644 --- a/Modules/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Sidebar/Chat/Content/CodeBlockView.qml @@ -1,8 +1,8 @@ import QtQuick -import QtQuick.Effects import QtQuick.Shapes import QtQuick.Layouts import Quickshell +import ZShell.Components import ZShell.Config import ZShell.Llm import qs.Components @@ -143,7 +143,7 @@ CustomRect { onTriggered: refresh() } - RowLayout { + SnapLayout { id: headerRow anchors.top: parent.top @@ -152,6 +152,7 @@ CustomRect { anchors.topMargin: Tokens.padding.small anchors.leftMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.small Item { diff --git a/Modules/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Sidebar/Chat/Content/ContentBubble.qml index efa390b..38cc691 100644 --- a/Modules/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Sidebar/Chat/Content/ContentBubble.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Llm import ZShell.Config import qs.Modules.Sidebar.Chat diff --git a/Modules/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Sidebar/Chat/Content/EmptyBackground.qml index af0bfef..214db05 100644 --- a/Modules/Sidebar/Chat/Content/EmptyBackground.qml +++ b/Modules/Sidebar/Chat/Content/EmptyBackground.qml @@ -1,10 +1,12 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services -ColumnLayout { +SnapLayout { +orientation: Qt.Vertical id: root spacing: Tokens.spacing.small diff --git a/Modules/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Sidebar/Chat/Content/ErrorBubble.qml index 9a73c55..85dad7d 100644 --- a/Modules/Sidebar/Chat/Content/ErrorBubble.qml +++ b/Modules/Sidebar/Chat/Content/ErrorBubble.qml @@ -4,6 +4,7 @@ import ZShell.Llm import ZShell.Config import qs.Components import qs.Services +import ZShell.Components Item { id: root @@ -78,7 +79,7 @@ Item { color: Colors.palette.m3errorContainer implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2 - ColumnLayout { + SnapLayout { id: errorInner anchors.top: parent.top @@ -87,6 +88,7 @@ Item { anchors.margins: Tokens.padding.medium spacing: Tokens.spacing.small + orientation: Qt.Vertical CustomText { id: header diff --git a/Modules/Sidebar/Chat/Content/ModelsContainer.qml b/Modules/Sidebar/Chat/Content/ModelsContainer.qml index 0f9d039..d379dc8 100644 --- a/Modules/Sidebar/Chat/Content/ModelsContainer.qml +++ b/Modules/Sidebar/Chat/Content/ModelsContainer.qml @@ -212,12 +212,14 @@ Item { clip: true implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2 - ColumnLayout { + SnapLayout { id: innerLayout anchors.bottomMargin: Tokens.padding.largeIncreased anchors.fill: parent anchors.margins: Tokens.padding.extraLarge + spacing: 0 + orientation: Qt.Vertical CustomText { font: Tokens.font.title.builders.large.weight(Font.Normal).build() @@ -264,7 +266,7 @@ Item { } } - RowLayout { + SnapLayout { id: layout anchors.left: parent.left @@ -274,6 +276,8 @@ Item { anchors.leftMargin: Tokens.padding.large anchors.rightMargin: Tokens.padding.large + spacing: Tokens.spacing.extraSmall + CustomText { id: name @@ -313,8 +317,9 @@ Item { } } - RowLayout { + SnapLayout { Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall TextButton { diff --git a/Modules/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Sidebar/Chat/Content/ProcessBlock.qml index e349081..3d58418 100644 --- a/Modules/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Sidebar/Chat/Content/ProcessBlock.qml @@ -43,7 +43,7 @@ Item { } } - RowLayout { + SnapLayout { id: layout anchors.top: parent.top @@ -168,7 +168,7 @@ Item { implicitWidth: ListView.view.width implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin - RowLayout { + SnapLayout { id: header anchors.left: parent.left diff --git a/Modules/Sidebar/Chat/SidebarView.qml b/Modules/Sidebar/Chat/SidebarView.qml index 1033684..b98d347 100644 --- a/Modules/Sidebar/Chat/SidebarView.qml +++ b/Modules/Sidebar/Chat/SidebarView.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import QtQuick.Controls import Quickshell import ZShell.Blobs diff --git a/Modules/Sidebar/Content.qml b/Modules/Sidebar/Content.qml index f6cb612..ee734f0 100644 --- a/Modules/Sidebar/Content.qml +++ b/Modules/Sidebar/Content.qml @@ -1,5 +1,7 @@ pragma ComponentBehavior: Bound +import ZShell +import ZShell.Components import ZShell.Config import ZShell.Llm import QtQuick @@ -22,21 +24,25 @@ Item { target: ChatState } - ColumnLayout { + SnapLayout { id: layout anchors.fill: parent + spacing: Tokens.spacing.small + orientation: Qt.Vertical Tabs { + id: tabs Layout.fillWidth: true - Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight + Layout.preferredHeight: ZUtils.snap(ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight, root) screenState: root.screenState nonAnimWidth: layout.width visible: height > 0 } Item { + id: tabContent Layout.fillHeight: true Layout.fillWidth: true clip: true @@ -161,10 +167,11 @@ Item { } CustomRect { + id: separator Layout.fillWidth: true - Layout.topMargin: Tokens.padding.small - layout.spacing + Layout.topMargin: ZUtils.snap(Tokens.padding.small - layout.spacing, root) color: Colors.tPalette.m3outlineVariant - implicitHeight: 1 + implicitHeight: Math.abs(pages.x) > root.width / 2 ? ZUtils.snapFloor(1, root) : ZUtils.snap(1, root) } } } diff --git a/Modules/Sidebar/Notif.qml b/Modules/Sidebar/Notif.qml deleted file mode 100644 index 9a7b5c2..0000000 --- a/Modules/Sidebar/Notif.qml +++ /dev/null @@ -1,199 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import ZShell.Config -import ZShell.Components -import ZShell.Services -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null - required property bool expanded - required property NotificationRow modelData - readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height - required property ScreenState screenState - - color: { - const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2); - return expanded ? c : Qt.alpha(c, 0); - } - implicitHeight: nonAnimHeight - radius: Tokens.rounding.large - Tokens.padding.small - state: expanded ? "expanded" : "" - - Behavior on implicitHeight { - Anim { - } - } - states: State { - name: "expanded" - - PropertyChanges { - compactBody.anchors.margins: Tokens.padding.medium - dummySummary.anchors.margins: Tokens.padding.medium - expandedContent.anchors.margins: Tokens.padding.medium - summary.anchors.margins: Tokens.padding.medium - summary.maximumLineCount: Number.MAX_SAFE_INTEGER - summary.width: root.width - Tokens.padding.medium * 2 - timeStr.implicitWidth - Tokens.spacing.small - timeStr.anchors.margins: Tokens.padding.medium - } - } - transitions: Transition { - Anim { - properties: "margins,width,maximumLineCount" - } - } - - TextMetrics { - id: summaryHeightMetrics - - font: summary.font - text: " " // Use this height to prevent weird characters from changing the line height - } - - CustomText { - id: summary - - anchors.left: parent.left - anchors.top: parent.top - color: root.modelData?.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface - elide: Text.ElideRight - maximumLineCount: 1 - text: root.modelData?.summary ?? "" - width: parent.width - wrapMode: Text.WordWrap - } - - CustomText { - id: dummySummary - - anchors.left: parent.left - anchors.top: parent.top - text: root.modelData?.summary ?? "" - visible: false - } - - WrappedLoader { - id: compactBody - - anchors.left: dummySummary.right - anchors.leftMargin: Tokens.spacing.small - anchors.right: parent.right - anchors.top: parent.top - shouldBeActive: !root.expanded - - sourceComponent: CustomText { - color: root.modelData?.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3outline - elide: Text.ElideRight - text: String(root.modelData?.body ?? "").replace(/\n/g, " ") - } - } - - WrappedLoader { - id: timeStr - - anchors.right: parent.right - anchors.top: parent.top - shouldBeActive: root.expanded - - sourceComponent: CustomText { - animate: true - color: Colors.palette.m3outline - text: root.modelData?.timeStr ?? "" - } - } - - WrappedLoader { - id: expandedContent - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: summary.bottom - anchors.topMargin: Tokens.spacing.extraSmall - shouldBeActive: root.expanded - - sourceComponent: ExpandedBody { - } - } - - component ExpandedBody: ColumnLayout { - readonly property alias body: bodyText - - spacing: Tokens.spacing.medium - - CustomText { - id: bodyText - - Layout.fillWidth: true - color: root.modelData?.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3onSurface - text: String(root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given") - textFormat: Text.MarkdownText - wrapMode: Text.WordWrap - - onLinkActivated: link => { - Qt.openUrlExternally(link); - root.screenState.sidebar = false; - } - } - - NotifActionList { - notif: root.modelData - } - } - component WrappedLoader: Loader { - id: comp - - required property bool shouldBeActive - - active: false - opacity: 0 - - // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set - states: State { - name: "active" - when: comp.shouldBeActive - - PropertyChanges { - comp.active: true - comp.opacity: 1 - } - } - transitions: [ - Transition { - from: "" - to: "active" - - SequentialAnimation { - PropertyAction { - property: "active" - } - - Anim { - property: "opacity" - type: Anim.DefaultEffects - } - } - }, - Transition { - from: "active" - to: "" - - SequentialAnimation { - Anim { - property: "opacity" - type: Anim.DefaultEffects - } - - PropertyAction { - property: "active" - } - } - } - ] - } -} diff --git a/Modules/Sidebar/NotifActionList.qml b/Modules/Sidebar/NotifActionList.qml deleted file mode 100644 index e753620..0000000 --- a/Modules/Sidebar/NotifActionList.qml +++ /dev/null @@ -1,133 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Widgets -import ZShell.Config -import ZShell.Services -import qs.Components -import qs.Effects -import qs.Services - -Item { - id: root - - required property NotificationRow notif - - Layout.fillWidth: true - implicitHeight: flickable.contentHeight - - CustomFlickable { - id: flickable - - anchors.fill: parent - contentHeight: actionList.implicitHeight - contentWidth: Math.max(width, actionList.implicitWidth) - - RowLayout { - id: actionList - - anchors.fill: parent - spacing: Tokens.spacing.extraSmall - - Repeater { - model: [ - { - isClose: true - }, - ...Array.from(root.notif?.actions ?? []), - { - isCopy: true - } - ] - - CustomRect { - id: action - - required property var modelData - - Layout.fillHeight: true - Layout.fillWidth: true - Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Tokens.padding.large : 0) - color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 4) - implicitHeight: actionInner.implicitHeight + Tokens.padding.small - implicitWidth: actionInner.implicitWidth + Tokens.padding.medium * 2 - radius: actionStateLayer.pressed ? Tokens.rounding.medium / 2 : Tokens.rounding.large - - Behavior on Layout.preferredWidth { - Anim { - type: Anim.FastSpatial - } - } - Behavior on radius { - Anim { - type: Anim.FastSpatial - } - } - - Timer { - id: copyTimer - - interval: 3000 - - onTriggered: actionInner.item.text = "content_copy" - } - - StateLayer { - id: actionStateLayer - - onClicked: { - if (action.modelData.isClose) { - root.notif.close(); - } else if (action.modelData.isCopy) { - Quickshell.clipboardText = root.notif.body; - actionInner.item.text = "inventory"; - copyTimer.start(); - } else if (action.modelData.invokable) { - action.modelData.invoke(); - } else if (!root.notif.resident) { - root.notif.close(); - } - } - } - - Loader { - id: actionInner - - anchors.centerIn: parent - sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif?.hasActionIcons ? iconComp : textComp - } - - Component { - id: iconBtn - - MaterialIcon { - animate: action.modelData.isCopy ?? false - color: Colors.palette.m3onSurfaceVariant - text: action.modelData.isCopy ? "content_copy" : "close" - } - } - - Component { - id: iconComp - - IconImage { - asynchronous: true - source: Quickshell.iconPath(action.modelData.identifier) - } - } - - Component { - id: textComp - - CustomText { - color: Colors.palette.m3onSurfaceVariant - text: action.modelData.text - } - } - } - } - } - } -} diff --git a/Modules/Sidebar/NotifDock.qml b/Modules/Sidebar/NotifDock.qml index 87757ca..81713d7 100644 --- a/Modules/Sidebar/NotifDock.qml +++ b/Modules/Sidebar/NotifDock.qml @@ -7,88 +7,101 @@ import QtQuick.Layouts import ZShell.Components import ZShell.Config import qs.Components -import qs.Modules import qs.Daemons +import qs.Modules import qs.Services Item { id: root - readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) required property ScreenState screenState + readonly property int notifCount: NotifServer.notClosed.length anchors.fill: parent anchors.margins: Tokens.padding.small - Component.onCompleted: NotifServer.list.forEach(n => n.popup = false) + Component.onCompleted: NotifServer.list.forEach(n => n.dismissPopup()) Item { id: title - anchors.left: parent.left - anchors.margins: 4 - anchors.right: parent.right anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.extraSmall + implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight) CustomText { id: count + anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin - anchors.verticalCenter: parent.verticalCenter + opacity: root.notifCount > 0 ? 1 : 0 + + text: root.notifCount color: Colors.palette.m3outline font: Tokens.font.label.large - opacity: root.notifCount > 0 ? 1 : 0 - text: root.notifCount Behavior on anchors.leftMargin { Anim {} } + Behavior on opacity { - Anim {} + Anim { + type: Anim.DefaultEffects + } } } CustomText { id: titleText - anchors.left: count.right - anchors.leftMargin: 7 - anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - color: Colors.palette.m3outline - elide: Text.ElideRight - font: Tokens.font.label.large + anchors.left: count.right + anchors.right: parent.right + anchors.leftMargin: Tokens.spacing.extraSmall + text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") + color: Colors.palette.m3outline + font: Tokens.font.label.large + elide: Text.ElideRight } } Item { id: clipRect - anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right anchors.top: title.bottom - anchors.topMargin: 10 + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.medium + + clip: true Loader { - active: opacity > 0 + asynchronous: true anchors.centerIn: parent + active: opacity > 0 opacity: root.notifCount > 0 ? 0 : 1 - Behavior on opacity { - Anim {} - } - sourceComponent: ColumnLayout { - spacing: 20 + sourceComponent: SnapLayout { + orientation: Qt.Vertical + spacing: Tokens.spacing.extraLarge CustomText { Layout.alignment: Qt.AlignHCenter + text: qsTr("No notifications") color: Colors.palette.m3outlineVariant font: Tokens.font.headline.builders.small.width(90).build() - text: qsTr("No Notifications") + } + } + + Behavior on opacity { + Anim { + type: Anim.StandardExtraLarge } } } @@ -97,10 +110,10 @@ Item { id: view anchors.fill: parent - contentHeight: notifList.implicitHeight - contentWidth: width + flickableDirection: Flickable.VerticalFlick - clip: true + contentWidth: width + contentHeight: notifList.implicitHeight CustomScrollBar.vertical: CustomScrollBar { flickable: view @@ -109,8 +122,8 @@ Item { NotifDockList { id: notifList - container: view screenState: root.screenState + container: view } } } @@ -118,21 +131,19 @@ Item { Timer { id: clearTimer - interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length))) repeat: true triggeredOnStart: true - + interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length))) onTriggered: { - const first = NotifServer.notClosed[0]; - if (!first) { + const group = NotifServer.groups[0]; + if (!group) { stop(); return; } - const appName = first.appName; let cleared = 0; - for (const n of NotifServer.notClosed.filter(n => n.appName === appName)) { - n.close(); + for (const n of [...group.activeNotifs]) { + n.dismiss(); cleared++; if (cleared > 30) { interval = 5; @@ -143,33 +154,40 @@ Item { } Loader { - active: opacity > 0 - anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.small + asynchronous: true anchors.right: parent.right - opacity: root.notifCount > 0 ? 1 : 0 - scale: root.notifCount > 0 ? 1 : 0.5 + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + + scale: root.notifCount > 0 ? 1 : 0.5 + opacity: root.notifCount > 0 ? 1 : 0 + active: opacity > 0 - Behavior on opacity { - Anim {} - } - Behavior on scale { - Anim {} - } sourceComponent: IconButton { id: clearBtn - font: Tokens.font.icon.large icon: "clear_all" + font: Tokens.font.icon.large isRound: true - onClicked: clearTimer.start() Elevation { anchors.fill: parent - level: clearBtn.stateLayer.containsMouse ? 4 : 3 radius: parent.radius z: -1 + level: clearBtn.stateLayer.containsMouse ? 4 : 3 + } + } + + Behavior on scale { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } } } diff --git a/Modules/Sidebar/NotifDockList.qml b/Modules/Sidebar/NotifDockList.qml index 759e6d9..1fc4640 100644 --- a/Modules/Sidebar/NotifDockList.qml +++ b/Modules/Sidebar/NotifDockList.qml @@ -4,8 +4,10 @@ import QtQuick import Quickshell import ZShell.Components import ZShell.Config -import qs.Daemons +import ZShell.Services import qs.Components +import qs.Components.Notifs +import qs.Daemons import qs.Services LazyListView { @@ -16,121 +18,12 @@ LazyListView { anchors.left: parent?.left anchors.right: parent?.right - asynchronous: true - cacheBuffer: 400 implicitHeight: contentHeight - readyDelay: 1 - removeDuration: Tokens.anim.durations.normal + spacing: Tokens.spacing.small - useCustomViewport: true - viewport: Qt.rect(0, container.contentY, width, container.height) - - delegate: Component { - MouseArea { - id: notif - - readonly property bool closed: notifInner.notifCount === 0 - required property int index - required property string modelData - property int startY - - function closeAll(): void { - clearTimer.start(); - } - - LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight - LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight - LazyListView.visibleHeight: notifInner.implicitHeight - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - cursorShape: pressed ? Qt.ClosedHandCursor : undefined - drag.axis: Drag.XAxis - drag.target: this - enabled: !closed - hoverEnabled: true - implicitHeight: notifInner.implicitHeight - opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 - preventStealing: true - scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - Behavior on scale { - Anim { - } - } - Behavior on x { - Anim { - } - } - Behavior on y { - enabled: notif.LazyListView.ready - - Anim { - } - } - - onPositionChanged: event => { - if (pressed) { - const diffY = event.y - startY; - if (Math.abs(diffY) > Config.notifs.expandThreshold) - notifInner.toggleExpand(diffY > 0); - } - } - onPressed: event => { - startY = event.y; - if (event.button === Qt.RightButton) - notifInner.toggleExpand(!notifInner.expanded); - else if (event.button === Qt.MiddleButton) - closeAll(); - } - onReleased: event => { - if (Math.abs(x) < width * Config.notifs.clearThreshold) - x = 0; - else - closeAll(); - } - - Timer { - id: clearTimer - - interval: 15 - repeat: true - triggeredOnStart: true - - onTriggered: { - const notifs = NotifServer.notClosed.filter(n => n.appName === notif.modelData); - if (notifs.length === 0) { - stop(); - return; - } - - for (const n of notifs.slice(0, 30)) - n.close(); - } - } - - NotifGroup { - id: notifInner - - container: root.container - modelData: notif.modelData - screenState: root.screenState - } - } - } - model: ScriptModel { - values: { - const map = new Map(); - for (const n of NotifServer.notClosed) - map.set(n.appName, null); - for (const n of NotifServer.list) - map.set(n.appName, null); - return [...map.keys()]; - } - } + readyDelay: 1 + cacheBuffer: 400 + asynchronous: true onViewportAdjustNeeded: d => { if (contentYAnim.running) @@ -139,10 +32,121 @@ LazyListView { contentYAnim.start(); } + useCustomViewport: true + viewport: Qt.rect(0, container.contentY, width, container.height) + + removeDuration: Tokens.anim.durations.normal + + model: ScriptModel { + values: [...NotifServer.groups] + } + + delegate: Component { + NotifSwipeArea { + id: notif + + required property int index + required property NotificationGroup modelData + + readonly property bool closed: card.notifs.length === 0 + + function toggleExpand(expand: bool): void { + if (expand) { + if (!expanded) + root.screenState.expandedNotifs.push(modelData.appName); + } else if (expanded) { + root.screenState.expandedNotifs.splice(root.screenState.expandedNotifs.indexOf(modelData.appName), 1); + } + } + + function closeAll(): void { + clearTimer.start(); + } + + Component.onDestruction: { + if (closed && expanded) + toggleExpand(false); + } + + LazyListView.trackViewport: !expanded && card.nonAnimHeight < card.implicitHeight + LazyListView.preferredHeight: closed ? 0 : card.nonAnimHeight + LazyListView.visibleHeight: card.implicitHeight + implicitHeight: card.implicitHeight + + opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1 + scale: LazyListView.adding ? 0 : 1 + + expanded: root.screenState.expandedNotifs.includes(modelData.appName) + enabled: !closed + + onClosedChanged: { + if (closed) + slideOut.start(); + } + onRequestExpand: expand => toggleExpand(expand) + onSwiped: closeAll() + onDismissed: closeAll() + + Anim { + id: slideOut + + target: notif + property: "x" + to: notif.x >= 0 ? notif.width : -notif.width + } + + Timer { + id: clearTimer + + interval: 15 + repeat: true + triggeredOnStart: true + onTriggered: { + const notifs = [...notif.modelData.activeNotifs]; + if (notifs.length === 0) { + stop(); + return; + } + + for (const n of notifs.slice(0, 30)) + n.dismiss(); + } + } + + NotifGroupCard { + id: card + + group: notif.modelData + container: root.container + expanded: notif.expanded + baseLayer: 1 + onRequestExpand: expand => notif.toggleExpand(expand) + onSwiped: notif => notif.dismiss() + onLinkActivated: root.screenState.sidebar = false + } + + Behavior on y { + enabled: notif.LazyListView.ready + + Anim {} + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim {} + } + } + } + Anim { id: contentYAnim - property: "contentY" target: root.container + property: "contentY" } } diff --git a/Modules/Sidebar/NotifGroup.qml b/Modules/Sidebar/NotifGroup.qml deleted file mode 100644 index 4cb7b67..0000000 --- a/Modules/Sidebar/NotifGroup.qml +++ /dev/null @@ -1,241 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Notifications -import ZShell.Config -import qs.Components -import qs.Services -import qs.Helpers -import qs.Daemons - -CustomRect { - id: root - - readonly property list activeNotifs: notifs.filter(n => !n.closed) - readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" - required property Flickable container - readonly property bool expanded: screenState.expandedNotifs.includes(modelData) - readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" - required property string modelData - readonly property int nonAnimHeight: { - const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Tokens.spacing.extraSmall) : 0); - const columnHeight = headerHeight + notifList.layoutHeight; - return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.small * 2); - } - readonly property int notifCount: activeNotifs.length - readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) - required property ScreenState screenState - readonly property int urgency: { - if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) - return NotificationUrgency.Critical; - if (activeNotifs.find(n => n.urgency === NotificationUrgency.Normal)) - return NotificationUrgency.Normal; - return NotificationUrgency.Low; - } - - function toggleExpand(expand: bool): void { - if (expand) { - if (!expanded) - screenState.expandedNotifs.push(modelData); - } else if (expanded) { - screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); - } - } - - anchors.left: parent?.left - anchors.right: parent?.right - clip: true - color: Colors.layer(Colors.palette.m3surfaceContainer, 2) - implicitHeight: nonAnimHeight - radius: Tokens.rounding.large - - Behavior on implicitHeight { - Anim { - } - } - - Component.onDestruction: { - if (notifCount === 0 && expanded) - screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); - } - - RowLayout { - id: content - - anchors.left: parent.left - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium - - Item { - Layout.alignment: Qt.AlignLeft | Qt.AlignTop - implicitHeight: Config.notifs.sizes.image - implicitWidth: Config.notifs.sizes.image - - Component { - id: imageComp - - Image { - asynchronous: true - cache: false - fillMode: Image.PreserveAspectCrop - height: Config.notifs.sizes.image - source: Qt.resolvedUrl(root.image) - sourceSize: { - const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); - return Qt.size(size, size); - } - width: Config.notifs.sizes.image - } - } - - Component { - id: appIconComp - - ColoredIcon { - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - implicitSize: Math.round(Config.notifs.sizes.image * 0.6) - layer.enabled: root.appIcon.endsWith("symbolic") - source: Quickshell.iconPath(root.appIcon) - } - } - - Component { - id: materialIconComp - - MaterialIcon { - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - text: Icons.getNotifIcon(root.activeNotifs[0]?.summary, root.urgency) - } - } - - ClipRect { - anchors.fill: parent - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) : Colors.palette.m3secondaryContainer - radius: Tokens.rounding.full - - Loader { - anchors.centerIn: parent - anchors.verticalCenterOffset: sourceComponent === materialIconComp ? 1 : 0 - asynchronous: true - sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp - } - } - - Loader { - active: root.appIcon && root.image - anchors.bottom: parent.bottom - anchors.right: parent.right - asynchronous: true - - sourceComponent: CustomRect { - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.urgency === NotificationUrgency.Low ? Colors.palette.m3surfaceContainerHigh : Colors.palette.m3secondaryContainer - implicitHeight: Config.notifs.sizes.badge - implicitWidth: Config.notifs.sizes.badge - radius: Tokens.rounding.full - - ColoredIcon { - anchors.centerIn: parent - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) - layer.enabled: root.appIcon.endsWith("symbolic") - source: Quickshell.iconPath(root.appIcon) - } - } - } - } - - Column { - id: column - - Layout.fillWidth: true - spacing: root.expanded ? Math.round(Tokens.spacing.extraSmall) : 0 - - Behavior on spacing { - Anim { - } - } - - RowLayout { - id: header - - anchors.left: parent.left - anchors.right: parent.right - spacing: Tokens.spacing.small - - CustomText { - Layout.fillWidth: true - color: Colors.palette.m3onSurfaceVariant - elide: Text.ElideRight - text: root.modelData - } - - CustomText { - animate: true - color: Colors.palette.m3outline - text: root.activeNotifs[0]?.timeStr ?? "" - } - - CustomRect { - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) - implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall - implicitWidth: expandBtn.implicitWidth + Tokens.padding.large - radius: Tokens.rounding.full - - StateLayer { - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurface - - onClicked: root.toggleExpand(!root.expanded) - } - - RowLayout { - id: expandBtn - - anchors.centerIn: parent - spacing: Tokens.spacing.extraSmall - - CustomText { - id: groupCount - - Layout.leftMargin: Tokens.padding.extraSmall / 2 - animate: true - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurfaceVariant - text: root.notifCount - } - - MaterialIcon { - Layout.rightMargin: -Tokens.padding.extraSmall / 2 - Layout.topMargin: root.expanded ? -Math.floor(Tokens.padding.extraSmall) : 0 - color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurfaceVariant - rotation: root.expanded ? 180 : 0 - text: "expand_more" - - Behavior on Layout.topMargin { - Anim { - } - } - Behavior on rotation { - Anim { - } - } - } - } - } - } - - NotifGroupList { - id: notifList - - container: root.container - expanded: root.expanded - notifs: root.notifs - screenState: root.screenState - - onRequestToggleExpand: expand => root.toggleExpand(expand) - } - } - } -} diff --git a/Modules/Sidebar/NotifGroupList.qml b/Modules/Sidebar/NotifGroupList.qml deleted file mode 100644 index 109ce8d..0000000 --- a/Modules/Sidebar/NotifGroupList.qml +++ /dev/null @@ -1,152 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import ZShell.Components -import ZShell.Services -import ZShell.Config -import qs.Components -import qs.Services - -LazyListView { - id: root - - required property Flickable container - required property bool expanded - required property list notifs - required property ScreenState screenState - - signal requestToggleExpand(expand: bool) - - anchors.left: parent.left - anchors.right: parent.right - asynchronous: true - cacheBuffer: 400 - implicitHeight: contentHeight - readyDelay: 1 - removeDuration: Tokens.anim.durations.normal - spacing: Math.round(Tokens.spacing.extraSmall) - useCustomViewport: true - viewport: { - tWatcher.transform; // mapToItem is not reactive so use this to trigger updates - return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height); - } - - delegate: Component { - MouseArea { - id: notif - - required property int index - required property NotificationRow modelData - property int startY - - LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight - LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined - drag.axis: Drag.XAxis - drag.target: this - enabled: !(modelData?.closed ?? true) - hoverEnabled: true - implicitHeight: notifInner.implicitHeight - opacity: LazyListView.removing || LazyListView.adding ? 0 : 1 - preventStealing: !root.expanded - scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - Behavior on scale { - Anim { - } - } - Behavior on x { - Anim { - } - } - Behavior on y { - enabled: notif.LazyListView.ready - - Anim { - } - } - - Component.onCompleted: modelData?.lock(this) - Component.onDestruction: modelData?.unlock(this) - onPositionChanged: event => { - if (pressed && !root.expanded) { - const diffY = event.y - startY; - if (Math.abs(diffY) > Config.notifs.expandThreshold) - root.requestToggleExpand(diffY > 0); - } - } - onPressed: event => { - startY = event.y; - if (event.button === Qt.RightButton) - root.requestToggleExpand(!root.expanded); - else if (event.button === Qt.MiddleButton) - modelData?.close(); - } - onReleased: event => { - if (Math.abs(x) < width * Config.notifs.clearThreshold) - x = 0; - else - modelData?.close(); - } - - ParallelAnimation { - running: notif.modelData?.closed ?? false - - onFinished: notif.modelData?.unlock(notif) - - Anim { - property: "opacity" - target: notif - to: 0 - type: Anim.DefaultEffects - } - - Anim { - property: "x" - target: notif - to: notif.x >= 0 ? notif.width : -notif.width - } - } - - Notif { - id: notifInner - - anchors.fill: parent - expanded: root.expanded - modelData: notif.modelData - screenState: root.screenState - } - } - } - model: ScriptModel { - values: { - if (root.expanded) - return root.notifs as Array; - - let count = 0; - let i = 0; - const previewNum = Config.notifs.groupPreviewNum; - while (i < root.notifs.length && count < previewNum) { - if (!(root.notifs[i]?.closed ?? true)) - count++; - i++; - } - - return root.notifs.slice(0, i) as Array; - } - } - - TransformWatcher { - id: tWatcher - - a: root.container.contentItem - b: root - } -} diff --git a/Modules/Sidebar/Tabs.qml b/Modules/Sidebar/Tabs.qml index 3dbd448..2ef6570 100644 --- a/Modules/Sidebar/Tabs.qml +++ b/Modules/Sidebar/Tabs.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import QtQuick.Templates import Quickshell +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -27,7 +28,7 @@ Item { currentIndex: root.screenState.sidebarTab implicitHeight: contentHeight - contentItem: RowLayout { + contentItem: SnapLayout { spacing: 0 Repeater { @@ -105,7 +106,7 @@ Item { implicitWidth: Math.max(icon.width, label.width) StateLayer { - color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface radius: Tokens.rounding.small onClicked: root.screenState.sidebarTab = tab.TabBar.index diff --git a/Modules/Sidebar/Utils/Cards/Record.qml b/Modules/Sidebar/Utils/Cards/Record.qml index 5094d86..c9f6197 100644 --- a/Modules/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Sidebar/Utils/Cards/Record.qml @@ -3,8 +3,10 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts -import qs.Components +import ZShell.Components +import ZShell import ZShell.Config +import qs.Components import qs.Helpers import qs.Services @@ -15,17 +17,18 @@ CustomRect { Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + implicitHeight: ZUtils.snap(layout.implicitHeight + layout.anchors.margins * 2, root) radius: Tokens.rounding.small - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.fill: parent anchors.margins: Tokens.padding.large spacing: Tokens.spacing.medium - RowLayout { + SnapLayout { spacing: Tokens.spacing.medium z: 1 @@ -50,7 +53,8 @@ CustomRect { } } - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical Layout.fillWidth: true spacing: 0 @@ -194,7 +198,7 @@ CustomRect { Component { id: recordingControls - RowLayout { + SnapLayout { spacing: Tokens.spacing.medium CustomRect { diff --git a/Modules/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Sidebar/Utils/Cards/RecordingList.qml index e06e927..b8c1985 100644 --- a/Modules/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Sidebar/Utils/Cards/RecordingList.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import ZShell.Components import Quickshell import Quickshell.Widgets import ZShell.Models @@ -11,7 +12,8 @@ import qs.Paths import ZShell.Config import qs.Services -ColumnLayout { +SnapLayout { +orientation: Qt.Vertical id: root required property ScreenState screenState @@ -24,7 +26,7 @@ ColumnLayout { onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded - RowLayout { + SnapLayout { spacing: Tokens.spacing.small MaterialIcon { @@ -74,7 +76,7 @@ ColumnLayout { to: 1 } } - delegate: RowLayout { + delegate: SnapLayout { id: recording property string baseName @@ -168,7 +170,8 @@ ColumnLayout { Behavior on opacity { Anim {} } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { +orientation: Qt.Vertical spacing: Tokens.spacing.small MaterialIcon { @@ -191,7 +194,7 @@ ColumnLayout { } } - RowLayout { + SnapLayout { spacing: Tokens.spacing.small MaterialIcon { diff --git a/Modules/Sidebar/Utils/Cards/Toggles.qml b/Modules/Sidebar/Utils/Cards/Toggles.qml index e0128f9..14d5785 100644 --- a/Modules/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Sidebar/Utils/Cards/Toggles.qml @@ -45,7 +45,8 @@ CustomRect { implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased radius: Tokens.rounding.small - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.fill: parent diff --git a/Modules/Sidebar/Utils/Content.qml b/Modules/Sidebar/Utils/Content.qml index 81e9296..749adf2 100644 --- a/Modules/Sidebar/Utils/Content.qml +++ b/Modules/Sidebar/Utils/Content.qml @@ -2,22 +2,26 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell import qs.Components import qs.Modules.Sidebar.Utils.Cards +import ZShell.Components Item { id: root required property ScreenState screenState - implicitHeight: layout.implicitHeight - implicitWidth: layout.implicitWidth + implicitHeight: ZUtils.snap(layout.implicitHeight, root) + implicitWidth: ZUtils.snap(layout.implicitWidth, root) - ColumnLayout { + SnapLayout { id: layout anchors.fill: parent - spacing: 8 + + spacing: Tokens.spacing.small + orientation: Qt.Vertical IdleInhibit {} diff --git a/Modules/Sidebar/Utils/IdleInhibit.qml b/Modules/Sidebar/Utils/IdleInhibit.qml index 7cb4b6e..f5d85ca 100644 --- a/Modules/Sidebar/Utils/IdleInhibit.qml +++ b/Modules/Sidebar/Utils/IdleInhibit.qml @@ -1,8 +1,10 @@ -import qs.Components -import ZShell.Config -import qs.Helpers import QtQuick import QtQuick.Layouts +import ZShell +import ZShell.Config +import ZShell.Components +import qs.Components +import qs.Helpers import qs.Services CustomRect { @@ -11,21 +13,22 @@ CustomRect { Layout.fillWidth: true clip: true color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + 18 * 2 + implicitHeight: ZUtils.snap((layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + 18 * 2), root) radius: Tokens.rounding.small Behavior on implicitHeight { Anim {} } - RowLayout { + SnapLayout { id: layout anchors.left: parent.left anchors.margins: 18 anchors.right: parent.right anchors.top: parent.top - spacing: 10 + + spacing: Tokens.spacing.medium CustomRect { color: IdleInhibitor.enabled ? Colors.palette.m3secondary : Colors.palette.m3secondaryContainer @@ -43,9 +46,11 @@ CustomRect { } } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: 0 + orientation: Qt.Vertical CustomText { Layout.fillWidth: true diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 8cc48df..2a14718 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -6,11 +6,15 @@ find_package(Qt6 REQUIRED COMPONENTS GuiPrivate Quick CanvasPainter + CanvasPainterTools QuickPrivate + QuickLayouts + QuickLayoutsPrivate Concurrent Sql Network DBus + Svg CanvasPainter WaylandClient WaylandClientPrivate diff --git a/Plugins/ZShell/Blobs/blobgroup.cpp b/Plugins/ZShell/Blobs/blobgroup.cpp index c286650..b993c73 100644 --- a/Plugins/ZShell/Blobs/blobgroup.cpp +++ b/Plugins/ZShell/Blobs/blobgroup.cpp @@ -2,7 +2,17 @@ #include "blobinvertedrect.hpp" #include "blobshape.hpp" -BlobGroup::BlobGroup(QObject* parent) : QObject(parent) {} +#include + +namespace { +constexpr int k_pumpMs = 16; +} // namespace + +BlobGroup::BlobGroup(QObject* parent) : QObject(parent) { + m_physicsPump.setInterval(k_pumpMs); + m_physicsPump.setTimerType(Qt::CoarseTimer); + connect(&m_physicsPump, &QTimer::timeout, this, &BlobGroup::pumpPhysics); +} BlobGroup::~BlobGroup() { for (auto* shape : std::as_const(m_shapes)) @@ -12,6 +22,7 @@ BlobGroup::~BlobGroup() { } void BlobGroup::setSmoothing(qreal s) { + if (s < 0.5) s = 0.5; if (qFuzzyCompare(m_smoothing, s)) return; m_smoothing = s; emit smoothingChanged(); @@ -73,7 +84,6 @@ void BlobGroup::markShapeDirty(BlobShape* source) { source->polish(); source->update(); - // Use cached padded rects to find spatial neighbors const float pad = static_cast(m_smoothing) * 2.0f; const QRectF srcRect( static_cast(source->m_cachedPaddedX - pad), @@ -103,6 +113,33 @@ void BlobGroup::markShapeDirty(BlobShape* source) { void BlobGroup::ensurePhysicsUpdated() { if (m_physicsUpdated) return; m_physicsUpdated = true; + for (auto* shape : std::as_const(m_shapes)) { + shape->updatePhysics(); + if (shape->needsFrame()) { + shape->polish(); + shape->update(); + } + } +} + +void BlobGroup::requestPhysics() { + if (!m_physicsPump.isActive()) m_physicsPump.start(); +} + +void BlobGroup::pumpPhysics() { for (auto* shape : std::as_const(m_shapes)) shape->updatePhysics(); + + const bool anyMoving = std::any_of( + m_shapes.cbegin(), m_shapes.cend(), [](const BlobShape* shape) { + return shape->needsFrame(); + }); + + if (!anyMoving) { + m_physicsPump.stop(); + return; + } + + markDirty(); + m_physicsUpdated = true; } diff --git a/Plugins/ZShell/Blobs/blobgroup.hpp b/Plugins/ZShell/Blobs/blobgroup.hpp index eb3ca35..a98709b 100644 --- a/Plugins/ZShell/Blobs/blobgroup.hpp +++ b/Plugins/ZShell/Blobs/blobgroup.hpp @@ -4,6 +4,7 @@ #include #include #include +#include class BlobShape; class BlobInvertedRect; @@ -51,16 +52,21 @@ class BlobGroup : public QObject { void markShapeDirty(BlobShape* source); void ensurePhysicsUpdated(); + void requestPhysics(); + signals: void smoothingChanged(); void colorChanged(); void cornerFillChanged(); private: + void pumpPhysics(); + qreal m_smoothing = 32.0; QColor m_color{0x44, 0x88, 0xff}; bool m_cornerFill = true; QList m_shapes; BlobInvertedRect* m_invertedRect = nullptr; bool m_physicsUpdated = false; + QTimer m_physicsPump; }; diff --git a/Plugins/ZShell/Blobs/blobgroup2.cpp b/Plugins/ZShell/Blobs/blobgroup2.cpp index 0f642ed..c298e90 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.cpp +++ b/Plugins/ZShell/Blobs/blobgroup2.cpp @@ -29,8 +29,15 @@ static float cornerFillFactor(float sd, float smoothFactor) { return std::max(outside, inside); } +namespace { +constexpr int k_pumpMs = 16; +} // namespace + BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents); + m_physicsPump.setInterval(k_pumpMs); + m_physicsPump.setTimerType(Qt::CoarseTimer); + connect(&m_physicsPump, &QTimer::timeout, this, &BlobGroup2::pumpPhysics); } BlobGroup2::~BlobGroup2() { @@ -41,6 +48,7 @@ BlobGroup2::~BlobGroup2() { } void BlobGroup2::setSmoothing(qreal s) { + if (s < 0.5) s = 0.5; if (qFuzzyCompare(m_smoothing, s)) return; m_smoothing = s; emit smoothingChanged(); @@ -101,17 +109,11 @@ void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) { void BlobGroup2::markDirty() { m_physicsUpdated = false; m_dataBuilt = false; - // Physics runs during polish, before the render pass. The panel content is - // transformed by deformMatrix from QML, so the matrix has to be final for this - // frame; building the data in updatePaintNode would let the transform lag one - // frame behind the material. polish(); update(); } void BlobGroup2::updatePolish() { - // The group is the only thing that draws, so it also drives the shapes' - // physics. ensureDataBuilt(); } @@ -122,32 +124,53 @@ void BlobGroup2::ensurePhysicsUpdated() { shape->updatePhysics(); } +void BlobGroup2::requestPhysics() { + if (!m_physicsPump.isActive()) m_physicsPump.start(); +} + +void BlobGroup2::pumpPhysics() { + for (auto* shape : std::as_const(m_shapes)) + shape->updatePhysics(); + + m_physicsUpdated = true; + + const bool anyMoving = std::any_of( + m_shapes.cbegin(), m_shapes.cend(), [](const BlobShape2* shape) { + return shape->needsFrame(); + }); + + if (!anyMoving) { + m_physicsPump.stop(); + return; + } + + m_dataBuilt = false; + polish(); + update(); +} + void BlobGroup2::ensureDataBuilt() { if (m_dataBuilt) return; ensurePhysicsUpdated(); - // Physics can request the next frame through markDirty(); the data is - // refreshed here anyway, so the flag is set after it runs. m_dataBuilt = true; m_data.rects.clear(); m_bounds = QRectF(); + m_data.hasInverted = false; + m_data.invertedRadius = 0; + memset(m_data.invertedOuter, 0, sizeof(m_data.invertedOuter)); + memset(m_data.invertedInner, 0, sizeof(m_data.invertedInner)); + memset(m_data.invertedOuterRadii, 0, sizeof(m_data.invertedOuterRadii)); const float smoothFactor = static_cast(m_smoothing); - // The same reach the shader uses to decide whether a rect can contribute, plus - // the shadow, so every pixel the shader can give a value to is inside the node. const double extent = static_cast(smoothFactor) * 1.5 + static_cast(m_shadowRadius); - // Group order, no pruning by proximity: the shader sees this list for every pixel. A - // shape that is not pushing is not in the list at all: shapes here are data for the - // group draw, so leaving a panel at rest out means it cannot define any part of the - // edge. A closing panel is still in the list while its curve runs out, even with no - // size left, which is what keeps the bounce. - // entryShapes maps a shared entry back to the shape it came from. + const bool dropAtRest = m_invertedRect != nullptr; QVector entryShapes; for (auto* shape : std::as_const(m_shapes)) { - if (!shape->isMoving()) continue; + if (dropAtRest && !shape->isMoving()) continue; if (shape->width() <= 0 || shape->height() <= 0) continue; if (static_cast(m_data.rects.size()) >= BlobGroupData::kMaxRects) continue; @@ -194,7 +217,6 @@ void BlobGroup2::ensureDataBuilt() { const int count = static_cast(m_data.rects.size()); - // Exclusion gates only the smooth blend. for (int i = 0; i < count; ++i) { int mask = 0; BlobShape2* const si = entryShapes[i]; @@ -214,10 +236,11 @@ void BlobGroup2::ensureDataBuilt() { BlobShape2* const si = entryShapes[i]; float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; - const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; - const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; - const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; - const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + const float dcx = ri.cx + ri.offsetX, dcy = ri.cy + ri.offsetY; + const float cTrX = dcx + ri.hw, cTrY = dcy - ri.hh; + const float cBrX = dcx + ri.hw, cBrY = dcy + ri.hh; + const float cBlX = dcx - ri.hw, cBlY = dcy + ri.hh; + const float cTlX = dcx - ri.hw, cTlY = dcy - ri.hh; if (cornerFill) { for (int j = 0; j < count; ++j) { @@ -227,37 +250,41 @@ void BlobGroup2::ensureDataBuilt() { if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) continue; const auto& rj = m_data.rects[j]; + const float jcx = rj.cx + rj.offsetX; + const float jcy = rj.cy + rj.offsetY; fTr = std::min( fTr, cornerFillFactor( - cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh), + cpuSdBox(cTrX, cTrY, jcx, jcy, rj.hw, rj.hh), smoothFactor)); fBr = std::min( fBr, cornerFillFactor( - cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh), + cpuSdBox(cBrX, cBrY, jcx, jcy, rj.hw, rj.hh), smoothFactor)); fBl = std::min( fBl, cornerFillFactor( - cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh), + cpuSdBox(cBlX, cBlY, jcx, jcy, rj.hw, rj.hh), smoothFactor)); fTl = std::min( fTl, cornerFillFactor( - cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh), + cpuSdBox(cTlX, cTlY, jcx, jcy, rj.hw, rj.hh), smoothFactor)); } } - ri.radius[0] = std::max(ri.radius[0] * fTr, minR); - ri.radius[1] = std::max(ri.radius[1] * fBr, minR); - ri.radius[2] = std::max(ri.radius[2] * fBl, minR); - ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + ri.radius[0] = ri.radius[0] > 0.0f ? std::max(ri.radius[0] * fTr, minR) + : 0.0f; + ri.radius[1] = ri.radius[1] > 0.0f ? std::max(ri.radius[1] * fBr, minR) + : 0.0f; + ri.radius[2] = ri.radius[2] > 0.0f ? std::max(ri.radius[2] * fBl, minR) + : 0.0f; + ri.radius[3] = ri.radius[3] > 0.0f ? std::max(ri.radius[3] * fTl, minR) + : 0.0f; } - // Inverted rect. The frame term is evaluated for every pixel in the bounds, - // so the group flag cannot be gated per shape. if (!m_invertedRect) return; auto* inv = static_cast(m_invertedRect); @@ -297,6 +324,8 @@ void BlobGroup2::ensureDataBuilt() { m_data.invertedInner[2] = innerHW; m_data.invertedInner[3] = innerHH; + inv->outerCornerRadii(m_data.invertedOuterRadii); + m_bounds = m_bounds.united(QRectF( static_cast(outerCX) - static_cast(outerHW) - extent, static_cast(outerCY) - static_cast(outerHH) - extent, @@ -311,7 +340,6 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) { material->m_paddedH = static_cast(m_bounds.height()); material->m_smoothFactor = static_cast(m_smoothing); - // The single group draw has no owner, so the shader skips the ownership test. material->m_myIndex = -2; material->m_color = m_color; material->m_hasInverted = m_data.hasInverted ? 1 : 0; @@ -324,6 +352,10 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) { material->m_invertedInner, data().invertedInner, sizeof(data().invertedInner)); + memcpy( + material->m_invertedOuterRadii, + data().invertedOuterRadii, + sizeof(data().invertedOuterRadii)); material->m_shadowRadius = static_cast(m_shadowRadius); material->m_shadowColor[0] = static_cast(m_shadowColor.redF()); @@ -339,8 +371,6 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) { } QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { - // Data is built during polish. This only covers a render pass that happens - // without one. ensureDataBuilt(); if (m_data.rects.isEmpty() && !m_data.hasInverted) { @@ -364,10 +394,6 @@ QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { node->setFlag(QSGNode::OwnsMaterial); } - // The shader evaluates the field at the scene position a vertex maps to, so - // the group item itself must not be transformed. The vertex positions are the - // bounds in this item's frame; the material carries the same rect in scene - // coordinates. auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); const QPointF p0 = mapFromScene(m_bounds.topLeft()); const QPointF p1 = mapFromScene(m_bounds.bottomRight()); diff --git a/Plugins/ZShell/Blobs/blobgroup2.hpp b/Plugins/ZShell/Blobs/blobgroup2.hpp index 9f922e6..23ca501 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.hpp +++ b/Plugins/ZShell/Blobs/blobgroup2.hpp @@ -9,22 +9,13 @@ #include #include #include +#include #include class BlobShape2; class BlobInvertedRect2; -// One shared valuation for a whole group, drawn by one node. -// -// The shapes here are data only: they say where the material should be. The group -// draws a single quad over everything the shader can evaluate, so a pixel cannot -// be evaluated by one shape and missed by another, and no second draw can -// disagree with the first. That is what makes overlapping shapes read as one -// surface - one value, one writer, one alpha per pixel. struct BlobGroupData { - // The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit - // mask. The shared list is capped at 16, in group order, so the cap is the - // same list for every pixel. static constexpr int kMaxRects = 16; QVector rects; @@ -32,6 +23,7 @@ struct BlobGroupData { float invertedRadius = 0; float invertedOuter[4] = {}; float invertedInner[4] = {}; + float invertedOuterRadii[4] = {}; }; class BlobGroup2 : public QQuickItem { @@ -91,10 +83,10 @@ class BlobGroup2 : public QQuickItem { [[nodiscard]] const BlobGroupData& data() const { return m_data; } - // One draw per group, so a change to any shape changes the value for every - // pixel. Refresh the whole group. void markDirty(); + void requestPhysics(); + signals: void smoothingChanged(); void colorChanged(); @@ -110,6 +102,7 @@ class BlobGroup2 : public QQuickItem { void ensurePhysicsUpdated(); void ensureDataBuilt(); void fillMaterial(BlobMaterial* material); + void pumpPhysics(); qreal m_smoothing = 32.0; QColor m_color{0x44, 0x88, 0xff}; @@ -120,9 +113,8 @@ class BlobGroup2 : public QQuickItem { BlobInvertedRect2* m_invertedRect = nullptr; bool m_physicsUpdated = false; bool m_dataBuilt = false; + QTimer m_physicsPump; BlobGroupData m_data; - // Scene-space union of what the shader can evaluate for any shape. The node - // covers exactly this, so gaps are not possible. QRectF m_bounds; }; diff --git a/Plugins/ZShell/Blobs/blobinvertedrect.cpp b/Plugins/ZShell/Blobs/blobinvertedrect.cpp index 0e4336c..2b5f218 100644 --- a/Plugins/ZShell/Blobs/blobinvertedrect.cpp +++ b/Plugins/ZShell/Blobs/blobinvertedrect.cpp @@ -11,28 +11,24 @@ BlobInvertedRect::BlobInvertedRect(QQuickItem* parent) : BlobShape(parent) {} static void setFrameIndices(quint16* idx) { - // Top strip: 0-1-4, 1-5-4 idx[0] = 0; idx[1] = 1; idx[2] = 4; idx[3] = 1; idx[4] = 5; idx[5] = 4; - // Right strip: 1-2-5, 2-6-5 idx[6] = 1; idx[7] = 2; idx[8] = 5; idx[9] = 2; idx[10] = 6; idx[11] = 5; - // Bottom strip: 2-3-6, 3-7-6 idx[12] = 2; idx[13] = 3; idx[14] = 6; idx[15] = 3; idx[16] = 7; idx[17] = 6; - // Left strip: 3-0-7, 0-4-7 idx[18] = 3; idx[19] = 0; idx[20] = 7; @@ -50,17 +46,20 @@ QSGNode* BlobInvertedRect::updatePaintNode( const float pad = static_cast(m_group->smoothing()); - // Compute inner hole boundary in local coords - // Inset past the inner border edge by 2x smoothing to cover the blend zone const float inset = pad * 2.0f; const float holeLeft = static_cast(m_borderLeft) + inset; const float holeTop = static_cast(m_borderTop) + inset; const float holeRight = static_cast(width() - m_borderRight) - inset; const float holeBot = static_cast(height() - m_borderBottom) - inset; - // If the hole is too small or invalid, fall back to full quad - if (holeLeft >= holeRight || holeTop >= holeBot) + if (holeLeft >= holeRight || holeTop >= holeBot) { + auto* oldGeoNode = static_cast(oldNode); + if (oldNode && oldGeoNode->geometry()->vertexCount() != 4) { + delete oldNode; + oldNode = nullptr; + } return BlobShape::updatePaintNode(oldNode, nullptr); + } auto* node = static_cast(oldNode); @@ -87,7 +86,6 @@ QSGNode* BlobInvertedRect::updatePaintNode( node->setFlag(QSGNode::OwnsMaterial); } - // Outer bounds (local coords) const float x0 = static_cast(m_localPaddedRect.x()); const float y0 = static_cast(m_localPaddedRect.y()); const float x1 = x0 + static_cast(m_localPaddedRect.width()); @@ -95,15 +93,12 @@ QSGNode* BlobInvertedRect::updatePaintNode( const float w = x1 - x0; const float h = y1 - y0; - // Update vertex positions and texture coordinates auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); - // Outer corners v[0].set(x0, y0, 0.0f, 0.0f); v[1].set(x1, y0, 1.0f, 0.0f); v[2].set(x1, y1, 1.0f, 1.0f); v[3].set(x0, y1, 0.0f, 1.0f); - // Inner corners (hole) v[4].set(holeLeft, holeTop, (holeLeft - x0) / w, (holeTop - y0) / h); v[5].set(holeRight, holeTop, (holeRight - x0) / w, (holeTop - y0) / h); v[6].set(holeRight, holeBot, (holeRight - x0) / w, (holeBot - y0) / h); @@ -111,7 +106,6 @@ QSGNode* BlobInvertedRect::updatePaintNode( node->markDirty(QSGNode::DirtyGeometry); - // Update material uniforms auto* material = static_cast(node->material()); material->m_paddedX = m_cachedPaddedX; material->m_paddedY = m_cachedPaddedY; @@ -218,7 +212,7 @@ void BlobInvertedRect::outerCornerRadii(float out[4]) const { const auto base = std::min(static_cast(m_outerRadius), maxR); out[0] = std::min( m_outerTopRightRadius >= 0 ? static_cast(m_outerTopRightRadius) - : base, + : base, maxR); out[1] = std::min( m_outerBottomRightRadius >= 0 diff --git a/Plugins/ZShell/Blobs/blobinvertedrect.hpp b/Plugins/ZShell/Blobs/blobinvertedrect.hpp index 60121d0..6ef26ef 100644 --- a/Plugins/ZShell/Blobs/blobinvertedrect.hpp +++ b/Plugins/ZShell/Blobs/blobinvertedrect.hpp @@ -59,12 +59,15 @@ class BlobInvertedRect : public BlobShape { void setOuterRadius(qreal v); - // Per-corner outer radii. -1 falls back to outerRadius (mirrors BlobRect). - [[nodiscard]] qreal outerTopLeftRadius() const { return m_outerTopLeftRadius; } + [[nodiscard]] qreal outerTopLeftRadius() const { + return m_outerTopLeftRadius; + } void setOuterTopLeftRadius(qreal v); - [[nodiscard]] qreal outerTopRightRadius() const { return m_outerTopRightRadius; } + [[nodiscard]] qreal outerTopRightRadius() const { + return m_outerTopRightRadius; + } void setOuterTopRightRadius(qreal v); diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.cpp b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp index 2972cf2..5f4f078 100644 --- a/Plugins/ZShell/Blobs/blobinvertedrect2.cpp +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp @@ -1,6 +1,8 @@ #include "blobinvertedrect2.hpp" #include "blobgroup2.hpp" +#include + BlobInvertedRect2::BlobInvertedRect2(QQuickItem* parent) : BlobShape2(parent) {} BlobInvertedRect2::~BlobInvertedRect2() { @@ -35,6 +37,64 @@ void BlobInvertedRect2::setBorderBottom(qreal v) { if (m_group) m_group->markDirty(); } +void BlobInvertedRect2::setOuterRadius(qreal v) { + if (qFuzzyCompare(m_outerRadius, v)) return; + m_outerRadius = v; + emit outerRadiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setOuterTopLeftRadius(qreal v) { + if (qFuzzyCompare(m_outerTopLeftRadius, v)) return; + m_outerTopLeftRadius = v; + emit outerTopLeftRadiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setOuterTopRightRadius(qreal v) { + if (qFuzzyCompare(m_outerTopRightRadius, v)) return; + m_outerTopRightRadius = v; + emit outerTopRightRadiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setOuterBottomLeftRadius(qreal v) { + if (qFuzzyCompare(m_outerBottomLeftRadius, v)) return; + m_outerBottomLeftRadius = v; + emit outerBottomLeftRadiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setOuterBottomRightRadius(qreal v) { + if (qFuzzyCompare(m_outerBottomRightRadius, v)) return; + m_outerBottomRightRadius = v; + emit outerBottomRightRadiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::outerCornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto base = std::min(static_cast(m_outerRadius), maxR); + out[0] = std::min( + m_outerTopRightRadius >= 0 ? static_cast(m_outerTopRightRadius) + : base, + maxR); + out[1] = std::min( + m_outerBottomRightRadius >= 0 + ? static_cast(m_outerBottomRightRadius) + : base, + maxR); + out[2] = std::min( + m_outerBottomLeftRadius >= 0 + ? static_cast(m_outerBottomLeftRadius) + : base, + maxR); + out[3] = std::min( + m_outerTopLeftRadius >= 0 ? static_cast(m_outerTopLeftRadius) + : base, + maxR); +} + void BlobInvertedRect2::registerWithGroup() { if (m_group) m_group->setInvertedRect(this); } diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.hpp b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp index 4b7fb85..6e63172 100644 --- a/Plugins/ZShell/Blobs/blobinvertedrect2.hpp +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp @@ -19,6 +19,21 @@ class BlobInvertedRect2 : public BlobShape2 { Q_PROPERTY( qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY borderBottomChanged) + Q_PROPERTY( + qreal outerRadius READ outerRadius WRITE setOuterRadius NOTIFY + outerRadiusChanged) + Q_PROPERTY( + qreal outerTopLeftRadius READ outerTopLeftRadius WRITE + setOuterTopLeftRadius NOTIFY outerTopLeftRadiusChanged) + Q_PROPERTY( + qreal outerTopRightRadius READ outerTopRightRadius WRITE + setOuterTopRightRadius NOTIFY outerTopRightRadiusChanged) + Q_PROPERTY( + qreal outerBottomLeftRadius READ outerBottomLeftRadius WRITE + setOuterBottomLeftRadius NOTIFY outerBottomLeftRadiusChanged) + Q_PROPERTY( + qreal outerBottomRightRadius READ outerBottomRightRadius WRITE + setOuterBottomRightRadius NOTIFY outerBottomRightRadiusChanged) public: explicit BlobInvertedRect2(QQuickItem* parent = nullptr); @@ -40,13 +55,48 @@ class BlobInvertedRect2 : public BlobShape2 { void setBorderBottom(qreal v); + [[nodiscard]] qreal outerRadius() const { return m_outerRadius; } + + void setOuterRadius(qreal v); + + [[nodiscard]] qreal outerTopLeftRadius() const { + return m_outerTopLeftRadius; + } + + void setOuterTopLeftRadius(qreal v); + + [[nodiscard]] qreal outerTopRightRadius() const { + return m_outerTopRightRadius; + } + + void setOuterTopRightRadius(qreal v); + + [[nodiscard]] qreal outerBottomLeftRadius() const { + return m_outerBottomLeftRadius; + } + + void setOuterBottomLeftRadius(qreal v); + + [[nodiscard]] qreal outerBottomRightRadius() const { + return m_outerBottomRightRadius; + } + + void setOuterBottomRightRadius(qreal v); + signals: void borderLeftChanged(); void borderRightChanged(); void borderTopChanged(); void borderBottomChanged(); + void outerRadiusChanged(); + void outerTopLeftRadiusChanged(); + void outerTopRightRadiusChanged(); + void outerBottomLeftRadiusChanged(); + void outerBottomRightRadiusChanged(); protected: + void outerCornerRadii(float out[4]) const override; + void registerWithGroup() override; void unregisterFromGroup() override; @@ -55,4 +105,10 @@ class BlobInvertedRect2 : public BlobShape2 { qreal m_borderRight = 0; qreal m_borderTop = 0; qreal m_borderBottom = 0; + + qreal m_outerRadius = 0; + qreal m_outerTopLeftRadius = -1; + qreal m_outerTopRightRadius = -1; + qreal m_outerBottomLeftRadius = -1; + qreal m_outerBottomRightRadius = -1; }; diff --git a/Plugins/ZShell/Blobs/blobmaterial.cpp b/Plugins/ZShell/Blobs/blobmaterial.cpp index e3b276f..d4bd70f 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.cpp +++ b/Plugins/ZShell/Blobs/blobmaterial.cpp @@ -44,22 +44,17 @@ bool BlobMaterialShader::updateUniformData( memcpy(buf->data() + 64, &opacity, 4); } - // Padded rect (offset 68) memcpy(buf->data() + 68, &mat->m_paddedX, 4); memcpy(buf->data() + 72, &mat->m_paddedY, 4); memcpy(buf->data() + 76, &mat->m_paddedW, 4); memcpy(buf->data() + 80, &mat->m_paddedH, 4); - // Smooth factor (offset 84) memcpy(buf->data() + 84, &mat->m_smoothFactor, 4); - // Rect count (offset 88) memcpy(buf->data() + 88, &mat->m_rectCount, 4); - // My index (offset 92). -2 skips the ownership test in the shader. memcpy(buf->data() + 92, &mat->m_myIndex, 4); - // Color as vec4 (offset 96, 16 bytes) const float color[4] = { mat->m_color.redF(), mat->m_color.greenF(), @@ -68,26 +63,19 @@ bool BlobMaterialShader::updateUniformData( }; memcpy(buf->data() + 96, color, 16); - // Has inverted (offset 112) memcpy(buf->data() + 112, &mat->m_hasInverted, 4); - // Inverted radius (offset 116) memcpy(buf->data() + 116, &mat->m_invertedRadius, 4); - // Padding at 120-127 (skip) - // Inverted outer (offset 128, 16 bytes) memcpy(buf->data() + 128, mat->m_invertedOuter, 16); - // Inverted inner (offset 144, 16 bytes) memcpy(buf->data() + 144, mat->m_invertedInner, 16); - // Rect data (offset 160, each rect = 5 vec4s = 80 bytes) const int count = qMin(mat->m_rectCount, 16); for (int i = 0; i < count; ++i) { const auto& r = mat->m_rects[i]; const int base = 160 + i * 80; - // Pack excludeMask into props.x via bit-cast (read in shader with floatBitsToInt) float maskAsFloat = NAN; memcpy(&maskAsFloat, &r.excludeMask, sizeof(float)); const float d0[4] = {r.cx, r.cy, r.hw, r.hh}; @@ -100,12 +88,9 @@ bool BlobMaterialShader::updateUniformData( memcpy(buf->data() + base + 64, r.radius, 16); } - // Shadow: after rectData, which ends at 1440. shadowColor is 16-byte aligned, - // so it lands at 1456. memcpy(buf->data() + 1440, &mat->m_shadowRadius, 4); memcpy(buf->data() + 1456, mat->m_shadowColor, 16); - // Outer edge corner radii, after shadowColor, at 1472. memcpy(buf->data() + 1472, mat->m_invertedOuterRadii, 16); return true; diff --git a/Plugins/ZShell/Blobs/blobmaterial.hpp b/Plugins/ZShell/Blobs/blobmaterial.hpp index 7cc35de..82ad03b 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.hpp +++ b/Plugins/ZShell/Blobs/blobmaterial.hpp @@ -8,14 +8,9 @@ struct BlobRectData { float cx = 0, cy = 0, hw = 0, hh = 0; float offsetX = 0, offsetY = 0; float minEig = 1.0f; - // Inverse of 2x2 deformation matrix, column-major for GLSL float invDeform[4] = {1, 0, 0, 1}; - // Screen-space AABB half-extents of the deformed rect float screenHalfX = 0, screenHalfY = 0; - // Effective per-corner radii (tr, br, bl, tl), pre-computed on CPU float radius[4] = {0, 0, 0, 0}; - // Bitmask of indices in this rect's m_cachedRects that mutually exclude (or are excluded by) this rect. - // Used by the shader to skip smin between excluded pairs. int excludeMask = 0; }; @@ -32,19 +27,14 @@ class BlobMaterial : public QSGMaterial { float m_paddedH = 0; float m_smoothFactor = 32.0f; int m_rectCount = 0; - // -2 means no owner test: the single group draw writes every pixel it covers. int m_myIndex = -2; QColor m_color{0x44, 0x88, 0xff}; int m_hasInverted = 0; float m_invertedRadius = 0; float m_invertedOuter[4] = {}; float m_invertedInner[4] = {}; - // Phase 2: shadow around the material edges. Matches the uniform block, which - // puts these after rectData. float m_shadowRadius = 0.0f; float m_shadowColor[4] = {0.0f, 0.0f, 0.0f, 0.5f}; - // Outer-edge corner radii (tr, br, bl, tl). Matches the uniform block, which - // puts this after shadowColor. float m_invertedOuterRadii[4] = {}; BlobRectData m_rects[16] = {}; }; diff --git a/Plugins/ZShell/Blobs/blobrect.cpp b/Plugins/ZShell/Blobs/blobrect.cpp index 3100604..758eb09 100644 --- a/Plugins/ZShell/Blobs/blobrect.cpp +++ b/Plugins/ZShell/Blobs/blobrect.cpp @@ -7,19 +7,116 @@ namespace { constexpr float k_retireMs = 120.0f; +constexpr qint64 k_minEvalMs = 10; +constexpr float k_staleSampleSec = 0.1f; +constexpr float k_staleMovePx = 2.0f; +constexpr float k_moveSpeed = 5.0f; +constexpr float k_settleDelta = 0.004f; +constexpr float k_settleVel = 0.05f; +constexpr float k_identityEps = 0.0005f; +constexpr float k_stillPx = 0.5f; +constexpr int k_settleStillSamples = 3; + +// How far the deform matrix is from identity, summed over its three distinct +// entries. The matrix is applied as a panel's transform, so it has to reach +// *exactly* identity rather than merely close to it: a residue of 1e-4 scales +// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a +// fraction of a device pixel, and anything that centers itself inside the +// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then +// antialiases. The residue also differs per entry, so different children land +// on different sub-pixel offsets - which reads as random blur. +float identityResidue(float dm00, float dm01, float dm11) { + return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f); } -BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {} +bool nearIdentity(float dm00, float dm01, float dm11) { + return identityResidue(dm00, dm01, dm11) <= k_identityEps; +} +} // namespace + +BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) { + m_stillSamples = k_settleStillSamples; +} BlobRect::~BlobRect() { if (m_group) m_group->removeShape(this); } +bool BlobRect::needsFrame() const { + // Pump until the matrix is *exactly* identity, not merely within epsilon: + // updatePhysics() snaps it there once it is near enough, so this stays true + // only for the frames between the last integration and that snap. + return m_physicsActive || m_retiring || + m_stillSamples < k_settleStillSamples || + identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f; +} + +void BlobRect::resetDeformMatrix() { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retiring = false; + m_physicsActive = false; + BlobShape::resetDeformMatrix(); +} + +void BlobRect::requestPhysics() { + if (m_group) m_group->requestPhysics(); +} + void BlobRect::updatePolish() { BlobShape::updatePolish(); + if (m_physicsActive || m_retiring) requestPhysics(); +} + +void BlobRect::finishAtIdentity() { + const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f && + !m_retiring && !m_physicsActive; + if (settled) return; + + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retiring = false; + m_physicsActive = false; + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + if (m_group) m_group->markDirty(); +} + +void BlobRect::beginRetire() { + if (m_retiring) return; + + m_retire00 = m_dm00; + m_retire01 = m_dm01; + m_retire11 = m_dm11; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retireElapsed.start(); + m_retiring = true; + requestPhysics(); +} + +void BlobRect::trySettle(float speed) { + if (m_retiring) return; if (!m_physicsActive) return; + const float totalDelta = + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (speed < k_moveSpeed && totalDelta < k_settleDelta && + totalVel < k_settleVel && m_stillSamples >= k_settleStillSamples) { + beginRetire(); + } +} + +void BlobRect::updatePhysics() { if (m_retiring) { const float t = std::clamp( static_cast(m_retireElapsed.elapsed()) / k_retireMs, @@ -28,100 +125,93 @@ void BlobRect::updatePolish() { const float s = t * t * (3.0f - 2.0f * t); if (t >= 1.0f) { + finishAtIdentity(); + return; + } + + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + + // Snap the emitted matrix to exact identity while the retire tail is + // below the visible threshold, but keep retiring: ending the retire early + // hides the background while the panel animation still has a tail. + if (nearIdentity(m_dm00, m_dm01, m_dm11)) { m_dm00 = 1.0f; m_dm01 = 0.0f; m_dm11 = 1.0f; - m_deformMatrix = QMatrix4x4(); - m_retiring = false; - m_physicsActive = false; - } else { - m_dm00 = m_retire00 + (1.0f - m_retire00) * s; - m_dm01 = m_retire01 - m_retire01 * s; - m_dm11 = m_retire11 + (1.0f - m_retire11) * s; - m_deformMatrix = QMatrix4x4( - m_dm00, - m_dm01, - 0, - 0, - m_dm01, - m_dm11, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 1); } + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); - - if (!m_physicsActive) return; - } else { - const float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + - std::abs(m_dm11 - 1.0f); - const float totalVel = - std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); - - if (totalDelta < 0.004f && totalVel < 0.05f) beginRetire(); } - QMetaObject::invokeMethod( - this, - [this]() { - if (m_physicsActive && m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); -} - -void BlobRect::beginRetire() { - m_retire00 = m_dm00; - m_retire01 = m_dm01; - m_retire11 = m_dm11; - m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; - m_retireElapsed.start(); - m_retiring = true; -} - -void BlobRect::updatePhysics() { const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); + if (!m_physicsActive && !m_retiring) { + const float residue = identityResidue(m_dm00, m_dm01, m_dm11); + if (residue > k_identityEps) { + beginRetire(); + return; + } + // Residue at or below the threshold is invisible, so it is written as + // exact identity - the panel transform then stops being a transform at + // all. Sampling continues below, so a new movement can restart physics. + if (residue > 0.0f) finishAtIdentity(); + } + if (!m_hasPrevPos) { m_prevScenePos = scenePos; m_elapsed.start(); m_hasPrevPos = true; + m_stillSamples = k_settleStillSamples; return; } - const float dt = static_cast(m_elapsed.restart()) / 1000.0f; - if (dt > 0.1f || dt < 0.001f) { - m_prevScenePos = scenePos; - if (m_physicsActive) checkAtRest(0.0f); + const qint64 evalMs = m_elapsed.elapsed(); + if (evalMs < k_minEvalMs) { return; } + m_elapsed.restart(); - const float velX = - static_cast(scenePos.x() - m_prevScenePos.x()) / dt; - const float velY = - static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + const float dx = static_cast(scenePos.x() - m_prevScenePos.x()); + const float dy = static_cast(scenePos.y() - m_prevScenePos.y()); m_prevScenePos = scenePos; + if (std::abs(dx) + std::abs(dy) > k_stillPx) + m_stillSamples = 0; + else + m_stillSamples += 1; + + const float dt = static_cast(evalMs) / 1000.0f; + if (dt > k_staleSampleSec) { + if (std::abs(dx) + std::abs(dy) > k_staleMovePx) { + m_physicsActive = true; + requestPhysics(); + } else { + trySettle(0.0f); + } + return; + } + + const float velX = dx / dt; + const float velY = dy / dt; + const float speed = std::sqrt(velX * velX + velY * velY); if (m_retiring) { - if (speed > 5.0f) + if (speed > k_moveSpeed) m_retiring = false; else return; } if (!m_physicsActive) { - if (speed < 5.0f) return; + if (speed < k_moveSpeed) return; m_physicsActive = true; + requestPhysics(); } const float kStretchFactor = static_cast(m_deformScale); @@ -131,7 +221,7 @@ void BlobRect::updatePhysics() { float target01 = 0.0f; float target11 = 1.0f; - if (speed > 5.0f) { + if (speed > k_moveSpeed) { const float targetStretch = 1.0f + std::min(speed * kStretchFactor, kMaxStretch); const float targetCompress = 1.0f / targetStretch; @@ -160,12 +250,27 @@ void BlobRect::updatePhysics() { m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp; m_dm11 += m_dmVel11 * dt; + // Land on exact identity as soon as the spring is within the invisible + // threshold, so the panel's contents are back on the device pixel grid - + // but keep physicsActive until trySettle() completes: the at-rest drop + // must not happen while the panel animation still has a slow tail. + if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + trySettle(speed); + return; + } + m_deformMatrix = QMatrix4x4( m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); - checkAtRest(speed); + trySettle(speed); } void BlobRect::setTopLeftRadius(qreal r) { @@ -340,16 +445,3 @@ void BlobRect::excludeCornersRemoveLast(QQmlListProperty* prop) { if (self->m_group) self->m_group->markDirty(); emit self->excludeCornersChanged(); } - -void BlobRect::checkAtRest(float speed) { - if (m_retiring) return; - - constexpr float kEpsilon = 0.002f; - const bool atRest = - std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && - std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && - std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && - speed < 5.0f; - - if (atRest) beginRetire(); -} diff --git a/Plugins/ZShell/Blobs/blobrect.hpp b/Plugins/ZShell/Blobs/blobrect.hpp index 967e339..fde7f75 100644 --- a/Plugins/ZShell/Blobs/blobrect.hpp +++ b/Plugins/ZShell/Blobs/blobrect.hpp @@ -105,15 +105,20 @@ class BlobRect : public BlobShape { protected: void updatePolish() override; void updatePhysics() override; + bool needsFrame() const override; + void resetDeformMatrix() override; private: - void checkAtRest(float speed); + void trySettle(float speed); void beginRetire(); + void finishAtIdentity(); + void requestPhysics(); QPointF m_prevScenePos; QElapsedTimer m_elapsed; bool m_physicsActive = false; bool m_hasPrevPos = false; + int m_stillSamples = 0; bool m_retiring = false; QElapsedTimer m_retireElapsed; diff --git a/Plugins/ZShell/Blobs/blobrect2.cpp b/Plugins/ZShell/Blobs/blobrect2.cpp index 3fdfa28..e4143c7 100644 --- a/Plugins/ZShell/Blobs/blobrect2.cpp +++ b/Plugins/ZShell/Blobs/blobrect2.cpp @@ -6,28 +6,96 @@ namespace { constexpr float k_retireMs = 120.0f; +constexpr qint64 k_minEvalMs = 10; +constexpr float k_staleSampleSec = 0.1f; +constexpr float k_staleMovePx = 2.0f; +constexpr float k_moveSpeed = 5.0f; +constexpr float k_settleDelta = 0.004f; +constexpr float k_settleVel = 0.05f; +constexpr float k_identityEps = 0.0005f; +constexpr float k_stillPx = 0.5f; +constexpr int k_settleStillSamples = 3; + +// How far the deform matrix is from identity, summed over its three distinct +// entries. The matrix is applied as a panel's transform, so it has to reach +// *exactly* identity rather than merely close to it: a residue of 1e-4 scales +// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a +// fraction of a device pixel, and anything that centers itself inside the +// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then +// antialiases. The residue also differs per entry, so different children land +// on different sub-pixel offsets - which reads as random blur. +float identityResidue(float dm00, float dm01, float dm11) { + return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f); +} + +bool nearIdentity(float dm00, float dm01, float dm11) { + return identityResidue(dm00, dm01, dm11) <= k_identityEps; +} } // namespace -BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {} +BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) { + m_stillSamples = k_settleStillSamples; +} BlobRect2::~BlobRect2() { if (m_group) m_group->removeShape(this); } +bool BlobRect2::needsFrame() const { + // Pump until the matrix is *exactly* identity, not merely within epsilon: + // updatePhysics() snaps it there once it is near enough, so this stays true + // only for the frames between the last integration and that snap. + return m_physicsActive || m_retiring || + m_stillSamples < k_settleStillSamples || + identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f; +} + +bool BlobRect2::isMoving() const { + return m_atRestOverride || m_physicsActive || m_retiring || + m_stillSamples < k_settleStillSamples; +} + +void BlobRect2::updateMoving() { + const bool moving = isMoving(); + if (moving == m_movingEmitted) return; + m_movingEmitted = moving; + emit movingChanged(); +} + +void BlobRect2::resetDeformMatrix() { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retiring = false; + m_physicsActive = false; + BlobShape2::resetDeformMatrix(); +} + +void BlobRect2::requestPhysics() { + if (m_group) m_group->requestPhysics(); +} + void BlobRect2::finishAtIdentity() { + const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f && + !m_retiring && !m_physicsActive; + if (settled) return; + m_dm00 = 1.0f; m_dm01 = 0.0f; m_dm11 = 1.0f; m_deformMatrix = QMatrix4x4(); + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; m_retiring = false; m_physicsActive = false; emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); + + if (m_group) m_group->markDirty(); + updateMoving(); } void BlobRect2::beginRetire() { - // The settle check would otherwise re-enter this every frame and restart the - // curve timer, so the curve never reaches t = 1. if (m_retiring) return; m_retire00 = m_dm00; @@ -36,10 +104,26 @@ void BlobRect2::beginRetire() { m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; m_retireElapsed.start(); m_retiring = true; + requestPhysics(); + updateMoving(); +} + +void BlobRect2::trySettle(float speed) { + if (m_retiring) return; + if (!m_physicsActive) return; + + const float totalDelta = + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (speed < k_moveSpeed && totalDelta < k_settleDelta && + totalVel < k_settleVel && m_stillSamples >= k_settleStillSamples) { + beginRetire(); + } } void BlobRect2::updatePhysics() { - // Shapes are data only, so the group drives physics here. if (m_retiring) { const float t = std::clamp( static_cast(m_retireElapsed.elapsed()) / k_retireMs, @@ -55,6 +139,17 @@ void BlobRect2::updatePhysics() { m_dm00 = m_retire00 + (1.0f - m_retire00) * s; m_dm01 = m_retire01 - m_retire01 * s; m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + + // Snap the emitted matrix to exact identity while the retire tail is + // below the visible threshold, but keep retiring: isMoving() drives the + // at-rest drop, and finishing early hides the background while the panel + // animation still has a tail. + if (nearIdentity(m_dm00, m_dm01, m_dm11)) { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + } + m_deformMatrix = QMatrix4x4( m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); emit rawDeformMatrixChanged(); @@ -63,12 +158,19 @@ void BlobRect2::updatePhysics() { if (!m_physicsActive) return; } - // No "not active" guard here: an inactive shape still has to be evaluated so it - // can see the panel start moving and become active. The frame loop is only kept - // alive while active, and it is requested before anything can return early — - // requesting it at the end lets a shape stop being evaluated with the matrix - // still off identity, which is what left the panel contents blurry. - if (m_physicsActive && m_group) m_group->markDirty(); + if (!m_physicsActive && !m_retiring) { + const float residue = identityResidue(m_dm00, m_dm01, m_dm11); + if (residue > k_identityEps) { + beginRetire(); + return; + } + // Residue at or below the threshold is invisible, so it is written as + // exact identity - the panel transform then stops being a transform at + // all. Sampling continues below, so a new movement can restart physics. + if (residue > 0.0f) finishAtIdentity(); + } + + if (m_physicsActive) requestPhysics(); const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); @@ -76,34 +178,54 @@ void BlobRect2::updatePhysics() { m_prevScenePos = scenePos; m_elapsed.start(); m_hasPrevPos = true; + m_stillSamples = k_settleStillSamples; + updateMoving(); return; } - const float dt = static_cast(m_elapsed.restart()) / 1000.0f; - if (dt > 0.1f || dt < 0.001f) { - m_prevScenePos = scenePos; - if (m_physicsActive) checkAtRest(0.0f); + const qint64 evalMs = m_elapsed.elapsed(); + if (evalMs < k_minEvalMs) { return; } + m_elapsed.restart(); - const float velX = - static_cast(scenePos.x() - m_prevScenePos.x()) / dt; - const float velY = - static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + const float dx = static_cast(scenePos.x() - m_prevScenePos.x()); + const float dy = static_cast(scenePos.y() - m_prevScenePos.y()); m_prevScenePos = scenePos; + if (std::abs(dx) + std::abs(dy) > k_stillPx) + m_stillSamples = 0; + else + m_stillSamples += 1; + + const float dt = static_cast(evalMs) / 1000.0f; + if (dt > k_staleSampleSec) { + if (std::abs(dx) + std::abs(dy) > k_staleMovePx) { + m_physicsActive = true; + requestPhysics(); + updateMoving(); + } else { + trySettle(0.0f); + } + return; + } + + const float velX = dx / dt; + const float velY = dy / dt; + const float speed = std::sqrt(velX * velX + velY * velY); if (m_retiring) { - if (speed > 5.0f) + if (speed > k_moveSpeed) m_retiring = false; else return; } if (!m_physicsActive) { - if (speed < 5.0f) return; + if (speed < k_moveSpeed) return; m_physicsActive = true; + requestPhysics(); } const float kStretchFactor = static_cast(m_deformScale); @@ -113,7 +235,7 @@ void BlobRect2::updatePhysics() { float target01 = 0.0f; float target11 = 1.0f; - if (speed > 5.0f) { + if (speed > k_moveSpeed) { const float targetStretch = 1.0f + std::min(speed * kStretchFactor, kMaxStretch); const float targetCompress = 1.0f / targetStretch; @@ -142,27 +264,30 @@ void BlobRect2::updatePhysics() { m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp; m_dm11 += m_dmVel11 * dt; + // Land on exact identity as soon as the spring is within the invisible + // threshold, so the panel's contents are back on the device pixel grid - + // but keep physicsActive until trySettle() completes: isMoving() drives the + // at-rest drop, and clearing physics early hides the background while the + // panel animation still has a slow tail. + if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + trySettle(speed); + updateMoving(); + return; + } + m_deformMatrix = QMatrix4x4( m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); - // Start the retire as soon as the matrix is close to identity, as in v1. Waiting - // for every term to fall below kEpsilon keeps the matrix just off identity for - // longer, and the panel content is transformed by it, which reads as blur on - // text. - const float totalDelta = - std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); - const float totalVel = - std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); - // The residual is already inside the rest epsilon, so the eased curve would only - // be aborted or restarted every frame by the panel creeping. Snap instead. - if (totalDelta < 0.004f && totalVel < 0.05f) { - finishAtIdentity(); - return; - } - - checkAtRest(speed); + trySettle(speed); + updateMoving(); } void BlobRect2::setTopLeftRadius(qreal r) { @@ -338,16 +463,3 @@ void BlobRect2::excludeCornersRemoveLast(QQmlListProperty* prop) { if (self->m_group) self->m_group->markDirty(); emit self->excludeCornersChanged(); } - -void BlobRect2::checkAtRest(float speed) { - if (m_retiring) return; - - constexpr float kEpsilon = 0.002f; - const bool atRest = - std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && - std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && - std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && - speed < 5.0f; - - if (atRest) beginRetire(); -} diff --git a/Plugins/ZShell/Blobs/blobrect2.hpp b/Plugins/ZShell/Blobs/blobrect2.hpp index 16832f0..c1194ae 100644 --- a/Plugins/ZShell/Blobs/blobrect2.hpp +++ b/Plugins/ZShell/Blobs/blobrect2.hpp @@ -25,6 +25,7 @@ class BlobRect2 : public BlobShape2 { Q_PROPERTY( bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY atRestOverrideChanged) + Q_PROPERTY(bool moving READ isMoving NOTIFY movingChanged) Q_PROPERTY( qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY topLeftRadiusChanged) @@ -78,18 +79,13 @@ class BlobRect2 : public BlobShape2 { if (m_atRestOverride == o) return; m_atRestOverride = o; emit atRestOverrideChanged(); + updateMoving(); if (m_group) m_group->markDirty(); } bool isExcluded(const BlobShape2* other) const override; bool isCornerExcluded(const BlobShape2* other) const override; - // The shape that is pushing is the one that can shape the bar edge. A shape that is - // retiring still counts, because the retire curve is still moving the surface. - // atRestOverride keeps the shape in the group's data even at rest; when it is false - // the actual spring state decides, so it is not an override of the state itself. - bool isMoving() const override { - return m_atRestOverride || m_physicsActive || m_retiring; - } + bool isMoving() const override; void cornerRadii(float out[4]) const override; [[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; } @@ -117,6 +113,7 @@ class BlobRect2 : public BlobShape2 { void excludeChanged(); void excludeCornersChanged(); void atRestOverrideChanged(); + void movingChanged(); void topLeftRadiusChanged(); void topRightRadiusChanged(); void bottomLeftRadiusChanged(); @@ -124,20 +121,24 @@ class BlobRect2 : public BlobShape2 { protected: void updatePhysics() override; + bool needsFrame() const override; + void resetDeformMatrix() override; private: - void checkAtRest(float speed); + void trySettle(float speed); void beginRetire(); void finishAtIdentity(); + void requestPhysics(); + void updateMoving(); - // Physics state QPointF m_prevScenePos; QElapsedTimer m_elapsed; bool m_physicsActive = false; bool m_hasPrevPos = false; - // Set from QML. Keeps the shape in the group's data while it is at rest; when it is - // false the spring state decides on its own. + int m_stillSamples = 0; + bool m_movingEmitted = false; + bool m_atRestOverride = false; bool m_retiring = false; @@ -146,13 +147,10 @@ class BlobRect2 : public BlobShape2 { float m_retire01 = 0.0f; float m_retire11 = 1.0f; - // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, - // m11) Rest state is identity: m00=1, m01=0, m11=1 float m_dm00 = 1.0f; float m_dm01 = 0.0f; float m_dm11 = 1.0f; - // Spring velocities for each component float m_dmVel00 = 0.0f; float m_dmVel01 = 0.0f; float m_dmVel11 = 0.0f; diff --git a/Plugins/ZShell/Blobs/blobshape.cpp b/Plugins/ZShell/Blobs/blobshape.cpp index 33fd5b4..aa66401 100644 --- a/Plugins/ZShell/Blobs/blobshape.cpp +++ b/Plugins/ZShell/Blobs/blobshape.cpp @@ -8,6 +8,10 @@ #include #include +namespace { +constexpr int kMaxRects = 16; +} // namespace + static float deformPadding(const QMatrix4x4& dm, float hw, float hh) { const float dm00 = dm(0, 0), dm01 = dm(0, 1); const float dm10 = dm(1, 0), dm11 = dm(1, 1); @@ -45,6 +49,7 @@ BlobShape::BlobShape(QQuickItem* parent) : QQuickItem(parent) { void BlobShape::setGroup(BlobGroup* g) { if (m_group == g) return; if (m_group && isComponentComplete()) unregisterFromGroup(); + if (m_group) resetDeformMatrix(); m_group = g; if (m_group && isComponentComplete()) registerWithGroup(); emit groupChanged(); @@ -87,6 +92,8 @@ void BlobShape::geometryChange( std::abs(m_pendingDh) > syncThreshold) { m_pendingDx = 0; m_pendingDy = 0; + m_pendingDw = 0; + m_pendingDh = 0; m_group->markShapeDirty(this); } } @@ -105,6 +112,12 @@ void BlobShape::updateCenteredDeformMatrix() { } } +void BlobShape::resetDeformMatrix() { + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); +} + void BlobShape::cornerRadii(float out[4]) const { const auto maxR = static_cast(std::min(width(), height())) * 0.5f; const auto r = std::min(static_cast(m_radius), maxR); @@ -187,6 +200,12 @@ void BlobShape::updatePolish() { } if (include) { + if (other == this && m_cachedRects.size() >= kMaxRects) { + m_cachedRects.removeLast(); + rectShapes.removeLast(); + } + if (m_cachedRects.size() >= kMaxRects) continue; + if (other == this) m_cachedMyIndex = static_cast(m_cachedRects.size()); @@ -240,8 +259,7 @@ void BlobShape::updatePolish() { m_cachedInvertedRadius = 0; memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter)); memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner)); - memset( - m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii)); + memset(m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii)); auto* inv = m_group->invertedRect(); if (inv) { @@ -308,10 +326,11 @@ void BlobShape::updatePolish() { BlobShape* const si = rectShapes[i]; float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; - const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; - const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; - const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; - const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + const float dcx = ri.cx + ri.offsetX, dcy = ri.cy + ri.offsetY; + const float cTrX = dcx + ri.hw, cTrY = dcy - ri.hh; + const float cBrX = dcx + ri.hw, cBrY = dcy + ri.hh; + const float cBlX = dcx - ri.hw, cBlY = dcy + ri.hh; + const float cTlX = dcx - ri.hw, cTlY = dcy - ri.hh; for (qsizetype j = 0; cornerFill && j < rectCount; ++j) { if (j == i) continue; @@ -319,10 +338,12 @@ void BlobShape::updatePolish() { BlobShape* const sj = rectShapes[j]; if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) continue; const auto& rj = m_cachedRects[j]; - const float sdTr = cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh); - const float sdBr = cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh); - const float sdBl = cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh); - const float sdTl = cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh); + const float jcx = rj.cx + rj.offsetX; + const float jcy = rj.cy + rj.offsetY; + const float sdTr = cpuSdBox(cTrX, cTrY, jcx, jcy, rj.hw, rj.hh); + const float sdBr = cpuSdBox(cBrX, cBrY, jcx, jcy, rj.hw, rj.hh); + const float sdBl = cpuSdBox(cBlX, cBlY, jcx, jcy, rj.hw, rj.hh); + const float sdTl = cpuSdBox(cTlX, cTlY, jcx, jcy, rj.hw, rj.hh); fTr = std::min(fTr, cornerFillFactor(sdTr, smoothFactor)); fBr = std::min(fBr, cornerFillFactor(sdBr, smoothFactor)); fBl = std::min(fBl, cornerFillFactor(sdBl, smoothFactor)); @@ -360,10 +381,14 @@ void BlobShape::updatePolish() { -cpuSdBox(cTlX, cTlY, icx, icy, ihw, ihh))); } - ri.radius[0] = std::max(ri.radius[0] * fTr, minR); - ri.radius[1] = std::max(ri.radius[1] * fBr, minR); - ri.radius[2] = std::max(ri.radius[2] * fBl, minR); - ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + ri.radius[0] = ri.radius[0] > 0.0f ? std::max(ri.radius[0] * fTr, minR) + : 0.0f; + ri.radius[1] = ri.radius[1] > 0.0f ? std::max(ri.radius[1] * fBr, minR) + : 0.0f; + ri.radius[2] = ri.radius[2] > 0.0f ? std::max(ri.radius[2] * fBl, minR) + : 0.0f; + ri.radius[3] = ri.radius[3] > 0.0f ? std::max(ri.radius[3] * fTl, minR) + : 0.0f; } } @@ -428,7 +453,7 @@ QSGNode* BlobShape::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { sizeof(m_cachedInvertedOuterRadii)); const int count = - static_cast(qMin(m_cachedRects.size(), qsizetype(16))); + static_cast(qMin(m_cachedRects.size(), qsizetype(kMaxRects))); material->m_rectCount = count; for (int i = 0; i < count; ++i) material->m_rects[i] = m_cachedRects[i]; diff --git a/Plugins/ZShell/Blobs/blobshape.hpp b/Plugins/ZShell/Blobs/blobshape.hpp index 71b0510..b06f6ce 100644 --- a/Plugins/ZShell/Blobs/blobshape.hpp +++ b/Plugins/ZShell/Blobs/blobshape.hpp @@ -61,9 +61,6 @@ class BlobShape : public QQuickItem { virtual void cornerRadii(float out[4]) const; - // Outer-edge corner radii. Only the inverted rect draws an outer frame edge, - // so the base has no outer rounding and returns zeros. Subclasses that expose - // outer corner properties override this (order: tr, br, bl, tl). virtual void outerCornerRadii(float out[4]) const { out[0] = 0; out[1] = 0; @@ -73,16 +70,19 @@ class BlobShape : public QQuickItem { virtual void updatePhysics() {} + virtual bool needsFrame() const { return false; } + virtual void registerWithGroup(); virtual void unregisterFromGroup(); void updateCenteredDeformMatrix(); + virtual void resetDeformMatrix(); + BlobGroup* m_group = nullptr; qreal m_radius = 0; QMatrix4x4 m_deformMatrix; // identity by default QMatrix4x4 m_centeredDeformMatrix; - // Cached data from updatePolish float m_cachedPaddedX = 0; float m_cachedPaddedY = 0; float m_cachedPaddedW = 0; diff --git a/Plugins/ZShell/Blobs/blobshape2.cpp b/Plugins/ZShell/Blobs/blobshape2.cpp index 8ce1232..9bb4425 100644 --- a/Plugins/ZShell/Blobs/blobshape2.cpp +++ b/Plugins/ZShell/Blobs/blobshape2.cpp @@ -9,6 +9,7 @@ BlobShape2::BlobShape2(QQuickItem* parent) : QQuickItem(parent) {} void BlobShape2::setGroup(BlobGroup2* g) { if (m_group == g) return; if (m_group && isComponentComplete()) unregisterFromGroup(); + if (m_group) resetDeformMatrix(); m_group = g; if (m_group && isComponentComplete()) registerWithGroup(); emit groupChanged(); @@ -32,7 +33,6 @@ void BlobShape2::geometryChange( QQuickItem::geometryChange(newGeometry, oldGeometry); updateCenteredDeformMatrix(); - // One draw covers the whole group, so any shape change is a group change. if (m_group) m_group->markDirty(); } @@ -49,6 +49,12 @@ void BlobShape2::updateCenteredDeformMatrix() { } } +void BlobShape2::resetDeformMatrix() { + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); +} + void BlobShape2::cornerRadii(float out[4]) const { const auto maxR = static_cast(std::min(width(), height())) * 0.5f; const auto r = std::min(static_cast(m_radius), maxR); diff --git a/Plugins/ZShell/Blobs/blobshape2.hpp b/Plugins/ZShell/Blobs/blobshape2.hpp index 230b660..519a1b0 100644 --- a/Plugins/ZShell/Blobs/blobshape2.hpp +++ b/Plugins/ZShell/Blobs/blobshape2.hpp @@ -7,12 +7,6 @@ class BlobGroup2; -// Data-only shape. -// -// The group draws a single node for the whole group, so a shape here contributes -// only what the shader needs: position, radii, exclusion, physics. A shape does -// not create a render node, so no pixel can be evaluated by one shape and missed -// by another. class BlobShape2 : public QQuickItem { Q_OBJECT Q_PROPERTY(BlobGroup2* group READ group WRITE setGroup NOTIFY groupChanged) @@ -56,8 +50,6 @@ class BlobShape2 : public QQuickItem { virtual bool isExcluded(const BlobShape2*) const { return false; } - // Whether this shape is part of the group's data. Only a pushing shape is, so a shape - // at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride. virtual bool isMoving() const { return false; } virtual bool isCornerExcluded(const BlobShape2* /*other*/) const { @@ -66,13 +58,23 @@ class BlobShape2 : public QQuickItem { virtual void cornerRadii(float out[4]) const; - // Called by the group when it rebuilds the shared data, once per frame. + virtual void outerCornerRadii(float out[4]) const { + out[0] = 0; + out[1] = 0; + out[2] = 0; + out[3] = 0; + } + virtual void updatePhysics() {} + virtual bool needsFrame() const { return false; } + virtual void registerWithGroup(); virtual void unregisterFromGroup(); void updateCenteredDeformMatrix(); + virtual void resetDeformMatrix(); + BlobGroup2* m_group = nullptr; qreal m_radius = 0; QMatrix4x4 m_deformMatrix; // identity by default diff --git a/Plugins/ZShell/Blobs/shaders/blob.frag b/Plugins/ZShell/Blobs/shaders/blob.frag index 5e6fdfc..6c73318 100644 --- a/Plugins/ZShell/Blobs/shaders/blob.frag +++ b/Plugins/ZShell/Blobs/shaders/blob.frag @@ -30,7 +30,6 @@ float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { } float sdRoundedBox4(vec2 p, vec2 center, vec2 halfSize, vec4 r) { - // r = (topRight, bottomRight, bottomLeft, topLeft) p -= center; r.xy = (p.x > 0.0) ? r.xy : r.wz; r.x = (p.y > 0.0) ? r.y : r.x; @@ -44,33 +43,32 @@ float sdBox(vec2 p, vec2 center, vec2 halfSize) { } float smin(float a, float b, float k) { - // Cubic smooth min (C2 continuous — no curvature kinks at blend boundary) - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * h * k * (1.0 / 6.0); + // Circular smooth min — the blend fillet is a true circular arc of radius k, + // tangent to both surfaces (not a polynomial/squircle curve). Deviates from + // min(a, b) only in the corner region where BOTH a < k and b < k (unlike the + // cubic, which deviates over the whole band |a - b| < k and dents the surface + // inward while the shapes are still apart). Always <= min(a, b); max blend + // depth at a == b is (sqrt(2) - 1) * k. + return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0))); } float smax(float a, float b, float k) { - float h = max(k - abs(a - b), 0.0) / k; - return max(a, b) + h * h * h * k * (1.0 / 6.0); + // Circular smooth max — dual of smin: -smin(-a, -b, k). Always >= max(a, b). + return min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0))); } float smaxSharpA(float a, float b, float k) { - // smax variant that keeps a's boundary sharp (no inward rounding at a = 0). - // Used for the frame inner side, so the outer edge keeps its own shape and - // always fills to the edges. - float h = max(k - abs(a - b), 0.0) / k; - float blend = h * h * h * k * (1.0 / 6.0); - blend *= smoothstep(0.0, k * 0.5, -a); + // Circular smax variant that keeps a's boundary sharp (no inward rounding at + // a = 0). Used for the frame outer edge so it always fills to the edges. + float sm = + min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0))); + float blend = (sm - max(a, b)) * smoothstep(0.0, k * 0.5, -a); return max(a, b) + blend; } void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); - // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the - // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner: - // myIndex stays -2, so the ownership test is skipped and this pass writes every - // pixel the quad covers. Excluded pairs still need the pairwise pass below. float dArr[16]; int owner = -2; float minDist = 1e10; @@ -84,12 +82,8 @@ void main() { vec4 radii = rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl) - // Offset center for asymmetric deformation vec2 center = rect.xy + props.yz; - // AABB early-out: skip rects far from this pixel. The reach has to include the - // shadow, otherwise a rect that is only inside the shadow range would be - // skipped and its distance could not be used. vec2 extent = sh.xy + vec2(smoothFactor * 1.5 + shadowRadius); if (abs(pixel.x - center.x) > extent.x || abs(pixel.y - center.y) > extent.y) { @@ -97,19 +91,13 @@ void main() { continue; } - // Apply pre-computed inverse deformation to the evaluation point mat2 invDeform = mat2(invDm.xy, invDm.zw); vec2 transformedPixel = center + invDeform * (pixel - center); - // Use pre-computed effective per-corner radii float d = sdRoundedBox4(transformedPixel, center, rect.zw, radii); - // Use pre-computed minimum eigenvalue for SDF correction d *= max(props.w, 0.01); - // Scale SDF on the axis facing a nearby border to narrow the smin blend - // zone in that direction only, without reducing k (which would cause sharp - // edges). if (hasInverted != 0) { vec2 screenHalf = sh.xy; @@ -122,26 +110,21 @@ void main() { float distX1 = (invertedInner.x + invertedInner.z) - (center.x - screenHalf.x); - // 0 = far from border, 1 = at border (max compression) float yProx = 1.0 - min(smoothstep(0.0, smoothFactor, distY0), smoothstep(0.0, smoothFactor, distY1)); float xProx = 1.0 - min(smoothstep(0.0, smoothFactor, distX0), smoothstep(0.0, smoothFactor, distX1)); - // Smooth axis weights: gradient-based at corners, face-based inside. vec2 q = abs(pixel - center) - screenHalf; vec2 qp = max(q, vec2(0.0)); float cornerLen = length(qp); - // Gradient direction in corner region (smooth 90-degree rotation) float gradX = qp.x / max(cornerLen, 0.001); float gradY = qp.y / max(cornerLen, 0.001); - // Smooth face weights for inside/edge (no hard branch) float faceY = smoothstep(-4.0, 4.0, q.y - q.x); float faceX = 1.0 - faceY; - // Blend: gradient in corner region, face-based inside float t = smoothstep(0.0, 2.0, cornerLen); float xWeight = mix(faceX, gradX, t); float yWeight = mix(faceY, gradY, t); @@ -158,15 +141,11 @@ void main() { } } - // Phase 2: hard-min baseline over all rects. float mergedSdf = 1e10; for (int i = 0; i < rectCount; i++) { mergedSdf = min(mergedSdf, dArr[i]); } - // Phase 3: pair-wise smin contributions, skipping excluded pairs. Pair smin - // <= min, so taking the min over all non-excluded pair smins gives the - // smoothly-merged SDF. for (int i = 0; i < rectCount; i++) { if (dArr[i] >= 1e9) continue; @@ -176,24 +155,20 @@ void main() { continue; if ((excludeMask & (1 << j)) != 0) continue; - // smin only deviates from min within smoothFactor - if (abs(dArr[i] - dArr[j]) >= smoothFactor) + // Circular smin deviates from min only where BOTH d values are < k. + if (max(dArr[i], dArr[j]) >= smoothFactor) continue; mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor)); } } if (hasInverted != 0) { - // Outer edge rounds with its own per-corner radii. With all-zero radii - // sdRoundedBox4 equals sdBox, so a frame that sets no outer radius is - // unchanged. smaxSharpA keeps this boundary authoritative for the fill. float dOuter = sdRoundedBox4(pixel, invertedOuter.xy, invertedOuter.zw, invertedOuterRadii) - 1.0; float dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); - // Border sinks: track the opposite rect edge, clamped to border thickness float innerTop = invertedInner.y - invertedInner.w; float innerBot = invertedInner.y + invertedInner.w; float innerLeft = invertedInner.x - invertedInner.z; @@ -203,65 +178,67 @@ void main() { float outerLeft = invertedOuter.x - invertedOuter.z; float outerRight = invertedOuter.x + invertedOuter.z; + float topZone = + 1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y); + float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y); + float leftZone = + 1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x); + float rightZone = + smoothstep(innerRight - smoothFactor, innerRight, pixel.x); + float sinkValue = 0.0; - for (int i = 0; i < rectCount; i++) { - vec4 rect = rectData[i * 5]; - vec4 sinkProps = rectData[i * 5 + 1]; - vec2 sinkSh = rectData[i * 5 + 3].xy; + // Sink onset / residual overlap: how far a rect must penetrate the border + // before the inner wall recedes to form its pocket. Too low and the wall + // recedes faster than the junction can stay convex, denting the inner edge + // inward near the rect's corners; too high and the rect nestles too deep + // before the wall yields. Tuned to the circular smin blend depth: half the + // gap-closing distance, (2 - sqrt(2)) * k / 2. + float preOff = smoothFactor * (2.0 - sqrt(2.0)) * 0.5; + float s = smoothFactor * 2.0; - // Screen-space center (with offset) and pre-computed AABB half-extents - vec2 ctr = rect.xy + sinkProps.yz; + if (topZone > 0.0 || botZone > 0.0 || leftZone > 0.0 || rightZone > 0.0) { + for (int i = 0; i < rectCount; i++) { + vec4 rect = rectData[i * 5]; + vec4 sinkProps = rectData[i * 5 + 1]; + vec2 sinkSh = rectData[i * 5 + 3].xy; - // Delay sink to absorb smin blend depth (cubic smin max = k/6) - float preOff = smoothFactor * (1.0 / 6.0); + vec2 ctr = rect.xy + sinkProps.yz; - // Top border: track rect's BOTTOM edge, only within border thickness - float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, - innerTop - outerTop); + float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, + innerTop - outerTop); - // Bottom border: track rect's TOP edge - float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0, - outerBot - innerBot); + float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0, + outerBot - innerBot); - // Left border: track rect's RIGHT edge - float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0, - innerLeft - outerLeft); + float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0, + innerLeft - outerLeft); - // Right border: track rect's LEFT edge - float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0, - outerRight - innerRight); + float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0, + outerRight - innerRight); - // Lateral distance from pixel to rect's extent along each edge - float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0); - float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0); + float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0); + float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0); - // Perpendicular proximity: full strength in border, fade inside inner - // area - float topZone = - 1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y); - float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y); - float leftZone = - 1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x); - float rightZone = - smoothstep(innerRight - smoothFactor, innerRight, pixel.x); - - float s = smoothFactor * 2.0; - float sink = max(max(topPen * smoothstep(s, 0.0, hLat) * topZone, - botPen * smoothstep(s, 0.0, hLat) * botZone), - max(leftPen * smoothstep(s, 0.0, vLat) * leftZone, - rightPen * smoothstep(s, 0.0, vLat) * rightZone)); - sinkValue = max(sinkValue, sink); + float sink = max(max(topPen * smoothstep(s, 0.0, hLat) * topZone, + botPen * smoothstep(s, 0.0, hLat) * botZone), + max(leftPen * smoothstep(s, 0.0, vLat) * leftZone, + rightPen * smoothstep(s, 0.0, vLat) * rightZone)); + sinkValue = max(sinkValue, sink); + } } dInner -= sinkValue; - // dOuter is already a true circle (sdRoundedBox4). Keep it exact whenever the - // outer arc is the active boundary; a smax term added there would pull the - // arc's sides inward (curvature/tangent mismatch), so the corner would not be - // a circle. Only blend on the inner side, where the inner boundary is active. - float negInner = -dInner; - float dFrame = dOuter >= negInner ? dOuter - : smaxSharpA(dOuter, negInner, smoothFactor); + // The circular smax fillet has radius kFrame; when it exceeds the border + // thickness it can't complete inside the border, so the sharp outer-box term + // bleeds onto the inner edge and bulges the inner corners (worst when + // thickness < smoothFactor). Clamp kFrame to the thinnest side so the inner + // edge stays a clean constant-radius arc. Each inner corner is bounded by + // its thinner adjacent side, so the global min is correct for every corner. + float minThick = min(min(innerTop - outerTop, outerBot - innerBot), + min(innerLeft - outerLeft, outerRight - innerRight)); + float kFrame = clamp(min(smoothFactor, minThick - 1.0), 1.0, smoothFactor); + float dFrame = smaxSharpA(dOuter, -dInner, kFrame); mergedSdf = smin(mergedSdf, dFrame, smoothFactor); if (dFrame < minDist) { @@ -269,37 +246,22 @@ void main() { } } - // A pixel this far from every rect cannot have a value, so it is not written. - // The one-unit margin keeps the outermost pixels of the quad out of the AA: their - // derivatives are undefined (the quad neighbour is outside the primitive), which - // is what drew a line along the coverage edge. float reach = max(smoothFactor, shadowRadius); - if (mergedSdf > reach - 1.0) + float pxWidth = max(length(vec2(dFdx(mergedSdf), dFdy(mergedSdf))), 1e-5); + if (mergedSdf > reach - max(2.0 * pxWidth, 1.0)) discard; - // Only the owner writes a pixel, which is what makes the per-shape path look - // like one surface: qt_Opacity is applied once per pixel instead of once per - // overlapping draw. The single group draw has no owner (myIndex == -2), so the - // test is skipped and the value below is the only value a pixel can have. if (myIndex >= -1 && owner != myIndex) discard; - // Fixed-width AA: fwidth is capped at one unit, so a bad derivative near the quad - // boundary cannot make a wide edge. - float fw = min(fwidth(mergedSdf), 1.0); - float materialAlpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + float materialAlpha = clamp(0.5 - mergedSdf / pxWidth, 0.0, 1.0); - // Phase 2: the shadow, as a distance falloff from the merged surface. The - // quadratic reaches zero with zero slope at its outer edge, so that edge needs no - // AA of its own. Only the single group draw writes a shadow: in the per-shape - // path overlapping draws would darken the same area twice. float shadowAlpha = 0.0; if (shadowRadius > 0.0 && myIndex == -2) { float t = clamp(mergedSdf / shadowRadius, 0.0, 1.0); shadowAlpha = (1.0 - t) * (1.0 - t) * shadowColor.a; } - // Material over shadow, premultiplied. float totalAlpha = materialAlpha + shadowAlpha * (1.0 - materialAlpha); vec3 rgb = color.rgb * materialAlpha + shadowColor.rgb * shadowAlpha * (1.0 - materialAlpha); diff --git a/Plugins/ZShell/Blobs/shaders/frag.spv b/Plugins/ZShell/Blobs/shaders/frag.spv new file mode 100644 index 0000000..0c52ace Binary files /dev/null and b/Plugins/ZShell/Blobs/shaders/frag.spv differ diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 7d69f21..9db3a5d 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -9,12 +9,19 @@ qml_module(ZShell toaster.hpp toaster.cpp qalculator.hpp qalculator.cpp zutils.hpp zutils.cpp + alignaudit.hpp alignaudit.cpp + alignauditor.hpp alignauditor.cpp LIBRARIES - Qt::Gui - Qt::Quick - Qt::Concurrent - Qt::Sql - Qt::DBus + Qt6::Gui + Qt6::Quick + Qt6::QuickPrivate + Qt6::QuickLayouts + Qt6::QuickLayoutsPrivate + Qt6::QmlPrivate + Qt6::CorePrivate + Qt6::Concurrent + Qt6::Sql + Qt6::DBus PkgConfig::Qalculate zshell-util ) @@ -24,6 +31,9 @@ target_compile_definitions(ZShell PRIVATE GIT_REVISION="${GIT_REVISION}" ) +target_compile_definitions(ZShell PRIVATE ALIGN_AUDIT_USE_QML_PRIVATE) + + # Node macros expand to Q_OBJECT, automoc can't see it otherwise list(APPEND CMAKE_AUTOMOC_MACRO_NAMES CONFIG_NODE diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index f732ea2..56b574b 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell-components URI ZShell.Components + DEPENDENCIES QtQuick.Layouts SOURCES lazylistview.hpp lazylistview.cpp chatlistview.hpp chatlistview.cpp @@ -12,10 +13,15 @@ qml_module(ZShell-components sparklinesceneitem.hpp sparklinesceneitem.cpp roundedmask.hpp roundedmask.cpp roundedclip.hpp roundedclip.cpp + snaplayout.hpp snaplayout.cpp + snapview.hpp snapview.cpp wheelinverter.hpp LIBRARIES Qt::Quick Qt::QuickPrivate + Qt::QuickLayouts + Qt::QuickLayoutsPrivate + Qt::GuiPrivate Qt::CanvasPainter ZShell-internal ) diff --git a/Plugins/ZShell/Components/snaplayout.cpp b/Plugins/ZShell/Components/snaplayout.cpp new file mode 100644 index 0000000..7b4badb --- /dev/null +++ b/Plugins/ZShell/Components/snaplayout.cpp @@ -0,0 +1,806 @@ +#include "snaplayout.hpp" + +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcQuickLayouts, "qt.quick.layouts") + +namespace ZShell::components { +namespace { + +qint64 originResidual(qreal logical, qreal dpr) { + const qreal device = logical * dpr; + return std::llround((device - std::floor(device)) * 1024.0) % 1024; +} + +// Snap one size hint to the device grid. `mode` is >0 to round up, <0 to round +// down, 0 to round to nearest. Infinities and NaN are left alone: they mean +// "unbounded". +qreal snapSizeHint(qreal value, qreal dpr, int mode) { + if (qIsNaN(value) || std::isinf(value)) return value; + + const double exact = value * dpr; + const double snapped = mode > 0 ? std::ceil(exact - 1e-9) + : mode < 0 + ? std::floor(exact + 1e-9) + : static_cast(std::llround(exact)); + return snapped / dpr; +} + +QPointF absoluteOrigin(const QQuickItem* item) { + qreal x = 0.0; + qreal y = 0.0; + for (const QQuickItem* it = item; it; it = it->parentItem()) { + x += it->x(); + y += it->y(); + } + return QPointF(x, y); +} + +} // namespace + +qreal SnapLayoutStyleInfo::spacing(Qt::Orientation /*orientation*/) const { +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) || defined(Q_OS_QNX) + qreal spacing = 8.0; +#else + qreal spacing = 5.0; +#endif +#if !defined(Q_OS_MACOS) && !defined(Q_OS_HARMONY) + spacing = qRound(spacing * (qreal(qt_defaultDpiX()) / 96.0)); +#endif + return spacing; +} + +qreal SnapLayoutStyleInfo::windowMargin(Qt::Orientation /*orientation*/) const { + return 0; +} + +bool SnapLayoutStyleInfo::hasChangedCore() const { + return false; +} + +SnapGridLayoutItem::SnapGridLayoutItem( + QQuickItem* item, + int row, + int column, + int rowSpan, + int columnSpan, + Qt::Alignment alignment) + : QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment) {} + +void SnapGridLayoutItem::setGeometry(const QRectF& rect) { + QQuickLayoutAttached* info = attachedLayoutObject(m_item, false); + engineRect = info ? rect.marginsRemoved(info->effectiveQMargins()) : rect; + placed = true; +} + +void SnapLayoutPrivate::mirrorChange() { + Q_Q(SnapLayout); + q->invalidate(); +} + +SnapLayout::SnapLayout(QQuickItem* parent) + : QQuickLayout(*new SnapLayoutPrivate, parent) { + d_func()->styleInfo = new SnapLayoutStyleInfo; +} + +SnapLayout::~SnapLayout() { + Q_D(SnapLayout); + clearAncestorListeners(); + deactivateRecur(); + delete d->styleInfo; +} + +void SnapLayout::componentComplete() { + QQuickLayout::componentComplete(); + + applySpacing(); + invalidate(); + ensureLayoutItemsUpdated(QQuickLayout::ApplySizeHints); + + if (qobject_cast(parentItem())) return; + rearrange(QSizeF(width(), height())); +} + +void SnapLayout::itemChange(ItemChange change, const ItemChangeData& value) { + QQuickLayout::itemChange(change, value); + if (change == ItemChildRemovedChange && value.item) + d_func()->childSizes.remove(value.item); + if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) { + applySpacing(); + invalidate(); + } +} + +void SnapLayout::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + Q_D(SnapLayout); + QQuickLayout::geometryChange(newGeometry, oldGeometry); + + if (!isReady()) return; + + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0; + if (dpr <= 0.0) return; + + // Any change of our own geometry can move us: our size changed and an anchor + // on a bottom or right edge moved us with it, or an ancestor resized and the + // same anchors moved us. Children were placed relative to the origin the + // arrangement saw: their local positions carry the compensation for that + // origin's fraction of a device pixel, so after a move they land on a + // fraction - which is why a centered child is blurred by a different amount + // every time the panel opens. Re-arrange whenever the origin's residual + // changes; it is remembered on every rearrange, so this fires only when a + // re-arrange is really needed, and it converges as soon as the animation + // stops moving the layout. + const QPointF origin = absoluteOrigin(this); + if (originResidual(origin.x(), dpr) == d->originResidualX && + originResidual(origin.y(), dpr) == d->originResidualY) + return; + + rearrangeForOriginMove(); +} + +// Children are placed against the origin the arrangement saw, so a move that +// changes the origin's fraction of a device pixel needs a new arrangement. +// polish() defers that to the next frame, and a polish request does nothing +// while one is already pending - if no frame follows, or the pending polish is +// never delivered, the children stay placed against the old origin forever. +// Rearrange immediately instead; polish() is only the fallback for the cases +// where rearranging now is not possible. +void SnapLayout::rearrangeForOriginMove() { + Q_D(SnapLayout); + const QSizeF size(width(), height()); + if (d->rearranging || size.width() <= 0.0 || size.height() <= 0.0) { + polish(); + return; + } + rearrange(size); +} + +QSizeF SnapLayout::sizeHint(Qt::SizeHint whichSizeHint) const { + Q_D(const SnapLayout); + const QSizeF engineHint = + d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo); + + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; + if (dpr <= 0.0) return engineHint; + + const bool horiz = d->orientation == Qt::Horizontal; + + // Qt builds implicit sizes out of whole logical pixels: + // QQuickLayout::effectiveSizeHints_helper takes `qCeil(item->implicitHeight())` + // for every child, so the sum can be an odd number of logical pixels, which is + // a half device pixel at a fractional ratio - 37 logical pixels at 1.5x is 55.5 + // device pixels. A layout that sizes itself from its implicit size then has an + // edge on a fraction of a device pixel, and anchored to a bottom edge its top + // edge - and every child measured down from that top - lands off the grid. + // + // The preferred size is therefore summed here in device pixels: each child's + // own hint rounded up to the device grid, plus its Layout margins and the + // device-normalized spacing. That is exactly what the arrangement writes, so + // the layout is neither larger than its children (dead space, and a Flickable + // that reports more content height than the content occupies) nor smaller + // (the last child cut off). + qint64 mainExtentDev = 0; + qint64 crossDev = 0; + qint64 maxMainDev = 0; + int count = 0; + + for (int i = 0; i < d->engine.itemCount(); ++i) { + const SnapGridLayoutItem* gridItem = + static_cast(d->engine.itemAt(i)); + QQuickItem* child = gridItem->layoutItem(); + if (!child || shouldIgnoreItem(child)) continue; + + QQuickLayoutAttached* info = attachedLayoutObject(child, false); + const QMarginsF margins = info ? info->effectiveQMargins() + : QMarginsF(0, 0, 0, 0); + + // The engine's per-child hints are authoritative: they follow + // Layout.preferredWidth/Height when those are set, otherwise the child's + // implicit size, otherwise the first width and height it had, and they + // include the child's Layout margins. They are also cached, so a nested + // layout contributes its own preferred size even before it is polished. + const QSizeF* hints = gridItem->effectiveSizeHints(); + + const qreal mainMargin = horiz ? margins.left() + margins.right() + : margins.top() + margins.bottom(); + const qreal crossMargin = horiz ? margins.top() + margins.bottom() + : margins.left() + margins.right(); + + qreal mainHint = (horiz ? hints[Qt::PreferredSize].width() + : hints[Qt::PreferredSize].height()) - + mainMargin; + qreal crossHint = (horiz ? hints[Qt::PreferredSize].height() + : hints[Qt::PreferredSize].width()) - + crossMargin; + + // The engine hint is `qCeil` of the implicit size, so it can sit up to a + // whole logical pixel above it - a device pixel per child at 1.5x, which + // adds up to dead space in a long column. When the child's own implicit + // size is known and the hint is just that ceiling, measure the implicit + // size and let the device rounding be the only rounding. + const qreal ownMain = horiz ? child->implicitWidth() + : child->implicitHeight(); + if (ownMain > 0.0 && mainHint <= ownMain + 1.0) mainHint = ownMain; + + const qreal ownCross = horiz ? child->implicitHeight() + : child->implicitWidth(); + if (ownCross > 0.0 && crossHint <= ownCross + 1.0) crossHint = ownCross; + + if (!std::isfinite(mainHint) || mainHint < 0.0) mainHint = 0.0; + if (!std::isfinite(crossHint) || crossHint < 0.0) crossHint = 0.0; + + // Rounding up keeps the hint from ever under-sizing its contents. + const qint64 mainDev = qint64(std::ceil(mainHint * dpr - 1e-9)); + const qint64 mainMarginDev = qint64(std::ceil(mainMargin * dpr - 1e-9)); + const qint64 crossDevForChild = + qint64(std::ceil(crossHint * dpr - 1e-9)) + + qint64(std::ceil(crossMargin * dpr - 1e-9)); + + mainExtentDev += mainDev + mainMarginDev; + maxMainDev = (std::max)(maxMainDev, mainDev + mainMarginDev); + crossDev = (std::max)(crossDev, crossDevForChild); + ++count; + } + + qreal space = d->spacingRequest; + if (qIsNaN(space)) space = d->styleInfo->spacing(d->orientation); + const qint64 gapDev = dpr > 0.0 ? qint64(std::llround(space * dpr)) + : qint64(space); + + if (uniformCellSizes() && count > 0) + mainExtentDev = maxMainDev * qint64(count); + if (count > 1) mainExtentDev += gapDev * qint64(count - 1); + + const QSizeF preferred( + horiz ? qreal(mainExtentDev) / dpr : qreal(crossDev) / dpr, + horiz ? qreal(crossDev) / dpr : qreal(mainExtentDev) / dpr); + + if (whichSizeHint == Qt::PreferredSize) return preferred; + + if (whichSizeHint == Qt::MinimumSize) { + // Minimums round up, but never past the preferred size, or cells would + // grow past what the arrangement writes. + const QSizeF minimum( + snapSizeHint(engineHint.width(), dpr, 1), + snapSizeHint(engineHint.height(), dpr, 1)); + return QSizeF( + (std::min)(minimum.width(), preferred.width()), + (std::min)(minimum.height(), preferred.height())); + } + + // Maximums round down, but never below the preferred size: a layout is always + // allowed to be as large as its contents need. + const QSizeF maximum( + snapSizeHint(engineHint.width(), dpr, -1), + snapSizeHint(engineHint.height(), dpr, -1)); + return QSizeF( + (std::max)(maximum.width(), preferred.width()), + (std::max)(maximum.height(), preferred.height())); +} + +Qt::Orientation SnapLayout::orientation() const { + return d_func()->orientation; +} + +void SnapLayout::setOrientation(Qt::Orientation orientation) { + Q_D(SnapLayout); + if (d->orientation == orientation) return; + d->orientation = orientation; + invalidate(); + emit orientationChanged(); +} + +Qt::LayoutDirection SnapLayout::layoutDirection() const { + return d_func()->layoutDirection; +} + +void SnapLayout::setLayoutDirection(Qt::LayoutDirection direction) { + Q_D(SnapLayout); + if (d->layoutDirection == direction) return; + d->layoutDirection = direction; + invalidate(); + emit layoutDirectionChanged(); +} + +Qt::LayoutDirection SnapLayout::effectiveLayoutDirection() const { + return !isMirrored() == (layoutDirection() == Qt::LeftToRight) + ? Qt::LeftToRight + : Qt::RightToLeft; +} + +qreal SnapLayout::spacing() const { + Q_D(const SnapLayout); + if (!std::isnan(d->spacingRequest)) return d->spacingRequest; + return d->styleInfo->spacing(d->orientation); +} + +void SnapLayout::applySpacing() { + Q_D(SnapLayout); + if (!isReady()) return; + + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; + + qreal space = d->spacingRequest; + if (std::isnan(space)) space = d->styleInfo->spacing(d->orientation); + + if (dpr > 0.0) space = qreal(std::llround(space * dpr)) / dpr; + + d->engine.setSpacing(space, Qt::Horizontal | Qt::Vertical); +} + +void SnapLayout::setSpacing(qreal space) { + Q_D(SnapLayout); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wfloat-equal" + if (std::isnan(space) || d->spacingRequest == space) return; +#pragma GCC diagnostic pop + d->spacingRequest = space; + applySpacing(); + invalidate(); + emit spacingChanged(); +} + +bool SnapLayout::uniformCellSizes() const { + Q_D(const SnapLayout); + return d->engine.uniformCellWidths(); +} + +void SnapLayout::setUniformCellSizes(bool uniformCellSizes) { + Q_D(SnapLayout); + if (d->engine.uniformCellHeights() == uniformCellSizes) return; + d->engine.setUniformCellWidths(uniformCellSizes); + d->engine.setUniformCellHeights(uniformCellSizes); + invalidate(); + emit uniformCellSizesChanged(); +} + +void SnapLayout::setAlignment(QQuickItem* item, Qt::Alignment align) { + Q_D(SnapLayout); + if (QQuickGridLayoutItem* layoutItem = d->engine.findLayoutItem(item)) { + layoutItem->setAlignment(align); + d->engine.invalidate(); + } + maybeSubscribeToBaseLineOffsetChanges(item); +} + +void SnapLayout::setStretchFactor( + QQuickItem* item, int stretchFactor, Qt::Orientation orient) { + Q_D(SnapLayout); + if (QQuickGridLayoutItem* layoutItem = d->engine.findLayoutItem(item)) { + layoutItem->setStretchFactor(stretchFactor, orient); + d->engine.invalidate(); + } +} + +void SnapLayout::invalidate(QQuickItem* childItem) { + Q_D(SnapLayout); + if (!isReady()) return; + + if (childItem) { + if (d->rearranging) { + if (!d->invalidateAfterRearrange.contains(childItem)) + d->invalidateAfterRearrange << childItem; + return; + } + if (QQuickGridLayoutItem* layoutItem = + d->engine.findLayoutItem(childItem)) { + layoutItem->invalidate(); + } + } + + d->engine.invalidate(); + QQuickLayout::invalidate(); + + if (auto* parentLayout = qobject_cast(parentItem())) + parentLayout->invalidate(this); +} + +void SnapLayout::updateLayoutItems() { + Q_D(SnapLayout); + if (!isReady()) return; + + const auto items = childItems(); + for (auto entry = d->childSizes.begin(); entry != d->childSizes.end();) { + if (items.contains(entry.key())) + ++entry; + else + entry = d->childSizes.erase(entry); + } + + d->engine.deleteItems(); + insertLayoutItems(); +} + +QQuickItem* SnapLayout::itemAt(int index) const { + Q_D(const SnapLayout); + return static_cast(d->engine.itemAt(index)) + ->layoutItem(); +} + +int SnapLayout::itemCount() const { + Q_D(const SnapLayout); + return d->engine.itemCount(); +} + +void SnapLayout::insertLayoutItems() { + Q_D(SnapLayout); + const auto items = childItems(); + for (QQuickItem* child : items) { + Q_ASSERT(child); + checkAnchors(child); + + if (shouldIgnoreItem(child)) continue; + QQuickLayoutAttached* info = attachedLayoutObject(child, false); + + Qt::Alignment alignment; + int hStretch = -1; + int vStretch = -1; + bool fillWidth = false; + bool fillHeight = false; + if (info) { + alignment = info->alignment(); + hStretch = info->horizontalStretchFactor(); + vStretch = info->verticalStretchFactor(); + fillWidth = info->fillWidth(); + fillHeight = info->fillHeight(); + } + + const int index = d->engine.rowCount(d->orientation); + d->engine.insertRow(index, d->orientation); + + int gridRow = 0; + int gridColumn = index; + if (d->orientation == Qt::Vertical) qSwap(gridRow, gridColumn); + QGridLayoutItem* layoutItem = + new SnapGridLayoutItem(child, gridRow, gridColumn, 1, 1, alignment); + + if (hStretch >= 0) { + if (!fillWidth) + qWarning() << "horizontalStretchFactor requires fillWidth to " + "also be set to true"; + layoutItem->setStretchFactor(hStretch, Qt::Horizontal); + } + if (vStretch >= 0) { + if (!fillHeight) + qWarning() << "verticalStretchFactor requires fillHeight to " + "also be set to true"; + layoutItem->setStretchFactor(vStretch, Qt::Vertical); + } + d->engine.insertItem(layoutItem, index); + } +} + +void SnapLayout::removeGridItem(QGridLayoutItem* gridItem) { + Q_D(SnapLayout); + const int index = gridItem->firstRow(d->orientation); + d->engine.removeItem(gridItem); + d->engine.removeRows(index, 1, d->orientation); +} + +void SnapLayout::itemDestroyed(QQuickItem* item) { + if (!isReady()) return; + Q_D(SnapLayout); + d->childSizes.remove(item); + if (QQuickGridLayoutItem* gridItem = d->engine.findLayoutItem(item)) { + removeGridItem(gridItem); + delete gridItem; + invalidate(); + } +} + +void SnapLayout::itemVisibilityChanged(QQuickItem* item) { + if (!isReady()) return; + invalidate(item); +} + +void SnapLayout::itemGeometryChanged( + QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) { + Q_UNUSED(item) + Q_UNUSED(change) + Q_UNUSED(oldGeometry) + if (!isReady()) return; + + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0; + if (dpr <= 0.0) return; + + // An ancestor changing size can move us too - a layout anchored to a bottom + // edge slides when its parent grows - so only the origin's fraction of a + // device pixel matters, not which kind of change reached us. + Q_D(SnapLayout); + const QPointF origin = absoluteOrigin(this); + if (originResidual(origin.x(), dpr) == d->originResidualX && + originResidual(origin.y(), dpr) == d->originResidualY) + return; + + rearrangeForOriginMove(); +} + +void SnapLayout::updateAncestorListeners() { + Q_D(SnapLayout); + + const QFlags mask = + QQuickItemPrivate::Geometry; + + QVector current; + for (QQuickItem* it = parentItem(); it; it = it->parentItem()) { + if (qobject_cast(it)) break; + current.append(it); + } + + for (const QPointer& ancestor : + std::as_const(d->ancestorListeners)) { + if (ancestor && !current.contains(ancestor.data())) { + QQuickItemPrivate::get(ancestor.data()) + ->removeItemChangeListener( + this, d->ancestorMasks.value(ancestor.data())); + d->ancestorMasks.remove(ancestor.data()); + } + } + + // Qt appends one registration per addItemChangeListener call, so only the + // bits that are not registered yet may be added; re-adding the same mask on + // every rearrange would duplicate listeners without bound. + for (QQuickItem* ancestor : std::as_const(current)) { + const auto have = d->ancestorMasks.value(ancestor); + const auto missing = mask & ~have; + const auto extra = have & ~mask; + + if (extra != 0) { + QQuickItemPrivate::get(ancestor)->removeItemChangeListener( + this, extra); + } + if (missing != 0) { + QQuickItemPrivate::get(ancestor)->addItemChangeListener( + this, missing); + } + + d->ancestorMasks[ancestor] = mask; + } + + d->ancestorListeners.clear(); + d->ancestorListeners.reserve(current.size()); + for (QQuickItem* ancestor : std::as_const(current)) + d->ancestorListeners.append(ancestor); +} + +void SnapLayout::clearAncestorListeners() { + Q_D(SnapLayout); + for (const QPointer& ancestor : + std::as_const(d->ancestorListeners)) { + if (ancestor) { + QQuickItemPrivate::get(ancestor.data()) + ->removeItemChangeListener( + this, d->ancestorMasks.value(ancestor.data())); + } + } + d->ancestorMasks.clear(); + d->ancestorListeners.clear(); +} + +void SnapLayout::arrangeChildren() { + Q_D(SnapLayout); + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; + if (dpr <= 0.0) return; + + const bool horiz = d->orientation == Qt::Horizontal; + const QPointF origin = absoluteOrigin(this); + const qint64 originXDev = std::llround(origin.x() * dpr); + const qint64 originYDev = std::llround(origin.y() * dpr); + + qreal prevEndExact = 0.0; + qint64 prevNearDev = 0; + qint64 prevSizeDev = 0; + bool started = false; + + // The layout's own main extent, rounded down: no child is placed past it, so + // a Flickable that takes this layout's height as its content height can still + // reach the last child. + const QSizeF self = size(); + const qint64 layoutMainExtentDev = + qint64(std::floor((horiz ? self.width() : self.height()) * dpr + 1e-6)); + + for (int i = 0; i < d->engine.itemCount(); ++i) { + SnapGridLayoutItem* gridItem = + static_cast(d->engine.itemAt(i)); + if (!gridItem->placed) continue; + QQuickItem* child = gridItem->layoutItem(); + const QRectF& cell = gridItem->engineRect; + + const qreal mainExact = (horiz ? cell.x() : cell.y()) * dpr; + const qreal mainSizeExact = + (horiz ? cell.width() : cell.height()) * dpr; + const qreal crossExact = (horiz ? cell.y() : cell.x()) * dpr; + const qint64 mainSizeDev = std::llround(mainSizeExact); + const qint64 crossSizeDev = + std::llround((horiz ? cell.height() : cell.width()) * dpr); + const qint64 engineWidth = horiz ? mainSizeDev : crossSizeDev; + const qint64 engineHeight = horiz ? crossSizeDev : mainSizeDev; + + qint64 mainNear = 0; + if (!started) { + mainNear = std::llround(mainExact); + started = true; + } else { + mainNear = prevNearDev + prevSizeDev + + std::llround(mainExact - prevEndExact); + } + + const qint64 mainFarExact = std::llround(mainExact + mainSizeExact); + + // An aligned child whose cell is its own implicit size is sized from that + // implicit size. The engine recomputes cells from animated geometry, so a + // cell that is mathematically 41 can arrive as 40.9999999999; when the + // device pixel ratio puts 41 exactly on half a device pixel, that epsilon + // flips the rounding from pass to pass and the child visibly resizes while + // it only moves. Children that fill their axis, or whose cell has grown + // past their implicit size, keep the engine-derived size so that animated + // fills still step through device pixels. + QQuickLayoutAttached* info = attachedLayoutObject(child, false); + const qreal mainOwn = horiz ? child->implicitWidth() + : child->implicitHeight(); + const qreal crossOwn = horiz ? child->implicitHeight() + : child->implicitWidth(); + const bool fillsMain = info && + (horiz ? info->fillWidth() : info->fillHeight()); + const bool fillsCross = + info && (horiz ? info->fillHeight() : info->fillWidth()); + + const auto snappedOwn = [&](bool fills, qreal cellSize, qreal ownSize) { + if (fills || ownSize <= 0.0 || qAbs(cellSize - ownSize) >= 1.0) + return qint64(-1); + // Round up, like Qt's `qCeil` of implicit sizes: a child is never + // smaller than the content it holds, and this is the same rounding + // sizeHint() sums to compute the layout's own preferred size, so the + // children fill the layout without slack and without overflow. + return (std::max)(qint64(std::ceil(ownSize * dpr - 1e-9)), + qint64(0)); + }; + + const qint64 mainOwnDev = + snappedOwn(fillsMain, horiz ? cell.width() : cell.height(), mainOwn); + const qint64 crossOwnDev = snappedOwn( + fillsCross, horiz ? cell.height() : cell.width(), crossOwn); + + // The size along the main axis. An implicit-sized child keeps its own + // implicit size, which carries the device-pixel parity ZUtils chose for + // it. Rounding is not neutral: an odd logical size at 1.5x is exactly half + // a device pixel and always rounds up, so left unchecked every child adds + // half a pixel and a long list ends outside the layout - a Flickable reads + // the layout's height as the end of the content and the last rows become + // unreachable. Corrections are made in steps of two device pixels: an item + // that centers its own content (Layout.HCenter / Layout.VCenter, + // anchors.centerIn) lands on whole device pixels only while its device + // size keeps that parity, so a one-pixel correction moves its centered + // children onto half a pixel and they antialias. Drift from the engine's + // edge is bounded at two device pixels, and no child is placed past the + // layout's own edge - a layout with less room than its children needs + // overflows in Qt too, so the engine's own edge is never pulled back. + const qint64 mainFarCeil = + (std::max)(mainFarExact, layoutMainExtentDev); + const qint64 mainLimit = + (std::min)(mainFarExact + 2, mainFarCeil) - mainNear; + + qint64 width = engineWidth; + qint64 height = engineHeight; + const qint64 mainSizeWritten = + mainOwnDev >= 0 ? mainOwnDev + : (std::max)(mainFarExact - mainNear, qint64(0)); + if (horiz) + width = mainSizeWritten; + else + height = mainSizeWritten; + + if (crossOwnDev >= 0) { + if (horiz) + height = crossOwnDev; + else + width = crossOwnDev; + } + + // Keep the size already written unless the engine asks for a different + // one, or something else resized the child. An aligned size stays + // aligned wherever the layout moves, and resizing again costs the child + // a relayout: an Image reloads its texture, a nested layout rearranges. + const auto stored = d->childSizes.constFind(child); + if (stored != d->childSizes.constEnd() && + stored.value().engineWidth == engineWidth && + stored.value().engineHeight == engineHeight && + std::llround(child->width() * dpr) == stored.value().width && + std::llround(child->height() * dpr) == stored.value().height) { + width = stored.value().width; + height = stored.value().height; + } + + // A remembered size can predate the layout being sized, so the limit is + // applied to whatever ends up being written, keeping the parity of the + // size this child is asking for. + qint64& mainAxisSize = horiz ? width : height; + const int mainParity = int(mainAxisSize % 2); + if (mainAxisSize > mainLimit) { + mainAxisSize = mainLimit; + if (int(mainAxisSize % 2) != mainParity) --mainAxisSize; + } + mainAxisSize = (std::max)(mainAxisSize, qint64(0)); + + d->childSizes[child] = SnapLayoutPrivate::ChildSize{ + engineWidth, engineHeight, width, height}; + + prevEndExact = mainExact + mainSizeExact; + prevNearDev = mainNear; + prevSizeDev = horiz ? width : height; + + const qint64 crossNear = std::llround(crossExact); + const qint64 nearX = originXDev + (horiz ? mainNear : crossNear); + const qint64 nearY = originYDev + (horiz ? crossNear : mainNear); + + const QRectF snapped( + qreal(nearX) / dpr - origin.x(), + qreal(nearY) / dpr - origin.y(), + qreal(width) / dpr, + qreal(height) / dpr); + + const QSizeF oldSize(child->width(), child->height()); + const QSizeF newSize = snapped.size(); + + child->setPosition(snapped.topLeft()); + if (newSize == oldSize) { + if (QQuickLayout* lay = qobject_cast(child)) { + if (lay->invalidatedArrangement()) lay->rearrange(newSize); + } + } else { + child->setSize(newSize); + } + } +} + +void SnapLayout::rearrange(const QSizeF& size) { + Q_D(SnapLayout); + if (!isReady() || !size.isValid()) return; + + const auto refCounter = qScopeGuard([&d] { --(d->recurRearrangeCounter); }); + if (d->recurRearrangeCounter++ == 2) { + qWarning() << "Qt Quick Layouts: Detected recursive rearrange. " + "Aborting after two iterations."; + return; + } + + ensureLayoutItemsUpdated( + QQuickLayout::ApplySizeHints | QQuickLayout::Recursive); + + d->rearranging = true; + d->engine.setVisualDirection(effectiveLayoutDirection()); + updateAncestorListeners(); + + if (const QQuickWindow* win = window()) { + if (const qreal dpr = win->effectiveDevicePixelRatio(); dpr > 0.0) { + const QPointF origin = absoluteOrigin(this); + d->originResidualX = originResidual(origin.x(), dpr); + d->originResidualY = originResidual(origin.y(), dpr); + } + } + + QQuickLayout::rearrange(size); + + for (int i = 0; i < d->engine.itemCount(); ++i) + static_cast(d->engine.itemAt(i))->placed = false; + d->engine.setGeometries(QRectF(QPointF(0, 0), size), d->styleInfo); + arrangeChildren(); + d->rearranging = false; + + for (auto childItem : std::as_const(d->invalidateAfterRearrange)) + invalidate(childItem); + d->invalidateAfterRearrange.clear(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/snaplayout.hpp b/Plugins/ZShell/Components/snaplayout.hpp new file mode 100644 index 0000000..bf879eb --- /dev/null +++ b/Plugins/ZShell/Components/snaplayout.hpp @@ -0,0 +1,164 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class SnapLayout; +class SnapLayoutPrivate; + +class SnapLayoutStyleInfo : public QAbstractLayoutStyleInfo { + public: + qreal spacing(Qt::Orientation orientation) const override; + qreal windowMargin(Qt::Orientation orientation) const override; + bool hasChangedCore() const override; +}; + +class SnapGridLayoutEngine : public QGridLayoutEngine { + public: + SnapGridLayoutEngine() : QGridLayoutEngine(Qt::AlignVCenter, false) {} + + QQuickGridLayoutItem* findLayoutItem(QQuickItem* item) const { + for (int i = int(q_items.size()) - 1; i >= 0; --i) { + QQuickGridLayoutItem* layoutItem = + static_cast(q_items.at(i)); + if (layoutItem->layoutItem() == item) return layoutItem; + } + return nullptr; + } +}; + +class SnapGridLayoutItem : public QQuickGridLayoutItem { + public: + SnapGridLayoutItem( + QQuickItem* item, + int row, + int column, + int rowSpan, + int columnSpan, + Qt::Alignment alignment); + + void setGeometry(const QRectF& rect) override; + + QRectF engineRect; + bool placed = false; +}; + +class SnapLayout : public QQuickLayout { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + Qt::Orientation orientation READ orientation WRITE setOrientation NOTIFY + orientationChanged) + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY( + Qt::LayoutDirection layoutDirection READ layoutDirection WRITE + setLayoutDirection NOTIFY layoutDirectionChanged) + Q_PROPERTY( + bool uniformCellSizes READ uniformCellSizes WRITE setUniformCellSizes + NOTIFY uniformCellSizesChanged) + + public: + explicit SnapLayout(QQuickItem* parent = nullptr); + ~SnapLayout() override; + + void componentComplete() override; + QSizeF sizeHint(Qt::SizeHint whichSizeHint) const override; + void setAlignment(QQuickItem* item, Qt::Alignment align) override; + void setStretchFactor( + QQuickItem* item, int stretchFactor, Qt::Orientation orient) override; + void invalidate(QQuickItem* childItem = nullptr) override; + void updateLayoutItems() override; + QQuickItem* itemAt(int index) const override; + int itemCount() const override; + void rearrange(const QSizeF& size) override; + + void itemChange(ItemChange change, const ItemChangeData& value) override; + + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + void itemDestroyed(QQuickItem* item) override; + void itemVisibilityChanged(QQuickItem* item) override; + void itemGeometryChanged( + QQuickItem* item, + QQuickGeometryChange change, + const QRectF& oldGeometry) override; + + Qt::Orientation orientation() const; + void setOrientation(Qt::Orientation orientation); + + qreal spacing() const; + void setSpacing(qreal spacing); + + Qt::LayoutDirection layoutDirection() const; + void setLayoutDirection(Qt::LayoutDirection direction); + Qt::LayoutDirection effectiveLayoutDirection() const; + + bool uniformCellSizes() const; + void setUniformCellSizes(bool uniformCellSizes); + + signals: + void orientationChanged(); + void spacingChanged(); + void layoutDirectionChanged(); + void uniformCellSizesChanged(); + + private: + Q_DECLARE_PRIVATE(SnapLayout) + + void insertLayoutItems(); + void removeGridItem(QGridLayoutItem* gridItem); + + void arrangeChildren(); + + // Re-arranges when the origin moved by a fraction of a device pixel. + void rearrangeForOriginMove(); + + void applySpacing(); + + void updateAncestorListeners(); + void clearAncestorListeners(); +}; + +class SnapLayoutPrivate : public QQuickLayoutPrivate { + Q_DECLARE_PUBLIC(SnapLayout) + + public: + SnapLayoutPrivate() = default; + + void mirrorChange() override; + + SnapGridLayoutEngine engine; + SnapLayoutStyleInfo* styleInfo = nullptr; + Qt::Orientation orientation = Qt::Horizontal; + Qt::LayoutDirection layoutDirection = Qt::LeftToRight; + qreal spacingRequest = qQNaN(); + bool rearranging = false; + int recurRearrangeCounter = 0; + QList invalidateAfterRearrange; + + QVector> ancestorListeners; + QHash> ancestorMasks; + qint64 originResidualX = -1; + qint64 originResidualY = -1; + + struct ChildSize { + qint64 engineWidth = 0; + qint64 engineHeight = 0; + qint64 width = 0; + qint64 height = 0; + }; + QHash childSizes; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/snapview.cpp b/Plugins/ZShell/Components/snapview.cpp new file mode 100644 index 0000000..4d580cd --- /dev/null +++ b/Plugins/ZShell/Components/snapview.cpp @@ -0,0 +1,806 @@ +#include "snapview.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { +namespace { + +// The same device-pixel helpers SnapLayout uses: the residual is the fractional +// part of an absolute device-pixel position, which is how a sub-pixel move of +// the view or its ancestors is noticed without reacting to whole-pixel moves. +qint64 originResidual(qreal logical, qreal dpr) { + const qreal device = logical * dpr; + return std::llround((device - std::floor(device)) * 1024.0) % 1024; +} + +QPointF absoluteOrigin(const QQuickItem* item) { + qreal x = 0.0; + qreal y = 0.0; + for (const QQuickItem* it = item; it; it = it->parentItem()) { + x += it->x(); + y += it->y(); + } + return QPointF(x, y); +} + +constexpr int AsyncBatchSize = 4; + +// Sizes are compared with a tolerance rather than ==, because the snapped +// logical values are quotients of device pixels and never exact. +bool differs(qreal a, qreal b) { + return qAbs(a - b) > 0.001; +} + +// Geometry is watched to notice sub-pixel moves; Destroyed is watched so a +// listener can be dropped while the item is still valid instead of at teardown. +constexpr QFlags WatchedChanges = + QQuickItemPrivate::Geometry | QQuickItemPrivate::Destroyed; + +void addGeometryListener(QQuickItem* item, QQuickItemChangeListener* listener) { + if (item) + QQuickItemPrivate::get(item)->addItemChangeListener( + listener, WatchedChanges); +} + +void removeGeometryListener( + QQuickItem* item, QQuickItemChangeListener* listener) { + if (item) + QQuickItemPrivate::get(item)->removeItemChangeListener( + listener, WatchedChanges); +} + +} // namespace + +SnapView::SnapView(QQuickItem* parent) : QQuickFlickable(parent) { + setClip(true); + + // Asynchronous batches are spread over event loop turns, so the frames + // between them can render; polish() from updatePolish would run them all + // inside one polish flush. + m_asyncTimer = new QTimer(this); + m_asyncTimer->setSingleShot(true); + m_asyncTimer->setInterval(0); + connect(m_asyncTimer, &QTimer::timeout, this, &SnapView::polish); +} + +SnapView::~SnapView() { + clearListeners(); + disconnectModel(); +} + +QAbstractItemModel* SnapView::model() const { + return m_model.data(); +} + +void SnapView::setModel(QAbstractItemModel* model) { + if (m_model == model) return; + + disconnectModel(); + for (int index : m_items.keys()) + destroyDelegate(index); + m_sizeDevCache.clear(); + m_rowSizes.clear(); + + m_model = model; + invalidateRoleNames(); + connectModel(); + rebuildRows(); + + if (m_componentComplete) polish(); + emit modelChanged(); +} + +QQmlComponent* SnapView::delegate() const { + return m_delegate.data(); +} + +void SnapView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) return; + + m_delegate = delegate; + for (int index : m_items.keys()) + destroyDelegate(index); + m_pendingCreate.clear(); + m_sizeDevCache.clear(); + + if (m_componentComplete) polish(); + emit delegateChanged(); +} + +Qt::Orientation SnapView::orientation() const { + return m_orientation; +} + +void SnapView::setOrientation(Qt::Orientation orientation) { + if (m_orientation == orientation) return; + m_orientation = orientation; + invalidateMainAxisCaches(); + if (m_componentComplete) polish(); + emit orientationChanged(); +} + +qreal SnapView::spacing() const { + return m_spacing; +} + +void SnapView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) return; + m_spacing = spacing; + if (m_componentComplete) polish(); + emit spacingChanged(); +} + +qreal SnapView::cacheBuffer() const { + return m_cacheBuffer; +} + +void SnapView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) return; + m_cacheBuffer = buffer; + if (m_componentComplete) polish(); + emit cacheBufferChanged(); +} + +qreal SnapView::estimatedSize() const { + return m_estimatedSize; +} + +void SnapView::setEstimatedSize(qreal size) { + if (qFuzzyCompare(m_estimatedSize, size)) return; + m_estimatedSize = size; + if (m_componentComplete) polish(); + emit estimatedSizeChanged(); +} + +bool SnapView::snapSizes() const { + return m_snapSizes; +} + +void SnapView::setSnapSizes(bool snapSizes) { + if (m_snapSizes == snapSizes) return; + m_snapSizes = snapSizes; + if (m_componentComplete) polish(); + emit snapSizesChanged(); +} + +bool SnapView::asynchronous() const { + return m_asynchronous; +} + +void SnapView::setAsynchronous(bool asynchronous) { + if (m_asynchronous == asynchronous) return; + m_asynchronous = asynchronous; + if (m_componentComplete) polish(); + emit asynchronousChanged(); +} + +int SnapView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +int SnapView::currentIndex() const { + return m_currentIndex; +} + +void SnapView::setCurrentIndex(int index) { + if (m_currentIndex == index) return; + m_currentIndex = index; + emit currentIndexChanged(); + emit currentItemChanged(); +} + +QQuickItem* SnapView::currentItem() const { + return m_items.value(m_currentIndex, nullptr); +} + +void SnapView::positionViewAtBeginning() { + positionViewAtIndex(0); +} + +void SnapView::positionViewAtEnd() { + positionViewAtIndex(count() - 1); +} + +void SnapView::positionViewAtIndex(int index) { + if (!m_componentComplete || index < 0 || index >= m_rows.size()) return; + + // Summed here rather than read from m_rows, whose offsets only refresh on + // polish: positioning must be right even when called between passes. + const bool horiz = m_orientation == Qt::Horizontal; + const qreal extent = horiz ? width() : height(); + const qreal spacing = effectiveSpacing(); + + qreal offset = 0.0; + qreal target = 0.0; + qreal total = 0.0; + for (int i = 0; i < m_rows.size(); ++i) { + if (i == index) target = offset; + const qreal size = rowSize(i); + total = offset + size; + offset += size + spacing; + } + + const qreal position = + std::clamp(target, 0.0, (std::max)(0.0, total - extent)); + const qint64 positionDev = std::llround(position * devicePixelRatio()); + const qreal snapped = qreal(positionDev) / devicePixelRatio(); + if (horiz) + setContentX(snapped); + else + setContentY(snapped); +} + +qreal SnapView::devicePixelRatio() const { + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; + return dpr > 0.0 ? dpr : 1.0; +} + +void SnapView::componentComplete() { + QQuickFlickable::componentComplete(); + m_componentComplete = true; + rebuildRows(); + updateAncestorListeners(); + polish(); +} + +void SnapView::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickFlickable::geometryChange(newGeometry, oldGeometry); + if (m_componentComplete && !m_placing && + newGeometry.size() != oldGeometry.size()) { + polish(); + } +} + +void SnapView::itemChange(ItemChange change, const ItemChangeData& value) { + QQuickFlickable::itemChange(change, value); + if (change == ItemSceneChange) { + // Leaving the scene means the ancestors are going away: drop the + // listeners now, while both sides are still whole. + if (value.window) { + updateAncestorListeners(); + polish(); + } else { + clearListeners(); + } + return; + } + if (change == ItemDevicePixelRatioHasChanged) { + updateAncestorListeners(); + polish(); + } +} + +void SnapView::updatePolish() { + if (!m_componentComplete) return; + layoutAndPlace(); + if (!m_pendingCreate.isEmpty() && m_asynchronous) m_asyncTimer->start(); +} + +void SnapView::itemGeometryChanged( + QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) { + Q_UNUSED(oldGeometry) + if (!m_componentComplete || m_placing) return; + + const qreal dpr = devicePixelRatio(); + + QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; + const bool watched = std::any_of( + m_ancestorListeners.constBegin(), + m_ancestorListeners.constEnd(), + [item](const QPointer& p) { return p == item; }); + if (item == content || watched) { + const QPointF origin = contentOrigin(); + if (originResidual(origin.x(), dpr) != m_originResidualX || + originResidual(origin.y(), dpr) != m_originResidualY) { + polish(); + } + return; + } + + if (change.sizeChange() && m_items.key(item, -1) >= 0) polish(); +} + +void SnapView::itemDestroyed(QQuickItem* item) { + m_sizeDevCache.remove(item); + const auto isItem = [item](const QPointer& p) { + return p == item; + }; + m_delegateListeners.erase( + std::remove_if( + m_delegateListeners.begin(), m_delegateListeners.end(), isItem), + m_delegateListeners.end()); + m_ancestorListeners.erase( + std::remove_if( + m_ancestorListeners.begin(), m_ancestorListeners.end(), isItem), + m_ancestorListeners.end()); + const int index = m_items.key(item, -1); + if (index >= 0) { + m_items.remove(index); + emit currentItemChanged(); + } +} + +qreal SnapView::effectiveSpacing() const { + return qreal(std::llround(m_spacing * devicePixelRatio())) / + devicePixelRatio(); +} + +qreal SnapView::rowSize(int index) const { + QQuickItem* item = m_items.value(index, nullptr); + if (item) { + const qreal measured = m_orientation == Qt::Horizontal ? item->width() + : item->height(); + if (measured > 0.0) return measured; + } + + const qreal cached = m_rowSizes.value(index, 0.0); + if (cached > 0.0) return cached; + return m_estimatedSize > 0.0 ? m_estimatedSize : 0.0; +} + +qint64 SnapView::snappedDeviceMainSize(QQuickItem* item) { + const qreal dpr = devicePixelRatio(); + + const qreal own = m_orientation == Qt::Horizontal ? item->width() + : item->height(); + if (own <= 0.0) return 0; + + // Reuse the cached device size while the item's own size is still within a + // device pixel of it, so an item that only moves keeps its size. + const qint64 cached = m_sizeDevCache.value(item, -1); + if (cached >= 0 && qAbs(own * dpr - qreal(cached)) <= 1.0) return cached; + + const qint64 snapped = std::llround(own * dpr); + m_sizeDevCache[item] = snapped; + return snapped; +} + +int SnapView::firstVisibleIndex() const { + if (m_rows.isEmpty()) return 0; + + // Row ends are non-decreasing (offsets accumulate size plus spacing), so the + // visible range can be found by binary search instead of a linear scan. + const qreal start = m_orientation == Qt::Horizontal ? contentX() + : contentY(); + const qreal from = (std::max)(0.0, start - m_cacheBuffer); + + const auto it = std::lower_bound( + m_rows.constBegin(), + m_rows.constEnd(), + from, + [](const Row& row, qreal value) { + return row.exactOffset + row.exactSize < value; + }); + return static_cast(std::distance(m_rows.constBegin(), it)); +} + +int SnapView::lastVisibleIndex() const { + if (m_rows.isEmpty()) return 0; + + const bool horiz = m_orientation == Qt::Horizontal; + const qreal start = horiz ? contentX() : contentY(); + const qreal extent = horiz ? width() : height(); + const qreal to = start + extent + m_cacheBuffer; + + const auto it = std::lower_bound( + m_rows.constBegin(), + m_rows.constEnd(), + to, + [](const Row& row, qreal value) { return row.exactOffset <= value; }); + if (it == m_rows.constBegin()) return 0; + return static_cast(std::distance(m_rows.constBegin(), it)) - 1; +} + +QPointF SnapView::contentOrigin() const { + // QQuickFlickablePrivate::get has no const overload; the item private is the + // base of the flickable private, so casting is the const path to it. + const auto* priv = static_cast( + QQuickItemPrivate::get(this)); + return absoluteOrigin(priv->contentItem); +} + +void SnapView::layoutAndPlace() { + const qreal dpr = devicePixelRatio(); + + const bool horiz = m_orientation == Qt::Horizontal; + const qreal spacing = effectiveSpacing(); + + // Cumulative exact offsets, so the rounding below has the same exact + // reference SnapLayout's arrangeChildren works from. Running this before + // updateDelegates gives the viewport range to recycle against, and running + // it again afterwards picks up sizes that changed when a re-keyed delegate + // got a new index. + const auto accumulate = [&]() { + qreal offset = 0.0; + for (int i = 0; i < m_rows.size(); ++i) { + qreal size = rowSize(i); + QQuickItem* item = m_items.value(i, nullptr); + if (item) { + const qreal measured = horiz ? item->width() : item->height(); + if (measured > 0.0) m_rowSizes[i] = measured; + } + + m_rows[i].exactOffset = offset; + m_rows[i].exactSize = size; + offset += size + spacing; + } + return m_rows.isEmpty() ? 0.0 : offset - spacing; + }; + + accumulate(); + updateDelegates(); + const qreal total = accumulate(); + + if (horiz) + setContentWidth(qreal(std::llround(total * dpr)) / dpr); + else + setContentHeight(qreal(std::llround(total * dpr)) / dpr); + + QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; + if (!content) return; + + const QPointF origin = absoluteOrigin(content); + const qint64 originXDev = std::llround(origin.x() * dpr); + const qint64 originYDev = std::llround(origin.y() * dpr); + const qint64 crossDev = std::llround((horiz ? height() : width()) * dpr); + + qint64 prevNear = 0; + qint64 prevSizeDev = 0; + qreal prevEndExact = 0.0; + bool started = false; + + m_placing = true; + for (int i = 0; i < m_rows.size(); ++i) { + const Row& row = m_rows.at(i); + const qreal mainExact = row.exactOffset * dpr; + + qint64 mainNear = 0; + if (!started) { + mainNear = std::llround(mainExact); + started = true; + } else { + mainNear = + prevNear + prevSizeDev + std::llround(mainExact - prevEndExact); + } + + QQuickItem* item = m_items.value(i, nullptr); + qint64 mainDev = std::llround(row.exactSize * dpr); + if (item) { + const qint64 ownDev = snappedDeviceMainSize(item); + if (ownDev > 0) mainDev = ownDev; + } + + prevEndExact = mainExact + row.exactSize * dpr; + prevSizeDev = mainDev; + prevNear = mainNear; + + if (!item) continue; + + const qreal mainSize = qreal(mainDev) / dpr; + const qreal crossSize = qreal(crossDev) / dpr; + + const qreal posX = horiz + ? qreal(originXDev + mainNear) / dpr - origin.x() + : qreal(originXDev) / dpr - origin.x(); + const qreal posY = + horiz ? qreal(originYDev) / dpr - origin.y() + : qreal(originYDev + mainNear) / dpr - origin.y(); + item->setPosition(QPointF(posX, posY)); + + // Only the cross axis is written unconditionally. Writing the main axis + // would replace the delegate's implicit-size fallback with an explicit + // value, so a delegate whose content changes size could never grow + // again. It is resized only when snapSizes asks for exactly that. + if (horiz) { + if (m_snapSizes && mainDev > 0 && differs(item->width(), mainSize)) + item->setWidth(mainSize); + if (differs(item->height(), crossSize)) item->setHeight(crossSize); + } else { + if (differs(item->width(), crossSize)) item->setWidth(crossSize); + if (m_snapSizes && mainDev > 0 && differs(item->height(), mainSize)) + item->setHeight(mainSize); + } + } + m_placing = false; + + trackOriginResidual(); +} + +void SnapView::updateDelegates() { + const int first = firstVisibleIndex(); + const int last = lastVisibleIndex(); + + for (int index : m_items.keys()) { + if (index < first || index > last) destroyDelegate(index); + } + + QVector missing; + for (int index = first; index <= last; ++index) { + if (m_items.contains(index)) + updateDelegateData(index); + else + missing.append(index); + } + + int budget = m_asynchronous ? AsyncBatchSize + : (std::numeric_limits::max)(); + m_pendingCreate.clear(); + for (int index : std::as_const(missing)) { + if (budget <= 0) { + m_pendingCreate.append(index); + continue; + } + (void)createDelegate(index); + --budget; + } +} + +QQuickItem* SnapView::createDelegate(int index) { + if (!m_delegate || !m_model || index < 0 || index >= m_model->rowCount()) + return nullptr; + if (m_items.contains(index)) return m_items.value(index); + + // Same as LazyListView: beginCreate + setInitialProperties + completeCreate, + // so delegates with pragma ComponentBehavior: Bound are accepted. + auto* compContext = m_delegate->creationContext(); + if (!compContext) compContext = qmlContext(this); + if (!compContext) return nullptr; + + QObject* obj = m_delegate->beginCreate(compContext); + QQuickItem* item = qobject_cast(obj); + if (!item) { + qWarning( + "SnapView: delegate creation failed for row %d: %s", + index, + qPrintable(m_delegate->errorString())); + if (obj) m_delegate->completeCreate(); + delete obj; + return nullptr; + } + + m_delegate->setInitialProperties(item, delegateProperties(index)); + + QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; + item->setParentItem(content ? content : this); + addGeometryListener(item, this); + m_delegateListeners.append(item); + m_items.insert(index, item); + + m_delegate->completeCreate(); + + emit currentItemChanged(); + return item; +} + +void SnapView::destroyDelegate(int index) { + QQuickItem* item = m_items.take(index); + if (!item) return; + + removeGeometryListener(item, this); + m_delegateListeners.removeAll(item); + m_sizeDevCache.remove(item); + item->deleteLater(); + emit currentItemChanged(); +} + +void SnapView::updateDelegateData(int index) { + QQuickItem* item = m_items.value(index, nullptr); + if (!item) return; + + const QVariantMap props = delegateProperties(index); + for (auto it = props.constBegin(); it != props.constEnd(); ++it) { + const QByteArray name = it.key().toUtf8(); + // Scrolls refresh every visible delegate each pass; writing only the + // values that actually changed keeps that cheap. + if (item->property(name.constData()) == it.value()) continue; + item->setProperty(name.constData(), it.value()); + } +} + +QVariantMap SnapView::delegateProperties(int index) { + QVariantMap props; + if (!m_model || index < 0 || index >= m_model->rowCount()) return props; + + const QModelIndex srcIndex = m_model->index(index, 0); + const auto& names = roleNames(); + bool hasModelData = false; + + for (auto it = names.constBegin(); it != names.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + props.insert(name, m_model->data(srcIndex, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + + props.insert(QStringLiteral("index"), index); + + if (!hasModelData) { + const auto role = names.isEmpty() ? Qt::DisplayRole + : names.constBegin().key(); + props.insert(QStringLiteral("modelData"), m_model->data(srcIndex, role)); + } + + return props; +} + +const QHash& SnapView::roleNames() { + if (!m_roleNamesValid) { + m_roleNames = m_model ? m_model->roleNames() : QHash(); + m_roleNamesValid = true; + } + return m_roleNames; +} + +void SnapView::invalidateRoleNames() { + m_roleNamesValid = false; +} + +void SnapView::invalidateMainAxisCaches() { + // Cached sizes belong to the axis that is no longer the main one. + m_rowSizes.fill(0.0); + m_sizeDevCache.clear(); +} + +void SnapView::rebuildRows() { + m_rows.resize(count()); + m_rowSizes.resize(count()); + if (m_currentIndex >= count()) m_currentIndex = -1; + emit countChanged(); + emit currentItemChanged(); +} + +void SnapView::trackOriginResidual() { + const qreal dpr = devicePixelRatio(); + const QPointF origin = contentOrigin(); + m_originResidualX = originResidual(origin.x(), dpr); + m_originResidualY = originResidual(origin.y(), dpr); +} + +void SnapView::shiftDelegateIndices(int from, int delta) { + if (delta == 0) return; + + QHash shifted; + for (auto it = m_items.constBegin(); it != m_items.constEnd(); ++it) { + shifted.insert( + it.key() >= from ? it.key() + delta : it.key(), it.value()); + } + m_items = shifted; + + for (int& index : m_pendingCreate) { + if (index >= from) index += delta; + } +} + +void SnapView::updateAncestorListeners() { + QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; + QVector> want; + if (content) want.append(content); + for (QQuickItem* it = parentItem(); it; it = it->parentItem()) + want.append(it); + + for (const QPointer& item : + std::as_const(m_ancestorListeners)) { + if (item && !want.contains(item)) + removeGeometryListener(item.data(), this); + } + for (const QPointer& item : std::as_const(want)) { + const bool known = std::any_of( + m_ancestorListeners.constBegin(), + m_ancestorListeners.constEnd(), + [&item](const QPointer& p) { return p == item; }); + if (!known) addGeometryListener(item.data(), this); + } + m_ancestorListeners = want; + + trackOriginResidual(); +} + +void SnapView::clearListeners() { + for (const QPointer& item : std::as_const(m_ancestorListeners)) + removeGeometryListener(item.data(), this); + m_ancestorListeners.clear(); + m_delegateListeners.clear(); +} + +void SnapView::connectModel() { + if (!m_model) return; + m_modelConnections.append( + QObject::connect( + m_model, + &QAbstractItemModel::rowsInserted, + this, + &SnapView::onRowsInserted)); + m_modelConnections.append( + QObject::connect( + m_model, + &QAbstractItemModel::rowsAboutToBeRemoved, + this, + &SnapView::onRowsAboutToBeRemoved)); + m_modelConnections.append( + QObject::connect( + m_model, + &QAbstractItemModel::rowsRemoved, + this, + &SnapView::onRowsRemoved)); + m_modelConnections.append( + QObject::connect( + m_model, + &QAbstractItemModel::dataChanged, + this, + &SnapView::onDataChanged)); + m_modelConnections.append( + QObject::connect( + m_model, + &QAbstractItemModel::modelReset, + this, + &SnapView::onModelReset)); + m_modelConnections.append( + QObject::connect( + m_model, &QAbstractItemModel::layoutChanged, this, [this]() { + rebuildRows(); + polish(); + })); +} + +void SnapView::disconnectModel() { + for (const auto& conn : std::as_const(m_modelConnections)) + disconnect(conn); + m_modelConnections.clear(); +} + +void SnapView::onRowsInserted(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + // Delegates at or after the inserted range now address a different row, and + // the inserted rows have no measured size yet. + shiftDelegateIndices(first, last - first + 1); + m_rowSizes.resize(count()); + for (int index = first; index <= last; ++index) + m_rowSizes[index] = 0.0; + rebuildRows(); + polish(); +} + +void SnapView::onRowsAboutToBeRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + for (int index = first; index <= last; ++index) + destroyDelegate(index); + m_rowSizes.remove(first, last - first + 1); + shiftDelegateIndices(last + 1, -(last - first + 1)); +} + +void SnapView::onRowsRemoved(const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + Q_UNUSED(first) + Q_UNUSED(last) + rebuildRows(); + polish(); +} + +void SnapView::onDataChanged( + const QModelIndex& topLeft, const QModelIndex& bottomRight) { + if (topLeft.parent().isValid() || bottomRight.parent().isValid()) return; + for (int index = topLeft.row(); index <= bottomRight.row(); ++index) + updateDelegateData(index); + // A row's content can change its size, so re-place as well. + polish(); +} + +void SnapView::onModelReset() { + for (int index : m_items.keys()) + destroyDelegate(index); + m_sizeDevCache.clear(); + m_rowSizes.clear(); + rebuildRows(); + polish(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/snapview.hpp b/Plugins/ZShell/Components/snapview.hpp new file mode 100644 index 0000000..abb195f --- /dev/null +++ b/Plugins/ZShell/Components/snapview.hpp @@ -0,0 +1,237 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QTimer; + +namespace ZShell::components { + +// A ListView replacement that lays delegates out the way SnapLayout lays out +// children: delegates are placed on device-pixel boundaries, the gaps between +// them are whole device pixels, siblings that move together keep their relative +// offsets, and a delegate that only moves is not resized. Like SnapLayout is to +// RowLayout/ColumnLayout, this is to ListView: a Flickable with its own +// placement, so there is no base-view positioner to fight with. +// +// Delegates receive `index` and `modelData` initial properties, so a delegate +// declares `required property int index` and `required property var modelData`. +// The cross-axis size (height in a horizontal view, width in a vertical one) is +// set by the view, as ListView does; the main-axis size comes from the delegate +// unless snapSizes is true. +class SnapView : public QQuickFlickable, public QQuickItemChangeListener { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged + FINAL) + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged FINAL) + + // Layout + Q_PROPERTY( + Qt::Orientation orientation READ orientation WRITE setOrientation NOTIFY + orientationChanged FINAL) + Q_PROPERTY( + qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged FINAL) + Q_PROPERTY( + qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY + cacheBufferChanged FINAL) + Q_PROPERTY( + qreal estimatedSize READ estimatedSize WRITE setEstimatedSize NOTIFY + estimatedSizeChanged FINAL) + + // Resize delegates to device pixels along the main axis too. Off by + // default, because delegates usually derive that size from their content + // and resizing them would break the binding. Placement is always + // device-pixel snapped. + Q_PROPERTY( + bool snapSizes READ snapSizes WRITE setSnapSizes NOTIFY snapSizesChanged + FINAL) + + // Spread delegate creation over frames instead of creating everything in + // the first polish pass. + Q_PROPERTY( + bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY + asynchronousChanged FINAL) + + // State + Q_PROPERTY(int count READ count NOTIFY countChanged FINAL) + Q_PROPERTY( + int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY + currentIndexChanged FINAL) + Q_PROPERTY( + QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged FINAL) + + signals: + void modelChanged(); + void delegateChanged(); + void orientationChanged(); + void spacingChanged(); + void cacheBufferChanged(); + void estimatedSizeChanged(); + void snapSizesChanged(); + void asynchronousChanged(); + void countChanged(); + void currentIndexChanged(); + void currentItemChanged(); + + public: + explicit SnapView(QQuickItem* parent = nullptr); + ~SnapView() override; + + [[nodiscard]] QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + [[nodiscard]] Qt::Orientation orientation() const; + void setOrientation(Qt::Orientation orientation); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] qreal cacheBuffer() const; + void setCacheBuffer(qreal buffer); + + [[nodiscard]] qreal estimatedSize() const; + void setEstimatedSize(qreal size); + + [[nodiscard]] bool snapSizes() const; + void setSnapSizes(bool snapSizes); + + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool asynchronous); + + [[nodiscard]] int count() const; + + [[nodiscard]] int currentIndex() const; + void setCurrentIndex(int index); + + [[nodiscard]] QQuickItem* currentItem() const; + + Q_INVOKABLE void positionViewAtBeginning(); + Q_INVOKABLE void positionViewAtEnd(); + Q_INVOKABLE void positionViewAtIndex(int index); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + void itemChange(ItemChange change, const ItemChangeData& value) override; + + // QQuickItemChangeListener + void itemGeometryChanged( + QQuickItem* item, + QQuickGeometryChange change, + const QRectF& oldGeometry) override; + void itemDestroyed(QQuickItem* item) override; + + private: + struct Row { + qreal exactOffset = 0.0; + qreal exactSize = 0.0; + }; + + // Main-axis size last measured from a delegate, kept per model row so the + // content extent stays right while delegates outside the viewport are + // destroyed, the way ListView keeps contentHeight. + QVector m_rowSizes; + + // Placement, mirroring SnapLayout::arrangeChildren: exact positions come + // from cumulative row sizes, each near edge is rounded on its own so gaps + // stay whole device pixels, and siblings keep their relative offsets. + void layoutAndPlace(); + void updateDelegates(); + [[nodiscard]] QQuickItem* createDelegate(int index); + void destroyDelegate(int index); + void updateDelegateData(int index); + + [[nodiscard]] qreal effectiveSpacing() const; + [[nodiscard]] qreal devicePixelRatio() const; + // Main-axis size of a row: the delegate's own size if it exists, otherwise + // the size cached when it was last visible, otherwise the estimate. + [[nodiscard]] qreal rowSize(int index) const; + [[nodiscard]] qint64 snappedDeviceMainSize(QQuickItem* item); + [[nodiscard]] int firstVisibleIndex() const; + [[nodiscard]] int lastVisibleIndex() const; + [[nodiscard]] QPointF contentOrigin() const; + void trackOriginResidual(); + + [[nodiscard]] QVariantMap delegateProperties(int index); + // The model's role names, cached because some models rebuild the hash on + // every call and this runs once per delegate per refresh. + [[nodiscard]] const QHash& roleNames(); + void invalidateRoleNames(); + + void rebuildRows(); + // Sizes are measured along the main axis, so switching axis invalidates + // every cached size. + void invalidateMainAxisCaches(); + // Re-keys the delegate map when rows shift, so an existing item keeps its + // place in the model instead of pointing at the wrong row. + void shiftDelegateIndices(int from, int delta); + void updateAncestorListeners(); + void clearListeners(); + void connectModel(); + void disconnectModel(); + + void onRowsInserted(const QModelIndex& parent, int first, int last); + void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void onRowsRemoved(const QModelIndex& parent, int first, int last); + void onDataChanged( + const QModelIndex& topLeft, const QModelIndex& bottomRight); + void onModelReset(); + + QPointer m_model; + QPointer m_delegate; + + Qt::Orientation m_orientation = Qt::Vertical; + qreal m_spacing = 0.0; + qreal m_cacheBuffer = 0.0; + qreal m_estimatedSize = -1.0; + bool m_snapSizes = false; + bool m_asynchronous = false; + + int m_currentIndex = -1; + + QVector m_rows; + QHash m_items; + QVector m_pendingCreate; + + // Device-pixel main size of a delegate, so an item that only moves keeps + // the size it already has rather than re-rounding a drifting exact size. + QHash m_sizeDevCache; + + // Cached model role names; valid until the model or its roles change. + QHash m_roleNames; + bool m_roleNamesValid = false; + + // Fires the next asynchronous creation batch on the next event loop turn, + // so batches land on different frames instead of one polish flush. + QTimer* m_asyncTimer = nullptr; + + // Items watched for sub-pixel moves. QPointer, as SnapLayout does, so a + // teardown order that destroys an item before the view cannot leave a + // dangling pointer to remove a listener from. + QVector> m_delegateListeners; + QVector> m_ancestorListeners; + qint64 m_originResidualX = -1; + qint64 m_originResidualY = -1; + + bool m_placing = false; + bool m_componentComplete = false; + + QList m_modelConnections; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp index 97b4dd5..cda7c5f 100644 --- a/Plugins/ZShell/Components/sparklinecontent.cpp +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -3,23 +3,120 @@ #include #include #include +#include + +#include +#include namespace ZShell::components { +namespace { +qint64 toDev(qreal logical, qreal dpr) { + return std::llround(logical * dpr); +} + +qreal fromDev(qint64 device, qreal dpr) { + return qreal(device) / dpr; +} + +// A line center whose device position leaves a whole-pixel-wide stroke: an even +// width sits on pixel boundaries, an odd width on pixel centers. Same rule as +// snapLine() in Components/LineGraph.qml. +qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) { + const qreal half = qreal(widthDev) / 2.0; + return (static_cast(std::llround(logical * dpr - half)) + half) / + dpr; +} + +// y of a cubic bezier at targetX, found by bisection on the parameter. Used to +// place one vertex per device pixel column, so a segment never interpolates its +// edge across several columns. +qreal cubicY( + const QPointF& p1, + const QPointF& c1, + const QPointF& c2, + const QPointF& p2, + qreal targetX) { + qreal lo = 0.0; + qreal hi = 1.0; + for (int it = 0; it < 16; ++it) { + const qreal t = (lo + hi) / 2.0; + const qreal u = 1.0 - t; + const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x() + + 3 * u * t * t * c2.x() + t * t * t * p2.x(); + if (x < targetX) + lo = t; + else + hi = t; + } + + const qreal t = (lo + hi) / 2.0; + const qreal u = 1.0 - t; + return u * u * u * p1.y() + 3 * u * u * t * c1.y() + + 3 * u * t * t * c2.y() + t * t * t * p2.y(); +} + +// Catmull-Rom controls with the control y clamped to the segment's own y span, +// so a curve never overshoots past its endpoints. +void segmentControls( + const QVector& points, + int i, + bool curveSmoothing, + QPointF& c1, + QPointF& c2) { + 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)]; + + if (curveSmoothing && points.size() > 2) { + const qreal k = 1.0 / 6.0; + c1 = p1 + (p2 - p0) * k; + c2 = p2 - (p3 - p1) * k; + + const qreal minY = qMin(p1.y(), p2.y()); + const qreal maxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(minY, c1.y(), maxY)); + c2.setY(qBound(minY, c2.y(), maxY)); + return; + } + + const qreal cx = (p1.x() + p2.x()) * 0.5; + c1 = QPointF(cx, p1.y()); + c2 = QPointF(cx, p2.y()); +} +} // namespace + SparklineContent::SparklineContent(QQuickItem* parent) : QQuickPaintedItem(parent) { setAntialiasing(true); } +void SparklineContent::itemChange( + ItemChange change, const ItemChangeData& value) { + QQuickPaintedItem::itemChange(change, value); + if (change == ItemSceneChange && value.window) + m_dpr = value.window->effectiveDevicePixelRatio(); + else if (change == ItemDevicePixelRatioHasChanged && window()) + m_dpr = window()->effectiveDevicePixelRatio(); + else + return; + + invalidate(); +} + qreal SparklineContent::stepX() const { if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0; - if (m_sliding) return m_viewportW / static_cast(m_historyLength - 1); - const int len = qMax( 2, (m_line1 && m_line1->count() >= 2) ? m_line1->count() : (m_line2 ? m_line2->count() : 2)); - return m_viewportW / static_cast(len - 1); + const qreal logical = + m_sliding ? m_viewportW / static_cast(m_historyLength - 1) + : m_viewportW / static_cast(len - 1); + // A whole device pixel per step: every sample lands on a pixel column, and + // one slide step moves the content by an exact pixel count. + return fromDev((std::max)(qint64(1), toDev(logical, m_dpr)), m_dpr); } void SparklineContent::setViewportSize( @@ -130,79 +227,97 @@ void SparklineContent::applyGeometry() { SparklineContent::LineGeometry SparklineContent::buildGeometry( internal::CircularBuffer* buffer) const { LineGeometry geom; - if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || - m_viewportW <= 0.0) + // Every input has to be a usable number: a NaN from QML (an animated maxValue + // fed by an empty buffer, for instance) would otherwise reach llround and the + // path stroker as garbage, which draws a shape that ignores the curve. + if (!buffer || buffer->count() < 2 || !std::isfinite(m_maxValue) || + m_maxValue <= 0.0 || !std::isfinite(m_viewportW) || + m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || m_viewportH <= 0.0) return geom; if (m_historyLength < 2) return geom; + const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0; const int len = buffer->count(); const qreal step = stepX(); - if (step <= 0.0) return geom; + if (!std::isfinite(step) || step <= 0.0) return geom; + + const qint64 stepDev = toDev(step, dpr); + const qint64 lineDev = std::isfinite(m_lineWidth) + ? (std::max)(qint64(1), toDev(m_lineWidth, dpr)) + : qint64(1); + const qint64 heightDev = toDev(m_viewportH, dpr); qreal startX = 0.0; if (m_sliding) startX = m_viewportW - (len - 1) * step + step; else startX = 0.0; + const qint64 startDev = toDev(startX, dpr); - const qreal h = m_viewportH; - const qreal strokePad = qCeil(m_lineWidth / 2); - const qreal curvePad = 3.0; - const qreal plotTop = strokePad + curvePad; - const qreal plotBottom = h - strokePad; - const qreal fillBottom = h; - const qreal plotH = qMax(1.0, plotBottom - plotTop); + // Plot bounds in device pixels: half a stroke plus the curve overshoot at + // the top, a half stroke and one pixel of antialiasing feather at the + // bottom, so neither edge of the stroke is cut by the item's own rect. + const qint64 curvePadDev = toDev(m_curveSmoothing ? 3.0 : 0.0, dpr); + const qint64 plotTopDev = lineDev / 2 + curvePadDev; + const qint64 plotBottomDev = + (std::max)(plotTopDev + 1, heightDev - (lineDev + 1) / 2 - 1); + const qint64 plotHDev = plotBottomDev - plotTopDev; + const qreal fillBottom = fromDev(heightDev, dpr); QVector points; points.reserve(len); for (int i = 0; i < len; ++i) { - const qreal value = - qBound(0.0, buffer->smoothedAt(i), m_maxValue); + const qreal raw = buffer->smoothedAt(i); - const qreal x = startX + i * step; - const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; - points.append(QPointF(x, y)); + // A non-finite sample poisons the whole path: llround of it is undefined, + // and the stroke and fill then ignore everything after that vertex. Treat + // it as no traffic instead. + const qreal value = + std::isfinite(raw) ? qBound(0.0, raw, m_maxValue) : 0.0; + + // Vertices on a pixel column, and on the y grid the stroke width needs. + // Item-local, so the parent's placement cannot move the shape. A flat run + // then lands on whole rows; slopes are interpolated, not snapped. + const qreal y = fromDev( + plotTopDev + static_cast(std::llround( + (1.0 - value / m_maxValue) * + static_cast(plotHDev))), + dpr); + points.append(QPointF( + fromDev(startDev + qint64(i) * stepDev, dpr), + snapLine(y, lineDev, dpr))); } QPainterPath linePath; - linePath.moveTo(points[0]); + linePath.moveTo(points.first()); - if (m_curveSmoothing && points.size() > 2) { - const qreal tension = 1.0; - const qreal k = tension / 6.0; - const int n = static_cast(points.size()); + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + QPointF c1; + QPointF c2; + segmentControls(points, i, m_curveSmoothing, c1, c2); - for (int i = 0; i < n - 1; ++i) { - const QPointF& p0 = points[qMax(i - 1, 0)]; - const QPointF& p1 = points[i]; - const QPointF& p2 = points[i + 1]; - const QPointF& p3 = points[qMin(i + 2, n - 1)]; - - QPointF c1 = p1 + (p2 - p0) * k; - QPointF c2 = p2 - (p3 - p1) * k; - - const qreal segMinY = qMin(p1.y(), p2.y()); - const qreal segMaxY = qMax(p1.y(), p2.y()); - c1.setY(qBound(segMinY, c1.y(), segMaxY)); - c2.setY(qBound(segMinY, c2.y(), segMaxY)); - - linePath.cubicTo(c1, c2, p2); - } - } else { - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + // One vertex per device pixel column, as Components/LineGraph.qml does: a + // segment spanning several columns would otherwise interpolate its edge + // across all of them. y stays continuous here - snapping it per column + // would step the slope one pixel at a time and read as a staircase. + const qint64 firstCol = std::llround(p1.x() * dpr) + 1; + const qint64 lastCol = std::llround(p2.x() * dpr) - 1; + for (qint64 col = firstCol; col <= lastCol; ++col) { + const qreal x = fromDev(col, dpr); + linePath.lineTo(x, cubicY(p1, c1, c2, p2, x)); } + + linePath.lineTo(p2); } geom.stroke = linePath; QPainterPath fillPath = linePath; - fillPath.lineTo(startX + (len - 1) * step, fillBottom); - fillPath.lineTo(startX, fillBottom); + fillPath.lineTo(points.last().x(), fillBottom); + fillPath.lineTo(points.first().x(), fillBottom); fillPath.closeSubpath(); geom.fill = fillPath; geom.hasData = true; @@ -223,6 +338,12 @@ void SparklineContent::paint(QPainter* painter) { painter->setRenderHint(QPainter::Antialiasing, true); + const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0; + const qreal lineWidth = + std::isfinite(m_lineWidth) + ? fromDev((std::max)(qint64(1), toDev(m_lineWidth, dpr)), dpr) + : 1.0; + auto drawOne = [&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) { if (!geom.hasData) return; @@ -233,7 +354,7 @@ void SparklineContent::paint(QPainter* painter) { painter->setBrush(fillColor); painter->drawPath(geom.fill); - QPen pen(color, m_lineWidth); + QPen pen(color, lineWidth); pen.setCapStyle(Qt::RoundCap); pen.setJoinStyle(Qt::RoundJoin); painter->setPen(pen); diff --git a/Plugins/ZShell/Components/sparklinecontent.hpp b/Plugins/ZShell/Components/sparklinecontent.hpp index 9417b94..7d18f43 100644 --- a/Plugins/ZShell/Components/sparklinecontent.hpp +++ b/Plugins/ZShell/Components/sparklinecontent.hpp @@ -32,6 +32,11 @@ class SparklineContent : public QQuickPaintedItem { [[nodiscard]] qreal stepX() const; + protected: + // The stroke width, sample spacing and plot bounds are quantized in device + // pixels, so they have to be built with the ratio the window actually has. + void itemChange(ItemChange change, const ItemChangeData& value) override; + private: struct LineGeometry { QPainterPath stroke; @@ -60,6 +65,7 @@ class SparklineContent : public QQuickPaintedItem { qreal m_viewportW = 0.0; qreal m_viewportH = 0.0; + qreal m_dpr = 1.0; LineGeometry m_geom1; LineGeometry m_geom2; diff --git a/Plugins/ZShell/Components/wavyline.cpp b/Plugins/ZShell/Components/wavyline.cpp index e5c2539..16bdda6 100644 --- a/Plugins/ZShell/Components/wavyline.cpp +++ b/Plugins/ZShell/Components/wavyline.cpp @@ -1,5 +1,6 @@ #include "wavyline.hpp" #include "wavylinerenderer.hpp" +#include namespace ZShell::controls { @@ -25,6 +26,14 @@ QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const { return new WavyLineRenderer; } +void WavyLine::itemChange(ItemChange change, const ItemChangeData& value) { + QCanvasPainterItem::itemChange(change, value); + if (change == ItemSceneChange && value.window) + m_dpr = value.window->effectiveDevicePixelRatio(); + else if (change == ItemDevicePixelRatioHasChanged && window()) + m_dpr = window()->effectiveDevicePixelRatio(); +} + int WavyLine::lineWidth() const { return m_lineWidth; } diff --git a/Plugins/ZShell/Components/wavyline.hpp b/Plugins/ZShell/Components/wavyline.hpp index 4025664..210e917 100644 --- a/Plugins/ZShell/Components/wavyline.hpp +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -103,6 +103,11 @@ class WavyLine : public QCanvasPainterItem { [[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer() const override; + // Keeps the renderer's device pixel ratio current: strokes and sample + // spacing are quantized in device pixels, so they must be built with the + // ratio the window actually has. + void itemChange(ItemChange change, const ItemChangeData& value) override; + private: friend class WavyLineRenderer; @@ -118,6 +123,7 @@ class WavyLine : public QCanvasPainterItem { qreal m_fullAngle; qreal m_radius; qreal m_value; + qreal m_dpr = 1.0; }; } // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavylinerenderer.cpp b/Plugins/ZShell/Components/wavylinerenderer.cpp index 7da2b07..917d075 100644 --- a/Plugins/ZShell/Components/wavylinerenderer.cpp +++ b/Plugins/ZShell/Components/wavylinerenderer.cpp @@ -1,6 +1,8 @@ #include "wavylinerenderer.hpp" #include "wavyline.hpp" #include +#include +#include namespace ZShell::controls { @@ -19,13 +21,48 @@ void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) { m_fullAngle = wavyLine->m_fullAngle; m_radius = wavyLine->m_radius; m_value = wavyLine->m_value; + m_dpr = wavyLine->m_dpr > 0.0 ? wavyLine->m_dpr : 1.0; } +namespace { +qint64 toDev(qreal logical, qreal dpr) { + return std::llround(logical * dpr); +} + +qreal fromDev(qint64 device, qreal dpr) { + return qreal(device) / dpr; +} + +// A center coordinate whose device position shares the parity of the stroke's +// device width: an even width centered on a pixel boundary and an odd width +// centered on a pixel center are the two cases where both edges of the stroke +// land on whole device pixels. Floored, so the caller gets the same value from +// the same device size every time; a rounded half could flip between two grid +// points depending on floating point noise. +qreal snapCenterDev(qint64 sizeDev, qint64 widthDev) { + const double halfParity = (widthDev % 2) == 0 ? 0.0 : 0.5; + return std::floor(static_cast(sizeDev) / 2.0 - halfParity) + + halfParity; +} + +qint64 snapAmplitude(qint64 widthDev, qreal multiplier) { + return std::llround(static_cast(widthDev) * multiplier); +} +} // namespace + void WavyLineRenderer::paint(QCanvasPainter* painter) { painter->clearRect(0, 0, width(), height()); + // The stroke is quantized in the item's own device pixel space, never + // against its position: the parent layout already places the item on the + // device grid, so a local whole-device value stays whole-device wherever + // the item moves, and a move cannot change the painted path. + const qreal dpr = m_dpr; + m_lineWidthDev = (std::max)(qint64(1), toDev(m_lineWidth, dpr)); + m_amplitudeDev = snapAmplitude(m_lineWidthDev, m_amplitudeMultiplier); + painter->setStrokeStyle(m_color); - painter->setLineWidth(m_lineWidth); + painter->setLineWidth(fromDev(m_lineWidthDev, dpr)); painter->setLineCap(QCanvasPainter::LineCap::Round); if (m_pathType == WavyLine::Arc) { @@ -36,18 +73,36 @@ void WavyLineRenderer::paint(QCanvasPainter* painter) { } void WavyLineRenderer::paintLinear(QCanvasPainter* painter) { - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; + const qreal dpr = m_dpr; + const auto lineWidth = fromDev(m_lineWidthDev, dpr); const auto phase = m_waveProgress * 2 * M_PI; - const auto centerY = height() / 2; + const qint64 heightDev = toDev(height(), dpr); + const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev); + const auto centerY = cyDev / dpr; 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 start = lineWidth / 2.0; + const auto fullEnd = width() - lineWidth / 2.0; const auto drawEnd = start + (fullEnd - start) * m_value; + // Keep the crest inside the item's own texture, with one device pixel for + // the antialiasing feather, so a tight item cannot cut it flat. + const qint64 halfDev = (m_lineWidthDev + 1) / 2; + const qint64 roomDev = static_cast( + std::floor((std::min)(cyDev, qreal(heightDev) - cyDev))); + const qint64 amplitudeDev = (std::max)(qint64(0), roomDev - halfDev - 1); + const auto amplitude = + fromDev((std::min)(m_amplitudeDev, amplitudeDev), dpr); + painter->beginPath(); bool first = true; - for (int x = m_lineWidth / 2; x <= drawEnd; ++x) { + // One vertex per device pixel column. A segment spanning several columns + // interpolates its edge across all of them, which is what makes a sloped + // stroke look soft rather than crisply anti-aliased. + const qint64 firstCol = toDev(start, dpr); + const qint64 lastCol = toDev(drawEnd, dpr); + for (qint64 col = firstCol; col <= lastCol; ++col) { + const auto x = fromDev(col, dpr); const auto theta = m_frequency * 2 * M_PI * (x + m_startX) / len + phase; const auto waveY = centerY + amplitude * qSin(theta); @@ -67,16 +122,44 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) { return; } + const qreal dpr = m_dpr; + const auto amplitude = fromDev(m_amplitudeDev, dpr); const auto startAngleRad = m_startAngle * M_PI / 180.0; const auto fullAngleRad = m_fullAngle * M_PI / 180.0; - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; - const auto cx = width() / 2.0; - const auto cy = height() / 2.0; - const auto radius = - m_radius > 0 - ? m_radius - : (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0; + // The center comes from the item's own device size and the stroke parity, + // the same rule Components/CircularProgress.qml applies to its background + // Shape arc, so the two rings share one center. + const qint64 widthDev = toDev(width(), dpr); + const qint64 heightDev = toDev(height(), dpr); + const qreal cxDev = snapCenterDev(widthDev, m_lineWidthDev); + const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev); + const auto cx = cxDev / dpr; + const auto cy = cyDev / dpr; + + // The path is painted into a texture exactly the item's size, so anything + // past its edge is cut flat. Keep the outer stroke edge, plus the wave + // crest, inside that rect and leave one device pixel for the antialiasing + // feather. Derived from the item's own size and its local center, so it + // does not depend on where the item is placed. + const qint64 halfDev = (m_lineWidthDev + 1) / 2; + const qint64 roomDev = static_cast( + std::floor((std::min)((std::min)(cxDev, qreal(widthDev) - cxDev), + (std::min)(cyDev, qreal(heightDev) - cyDev)))); + const qint64 maxRadiusDev = roomDev - m_amplitudeDev - halfDev - 1; + + // The radius is a whole device pixel count. Derived from the item's own + // device size, so it does not depend on where the item is. + qint64 radiusDev; + if (m_radius > 0) { + radiusDev = toDev(m_radius, dpr); + } else { + const qint64 inner = (std::min)(widthDev, heightDev) - m_lineWidthDev - + 2 * m_amplitudeDev; + radiusDev = (std::max)(qint64(1), inner / 2); + } + radiusDev = (std::max)(qint64(1), (std::min)(radiusDev, maxRadiusDev)); + const auto radius = fromDev(radiusDev, dpr); if (radius <= 0) { return; @@ -91,7 +174,11 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) { return; } - const auto N = qMax(64, qCeil(radius * drawAngleRad)); + // Sample every device pixel of arc length, not every logical one: at a + // fractional ratio a logical step is more than one device pixel wide, and + // the edge would then be interpolated across the pixels it crosses. + const auto N = + qMax(64, qCeil(static_cast(toDev(radius, dpr)) * drawAngleRad)); const auto dTheta = drawAngleRad / N; painter->beginPath(); diff --git a/Plugins/ZShell/Components/wavylinerenderer.hpp b/Plugins/ZShell/Components/wavylinerenderer.hpp index 191ca5f..2676e59 100644 --- a/Plugins/ZShell/Components/wavylinerenderer.hpp +++ b/Plugins/ZShell/Components/wavylinerenderer.hpp @@ -27,6 +27,9 @@ class WavyLineRenderer final : public QCanvasPainterItemRenderer { qreal m_fullAngle; qreal m_radius; qreal m_value; + qreal m_dpr = 1.0; + qint64 m_lineWidthDev = 4; + qint64 m_amplitudeDev = 0; }; } // namespace ZShell::controls diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp index ac5c4a7..5e61794 100644 --- a/Plugins/ZShell/Config/font.hpp +++ b/Plugins/ZShell/Config/font.hpp @@ -137,12 +137,12 @@ class FontTokens : public QObject { Q_OBJECT QML_ANONYMOUS - Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) - Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) - Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) - Q_PROPERTY(FontStyle* label READ label CONSTANT FINAL) - Q_PROPERTY(FontStyle* mono READ mono CONSTANT FINAL) - Q_PROPERTY(IconFontStyle* icon READ icon CONSTANT FINAL) + Q_PROPERTY(ZShell::config::FontStyle* headline READ headline CONSTANT FINAL) + Q_PROPERTY(ZShell::config::FontStyle* title READ title CONSTANT FINAL) + Q_PROPERTY(ZShell::config::FontStyle* body READ body CONSTANT FINAL) + Q_PROPERTY(ZShell::config::FontStyle* label READ label CONSTANT FINAL) + Q_PROPERTY(ZShell::config::FontStyle* mono READ mono CONSTANT FINAL) + Q_PROPERTY(ZShell::config::IconFontStyle* icon READ icon CONSTANT FINAL) Q_PROPERTY( ZShell::config::FontBuilder clock READ clock NOTIFY clockChanged FINAL) Q_PROPERTY(QString workspaces READ workspaces NOTIFY workspacesChanged FINAL) diff --git a/Plugins/ZShell/Config/launcher.hpp b/Plugins/ZShell/Config/launcher.hpp index 27dafca..8e58b1a 100644 --- a/Plugins/ZShell/Config/launcher.hpp +++ b/Plugins/ZShell/Config/launcher.hpp @@ -21,15 +21,6 @@ class LauncherActionEntry : public settings::ObjectNode { CONFIG_LIST_TYPE(LauncherActionEntry, LauncherActionList) -class LauncherSizes : public settings::ObjectNode { - CONFIG_NODE(LauncherSizes, settings::ObjectNode) - - CONFIG_PROPERTY(int, itemHeight, 50) - CONFIG_PROPERTY(int, itemWidth, 600) - CONFIG_PROPERTY(int, wallpaperHeight, 200) - CONFIG_PROPERTY(int, wallpaperWidth, 280) -}; - class UseFuzzy : public settings::ObjectNode { CONFIG_NODE(UseFuzzy, settings::ObjectNode) @@ -127,7 +118,6 @@ class Launcher : public settings::ObjectNode { CONFIG_PROPERTY(QStringList, hiddenApps, {}) CONFIG_PROPERTY(int, maxAppsShown, 18) CONFIG_PROPERTY(int, maxWallpapers, 7) - CONFIG_SUBOBJECT(LauncherSizes, sizes) CONFIG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) CONFIG_SUBOBJECT(UseFuzzy, useFuzzy) CONFIG_PROPERTY(bool, uwsm, true) diff --git a/Plugins/ZShell/Config/notifs.hpp b/Plugins/ZShell/Config/notifs.hpp index 9283020..2c7eea2 100644 --- a/Plugins/ZShell/Config/notifs.hpp +++ b/Plugins/ZShell/Config/notifs.hpp @@ -18,6 +18,7 @@ class Notifs : public settings::ObjectNode { CONFIG_PROPERTY(bool, actionOnClick, false) CONFIG_PROPERTY(int, appNotifCooldown, 0) CONFIG_PROPERTY(qreal, clearThreshold, 0.3) + CONFIG_PROPERTY(int, clearVelocity, 1000) CONFIG_PROPERTY(int, defaultExpireTimeout, 5000) CONFIG_PROPERTY(int, expandThreshold, 20) CONFIG_PROPERTY(bool, expire, true) diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index c19848f..07f6b52 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -164,11 +164,21 @@ class DashboardTokens : public settings::ObjectNode { CONFIG_PROPERTY(int, weatherWidth, 250) }; +class LauncherTokens : public settings::ObjectNode { + CONFIG_NODE(LauncherTokens, settings::ObjectNode) + + CONFIG_PROPERTY(int, itemHeight, 50) + CONFIG_PROPERTY(int, itemWidth, 600) + CONFIG_PROPERTY(int, wallpaperHeight, 200) + CONFIG_PROPERTY(int, wallpaperWidth, 280) +}; + class SizeTokens : public settings::ObjectNode { CONFIG_NODE(SizeTokens, settings::ObjectNode) CONFIG_SUBOBJECT(DashboardTokens, dashboard) CONFIG_SUBOBJECT(BarTokens, bar) + CONFIG_SUBOBJECT(LauncherTokens, launcher) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index ff6b832..0bfcee2 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -29,3 +29,9 @@ qml_module(ZShell-internal PkgConfig::GL ZShell-config ) + +qt_add_custom_brush_shaders(ZShell-internal "internal_brush_shaders" + PREFIX "/" + FILES + shaders/strokeholemask.frag +) diff --git a/Plugins/ZShell/Internal/shaders/strokeholemask.frag b/Plugins/ZShell/Internal/shaders/strokeholemask.frag new file mode 100644 index 0000000..8931102 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/strokeholemask.frag @@ -0,0 +1,22 @@ +#version 440 + +QC_INCLUDE "customfrag.glsl" + + +float sdRoundRect(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + vec2(r); + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +void main() { + float keep = 1.0; + + if (data4.x > 0.5) { + vec2 p = fragCoord - data1.xy; + float feather = max(data3.y, 0.0001); + keep = smoothstep(-feather, feather, sdRoundRect(p, data1.zw, data3.x)); + } + + float a = keep * data2.w * globalAlpha * antialiasingAlpha(); + fragColor = vec4(data2.rgb * a, a); +} diff --git a/Plugins/ZShell/Internal/strokecanvasitem.cpp b/Plugins/ZShell/Internal/strokecanvasitem.cpp index 50bc676..190a9a5 100644 --- a/Plugins/ZShell/Internal/strokecanvasitem.cpp +++ b/Plugins/ZShell/Internal/strokecanvasitem.cpp @@ -71,11 +71,23 @@ static QCanvasPath buildStrokePath( void StrokeCanvasItem::undoLastGroup() { if (m_strokes.isEmpty()) return; + const int lastGroup = m_strokes.last().groupId; + + QVector group; for (int i = static_cast(m_strokes.size()) - 1; i >= 0 && m_strokes[i].groupId == lastGroup; --i) - m_strokes.remove(i); + group.prepend(m_strokes.takeAt(i)); + + m_redoStack.append(group); + update(); +} + +void StrokeCanvasItem::redo() { + if (m_redoStack.isEmpty()) return; + + m_strokes.append(m_redoStack.takeLast()); update(); } @@ -106,19 +118,14 @@ void StrokeCanvasItem::setHoverPoint(const QPointF& point) { 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; +void StrokeCanvasItem::showHover() { + if (m_hoverVisible) return; - if (pointChanged || visibleChanged) { - m_hoverVisible = true; - m_hoverPoint = newPoint; - if (pointChanged) emit hoverPointChanged(); - if (visibleChanged) emit hoverVisibleChanged(); - update(); - } + m_hoverVisible = true; + update(); + + emit hoverVisibleChanged(); } void StrokeCanvasItem::hideHover() { @@ -139,6 +146,24 @@ void StrokeCanvasItem::setPenWidth(qreal width) { emit penWidthChanged(); } +void StrokeCanvasItem::setHole(const QRectF& hole) { + if (m_hole == hole) return; + + m_hole = hole; + update(); + + emit holeChanged(); +} + +void StrokeCanvasItem::setHoleRadius(qreal radius) { + if (qFuzzyCompare(m_holeRadius, radius)) return; + + m_holeRadius = radius; + update(); + + emit holeRadiusChanged(); +} + void StrokeCanvasItem::beginStroke(qreal x, qreal y, bool polyline) { m_isDrawing = true; m_currentStroke.points.clear(); @@ -199,6 +224,7 @@ void StrokeCanvasItem::endStroke() { m_currentStroke.points.clear(); m_strokes.append(m_currentStroke); m_currentStroke = {}; + m_redoStack.clear(); update(); } @@ -206,6 +232,7 @@ void StrokeCanvasItem::endStroke() { void StrokeCanvasItem::clear() { m_strokes.clear(); m_currentStroke = {}; + m_redoStack.clear(); update(); } diff --git a/Plugins/ZShell/Internal/strokecanvasitem.hpp b/Plugins/ZShell/Internal/strokecanvasitem.hpp index 7f1e9a0..f727bda 100644 --- a/Plugins/ZShell/Internal/strokecanvasitem.hpp +++ b/Plugins/ZShell/Internal/strokecanvasitem.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,10 @@ class StrokeCanvasItem : public QCanvasPainterItem { hoverPointChanged) Q_PROPERTY( qreal penWidth READ penWidth WRITE setPenWidth NOTIFY penWidthChanged) + Q_PROPERTY(QRectF hole READ hole WRITE setHole NOTIFY holeChanged) + Q_PROPERTY( + qreal holeRadius READ holeRadius WRITE setHoleRadius NOTIFY + holeRadiusChanged) public: explicit StrokeCanvasItem(QQuickItem* parent = nullptr); @@ -37,15 +42,20 @@ class StrokeCanvasItem : public QCanvasPainterItem { void setHoverVisible(bool visible); void setHoverPoint(const QPointF& point); - Q_INVOKABLE void showHover(qreal x, qreal y); + Q_INVOKABLE void showHover(); Q_INVOKABLE void hideHover(); Q_INVOKABLE void undoLastGroup(); + Q_INVOKABLE void redo(); [[nodiscard]] QColor penColor() const { return m_penColor; } [[nodiscard]] qreal penWidth() const { return m_penWidth; } + [[nodiscard]] QRectF hole() const { return m_hole; } + [[nodiscard]] qreal holeRadius() const { return m_holeRadius; } void setPenColor(const QColor& color); void setPenWidth(qreal width); + void setHole(const QRectF& hole); + void setHoleRadius(qreal radius); Q_INVOKABLE void clear(); @@ -56,6 +66,8 @@ class StrokeCanvasItem : public QCanvasPainterItem { signals: void penColorChanged(); void penWidthChanged(); + void holeChanged(); + void holeRadiusChanged(); void hoverVisibleChanged(); void hoverPointChanged(); @@ -72,9 +84,12 @@ class StrokeCanvasItem : public QCanvasPainterItem { qreal m_penWidth = 4.0; bool m_isDrawing = false; + QRectF m_hole; + qreal m_holeRadius = 0.0; int m_nextGroupId = 0; QVector m_strokes; Stroke m_currentStroke; + QVector> m_redoStack; }; }; // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp index 11b2d08..7f729d4 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -1,11 +1,98 @@ #include "strokecanvasrenderer.hpp" #include "strokecanvasitem.hpp" +#include +#include +#include #include #include namespace ZShell::internal { -static void drawStroke( +using Qt::StringLiterals::operator""_s; + +static const QString kStrokeHoleShader = u":/shaders/strokeholemask.frag.qsb"_s; + +namespace { + +struct HoleUniforms { + QVector4D data1; // center.xy, halfSize.xy + QVector4D data3; // x: radius, y: feather in logical px + QVector4D data4; // x: hole enabled +}; + +static HoleUniforms holeUniforms( + const QRectF& hole, qreal radius, qreal devicePixelRatio) { + const bool active = hole.isValid() && hole.width() > 0 && hole.height() > 0; + + if (!active) return {QVector4D(), QVector4D(), QVector4D()}; + + const QPointF center = hole.center(); + const QPointF half(hole.width() * 0.5, hole.height() * 0.5); + const qreal maxRadius = qMin(half.x(), half.y()); + + return { + QVector4D( + static_cast(center.x()), + static_cast(center.y()), + static_cast(half.x()), + static_cast(half.y())), + QVector4D( + static_cast(qMin(radius, maxRadius)), + static_cast(1.0 / devicePixelRatio), + 0.0f, + 0.0f), + QVector4D(1.0, 0.0, 0.0, 0.0), + }; +} + +static void applyHole(QCanvasCustomBrush& brush, const HoleUniforms& u) { + brush.setData1(u.data1); + brush.setData3(u.data3); + brush.setData4(u.data4); +} + +}; // namespace + +void StrokeCanvasRenderer::updateBrushHole() { + const HoleUniforms uniforms = + holeUniforms(m_hole, m_holeRadius, m_devicePixelRatio); + + if (uniforms.data1 == m_lastHoleData1 && + uniforms.data3 == m_lastHoleData3 && uniforms.data4 == m_lastHoleData4) + return; + + m_lastHoleData1 = uniforms.data1; + m_lastHoleData3 = uniforms.data3; + m_lastHoleData4 = uniforms.data4; + ++m_holeToken; + + for (auto& entry : m_brushes) { + if (entry.holeToken == m_holeToken) continue; + + applyHole(entry.brush, uniforms); + entry.holeToken = m_holeToken; + } +} + +QCanvasCustomBrush& StrokeCanvasRenderer::brushFor(const QColor& color) { + const QRgb key = color.rgba(); + + if (auto it = m_brushes.find(key); it != m_brushes.end()) return it->brush; + + const HoleUniforms uniforms = + holeUniforms(m_hole, m_holeRadius, m_devicePixelRatio); + + BrushEntry entry; + entry.brush.setFragmentShader(kStrokeHoleShader); + applyHole(entry.brush, uniforms); + entry.brush.setData2( + QVector4D(color.redF(), color.greenF(), color.blueF(), color.alphaF())); + entry.holeToken = m_holeToken; + + return m_brushes.insert(key, entry)->brush; +} + +void StrokeCanvasRenderer::drawStroke( QCanvasPainter* painter, const QVector& points, const QColor& color, @@ -13,8 +100,10 @@ static void drawStroke( bool polyline) { if (points.isEmpty()) return; - painter->setStrokeStyle(color); - painter->setFillStyle(color); + QCanvasCustomBrush& brush = brushFor(color); + + painter->setStrokeStyle(brush); + painter->setFillStyle(brush); painter->setLineWidth(width); painter->setLineCap(QCanvasPainter::LineCap::Round); painter->setLineJoin(QCanvasPainter::LineJoin::Round); @@ -65,7 +154,7 @@ static void drawStroke( painter->stroke(); } -static void drawHoverCursor( +void StrokeCanvasRenderer::drawHoverCursor( QCanvasPainter* painter, const QPointF& point, qreal penWidth, @@ -74,7 +163,7 @@ static void drawHoverCursor( const qreal radius = penWidth * 0.5; if (isDrawing) { - painter->setFillStyle(penColor); + painter->setFillStyle(brushFor(penColor)); painter->beginPath(); painter->circle(point, radius); painter->fill(); @@ -85,7 +174,7 @@ static void drawHoverCursor( const bool useDashes = penWidth > 10.0; auto drawOutline = [&](const QColor& color, qreal width) { - painter->setStrokeStyle(color); + painter->setStrokeStyle(brushFor(color)); painter->setLineWidth(width); painter->setLineCap(QCanvasPainter::LineCap::Round); @@ -117,7 +206,7 @@ static void drawHoverCursor( }; auto drawCrosshair = [&](const QColor& color, qreal width) { - painter->setStrokeStyle(color); + painter->setStrokeStyle(brushFor(color)); painter->setLineWidth(width); painter->setLineCap(QCanvasPainter::LineCap::Round); @@ -148,8 +237,11 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { m_penColor = canvas->m_penColor; m_penWidth = canvas->m_penWidth; + m_hole = canvas->m_hole; + m_holeRadius = canvas->m_holeRadius; + m_devicePixelRatio = + canvas->window() ? canvas->window()->effectiveDevicePixelRatio() : 1.0; - // Detect removed strokes: present in renderer but no longer in canvas QSet canvasGroupIds; for (const auto& stroke : canvas->m_strokes) canvasGroupIds.insert(stroke.groupId); @@ -162,7 +254,6 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { } } - // Detect added strokes: present in canvas but not yet in renderer QSet rendererGroupIds; for (const auto& stroke : m_strokes) rendererGroupIds.insert(stroke.groupId); @@ -180,6 +271,8 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { } void StrokeCanvasRenderer::paint(QCanvasPainter* painter) { + updateBrushHole(); + for (int id : m_pendingGroupRemovals) painter->removePathGroup(id); m_pendingGroupRemovals.clear(); @@ -187,8 +280,8 @@ void StrokeCanvasRenderer::paint(QCanvasPainter* painter) { painter->clearRect(0, 0, width(), height()); for (const auto& stroke : m_strokes) { - painter->setStrokeStyle(stroke.color); - painter->setFillStyle(stroke.color); + painter->setStrokeStyle(brushFor(stroke.color)); + painter->setFillStyle(brushFor(stroke.color)); painter->setLineWidth(stroke.width); painter->setLineCap(QCanvasPainter::LineCap::Round); painter->setLineJoin(QCanvasPainter::LineJoin::Round); diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.hpp b/Plugins/ZShell/Internal/strokecanvasrenderer.hpp index 2877b6d..e09bce3 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.hpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.hpp @@ -1,26 +1,58 @@ #pragma once +#include #include +#include +#include +#include #include #include "stroke.hpp" namespace ZShell::internal { +struct BrushEntry { + QCanvasCustomBrush brush; + qint64 holeToken = -1; // hole generation this brush was built with +}; + class StrokeCanvasRenderer final : public QCanvasPainterItemRenderer { public: void synchronizeData(QCanvasPainterItem* item) override; void paint(QCanvasPainter* painter) override; private: + QCanvasCustomBrush& brushFor(const QColor& color); + void updateBrushHole(); + void drawStroke( + QCanvasPainter* painter, + const QVector& points, + const QColor& color, + qreal width, + bool polyline); + void drawHoverCursor( + QCanvasPainter* painter, + const QPointF& point, + qreal penWidth, + QColor penColor, + bool isDrawing); + QColor m_penColor; qreal m_penWidth = 4.0; bool m_hoverVisible = false; QPointF m_hoverPoint; bool m_isDrawing = false; + QRectF m_hole; + qreal m_holeRadius = 0.0; + qreal m_devicePixelRatio = 1.0; + QVector4D m_lastHoleData1; + QVector4D m_lastHoleData3; + QVector4D m_lastHoleData4; + qint64 m_holeToken = 0; QVector m_strokes; Stroke m_currentStroke; QVector m_pendingGroupRemovals; + QHash m_brushes; }; }; // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/notificationstore.cpp b/Plugins/ZShell/Services/notificationstore.cpp index 20cd9d1..0042837 100644 --- a/Plugins/ZShell/Services/notificationstore.cpp +++ b/Plugins/ZShell/Services/notificationstore.cpp @@ -6,36 +6,222 @@ #include #include #include -#include #include #include #include +#include +#include #include #include #include #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include +#include #include Q_LOGGING_CATEGORY(notifLog, "ZShell.Services.notifs", QtInfoMsg) namespace { constexpr int MAX_ROWS = 500; +constexpr int MAX_PER_GROUP = 50; constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000; constexpr int TICK_MS = 250; constexpr int MAX_IMAGE_DIM = 512; +// Memoized icon theme file lookups (hits and misses) per theme configuration. +constexpr int MAX_ICON_PATHS = 256; constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000; constexpr qint64 TIME_STR_REFRESH_MS = 2000; +// Coalesces content updates (live replaces_id edits, popup state) into one +// transaction instead of one UPDATE per changed field. +constexpr int FLUSH_MS = 300; + +// Quickshell serves inline notification pictures through image://qsimage/ +// (and qspixmap) handles that belong to the live notification, not to the +// shell: once that notification is replaced or dismissed, the handle is gone +// and the url fails to resolve. Such a source is only ever useful as the raw +// url long enough to read the bytes, so the UI must never see it. +bool transientImageSource(const QString& source) { + if (source.isEmpty()) { + return false; + } + + const QUrl url(source); + return url.scheme() == QLatin1String("image") && + (url.host() == QLatin1String("qsimage") || + url.host() == QLatin1String("qspixmap")); +} + +// The url the UI should use: the cached blob when there is one, and the raw +// source only while that source keeps resolving. +QString displayImage(const QString& source, const QString& digest) { + if (!digest.isEmpty()) { + return QStringLiteral("image://notifimg/") + digest; + } + + return transientImageSource(source) ? QString() : source; +} + +// Icon theme state, captured on the GUI thread. `QIcon::themeName()`, +// `fallbackThemeName()` and `themeSearchPaths()` touch GUI-thread only state, +// but the strings they return are safe to hand to a worker. +struct IconTheme { + QStringList searchPaths; + QStringList themes; + // Changes when the theme or the search paths change, so the path cache + // below can be dropped after a theme switch. + QString signature; +}; + +IconTheme iconTheme() { + IconTheme theme; + theme.searchPaths = QIcon::themeSearchPaths(); + + for (const auto& name : + {QIcon::themeName(), + QIcon::fallbackThemeName(), + QStringLiteral("hicolor"), + QStringLiteral("Adwaita")}) { + if (!name.isEmpty() && !theme.themes.contains(name)) { + theme.themes.append(name); + } + } + + theme.signature = theme.searchPaths.join(QLatin1Char('\n')) + + QLatin1Char('\n') + theme.themes.join(QLatin1Char('\n')); + return theme; +} + +// Resolve an icon name to a file the theme actually ships, so notification +// images that only name an icon can still be cached. KF6's KIconLoader is not +// thread-safe (it crashed the shell when the icon provider was called from a +// worker) and QIcon painting is GUI-thread only, so this only builds candidate +// paths and stats them - which makes it worker-thread safe when the theme +// strings come from iconTheme(). Hits and misses are both memoized: a sender +// repeats the same icon name, and a name the theme lacks costs thousands of +// stat calls to discover. +QString iconFileForName(const QString& name, const IconTheme& theme) { + if (name.isEmpty()) { + return {}; + } + + // Stat results only change when a theme is installed or removed, which the + // signature covers; the cap just keeps a stream of odd names bounded. + static std::mutex cacheMutex; + static QHash pathCache; + static QString cacheSignature; + + std::lock_guard lock(cacheMutex); + + if (cacheSignature != theme.signature) { + cacheSignature = theme.signature; + pathCache.clear(); + } + + const auto cached = pathCache.constFind(name); + if (cached != pathCache.constEnd()) { + return cached.value(); + } + + const QString found = [&]() { + if (QFileInfo::exists(name)) { + return name; + } + + const QFileInfo info(name); + const QStringList names = + info.suffix().isEmpty() + ? QStringList{name} + : QStringList{name, info.completeBaseName()}; + + static const QStringList sizes = {"256x256", "192x192", "128x128", + "96x96", "64x64", "48x48", + "32x32", "24x24", "22x22", + "16x16", "scalable", "256", + "192", "128", "96", + "64", "48", "32", + "24", "22", "16"}; + static const QStringList contexts = { + "apps", "actions", "devices", "places", "status"}; + static const QStringList extensions = {"svg", "svgz", "png"}; + + for (const auto& searchPath : theme.searchPaths) { + for (const auto& themeName : theme.themes) { + const QDir themeDir(QDir(searchPath).filePath(themeName)); + if (!themeDir.exists()) { + continue; + } + + for (const auto& candidateName : names) { + for (const auto& size : sizes) { + for (const auto& context : contexts) { + for (const auto& extension : extensions) { + const QString suffix = + candidateName + "." + extension; + + // hicolor: ///. + const QString canonical = themeDir.filePath( + size + "/" + context + "/" + extension + + "/" + suffix); + if (QFileInfo::exists(canonical)) { + return canonical; + } + + // hicolor and Papirus both also ship + // //. + const QString sized = themeDir.filePath( + size + "/" + context + "/" + suffix); + if (QFileInfo::exists(sized)) { + return sized; + } + + // themes ordering size under context + const QString nested = themeDir.filePath( + context + "/" + size + "/" + suffix); + if (QFileInfo::exists(nested)) { + return nested; + } + } + } + } + + // theme without size directories: /. + for (const auto& context : contexts) { + for (const auto& extension : extensions) { + const QString plain = themeDir.filePath( + context + "/" + candidateName + "." + + extension); + if (QFileInfo::exists(plain)) { + return plain; + } + } + } + } + } + } + + return QString(); + }(); + + if (pathCache.size() >= MAX_ICON_PATHS) { + pathCache.clear(); + } + + pathCache.insert(name, found); + return found; +} } // namespace namespace ZShell::Services { @@ -149,18 +335,18 @@ thread_local QString NotifImageProvider::m_threadUuid; thread_local quint64 NotifImageProvider::m_threadGeneration; NotificationActionRow::NotificationActionRow( - QObject* action, const QString& identifier, const QString& text, + QObject* action, + const QString& identifier, + const QString& text, QObject* parent) : QObject(parent) , m_action(action) , m_identifier(identifier) , m_text(text) { if (action) { - QObject::connect( - action, - &QObject::destroyed, - this, - [this]() { emit invokableChanged(); }); + QObject::connect(action, &QObject::destroyed, this, [this]() { + emit invokableChanged(); + }); } } @@ -235,13 +421,14 @@ void NotificationRow::setPopup(bool popup) { m_popup = popup; m_popupPaused = false; - m_deadline = popup ? QDateTime::currentMSecsSinceEpoch() + - m_store->popupTimeout() - : 0; + m_deadline = + popup ? QDateTime::currentMSecsSinceEpoch() + m_store->popupTimeout() + : 0; emit popupChanged(); if (m_store != nullptr) { - m_store->persistPopup(this); + m_store->markDirty(this); + m_store->updatePopupGroup(this); } } @@ -286,15 +473,28 @@ void NotificationRow::lock(QObject* item) { } m_locks.insert(item); - QObject::connect(item, &QObject::destroyed, this, [this, item]() { - m_locks.remove(item); + // Goes through unlock() so releasing the last lock on an already-closed + // row finishes the removal the close started. The destroyed signal + // carries the dying object, which is the lock holder. + QObject::connect(item, &QObject::destroyed, this, [this](QObject* obj) { + unlock(obj); }); } void NotificationRow::unlock(QObject* item) { m_locks.remove(item); - if (m_closed && m_locks.isEmpty()) { + if (!m_locks.isEmpty()) { + return; + } + + if (m_deleteAfterUnlock) { + m_deleteAfterUnlock = false; + deleteLater(); + return; + } + + if (m_closed) { m_store->removeRow(this); } } @@ -307,6 +507,44 @@ void NotificationRow::resumePopup() { m_popupPaused = false; } +// The v2 notification UI uses caelestia's naming; these are thin aliases of the +// existing behaviour so both spellings work. +void NotificationRow::dismiss() { + close(); +} + +void NotificationRow::dismissPopup() { + if (m_popup) { + setPopup(false); + } +} + +void NotificationRow::holdPopup() { + pausePopup(); +} + +void NotificationRow::releasePopup() { + resumePopup(); +} + +void NotificationRow::ref(QObject* item) { + lock(item); +} + +void NotificationRow::unref(QObject* item) { + unlock(item); +} + +// True when path is this row's image, in either its raw or cached form, so the +// UI can tell "the same picture changed url" from "a different picture". +bool NotificationRow::isSameImage(const QString& path) const { + return path == m_image || path == m_imageSource; +} + +bool NotificationRow::isSameAppIcon(const QString& path) const { + return path == m_appIcon; +} + bool NotificationRow::expired(qint64 now) const { return m_popup && !m_popupPaused && !m_closed && m_deadline > 0 && now >= m_deadline; @@ -338,6 +576,176 @@ void NotificationRow::refreshTimeStr() { } } +NotificationGroup::NotificationGroup(const QString& appName, QObject* parent) + : QObject(parent), m_appName(appName) {} + +QQmlListProperty NotificationGroup::activeNotifs() { + return QQmlListProperty(this, &m_active); +} + +QString NotificationGroup::appIcon() const { + return m_iconSource ? m_iconSource->m_appIcon : QString(); +} + +QString NotificationGroup::appName() const { + return m_appName; +} + +int NotificationGroup::count() const { + return m_count; +} + +QString NotificationGroup::firstSummary() const { + return m_firstSummary; +} + +QString NotificationGroup::image() const { + return m_imageSource ? m_imageSource->m_image : QString(); +} + +QQmlListProperty NotificationGroup::notifs() { + return QQmlListProperty(this, &m_notifs); +} + +QString NotificationGroup::timeStr() const { + return m_timeStr; +} + +int NotificationGroup::urgency() const { + return m_urgency; +} + +void NotificationGroup::addRow(NotificationRow* row) { + m_notifs.prepend(row); + QObject::connect( + row, &NotificationRow::imageChanged, this, &NotificationGroup::sync); + QObject::connect( + row, &NotificationRow::appIconChanged, this, &NotificationGroup::sync); + QObject::connect( + row, &NotificationRow::urgencyChanged, this, &NotificationGroup::sync); + emit notifsChanged(); + refreshActive(); + sync(); +} + +void NotificationGroup::removeRow(NotificationRow* row) { + const bool had = m_notifs.removeOne(row); + if (!had) { + return; + } + + QObject::disconnect(row, nullptr, this, nullptr); + emit notifsChanged(); + // Must not drop the row from m_active here: refreshActive() only emits + // activeNotifsChanged when the list it rebuilds differs from the stored + // one, and a silently pre-edited m_active is equal to the rebuild. The + // popup stack reads activeNotifs, so skipping the emit left a closed row + // occupying its slot - an invisible gap other entries did not move into. + refreshActive(); + sync(); +} + +void NotificationGroup::refreshActive() { + QList active; + active.reserve(m_notifs.size()); + for (auto* row : std::as_const(m_notifs)) { + if (!row->m_closed) { + active.append(row); + } + } + + if (active != m_active) { + m_active = active; + emit activeNotifsChanged(); + } + + if (m_count != static_cast(m_active.size())) { + m_count = static_cast(m_active.size()); + emit countChanged(); + } + + NotificationRow* first = m_active.isEmpty() ? nullptr : m_active.first(); + + const QString timeStr = first != nullptr ? first->m_timeStr : QString(); + if (timeStr != m_timeStr) { + m_timeStr = timeStr; + emit timeStrChanged(); + } + + const QString summary = first != nullptr ? first->m_summary : QString(); + if (summary != m_firstSummary) { + m_firstSummary = summary; + emit firstSummaryChanged(); + } +} + +void NotificationGroup::sync() { + NotificationRow* imageSource = nullptr; + NotificationRow* iconSource = nullptr; + int urgency = 0; + + for (auto* row : std::as_const(m_active)) { + if (imageSource == nullptr && !row->m_image.isEmpty()) { + imageSource = row; + } + if (iconSource == nullptr && !row->m_appIcon.isEmpty()) { + iconSource = row; + } + urgency = (std::max)(urgency, row->m_urgency); + } + + if (m_imageSource != imageSource) { + m_imageSource = imageSource; + emit imageChanged(); + } + if (m_iconSource != iconSource) { + m_iconSource = iconSource; + emit appIconChanged(); + } + if (urgency != m_urgency) { + m_urgency = urgency; + emit urgencyChanged(); + } +} + +// Delegates compare their own shown url against the group's current source row, +// so a row swapping raw source <-> cached blob does not re-trigger the fade. +bool NotificationGroup::isSameImage(const QString& path) const { + return m_imageSource != nullptr && m_imageSource->isSameImage(path); +} + +bool NotificationGroup::isSameAppIcon(const QString& path) const { + return m_iconSource != nullptr && m_iconSource->isSameAppIcon(path); +} + +void NotificationGroup::lock(QObject* item) { + if (item != nullptr && !m_locks.contains(item)) { + m_locks.insert(item); + // No Qt::UniqueConnection here: it requires a member-function pointer, + // and the contains() check above already keeps this one-shot per item. + QObject::connect( + item, &QObject::destroyed, this, [this, item](QObject*) { + unlock(item); + }); + } +} + +void NotificationGroup::unlock(QObject* item) { + m_locks.remove(item); + if (m_releasing && m_locks.isEmpty()) { + deleteLater(); + } +} + +// Called by the store once the group has no rows left. A delegate may still be +// animating it out, so the delete waits for its last unlock. +void NotificationGroup::release() { + m_releasing = true; + if (m_locks.isEmpty()) { + deleteLater(); + } +} + NotificationStore::NotificationStore(QObject* parent) : QObject(parent) , m_uuid(QUuid::createUuid().toString(QUuid::WithoutBraces)) { @@ -345,6 +753,11 @@ NotificationStore::NotificationStore(QObject* parent) m_tick->setInterval(TICK_MS); QObject::connect(m_tick, &QTimer::timeout, this, &NotificationStore::tick); + m_flush = new QTimer(this); + m_flush->setSingleShot(true); + m_flush->setInterval(FLUSH_MS); + QObject::connect(m_flush, &QTimer::timeout, this, &NotificationStore::flush); + // Config-backed settings, same pattern as TickingService: read the // config node directly and update live on its change signals. QML may // still write these properties; the next config change re-applies. @@ -356,13 +769,13 @@ NotificationStore::NotificationStore(QObject* parent) this, [this, notifs] { applyNotifConfig(notifs); }); QObject::connect( - notifs, - &config::Notifs::expireChanged, - this, - [this, notifs] { applyNotifConfig(notifs); }); + notifs, &config::Notifs::expireChanged, this, [this, notifs] { + applyNotifConfig(notifs); + }); } NotificationStore::~NotificationStore() { + flush(); closeDb(); } @@ -386,7 +799,7 @@ void NotificationStore::setPath(const QString& path) { prune(); loadRows(); qCInfo(notifLog) << "NotificationStore: ready with" << m_rows.size() - << "stored rows from" << m_path; + << "stored rows from" << m_path; const qint64 now = QDateTime::currentMSecsSinceEpoch(); m_nextPrune = now + PRUNE_INTERVAL_MS; @@ -429,11 +842,16 @@ QQmlListProperty NotificationStore::rows() { return QQmlListProperty(this, &m_rows); } +QQmlListProperty NotificationStore::groups() { + return QQmlListProperty(this, &m_groups); +} + +QQmlListProperty NotificationStore::popupGroups() { + return QQmlListProperty(this, &m_popupGroups); +} + NotificationRow* NotificationStore::insert( const QJSValue& notification, bool popup) { - QElapsedTimer clock; - clock.start(); - const auto wrapped = notification.toVariant().value(); if (wrapped == nullptr) { qWarning(notifLog) << "NotificationStore: expected a Notification"; @@ -468,23 +886,16 @@ NotificationRow* NotificationStore::insert( } insertDbRow(row); - const qint64 dbMs = clock.elapsed(); startImageCache(row); - const qint64 imageMs = clock.elapsed() - dbMs; m_rows.prepend(row); + addGroup(row); + if (popup) { + addPopupGroup(row); + } row->refreshTimeStr(); scheduleRowsChanged(); - // rowsChanged is coalesced onto the next event loop turn, so the QML - // reaction (filter re-evaluation + delegate rebind) runs once for a burst - // of inserts instead of once per notification. This timing therefore - // covers the synchronous part: attach + db + image cache start. - if (const qint64 totalMs = clock.elapsed(); totalMs >= 3) { - qCDebug(notifLog) << "perf: insert total" << totalMs - << "ms | attach+db" << dbMs << "ms | image" << imageMs << "ms"; - } - return row; } @@ -504,6 +915,9 @@ void NotificationStore::scheduleRowsChanged() { } void NotificationStore::clear() { + m_flush->stop(); + m_dirty.clear(); + const auto db = QSqlDatabase::database(m_uuid, false); if (db.isOpen()) { QSqlQuery query(db); @@ -529,17 +943,15 @@ void NotificationStore::clear() { emit rowsChanged(); } -void NotificationStore::copyFields( - NotificationRow* row, QObject* notification) { +void NotificationStore::copyFields(NotificationRow* row, QObject* notification) { row->m_summary = notification->property("summary").toString(); row->m_body = notification->property("body").toString(); row->m_appName = notification->property("appName").toString(); row->m_appIcon = notification->property("appIcon").toString(); row->m_imageSource = notification->property("image").toString(); - row->m_image = row->m_imageSource; + row->m_image = displayImage(row->m_imageSource, row->m_digest); row->m_resident = notification->property("resident").toBool(); - row->m_hasActionIcons = - notification->property("hasActionIcons").toBool(); + row->m_hasActionIcons = notification->property("hasActionIcons").toBool(); row->m_urgency = notification->property("urgency").toInt(); } @@ -573,10 +985,9 @@ void NotificationStore::attachNotification( // Connected straight to the slot (not through an intermediate // signal) so sender() in handleLiveUpdate is the Notification. static const QMetaMethod slot = [] { - const QMetaObject& storeMeta = - NotificationStore::staticMetaObject; - return storeMeta.method(storeMeta.indexOfSlot( - "handleLiveUpdate()")); + const QMetaObject& storeMeta = NotificationStore::staticMetaObject; + return storeMeta.method( + storeMeta.indexOfSlot("handleLiveUpdate()")); }(); static const QList mirrored = { "summaryChanged", @@ -613,8 +1024,8 @@ void NotificationStore::attachNotification( void NotificationStore::handleLiveUpdate() { const auto it = m_liveRows.constFind(sender()); if (it == m_liveRows.constEnd()) { - qWarning(notifLog) - << "NotificationStore: live update for untracked notification object"; + qWarning(notifLog) << "NotificationStore: live update for untracked " + "notification object"; return; } @@ -626,8 +1037,7 @@ void NotificationStore::handleLiveUpdate() { const QString appIcon = it.key()->property("appIcon").toString(); const QString imageSource = it.key()->property("image").toString(); const bool resident = it.key()->property("resident").toBool(); - const bool hasActionIcons = - it.key()->property("hasActionIcons").toBool(); + const bool hasActionIcons = it.key()->property("hasActionIcons").toBool(); const int urgency = it.key()->property("urgency").toInt(); const bool imageChanged = imageSource != row->m_imageSource; @@ -672,14 +1082,14 @@ void NotificationStore::handleLiveUpdate() { qDeleteAll(row->m_actions); row->m_actions.clear(); - row->m_actions = parseActions( - row->m_jsNotification.property("actions"), row); + row->m_actions = + parseActions(row->m_jsNotification.property("actions"), row); emit row->actionsChanged(); if (imageChanged) { row->m_imageSource = imageSource; row->m_digest.clear(); - row->m_image = imageSource; + row->m_image = displayImage(imageSource, row->m_digest); emit row->imageSourceChanged(); emit row->imageChanged(); startImageCache(row); @@ -687,11 +1097,21 @@ void NotificationStore::handleLiveUpdate() { // New content deserves a full look: restart the popup countdown. if (row->m_popup) { - row->m_deadline = - QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; + row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; } - updateDbRow(row); + // The row may have moved to another app name (replaces_id can change it). + if (row->m_group != nullptr && row->m_group->m_appName != row->m_appName) { + removeGroupRow(row); + addGroup(row); + + if (row->m_popupGroup != nullptr) { + removePopupGroupRow(row); + addPopupGroup(row); + } + } + + markDirty(row); } void NotificationStore::updateDbRow(NotificationRow* row) { @@ -706,17 +1126,18 @@ void NotificationStore::updateDbRow(NotificationRow* row) { QJsonArray actions; for (auto* action : std::as_const(row->m_actions)) { - actions.append(QJsonObject{ - {"identifier", action->identifier()}, - {"text", action->text()}}); + actions.append( + QJsonObject{ + {"identifier", action->identifier()}, + {"text", action->text()}}); } QSqlQuery query(db); query.prepare( "UPDATE notifications SET" " app_name = ?, app_icon = ?, summary = ?, body = ?," - " urgency = ?, resident = ?, has_action_icons = ?," - " actions = ?, image_source = ?, image_digest = ?" + " urgency = ?, resident = ?, has_action_icons = ?, popup = ?," + " actions = ?, image_source = ?, image_digest = ?, progress = ?" " WHERE id = ?"); query.addBindValue(row->m_appName); query.addBindValue(row->m_appIcon); @@ -725,15 +1146,18 @@ void NotificationStore::updateDbRow(NotificationRow* row) { query.addBindValue(row->m_urgency); query.addBindValue(row->m_resident ? 1 : 0); query.addBindValue(row->m_hasActionIcons ? 1 : 0); + query.addBindValue(row->m_popup ? 1 : 0); query.addBindValue( - QString::fromUtf8(QJsonDocument(actions).toJson(QJsonDocument::Compact))); + QString::fromUtf8( + QJsonDocument(actions).toJson(QJsonDocument::Compact))); query.addBindValue(row->m_imageSource); query.addBindValue(row->m_digest); + query.addBindValue(row->m_progress); query.addBindValue(row->m_rowId); if (!query.exec()) { - qWarning(notifLog) - << "NotificationStore: could not update row" << query.lastError(); + qWarning(notifLog) << "NotificationStore: could not update row" + << query.lastError(); } } @@ -747,8 +1171,9 @@ QList NotificationStore::parseActions( const int length = actions.property("length").toVariant().toInt(); for (int i = 0; i < length; i++) { - auto* object = - actions.property(static_cast(i)).toVariant().value(); + auto* object = actions.property(static_cast(i)) + .toVariant() + .value(); if (object == nullptr) { continue; } @@ -776,10 +1201,7 @@ NotificationRow* NotificationStore::rowForNotificationId(int id) const { void NotificationStore::handleClosed(NotificationRow* row) { // During clear() the bulk DELETE already removed every row. if (m_clearing) { - if (row->m_locks.isEmpty()) { - removeRow(row); - } - + removeRow(row); return; } @@ -791,9 +1213,18 @@ void NotificationStore::handleClosed(NotificationRow* row) { query.exec(); } - if (row->m_locks.isEmpty()) { - removeRow(row); + m_dirty.remove(row); + removePopupGroupRow(row); + + if (row->m_group != nullptr) { + row->m_group->refreshActive(); + row->m_group->sync(); } + + // A closed row leaves the store immediately, so its group and the sidebar + // order update at once; a delegate that still holds it only delays the + // delete, never the removal. + removeRow(row); } void NotificationStore::removeRow(NotificationRow* row) { @@ -801,13 +1232,23 @@ void NotificationStore::removeRow(NotificationRow* row) { return; } + removePopupGroupRow(row); + removeGroupRow(row); + m_dirty.remove(row); + // The row is gone; drop any live-notification mapping to it so // handleLiveUpdate can never resolve a freed pointer. erase_if(m_liveRows, [row](std::pair e) { return e.second == row; }); - row->deleteLater(); + // A delegate animating this row out still holds it; unlock() deletes once + // the last lock is gone. + if (row->m_locks.isEmpty()) { + row->deleteLater(); + } else { + row->m_deleteAfterUnlock = true; + } if (!m_clearing) { orphanCleanup(); @@ -815,26 +1256,135 @@ void NotificationStore::removeRow(NotificationRow* row) { } } -void NotificationStore::persistPopup(const NotificationRow* row) { +void NotificationStore::markDirty(NotificationRow* row) { if (row->m_rowId < 0) { return; } - const auto db = QSqlDatabase::database(m_uuid, false); - if (!db.isOpen()) { + m_dirty.insert(row); + m_flush->start(); +} + +void NotificationStore::flush() { + m_flush->stop(); + if (m_dirty.isEmpty()) { return; } - QSqlQuery query(db); - query.prepare("UPDATE notifications SET popup = ? WHERE id = ?"); - query.addBindValue(row->m_popup ? 1 : 0); - query.addBindValue(row->m_rowId); - - if (!query.exec()) { - qWarning(notifLog) - << "NotificationStore: could not persist popup state" - << query.lastError(); + auto db = QSqlDatabase::database(m_uuid, false); + if (!db.isOpen()) { + m_dirty.clear(); + return; } + + if (!db.transaction()) { + qWarning(notifLog) << "NotificationStore: flush transaction failed" + << db.lastError(); + } + + for (auto* row : std::as_const(m_dirty)) { + updateDbRow(row); + } + m_dirty.clear(); + + if (!db.commit()) { + qWarning(notifLog) << "NotificationStore: flush commit failed" + << db.lastError(); + } +} + +void NotificationStore::addGroup(NotificationRow* row) { + auto* group = m_groupsByName.value(row->m_appName, nullptr); + + if (group == nullptr) { + group = new NotificationGroup(row->m_appName, this); + m_groupsByName.insert(row->m_appName, group); + m_groups.prepend(group); + emit groupsChanged(); + } else { + // Most-recently-active group first, matching the sidebar order. + const qsizetype index = m_groups.indexOf(group); + if (index > 0) { + m_groups.move(index, 0); + emit groupsChanged(); + } + } + + row->m_group = group; + group->addRow(row); +} + +void NotificationStore::removeGroupRow(NotificationRow* row) { + auto* group = row->m_group; + if (group == nullptr) { + return; + } + + row->m_group = nullptr; + group->removeRow(row); + + if (!group->m_notifs.isEmpty()) { + return; + } + + m_groups.removeOne(group); + m_groupsByName.remove(group->m_appName); + group->release(); + emit groupsChanged(); +} + +// Popup groups mirror m_groups over the rows currently showing as popups, so +// the popup stack can render one card per app instead of one per notification. +void NotificationStore::addPopupGroup(NotificationRow* row) { + if (row->m_popupGroup != nullptr) { + return; + } + + auto* group = m_popupGroupsByName.value(row->m_appName, nullptr); + + if (group == nullptr) { + group = new NotificationGroup(row->m_appName, this); + m_popupGroupsByName.insert(row->m_appName, group); + m_popupGroups.prepend(group); + emit popupGroupsChanged(); + } else { + const qsizetype index = m_popupGroups.indexOf(group); + if (index > 0) { + m_popupGroups.move(index, 0); + emit popupGroupsChanged(); + } + } + + row->m_popupGroup = group; + group->addRow(row); +} + +void NotificationStore::removePopupGroupRow(NotificationRow* row) { + auto* group = row->m_popupGroup; + if (group == nullptr) { + return; + } + + row->m_popupGroup = nullptr; + group->removeRow(row); + + if (!group->m_notifs.isEmpty()) { + return; + } + + m_popupGroups.removeOne(group); + m_popupGroupsByName.remove(group->m_appName); + group->release(); + emit popupGroupsChanged(); +} + +void NotificationStore::updatePopupGroup(NotificationRow* row) { + if (row->m_popup && !row->m_closed) { + addPopupGroup(row); + return; + } + + removePopupGroupRow(row); } void NotificationStore::insertDbRow(NotificationRow* row) { @@ -845,9 +1395,10 @@ void NotificationStore::insertDbRow(NotificationRow* row) { QJsonArray actions; for (auto* action : std::as_const(row->m_actions)) { - actions.append(QJsonObject{ - {"identifier", action->identifier()}, - {"text", action->text()}}); + actions.append( + QJsonObject{ + {"identifier", action->identifier()}, + {"text", action->text()}}); } QSqlQuery query(db); @@ -855,8 +1406,8 @@ void NotificationStore::insertDbRow(NotificationRow* row) { "INSERT INTO notifications" " (notification_id, app_name, app_icon, summary, body, time," " urgency, resident, has_action_icons, popup, actions," - " image_source, image_digest)" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + " image_source, image_digest, progress)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); query.addBindValue(row->m_notificationId); query.addBindValue(row->m_appName); query.addBindValue(row->m_appIcon); @@ -868,13 +1419,15 @@ void NotificationStore::insertDbRow(NotificationRow* row) { query.addBindValue(row->m_hasActionIcons ? 1 : 0); query.addBindValue(row->m_popup ? 1 : 0); query.addBindValue( - QString::fromUtf8(QJsonDocument(actions).toJson(QJsonDocument::Compact))); + QString::fromUtf8( + QJsonDocument(actions).toJson(QJsonDocument::Compact))); query.addBindValue(row->m_imageSource); query.addBindValue(row->m_digest); + query.addBindValue(row->m_progress); if (!query.exec()) { - qWarning(notifLog) - << "NotificationStore: could not insert row" << query.lastError(); + qWarning(notifLog) << "NotificationStore: could not insert row" + << query.lastError(); return; } @@ -891,7 +1444,7 @@ bool NotificationStore::openDb() { if (!db.open()) { qWarning(notifLog) << "NotificationStore: could not open database" - << m_path << db.lastError(); + << m_path << db.lastError(); return false; } @@ -908,7 +1461,8 @@ bool NotificationStore::openDb() { " notification_id INTEGER, app_name TEXT, app_icon TEXT," " summary TEXT, body TEXT, time INTEGER, urgency INTEGER," " resident INTEGER, has_action_icons INTEGER, popup INTEGER," - " actions TEXT, image_source TEXT, image_digest TEXT)", + " actions TEXT, image_source TEXT, image_digest TEXT," + " progress INTEGER)", "CREATE TABLE IF NOT EXISTS images(" "digest TEXT PRIMARY KEY, png BLOB)", "CREATE INDEX IF NOT EXISTS notifications_time" @@ -925,6 +1479,20 @@ bool NotificationStore::openDb() { } } + // Databases created before progress was stored lack the column; ALTER adds + // it instead of dropping history (caelestia drops and recreates). + if (!db.record("notifications").contains("progress")) { + QSqlQuery alter(db); + if (!alter.exec( + "ALTER TABLE notifications ADD COLUMN progress INTEGER")) { + qWarning(notifLog) + << "NotificationStore: could not add progress column" + << alter.lastError(); + closeDb(); + return false; + } + } + if (m_provider != nullptr) { m_provider->setDbPath(m_path); return true; @@ -990,7 +1558,7 @@ void NotificationStore::loadRows() { query.exec( "SELECT id, notification_id, app_name, app_icon, summary, body, time," " urgency, resident, has_action_icons, popup, actions," - " image_source, image_digest" + " image_source, image_digest, progress" " FROM notifications ORDER BY time DESC"); while (query.next()) { @@ -1006,12 +1574,17 @@ void NotificationStore::loadRows() { row->m_urgency = query.value(7).toInt(); row->m_resident = query.value(8).toInt() != 0; row->m_hasActionIcons = query.value(9).toInt() != 0; - row->m_popup = query.value(10).toInt() != 0; + // Popup state is deliberately not restored: showing stored + // notifications as toasts again after a shell restart would + // replay an old burst, and with autoExpire off the flag never + // clears itself. The rows stay in the sidebar instead. + row->m_popup = false; row->m_imageSource = query.value(12).toString(); row->m_digest = query.value(13).toString(); + row->m_progress = query.value(14).toInt(); - const auto actions = QJsonDocument::fromJson( - query.value(11).toString().toUtf8()); + const auto actions = + QJsonDocument::fromJson(query.value(11).toString().toUtf8()); if (actions.isArray()) { for (const auto& action : actions.array()) { const auto object = action.toObject(); @@ -1023,14 +1596,7 @@ void NotificationStore::loadRows() { } } - if (row->m_popup) { - row->m_deadline = - QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; - } - - row->m_image = row->m_digest.isEmpty() ? row->m_imageSource - : QStringLiteral("image://notifimg/") + - row->m_digest; + row->m_image = displayImage(row->m_imageSource, row->m_digest); row->refreshTimeStr(); if (row->m_digest.isEmpty()) { @@ -1038,6 +1604,7 @@ void NotificationStore::loadRows() { } m_rows.append(row); + addGroup(row); } emit rowsChanged(); @@ -1082,7 +1649,8 @@ void NotificationStore::migrateLegacyJson() { row->m_urgency = object.value("urgency").toInt(1); row->m_resident = object.value("resident").toBool(); row->m_hasActionIcons = object.value("hasActionIcons").toBool(); - row->m_popup = object.value("popup").toBool(); + // Same reasoning as loadRows(): never re-toast migrated rows. + row->m_popup = false; const auto time = object.value("time"); if (time.isDouble()) { @@ -1104,17 +1672,13 @@ void NotificationStore::migrateLegacyJson() { row)); } - row->m_image = row->m_imageSource; - - if (row->m_popup) { - row->m_deadline = - QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; - } + row->m_image = displayImage(row->m_imageSource, row->m_digest); row->refreshTimeStr(); insertDbRow(row); startImageCache(row); m_rows.append(row); + addGroup(row); } if (!m_rows.isEmpty()) { @@ -1128,6 +1692,8 @@ void NotificationStore::migrateLegacyJson() { } void NotificationStore::prune() { + flush(); + const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; @@ -1142,9 +1708,11 @@ void NotificationStore::prune() { << query.lastError(); } + // Total cap, preferring higher urgency so a chatty app cannot push out + // an important notification. if (!query.exec( "DELETE FROM notifications WHERE id NOT IN" - " (SELECT id FROM notifications ORDER BY time DESC" + " (SELECT id FROM notifications ORDER BY urgency DESC, time DESC" " LIMIT " + QString::number(MAX_ROWS) + ")")) { qWarning(notifLog) @@ -1152,6 +1720,30 @@ void NotificationStore::prune() { << query.lastError(); } + // Per-app cap: one app flooding must not crowd out the other groups. + QStringList appNames; + if (query.exec("SELECT DISTINCT app_name FROM notifications")) { + while (query.next()) { + appNames.append(query.value(0).toString()); + } + } + + for (const auto& appName : std::as_const(appNames)) { + QSqlQuery perGroup(db); + perGroup.prepare( + "DELETE FROM notifications WHERE app_name = ? AND id NOT IN" + " (SELECT id FROM notifications WHERE app_name = ?" + " ORDER BY urgency DESC, time DESC LIMIT " + + QString::number(MAX_PER_GROUP) + ")"); + perGroup.addBindValue(appName); + perGroup.addBindValue(appName); + + if (!perGroup.exec()) { + qWarning(notifLog) << "NotificationStore: could not trim group" + << appName << perGroup.lastError(); + } + } + orphanCleanup(); trimRows(); } @@ -1161,29 +1753,43 @@ void NotificationStore::trimRows() { return; } - // m_rows is newest-first. Drop closed rows from the oldest end until - // the cap is met; open rows stay (few, and the sender may act on - // them). Their DB rows are removed in case the DB trim above kept a - // different set (closed rows already deleted their DB row on close, - // so the two views can differ). + // Only closed, unlocked rows are droppable (open rows may still be + // acted on by the sender). Weakest first: lowest urgency, then oldest. const auto db = QSqlDatabase::database(m_uuid, false); - for (qsizetype i = m_rows.size() - 1; - i >= 0 && m_rows.size() > MAX_ROWS; i--) { - NotificationRow* row = m_rows.at(i); + while (m_rows.size() > MAX_ROWS) { + NotificationRow* victim = nullptr; - if (!row->m_closed || !row->m_locks.isEmpty()) { - continue; + for (auto* row : std::as_const(m_rows)) { + if (!row->m_closed || !row->m_locks.isEmpty()) { + continue; + } + + if (victim == nullptr) { + victim = row; + continue; + } + + const bool weaker = row->m_urgency < victim->m_urgency || + (row->m_urgency == victim->m_urgency && + row->m_timeMs <= victim->m_timeMs); + if (weaker) { + victim = row; + } } - if (row->m_rowId >= 0 && db.isOpen()) { + if (victim == nullptr) { + return; + } + + if (victim->m_rowId >= 0 && db.isOpen()) { QSqlQuery query(db); query.prepare("DELETE FROM notifications WHERE id = ?"); - query.addBindValue(row->m_rowId); + query.addBindValue(victim->m_rowId); query.exec(); } - removeRow(row); + removeRow(victim); } } @@ -1196,56 +1802,80 @@ void NotificationStore::startImageCache(NotificationRow* row) { const QUrl url(row->m_imageSource); - // Resolve *what* to read on the main thread (engine provider lookups - // belong here), but do no decoding here: providers are designed to - // serve requestImage from non-GUI threads, and SVG rasterization was - // blocking the shell 3-9ms per notification. + // Resolve *what* to read on the main thread (engine provider lookups and + // QIcon's theme state belong here), but do no file scanning and no + // decoding here: providers are designed to serve requestImage from non-GUI + // threads, and SVG rasterization was blocking the shell 3-9ms per + // notification. QString localPath; QQuickImageProvider* provider = nullptr; QString providerId; bool usePixmap = false; + QImage inlineImage; + QString iconName; + IconTheme theme; if (url.isLocalFile()) { localPath = url.toLocalFile(); } else if (url.scheme() == QLatin1String("image")) { + const QString id = url.path().startsWith(QLatin1Char('/')) + ? url.path().mid(1) + : url.path(); const QString host = url.host(); - if (host != QLatin1String("qsimage") && host != QLatin1String("qspixmap")) { - // Persistent urls (e.g. image://icon/...) resolve at any - // shell start; caching them is pointless, and calling the - // icon provider from this thread races KF6's KIconLoader, - // which is not thread-safe (crashed the shell). The row - // keeps rendering the url directly. - return; - } - // Transient inline notification images (image://qsimage/...). - // Provider objects are engine-owned and outlive this task; - // ids owned by a destroyed notification resolve to nothing. - auto* engine = qmlEngine(this); - if (engine == nullptr) { - return; - } + if (host != QLatin1String("qsimage") && + host != QLatin1String("qspixmap")) { + // Provider urls such as image://icon/... are served by KF6's + // KIconLoader, which is not thread-safe (calling it from a + // worker crashed the shell). Only QIcon's theme state is read + // here, because the GUI thread owns it; the candidate scanning it + // feeds is pure filesystem work and runs in the worker below. A + // name the theme does not have means thousands of stat calls, and + // doing those here stalled inserts for milliseconds. If nothing + // matches, the row keeps rendering the url directly. + iconName = id; + theme = iconTheme(); + } else { + // Transient inline notification images (image://qsimage/...). + // Provider objects are engine-owned and outlive this task; + // ids owned by a destroyed notification resolve to nothing. + auto* engine = qmlEngine(this); + if (engine == nullptr) { + return; + } - auto* base = const_cast( - engine->imageProvider(url.host())); - provider = dynamic_cast(base); - if (provider == nullptr) { - return; - } + auto* base = const_cast( + engine->imageProvider(url.host())); + provider = dynamic_cast(base); + if (provider == nullptr) { + return; + } - providerId = url.path().startsWith(QLatin1Char('/')) - ? url.path().mid(1) - : url.path(); + providerId = id; - switch (provider->imageType()) { - case QQuickImageProvider::Image: - usePixmap = false; - break; - case QQuickImageProvider::Pixmap: - usePixmap = true; - break; - default: - return; + switch (provider->imageType()) { + case QQuickImageProvider::Image: + usePixmap = false; + break; + case QQuickImageProvider::Pixmap: + usePixmap = true; + break; + default: + return; + } + + // Copy the pixels here instead of in the worker: the handle + // belongs to the sender's notification, and it can be gone by the + // time the task runs, which silently dropped the picture. This is + // a memory copy of an already decoded image, not a rasterization. + inlineImage = + usePixmap + ? provider->requestPixmap(providerId, nullptr, QSize()) + .toImage() + : provider->requestImage(providerId, nullptr, QSize()); + if (inlineImage.isNull()) { + return; + } } } @@ -1265,19 +1895,31 @@ void NotificationStore::startImageCache(NotificationRow* row) { watcher->deleteLater(); }); - watcher->setFuture(QtConcurrent::run( - [localPath, provider, providerId, usePixmap]() { - QImage image; + watcher->setFuture( + QtConcurrent::run([localPath, + iconName, + theme, + provider, + providerId, + usePixmap, + inlineImage]() { + QImage image = inlineImage; + QString path = localPath; - if (!localPath.isEmpty()) { - QImageReader reader(localPath); + if (image.isNull() && path.isEmpty() && !iconName.isEmpty()) { + path = iconFileForName(iconName, theme); + } + + if (image.isNull() && !path.isEmpty()) { + QImageReader reader(path); reader.setAutoTransform(true); image = reader.read(); - } else if (provider != nullptr) { - image = usePixmap - ? provider->requestPixmap(providerId, nullptr, QSize()) - .toImage() - : provider->requestImage(providerId, nullptr, QSize()); + } else if (image.isNull() && provider != nullptr) { + image = + usePixmap + ? provider->requestPixmap(providerId, nullptr, QSize()) + .toImage() + : provider->requestImage(providerId, nullptr, QSize()); } if (image.isNull()) { @@ -1288,12 +1930,47 @@ void NotificationStore::startImageCache(NotificationRow* row) { if (normalized.width() > MAX_IMAGE_DIM || normalized.height() > MAX_IMAGE_DIM) { normalized = normalized.scaled( - MAX_IMAGE_DIM, MAX_IMAGE_DIM, Qt::KeepAspectRatio, + MAX_IMAGE_DIM, + MAX_IMAGE_DIM, + Qt::KeepAspectRatio, Qt::SmoothTransformation); } normalized = normalized.convertToFormat(QImage::Format_RGBA8888); + // Cache the avatar content already cut to a circle, so the view + // does not have to mask it with a layer effect every frame. + const int side = + (std::min)(normalized.width(), normalized.height()); + const QRect square( + (normalized.width() - side) / 2, + (normalized.height() - side) / 2, + side, + side); + QImage circle(side, side, QImage::Format_RGBA8888); + circle.fill(Qt::transparent); + + QImage mask(side, side, QImage::Format_ARGB32); + mask.fill(Qt::transparent); + { + QPainter maskPainter(&mask); + maskPainter.setRenderHint(QPainter::Antialiasing); + maskPainter.setPen(Qt::NoPen); + maskPainter.setBrush(QColor(0, 0, 0, 255)); + maskPainter.drawEllipse(QRect(0, 0, side, side)); + } + + { + QPainter painter(&circle); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(QRect(0, 0, side, side), normalized, square); + painter.setCompositionMode( + QPainter::CompositionMode_DestinationIn); + painter.drawImage(0, 0, mask); + } + + normalized = circle; + const QByteArray bytes( reinterpret_cast(normalized.constBits()), normalized.sizeInBytes()); @@ -1333,16 +2010,18 @@ void NotificationStore::storeImageBlob( if (row != nullptr) { if (row->m_rowId >= 0) { QSqlQuery update(db); - update.prepare("UPDATE notifications SET image_digest = ?" - " WHERE id = ?"); + update.prepare( + "UPDATE notifications SET image_digest = ?" + " WHERE id = ?"); update.addBindValue(digest); update.addBindValue(row->m_rowId); update.exec(); } row->m_digest = digest; - row->m_image = - QStringLiteral("image://notifimg/") + digest; + row->m_image = QStringLiteral("image://notifimg/") + digest; + // The group reads its image through the source row and listens to + // imageChanged, so this emit is enough to keep the card in sync. emit row->imageChanged(); } } @@ -1362,6 +2041,10 @@ void NotificationStore::tick() { for (auto* row : std::as_const(m_rows)) { row->refreshTimeStr(); } + + for (auto* group : std::as_const(m_groups)) { + group->refreshActive(); + } } if (m_nextPrune <= now) { diff --git a/Plugins/ZShell/Services/notificationstore.hpp b/Plugins/ZShell/Services/notificationstore.hpp index f9af698..6a0eb2e 100644 --- a/Plugins/ZShell/Services/notificationstore.hpp +++ b/Plugins/ZShell/Services/notificationstore.hpp @@ -18,6 +18,7 @@ class Notifs; namespace ZShell::Services { +class NotificationGroup; class NotificationStore; class NotifImageProvider; @@ -58,12 +59,15 @@ class NotificationRow : public QObject { QML_ELEMENT QML_UNCREATABLE("NotificationRow instances come from a NotificationStore") - Q_PROPERTY(QQmlListProperty actions READ actions NOTIFY actionsChanged) + Q_PROPERTY( + QQmlListProperty actions READ actions NOTIFY + actionsChanged) Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged) Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged) Q_PROPERTY(QString body READ body NOTIFY bodyChanged) Q_PROPERTY(bool closed READ closed NOTIFY closedChanged) - Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged) + Q_PROPERTY( + bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged) Q_PROPERTY(QString image READ image NOTIFY imageChanged) Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged) Q_PROPERTY(int notificationId READ notificationId CONSTANT) @@ -97,9 +101,17 @@ class NotificationRow : public QObject { [[nodiscard]] int urgency() const; Q_INVOKABLE void close(); + Q_INVOKABLE void dismiss(); + Q_INVOKABLE void dismissPopup(); + Q_INVOKABLE void holdPopup(); + Q_INVOKABLE bool isSameAppIcon(const QString& path) const; + Q_INVOKABLE bool isSameImage(const QString& path) const; Q_INVOKABLE void lock(QObject* item); Q_INVOKABLE void pausePopup(); + Q_INVOKABLE void ref(QObject* item); + Q_INVOKABLE void releasePopup(); Q_INVOKABLE void resumePopup(); + Q_INVOKABLE void unref(QObject* item); Q_INVOKABLE void unlock(QObject* item); signals: @@ -119,12 +131,15 @@ class NotificationRow : public QObject { void urgencyChanged(); private: + friend class NotificationGroup; friend NotificationStore; [[nodiscard]] bool expired(qint64 now) const; void refreshTimeStr(); QList m_actions; + NotificationGroup* m_group = nullptr; + NotificationGroup* m_popupGroup = nullptr; NotificationStore* m_store = nullptr; QString m_appIcon; @@ -144,6 +159,10 @@ class NotificationRow : public QObject { QSet m_locks; QJSValue m_jsNotification; + // The store has already dropped this row from its lists, but a delegate is + // still animating it out; the delete waits for the last unlock. + bool m_deleteAfterUnlock = false; + bool m_closed = false; bool m_hasActionIcons = false; bool m_liveConnected = false; @@ -156,15 +175,100 @@ class NotificationRow : public QObject { int m_urgency = 1; }; +class NotificationGroup : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("NotificationGroup instances come from a NotificationStore") + + Q_PROPERTY( + QQmlListProperty activeNotifs READ activeNotifs NOTIFY + activeNotifsChanged) + Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged) + Q_PROPERTY(QString appName READ appName CONSTANT) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QString firstSummary READ firstSummary NOTIFY firstSummaryChanged) + Q_PROPERTY(QString image READ image NOTIFY imageChanged) + Q_PROPERTY( + QQmlListProperty notifs READ notifs NOTIFY + notifsChanged) + Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged) + Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged) + + public: + NotificationGroup(const QString& appName, QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty activeNotifs(); + [[nodiscard]] QString appIcon() const; + [[nodiscard]] QString appName() const; + [[nodiscard]] int count() const; + [[nodiscard]] QString firstSummary() const; + [[nodiscard]] QString image() const; + [[nodiscard]] QQmlListProperty notifs(); + [[nodiscard]] QString timeStr() const; + [[nodiscard]] int urgency() const; + + Q_INVOKABLE bool isSameAppIcon(const QString& path) const; + Q_INVOKABLE bool isSameImage(const QString& path) const; + Q_INVOKABLE void lock(QObject* item); + Q_INVOKABLE void unlock(QObject* item); + + signals: + void activeNotifsChanged(); + void appIconChanged(); + void countChanged(); + void firstSummaryChanged(); + void imageChanged(); + void notifsChanged(); + void timeStrChanged(); + void urgencyChanged(); + + private: + friend NotificationStore; + + void addRow(NotificationRow* row); + void refreshActive(); + void release(); + void removeRow(NotificationRow* row); + void sync(); + + QList m_active; + QList m_notifs; + + // The group reads image/appIcon through the row it took them from, so a + // row swapping between its raw source and the cached blob keeps the card + // in sync instead of holding a stale copy. + QPointer m_iconSource; + QPointer m_imageSource; + QSet m_locks; + + QString m_appName; + QString m_firstSummary; + QString m_timeStr; + + int m_count = 0; + int m_urgency = 1; + bool m_releasing = false; +}; + class NotificationStore : public QObject { Q_OBJECT QML_ELEMENT - Q_PROPERTY(bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY autoExpireChanged) + Q_PROPERTY( + bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY + autoExpireChanged) + Q_PROPERTY( + QQmlListProperty groups READ groups NOTIFY + groupsChanged) + Q_PROPERTY( + QQmlListProperty popupGroups READ popupGroups NOTIFY + popupGroupsChanged) Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) - Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY - popupTimeoutChanged) - Q_PROPERTY(QQmlListProperty rows READ rows NOTIFY rowsChanged) + Q_PROPERTY( + int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY + popupTimeoutChanged) + Q_PROPERTY( + QQmlListProperty rows READ rows NOTIFY rowsChanged) public: explicit NotificationStore(QObject* parent = nullptr); @@ -176,17 +280,23 @@ class NotificationStore : public QObject { [[nodiscard]] bool autoExpire() const; void setAutoExpire(bool autoExpire); + [[nodiscard]] QQmlListProperty groups(); + [[nodiscard]] QQmlListProperty popupGroups(); + [[nodiscard]] int popupTimeout() const; void setPopupTimeout(int timeout); [[nodiscard]] QQmlListProperty rows(); - Q_INVOKABLE NotificationRow* insert(const QJSValue& notification, bool popup); + Q_INVOKABLE NotificationRow* insert( + const QJSValue& notification, bool popup); Q_INVOKABLE void clear(); signals: void autoExpireChanged(); + void groupsChanged(); void pathChanged(); + void popupGroupsChanged(); void popupTimeoutChanged(); void rowsChanged(); @@ -198,18 +308,24 @@ class NotificationStore : public QObject { void attachNotification( NotificationRow* row, QObject* notification, const QJSValue& js); + void addGroup(NotificationRow* row); void applyNotifConfig(const config::Notifs* notifs); void closeDb(); void copyFields(NotificationRow* row, QObject* notification); + void flush(); void handleClosed(NotificationRow* row); void insertDbRow(NotificationRow* row); void loadRows(); + void markDirty(NotificationRow* row); void migrateLegacyJson(); [[nodiscard]] bool openDb(); void orphanCleanup(); - void persistPopup(const NotificationRow* row); void prune(); + void addPopupGroup(NotificationRow* row); + void removePopupGroupRow(NotificationRow* row); + void updatePopupGroup(NotificationRow* row); void registerProvider(); + void removeGroupRow(NotificationRow* row); void removeRow(NotificationRow* row); // Coalesced rowsChanged: a burst of inserts/removes triggers one QML // rebind on the next event loop turn instead of one per notification. @@ -238,10 +354,16 @@ class NotificationStore : public QObject { QString m_uuid; QString m_path; + QList m_groups; + QList m_popupGroups; QList m_rows; QHash m_liveRows; + QHash m_groupsByName; + QHash m_popupGroupsByName; + QSet m_dirty; NotifImageProvider* m_provider = nullptr; + QTimer* m_flush = nullptr; QTimer* m_tick = nullptr; }; diff --git a/Plugins/ZShell/alignaudit.cpp b/Plugins/ZShell/alignaudit.cpp new file mode 100644 index 0000000..861a21b --- /dev/null +++ b/Plugins/ZShell/alignaudit.cpp @@ -0,0 +1,206 @@ +#include "alignaudit.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef ALIGN_AUDIT_USE_QML_PRIVATE +#include +#endif + +namespace ZShell { +namespace { + +bool isOff(double v, double tol) { + return std::abs(v - std::round(v)) > tol; +} + +QString num(double v, bool off) { + return QString::number(v, 'f', 2) + (off ? QStringLiteral("*") : QString()); +} + +QString typeName(const QQuickItem* item) { + static const QRegularExpression suffix(QStringLiteral("_QML(TYPE)?_\\d+$")); + QString name = QString::fromUtf8(item->metaObject()->className()); + name.remove(suffix); + if (name.startsWith(QLatin1String("QQuick"))) { + name.remove(0, 6); + } + return name; +} + +QString label(const QQuickItem* item) { + QString s = typeName(item); + if (!item->objectName().isEmpty()) { + s += QLatin1Char('#') + item->objectName(); + } + return s; +} + +QString ancestry(const QQuickItem* item, int maxDepth = 8) { + QStringList parts; + for (const QQuickItem* i = item; i && parts.size() < maxDepth; + i = i->parentItem()) { + parts.prepend(label(i)); + } + return parts.join(QLatin1String(" > ")); +} + +struct Source { + QString file; + int line = 0; + int column = 0; +}; + +Source sourceOf(QQuickItem* item) { + Source s; + if (const QQmlContext* ctx = qmlContext(item)) { + const QUrl url = ctx->baseUrl(); + s.file = url.isLocalFile() ? url.toLocalFile() : url.toString(); + } +#ifdef ALIGN_AUDIT_USE_QML_PRIVATE + if (const QQmlData* d = QQmlData::get(item, false)) { + s.line = int(d->lineNumber); + s.column = int(d->columnNumber); + } +#endif + return s; +} + +void visit(QQuickItem* item, const std::function& fn) { + if (!item->isVisible() || item->opacity() <= 0.0) { + return; // whole subtree is invisible + } + fn(item); + for (QQuickItem* child : item->childItems()) { + visit(child, fn); + } +} + +int auditWindow(QQuickWindow* window, const Options& o, QTextStream& out) { + const double dpr = window->effectiveDevicePixelRatio(); + const double tol = o.tolerance; + int count = 0; + + visit(window->contentItem(), [&](QQuickItem* item) { + if (item->width() <= 0 || item->height() <= 0) { + return; + } + if (o.paintedOnly && + !item->flags().testFlag(QQuickItem::ItemHasContents) && + !item->clip()) { + return; + } + + const bool isText = item->inherits("QQuickText"); + + const QRectF r = + item->mapRectToScene(QRectF(0, 0, item->width(), item->height())); + const double l = r.left() * dpr, t = r.top() * dpr; + const double rt = r.right() * dpr, b = r.bottom() * dpr; + + const bool offL = isOff(l, tol), offT = isOff(t, tol); + const bool offR = !isText && isOff(rt, tol); + const bool offB = !isText && isOff(b, tol); + if (!(offL || offT || offR || offB)) { + return; + } + + const bool own = isOff(item->x() * dpr, tol) || + isOff(item->y() * dpr, tol) || + (!isText && (isOff(item->width() * dpr, tol) || + isOff(item->height() * dpr, tol))); + if (!own && !o.includeInherited) { + return; + } + + const Source src = sourceOf(item); + out << src.file; + if (src.line > 0) { + out << ':' << src.line << ':' << src.column; + } + out << ": " << label(item) << " unaligned (" + << (own ? "own" : "inherited") << ")\n" + << " device px: left=" << num(l, offL) << " top=" << num(t, offT) + << " right=" << num(rt, offR) << " bottom=" << num(b, offB) << "\n" + << " local: x=" << item->x() << " y=" << item->y() + << " w=" << item->width() << " h=" << item->height() << "\n" + << " path: " << ancestry(item) << "\n"; + ++count; + }); + return count; +} + +} // namespace + +struct ScreenReport { + double dpr = 1.0; + int windows = 0; + int count = 0; + QString body; +}; + +int run(const Options& options) { + QMap reports; + + for (QWindow* w : QGuiApplication::allWindows()) { + auto* qw = qobject_cast(w); + if (!qw || !qw->isVisible() || !qw->contentItem()) { + continue; + } + + const QString name = qw->screen() ? qw->screen()->name() + : QStringLiteral("?"); + ScreenReport& report = reports[name]; + report.dpr = qw->effectiveDevicePixelRatio(); + ++report.windows; + + QString windowBody; + QTextStream stream(&windowBody); + report.count += auditWindow(qw, options, stream); + stream.flush(); + report.body += windowBody; + } + + QFile file(options.outputPath); + if (!file.open( + QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + qWarning() << "AlignAudit: cannot open" << options.outputPath; + return -1; + } + QTextStream out(&file); + + int total = 0; + for (const ScreenReport& r : std::as_const(reports)) { + total += r.count; + } + + out << "# align audit " + << QDateTime::currentDateTime().toString(Qt::ISODate) + << " ('*' marks the off edges, values are device pixels)\n" + << "# total: " << total << "\n\n"; + + for (auto it = reports.cbegin(); it != reports.cend(); ++it) { + out << "# screen " << it.key() << " dpr=" << it.value().dpr + << " windows=" << it.value().windows + << " offenders=" << it.value().count << "\n" + << it.value().body << "\n"; + } + return total; +} + +void runLater(int delayMs, const Options& options) { + QTimer::singleShot(delayMs, qApp, [options] { run(options); }); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/alignaudit.hpp b/Plugins/ZShell/alignaudit.hpp new file mode 100644 index 0000000..ff0a90f --- /dev/null +++ b/Plugins/ZShell/alignaudit.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +namespace ZShell { + +struct Options { + QString outputPath = QStringLiteral("/tmp/align-audit.txt"); + bool paintedOnly = true; // skip items that draw nothing + bool includeInherited = + false; // also report items only off because an ancestor is + double tolerance = 1e-3; // in device pixels +}; + +Q_INVOKABLE void runLater(int delayMs, const Options& options = {}); + +int run(const Options& options = {}); + +} // namespace ZShell diff --git a/Plugins/ZShell/alignauditor.cpp b/Plugins/ZShell/alignauditor.cpp new file mode 100644 index 0000000..7d83519 --- /dev/null +++ b/Plugins/ZShell/alignauditor.cpp @@ -0,0 +1,26 @@ +#include "alignauditor.hpp" + +#include + +namespace ZShell { + +Options AlignAuditor::options() const { + Options o; + o.outputPath = m_outputPath; + o.paintedOnly = m_paintedOnly; + o.includeInherited = m_includeInherited; + o.tolerance = m_tolerance; + return o; +} + +int AlignAuditor::run() { + m_lastCount = ZShell::run(options()); + emit finished(m_lastCount); + return m_lastCount; +} + +void AlignAuditor::runLater() { + QTimer::singleShot(m_delayMs, this, [this] { run(); }); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/alignauditor.hpp b/Plugins/ZShell/alignauditor.hpp new file mode 100644 index 0000000..17279a8 --- /dev/null +++ b/Plugins/ZShell/alignauditor.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "alignaudit.hpp" + +namespace ZShell { + +class AlignAuditor : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString outputPath MEMBER m_outputPath NOTIFY outputPathChanged) + Q_PROPERTY(int delayMs MEMBER m_delayMs NOTIFY delayMsChanged) + Q_PROPERTY(bool paintedOnly MEMBER m_paintedOnly NOTIFY paintedOnlyChanged) + Q_PROPERTY( + bool includeInherited MEMBER m_includeInherited NOTIFY + includeInheritedChanged) + Q_PROPERTY(double tolerance MEMBER m_tolerance NOTIFY toleranceChanged) + Q_PROPERTY(int lastCount READ lastCount NOTIFY finished) + + public: + explicit AlignAuditor(QObject* parent = nullptr) : QObject(parent) {} + + Q_INVOKABLE int run(); + + Q_INVOKABLE void runLater(); + + int lastCount() const { return m_lastCount; } + + signals: + void outputPathChanged(); + void delayMsChanged(); + void paintedOnlyChanged(); + void includeInheritedChanged(); + void toleranceChanged(); + void finished(int count); + + private: + Options options() const; + + QString m_outputPath = QStringLiteral("/tmp/align-audit.txt"); + int m_delayMs = 5000; + bool m_paintedOnly = true; + bool m_includeInherited = false; + double m_tolerance = 1e-3; + int m_lastCount = 0; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index 8a81a39..fabcce0 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -1,8 +1,14 @@ #include "zutils.hpp" #include +#include +#include #include #include +#include +#include +#include +#include #include #include #include @@ -337,4 +343,422 @@ QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) { return out; } +qreal ZUtils::dprFor(QQuickItem* item) { + if (item) { + if (const QQuickWindow* window = item->window()) { + return window->effectiveDevicePixelRatio(); + } + } + if (const QScreen* screen = QGuiApplication::primaryScreen()) { + return screen->devicePixelRatio(); + } + return 1.0; +} + +namespace { + +// Per-axis centering detected from the item's own state. Anchor lines are +// Q_GADGET values with no properties, so centering is read from the anchors' +// used-anchor bitmask instead of by evaluating `anchors.verticalCenter`. +// Layout alignment comes from the attached Layout object; an item with no +// attached object uses Qt's default (AlignLeft | AlignVCenter). +struct Centering { + bool horizontal = false; + bool vertical = false; + // anchors.alignWhenCentered: false makes Qt round half-sizes in logical px + // (y = qRound(parentSize/2) - qRound(size/2)) instead of dividing exactly. + bool alignWhenCentered = true; + QString source; +}; + +Centering detectCentering(QQuickItem* item) { + Centering c; + QQuickAnchors* anchors = QQuickItemPrivate::get(item)->_anchors; + if (anchors) { + const QQuickAnchorsPrivate* ad = QQuickAnchorsPrivate::get(anchors); + c.alignWhenCentered = ad->centerAligned; + if (ad->usedAnchors & QQuickAnchors::HCenterAnchor) { + c.horizontal = true; + c.source = QStringLiteral("hCenter"); + } + if (ad->usedAnchors & QQuickAnchors::VCenterAnchor) { + c.vertical = true; + c.source += (c.source.isEmpty() ? "" : " ") + QStringLiteral("vCenter"); + } + } + + QQuickItem* parent = item->parentItem(); + if (parent && parent->inherits("QQuickLayout")) { + QQuickLayoutAttached* info = attachedLayoutObject(item, false); + const Qt::Alignment flags = info ? info->alignment() : Qt::Alignment(); + // Filling an axis means the layout stretches that axis, so no centering. + if (info && !info->fillWidth() && (flags & Qt::AlignHCenter)) { + c.horizontal = true; + c.source += (c.source.isEmpty() ? "" : " ") + + QStringLiteral("Layout.HCenter"); + } + const bool defaultVCenter = flags == 0 || (flags & Qt::AlignVCenter); + if ((!info || !info->fillHeight()) && defaultVCenter && + !(flags & (Qt::AlignTop | Qt::AlignBottom))) { + c.vertical = true; + c.source += (c.source.isEmpty() ? "" : " ") + + QStringLiteral("Layout.VCenter"); + } + } + return c; +} + +// Parity (mod 2) of an item's whole-device-pixel size; no item = even. +int devParity(const QQuickItem* it, qreal dpr, bool horizontal) { + if (!it) + return 0; + const qint64 dev = std::llround((horizontal ? it->width() : it->height()) * dpr); + return static_cast(dev % 2); +} + +// Snap a size to a whole device-pixel count whose parity matches `parity` +// (the parent's device-size parity). An item centered by *exact* division +// (Layout alignment, or anchors with alignWhenCentered: true) lands on whole +// device pixels exactly when its device size shares the parent's parity, +// since edges = parentLeft + (parentSize - size) / 2. +qreal snapToParity(qreal value, qreal dpr, int parity, int mode) { + const double exact = value * dpr; + qint64 n = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) + : mode < 0 ? std::llround(std::floor(exact + 1e-6)) + : std::llround(exact); + if (static_cast(n % 2) != parity) { + if (mode > 0) { + ++n; + } else if (mode < 0) { + --n; + } else { + // nearest: prefer the side closer to exact; exact ties go up + n = (exact >= static_cast(n)) ? n + 1 : n - 1; + } + } + if (n < 0) + n = 0; + return static_cast(n) / dpr; +} + +// qRound() semantics: round half away from zero. +qint64 roundAway(double value) { + return value >= 0.0 ? qint64(value + 0.5) : qint64(value - 0.5); +} + +// Snap a size for an item centered by anchors with `alignWhenCentered: false`. +// Qt resolves that as `y = qRound(parentSize / 2) + parentPos - size / 2` +// (qquickanchors.cpp: the *anchor target* rounds its half, the anchored item +// divides exactly), so with the parent's half at a whole device offset the +// child's device size must be even; if the parent's half sits on a half device +// pixel, the child's device size must be odd instead. +qreal snapCenteredSize(qreal value, qreal dpr, qint64 parentHalfRounded, int mode) { + const double exact = value * dpr; + const double anchorDev = double(parentHalfRounded) * dpr; + const bool anchorWhole = + std::fabs(anchorDev - std::round(anchorDev)) < 1e-6; + const qint64 parity = anchorWhole ? 0 : 1; + + qint64 base = mode > 0 ? std::llround(std::ceil(exact - 1e-6)) + : mode < 0 ? std::llround(std::floor(exact + 1e-6)) + : std::llround(exact); + for (qint64 step = 0; step < 8; ++step) { + if (base + step >= 0 && (base + step) % 2 == parity) + return qreal(base + step) / dpr; + if (step > 0 && base - step >= 0 && (base - step) % 2 == parity) + return qreal(base - step) / dpr; + } + return qreal(std::max(0, base)) / dpr; +} + +// Snap a size along `horizontal` for an item whose placement on that axis is +// centered. Anchor-centered items follow Qt's rounding (`alignWhenCentered`); +// anything else only needs the parent's device-size parity. +qreal snapCenteredAxis( + qreal value, + QQuickItem* item, + qreal dpr, + bool horizontal, + const Centering& centering, + int mode) { + QQuickItem* parent = item->parentItem(); + if (!centering.alignWhenCentered && parent) { + const qreal parentSize = horizontal ? parent->width() : parent->height(); + return snapCenteredSize(value, dpr, roundAway(parentSize / 2.0), mode); + } + return snapToParity(value, dpr, devParity(parent, dpr, horizontal), mode); +} + +} // namespace + +qreal ZUtils::snap(qreal value, QQuickItem* item) { + const qreal dpr = dprFor(item); + if (item) { + const Centering c = detectCentering(item); + if (c.horizontal) + return snapCenteredAxis(value, item, dpr, true, c, 0); + if (c.vertical) + return snapCenteredAxis(value, item, dpr, false, c, 0); + } + return std::round(value * dpr) / dpr; +} + +qreal ZUtils::snapCeil(qreal value, QQuickItem* item) { + const qreal dpr = dprFor(item); + if (item) { + const Centering c = detectCentering(item); + if (c.horizontal) + return snapCenteredAxis(value, item, dpr, true, c, 1); + if (c.vertical) + return snapCenteredAxis(value, item, dpr, false, c, 1); + } + return std::ceil(value * dpr - 1e-6) / dpr; +} + +qreal ZUtils::snapFloor(qreal value, QQuickItem* item) { + const qreal dpr = dprFor(item); + if (item) { + const Centering c = detectCentering(item); + if (c.horizontal) + return snapCenteredAxis(value, item, dpr, true, c, -1); + if (c.vertical) + return snapCenteredAxis(value, item, dpr, false, c, -1); + } + return std::floor(value * dpr + 1e-6) / dpr; +} + +bool ZUtils::logPlacement(QQuickItem* item, const QString& tag) { + if (!item) { + qWarning() << "logPlacement: null item"; + return false; + } + + constexpr double tol = 1e-3; + const auto isOff = [](double v) { + return std::abs(v - std::round(v)) > tol; + }; + const auto fmt = [&](double v) { + return QString::number(v, 'f', 2) + + (isOff(v) ? QStringLiteral("*") : QString()); + }; + + // Centering is detected from the item: anchors.centerIn, the individual + // center anchor lines, and Layout alignment. + const Centering c = detectCentering(item); + const bool centeredH = c.horizontal; + const bool centeredV = c.vertical; + const QString& centerSrc = c.source; + + const double dpr = dprFor(item); + const QRectF r = + item->mapRectToScene(QRectF(0, 0, item->width(), item->height())); + const double l = r.left() * dpr; + const double t = r.top() * dpr; + const double rt = r.right() * dpr; + const double b = r.bottom() * dpr; + + // Same rule as the audit: a Text's fractional implicit size doesn't blur anything. + const bool isText = item->inherits("QQuickText"); + + // Centered rule per axis: whole device size, and the center on the grid + // with parity matching that size (even size -> integer center, odd -> + // pixel center at .5). That is what makes both edges land on whole device + // pixels when the center is divided exactly (`alignWhenCentered: true`). + // With `alignWhenCentered: false` Qt rounds the anchor target's half + // instead of dividing, so the edges are what matter. + const auto centeredAxisOk = [&](double lo, double hi) { + const double size = hi - lo; + if (isOff(size)) + return false; + if (!c.alignWhenCentered) + return !isOff(lo) && !isOff(hi); + const double center = (lo + hi) / 2.0; + const double parity = (std::llround(size) % 2 == 0) ? 0.0 : 0.5; + double err = std::fmod(center - parity, 1.0); + if (err < 0) + err += 1.0; + return std::min(err, 1.0 - err) <= tol; + }; + + bool aligned; + if (centeredH && centeredV) { + aligned = centeredAxisOk(l, rt) && (isText || centeredAxisOk(t, b)); + } else if (centeredH) { + aligned = centeredAxisOk(l, rt) && !isOff(t) && (isText || !isOff(b)); + } else if (centeredV) { + aligned = !isOff(l) && centeredAxisOk(t, b) && (isText || !isOff(rt)); + } else { + aligned = !isOff(l) && !isOff(t) && + (isText || (!isOff(rt) && !isOff(b))); + } + + QString file; + if (const QQmlContext* ctx = qmlContext(item)) { + const QUrl url = ctx->baseUrl(); + file = url.isLocalFile() ? url.toLocalFile() : url.toString(); + } + + qInfo().noquote() + << (aligned ? "[ALIGNED] " : "[UNALIGNED]") + << (tag.isEmpty() ? QString::fromUtf8(item->metaObject()->className()) + : tag) + << "\n device px: left=" << fmt(l) << " top=" << fmt(t) + << " right=" << fmt(rt) << " bottom=" << fmt(b) + << (isText ? " (right/bottom ignored: Text)" : "") + << (!centerSrc.isEmpty() + ? " (centered: " + centerSrc + ")" + : QString()) + << (centeredH || centeredV + ? QString("\n center: x=%1 y=%2 size=%3 x %4") + .arg(QString::number((l + rt) / 2, 'f', 2)) + .arg(QString::number((t + b) / 2, 'f', 2)) + .arg(QString::number(rt - l, 'f', 2)) + .arg(QString::number(b - t, 'f', 2)) + : QString()) + << "\n size: " << fmt(item->width() * dpr) << " x " + << fmt(item->height() * dpr) << "\n local: x=" << item->x() + << " y=" << item->y() << " w=" << item->width() + << " h=" << item->height() << "\n dpr=" << dpr + << (item->window() ? "" : " (NO WINDOW, using fallback dpr)") + << (file.isEmpty() ? QString() : "\n file: " + file); + + return aligned; +} + +bool ZUtils::logCurvePlacement( + QQuickItem* item, + const QList& samples, + qreal lineWidth, + const QString& tag) { + if (!item) { + qWarning() << "logCurvePlacement: null item"; + return false; + } + if (samples.size() < 4 || samples.size() % 2 != 0) { + qWarning() << "logCurvePlacement: need flat x,y pairs (>= 2 points)"; + return false; + } + + constexpr double tol = 1e-3; + + const double dpr = dprFor(item); + const int devW = static_cast( + std::max(1, std::llround(lineWidth * dpr))); + const double half = devW / 2.0; + + qsizetype offCount = 0; + qsizetype sloped = 0; + double worst = 0.0; + double worstX = 0.0; + double worstY = 0.0; + double worstEdge = 0.0; + int worstSide = 0; // -1 top edge, +1 bottom edge + + for (qsizetype i = 0; i < samples.size(); i += 2) { + const double x = samples[i]; + const double y = samples[i + 1]; + + // A sloped stroke's edge is never horizontal in device space, so + // only locally-flat samples can (and must) sit on pixel boundaries + const double yPrev = i >= 2 ? samples[i - 1] : y; + const double yNext = i + 3 < samples.size() ? samples[i + 3] : y; + if (std::abs(yNext - yPrev) > tol) { + ++sloped; + continue; + } + + const double yc = y * dpr; + const double top = yc - half; + const double bottom = yc + half; + for (int side = -1; side <= 1; side += 2) { + const double edge = side < 0 ? top : bottom; + const double dev = std::abs(edge - std::round(edge)); + if (dev > tol) { + ++offCount; + if (dev > worst) { + worst = dev; + worstX = x; + worstY = y; + worstEdge = edge; + worstSide = side; + } + } + } + } + + const qsizetype total = samples.size() / 2; + const qsizetype flat = total - sloped; + const bool aligned = offCount == 0; + + qInfo().noquote() + << (aligned ? "[CURVE ALIGNED] " : "[CURVE UNALIGNED]") + << (tag.isEmpty() ? QString::fromUtf8(item->metaObject()->className()) + : tag) + << "\n samples: " << total << " points (" << flat << " flat, " + << sloped << " sloped ignored), dpr=" << dpr + << ", stroke width=" << lineWidth << " logical = " << devW + << " device px" + << "\n edges: " << offCount << " of " << flat * 2 + << " flat stroke edges off the device grid"; + if (!aligned) { + qInfo().noquote() + << " worst: deviation=" << QString::number(worst, 'f', 3) + << " device px at logical x=" << QString::number(worstX, 'f', 2) + << " y=" << QString::number(worstY, 'f', 2) + << " (" << (worstSide < 0 ? "top" : "bottom") << " edge device=" + << QString::number(worstEdge, 'f', 3) << ")"; + } + + return aligned; +} + +QList ZUtils::distribute( + qreal total, const QList& preferred, QQuickItem* item) { + const qsizetype n = preferred.size(); + QList out(n, 0.0); + if (n == 0) { + return out; + } + + const qreal dpr = dprFor(item); + const qint64 totalPx = std::max(0, std::llround(total * dpr)); + + QList px; + px.reserve(n); + qint64 sum = 0; + for (const qreal p : preferred) { + const qint64 v = + std::max(0, std::llround(std::ceil(p * dpr - 1e-6))); + px.append(v); + sum += v; + } + + const qint64 left = totalPx - sum; + if (left >= 0) { + // Extra space: split evenly, remainder pixels to the first items. + const qint64 base = left / n; + const qint64 rem = left % n; + for (qsizetype i = 0; i < n; ++i) { + px[i] += base + (i < rem ? 1 : 0); + } + } else if (sum > 0) { + // Not enough space: shrink proportionally, then hand out the rounding leftovers. + qint64 assigned = 0; + for (qsizetype i = 0; i < n; ++i) { + px[i] = static_cast(std::floor( + static_cast(px[i]) * static_cast(totalPx) / + static_cast(sum))); + assigned += px[i]; + } + for (qsizetype i = 0; assigned < totalPx; i = (i + 1) % n, ++assigned) { + ++px[i]; + } + } + + for (qsizetype i = 0; i < n; ++i) { + out[i] = static_cast(px[i]) / dpr; + } + return out; +} + } // namespace ZShell diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp index 3900cc0..b9f8aa8 100644 --- a/Plugins/ZShell/zutils.hpp +++ b/Plugins/ZShell/zutils.hpp @@ -53,6 +53,44 @@ class ZUtils : public QObject { Q_INVOKABLE static QList findChildrenMatching( QQuickItem* root, const QString& pattern); + // Device pixel ratio of the window the item lives in (falls back to the primary screen, then 1.0) + Q_INVOKABLE static qreal dprFor(QQuickItem* item); + + // Snap a logical value to the device pixel grid of the item's window. + // Centering is detected from the item: anchors.centerIn, + // anchors.horizontalCenter / verticalCenter, and Layout alignment. A size + // along a centered axis is snapped so the centered edges land on whole + // device pixels — using Qt's own rounding rule for that anchor style + // (exact halves when `alignWhenCentered` is true, the rounded half of the + // anchor target when it is false). + Q_INVOKABLE static qreal snap(qreal value, QQuickItem* item); + Q_INVOKABLE static qreal snapCeil(qreal value, QQuickItem* item); // text-derived sizes + Q_INVOKABLE static qreal snapFloor(qreal value, QQuickItem* item); + // Logs where an item sits in device pixels and whether it is aligned. + // Returns true if aligned. `tag` is an optional label for the log line. + // Items centered on a target are judged by the centering rule above + // (whole device size, plus the offset the anchor produces), which is + // detected from the item's anchors and Layout alignment. + Q_INVOKABLE static bool logPlacement( + QQuickItem* item, + const QString& tag = QString()); + + // Walks a stroked curve path (flat x,y pairs in the item's logical coords, + // every point along the curve, not just its defined points) and checks + // whether both stroke edges land on device pixel boundaries at `lineWidth`. + // Returns true if every sample is aligned. + Q_INVOKABLE static bool logCurvePlacement( + QQuickItem* item, + const QList& samples, + qreal lineWidth, + const QString& tag = QString()); + + // Distributes `total` (logical px) across items with the given preferred widths. + // Every result is a whole number of device pixels and the results sum exactly to + // the snapped total. If the preferred widths don't fit, they shrink proportionally. + Q_INVOKABLE static QList distribute( + qreal total, const QList& preferred, QQuickItem* item); + [[nodiscard]] QString version() const; [[nodiscard]] QString qtVersion() const; }; diff --git a/Plugins/gersemi/qt-add-shaders.cmake b/Plugins/gersemi/qt-add-shaders.cmake new file mode 100644 index 0000000..210f2b1 --- /dev/null +++ b/Plugins/gersemi/qt-add-shaders.cmake @@ -0,0 +1,7 @@ +function(qt_add_shaders target name) + set(options BATCHABLE OPTIMIZED NOHLSL NOMSL PRECOMPILE) + set(oneValueArgs GLSL HLSL MSL PREFIX BASE OUTPUT_TARGETS) + set(multiValueArgs FILES DEFINES OUTPUTS) + + cmake_parse_arguments(_ "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) +endfunction() diff --git a/scripts/symbols-preview.py b/scripts/symbols-preview.py new file mode 100644 index 0000000..1497fe3 --- /dev/null +++ b/scripts/symbols-preview.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +"""Generate a searchable preview page for installed Material Symbols fonts. + +Usage: + symbols-preview.py [out.html] [--styles rounded,outlined,sharp] [--source url|local|embed] + +--source local (the default) uses local() only: nothing extra on disk, and all four +axes apply, because ~/.config/fontconfig/conf.d/90-material-symbols-variable.conf +re-scans the installed fonts with "variable: True" so Firefox stops treating the +local() match as static. Without that fontconfig edit, Firefox maps only `wght`. +local() matches the full font name (name ID 4) and PostScript name (ID 6), NOT the +family name, which is why local("Material Symbols Rounded") silently fails. + +--source url writes one subsetted variable WOFF2 per style next to the HTML and +loads them with url(). Webfonts apply all four axes regardless of the fontconfig +flag, so this is the fallback if the advertising is ever removed. + +--source embed puts the subset in the page as a data URL: self-contained, ~2 MB/style. +""" +import argparse +import base64 +import json +import os +import subprocess +import tempfile +from fontTools import ttLib + +STYLES = ("rounded", "outlined", "sharp") +FAMILY = {"rounded": "Material Symbols Rounded", "outlined": "Material Symbols Outlined", "sharp": "Material Symbols Sharp"} +FEATURES = "rlig,rclt" + + +def installed(): + """Return {style: ttf path} for every installed Material Symbols style.""" + out = subprocess.run(["fc-list", "--format", "%{file}|%{family}\n"], capture_output=True, text=True).stdout + found = {} + for line in out.splitlines(): + path, _, families = line.partition("|") + for style, family in FAMILY.items(): + if style in found: + continue + if family in families and path.strip().endswith(".ttf"): + found[style] = path.strip() + return found + + +def names_of(font): + """Icon name -> codepoint, read from the font's rlig/rclt ligature lookups.""" + cmap = {} + for table in font["cmap"].tables: + if table.isUnicode(): + cmap.update(table.cmap) + rev = {g: u for u, g in cmap.items()} + names = {} + for lookup in font["GSUB"].table.LookupList.Lookup: + for sub in lookup.SubTable: + root = sub.ExtSubTable if lookup.LookupType == 7 else sub + ligatures = getattr(root, "ligatures", None) + if not ligatures: + continue + for first, entries in ligatures.items(): + for lig in entries: + comps = (first,) + tuple(lig.Component) + if any(c not in rev for c in comps): + continue + code = rev.get(lig.LigGlyph) + if code is not None and code >= 0xE000: + names["".join(chr(rev[c]) for c in comps)] = code + return names, cmap, rev + + +def local_names(font): + """local() matches full font name (id 4) and PostScript name (id 6), not family.""" + name = font["name"] + + def pick(name_id): + for record in name.names: + if record.nameID == name_id and record.platformID in (1, 3): + return record.toUnicode() + return None + + return [n for n in (pick(4), pick(6), pick(1)) if n] + + +def axes_of(font): + if "fvar" not in font: + return {} + return {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in font["fvar"].axes} + + +# display order; any axis not listed still gets a slider, appended after these +AXIS_ORDER = ("FILL", "wght", "GRAD", "opsz") + + +def axis_sliders(axes): + """Return (slider markup, JS axis specs) driven by the font's own fvar table.""" + order = [t for t in AXIS_ORDER if t in axes] + [t for t in axes if t not in AXIS_ORDER] + html, js = [], {} + for tag in order: + lo, def_, hi = axes[tag] + integral = lo.is_integer() and hi.is_integer() and def_.is_integer() + # narrow-range axes (e.g. FILL 0..1) need fractional steps to be useful; + # wide axes stay at whole-number steps + step = 0.01 if hi - lo <= 2 else (1 if integral else 0.01) + sid = tag.lower() + html.append( + f'
{tag}' + f'
' + ) + js[sid] = {"tag": tag, "min": lo if integral else round(lo, 2), + "max": hi if integral else round(hi, 2), + "def": def_ if integral else round(def_, 2), + "step": step} + return "\n".join(html), js + + +def subset(font_path, glyphs, out_path): + tmp = tempfile.mkdtemp() + glyphs_file = os.path.join(tmp, "glyphs.txt") + open(glyphs_file, "w").write(",".join(glyphs)) + subprocess.run( + ["pyftsubset", font_path, f"--glyphs-file={glyphs_file}", + f"--layout-features={FEATURES}", "--flavor=woff2", f"--output-file={out_path}"], + check=True, + ) + return os.path.getsize(out_path) + + +def all_glyphs(font, rev): + keep = {g for g, u in rev.items() if chr(u).isalnum() or chr(u) == "_"} + for lookup in font["GSUB"].table.LookupList.Lookup: + for sub in lookup.SubTable: + root = sub.ExtSubTable if lookup.LookupType == 7 else sub + ligatures = getattr(root, "ligatures", None) + if not ligatures: + continue + for first, entries in ligatures.items(): + keep.add(first) + for lig in entries: + keep.add(lig.LigGlyph) + keep.update(lig.Component) + return sorted(keep) + + +TEMPLATE = """ + + + + +Material Symbols — {count} icons + + + + + +
+
+ + + +
+ + + +
+
+
+
+ + + +
+
+
+ + + + +""" + + +def build(styles, source="local", out_path="/tmp/opencode/symbols-preview.html"): + """source: local | url | embed + + local() is the default: with the fontconfig advertisement in place + (conf.d/90-material-symbols-variable.conf), Firefox treats the local() match + as variable and applies all four axes. url()/embed remain as fallbacks for + machines without that config. + """ + fonts = {} + for key in styles: + fonts[key] = ttLib.TTFont(installed()[key]) + + # name -> codepoint, from the first style; other styles checked for coverage + primary_key = styles[0] + primary_names = names_of(fonts[primary_key])[0] + per_style = {key: names_of(fonts[key])[0] for key in styles} + icons = sorted(primary_names.items()) + + outdir = os.path.dirname(os.path.abspath(out_path)) or "." + faces, style_js, written = [], {}, [] + for key in styles: + font = fonts[key] + locs = ", ".join(f'local("{n}")' for n in local_names(font)) + parts = [] + if source in ("url", "embed"): + cmap_data = names_of(font) + if source == "url": + fname = f"symbols-{key}.woff2" + dest = os.path.join(outdir, fname) + written.append((fname, subset(installed()[key], all_glyphs(font, cmap_data[2]), dest))) + # url() first: applies all axes without needing the fontconfig advertisement + parts.append(f'url("{fname}") format("woff2")') + parts.append(locs) + else: + tmp = os.path.join(tempfile.mkdtemp(), f"{key}.woff2") + size = subset(installed()[key], all_glyphs(font, cmap_data[2]), tmp) + data = base64.b64encode(open(tmp, "rb").read()).decode() + written.append((f"(embedded {key})", size)) + parts.append(f'url(data:font/woff2;base64,{data}) format("woff2")') + else: + parts.append(locs) + faces.append(f' @font-face {{ font-family: "Sym-{key.title()}"; src: {", ".join(parts)}; }}') + same = set(per_style[key]) == set(primary_names) + style_js[key] = {"family": f"Sym-{key.title()}", "names": None if same else sorted(per_style[key])} + axes = axes_of(fonts[primary_key]) + slider_html, axis_js = axis_sliders(axes) + + buttons = "".join( + f'' + for key in styles + ) + html = TEMPLATE.format( + font_faces="\n".join(faces), + count=len(icons), + style_list=", ".join(FAMILY[k] for k in styles), + style_buttons=buttons, + axis_sliders=slider_html, + default_style=primary_key, + icons=json.dumps([[n, f"{hex(c)}"] for n, c in icons]), + styles=json.dumps(style_js), + axes=json.dumps(axis_js), + ) + open(out_path, "w").write(html) + counts = {k: (len(icons) if v["names"] is None else len(v["names"])) for k, v in style_js.items()} + return out_path, len(html), counts, written + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("out", nargs="?", default="/tmp/opencode/symbols-preview.html") + ap.add_argument("--styles", default=",".join(STYLES)) + ap.add_argument("--source", choices=("local", "url", "embed"), default="local", + help="local = system font via local() (all axes work with the fontconfig " + "advertisement); url = sibling woff2 subsets (all axes work anywhere); " + "embed = self-contained but multi-MB page") + args = ap.parse_args() + want = [s for s in args.styles.split(",") if s in STYLES] + have = installed() + styles = [s for s in want if s in have] + if not styles: + raise SystemExit("no installed Material Symbols style found") + for s in want: + if s not in have: + print(f" skipping {s}: not installed") + path, size, counts, written = build(styles, source=args.source, out_path=args.out) + print(f"{path}: {size} bytes, source={args.source}") + print(" styles:", ", ".join(f"{k} ({v} icons)" for k, v in counts.items())) + for name, bytes_ in written: + print(f" wrote {name}: {bytes_} bytes") + for s in styles: + print(f" {FAMILY[s]}: {installed()[s]}") + + +if __name__ == "__main__": + main() diff --git a/shell.qml b/shell.qml index 7458221..519ddc5 100644 --- a/shell.qml +++ b/shell.qml @@ -3,8 +3,10 @@ //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts +import QtQuick import Quickshell import Quickshell.Services.UPower +import ZShell import qs.Modules import qs.Modules.Lock import qs.Drawers @@ -18,6 +20,18 @@ ShellRoot { settings.watchFiles: true + // AlignAuditor { + // id: alignAudit + // + // outputPath: "/tmp/align-audit.txt" + // delayMs: 10000 + // + // onFinished: count => console.log("FINISHED SCAN:", count) + // + // Component.onCompleted: if (Quickshell.env("ZSHELL_ALIGN")) + // alignAudit.runLater() + // } + Drawers {} Lock {