From 9cbb90f9e13683c92c753d164e2c54457b6aa66a Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 29 Oct 2026 17:34:57 +0100 Subject: [PATCH 01/15] feat: initial commit for pixel-snapping components and helpers --- Components/CustomProgressBar.qml | 7 +- Components/CustomSlider.qml | 12 +- Components/IconButton.qml | 5 +- Components/IconTextButton.qml | 4 +- Components/LineGraph.qml | 155 ++++--- Components/Menu.qml | 8 +- Components/SplitButton.qml | 3 +- Components/Toast/ToastItem.qml | 7 +- Drawers/Panels.qml | 14 +- Greeter/Center.qml | 9 +- Greeter/Content.qml | 5 +- Greeter/InputField.qml | 1 + Greeter/SessionDock.qml | 14 +- Greeter/UserDock.qml | 14 +- Helpers/Picker/AnnotationToolbar.qml | 2 +- Modules/Bar/Bar.qml | 63 ++- Modules/Bar/BarLoader.qml | 1 + Modules/Bar/Components/Clock.qml | 6 +- Modules/Bar/Components/MediaWidget.qml | 1 + Modules/Bar/Components/NetworkWidget.qml | 3 +- Modules/Bar/Components/Resource.qml | 1 + Modules/Bar/Components/Resources.qml | 1 + Modules/Bar/Components/StatusIcons.qml | 15 +- .../Components/StatusIcons/AudioWidget.qml | 1 + .../Bar/Components/StatusIcons/MicWidget.qml | 1 + .../Components/StatusIcons/UPowerWidget.qml | 1 + Modules/Bar/Components/Tray/TrayItem.qml | 5 +- Modules/Bar/Components/TrayIcons.qml | 9 +- Modules/Bar/Components/UpdatesWidget.qml | 1 + Modules/Bar/Components/WindowTitle.qml | 2 +- Modules/Bar/Popouts/AudioPopout.qml | 25 +- Modules/Bar/Popouts/NetworkPopout.qml | 4 +- Modules/Bar/Popouts/TrayMenuPopout.qml | 4 +- Modules/Bar/Popouts/UPowerPopout.qml | 3 +- Modules/Bar/Popouts/UpdatesPopout.qml | 8 +- Modules/Clipboard/Content.qml | 5 +- Modules/Dashboard/Content.qml | 9 +- Modules/Dashboard/Dash.qml | 132 +++--- Modules/Dashboard/Dash/Calendar.qml | 6 +- Modules/Dashboard/Dash/Resources.qml | 20 +- Modules/Dashboard/Media.qml | 14 +- Modules/Dashboard/Media/Details.qml | 332 +++++++------- Modules/Dashboard/Media/LyricList.qml | 7 +- Modules/Dashboard/Media/LyricsAndSelector.qml | 6 +- Modules/Dashboard/Media/LyricsInfo.qml | 4 +- .../Dashboard/Processes/ProcessesHeader.qml | 1 + Modules/Dashboard/Processes/ProcessesList.qml | 3 +- Modules/Dashboard/Processes/SystemInfo.qml | 10 +- Modules/Dashboard/Tabs.qml | 89 ++-- Modules/Dashboard/Weather/Graphs.qml | 18 +- Modules/Dashboard/WeatherTab.qml | 21 +- .../DesktopIcons/BackgroundContextMenu.qml | 12 +- .../DesktopIcons/DesktopIconContextMenu.qml | 20 +- Modules/Dock/Parts/DockAppButton.qml | 9 +- Modules/Dock/Parts/DockSeparator.qml | 1 + Modules/Launcher/AppList.qml | 4 +- Modules/Launcher/CarouselView.qml | 5 +- Modules/Launcher/Content.qml | 5 +- Modules/Launcher/ContentList.qml | 9 +- Modules/Launcher/Items/ActionItem.qml | 2 +- Modules/Launcher/Items/AppItem.qml | 60 ++- Modules/Launcher/Items/CalcItem.qml | 3 +- Modules/Lock/Center.qml | 13 +- Modules/Lock/Content.qml | 14 +- Modules/Lock/InputField.qml | 1 + Modules/Lock/Media.qml | 4 +- Modules/Lock/NotifDock.qml | 10 +- Modules/Lock/NotifGroup.qml | 23 +- Modules/Lock/Resources.qml | 4 +- Modules/Lock/Weather/BriefInfo.qml | 6 +- Modules/Lock/Weather/Forecast.qml | 6 +- Modules/Osd/Content.qml | 30 +- Modules/Osd/Wrapper.qml | 10 +- Modules/Polkit/Polkit.qml | 19 +- Modules/Resources/Cards/BatteryTank.qml | 6 +- Modules/Resources/Cards/HeroCard.qml | 32 +- Modules/Resources/Cards/MemoryCard.qml | 41 +- Modules/Resources/Cards/NetworkCard.qml | 23 +- Modules/Resources/Cards/StorageCard.qml | 19 +- Modules/Resources/Cards/VramCard.qml | 89 ---- Modules/Resources/Content.qml | 52 ++- Modules/Settings/Common/AudioDeviceList.qml | 3 +- Modules/Settings/Common/DialogRowButton.qml | 6 +- Modules/Settings/Common/InfoRow.qml | 6 +- Modules/Settings/Common/ItemList.qml | 4 +- Modules/Settings/Common/ListEditor.qml | 3 +- Modules/Settings/Common/PageBase.qml | 6 +- Modules/Settings/Common/PopupRow.qml | 6 +- Modules/Settings/Common/RowButton.qml | 3 +- Modules/Settings/Common/SearchPopup.qml | 4 +- Modules/Settings/Common/SectionHeader.qml | 2 +- Modules/Settings/Common/SelectRow.qml | 6 +- Modules/Settings/Common/SliderRow.qml | 8 +- Modules/Settings/Common/SpinRow.qml | 6 +- Modules/Settings/Common/TextFieldRow.qml | 6 +- Modules/Settings/Common/TimeDialogRow.qml | 6 +- Modules/Settings/Common/TimeDialogToggle.qml | 1 + Modules/Settings/Common/TimeInput.qml | 7 +- Modules/Settings/Common/ToggleRow.qml | 1 + Modules/Settings/Common/WallItem.qml | 4 +- Modules/Settings/Common/WallpaperCropper.qml | 2 +- Modules/Settings/NavPane.qml | 4 +- Modules/Settings/NavPane/NavLocations.qml | 17 +- Modules/Settings/PageCompRegistry.qml | 4 +- Modules/Settings/Pages/AboutPage.qml | 7 +- Modules/Settings/Pages/Apps/AllApps.qml | 9 +- Modules/Settings/Pages/Apps/AppInfo.qml | 11 +- Modules/Settings/Pages/AppsPage.qml | 9 +- Modules/Settings/Pages/Audio/AppVolumes.qml | 4 +- Modules/Settings/Pages/AudioPage.qml | 9 +- .../Settings/Pages/Panels/Bar/BarClock.qml | 4 +- .../Pages/Panels/Bar/BarStatusIcons.qml | 4 +- Modules/Settings/Pages/Panels/Bar/BarTray.qml | 4 +- Modules/Settings/Pages/Panels/BarPanel.qml | 4 +- .../Settings/Pages/Panels/DashboardPanel.qml | 4 +- .../Settings/Pages/Panels/LauncherPanel.qml | 4 +- .../Settings/Pages/Panels/ResourcesPanel.qml | 4 +- .../Pages/Panels/Sidebar/SidebarLlm.qml | 4 +- .../Settings/Pages/Panels/SidebarPanel.qml | 4 +- Modules/Settings/Pages/PanelsPage.qml | 4 +- Modules/Settings/Pages/ScreenshotPage.qml | 3 +- .../Pages/Services/NotificationsPage.qml | 4 +- Modules/Settings/Pages/ServicesPage.qml | 4 +- .../Settings/Pages/Wallpaper/ColorsFonts.qml | 4 +- .../Pages/Wallpaper/WallpaperSelect.qml | 7 +- Modules/Settings/Pages/WallpaperPage.qml | 3 +- Modules/Sidebar/Chat/ChatContent.qml | 2 +- Modules/Sidebar/Chat/ChatList.qml | 8 +- .../Sidebar/Chat/Content/ApprovalPrompt.qml | 15 +- .../Sidebar/Chat/Content/CodeBlockView.qml | 5 +- .../Sidebar/Chat/Content/ContentBubble.qml | 1 + .../Sidebar/Chat/Content/EmptyBackground.qml | 4 +- Modules/Sidebar/Chat/Content/ErrorBubble.qml | 4 +- .../Sidebar/Chat/Content/ModelsContainer.qml | 11 +- Modules/Sidebar/Chat/Content/ProcessBlock.qml | 4 +- Modules/Sidebar/Chat/SidebarView.qml | 1 + Modules/Sidebar/Content.qml | 15 +- Modules/Sidebar/Notif.qml | 10 +- Modules/Sidebar/NotifActionList.qml | 173 ++++--- Modules/Sidebar/NotifDock.qml | 12 +- Modules/Sidebar/NotifGroup.qml | 38 +- Modules/Sidebar/NotifGroupList.qml | 11 +- Modules/Sidebar/Tabs.qml | 5 +- Modules/Sidebar/Utils/Cards/Record.qml | 16 +- Modules/Sidebar/Utils/Cards/RecordingList.qml | 13 +- Modules/Sidebar/Utils/Cards/Toggles.qml | 3 +- Modules/Sidebar/Utils/Content.qml | 12 +- Modules/Sidebar/Utils/IdleInhibit.qml | 19 +- Plugins/CMakeLists.txt | 3 + Plugins/ZShell/CMakeLists.txt | 20 +- Plugins/ZShell/Components/CMakeLists.txt | 5 + Plugins/ZShell/Components/snaplayout.cpp | 419 +++++++++++++++++ Plugins/ZShell/Components/snaplayout.hpp | 129 ++++++ Plugins/ZShell/Config/font.hpp | 12 +- Plugins/ZShell/Config/launcher.hpp | 10 - Plugins/ZShell/Config/tokens.hpp | 10 + Plugins/ZShell/alignaudit.cpp | 206 +++++++++ Plugins/ZShell/alignaudit.hpp | 19 + Plugins/ZShell/alignauditor.cpp | 26 ++ Plugins/ZShell/alignauditor.hpp | 52 +++ Plugins/ZShell/zutils.cpp | 424 ++++++++++++++++++ Plugins/ZShell/zutils.hpp | 38 ++ shell.qml | 14 + 163 files changed, 2671 insertions(+), 951 deletions(-) delete mode 100644 Modules/Resources/Cards/VramCard.qml create mode 100644 Plugins/ZShell/Components/snaplayout.cpp create mode 100644 Plugins/ZShell/Components/snaplayout.hpp create mode 100644 Plugins/ZShell/alignaudit.cpp create mode 100644 Plugins/ZShell/alignaudit.hpp create mode 100644 Plugins/ZShell/alignauditor.cpp create mode 100644 Plugins/ZShell/alignauditor.hpp diff --git a/Components/CustomProgressBar.qml b/Components/CustomProgressBar.qml index c9666f5..1fddd73 100644 --- a/Components/CustomProgressBar.qml +++ b/Components/CustomProgressBar.qml @@ -99,6 +99,7 @@ ProgressBar { } Line { + id: dot property real implicitSize anchors.right: parent.right @@ -124,8 +125,7 @@ ProgressBar { anchors.left: parent.left Behavior on implicitWidth { - Anim { - } + Anim {} } Component.onCompleted: implicitWidth = Qt.binding(() => parent.width * root.visualPosition) @@ -222,8 +222,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/LineGraph.qml b/Components/LineGraph.qml index c16a1a6..b8f32fa 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,62 @@ 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); + } + 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 +119,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 +147,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 +173,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 +199,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 +222,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); @@ -279,11 +295,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 +314,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 +322,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 +334,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 +348,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 +361,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 +371,7 @@ Item { continue; } const pt = { - x: xAt(i), + x: snapV(xAt(i)), y: yAt(series[i]) }; if (!current) @@ -369,8 +388,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 +399,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 +406,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 +413,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 +453,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 6a3db28..1cdb710 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 @@ -111,12 +112,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 @@ -153,11 +156,12 @@ MouseArea { } } - RowLayout { + SnapLayout { id: menuOptionRow anchors.fill: parent anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small MaterialIcon { 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/Drawers/Panels.qml b/Drawers/Panels.qml index 46fcda3..0807b23 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 @@ -74,7 +75,10 @@ Item { anchors.right: parent.right anchors.rightMargin: sidebar.width * (1 - sidebar.offsetScale) - anchors.verticalCenter: parent.verticalCenter + + y: ZUtils.snap((parent.height - implicitHeight) / 2, root) + + // anchors.verticalCenter: parent.verticalCenter clip: sidebar.visible implicitHeight: osd.implicitHeight implicitWidth: osd.implicitWidth * (1 - osd.offsetScale) @@ -82,8 +86,7 @@ Item { Osd.Wrapper { id: osd - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.fill: parent screen: root.screen screenState: root.screenState sidebarOrSessionVisible: sidebar.visible @@ -129,8 +132,9 @@ Item { 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 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 856b391..e19aba4 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,11 +4,12 @@ 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 -GridLayout { +SnapLayout { id: root required property bool fullscreen @@ -74,9 +75,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 @@ -229,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 e47f198..c5557ed 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 real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property ScreenState screenState @@ -67,7 +68,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 7d2297f..47c6d24 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 b22d9e7..f39a03d 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 32bd1f2..f8979e8 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 b0c58af..ac131ba 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 @@ -51,17 +52,15 @@ WidgetBase { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } - GridLayout { + SnapLayout { id: grid anchors.bottomMargin: root.horizontal ? 0 : Tokens.padding.medium @@ -69,7 +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 + orientation: root.horizontal ? Qt.Horizontal : Qt.Vertical + spacing: root.horizontal ? Tokens.spacing.extraSmall : -2 Repeater { model: ScriptModel { @@ -111,8 +111,7 @@ WidgetBase { delegate: EntryWrapper { name: "upower" - UPowerWidget { - } + UPowerWidget {} } } } diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index a6fab2a..8235a14 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 cb61bee..093f45e 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 fae91a7..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,8 +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 + 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/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..058970c 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 @@ -47,7 +48,8 @@ StackView { duration: 0 } } - component SubMenu: ColumnLayout { + component SubMenu: SnapLayout { +orientation: Qt.Vertical id: menu required property QsMenuHandle handle 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/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 8fd5c62..be9d497 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 @@ -110,7 +111,7 @@ Item { Repeater { model: lineRow.modelData.text.split("\t").slice(1) - RowLayout { + SnapLayout { height: lineText.height width: lineText.height + Tokens.spacing.extraSmall @@ -224,7 +225,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..96e7a8f 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 { +orientation: Qt.Vertical id: layout 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: ZUtils.snap((root.height - layout.anchors.margins * 2 - layout.spacing * 4) / 5, root) 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..c82939f 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 @@ -141,7 +142,8 @@ Item { } ] - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical id: layout anchors.centerIn: parent 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..dc0d58b 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 { @@ -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 { 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 1d8e734..7986939 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 ClipRect { @@ -14,15 +15,18 @@ ClipRect { implicitWidth: layout.implicitWidth radius: Tokens.rounding.largeIncreased - 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,9 +54,11 @@ ClipRect { lock: root.lock } - ColumnLayout { + SnapLayout { Layout.fillWidth: true + spacing: Tokens.spacing.medium + orientation: Qt.Vertical CustomRect { Layout.fillHeight: true 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 0d08f40..4d8de5e 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -43,14 +43,16 @@ ClipRect { } } - 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 af8ed98..0af1316 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -4,20 +4,23 @@ import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config import qs.Modules import qs.Components import qs.Helpers -import ZShell.Config import qs.Daemons import qs.Services -ColumnLayout { +SnapLayout { id: root required property var lock anchors.fill: parent anchors.margins: Tokens.padding.large + + orientation: Qt.Vertical spacing: Tokens.spacing.small CustomText { @@ -46,8 +49,9 @@ ColumnLayout { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { spacing: Tokens.spacing.largeIncreased + orientation: Qt.Vertical Image { asynchronous: true diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 12eeab1..27a79e9 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -5,10 +5,11 @@ import Quickshell.Widgets import Quickshell.Services.Notifications import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config import qs.Modules import qs.Components import qs.Helpers -import ZShell.Config import qs.Daemons import qs.Services @@ -36,13 +37,14 @@ CustomRect { } } - RowLayout { + SnapLayout { id: content anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.right: parent.right anchors.top: parent.top + spacing: Tokens.spacing.medium Item { @@ -118,15 +120,18 @@ CustomRect { } } - ColumnLayout { + SnapLayout { 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 { + spacing: Math.round(Tokens.spacing.small / 2) + orientation: Qt.Vertical + + SnapLayout { Layout.bottomMargin: -parent.spacing Layout.fillWidth: true + spacing: Tokens.spacing.small CustomText { @@ -167,10 +172,11 @@ CustomRect { } } - RowLayout { + SnapLayout { id: expandBtn anchors.centerIn: parent + spacing: Tokens.spacing.small / 2 CustomText { @@ -261,7 +267,10 @@ CustomRect { Behavior on opacity { Anim {} } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { + orientation: Qt.Vertical + spacing: Tokens.spacing.extraSmall + Repeater { model: ScriptModel { values: root.notifs.slice(Config.notifs.groupPreviewNum) 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 cd92521..eaa4e10 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 dd8cf82..c6476ab 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 @@ -49,7 +50,8 @@ CustomRect { orientation: VerticalFadeListView.Horizontal spacing: Tokens.spacing.medium - delegate: ColumnLayout { + delegate: SnapLayout { +orientation: Qt.Vertical id: hour readonly property var cond: modelData 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..9779edc 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 { @@ -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..cd3255e 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,8 +17,8 @@ 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 ClipRect { @@ -43,7 +45,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 +64,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 +86,17 @@ CustomRect { } } - ColumnLayout { + SnapLayout { id: detailsRow anchors.bottom: parent.bottom anchors.left: parent.left anchors.margins: Tokens.padding.largeIncreased - spacing: Tokens.spacing.extraSmall - RowLayout { + spacing: Tokens.spacing.extraSmall + orientation: Qt.Vertical + + SnapLayout { Layout.leftMargin: -Tokens.padding.extraSmall spacing: Tokens.spacing.extraSmall @@ -108,32 +114,34 @@ CustomRect { CustomProgressBar { fgColor: root.accent - implicitHeight: Tokens.padding.extraSmall + implicitHeight: Tokens.padding.small indeterminate: isNaN(root.usage) || isNaN(root.temperature) value: root.temperature / 100 } } - 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..46b37d6 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,17 +52,17 @@ 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 { +orientation: Qt.Vertical id: usageColumn anchors.centerIn: parent @@ -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..5f443ef 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 { @@ -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/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..976cfd1 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -2,12 +2,14 @@ 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 { +orientation: Qt.Vertical id: root property bool animateScroll: false @@ -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..07c8acb 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -import qs.Components import ZShell.Config +import qs.Components import qs.Services CustomText { 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 84d15e5..3825ab0 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 966f6ee..345e8f5 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -156,7 +156,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..48e4e50 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,7 +17,8 @@ PageBase { isSubPage: true title: qsTr("All apps") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 @@ -45,7 +47,7 @@ PageBase { } } - RowLayout { + SnapLayout { id: appRow anchors.fill: parent @@ -60,7 +62,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/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..b7b7cfb 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -1,4 +1,5 @@ import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Modules.Settings.Common @@ -7,7 +8,8 @@ PageBase { title: qsTr("Panels") - ColumnLayout { + SnapLayout { +orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top spacing: Tokens.spacing.extraSmall / 2 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 a4d4906..52bc8e5 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 @@ -75,7 +77,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 f3fcb6e..a5c2445 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -15,7 +15,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 ae9549b..abb6c36 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 6458e0c..71ac860 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 @@ -160,7 +160,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 index ae7a016..ebc56b9 100644 --- a/Modules/Sidebar/Notif.qml +++ b/Modules/Sidebar/Notif.qml @@ -7,6 +7,7 @@ import ZShell.Config import qs.Components import qs.Services import qs.Daemons +import ZShell.Components CustomRect { id: root @@ -26,8 +27,7 @@ CustomRect { state: expanded ? "expanded" : "" Behavior on implicitHeight { - Anim { - } + Anim {} } states: State { name: "expanded" @@ -116,14 +116,14 @@ CustomRect { anchors.topMargin: Tokens.spacing.extraSmall shouldBeActive: root.expanded - sourceComponent: ExpandedBody { - } + sourceComponent: ExpandedBody {} } - component ExpandedBody: ColumnLayout { + component ExpandedBody: SnapLayout { readonly property alias body: bodyText spacing: Tokens.spacing.medium + orientation: Qt.Vertical CustomText { id: bodyText diff --git a/Modules/Sidebar/NotifActionList.qml b/Modules/Sidebar/NotifActionList.qml index 1bd33b5..9797e26 100644 --- a/Modules/Sidebar/NotifActionList.qml +++ b/Modules/Sidebar/NotifActionList.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import Quickshell import Quickshell.Widgets import ZShell.Config +import ZShell.Components import qs.Components import qs.Effects import qs.Services @@ -16,115 +17,107 @@ Item { required property NotifServer.Notif notif Layout.fillWidth: true - implicitHeight: flickable.contentHeight + implicitHeight: actionList.implicitHeight - CustomFlickable { - id: flickable + SnapLayout { + id: actionList anchors.fill: parent - contentHeight: actionList.implicitHeight - contentWidth: Math.max(width, actionList.implicitWidth) + spacing: Tokens.spacing.extraSmall - RowLayout { - id: actionList + Repeater { + model: [ + { + isClose: true + }, + ...(root.notif?.actions ?? []), + { + isCopy: true + } + ] - anchors.fill: parent - spacing: Tokens.spacing.extraSmall + CustomRect { + id: action - Repeater { - model: [ - { - isClose: true - }, - ...(root.notif?.actions ?? []), - { - isCopy: true + 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 + } + } - CustomRect { - id: action + Timer { + id: copyTimer - required property var modelData + interval: 3000 - 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 + onTriggered: actionInner.item.text = "content_copy" + } - Behavior on Layout.preferredWidth { - Anim { - type: Anim.FastSpatial + 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.invoke) { + action.modelData.invoke(); + } else if (!root.notif.resident) { + root.notif.close(); } } - Behavior on radius { - Anim { - type: Anim.FastSpatial - } + } + + 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" } + } - Timer { - id: copyTimer + Component { + id: iconComp - interval: 3000 - - onTriggered: actionInner.item.text = "content_copy" + IconImage { + asynchronous: true + source: Quickshell.iconPath(action.modelData.identifier) } + } - StateLayer { - id: actionStateLayer + Component { + id: textComp - 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.invoke) { - 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 - } + CustomText { + color: Colors.palette.m3onSurfaceVariant + text: action.modelData.text } } } diff --git a/Modules/Sidebar/NotifDock.qml b/Modules/Sidebar/NotifDock.qml index e9b9227..7ae85d1 100644 --- a/Modules/Sidebar/NotifDock.qml +++ b/Modules/Sidebar/NotifDock.qml @@ -1,13 +1,14 @@ pragma ComponentBehavior: Bound -import qs.Components -import ZShell.Config -import qs.Modules -import qs.Daemons import Quickshell import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Modules +import qs.Daemons import qs.Services Item { @@ -80,8 +81,9 @@ Item { Behavior on opacity { Anim {} } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { spacing: 20 + orientation: Qt.Vertical CustomText { Layout.alignment: Qt.AlignHCenter diff --git a/Modules/Sidebar/NotifGroup.qml b/Modules/Sidebar/NotifGroup.qml index 4cb7b67..98e6cfb 100644 --- a/Modules/Sidebar/NotifGroup.qml +++ b/Modules/Sidebar/NotifGroup.qml @@ -9,6 +9,7 @@ import qs.Components import qs.Services import qs.Helpers import qs.Daemons +import ZShell.Components CustomRect { id: root @@ -52,8 +53,7 @@ CustomRect { radius: Tokens.rounding.large Behavior on implicitHeight { - Anim { - } + Anim {} } Component.onDestruction: { @@ -61,13 +61,16 @@ CustomRect { screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } - RowLayout { + SnapLayout { id: content anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.right: parent.right anchors.top: parent.top + + objectName: "notif-content" + spacing: Tokens.spacing.medium Item { @@ -148,24 +151,29 @@ CustomRect { } } - Column { + SnapLayout { id: column Layout.fillWidth: true + spacing: root.expanded ? Math.round(Tokens.spacing.extraSmall) : 0 + orientation: Qt.Vertical + + objectName: "notif-column" Behavior on spacing { - Anim { - } + Anim {} } - RowLayout { + SnapLayout { id: header - anchors.left: parent.left - anchors.right: parent.right + Layout.fillWidth: true + spacing: Tokens.spacing.small + objectName: "notif-header" + CustomText { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant @@ -191,12 +199,14 @@ CustomRect { onClicked: root.toggleExpand(!root.expanded) } - RowLayout { + SnapLayout { id: expandBtn anchors.centerIn: parent spacing: Tokens.spacing.extraSmall + objectName: "notif-expand" + CustomText { id: groupCount @@ -214,12 +224,10 @@ CustomRect { text: "expand_more" Behavior on Layout.topMargin { - Anim { - } + Anim {} } Behavior on rotation { - Anim { - } + Anim {} } } } @@ -229,6 +237,8 @@ CustomRect { NotifGroupList { id: notifList + Layout.fillWidth: true + container: root.container expanded: root.expanded notifs: root.notifs diff --git a/Modules/Sidebar/NotifGroupList.qml b/Modules/Sidebar/NotifGroupList.qml index b047878..9da9aab 100644 --- a/Modules/Sidebar/NotifGroupList.qml +++ b/Modules/Sidebar/NotifGroupList.qml @@ -18,8 +18,6 @@ LazyListView { signal requestToggleExpand(expand: bool) - anchors.left: parent.left - anchors.right: parent.right asynchronous: true cacheBuffer: 400 implicitHeight: contentHeight @@ -59,18 +57,15 @@ LazyListView { } } Behavior on scale { - Anim { - } + Anim {} } Behavior on x { - Anim { - } + Anim {} } Behavior on y { enabled: notif.LazyListView.ready - Anim { - } + Anim {} } Component.onCompleted: modelData?.lock(this) 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 43f93e5..d543c69 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -7,10 +7,13 @@ find_package(Qt6 REQUIRED COMPONENTS Quick CanvasPainter QuickPrivate + QuickLayouts + QuickLayoutsPrivate Concurrent Sql Network DBus + Svg CanvasPainter WaylandClient WaylandClientPrivate diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 7d1bbaf..0585b5b 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..af2766f 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,14 @@ qml_module(ZShell-components sparklinesceneitem.hpp sparklinesceneitem.cpp roundedmask.hpp roundedmask.cpp roundedclip.hpp roundedclip.cpp + snaplayout.hpp snaplayout.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..44798a2 --- /dev/null +++ b/Plugins/ZShell/Components/snaplayout.cpp @@ -0,0 +1,419 @@ +#include "snaplayout.hpp" + +#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; +} + +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, + SnapLayout* layout) + : QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment) + , m_layout(layout) {} + +void SnapGridLayoutItem::setGeometry(const QRectF& rect) { + QQuickLayoutAttached* info = attachedLayoutObject(m_item, false); + QRectF placed = info ? rect.marginsRemoved(info->effectiveQMargins()) + : rect; + + const QQuickWindow* win = m_item->window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; + + if (dpr > 0.0) { + const QPointF origin = absoluteOrigin(m_layout); + const qreal left = (origin.x() + placed.x()) * dpr; + const qreal top = (origin.y() + placed.y()) * dpr; + const qreal right = (origin.x() + placed.right()) * dpr; + const qreal bottom = (origin.y() + placed.bottom()) * dpr; + + const qint64 nearX = std::llround(left); + const qint64 nearY = std::llround(top); + const qint64 farX = std::llround(right); + const qint64 farY = std::llround(bottom); + + placed = QRectF( + qreal(nearX) / dpr - origin.x(), + qreal(nearY) / dpr - origin.y(), + qreal(farX - nearX) / dpr, + qreal(farY - nearY) / dpr); + } + + const QSizeF oldSize(m_item->width(), m_item->height()); + const QSizeF newSize = placed.size(); + + m_item->setPosition(placed.topLeft()); + if (newSize == oldSize) { + if (QQuickLayout* lay = qobject_cast(m_item)) { + if (lay->invalidatedArrangement()) lay->rearrange(newSize); + } + } else { + m_item->setSize(newSize); + } +} + +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(); + + 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 == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) + invalidate(); +} + +QSizeF SnapLayout::sizeHint(Qt::SizeHint whichSizeHint) const { + Q_D(const SnapLayout); + return d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo); +} + +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); + return d->engine.spacing(d->orientation, d->styleInfo); +} + +void SnapLayout::setSpacing(qreal space) { + Q_D(SnapLayout); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wfloat-equal" + if (std::isnan(space) || spacing() == space) return; +#pragma GCC diagnostic pop + d->engine.setSpacing(space, Qt::Horizontal | Qt::Vertical); + 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; + + 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, this); + + 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); + 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(oldGeometry); + if (!isReady() || !change.positionChange()) return; + + Q_D(SnapLayout); + const QQuickWindow* win = window(); + const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0; + if (dpr <= 0.0) return; + + const QPointF origin = absoluteOrigin(this); + if (originResidual(origin.x(), dpr) == d->originResidualX && + originResidual(origin.y(), dpr) == d->originResidualY) + return; + polish(); +} + +void SnapLayout::updateAncestorListeners() { + Q_D(SnapLayout); + 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, QQuickItemPrivate::Geometry); + } + for (QQuickItem* ancestor : std::as_const(current)) { + if (!d->ancestorListeners.contains(ancestor)) + QQuickItemPrivate::get(ancestor)->addItemChangeListener( + this, QQuickItemPrivate::Geometry); + } + 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, QQuickItemPrivate::Geometry); + } + d->ancestorListeners.clear(); +} + +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); + d->engine.setGeometries(QRectF(QPointF(0, 0), size), d->styleInfo); + 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..150ae02 --- /dev/null +++ b/Plugins/ZShell/Components/snaplayout.hpp @@ -0,0 +1,129 @@ +#pragma once + +#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 SnapGridLayoutItem : public QQuickGridLayoutItem { + public: + SnapGridLayoutItem( + QQuickItem* item, + int row, + int column, + int rowSpan, + int columnSpan, + Qt::Alignment alignment, + SnapLayout* layout); + + void setGeometry(const QRectF& rect) override; + + private: + SnapLayout* m_layout; +}; + +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 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 updateAncestorListeners(); + void clearAncestorListeners(); +}; + +class SnapLayoutPrivate : public QQuickLayoutPrivate { + Q_DECLARE_PUBLIC(SnapLayout) + + public: + SnapLayoutPrivate() = default; + + void mirrorChange() override; + + QQuickGridLayoutEngine engine; + SnapLayoutStyleInfo* styleInfo = nullptr; + Qt::Orientation orientation = Qt::Horizontal; + Qt::LayoutDirection layoutDirection = Qt::LeftToRight; + bool rearranging = false; + int recurRearrangeCounter = 0; + QList invalidateAfterRearrange; + + QVector> ancestorListeners; + qint64 originResidualX = -1; + qint64 originResidualY = -1; +}; + +} // namespace ZShell::components 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/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/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/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 { -- 2.47.3 From 5896256372e15faa7053fbad8c5a7ba91cb4ed6a Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 24 Oct 2026 23:28:17 +0200 Subject: [PATCH 02/15] feat: initial video wallpaper implementation --- Helpers/Wallpapers.qml | 27 +- Modules/Launcher/Items/WallpaperTile.qml | 64 +- Modules/Settings/Common/WallItem.qml | 30 +- Modules/Settings/Common/WallpaperCropper.qml | 44 +- .../Pages/Wallpaper/WallpaperSelect.qml | 3 +- Plugins/CMakeLists.txt | 1 + Plugins/ZShell/Internal/CMakeLists.txt | 1 + Plugins/ZShell/Internal/wallpaperservice.cpp | 694 ++++++++++++++++-- Plugins/ZShell/Internal/wallpaperservice.hpp | 64 +- Plugins/ZShell/Internal/wallpapersurface.cpp | 47 +- Plugins/ZShell/Internal/wallpapersurface.hpp | 17 +- Plugins/ZShell/Models/filesystemmodel.cpp | 239 +++++- Plugins/ZShell/Models/filesystemmodel.hpp | 12 +- cli/src/zshell/subcommands/scheme.py | 125 ++++ 14 files changed, 1280 insertions(+), 88 deletions(-) diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index c335feb..bb59c3f 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -18,6 +18,8 @@ Searcher { property string actualCurrent: WallpaperPath.currentWallpaperPath property alias crops: adapter.monitorCrops readonly property string current: showPreview ? previewPath : actualCurrent + readonly property string currentThumbnail: thumbnailFor(current) + readonly property bool currentIsVideo: isVideoPath(current) property alias monitorCrops: monitorCrops property bool pendingPreviewClear property bool previewColorLock @@ -32,6 +34,29 @@ Searcher { return category; } + readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"] + + function isVideoPath(path: string): bool { + if (!path) + return false; + const dot = path.lastIndexOf("."); + if (dot < 0) + return false; + return videoExts.includes(path.slice(dot + 1).toLowerCase()); + } + + function thumbnailFor(path: string): string { + if (!isVideoPath(path)) + return path; + + for (const entry of root.list) { + if (entry.path === path && entry.thumbnailPath) + return entry.thumbnailPath; + } + + return path; + } + function getCrop(screen: string): var { return root.crops[screen]; } @@ -125,7 +150,7 @@ Searcher { FileSystemModel { id: wallpapers - filter: FileSystemModel.Images + filter: FileSystemModel.Media path: Config.general.wallpaperPath recursive: true } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 1162fee..e2d1473 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import Quickshell import QtQuick import ZShell.Config @@ -9,7 +11,7 @@ Item { id: root required property bool isCurrent - required property var modelData // FileSystemEntry-like {path, relativePath} + required property var modelData required property real tileHeight required property real tileWidth required property ScreenState screenState @@ -29,25 +31,75 @@ Item { radius: Tokens.rounding.small y: Tokens.padding.large - MaterialIcon { + Loader { + active: opacity > 0 anchors.centerIn: parent - color: Colors.tPalette.m3outline - fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() - text: "image" + opacity: img.status === Image.Ready ? 0 : 1 + + Behavior on opacity { + Anim {} + } + sourceComponent: CustomRect { + color: Colors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + radius: Tokens.rounding.full + + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(image.width, image.height) * 0.3 + } + } } CachingImage { + id: img anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top cache: true fillMode: Image.PreserveAspectCrop height: root.tileHeight - path: root.modelData ? root.modelData.path : "" + path: root.modelData ? root.modelData.thumbnailPath : "" smooth: root.isCurrent sourceSize.height: root.tileHeight sourceSize.width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9) + + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } StateLayer { diff --git a/Modules/Settings/Common/WallItem.qml b/Modules/Settings/Common/WallItem.qml index 3825ab0..fbdf4bf 100644 --- a/Modules/Settings/Common/WallItem.qml +++ b/Modules/Settings/Common/WallItem.qml @@ -41,8 +41,7 @@ orientation: Qt.Vertical opacity: img.status === Image.Ready ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primaryContainer @@ -66,18 +65,43 @@ orientation: Qt.Vertical anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectCrop - opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true sourceSize: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return Qt.size(width * dpr, height * dpr); } + opacity: status === Image.Ready ? 1 : 0 Behavior on opacity { Anim { type: Anim.SlowEffects } } + + Loader { + opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 345e8f5..eb116b6 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,7 +13,9 @@ import qs.Services Item { id: wrapper - property int buttonRowHeight: Tokens.padding.large * 3 + readonly property bool isVideo: Wallpapers.currentIsVideo + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 readonly property bool changesMade: { cropRevision; @@ -25,9 +27,9 @@ Item { return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; } + + property int buttonRowHeight: Tokens.padding.large * 3 property int cropRevision: 0 - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null - readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -127,7 +129,7 @@ Item { anchors.rightMargin: Tokens.padding.extraLarge * 2 anchors.top: parent.top - active: wrapper.screens.length > 1 + active: wrapper.screens.length > 1 && !wrapper.isVideo sourceComponent: ButtonRow { id: screenSelector @@ -163,6 +165,7 @@ Item { anchors.left: parent.left anchors.right: parent.right implicitHeight: 30 + enabled: !wrapper.isVideo CustomSlider { id: zoomSlider @@ -274,7 +277,7 @@ Item { opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true smooth: true - source: Wallpapers.current + source: Wallpapers.currentThumbnail sourceSize.height: { const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; return imageSlot.height * dpr; @@ -285,12 +288,37 @@ Item { type: Anim.SlowEffects } } + + Loader { + opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0 + active: opacity > 0 + anchors.centerIn: parent + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + + sourceComponent: CustomRect { + implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2 + implicitHeight: implicitWidth + radius: Tokens.rounding.full + color: Colors.tPalette.m3surface + + MaterialIcon { + id: videoIcon + anchors.centerIn: parent + text: "video_library" + fontStyle: Tokens.font.icon.extraLarge + } + } + } } Loader { id: cropRectLoader - active: scaledImg.status === Image.Ready + active: scaledImg.status === Image.Ready && !wrapper.isVideo sourceComponent: Component { CustomRect { @@ -333,7 +361,6 @@ Item { y = t.y; } - // Where the rect should be according to saved data (same rules as a restore). function savedTarget() { const data = Wallpapers.getCrop(wrapper.currentScreen.name); const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); @@ -436,9 +463,10 @@ Item { } anchors.fill: parent - cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor hoverEnabled: true preventStealing: true + enabled: !wrapper.isVideo onPositionChanged: mouse => { if (pressed) { diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 52bc8e5..9e3df47 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -52,11 +52,12 @@ orientation: Qt.Vertical } WallItem { + id: wallItem required property FileSystemEntry modelData enabled: modelData opacity: modelData ? 1 : 0 - source: String(modelData?.path ?? "") + source: String(modelData?.thumbnailPath ?? "") onClicked: { Wallpapers.setWallpaper(modelData.path); diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index d543c69..0ba38d7 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -23,6 +23,7 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) +pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index b2c039a..76ad6a1 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -39,4 +39,5 @@ qml_module(ZShell-internal PkgConfig::EGL PkgConfig::WAYLAND_EGL PkgConfig::GL + PkgConfig::MPV ) diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index f8c9c10..9875d4d 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -7,15 +7,26 @@ #include #include +#include +#include + +#include + #include #include #include #include +#include #include #include #include +#include +#include +#include +#include + #include #include @@ -29,7 +40,6 @@ namespace { const char* kVertexShader = R"( #version 300 es void main() { - // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); } @@ -41,16 +51,36 @@ precision highp float; uniform sampler2D source; uniform vec2 size; uniform vec4 crop; +uniform float alpha; out vec4 outColor; void main() { - // The pixels are stored top-down, so the fragment coordinate is flipped. vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; - outColor = texture(source, crop.xy + p * crop.zw); + vec4 c = texture(source, crop.xy + p * crop.zw); + outColor = vec4(c.rgb, c.a * alpha); } )"; constexpr int kWaitTimeoutMs = 20; +constexpr int kFadeDurationMs = 400; + +const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + void logShader(uint32_t object, bool program) { int ok = 0; if (program) @@ -94,6 +124,16 @@ WallpaperService::~WallpaperService() { if (m_thread.joinable()) m_thread.join(); + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + for (auto* surface : m_surfaces) delete surface; m_surfaces.clear(); @@ -102,6 +142,8 @@ WallpaperService::~WallpaperService() { if (eglMakeCurrent( m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { if (m_texture) glDeleteTextures(1, &m_texture); + if (m_previousTexture) glDeleteTextures(1, &m_previousTexture); + if (m_blackTex) glDeleteTextures(1, &m_blackTex); if (m_program) glDeleteProgram(m_program); } @@ -127,22 +169,210 @@ QString WallpaperService::path() const { } void WallpaperService::setPath(const QString& path) { - if (m_path == path) return; + if (path.isEmpty() || path == this->path()) return; + + if (isVideoPath(path) && needsTranscode(path)) { + const QString dst = transcodeTarget(path); + + if (QFile::exists(dst)) { + applyPath(dst); + return; + } + + applyPath(path); + transcodeVideo(path, dst); + return; + } + + applyPath(path); +} + +void WallpaperService::applyPath(const QString& path) { + const bool video = isVideoPath(path); { std::lock_guard lock(m_mutex); m_path = path; + const bool wasVideo = m_isVideo && m_mpv != nullptr; + const bool wasImage = !m_image.isNull() && m_texture != 0; + + m_previousVideo = wasVideo && !video; + + if (wasImage) { + m_previousImage = m_image; + m_previousCrops = m_crops; + m_previousTexture = 0; + } else { + m_previousImage = QImage(); + } + + m_isVideo = video; + + m_fading = true; + m_fadeClockStarted = false; + m_forceDecode = true; m_imageChanged = true; + m_videoReload = true; } emit pathChanged(); start(); + queueAll(); } +bool WallpaperService::isHardwareCodec(const QString& codec) { + static const QStringList hardware{ + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", + }; + + return hardware.contains(codec); +} + +QString WallpaperService::codecName(const QString& path) const { + QProcess probe; + probe.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + + if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { + qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit" + << probe.exitCode() << ")"; + return {}; + } + + const QString codec = + QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); + qWarning() << "WallpaperService: codec of" << path << "is" + << (codec.isEmpty() ? "" : codec); + return codec; +} + +QString WallpaperService::cacheDir() const { + return QDir::homePath() + "/.cache/zshell/wallpaper-videos"; +} + +QString WallpaperService::transcodeTarget(const QString& path) const { + const QFileInfo info(path); + + const QByteArray key = + path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch()); + + const QString hash = QString::fromUtf8( + QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex()); + + return cacheDir() + '/' + hash + ".av1.mkv"; +} + +bool WallpaperService::needsTranscode(const QString& path) const { + if (path.startsWith(cacheDir())) return false; + + const QString codec = codecName(path); + if (codec.isEmpty()) return false; + + return !isHardwareCodec(codec); +} + +void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { + QDir().mkpath(cacheDir()); + + if (m_transcode) { + m_transcode->kill(); + m_transcode->deleteLater(); + m_transcode = nullptr; + } + + m_transcode = new QProcess(this); + auto* proc = m_transcode; + + connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { + qWarning().noquote() + << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); + }); + + connect( + proc, + &QProcess::finished, + this, + [this, proc, dst](int code, QProcess::ExitStatus status) { + const bool ok = code == 0 && status == QProcess::NormalExit && + QFile::exists(dst); + + qWarning() << "WallpaperService: transcode finished, exit" << code + << "status" << int(status) << "exists" + << QFile::exists(dst) << "->" << dst; + + if (proc == m_transcode) m_transcode = nullptr; + proc->deleteLater(); + + if (ok) applyPath(dst); + }); + + qWarning() << "WallpaperService: transcoding" << src << "->" << dst; + + proc->setProgram("nice"); + proc->setArguments({ + "-n", + "19", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-threads", + "2", + "-i", + src, + "-map", + "0:v:0", + "-vf", + "scale=in_range=pc:out_range=tv:out_color_matrix=bt709", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-c:v", + "libsvtav1", + "-preset", + "6", + "-crf", + "20", + "-pix_fmt", + "yuv420p10le", + "-an", + dst, + }); + + proc->start(); +} + void WallpaperService::addScreen(const QString& screen) { { std::lock_guard lock(m_mutex); @@ -212,6 +442,178 @@ void WallpaperService::redraw() { queueAll(); } +bool WallpaperService::isVideoPath(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +void WallpaperService::startVideo(const QString& path) { + if (path.isEmpty()) return; + + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); + + if (!m_mpv) { + std::setlocale(LC_NUMERIC, "C"); + + m_mpv = mpv_create(); + if (!m_mpv) { + qWarning() << "WallpaperService: could not create mpv handle"; + return; + } + + mpv_set_option_string(m_mpv, "config", "no"); + mpv_set_option_string(m_mpv, "vo", "libmpv"); + mpv_set_option_string(m_mpv, "audio", "no"); + mpv_set_option_string(m_mpv, "loop", "inf"); + mpv_set_option_string(m_mpv, "hwdec", "auto-copy"); + mpv_set_option_string(m_mpv, "video-sync", "display"); + mpv_set_option_string(m_mpv, "framedrop", "decoder"); + mpv_set_option_string(m_mpv, "background-color", "#00000000"); + + if (mpv_initialize(m_mpv) < 0) { + qWarning() << "WallpaperService: mpv_initialize failed"; + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + return; + } + } + + const QByteArray local = path.toUtf8(); + + initMpvRender(); + + const char* loadCmd[]{"loadfile", local.constData(), nullptr}; + if (mpv_command(m_mpv, loadCmd) < 0) + qWarning() << "WallpaperService: mpv could not load" << path; + + m_mpvFrameReady.store(true, std::memory_order_relaxed); +} + +void WallpaperService::pumpMpvEvents() { + if (!m_mpv) return; + + mpv_event* event = nullptr; + while ((event = mpv_wait_event(m_mpv, 0)) != nullptr && + event->event_id != MPV_EVENT_NONE) { + if (event->event_id == MPV_EVENT_LOG_MESSAGE) { + auto* data = static_cast(event->data); + if (data && data->text) + qWarning().noquote() << "mpv:" << data->prefix << data->text; + } + + if (event->event_id == MPV_EVENT_FILE_LOADED) { + m_videoLoaded.store(true, std::memory_order_relaxed); + } + + if (event->event_id == MPV_EVENT_END_FILE) { + m_videoLoaded.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + + auto* data = static_cast(event->data); + if (data && data->error != MPV_END_FILE_REASON_EOF) + qWarning() << "WallpaperService: mpv ended the file, error" + << data->error; + } + } +} + +void WallpaperService::stopVideo() { + teardownMpv(); +} + +void WallpaperService::teardownMpv() { + if (m_mpvRender) { + mpv_render_context_free(m_mpvRender); + m_mpvRender = nullptr; + } + + if (m_mpv) { + mpv_terminate_destroy(m_mpv); + m_mpv = nullptr; + } + + m_mpvFrameReady.store(false, std::memory_order_relaxed); + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); +} + +void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) { + (void)ctx; + + if (strcmp(name, "glFenceSync") == 0) + return reinterpret_cast( + +[](GLenum, GLbitfield) -> GLsync { return nullptr; }); + + return reinterpret_cast(eglGetProcAddress(name)); +} + +void WallpaperService::mpvUpdate(void* ctx) { + auto* self = static_cast(ctx); + + self->m_mpvFrameReady.store(true, std::memory_order_relaxed); + + if (self->m_videoLoaded.load(std::memory_order_relaxed)) + self->m_videoReady.store(true, std::memory_order_relaxed); + + self->wake(); +} + +void WallpaperService::initMpvRender() { + if (m_mpvRender || !m_mpv) return; + + mpv_opengl_init_params gl_init{getMpvProcAddress, nullptr}; + + mpv_render_param params[]{ + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) { + qWarning() << "WallpaperService: could not create mpv render context"; + m_mpvRender = nullptr; + return; + } + + mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this); +} + +void WallpaperService::renderMpvToSurface(const QSize& size) { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + glViewport(0, 0, size.width(), size.height()); + + mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA}; + + int flip = 1; + int block = 0; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(m_mpvRender, params); +} + +void WallpaperService::createBlackTexture(uint32_t& texture) { + if (texture != 0) return; + + const unsigned char pixel[4] = {0, 0, 0, 255}; + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel); +} + void WallpaperService::start() { { std::lock_guard lock(m_mutex); @@ -260,11 +662,7 @@ void WallpaperService::wake() { } void WallpaperService::queue(WallpaperSurface* surface) { - { - std::lock_guard lock(m_mutex); - m_pending.push_back(surface); - } - + surface->markDirty(); wake(); } @@ -273,7 +671,7 @@ void WallpaperService::queueAll() { std::lock_guard lock(m_mutex); for (auto* surface : m_surfaces) - m_pending.push_back(surface); + surface->markDirty(); } wake(); @@ -323,13 +721,50 @@ void WallpaperService::present() { ensureDecodeSize(); - std::vector pending; + QList surfaces; + bool fading = false; + bool videoReload = false; + QString path; + bool isVideo = false; + { std::lock_guard lock(m_mutex); - pending.swap(m_pending); + + if (m_videoReload) { + m_videoReload = false; + videoReload = true; + path = m_path; + isVideo = m_isVideo; + } + + fading = m_fading; + const bool newVideoFrame = + m_isVideo && + m_mpvFrameReady.exchange(false, std::memory_order_relaxed); + + if (fading || newVideoFrame) + for (auto* surface : m_surfaces) + surface->markDirty(); + + surfaces = m_surfaces; } - for (auto* surface : pending) + if (m_context == EGL_NO_CONTEXT) return; + + eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context); + + if (videoReload) { + if (isVideo) + startVideo(path); + else if (m_mpv && !m_previousVideo) + teardownMpv(); + } + + const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr; + + if (videoActive) pumpMpvEvents(); + + for (auto* surface : surfaces) drawSurface(surface); } @@ -494,7 +929,8 @@ uint32_t WallpaperService::createProgram() { logShader(program, true); if (glGetUniformLocation(program, "size") < 0 || - glGetUniformLocation(program, "crop") < 0) { + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0) { qWarning() << "WallpaperService: the program does not have the " "uniforms it needs"; glDeleteProgram(program); @@ -503,22 +939,23 @@ uint32_t WallpaperService::createProgram() { m_sizeUniform = glGetUniformLocation(program, "size"); m_cropUniform = glGetUniformLocation(program, "crop"); + m_alphaUniform = glGetUniformLocation(program, "alpha"); glDeleteShader(vertex); glDeleteShader(fragment); return program; } -void WallpaperService::uploadImage() { - if (m_image.isNull()) return; +void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { + if (image.isNull()) return; - if (m_texture == 0) { - glGenTextures(1, &m_texture); - glBindTexture(GL_TEXTURE_2D, m_texture); + if (texture == 0) { + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); } else { - glBindTexture(GL_TEXTURE_2D, m_texture); + glBindTexture(GL_TEXTURE_2D, texture); } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); @@ -526,12 +963,35 @@ void WallpaperService::uploadImage() { GL_TEXTURE_2D, 0, GL_RGBA8, - m_image.width(), - m_image.height(), + image.width(), + image.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, - m_image.constBits()); + image.constBits()); +} + +void WallpaperService::uploadImage() { + uploadTexture(m_texture, m_image); +} + +void WallpaperService::drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha) { + glBindTexture(GL_TEXTURE_2D, texture); + glUniform2f(GLint(m_sizeUniform), sizeW, sizeH); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_alphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); } bool WallpaperService::decode(const QString& path) { @@ -564,6 +1024,8 @@ bool WallpaperService::ensureDecodeSize() { { std::lock_guard lock(m_mutex); + if (m_isVideo) return false; + largest = largestBufferSize(); if (largest.isEmpty()) return false; @@ -611,7 +1073,10 @@ QSize WallpaperService::largestBufferSize() { } QRectF WallpaperService::sourceRect( - const QVariantMap& crops, const QString& screen, const QSize& size) const { + const QVariantMap& crops, + const QString& screen, + const QSize& size, + const QSize& image) const { QVariantMap crop = cropFor(crops, screen); QRectF rect( @@ -620,10 +1085,10 @@ QRectF WallpaperService::sourceRect( crop.value("width", 1.0).toDouble(), crop.value("height", 1.0).toDouble()); - if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; + if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect; - qreal imageWidth = qreal(m_image.width()); - qreal imageHeight = qreal(m_image.height()); + qreal imageWidth = qreal(image.width()); + qreal imageHeight = qreal(image.height()); QRectF pixels( rect.x() * imageWidth, @@ -652,6 +1117,8 @@ QRectF WallpaperService::sourceRect( } void WallpaperService::drawSurface(WallpaperSurface* surface) { + if (!surface->needsDraw() || !surface->frameReady()) return; + EGLSurface target = surface->eglSurface(); if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || surface->bufferSize().isEmpty()) @@ -662,9 +1129,8 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { return; } - // Release the context when this scope ends (see FrameService::drawSurface). - // Holding it across the draw loop's sleep serializes the two raw-EGL clients on - // NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts. + eglSwapInterval(m_eglDisplay, 0); + struct ContextRelease { EGLDisplay display; ~ContextRelease() { @@ -675,7 +1141,6 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { QVariantMap crops; QSize size; - bool upload = false; { std::lock_guard lock(m_mutex); @@ -683,38 +1148,153 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { crops = m_crops; size = surface->bufferSize(); - upload = m_imageChanged; - m_imageChanged = false; + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_program == 0) return; + + const bool videoNow = m_isVideo; + + if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; + + const bool videoAsBackground = + (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr; + + if (videoAsBackground) { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + glDisable(GL_BLEND); + + glViewport(0, 0, size.width(), size.height()); + renderMpvToSurface(size); + } else { + glUseProgram(m_program); + glViewport(0, 0, size.width(), size.height()); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + } + + if (!videoNow) { + const bool upload = m_imageChanged; + m_imageChanged = false; + + if (m_texture == 0 || upload) uploadImage(); + + if (m_texture == 0 || m_image.isNull()) return; + } + + glUseProgram(m_program); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + if (m_fading && !m_fadeClockStarted) { + m_fadeClockStarted = true; + m_fadeTimer.start(); + } + + bool fade = false; + bool fadeDone = false; + qreal progress = 1.0; + + if (m_fading) { + progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs); + if (progress >= 1.0) + fadeDone = true; + else + fade = true; + } + + const float w = float(size.width()); + const float h = float(size.height()); + + if (videoNow) { + if (fade && !m_previousImage.isNull()) { + if (m_previousTexture == 0) + uploadTexture(m_previousTexture, m_previousImage); + + if (m_previousTexture != 0) { + const QRectF previousRect = sourceRect( + m_previousCrops, + surface->screen(), + size, + m_previousImage.size()); + + drawTexture( + m_previousTexture, + previousRect, + w, + h, + 1.0f - float(progress)); + } + } else if (fade && !m_firstFadeDone) { + if (m_blackTex == 0) createBlackTexture(m_blackTex); + + if (m_blackTex != 0) + drawTexture( + m_blackTex, + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + 1.0f - float(progress)); + } + } else { + const QRectF rect = + sourceRect(crops, surface->screen(), size, m_image.size()); + + if (fade) { + if (!m_previousVideo && !m_previousImage.isNull() && + m_previousTexture == 0) { + uploadTexture(m_previousTexture, m_previousImage); + } + + if (!m_previousVideo && m_previousTexture != 0) { + const QRectF previousRect = sourceRect( + m_previousCrops, + surface->screen(), + size, + m_previousImage.size()); + + drawTexture(m_previousTexture, previousRect, w, h, 1.0f); + } + + drawTexture(m_texture, rect, w, h, float(progress)); + } else { + drawTexture(m_texture, rect, w, h, 1.0f); + } + } + + if (fadeDone) { + m_fading = false; + m_firstFadeDone = true; + + if (m_previousVideo) { + teardownMpv(); + m_previousVideo = false; + } + + if (m_previousTexture != 0) { + glDeleteTextures(1, &m_previousTexture); + m_previousTexture = 0; + } + + m_previousImage = QImage(); + } } - if (size.isEmpty()) return; + surface->requestFrame(); - if (m_program == 0) m_program = createProgram(); - if (m_texture == 0 || upload) uploadImage(); - - if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; - - glUseProgram(m_program); - glBindTexture(GL_TEXTURE_2D, m_texture); - - glUniform2f( - GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); - - QRectF rect = sourceRect(crops, surface->screen(), size); - glUniform4f( - GLint(m_cropUniform), - GLfloat(rect.x()), - GLfloat(rect.y()), - GLfloat(rect.width()), - GLfloat(rect.height())); - - glViewport(0, 0, size.width(), size.height()); - glDrawArrays(GL_TRIANGLES, 0, 3); - - if (!eglSwapBuffers(m_eglDisplay, target)) + if (!eglSwapBuffers(m_eglDisplay, target)) { qWarning() << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" << QString::number(unsigned(eglGetError()), 16); + return; + } + + if ((m_isVideo || m_previousVideo) && m_mpvRender) + mpv_render_context_report_swap(m_mpvRender); + + surface->endDraw(); } void WallpaperService::addSurface(const QString& screen) { diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp index a68d84d..d37b5b7 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -16,12 +17,18 @@ #include #include #include +#include #include #include #include "wallpapersurface.hpp" +struct mpv_handle; +struct mpv_render_context; + +class QProcess; + namespace ZShell::internal { class WallpaperSurface; @@ -57,6 +64,29 @@ class WallpaperService : public QObject { private: friend class WallpaperSurface; + static bool isVideoPath(const QString& path); + + static bool isHardwareCodec(const QString& codec); + + [[nodiscard]] QString codecName(const QString& path) const; + [[nodiscard]] QString cacheDir() const; + [[nodiscard]] QString transcodeTarget(const QString& path) const; + [[nodiscard]] bool needsTranscode(const QString& path) const; + void transcodeVideo(const QString& src, const QString& dst); + + void applyPath(const QString& path); + + void startVideo(const QString& path); + void stopVideo(); + void teardownMpv(); + + void initMpvRender(); + void renderMpvToSurface(const QSize& size); + void pumpMpvEvents(); + + static void* getMpvProcAddress(void* ctx, const char* name); + static void mpvUpdate(void* ctx); + void bindGlobals(); static void registryGlobal( void* data, @@ -70,7 +100,16 @@ class WallpaperService : public QObject { [[nodiscard]] bool setupGl(); [[nodiscard]] uint32_t createProgram(); + void uploadTexture(uint32_t& texture, const QImage& image); void uploadImage(); + void createBlackTexture(uint32_t& texture); + + void drawTexture( + uint32_t texture, + const QRectF& rect, + float sizeW, + float sizeH, + float alpha); bool decode(const QString& path); @@ -95,7 +134,8 @@ class WallpaperService : public QObject { [[nodiscard]] QRectF sourceRect( const QVariantMap& crops, const QString& screen, - const QSize& size) const; + const QSize& size, + const QSize& image) const; void start(); @@ -108,6 +148,18 @@ class WallpaperService : public QObject { QImage m_image; + bool m_isVideo = false; + bool m_videoReload = false; + bool m_previousVideo = false; + mpv_handle* m_mpv = nullptr; + mpv_render_context* m_mpvRender = nullptr; + uint32_t m_blackTex = 0; + std::atomic m_mpvFrameReady{false}; + std::atomic m_videoReady{false}; + std::atomic m_videoLoaded{false}; + + QProcess* m_transcode = nullptr; + QSize m_sourceSize; QSize m_decodeSize; @@ -125,12 +177,20 @@ class WallpaperService : public QObject { uint32_t m_program = 0; int m_sizeUniform = -1; int m_cropUniform = -1; + int m_alphaUniform = -1; + + QImage m_previousImage; + QVariantMap m_previousCrops; + uint32_t m_previousTexture = 0; + bool m_fading = false; + bool m_fadeClockStarted = false; + bool m_firstFadeDone = false; + QElapsedTimer m_fadeTimer; QList m_surfaces; std::thread m_thread; mutable std::recursive_mutex m_mutex; - std::vector m_pending; wl_event_queue* m_queue = nullptr; int m_wake[2] = {-1, -1}; diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp index 8fe99e6..44c4297 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -53,6 +53,11 @@ WallpaperSurface::WallpaperSurface( } WallpaperSurface::~WallpaperSurface() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + if (m_eglSurface != EGL_NO_SURFACE) eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); @@ -68,8 +73,45 @@ WallpaperSurface::~WallpaperSurface() { if (m_surface) wl_surface_destroy(m_surface); } -void WallpaperSurface::redraw() { - m_service->queue(this); +void WallpaperSurface::markDirty() { + m_dirty.store(true, std::memory_order_relaxed); +} + +bool WallpaperSurface::needsDraw() const { + return m_dirty.load(std::memory_order_relaxed); +} + +bool WallpaperSurface::frameReady() const { + return m_frameReady.load(std::memory_order_relaxed); +} + +void WallpaperSurface::requestFrame() { + if (!m_surface || m_frameCallback) return; + + m_frameCallback = wl_surface_frame(m_surface); + + static const struct wl_callback_listener listener = { + [](void* data, struct wl_callback*, uint32_t) { + static_cast(data)->onFrameDone(); + }, + }; + + wl_callback_add_listener(m_frameCallback, &listener, this); +} + +void WallpaperSurface::onFrameDone() { + if (m_frameCallback) { + wl_callback_destroy(m_frameCallback); + m_frameCallback = nullptr; + } + + m_frameReady.store(true, std::memory_order_relaxed); + m_service->wake(); +} + +void WallpaperSurface::endDraw() { + m_dirty.store(false, std::memory_order_relaxed); + m_frameReady.store(false, std::memory_order_relaxed); } void WallpaperSurface::zwlr_layer_surface_v1_configure( @@ -141,6 +183,7 @@ void WallpaperSurface::updateBuffer() { if (QtWayland::wp_viewport::isInitialized()) set_destination(m_size.width(), m_size.height()); + requestFrame(); m_service->queue(this); } diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Internal/wallpapersurface.hpp index d534c99..bada4ee 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.hpp +++ b/Plugins/ZShell/Internal/wallpapersurface.hpp @@ -3,6 +3,8 @@ #include #include +#include + #include #include @@ -32,7 +34,14 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } - void redraw(); + void markDirty(); + [[nodiscard]] bool needsDraw() const; + + [[nodiscard]] bool frameReady() const; + + void requestFrame(); + + void endDraw(); private: void zwlr_layer_surface_v1_configure( @@ -42,6 +51,8 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, void updateBuffer(); + void onFrameDone(); + WallpaperService* m_service; QString m_screen; wl_surface* m_surface; @@ -53,6 +64,10 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, qreal m_scale = 1.0; bool configured = false; + + std::atomic m_dirty{false}; + std::atomic m_frameReady{true}; + struct wl_callback* m_frameCallback = nullptr; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp index 02f18f0..c7d7bad 100644 --- a/Plugins/ZShell/Models/filesystemmodel.cpp +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -1,17 +1,183 @@ #include "filesystemmodel.hpp" +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include +#include + namespace ZShell::models { +const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +bool hasVideoExtension(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +QString videoThumbnailPath(const QString& path) { + const QFileInfo info(path); + + QString key = QStringLiteral("%1_%2_%3") + .arg(info.baseName()) + .arg(info.size()) + .arg(info.lastModified().toMSecsSinceEpoch() / 1000); + key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_"); + + return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" + + key + "_preview.jpg"; +} + +bool generateVideoThumbnail(const QString& src, const QString& cachePath) { + if (QFile::exists(cachePath)) { + return true; + } + + const QString scale = QStringLiteral( + "scale=1920:1080:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2"); + + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + process.waitForFinished(15000); + return qMakePair(process.exitCode(), process.readAllStandardOutput()); + }; + + double duration = 0.0; + const auto probe = runProcess( + "ffprobe", + { + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + src, + }); + if (probe.first == 0) { + duration = QString::fromUtf8(probe.second).trimmed().toDouble(); + } + + const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) + : 8; + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return false; + } + + int bestIndex = -1; + double bestChroma = -1.0; + double bestLuma = -1.0; + + for (int i = 0; i < samples; ++i) { + const double frac = duration > 0 ? (i + 0.5) / samples : 0.0; + const QString seek = duration > 0 + ? QString::number(frac * duration, 'f', 3) + : QStringLiteral("0"); + + const QString framePath = + tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i)); + + runProcess( + "ffmpeg", + { + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + src, + "-frames:v", + "1", + "-vf", + scale, + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + framePath, + }); + + QImage frame(framePath); + if (frame.isNull()) { + continue; + } + + frame = frame.convertToFormat(QImage::Format_RGB888); + + double chroma = 0.0; + double luma = 0.0; + int count = 0; + for (int y = 0; y < frame.height(); ++y) { + const auto* line = frame.constScanLine(y); + for (int x = 0; x < frame.width(); ++x) { + const int r = line[x * 3]; + const int g = line[x * 3 + 1]; + const int b = line[x * 3 + 2]; + chroma += std::max({r, g, b}) - std::min({r, g, b}); + luma += r + g + b; + ++count; + } + } + + const int n = std::max(1, count); + chroma /= n; + luma /= 3.0 * n; + + if (chroma > bestChroma + 0.5 || + (chroma > bestChroma - 0.5 && luma > bestLuma)) { + bestChroma = chroma; + bestLuma = luma; + bestIndex = i; + } + } + + if (bestIndex < 0) { + return false; + } + + QDir().mkpath(QFileInfo(cachePath).absolutePath()); + return QFile::copy( + tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)), + cachePath); +} + FileSystemEntry::FileSystemEntry( const QString& path, const QString& relativePath, QObject* parent) : QObject(parent) , m_fileInfo(path) , m_path(path) , m_relativePath(relativePath) + , m_thumbnailPath(path) , m_isImageInitialised(false) , m_mimeTypeInitialised(false) {} @@ -56,6 +222,23 @@ bool FileSystemEntry::isImage() const { return m_isImage; } +bool FileSystemEntry::isVideo() const { + return hasVideoExtension(m_path); +} + +QString FileSystemEntry::thumbnailPath() const { + return m_thumbnailPath; +} + +void FileSystemEntry::setThumbnailPath(const QString& path) { + if (m_thumbnailPath == path) { + return; + } + + m_thumbnailPath = path; + emit thumbnailPathChanged(); +} + QString FileSystemEntry::mimeType() const { if (!m_mimeTypeInitialised) { const QMimeDatabase db; @@ -318,11 +501,20 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { std::optional iter; - if (filter == Images) { + if (filter == Images || filter == Videos || filter == Media) { QStringList extraNameFilters = nameFilters; - const auto formats = QImageReader::supportedImageFormats(); - for (const auto& format : formats) { - extraNameFilters << "*." + format; + + if (filter == Images || filter == Media) { + const auto formats = QImageReader::supportedImageFormats(); + for (const auto& format : formats) { + extraNameFilters << "*." + format; + } + } + + if (filter == Videos || filter == Media) { + for (const auto& ext : kVideoExtensions) { + extraNameFilters << "*." + ext; + } } QDir::Filters filters = QDir::Files; @@ -366,6 +558,13 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { if (!reader.canRead()) { continue; } + } else if (filter == Media) { + if (!hasVideoExtension(path)) { + QImageReader reader(path); + if (!reader.canRead()) { + continue; + } + } } newPaths.insert(path); @@ -409,6 +608,30 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } +void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) { + const QPointer guard(entry); + const QString source = entry->path(); + const QString cachePath = videoThumbnailPath(source); + + const auto future = QtConcurrent::run([source, cachePath]() { + return generateVideoThumbnail(source, cachePath); + }); + + const auto watcher = new QFutureWatcher(this); + connect( + watcher, + &QFutureWatcher::finished, + this, + [guard, cachePath, watcher]() { + if (guard && watcher->result()) { + guard->setThumbnailPath(cachePath); + } + watcher->deleteLater(); + }); + watcher->setFuture(future); + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) +} + void FileSystemModel::applyChanges( const QSet& removedPaths, const QSet& addedPaths) { QList removedIndices; @@ -450,8 +673,12 @@ void FileSystemModel::applyChanges( // Create new entries QList newEntries; for (const auto& path : addedPaths) { - newEntries << new FileSystemEntry( - path, m_dir.relativeFilePath(path), this); + auto* entry = + new FileSystemEntry(path, m_dir.relativeFilePath(path), this); + if (entry->isVideo()) { + startThumbnailGeneration(entry); + } + newEntries << entry; } std::sort( newEntries.begin(), diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp index 94bb29a..b55bb47 100644 --- a/Plugins/ZShell/Models/filesystemmodel.hpp +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -28,6 +28,9 @@ class FileSystemEntry : public QObject { Q_PROPERTY(qint64 size READ size CONSTANT) Q_PROPERTY(bool isDir READ isDir CONSTANT) Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(bool isVideo READ isVideo CONSTANT) + Q_PROPERTY( + QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged) Q_PROPERTY(QString mimeType READ mimeType CONSTANT) public: @@ -45,18 +48,24 @@ class FileSystemEntry : public QObject { [[nodiscard]] qint64 size() const; [[nodiscard]] bool isDir() const; [[nodiscard]] bool isImage() const; + [[nodiscard]] bool isVideo() const; + [[nodiscard]] QString thumbnailPath() const; [[nodiscard]] QString mimeType() const; + void setThumbnailPath(const QString& path); + void updateRelativePath(const QDir& dir); signals: void relativePathChanged(); + void thumbnailPathChanged(); private: const QFileInfo m_fileInfo; const QString m_path; QString m_relativePath; + QString m_thumbnailPath; mutable bool m_isImage; mutable bool m_isImageInitialised; @@ -91,7 +100,7 @@ class FileSystemModel : public QAbstractListModel { NOTIFY entriesChanged) public: - enum Filter { NoFilter, Images, Files, Dirs }; + enum Filter { NoFilter, Images, Videos, Media, Files, Dirs }; Q_ENUM(Filter) explicit FileSystemModel(QObject* parent = nullptr); @@ -156,6 +165,7 @@ class FileSystemModel : public QAbstractListModel { void updateEntriesForDir(const QString& dir); void applyChanges( const QSet& removedPaths, const QSet& addedPaths); + void startThumbnailGeneration(FileSystemEntry* entry); [[nodiscard]] bool compareEntries( const FileSystemEntry* a, const FileSystemEntry* b) const; }; diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index f0c8e72..adc36c2 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -5,6 +5,7 @@ import re import shutil import subprocess import sys +import tempfile from pathlib import Path from typing import Any @@ -31,6 +32,23 @@ from zshell.utils.schemepalettes import ( app = typer.Typer() +VIDEO_EXTENSIONS = { + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".wmv", + ".flv", + ".m4v", + ".mpg", + ".mpeg", + ".3gp", + ".ogv", + ".ts", + ".vob", +} + def _complete_scheme_name(incomplete): schemes = [ @@ -351,6 +369,109 @@ def generate( safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) return thumb_dir / f"{safe_key}_thumbnail.jpg" + def is_video(path: Path) -> bool: + return path.suffix.lower() in VIDEO_EXTENSIONS + + def video_duration(path: Path) -> float: + try: + out = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + str(path), + ], + capture_output=True, + text=True, + check=False, + ) + return float(out.stdout.strip()) + except (ValueError, OSError): + return 0.0 + + def video_frame_chroma(path: Path) -> tuple[float, float]: + """Return (mean chroma, mean luminance) of a frame. + + Mean chroma (mean of max-min across RGB channels) is ~0 for black, + white and grey frames, and rises with saturation - so it identifies a + "good", colorful frame and rejects black or fade frames. + """ + with Image.open(path) as im: + im = im.convert("RGB") + pixels = list(im.getdata()) + + chroma = 0 + luma = 0 + for r, g, b in pixels: + chroma += max(r, g, b) - min(r, g, b) + luma += r + g + b + + n = max(1, len(pixels)) + return chroma / n, luma / (3 * n) + + def generate_video_thumbnail( + video_path: Path, cache_path: Path, size=(128, 128) + ) -> None: + w, h = size + duration = video_duration(video_path) + + samples = 8 if duration <= 0 else min(10, max(4, int(duration))) + + tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-")) + try: + best: Path | None = None + best_key: tuple[float, float] = (-1.0, -1.0) + + for i in range(samples): + frac = (i + 0.5) / samples if duration > 0 else 0.0 + seek = f"{frac * duration:.3f}" if duration > 0 else "0" + frame = tmp_dir / f"frame_{i}.jpg" + + subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + str(video_path), + "-frames:v", + "1", + "-vf", + f"scale={w}:{h}", + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + str(frame), + ], + check=False, + ) + + if not frame.exists(): + continue + + key = video_frame_chroma(frame) + if key > best_key: + best_key = key + best = frame + + if best is None: + raise RuntimeError( + f"Could not extract a frame from {video_path}" + ) + + shutil.copy(best, cache_path) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + def generate_thumbnail( image_path: Path, thumb_dir: Path, size=(128, 128) ) -> Path: @@ -360,6 +481,10 @@ def generate( if cache_path.exists(): return cache_path + if is_video(image_path): + generate_video_thumbnail(image_path, cache_path, size) + return cache_path + image = Image.open(image_path) image.draft("RGB", size) image = image.convert("RGB") -- 2.47.3 From c0744b29b68ed94f501ae8164bbef40eb4360fb0 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 25 Oct 2026 01:58:45 +0200 Subject: [PATCH 03/15] chore: suppress more warnings --- cli/src/zshell/subcommands/shell.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/zshell/subcommands/shell.py b/cli/src/zshell/subcommands/shell.py index 30bd175..f55311a 100644 --- a/cli/src/zshell/subcommands/shell.py +++ b/cli/src/zshell/subcommands/shell.py @@ -15,7 +15,8 @@ qt_logging_rules = ( "scene=false;" "quickshell.service.sni.watcher=false;" "default.warning=false;" - "default.info=false" + "default.info=false;" + "qt.sql.qsqldatabase" ) -- 2.47.3 From 76f5a23c2a272492109a889ddb467aa07f633357 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 26 Oct 2026 02:51:28 +0100 Subject: [PATCH 04/15] feat: skip two-way copy for cuda devices when decoding video wallpapers + cache wallpapers as blobs in new database --- Helpers/Wallpapers.qml | 27 +- Modules/Launcher/Items/WallpaperTile.qml | 6 +- .../Pages/Wallpaper/WallpaperSelect.qml | 2 +- Plugins/CMakeLists.txt | 6 + Plugins/ZShell/Internal/CMakeLists.txt | 18 + .../ZShell/Internal/shaders/wallpaper.frag | 11 + .../ZShell/Internal/shaders/wallpaper.vert | 4 + .../Internal/shaders/wallpaper_video.frag | 105 +++ Plugins/ZShell/Internal/videoplayer.cpp | 529 +++++++++++++ Plugins/ZShell/Internal/videoplayer.hpp | 87 ++ Plugins/ZShell/Internal/wallpaperservice.cpp | 656 +++++++++++---- Plugins/ZShell/Internal/wallpaperservice.hpp | 74 +- Plugins/ZShell/Internal/wallpapersurface.cpp | 1 + Plugins/ZShell/Models/CMakeLists.txt | 5 + Plugins/ZShell/Models/filesystemmodel.cpp | 243 ++---- Plugins/ZShell/Models/filesystemmodel.hpp | 22 +- Plugins/ZShell/Models/wallpaperdb.cpp | 746 ++++++++++++++++++ Plugins/ZShell/Models/wallpaperdb.hpp | 174 ++++ Plugins/ZShell/Models/wallpaperutil.hpp | 313 ++++++++ cli/src/zshell/subcommands/scheme.py | 40 +- 20 files changed, 2739 insertions(+), 330 deletions(-) create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper.vert create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_video.frag create mode 100644 Plugins/ZShell/Internal/videoplayer.cpp create mode 100644 Plugins/ZShell/Internal/videoplayer.hpp create mode 100644 Plugins/ZShell/Models/wallpaperdb.cpp create mode 100644 Plugins/ZShell/Models/wallpaperdb.hpp create mode 100644 Plugins/ZShell/Models/wallpaperutil.hpp diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index bb59c3f..272e106 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -27,7 +27,7 @@ Searcher { property bool recentlyChanged property bool showPreview: false - function getCategoryFor(w: FileSystemEntry): string { + function getCategoryFor(w: WallpaperEntry): string { let category = w.parentDir.slice(Paths.wallsdir.length + 1); if (category.includes("/")) category = category.slice(0, category.indexOf("/")); @@ -46,12 +46,12 @@ Searcher { } function thumbnailFor(path: string): string { - if (!isVideoPath(path)) - return path; + if (!path) + return ""; for (const entry of root.list) { - if (entry.path === path && entry.thumbnailPath) - return entry.thumbnailPath; + if (entry.path === path) + return entry.thumbnailPath || path; } return path; @@ -147,12 +147,13 @@ Searcher { onTriggered: monitorCrops.writeAdapter() } - FileSystemModel { + WallpaperDb { id: wallpapers - filter: FileSystemModel.Media + dbPath: `${Paths.state}/wallpapers.sqlite` + cacheSize: wallpaper.largestBufferSize + requireHardwareCodec: true path: Config.general.wallpaperPath - recursive: true } Process { @@ -171,9 +172,19 @@ Searcher { WallpaperService { id: wallpaper + dbPath: wallpapers.dbPath path: root.current } + Connections { + target: wallpapers + + function onCacheStored(path: string): void { + if (path === root.current) + wallpaper.invalidateCache(); + } + } + Variants { model: Quickshell.screens diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index e2d1473..206cb16 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -55,15 +55,15 @@ Item { } } - CachingImage { + Image { id: img anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - cache: true + asynchronous: true fillMode: Image.PreserveAspectCrop height: root.tileHeight - path: root.modelData ? root.modelData.thumbnailPath : "" + source: root.modelData ? root.modelData.thumbnailPath : "" smooth: root.isCurrent sourceSize.height: root.tileHeight sourceSize.width: root.tileHeight * (16 / 9) diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 9e3df47..e1f7f78 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -53,7 +53,7 @@ orientation: Qt.Vertical WallItem { id: wallItem - required property FileSystemEntry modelData + required property WallpaperEntry modelData enabled: modelData opacity: modelData ? 1 : 0 diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 0ba38d7..5536e7a 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -24,6 +24,12 @@ pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) + +pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) +pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) +pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) + +find_package(CUDAToolkit QUIET) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 76ad6a1..a81311e 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -22,11 +22,13 @@ qml_module(ZShell-internal linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp + videoplayer.hpp videoplayer.cpp cursor.hpp LIBRARIES Qt::Gui Qt::Quick Qt::Concurrent + Qt::Sql Qt::Core Qt::Network Qt::DBus @@ -40,4 +42,20 @@ qml_module(ZShell-internal PkgConfig::WAYLAND_EGL PkgConfig::GL PkgConfig::MPV + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL ) + +qt_add_resources(ZShell-internal "wallpaper_shaders" + PREFIX "/" + FILES + shaders/wallpaper.vert + shaders/wallpaper.frag + shaders/wallpaper_video.frag +) + +if(CUDAToolkit_FOUND) + target_link_libraries(ZShell-internal PRIVATE CUDA::cuda_driver) + target_compile_definitions(ZShell-internal PRIVATE ZHAVE_VIDEO=1) +endif() diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.frag b/Plugins/ZShell/Internal/shaders/wallpaper.frag new file mode 100644 index 0000000..b0db80a --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper.frag @@ -0,0 +1,11 @@ +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +uniform float alpha; +out vec4 outColor; + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec4 c = texture(source, crop.xy + p * crop.zw); + outColor = vec4(c.rgb, c.a * alpha); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.vert b/Plugins/ZShell/Internal/shaders/wallpaper.vert new file mode 100644 index 0000000..8529808 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper.vert @@ -0,0 +1,4 @@ +void main() { + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_video.frag b/Plugins/ZShell/Internal/shaders/wallpaper_video.frag new file mode 100644 index 0000000..9c5aa55 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_video.frag @@ -0,0 +1,105 @@ +uniform sampler2D luma; +uniform sampler2D chroma; +uniform vec2 size; +uniform vec2 srcSize; +uniform vec4 crop; +uniform float alpha; +uniform int matrix; +uniform int range; +uniform vec2 chromaOff; +out vec4 outColor; + +float noise(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +const float PI = 3.1415926535893; + +float sinc(float x) { + x *= PI; + return abs(x) < 1e-6 ? 1.0 : sin(x) / x; +} + +float lanczos(float x) { + if (abs(x) >= 3.0) + return 0.0; + return sinc(x) * sinc(x / 3.0); +} + +float hermite(float x) { + float a = abs(x); + if (a >= 1.0) + return 0.0; + return 1.0 - 3.0 * a * a + 2.0 * a * a * a; +} + +vec4 resample(sampler2D tex, vec2 plane, vec2 dest, vec2 uv) { + vec2 pt = 1.0 / plane; + float wx[6]; + float wy[6]; + vec2 base; + + int upx = dest.x >= plane.x ? 1 : 0; + int tx = upx == 1 ? 6 : 2; + float fsx = max(1.0, plane.x / dest.x); + float f = fract(uv.x * plane.x - 0.5); + base.x = uv.x - f * pt.x - pt.x * (float(tx) * 0.5 - 1.0); + float sx = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= tx) { wx[i] = 0.0; continue; } + float x = f - (float(i) - float(tx) * 0.5 + 1.0); + wx[i] = upx == 1 ? lanczos(x / fsx) : hermite(x / fsx); + sx += wx[i]; + } + if (sx != 0.0) + for (int i = 0; i < 6; ++i) wx[i] /= sx; + + int upy = dest.y >= plane.y ? 1 : 0; + int ty = upy == 1 ? 6 : 2; + float fsy = max(1.0, plane.y / dest.y); + f = fract(uv.y * plane.y - 0.5); + base.y = uv.y - f * pt.y - pt.y * (float(ty) * 0.5 - 1.0); + float sy = 0.0; + for (int i = 0; i < 6; ++i) { + if (i >= ty) { wy[i] = 0.0; continue; } + float y = f - (float(i) - float(ty) * 0.5 + 1.0); + wy[i] = upy == 1 ? lanczos(y / fsy) : hermite(y / fsy); + sy += wy[i]; + } + if (sy != 0.0) + for (int i = 0; i < 6; ++i) wy[i] /= sy; + + vec4 col = vec4(0.0); + for (int i = 0; i < 6; ++i) { + if (wx[i] == 0.0) + continue; + for (int j = 0; j < 6; ++j) { + if (wy[j] == 0.0) + continue; + vec2 c = base + vec2(float(i) * pt.x, float(j) * pt.y); + col += (wx[i] * wy[j]) * texture(tex, c); + } + } + return col; +} + +void main() { + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + vec2 uv = crop.xy + p * crop.zw; + float Yv = resample(luma, srcSize, size, uv).r; + vec2 ch = resample(chroma, srcSize * 0.5, size, uv + chromaOff).rg; + vec3 yuv; + if (range == 0) { + yuv = vec3((Yv - 16.0 / 255.0) / (219.0 / 255.0), + (ch.r - 128.0 / 255.0) / (224.0 / 255.0), + (ch.g - 128.0 / 255.0) / (224.0 / 255.0)); + } else { + yuv = vec3(Yv, ch.r - 0.5, ch.g - 0.5); + } + mat3 m = matrix == 1 + ? mat3(1.0, 1.0, 1.0, 0.0, -0.187324, 1.855600, 1.574800, -0.468124, 0.0) + : mat3(1.0, 1.0, 1.0, 0.0, -0.344136, 1.772000, 1.402000, -0.714136, 0.0); + vec3 rgb = m * yuv; + rgb += (noise(gl_FragCoord.xy) - 0.5) / 255.0; + outColor = vec4(clamp(rgb, 0.0, 1.0), alpha); +} diff --git a/Plugins/ZShell/Internal/videoplayer.cpp b/Plugins/ZShell/Internal/videoplayer.cpp new file mode 100644 index 0000000..ba49577 --- /dev/null +++ b/Plugins/ZShell/Internal/videoplayer.cpp @@ -0,0 +1,529 @@ +#include "videoplayer.hpp" + +#ifdef ZHAVE_VIDEO + +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include +#include +#include +#include + +#include +#include +#include + +#include + +extern "C" CUresult cuGraphicsGLRegisterImage( + CUgraphicsResource* resource, + GLuint image, + GLenum target, + unsigned int flags); + +Q_LOGGING_CATEGORY(videoLog, "ZShell.Internal.wallpaper") + +namespace { + +enum AVPixelFormat chooseFormat( + AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + (void)ctx; + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + if (*p == AV_PIX_FMT_CUDA) return AV_PIX_FMT_CUDA; + return pix_fmts[0]; +} + +} // namespace + +VideoPlayer::VideoPlayer(EGLDisplay display) : m_eglDisplay(display) {} + +VideoPlayer::~VideoPlayer() { + stop(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + av_frame_free(&m_pending); + } + if (m_displayed) { + av_frame_unref(m_displayed); + av_frame_free(&m_displayed); + } + + if (m_cuCtx) { + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + cuCtxPopCurrent(&prev); + cuCtxDestroy(m_cuCtx); + m_cuCtx = nullptr; + } + m_texSize = QSize(); +} + +void VideoPlayer::setReadyCallback(std::function callback) { + m_readyCb = std::move(callback); +} + +void VideoPlayer::setFrameCallback(std::function callback) { + m_frameCb = std::move(callback); +} + +bool VideoPlayer::openDecoder(const QString& path) { + if (avformat_open_input( + &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { + qCWarning( + videoLog, + "VideoPlayer: could not open %s", + path.toUtf8().constData()); + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not read stream info"); + return false; + } + + m_streamIndex = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + m_streamIndex = int(i); + break; + } + if (m_streamIndex < 0) { + qCWarning(videoLog, "VideoPlayer: no video stream"); + return false; + } + + AVCodecParameters* par = m_fmt->streams[m_streamIndex]->codecpar; + const AVCodec* codec = avcodec_find_decoder(par->codec_id); + const AVCodec* hwcodec = nullptr; + void* it = nullptr; + while (const AVCodec* c = av_codec_iterate(&it)) { + if (c->id != par->codec_id || !av_codec_is_decoder(c)) continue; + for (int i = 0; + const AVCodecHWConfig* cfg = avcodec_get_hw_config(c, i); + ++i) + if (cfg->device_type == AV_HWDEVICE_TYPE_CUDA && + (cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) { + hwcodec = c; + break; + } + if (hwcodec) break; + } + if (hwcodec) codec = hwcodec; + if (!codec) { + qCWarning( + videoLog, + "VideoPlayer: no decoder for codec id %d", + int(par->codec_id)); + return false; + } + + m_codec = avcodec_alloc_context3(codec); + if (!m_codec) return false; + if (avcodec_parameters_to_context(m_codec, par) < 0) { + qCWarning(videoLog, "VideoPlayer: could not copy codec params"); + return false; + } + + m_hwDeviceCtx = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_CUDA); + if (!m_hwDeviceCtx) { + qCWarning( + videoLog, "VideoPlayer: could not allocate cuda hwdevice context"); + return false; + } + AVCUDADeviceContext* cudaHw = reinterpret_cast( + reinterpret_cast(m_hwDeviceCtx->data)->hwctx); + if (m_cuCtx) cudaHw->cuda_ctx = m_cuCtx; + if (av_hwdevice_ctx_init(m_hwDeviceCtx) < 0) { + qCWarning( + videoLog, "VideoPlayer: could not create cuda hwdevice context"); + av_buffer_unref(&m_hwDeviceCtx); + return false; + } + m_codec->hw_device_ctx = av_buffer_ref(m_hwDeviceCtx); + m_codec->get_format = chooseFormat; + + if (avcodec_open2(m_codec, codec, nullptr) < 0) { + qCWarning(videoLog, "VideoPlayer: could not open codec"); + return false; + } + + return true; +} + +void VideoPlayer::closeDecoder() { + if (m_codec) { + avcodec_free_context(&m_codec); + m_codec = nullptr; + } + if (m_hwDeviceCtx) { + av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + } + if (m_fmt) { + avformat_close_input(&m_fmt); + m_fmt = nullptr; + } + m_streamIndex = -1; +} + +void VideoPlayer::decodeLoop(const QString& path) { + if (!openDecoder(path)) { + m_threadRunning = false; + return; + } + + AVRational fr = m_fmt->streams[m_streamIndex]->avg_frame_rate; + if (fr.num <= 0 || fr.den <= 0) fr = m_codec->framerate; + if (fr.num <= 0 || fr.den <= 0) fr = AVRational{30, 1}; + const int64_t intervalUs = + av_rescale_q(1, AVRational{fr.den, fr.num}, AVRational{1, 1000000}); + + AVFrame* frame = av_frame_alloc(); + if (!frame) { + m_threadRunning = false; + return; + } + + AVPacket pkt{}; + + while (m_threadRunning) { + int ret = av_read_frame(m_fmt, &pkt); + if (ret == AVERROR_EOF) { + if (avformat_seek_file( + m_fmt, m_streamIndex, INT64_MIN, 0, INT64_MAX, 0) < 0) { + qCWarning(videoLog, "VideoPlayer: loop seek failed; stopping"); + break; + } + avcodec_flush_buffers(m_codec); + continue; + } + if (ret < 0) { + qCWarning(videoLog, "VideoPlayer: read error; stopping"); + break; + } + if (pkt.stream_index != m_streamIndex) { + av_packet_unref(&pkt); + continue; + } + + if (avcodec_send_packet(m_codec, &pkt) < 0) { + av_packet_unref(&pkt); + continue; + } + av_packet_unref(&pkt); + + while (m_threadRunning && avcodec_receive_frame(m_codec, frame) == 0) { + bool wasEmpty = false; + { + std::lock_guard lock(m_mtx); + if (!m_pending) m_pending = av_frame_alloc(); + av_frame_unref(m_pending); + if (av_frame_ref(m_pending, frame) >= 0) { + wasEmpty = !m_haveFrame; + m_haveFrame = true; + } + } + if (wasEmpty && m_readyCb) m_readyCb(); + if (m_frameCb) m_frameCb(); + av_frame_unref(frame); + } + + std::this_thread::sleep_for(std::chrono::microseconds(intervalUs)); + } + + av_frame_free(&frame); + closeDecoder(); + m_threadRunning = false; +} + +bool VideoPlayer::start(const QString& path) { + stop(); + + if (!ensureCudaContext()) return false; + + m_threadRunning = true; + m_thread = std::thread([this, path]() { decodeLoop(path); }); + return true; +} + +void VideoPlayer::stop() { + m_threadRunning = false; + if (m_thread.joinable()) m_thread.join(); + + closeDecoder(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + m_pending = nullptr; + } + if (m_displayed) { + av_frame_unref(m_displayed); + m_displayed = nullptr; + } + m_haveFrame = false; + m_size = QSize(); +} + +QSize VideoPlayer::frameSize() const { + std::lock_guard lock(m_mtx); + return m_size; +} + +void VideoPlayer::deleteTexture() { + std::lock_guard lock(m_mtx); + CUcontext prev = nullptr; + if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + if (m_cuCtx) cuCtxPopCurrent(&prev); + m_texSize = QSize(); +} + +bool VideoPlayer::ensureCudaContext() { + if (m_cuCtx) return true; + + if (cuInit(0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuInit failed"); + return false; + } + CUdevice dev = 0; + if (cuDeviceGet(&dev, 0) != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: cuDeviceGet failed"); + return false; + } + if (cuCtxCreate_v4(&m_cuCtx, nullptr, 0, dev) != CUDA_SUCCESS) { + m_cuCtx = nullptr; + qCWarning(videoLog, "VideoPlayer: cuCtxCreate failed"); + return false; + } + CUcontext popped = nullptr; + cuCtxPopCurrent(&popped); + return true; +} + +bool VideoPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { + if (!ensureCudaContext()) return false; + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + + const QSize sz = QSize(int(w), int(h)); + if (m_texSize == sz && m_texComp == compBytes && m_texY != 0) { + cuCtxPopCurrent(&prev); + return true; + } + + if (m_resY) cuGraphicsUnregisterResource(m_resY); + if (m_resUV) cuGraphicsUnregisterResource(m_resUV); + m_resY = m_resUV = nullptr; + if (m_texY) glDeleteTextures(1, &m_texY); + if (m_texUV) glDeleteTextures(1, &m_texUV); + m_texY = m_texUV = 0; + + const bool wide = compBytes == 2; + auto makeTex = + [&](GLuint& tex, GLint internal, GLenum fmt, unsigned tw, unsigned th) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal, + GLint(tw), + GLint(th), + 0, + fmt, + wide ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE, + nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + makeTex(m_texY, wide ? GL_R16 : GL_R8, GL_RED, w, h); + makeTex(m_texUV, wide ? GL_RG16 : GL_RG8, GL_RG, w / 2, h / 2); + + CUresult r = cuGraphicsGLRegisterImage( + &m_resY, + m_texY, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + const char* es = nullptr; + cuGetErrorString(r, &es); + qCWarning( + videoLog, + "VideoPlayer: register luma tex failed (%s)", + es ? es : "?"); + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + r = cuGraphicsGLRegisterImage( + &m_resUV, + m_texUV, + GL_TEXTURE_2D, + CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD); + if (r != CUDA_SUCCESS) { + qCWarning(videoLog, "VideoPlayer: register chroma tex failed"); + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + m_texSize = QSize(); + cuCtxPopCurrent(&prev); + return false; + } + + m_texSize = sz; + m_texComp = compBytes; + cuCtxPopCurrent(&prev); + return true; +} + +bool VideoPlayer::importCurrentFrame() { + AVFrame* frame = nullptr; + { + std::lock_guard lock(m_mtx); + if (!m_pending || m_pending->format == AV_PIX_FMT_NONE) return false; + frame = m_pending; + m_pending = nullptr; + } + + if (frame->format != AV_PIX_FMT_CUDA || !frame->hw_frames_ctx) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + videoLog, + "VideoPlayer: frame not on GPU (fmt=%s hw_frames=%d)", + av_get_pix_fmt_name(AVPixelFormat(frame->format)), + frame->hw_frames_ctx != nullptr); + av_frame_unref(frame); + return false; + } + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVPixelFormat sw = hf->sw_format; + const bool tenBit = sw == AV_PIX_FMT_P010LE || sw == AV_PIX_FMT_P010BE || + sw == AV_PIX_FMT_P012LE || sw == AV_PIX_FMT_P016LE; + const int compBytes = tenBit ? 2 : 1; + const size_t compSize = size_t(compBytes); + + const unsigned w = unsigned(frame->width); + const unsigned h = unsigned(frame->height); + if (w == 0 || h == 0) { + av_frame_unref(frame); + return false; + } + + size_t pitchY = size_t(frame->linesize[0]); + size_t pitchUV = frame->linesize[1] > 0 ? size_t(frame->linesize[1]) + : pitchY; + if (pitchY == 0) pitchY = size_t(w) * compSize; + + switch (frame->colorspace) { + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + m_matrix = 0; + break; + case AVCOL_SPC_BT709: + m_matrix = 1; + break; + default: + m_matrix = h >= 720 ? 1 : 0; + break; + } + m_range = frame->color_range == AVCOL_RANGE_JPEG ? 1 : 0; + m_size = QSize(int(w), int(h)); + + if (!ensureTextures(w, h, compBytes)) { + av_frame_unref(frame); + return false; + } + + CUcontext prev = nullptr; + cuCtxPushCurrent(m_cuCtx); + CUgraphicsResource res[2] = {m_resY, m_resUV}; + CUresult r = cuGraphicsMapResources(2, res, nullptr); + if (r != CUDA_SUCCESS) { + cuCtxPopCurrent(&prev); + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuGraphicsMapResources failed"); + return false; + } + + CUarray arrY = nullptr, arrUV = nullptr; + cuGraphicsSubResourceGetMappedArray(&arrY, m_resY, 0, 0); + cuGraphicsSubResourceGetMappedArray(&arrUV, m_resUV, 0, 0); + + const size_t dstPitch = size_t(w) * compSize; + CUDA_MEMCPY2D mc{}; + mc.srcMemoryType = CU_MEMORYTYPE_DEVICE; + mc.dstMemoryType = CU_MEMORYTYPE_ARRAY; + mc.srcDevice = CUdeviceptr(frame->data[0]); + mc.srcPitch = pitchY; + mc.dstArray = arrY; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchY, dstPitch); + mc.Height = h; + r = cuMemcpy2D(&mc); + if (r == CUDA_SUCCESS) { + mc.srcDevice = CUdeviceptr(frame->data[1]); + mc.srcPitch = pitchUV; + mc.dstArray = arrUV; + mc.dstPitch = dstPitch; + mc.WidthInBytes = std::min(pitchUV, dstPitch); + mc.Height = h / 2; + r = cuMemcpy2D(&mc); + } + + cuGraphicsUnmapResources(2, res, nullptr); + cuCtxPopCurrent(&prev); + + if (r != CUDA_SUCCESS) { + av_frame_unref(frame); + qCWarning(videoLog, "VideoPlayer: cuMemcpy2D failed"); + return false; + } + + { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + } + + return true; +} + +#endif diff --git a/Plugins/ZShell/Internal/videoplayer.hpp b/Plugins/ZShell/Internal/videoplayer.hpp new file mode 100644 index 0000000..b6137e0 --- /dev/null +++ b/Plugins/ZShell/Internal/videoplayer.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include + +#include +#include +#include +#include + +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +} + +class VideoPlayer { + public: + explicit VideoPlayer(EGLDisplay display); + ~VideoPlayer(); + + void setReadyCallback(std::function callback); + void setFrameCallback(std::function callback); + + bool start(const QString& path); + void stop(); + + + bool importCurrentFrame(); + + void deleteTexture(); + + [[nodiscard]] uint32_t textureId() const { return m_texY; } + [[nodiscard]] uint32_t chromaTextureId() const { return m_texUV; } + [[nodiscard]] int colorMatrix() const { return m_matrix; } + [[nodiscard]] int colorRange() const { return m_range; } + [[nodiscard]] QSize frameSize() const; + + private: + void decodeLoop(const QString& path); + bool openDecoder(const QString& path); + void closeDecoder(); + + bool ensureTextures(unsigned w, unsigned h, int compBytes); + + bool ensureCudaContext(); + + EGLDisplay m_eglDisplay; + std::function m_readyCb; + std::function m_frameCb; + + std::thread m_thread; + std::atomic m_threadRunning{false}; + + mutable std::mutex m_mtx; + AVFrame* m_pending = nullptr; + bool m_haveFrame = false; + + AVFrame* m_displayed = nullptr; + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_codec = nullptr; + AVBufferRef* m_hwDeviceCtx = nullptr; + int m_streamIndex = -1; + + CUcontext m_cuCtx = nullptr; + CUgraphicsResource m_resY = nullptr; + CUgraphicsResource m_resUV = nullptr; + QSize m_texSize; + + uint32_t m_texY = 0; + uint32_t m_texUV = 0; + + int m_matrix = 1; + int m_range = 0; + int m_texComp = 1; + QSize m_size; +}; diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index 9875d4d..9b512e8 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -7,6 +7,10 @@ #include #include +#ifndef GL_TEXTURE_EXTERNAL_OES +#define GL_TEXTURE_EXTERNAL_OES 0x8D66 +#endif + #include #include @@ -17,10 +21,15 @@ #include #include +#include #include #include #include #include +#include +#include +#include +#include #include #include @@ -35,30 +44,38 @@ namespace ZShell::internal { +Q_LOGGING_CATEGORY(wpLog, "ZShell.Internal.wallpaper") + namespace { -const char* kVertexShader = R"( -#version 300 es -void main() { - vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); -} -)"; +#ifdef ZHAVE_VIDEO +const bool kMpvMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_MPV"); +#endif -const char* kFragmentShader = R"( -#version 300 es -precision highp float; -uniform sampler2D source; -uniform vec2 size; -uniform vec4 crop; -uniform float alpha; -out vec4 outColor; -void main() { - vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; - vec4 c = texture(source, crop.xy + p * crop.zw); - outColor = vec4(c.rgb, c.a * alpha); +#ifdef ZHAVE_VIDEO +const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES") || + kMpvMode; +#else +const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES"); +#endif + +const char* shaderHeader(bool fragment) { + if (kGlesMode) + return fragment ? "#version 300 es\nprecision highp float;\n" + : "#version 300 es\n"; + return "#version 330\n"; +} + +QByteArray shaderSource(const QString& file, bool fragment) { + QFile source(QStringLiteral(":/shaders/") + file); + if (!source.open(QIODevice::ReadOnly)) { + qWarning(wpLog) << "WallpaperService: could not read the shader" + << file; + return {}; + } + + return QByteArray(shaderHeader(fragment)) + source.readAll(); } -)"; constexpr int kWaitTimeoutMs = 20; @@ -96,8 +113,8 @@ void logShader(uint32_t object, bool program) { else glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); - qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") - << "failed:" << info; + qWarning(wpLog) << "WallpaperService:" + << (program ? "linking" : "compiling") << "failed:" << info; } QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { @@ -143,8 +160,19 @@ WallpaperService::~WallpaperService() { m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { if (m_texture) glDeleteTextures(1, &m_texture); if (m_previousTexture) glDeleteTextures(1, &m_previousTexture); + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); if (m_blackTex) glDeleteTextures(1, &m_blackTex); if (m_program) glDeleteProgram(m_program); + if (m_videoProgram) glDeleteProgram(m_videoProgram); +#ifdef ZHAVE_VIDEO + if (m_player) { + m_player->stop(); + m_player->deleteTexture(); + m_player.reset(); + } +#endif } eglDestroyContext(m_eglDisplay, m_context); @@ -168,6 +196,23 @@ QString WallpaperService::path() const { return m_path; } +QString WallpaperService::dbPath() const { + std::lock_guard lock(m_mutex); + return m_dbPath; +} + +void WallpaperService::setDbPath(const QString& path) { + { + std::lock_guard lock(m_mutex); + if (m_dbPath == path) return; + m_dbPath = path; + m_forceDecode = true; + } + + emit dbPathChanged(); + wake(); +} + void WallpaperService::setPath(const QString& path) { if (path.isEmpty() || path == this->path()) return; @@ -194,12 +239,19 @@ void WallpaperService::applyPath(const QString& path) { std::lock_guard lock(m_mutex); m_path = path; - const bool wasVideo = m_isVideo && m_mpv != nullptr; +#ifdef ZHAVE_VIDEO + const bool videoBackend = m_mpv != nullptr || m_player != nullptr; +#else + const bool videoBackend = m_mpv != nullptr; +#endif + const bool wasVideo = m_isVideo && videoBackend; const bool wasImage = !m_image.isNull() && m_texture != 0; m_previousVideo = wasVideo && !video; if (wasImage) { + if (m_previousTexture != 0) + m_orphanTextures.push_back(m_previousTexture); m_previousImage = m_image; m_previousCrops = m_crops; m_previousTexture = 0; @@ -211,6 +263,7 @@ void WallpaperService::applyPath(const QString& path) { m_fading = true; m_fadeClockStarted = false; + m_replacementReady = false; m_forceDecode = true; m_imageChanged = true; @@ -258,15 +311,13 @@ QString WallpaperService::codecName(const QString& path) const { }); if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { - qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit" - << probe.exitCode() << ")"; + qWarning(wpLog) << "WallpaperService: ffprobe failed for" << path + << "(exit" << probe.exitCode() << ")"; return {}; } const QString codec = QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); - qWarning() << "WallpaperService: codec of" << path << "is" - << (codec.isEmpty() ? "" : codec); return codec; } @@ -309,7 +360,7 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { auto* proc = m_transcode; connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { - qWarning().noquote() + qWarning(wpLog).noquote() << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); }); @@ -321,9 +372,9 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { const bool ok = code == 0 && status == QProcess::NormalExit && QFile::exists(dst); - qWarning() << "WallpaperService: transcode finished, exit" << code - << "status" << int(status) << "exists" - << QFile::exists(dst) << "->" << dst; + qWarning(wpLog) << "WallpaperService: transcode finished, exit" + << code << "status" << int(status) << "exists" + << QFile::exists(dst) << "->" << dst; if (proc == m_transcode) m_transcode = nullptr; proc->deleteLater(); @@ -331,7 +382,7 @@ void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { if (ok) applyPath(dst); }); - qWarning() << "WallpaperService: transcoding" << src << "->" << dst; + qWarning(wpLog) << "WallpaperService: transcoding" << src << "->" << dst; proc->setProgram("nice"); proc->setArguments({ @@ -452,12 +503,23 @@ void WallpaperService::startVideo(const QString& path) { m_videoReady.store(false, std::memory_order_relaxed); m_videoLoaded.store(false, std::memory_order_relaxed); +#ifdef ZHAVE_VIDEO + if (m_usePlayer && !kMpvMode) { + if (startPlayerVideo(path)) return; + + qWarning(wpLog) << "WallpaperService: the GPU video player failed, " + "falling back to mpv"; + m_usePlayer = false; + m_player.reset(); + } +#endif + if (!m_mpv) { std::setlocale(LC_NUMERIC, "C"); m_mpv = mpv_create(); if (!m_mpv) { - qWarning() << "WallpaperService: could not create mpv handle"; + qWarning(wpLog) << "WallpaperService: could not create mpv handle"; return; } @@ -471,7 +533,7 @@ void WallpaperService::startVideo(const QString& path) { mpv_set_option_string(m_mpv, "background-color", "#00000000"); if (mpv_initialize(m_mpv) < 0) { - qWarning() << "WallpaperService: mpv_initialize failed"; + qWarning(wpLog) << "WallpaperService: mpv_initialize failed"; mpv_terminate_destroy(m_mpv); m_mpv = nullptr; return; @@ -484,7 +546,7 @@ void WallpaperService::startVideo(const QString& path) { const char* loadCmd[]{"loadfile", local.constData(), nullptr}; if (mpv_command(m_mpv, loadCmd) < 0) - qWarning() << "WallpaperService: mpv could not load" << path; + qWarning(wpLog) << "WallpaperService: mpv could not load" << path; m_mpvFrameReady.store(true, std::memory_order_relaxed); } @@ -495,12 +557,6 @@ void WallpaperService::pumpMpvEvents() { mpv_event* event = nullptr; while ((event = mpv_wait_event(m_mpv, 0)) != nullptr && event->event_id != MPV_EVENT_NONE) { - if (event->event_id == MPV_EVENT_LOG_MESSAGE) { - auto* data = static_cast(event->data); - if (data && data->text) - qWarning().noquote() << "mpv:" << data->prefix << data->text; - } - if (event->event_id == MPV_EVENT_FILE_LOADED) { m_videoLoaded.store(true, std::memory_order_relaxed); } @@ -511,16 +567,12 @@ void WallpaperService::pumpMpvEvents() { auto* data = static_cast(event->data); if (data && data->error != MPV_END_FILE_REASON_EOF) - qWarning() << "WallpaperService: mpv ended the file, error" - << data->error; + qWarning(wpLog) << "WallpaperService: mpv ended the file, error" + << data->error; } } } -void WallpaperService::stopVideo() { - teardownMpv(); -} - void WallpaperService::teardownMpv() { if (m_mpvRender) { mpv_render_context_free(m_mpvRender); @@ -570,8 +622,11 @@ void WallpaperService::initMpvRender() { {MPV_RENDER_PARAM_INVALID, nullptr}, }; - if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) { - qWarning() << "WallpaperService: could not create mpv render context"; + const int rc = mpv_render_context_create(&m_mpvRender, m_mpv, params); + if (rc < 0) { + qWarning(wpLog) + << "WallpaperService: could not create mpv render context, code" + << rc; m_mpvRender = nullptr; return; } @@ -626,7 +681,7 @@ void WallpaperService::start() { auto* interface = qApp->nativeInterface(); if (!interface) { - qWarning() << "WallpaperService: this is not a Wayland session"; + qWarning(wpLog) << "WallpaperService: this is not a Wayland session"; return; } @@ -638,7 +693,8 @@ void WallpaperService::start() { if (!m_queue) return; if (pipe2(m_wake, O_CLOEXEC) < 0) { - qWarning() << "WallpaperService: could not make the pipe to wake on"; + qWarning(wpLog) + << "WallpaperService: could not make the pipe to wake on"; return; } @@ -658,7 +714,8 @@ void WallpaperService::wake() { char byte = 0; if (::write(m_wake[1], &byte, 1) < 0) - qWarning() << "WallpaperService: could not wake the drawing thread"; + qWarning(wpLog) + << "WallpaperService: could not wake the drawing thread"; } void WallpaperService::queue(WallpaperSurface* surface) { @@ -678,6 +735,23 @@ void WallpaperService::queueAll() { } void WallpaperService::loop() { + eglBindAPI(kGlesMode ? EGL_OPENGL_ES_API : EGL_OPENGL_API); + if (m_context == EGL_NO_CONTEXT) { + if (kGlesMode) { + const EGLint contextAttributes[] = { + EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + } else { + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, nullptr); + } + if (m_context == EGL_NO_CONTEXT) { + qWarning(wpLog) << "WallpaperService: eglCreateContext failed"; + return; + } + } + while (!m_stop) { present(); @@ -719,7 +793,12 @@ void WallpaperService::present() { addSurfaces(); - ensureDecodeSize(); + ensureImage(); + + if (m_isVideo && m_videoReady.load(std::memory_order_relaxed)) { + std::lock_guard lock(m_mutex); + m_replacementReady = true; + } QList surfaces; bool fading = false; @@ -730,12 +809,9 @@ void WallpaperService::present() { { std::lock_guard lock(m_mutex); - if (m_videoReload) { - m_videoReload = false; - videoReload = true; - path = m_path; - isVideo = m_isVideo; - } + videoReload = m_videoReload; + path = m_path; + isVideo = m_isVideo; fading = m_fading; const bool newVideoFrame = @@ -751,18 +827,59 @@ void WallpaperService::present() { if (m_context == EGL_NO_CONTEXT) return; - eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context); + EGLSurface importTarget = EGL_NO_SURFACE; + for (auto* surface : surfaces) + if (surface->eglSurface() != EGL_NO_SURFACE) { + importTarget = surface->eglSurface(); + break; + } + + if (importTarget == EGL_NO_SURFACE) return; + + if (!eglMakeCurrent(m_eglDisplay, importTarget, importTarget, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + if (videoReload) { + std::lock_guard lock(m_mutex); + m_videoReload = false; + } + + if (!m_orphanTextures.empty()) { + std::lock_guard lock(m_mutex); + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + } if (videoReload) { if (isVideo) startVideo(path); else if (m_mpv && !m_previousVideo) teardownMpv(); +#ifdef ZHAVE_VIDEO + else if (m_player && !m_previousVideo) { + m_player->deleteTexture(); + stopPlayerVideo(); + } +#endif } +#ifdef ZHAVE_VIDEO + const bool videoActive = (m_isVideo || m_previousVideo) && + (m_mpv != nullptr || m_player != nullptr); +#else const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr; +#endif - if (videoActive) pumpMpvEvents(); + if (videoActive && m_mpv != nullptr) pumpMpvEvents(); + +#ifdef ZHAVE_VIDEO + if (videoActive && m_player != nullptr) m_player->importCurrentFrame(); +#endif for (auto* surface : surfaces) drawSurface(surface); @@ -868,13 +985,13 @@ bool WallpaperService::setupGl() { } if (m_eglDisplay == EGL_NO_DISPLAY) { - qWarning() << "WallpaperService: no EGL display"; + qWarning(wpLog) << "WallpaperService: no EGL display"; return false; } EGLint major = 0, minor = 0; if (!eglInitialize(m_eglDisplay, &major, &minor)) { - qWarning() << "WallpaperService: eglInitialize failed"; + qWarning(wpLog) << "WallpaperService: eglInitialize failed"; return false; } @@ -882,7 +999,7 @@ bool WallpaperService::setupGl() { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, - EGL_OPENGL_ES2_BIT, + kGlesMode ? EGL_OPENGL_ES2_BIT : (EGL_OPENGL_BIT | EGL_OPENGL_ES2_BIT), EGL_RED_SIZE, 8, EGL_GREEN_SIZE, @@ -896,15 +1013,8 @@ bool WallpaperService::setupGl() { EGLint count = 0; if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || count == 0) { - qWarning() << "WallpaperService: no EGL config for window surfaces"; - return false; - } - - const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; - m_context = eglCreateContext( - m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); - if (m_context == EGL_NO_CONTEXT) { - qWarning() << "WallpaperService: eglCreateContext failed"; + qWarning(wpLog) + << "WallpaperService: no EGL config for window surfaces"; return false; } @@ -912,13 +1022,18 @@ bool WallpaperService::setupGl() { } uint32_t WallpaperService::createProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertex, 1, &kVertexShader, nullptr); + glShaderSource(vertex, 1, &vs, nullptr); glCompileShader(vertex); logShader(vertex, false); uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragment, 1, &kFragmentShader, nullptr); + glShaderSource(fragment, 1, &fs, nullptr); glCompileShader(fragment); logShader(fragment, false); @@ -930,9 +1045,10 @@ uint32_t WallpaperService::createProgram() { if (glGetUniformLocation(program, "size") < 0 || glGetUniformLocation(program, "crop") < 0 || - glGetUniformLocation(program, "alpha") < 0) { - qWarning() << "WallpaperService: the program does not have the " - "uniforms it needs"; + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "source") < 0) { + qWarning(wpLog) << "WallpaperService: the program does not have the " + "uniforms it needs"; glDeleteProgram(program); return 0; } @@ -940,12 +1056,137 @@ uint32_t WallpaperService::createProgram() { m_sizeUniform = glGetUniformLocation(program, "size"); m_cropUniform = glGetUniformLocation(program, "crop"); m_alphaUniform = glGetUniformLocation(program, "alpha"); + m_sourceUniform = glGetUniformLocation(program, "source"); + + glUseProgram(program); + glUniform1i(GLint(m_sourceUniform), 0); glDeleteShader(vertex); glDeleteShader(fragment); return program; } +#ifdef ZHAVE_VIDEO +uint32_t WallpaperService::createVideoProgram() { + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource("wallpaper_video.frag", true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "srcSize") < 0 || + glGetUniformLocation(program, "crop") < 0 || + glGetUniformLocation(program, "alpha") < 0 || + glGetUniformLocation(program, "luma") < 0 || + glGetUniformLocation(program, "chroma") < 0 || + glGetUniformLocation(program, "matrix") < 0 || + glGetUniformLocation(program, "range") < 0 || + glGetUniformLocation(program, "chromaOff") < 0) { + qWarning(wpLog) + << "WallpaperService: the video program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_videoSizeUniform = glGetUniformLocation(program, "size"); + m_videoSrcSizeUniform = glGetUniformLocation(program, "srcSize"); + m_videoCropUniform = glGetUniformLocation(program, "crop"); + m_videoAlphaUniform = glGetUniformLocation(program, "alpha"); + m_videoLumaUniform = glGetUniformLocation(program, "luma"); + m_videoChromaUniform = glGetUniformLocation(program, "chroma"); + m_videoMatrixUniform = glGetUniformLocation(program, "matrix"); + m_videoRangeUniform = glGetUniformLocation(program, "range"); + m_videoChromaOffUniform = glGetUniformLocation(program, "chromaOff"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, lumaTex); + glUniform1i(GLint(m_videoLumaUniform), 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, chromaTex); + glUniform1i(GLint(m_videoChromaUniform), 1); + + glUniform1i(GLint(m_videoMatrixUniform), matrix); + glUniform1i(GLint(m_videoRangeUniform), range); + + glUniform2f( + GLint(m_videoChromaOffUniform), + range == 0 && srcW > 0 ? 0.5f / srcW : 0.0f, + 0.0f); + + glUniform2f(GLint(m_videoSizeUniform), sizeW, sizeH); + glUniform2f(GLint(m_videoSrcSizeUniform), srcW, srcH); + glUniform4f( + GLint(m_videoCropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + glUniform1f(GLint(m_videoAlphaUniform), alpha); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +bool WallpaperService::startPlayerVideo(const QString& path) { + if (!m_player) m_player = std::make_unique(m_eglDisplay); + + m_player->setReadyCallback([this]() { + m_videoLoaded.store(true, std::memory_order_relaxed); + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + m_player->setFrameCallback([this]() { + m_videoReady.store(true, std::memory_order_relaxed); + queueAll(); + }); + + if (!m_player->start(path)) return false; + + return true; +} + +void WallpaperService::stopPlayerVideo() { + if (!m_player) return; + + m_player->stop(); + m_player.reset(); +} +#endif + void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { if (image.isNull()) return; @@ -981,7 +1222,9 @@ void WallpaperService::drawTexture( float sizeW, float sizeH, float alpha) { + glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(GLint(m_sourceUniform), 0); glUniform2f(GLint(m_sizeUniform), sizeW, sizeH); glUniform4f( GLint(m_cropUniform), @@ -994,70 +1237,161 @@ void WallpaperService::drawTexture( glDrawArrays(GL_TRIANGLES, 0, 3); } -bool WallpaperService::decode(const QString& path) { - if (path.isEmpty()) return false; +QByteArray WallpaperService::cachedBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + QString databasePath; + { + std::lock_guard lock(m_mutex); + databasePath = m_dbPath; + } + + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + const QString connection = QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT cache, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QImage WallpaperService::decodeImage( + const QString& path, const QSize& decodeSize) const { + if (path.isEmpty()) return {}; + + QImageReader probe(path); + const QSize source = probe.size(); + QSize required = source.isEmpty() ? decodeSize : source; + if (!source.isEmpty() && !decodeSize.isEmpty()) { + const QSize cover = + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && + cover.height() <= source.height()) + required = cover; + } + + const QByteArray blob = cachedBlob(path); + if (!blob.isEmpty()) { + QImage image = QImage::fromData(blob, "JPEG"); + if (!image.isNull() && image.width() >= required.width() && + image.height() >= required.height()) { + return image.convertToFormat(QImage::Format_RGBA8888); + } + } QImageReader reader(path); reader.setAutoTransform(true); - QSize size = reader.size(); - if (size.isValid()) m_sourceSize = size; - - if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) + if (!decodeSize.isEmpty() && !source.isEmpty()) reader.setScaledSize( - m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding)); QImage image = reader.read(); if (image.isNull()) { - qWarning() << "WallpaperService: could not read the image" << path; - return false; + qWarning(wpLog) << "WallpaperService: could not read the image" << path; + return {}; } - m_image = image.convertToFormat(QImage::Format_RGBA8888); - return !m_image.isNull(); + return image.convertToFormat(QImage::Format_RGBA8888); } -bool WallpaperService::ensureDecodeSize() { +void WallpaperService::ensureImage() { QSize largest; QString path; + bool needDecode = false; { std::lock_guard lock(m_mutex); - if (m_isVideo) return false; + if (m_isVideo) return; largest = largestBufferSize(); - if (largest.isEmpty()) return false; + if (largest.isEmpty()) return; - if (!m_forceDecode && !m_decodeSize.isEmpty() && - qint64(largest.width()) * largest.height() <= - qint64(m_decodeSize.width()) * m_decodeSize.height()) - return false; - - QSize target = - m_sourceSize.isEmpty() - ? largest - : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); - - if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) - return false; - - m_forceDecode = false; - m_decodeSize = largest; path = m_path; + + if (m_stagedReady && m_stagedPath == path) { + m_image = m_stagedImage; + m_stagedImage = QImage(); + m_stagedReady = false; + m_decodeSize = largest; + m_imageChanged = true; + m_replacementReady = true; + } else { + m_stagedReady = false; + m_stagedImage = QImage(); + + const bool grew = m_decodeSize.isEmpty() || + largest.width() > m_decodeSize.width() || + largest.height() > m_decodeSize.height(); + + if (!m_decoding && (m_forceDecode || path != m_decodePath || + m_image.isNull() || grew)) { + m_forceDecode = false; + m_decoding = true; + m_decodePath = path; + needDecode = true; + } else { + m_forceDecode = false; + } + } } - if (!decode(path)) return false; + if (!needDecode) return; + + QThreadPool::globalInstance()->start([this, path, largest]() { + const QImage image = decodeImage(path, largest); - { std::lock_guard lock(m_mutex); - m_imageChanged = true; - } + m_stagedImage = image; + m_stagedPath = path; + m_stagedReady = !image.isNull(); + m_decoding = false; + }); - return true; + wake(); } -QSize WallpaperService::largestBufferSize() { +void WallpaperService::invalidateCache() { + { + std::lock_guard lock(m_mutex); + if (m_isVideo) return; + m_forceDecode = true; + } + + wake(); +} + +QSize WallpaperService::largestBufferSize() const { + std::lock_guard lock(m_mutex); + QSize largest; for (auto* surface : m_surfaces) { @@ -1072,6 +1406,11 @@ QSize WallpaperService::largestBufferSize() { return largest; } +void WallpaperService::notifyBufferSizes() { + QMetaObject::invokeMethod( + this, "largestBufferSizeChanged", Qt::QueuedConnection); +} + QRectF WallpaperService::sourceRect( const QVariantMap& crops, const QString& screen, @@ -1125,12 +1464,19 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { return; if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { - qWarning() << "WallpaperService: the context cannot be made current"; + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); return; } eglSwapInterval(m_eglDisplay, 0); + if (m_vao == 0) { + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + } + struct ContextRelease { EGLDisplay display; ~ContextRelease() { @@ -1157,17 +1503,51 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; +#ifdef ZHAVE_VIDEO + const bool videoAsBackground = + (videoNow || (m_previousVideo && m_fading)) && + (m_mpv != nullptr || m_player != nullptr); +#else const bool videoAsBackground = (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr; +#endif if (videoAsBackground) { - if (!m_mpvRender) initMpvRender(); - if (!m_mpvRender) return; - glDisable(GL_BLEND); - glViewport(0, 0, size.width(), size.height()); - renderMpvToSurface(size); + +#ifdef ZHAVE_VIDEO + if (m_player) { + if (m_player->textureId() != 0) { + if (m_videoProgram == 0) + m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_player->frameSize(); + drawVideoTexture( + m_player->textureId(), + m_player->chromaTextureId(), + m_player->colorMatrix(), + m_player->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + float(size.width()), + float(size.height()), + src.width() > 0 ? float(src.width()) + : float(size.width()), + src.height() > 0 ? float(src.height()) + : float(size.height()), + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + + renderMpvToSurface(size); + } } else { glUseProgram(m_program); glViewport(0, 0, size.width(), size.height()); @@ -1198,13 +1578,21 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { qreal progress = 1.0; if (m_fading) { - progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs); - if (progress >= 1.0) + progress = m_fadeClockStarted ? qreal(m_fadeTimer.elapsed()) / + qreal(kFadeDurationMs) + : 0.0; + + if (progress >= 1.0 && m_replacementReady) fadeDone = true; else fade = true; } + const float previousAlpha = m_replacementReady ? float(1.0 - progress) + : 1.0f; + const float replacementAlpha = m_replacementReady ? float(progress) + : 0.0f; + const float w = float(size.width()); const float h = float(size.height()); @@ -1221,11 +1609,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { m_previousImage.size()); drawTexture( - m_previousTexture, - previousRect, - w, - h, - 1.0f - float(progress)); + m_previousTexture, previousRect, w, h, previousAlpha); } } else if (fade && !m_firstFadeDone) { if (m_blackTex == 0) createBlackTexture(m_blackTex); @@ -1236,7 +1620,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { QRectF(0.0, 0.0, 1.0, 1.0), w, h, - 1.0f - float(progress)); + previousAlpha); } } else { const QRectF rect = @@ -1258,7 +1642,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { drawTexture(m_previousTexture, previousRect, w, h, 1.0f); } - drawTexture(m_texture, rect, w, h, float(progress)); + drawTexture(m_texture, rect, w, h, replacementAlpha); } else { drawTexture(m_texture, rect, w, h, 1.0f); } @@ -1270,6 +1654,12 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { if (m_previousVideo) { teardownMpv(); +#ifdef ZHAVE_VIDEO + if (m_player) { + m_player->deleteTexture(); + stopPlayerVideo(); + } +#endif m_previousVideo = false; } @@ -1285,7 +1675,7 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { surface->requestFrame(); if (!eglSwapBuffers(m_eglDisplay, target)) { - qWarning() + qWarning(wpLog) << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" << QString::number(unsigned(eglGetError()), 16); return; @@ -1309,15 +1699,15 @@ void WallpaperService::addSurface(const QString& screen) { } if (!qscreen) { - qWarning() << "WallpaperService:" << screen << "is not a screen"; + qWarning(wpLog) << "WallpaperService:" << screen << "is not a screen"; return; } auto* waylandScreen = dynamic_cast(qscreen->handle()); if (!waylandScreen || !waylandScreen->output()) { - qWarning() << "WallpaperService:" << screen - << "is not a Wayland screen"; + qWarning(wpLog) << "WallpaperService:" << screen + << "is not a Wayland screen"; return; } @@ -1331,6 +1721,8 @@ void WallpaperService::addSurface(const QString& screen) { std::lock_guard lock(m_mutex); m_surfaces.append(surface); } + + notifyBufferSizes(); } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp index d37b5b7..25cbd65 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -18,6 +18,11 @@ #include #include #include +#include + +#ifdef ZHAVE_VIDEO +#include "videoplayer.hpp" +#endif #include #include @@ -39,6 +44,10 @@ class WallpaperService : public QObject { Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + QSize largestBufferSize READ largestBufferSize NOTIFY + largestBufferSizeChanged) public: explicit WallpaperService(QObject* parent = nullptr); @@ -47,6 +56,11 @@ class WallpaperService : public QObject { [[nodiscard]] QString path() const; void setPath(const QString& path); + [[nodiscard]] QSize largestBufferSize() const; + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + void addScreen(const QString& screen); void removeScreen(const QString& screen); @@ -57,9 +71,13 @@ class WallpaperService : public QObject { Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + Q_INVOKABLE void invalidateCache(); + signals: void pathChanged(); void cropsChanged(); + void dbPathChanged(); + void largestBufferSizeChanged(); private: friend class WallpaperSurface; @@ -77,7 +95,6 @@ class WallpaperService : public QObject { void applyPath(const QString& path); void startVideo(const QString& path); - void stopVideo(); void teardownMpv(); void initMpvRender(); @@ -87,6 +104,23 @@ class WallpaperService : public QObject { static void* getMpvProcAddress(void* ctx, const char* name); static void mpvUpdate(void* ctx); +#ifdef ZHAVE_VIDEO + bool startPlayerVideo(const QString& path); + void stopPlayerVideo(); + [[nodiscard]] uint32_t createVideoProgram(); + void drawVideoTexture( + uint32_t lumaTex, + uint32_t chromaTex, + int matrix, + int range, + const QRectF& rect, + float sizeW, + float sizeH, + float srcW, + float srcH, + float alpha); +#endif + void bindGlobals(); static void registryGlobal( void* data, @@ -111,16 +145,18 @@ class WallpaperService : public QObject { float sizeH, float alpha); - bool decode(const QString& path); + [[nodiscard]] QImage decodeImage( + const QString& path, const QSize& decodeSize) const; - bool ensureDecodeSize(); + [[nodiscard]] QByteArray cachedBlob(const QString& path) const; - [[nodiscard]] QSize largestBufferSize(); + void ensureImage(); + + void notifyBufferSizes(); void queue(WallpaperSurface* surface); void queueAll(); - void startDrawing(); void loop(); void present(); @@ -143,6 +179,7 @@ class WallpaperService : public QObject { void addSurface(const QString& screen); QString m_path; + QString m_dbPath; QStringList m_screens; QVariantMap m_crops; @@ -160,9 +197,15 @@ class WallpaperService : public QObject { QProcess* m_transcode = nullptr; - QSize m_sourceSize; QSize m_decodeSize; + QImage m_stagedImage; + QString m_stagedPath; + bool m_stagedReady = false; + bool m_decoding = false; + QString m_decodePath; + bool m_replacementReady = false; + wl_display* m_display = nullptr; wl_registry* m_registry = nullptr; wl_compositor* m_compositor = nullptr; @@ -178,10 +221,29 @@ class WallpaperService : public QObject { int m_sizeUniform = -1; int m_cropUniform = -1; int m_alphaUniform = -1; + int m_sourceUniform = -1; + + uint32_t m_videoProgram = 0; + uint32_t m_vao = 0; + int m_videoSizeUniform = -1; + int m_videoSrcSizeUniform = -1; + int m_videoCropUniform = -1; + int m_videoAlphaUniform = -1; + int m_videoLumaUniform = -1; + int m_videoChromaUniform = -1; + int m_videoMatrixUniform = -1; + int m_videoRangeUniform = -1; + int m_videoChromaOffUniform = -1; + +#ifdef ZHAVE_VIDEO + std::unique_ptr m_player; + bool m_usePlayer = true; +#endif QImage m_previousImage; QVariantMap m_previousCrops; uint32_t m_previousTexture = 0; + std::vector m_orphanTextures; bool m_fading = false; bool m_fadeClockStarted = false; bool m_firstFadeDone = false; diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp index 44c4297..1fb7834 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -185,6 +185,7 @@ void WallpaperSurface::updateBuffer() { requestFrame(); m_service->queue(this); + m_service->notifyBufferSizes(); } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt index 9bac1a8..6d677a0 100644 --- a/Plugins/ZShell/Models/CMakeLists.txt +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -2,7 +2,12 @@ qml_module(ZShell-models URI ZShell.Models SOURCES filesystemmodel.hpp filesystemmodel.cpp + wallpaperdb.hpp wallpaperdb.cpp + wallpaperutil.hpp LIBRARIES Qt::Gui + Qt::Quick + Qt::Qml Qt::Concurrent + Qt::Sql ) diff --git a/Plugins/ZShell/Models/filesystemmodel.cpp b/Plugins/ZShell/Models/filesystemmodel.cpp index c7d7bad..72f423c 100644 --- a/Plugins/ZShell/Models/filesystemmodel.cpp +++ b/Plugins/ZShell/Models/filesystemmodel.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace ZShell::models { @@ -39,136 +40,43 @@ bool hasVideoExtension(const QString& path) { return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); } -QString videoThumbnailPath(const QString& path) { - const QFileInfo info(path); +const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; - QString key = QStringLiteral("%1_%2_%3") - .arg(info.baseName()) - .arg(info.size()) - .arg(info.lastModified().toMSecsSinceEpoch() / 1000); - key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_"); - - return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" + - key + "_preview.jpg"; -} - -bool generateVideoThumbnail(const QString& src, const QString& cachePath) { - if (QFile::exists(cachePath)) { - return true; - } - - const QString scale = QStringLiteral( - "scale=1920:1080:force_original_aspect_ratio=decrease," - "scale=trunc(iw/2)*2:trunc(ih/2)*2"); - - auto runProcess = [](const QString& program, const QStringList& args) { - QProcess process; - process.start(program, args); - process.waitForFinished(15000); - return qMakePair(process.exitCode(), process.readAllStandardOutput()); - }; - - double duration = 0.0; - const auto probe = runProcess( +bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( "ffprobe", { "-v", "error", + "-select_streams", + "v:0", "-show_entries", - "format=duration", + "stream=codec_name", "-of", "default=nw=1:nk=1", - src, + path, }); - if (probe.first == 0) { - duration = QString::fromUtf8(probe.second).trimmed().toDouble(); - } + process.waitForFinished(5000); - const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) - : 8; - - QTemporaryDir tmpDir; - if (!tmpDir.isValid()) { + if (process.exitCode() != 0) { return false; } - int bestIndex = -1; - double bestChroma = -1.0; - double bestLuma = -1.0; - - for (int i = 0; i < samples; ++i) { - const double frac = duration > 0 ? (i + 0.5) / samples : 0.0; - const QString seek = duration > 0 - ? QString::number(frac * duration, 'f', 3) - : QStringLiteral("0"); - - const QString framePath = - tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i)); - - runProcess( - "ffmpeg", - { - "-hide_banner", - "-loglevel", - "error", - "-ss", - seek, - "-i", - src, - "-frames:v", - "1", - "-vf", - scale, - "-pix_fmt", - "yuvj420p", - "-q:v", - "2", - "-y", - framePath, - }); - - QImage frame(framePath); - if (frame.isNull()) { - continue; - } - - frame = frame.convertToFormat(QImage::Format_RGB888); - - double chroma = 0.0; - double luma = 0.0; - int count = 0; - for (int y = 0; y < frame.height(); ++y) { - const auto* line = frame.constScanLine(y); - for (int x = 0; x < frame.width(); ++x) { - const int r = line[x * 3]; - const int g = line[x * 3 + 1]; - const int b = line[x * 3 + 2]; - chroma += std::max({r, g, b}) - std::min({r, g, b}); - luma += r + g + b; - ++count; - } - } - - const int n = std::max(1, count); - chroma /= n; - luma /= 3.0 * n; - - if (chroma > bestChroma + 0.5 || - (chroma > bestChroma - 0.5 && luma > bestLuma)) { - bestChroma = chroma; - bestLuma = luma; - bestIndex = i; - } - } - - if (bestIndex < 0) { - return false; - } - - QDir().mkpath(QFileInfo(cachePath).absolutePath()); - return QFile::copy( - tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)), - cachePath); + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); } FileSystemEntry::FileSystemEntry( @@ -177,7 +85,6 @@ FileSystemEntry::FileSystemEntry( , m_fileInfo(path) , m_path(path) , m_relativePath(relativePath) - , m_thumbnailPath(path) , m_isImageInitialised(false) , m_mimeTypeInitialised(false) {} @@ -227,16 +134,7 @@ bool FileSystemEntry::isVideo() const { } QString FileSystemEntry::thumbnailPath() const { - return m_thumbnailPath; -} - -void FileSystemEntry::setThumbnailPath(const QString& path) { - if (m_thumbnailPath == path) { - return; - } - - m_thumbnailPath = path; - emit thumbnailPathChanged(); + return m_path; } QString FileSystemEntry::mimeType() const { @@ -404,6 +302,46 @@ void FileSystemModel::setNameFilters(const QStringList& nameFilters) { update(); } +bool FileSystemModel::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void FileSystemModel::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + update(); +} + +bool FileSystemModel::isHardwareVideo(const QString& path) { + const QFileInfo info(path); + const QString key = + path + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch() / 1000); + + { + std::lock_guard lock(m_codecMutex); + + const auto it = m_codecCache.constFind(key); + if (it != m_codecCache.constEnd()) { + return it.value(); + } + } + + const bool hardware = probeHardwareCodec(path); + + { + std::lock_guard lock(m_codecMutex); + m_codecCache.insert(key, hardware); + } + + return hardware; +} + QQmlListProperty FileSystemModel::entries() { return QQmlListProperty(this, &m_entries); } @@ -434,14 +372,12 @@ void FileSystemModel::watchDirIfRecursive(const QString& path) { const auto paths = watcher->result(); if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) { - // Ignore if dir or showHidden has changed m_watcher.addPaths(paths); } watcher->deleteLater(); }); watcher->setFuture(future); } - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } void FileSystemModel::update() { @@ -488,6 +424,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { const auto showHidden = m_showHidden; const auto filter = m_filter; const auto nameFilters = m_nameFilters; + const auto requireHardware = m_requireHardwareCodec; QSet oldPaths; for (const auto& entry : std::as_const(m_entries)) { @@ -495,7 +432,7 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { } const auto future = QtConcurrent::run( - [=](QPromise, QSet>>& promise) { + [=, this](QPromise, QSet>>& promise) { const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags; @@ -553,18 +490,15 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { QString path = iter->next(); - if (filter == Images) { + if (hasVideoExtension(path)) { + if (requireHardware && !isHardwareVideo(path)) { + continue; + } + } else if (filter == Images || filter == Media) { QImageReader reader(path); if (!reader.canRead()) { continue; } - } else if (filter == Media) { - if (!hasVideoExtension(path)) { - QImageReader reader(path); - if (!reader.canRead()) { - continue; - } - } } newPaths.insert(path); @@ -605,31 +539,6 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) { }); watcher->setFuture(future); - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) -} - -void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) { - const QPointer guard(entry); - const QString source = entry->path(); - const QString cachePath = videoThumbnailPath(source); - - const auto future = QtConcurrent::run([source, cachePath]() { - return generateVideoThumbnail(source, cachePath); - }); - - const auto watcher = new QFutureWatcher(this); - connect( - watcher, - &QFutureWatcher::finished, - this, - [guard, cachePath, watcher]() { - if (guard && watcher->result()) { - guard->setThumbnailPath(cachePath); - } - watcher->deleteLater(); - }); - watcher->setFuture(future); - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } void FileSystemModel::applyChanges( @@ -642,7 +551,6 @@ void FileSystemModel::applyChanges( } std::sort(removedIndices.begin(), removedIndices.end(), std::greater()); - // Batch remove old entries int start = -1; int end = -1; for (int idx : std::as_const(removedIndices)) { @@ -670,14 +578,10 @@ void FileSystemModel::applyChanges( endRemoveRows(); } - // Create new entries QList newEntries; for (const auto& path : addedPaths) { auto* entry = new FileSystemEntry(path, m_dir.relativeFilePath(path), this); - if (entry->isVideo()) { - startThumbnailGeneration(entry); - } newEntries << entry; } std::sort( @@ -687,7 +591,6 @@ void FileSystemModel::applyChanges( return compareEntries(a, b); }); - // Batch insert new entries int insertStart = -1; QList batchItems; for (const auto& entry : std::as_const(newEntries)) { diff --git a/Plugins/ZShell/Models/filesystemmodel.hpp b/Plugins/ZShell/Models/filesystemmodel.hpp index b55bb47..2cabeb9 100644 --- a/Plugins/ZShell/Models/filesystemmodel.hpp +++ b/Plugins/ZShell/Models/filesystemmodel.hpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -29,8 +31,7 @@ class FileSystemEntry : public QObject { Q_PROPERTY(bool isDir READ isDir CONSTANT) Q_PROPERTY(bool isImage READ isImage CONSTANT) Q_PROPERTY(bool isVideo READ isVideo CONSTANT) - Q_PROPERTY( - QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged) + Q_PROPERTY(QString thumbnailPath READ thumbnailPath CONSTANT) Q_PROPERTY(QString mimeType READ mimeType CONSTANT) public: @@ -52,20 +53,16 @@ class FileSystemEntry : public QObject { [[nodiscard]] QString thumbnailPath() const; [[nodiscard]] QString mimeType() const; - void setThumbnailPath(const QString& path); - void updateRelativePath(const QDir& dir); signals: void relativePathChanged(); - void thumbnailPathChanged(); private: const QFileInfo m_fileInfo; const QString m_path; QString m_relativePath; - QString m_thumbnailPath; mutable bool m_isImage; mutable bool m_isImageInitialised; @@ -94,6 +91,9 @@ class FileSystemModel : public QAbstractListModel { Q_PROPERTY( QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) Q_PROPERTY( QQmlListProperty entries READ entries @@ -132,6 +132,9 @@ class FileSystemModel : public QAbstractListModel { [[nodiscard]] QStringList nameFilters() const; void setNameFilters(const QStringList& nameFilters); + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + [[nodiscard]] QQmlListProperty entries(); signals: @@ -142,6 +145,7 @@ class FileSystemModel : public QAbstractListModel { void sortReverseChanged(); void filterChanged(); void nameFiltersChanged(); + void requireHardwareCodecChanged(); void entriesChanged(); private: @@ -157,6 +161,10 @@ class FileSystemModel : public QAbstractListModel { bool m_sortReverse; Filter m_filter; QStringList m_nameFilters; + bool m_requireHardwareCodec = false; + + QHash m_codecCache; + QMutex m_codecMutex; void watchDirIfRecursive(const QString& path); void update(); @@ -165,7 +173,7 @@ class FileSystemModel : public QAbstractListModel { void updateEntriesForDir(const QString& dir); void applyChanges( const QSet& removedPaths, const QSet& addedPaths); - void startThumbnailGeneration(FileSystemEntry* entry); + [[nodiscard]] bool isHardwareVideo(const QString& path); [[nodiscard]] bool compareEntries( const FileSystemEntry* a, const FileSystemEntry* b) const; }; diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp new file mode 100644 index 0000000..25fe780 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -0,0 +1,746 @@ +#include "wallpaperdb.hpp" +#include "wallpaperutil.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(dbLog, "ZShell.Internal.wallpaper.db", QtWarningMsg) + +namespace ZShell::models { + +class WallpaperImageProvider : public QQuickImageProvider { + public: + explicit WallpaperImageProvider(const QString& dbPath) + : QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {} + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + const QString path = + QString::fromUtf8(QByteArray::fromHex(id.toUtf8())); + if (path.isEmpty() || !openThreadDb()) { + return {}; + } + + QSqlQuery query(QSqlDatabase::database(m_threadUuid)); + query.prepare("SELECT cache FROM wallpapers WHERE path = ?"); + query.addBindValue(path); + + if (!query.exec() || !query.next()) { + return {}; + } + + const QByteArray bytes = query.value(0).toByteArray(); + if (bytes.isEmpty()) { + return {}; + } + + QImage image = QImage::fromData(bytes, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (size) { + *size = image.size(); + } + + if (requestedSize.isValid() && !requestedSize.isEmpty() && + (image.width() > requestedSize.width() || + image.height() > requestedSize.height())) { + image = image.scaled( + requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + return image; + } + + private: + bool openThreadDb() { + if (!m_threadUuid.isEmpty()) { + return true; + } + + if (m_dbPath.isEmpty()) { + return false; + } + + const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + + auto db = QSqlDatabase::addDatabase("QSQLITE", uuid); + db.setConnectOptions("QSQLITE_OPEN_READONLY"); + db.setDatabaseName(m_dbPath); + + if (!db.open()) { + return false; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + + m_threadUuid = uuid; + return true; + } + + const QString m_dbPath; + thread_local static QString m_threadUuid; +}; + +thread_local QString WallpaperImageProvider::m_threadUuid; + +WallpaperEntry::WallpaperEntry( + const QString& path, const QString& relativePath, QObject* parent) + : QObject(parent) + , m_fileInfo(path) + , m_path(path) + , m_relativePath(relativePath) {} + +QString WallpaperEntry::path() const { + return m_path; +} + +QString WallpaperEntry::relativePath() const { + return m_relativePath; +} + +QString WallpaperEntry::name() const { + return m_fileInfo.fileName(); +} + +QString WallpaperEntry::baseName() const { + return m_fileInfo.baseName(); +} + +QString WallpaperEntry::parentDir() const { + return m_fileInfo.absolutePath(); +} + +QString WallpaperEntry::suffix() const { + return m_fileInfo.completeSuffix(); +} + +qint64 WallpaperEntry::size() const { + return m_fileInfo.size(); +} + +bool WallpaperEntry::isImage() const { + return !hasVideoExtension(m_path); +} + +bool WallpaperEntry::isVideo() const { + return hasVideoExtension(m_path); +} + +bool WallpaperEntry::hardware() const { + return m_hardware; +} + +bool WallpaperEntry::cached() const { + return !m_thumbnailPath.isEmpty(); +} + +QString WallpaperEntry::thumbnailPath() const { + if (!m_thumbnailPath.isEmpty()) { + return m_thumbnailPath; + } + + return isImage() ? m_path : QString(); +} + +void WallpaperEntry::setRelativePath(const QString& path) { + if (m_relativePath == path) { + return; + } + + m_relativePath = path; + emit relativePathChanged(); +} + +void WallpaperEntry::setHardware(bool hardware) { + if (m_hardware == hardware) { + return; + } + + m_hardware = hardware; + emit hardwareChanged(); +} + +void WallpaperEntry::setThumbnailPath(const QString& path) { + if (m_thumbnailPath == path) { + return; + } + + m_thumbnailPath = path; + emit thumbnailPathChanged(); +} + +WallpaperDb::WallpaperDb(QObject* parent) : QObject(parent) { + m_uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_dbPath = QDir::homePath() + "/.local/state/zshell/wallpapers.sqlite"; + + m_queueTimer.setInterval(300); + m_queueTimer.setTimerType(Qt::CoarseTimer); + QObject::connect( + &m_queueTimer, &QTimer::timeout, this, &WallpaperDb::processQueue); + + m_watchTimer.setInterval(400); + m_watchTimer.setSingleShot(true); + QObject::connect( + &m_watcher, &QFileSystemWatcher::directoryChanged, this, [this]() { + m_watchTimer.start(); + }); + QObject::connect( + &m_watchTimer, &QTimer::timeout, this, &WallpaperDb::scanDir); +} + +WallpaperDb::~WallpaperDb() { + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } +} + +QString WallpaperDb::path() const { + return m_path; +} + +void WallpaperDb::setPath(const QString& path) { + if (m_path == path) { + return; + } + + m_path = path; + m_dir = QDir(path); + emit pathChanged(); + + for (const auto& dir : m_watcher.directories()) { + m_watcher.removePath(dir); + } + + scanDir(); +} + +QString WallpaperDb::dbPath() const { + return m_dbPath; +} + +void WallpaperDb::setDbPath(const QString& path) { + if (m_dbPath == path) { + return; + } + + m_dbPath = path; + emit dbPathChanged(); + + if (!m_path.isEmpty()) { + scanDir(); + } +} + +bool WallpaperDb::requireHardwareCodec() const { + return m_requireHardwareCodec; +} + +void WallpaperDb::setRequireHardwareCodec(bool requireHardwareCodec) { + if (m_requireHardwareCodec == requireHardwareCodec) { + return; + } + + m_requireHardwareCodec = requireHardwareCodec; + emit requireHardwareCodecChanged(); + + rebuildEntries(); +} + +QQmlListProperty WallpaperDb::entries() { + return QQmlListProperty(this, &m_entries); +} + +QString WallpaperDb::thumbnailUrl(const QString& path) { + return QStringLiteral("image: + + QString::fromUtf8(path.toUtf8().toHex()); +} + +QSize WallpaperDb::cacheSize() const { + return m_cacheSize; +} + +void WallpaperDb::setCacheSize(const QSize& cacheSize) { + if (m_cacheSize == cacheSize) { + return; + } + + const QSize prev = m_cacheSize; + const bool hadPrevious = !prev.isEmpty(); + + m_cacheSize = cacheSize; + emit cacheSizeChanged(); + + if (hadPrevious && qint64(cacheSize.width()) * cacheSize.height() > + qint64(prev.width()) * prev.height()) { + const auto db = QSqlDatabase::database(m_uuid, false); + + for (auto* entry : std::as_const(m_byPath)) { + if (!entry->cached()) { + continue; + } + + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = NULL, scheme_thumb = NULL" + " WHERE path = ?"); + query.addBindValue(entry->path()); + query.exec(); + } + + entry->setThumbnailPath(QString()); + queueMetadata(entry, entry->isVideo()); + } + + rebuildEntries(); + } + + if (!m_queue.isEmpty() && !m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::registerProvider() { + if (m_provider != nullptr) { + return; + } + + auto* engine = qmlEngine(this); + if (engine == nullptr) { + return; + } + + m_provider = new WallpaperImageProvider(m_dbPath); + engine->addImageProvider(QStringLiteral("wallpapers"), m_provider); +} + +void WallpaperDb::openDb() { + if (m_dbPath.isEmpty()) { + return; + } + + if (QSqlDatabase::contains(m_uuid)) { + { + auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen() && db.databaseName() == m_dbPath) { + registerProvider(); + return; + } + + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_uuid); + } + + QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); + + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(m_dbPath); + + if (!db.open()) { + qWarning(dbLog) << "WallpaperDb: could not open database" << m_dbPath + << db.lastError(); + return; + } + + QSqlQuery query(db); + query.exec("PRAGMA journal_mode=WAL"); + query.exec( + "CREATE TABLE IF NOT EXISTS wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + + query.exec("SELECT cache FROM wallpapers LIMIT 1"); + if (query.lastError().isValid()) { + query.exec("DROP TABLE IF EXISTS wallpapers"); + query.exec( + "CREATE TABLE wallpapers(" + "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + } else { + query.exec("ALTER TABLE wallpapers ADD COLUMN scheme_thumb BLOB"); + } + + registerProvider(); +} + +void WallpaperDb::scanDir() { + openDb(); + + watchDir(m_dir.absolutePath()); + + if (!m_dir.exists()) { + if (!m_entries.isEmpty()) { + m_entries.clear(); + emit entriesChanged(); + } + + return; + } + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, &QFutureWatcher::finished, this, [this, watcher]() { + applyScan(watcher->result()); + watcher->deleteLater(); + }); + + const QDir dir = m_dir; + + QHash> knownCodecs; + for (const auto* entry : std::as_const(m_byPath)) { + if (entry->isVideo()) { + knownCodecs.insert( + entry->path(), qMakePair(entry->size(), entry->hardware())); + } + } + + watcher->setFuture(QtConcurrent::run([dir, knownCodecs]() { + ScanResult result; + result.dir = dir; + + QDirIterator it(dir.absolutePath(), QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + const QFileInfo info(path); + + if (info.isDir()) { + continue; + } + + if (hasVideoExtension(path)) { + FileEntry file; + file.info = info; + + const auto known = knownCodecs.constFind(path); + if (known != knownCodecs.constEnd() && + known.value().first == info.size()) { + file.hardware = known.value().second; + } else { + file.hardware = probeHardwareCodec(path); + } + + result.files.append(file); + } else { + QImageReader reader(path); + if (reader.canRead()) { + FileEntry file; + file.info = info; + result.files.append(file); + } + } + } + + return result; + })); +} + +void WallpaperDb::watchDir(const QString& dir) { + if (dir.isEmpty() || m_watcher.directories().contains(dir)) { + return; + } + + if (!QDir(dir).exists()) { + return; + } + + m_watcher.addPath(dir); +} + +void WallpaperDb::applyScan(const ScanResult& result) { + qCInfo( + dbLog, + "WallpaperDb: scan of %s found %d files (cache target %dx%d)", + m_dir.absolutePath().toUtf8().constData(), + int(result.files.size()), + m_cacheSize.width(), + m_cacheSize.height()); + + const auto db = QSqlDatabase::database(m_uuid, false); + + QSet seen; + for (const auto& file : result.files) { + seen.insert(file.info.absoluteFilePath()); + } + + for (auto it = m_byPath.begin(); it != m_byPath.end();) { + if (!seen.contains(it.key())) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare("DELETE FROM wallpapers WHERE path = ?"); + query.addBindValue(it.key()); + query.exec(); + } + + delete it.value(); + it = m_byPath.erase(it); + } else { + ++it; + } + } + + for (const auto& file : result.files) { + const QFileInfo& info = file.info; + const QString path = info.absoluteFilePath(); + + const qint64 size = info.size(); + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + const bool isVideo = hasVideoExtension(path); + + watchDir(info.absolutePath()); + + const auto existing = m_byPath.constFind(path); + if (existing != m_byPath.constEnd()) { + if (existing.value()->size() == size) { + existing.value()->setHardware(file.hardware); + continue; + } + } + + WallpaperEntry* entry = m_byPath.value(path); + if (!entry) { + entry = + new WallpaperEntry(path, m_dir.relativeFilePath(path), this); + m_byPath.insert(path, entry); + } + + entry->setHardware(file.hardware); + + bool reused = false; + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "SELECT cache, scheme_thumb, size, mtime FROM wallpapers" + " WHERE path = ?"); + query.addBindValue(path); + query.exec(); + + if (query.next()) { + const auto cache = query.value(0).toByteArray(); + const auto scheme = query.value(1).toByteArray(); + const auto storedSize = query.value(2).toLongLong(); + const auto storedMtime = query.value(3).toLongLong(); + + if (storedSize == size && storedMtime == mtime && + !cache.isEmpty()) { + reused = true; + entry->setThumbnailPath(thumbnailUrl(path)); + + if (scheme.isEmpty()) { + queueMetadata(entry, isVideo, cache); + } + } + } + } + + if (!reused) { + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "INSERT OR REPLACE INTO wallpapers" + " (path, kind, hardware, size, mtime, cache)" + " VALUES (?, ?, ?, ?, ?, NULL)"); + query.addBindValue(path); + query.addBindValue(isVideo ? "video" : "image"); + query.addBindValue( + isVideo ? (file.hardware ? 1 : 0) : QVariant()); + query.addBindValue(size); + query.addBindValue(mtime); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the row for" << path + << query.lastError(); + } + } + + queueMetadata(entry, isVideo); + } + } + + rebuildEntries(); +} + +void WallpaperDb::queueMetadata( + WallpaperEntry* entry, bool isVideo, const QByteArray& existingCache) { + qCInfo( + dbLog, + "WallpaperDb: queued cache job for %s (%s%s)", + entry->path().toUtf8().constData(), + isVideo ? "video" : "image", + existingCache.isEmpty() ? "" : ", existing blob"); + + m_queue.append(MetadataJob{entry, entry->path(), isVideo, existingCache}); + + if (!m_queueTimer.isActive()) { + m_queueTimer.start(); + } +} + +void WallpaperDb::processQueue() { + if (m_jobRunning) { + return; + } + + if (m_queue.isEmpty()) { + m_queueTimer.stop(); + return; + } + + if (m_cacheSize.isEmpty()) { + return; + } + + const MetadataJob job = m_queue.takeFirst(); + m_jobRunning = true; + + qCInfo( + dbLog, + "WallpaperDb: caching %s (%s, %d jobs left in queue)", + job.path.toUtf8().constData(), + job.isVideo ? "video" : "image", + int(m_queue.size())); + + runMetadataJob(job); +} + +void WallpaperDb::runMetadataJob(const MetadataJob& job) { + struct CacheResult { + QString path; + QByteArray cache; + QByteArray scheme; + }; + + const QSize cacheSize = m_cacheSize; + + QElapsedTimer clock; + clock.start(); + + auto* watcher = new QFutureWatcher(this); + QObject::connect( + watcher, + &QFutureWatcher::finished, + this, + [this, watcher, clock]() { + const auto result = watcher->result(); + + qCInfo( + dbLog, + "WallpaperDb: %s produced %d bytes of cache in %dms", + result.path.toUtf8().constData(), + int(result.cache.size()), + int(clock.elapsed())); + + if (QSqlDatabase::contains(m_uuid)) { + const auto db = QSqlDatabase::database(m_uuid, false); + if (db.isOpen()) { + QSqlQuery query(db); + query.prepare( + "UPDATE wallpapers SET cache = ?, scheme_thumb = ?" + " WHERE path = ?"); + query.addBindValue(result.cache); + query.addBindValue(result.scheme); + query.addBindValue(result.path); + if (!query.exec()) { + qWarning(dbLog) + << "WallpaperDb: could not store the cache for" + << result.path << query.lastError(); + } + } + } + + if (!result.cache.isEmpty()) { + if (auto* entry = m_byPath.value(result.path)) { + entry->setThumbnailPath(thumbnailUrl(result.path)); + } + + emit cacheStored(result.path); + } + + m_jobRunning = false; + rebuildEntries(); + watcher->deleteLater(); + }); + + watcher->setFuture(QtConcurrent::run([job, cacheSize]() { + CacheResult result; + result.path = job.path; + + QByteArray cache = job.existingCache; + if (cache.isEmpty()) { + cache = job.isVideo ? videoCacheBytes(job.path, cacheSize) + : imageCacheBytes(job.path, cacheSize); + } + + result.cache = cache; + result.scheme = cache.isEmpty() ? QByteArray() + : schemeThumbBytes(cache, 128); + + return result; + })); +} + +void WallpaperDb::rebuildEntries() { + QList next; + next.reserve(m_byPath.size()); + + for (auto* entry : std::as_const(m_byPath)) { + if (entryAllowed(entry)) { + next.append(entry); + } + } + + std::sort( + next.begin(), + next.end(), + [](const WallpaperEntry* a, const WallpaperEntry* b) { + return a->relativePath() < b->relativePath(); + }); + + if (next == m_entries) { + return; + } + + m_entries = next; + emit entriesChanged(); +} + +bool WallpaperDb::entryAllowed(const WallpaperEntry* entry) const { + if (!entry->isVideo()) { + return true; + } + + if (!m_requireHardwareCodec) { + return true; + } + + return entry->hardware(); +} + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperdb.hpp b/Plugins/ZShell/Models/wallpaperdb.hpp new file mode 100644 index 0000000..30592e4 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperdb.hpp @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::models { + +class WallpaperImageProvider; + +class WallpaperEntry : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE( + "WallpaperEntry instances can only be retrieved from a WallpaperDb") + + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged) + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString baseName READ baseName CONSTANT) + Q_PROPERTY(QString parentDir READ parentDir CONSTANT) + Q_PROPERTY(QString suffix READ suffix CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(bool isVideo READ isVideo CONSTANT) + Q_PROPERTY(bool hardware READ hardware NOTIFY hardwareChanged) + Q_PROPERTY( + QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged) + + public: + explicit WallpaperEntry( + const QString& path, + const QString& relativePath, + QObject* parent = nullptr); + + [[nodiscard]] QString path() const; + [[nodiscard]] QString relativePath() const; + [[nodiscard]] QString name() const; + [[nodiscard]] QString baseName() const; + [[nodiscard]] QString parentDir() const; + [[nodiscard]] QString suffix() const; + [[nodiscard]] qint64 size() const; + [[nodiscard]] bool isImage() const; + [[nodiscard]] bool isVideo() const; + [[nodiscard]] bool hardware() const; + [[nodiscard]] bool cached() const; + [[nodiscard]] QString thumbnailPath() const; + + void setRelativePath(const QString& path); + void setHardware(bool hardware); + void setThumbnailPath(const QString& path); + + signals: + void relativePathChanged(); + void hardwareChanged(); + void thumbnailPathChanged(); + + private: + const QFileInfo m_fileInfo; + + const QString m_path; + QString m_relativePath; + QString m_thumbnailPath; + bool m_hardware = false; +}; + +class WallpaperDb : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + Q_PROPERTY(QString dbPath READ dbPath WRITE setDbPath NOTIFY dbPathChanged) + Q_PROPERTY( + bool requireHardwareCodec READ requireHardwareCodec WRITE + setRequireHardwareCodec NOTIFY requireHardwareCodecChanged) + Q_PROPERTY( + QQmlListProperty entries READ entries + NOTIFY entriesChanged) + Q_PROPERTY( + QSize cacheSize READ cacheSize WRITE setCacheSize NOTIFY + cacheSizeChanged) + + public: + explicit WallpaperDb(QObject* parent = nullptr); + ~WallpaperDb() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QString dbPath() const; + void setDbPath(const QString& path); + + [[nodiscard]] bool requireHardwareCodec() const; + void setRequireHardwareCodec(bool requireHardwareCodec); + + [[nodiscard]] QQmlListProperty entries(); + + [[nodiscard]] QSize cacheSize() const; + void setCacheSize(const QSize& cacheSize); + + [[nodiscard]] static QString thumbnailUrl(const QString& path); + + signals: + void pathChanged(); + void dbPathChanged(); + void requireHardwareCodecChanged(); + void entriesChanged(); + void cacheSizeChanged(); + + void cacheStored(const QString& path); + + private: + struct FileEntry { + QFileInfo info; + bool hardware = false; + }; + + struct ScanResult { + QVector files; + QDir dir; + }; + + struct MetadataJob { + WallpaperEntry* entry; + QString path; + bool isVideo; + QByteArray existingCache; + }; + + void openDb(); + void registerProvider(); + void scanDir(); + void watchDir(const QString& dir); + void applyScan(const ScanResult& result); + void queueMetadata( + WallpaperEntry* entry, + bool isVideo, + const QByteArray& existingCache = {}); + void processQueue(); + void runMetadataJob(const MetadataJob& job); + void rebuildEntries(); + + [[nodiscard]] bool entryAllowed(const WallpaperEntry* entry) const; + + QDir m_dir; + QString m_path; + QString m_dbPath; + QString m_uuid; + WallpaperImageProvider* m_provider = nullptr; + + bool m_requireHardwareCodec = false; + QSize m_cacheSize; + + QList m_entries; + QHash m_byPath; + + QVector m_queue; + QTimer m_queueTimer; + bool m_jobRunning = false; + + QFileSystemWatcher m_watcher; + QTimer m_watchTimer; +}; + +} // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperutil.hpp b/Plugins/ZShell/Models/wallpaperutil.hpp new file mode 100644 index 0000000..d5b3a13 --- /dev/null +++ b/Plugins/ZShell/Models/wallpaperutil.hpp @@ -0,0 +1,313 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ZShell::models { +namespace { + +inline const QStringList kVideoExtensions = { + "mp4", + "mkv", + "webm", + "avi", + "mov", + "wmv", + "flv", + "m4v", + "mpg", + "mpeg", + "3gp", + "ogv", + "ts", + "vob", +}; + +inline bool hasVideoExtension(const QString& path) { + return kVideoExtensions.contains(QFileInfo(path).suffix().toLower()); +} + +inline const QStringList kHardwareCodecs = { + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", +}; + +inline bool probeHardwareCodec(const QString& path) { + QProcess process; + process.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + process.waitForFinished(5000); + + if (process.exitCode() != 0) { + return false; + } + + const QString codec = + QString::fromUtf8(process.readAllStandardOutput()).trimmed().toLower(); + return kHardwareCodecs.contains(codec); +} + +inline QSize coverSizeNoUpscale(const QSize& source, const QSize& fit) { + if (!source.isValid() || source.isEmpty() || !fit.isValid() || + fit.isEmpty()) { + return source; + } + + const QSize cover = source.scaled(fit, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && cover.height() <= source.height()) { + return cover; + } + + return source; +} + +inline QImage cleanImage(const QImage& src) { + QImage out(src.size(), QImage::Format_RGB888); + if (out.isNull()) { + return out; + } + + const auto rowBytes = + static_cast(std::min(src.bytesPerLine(), out.bytesPerLine())); + for (int y = 0; y < src.height(); ++y) { + std::memcpy(out.scanLine(y), src.constScanLine(y), rowBytes); + } + + return out; +} + +inline QByteArray imageCacheBytes(const QString& path, const QSize& fit) { + QImageReader reader(path); + reader.setAutoTransform(true); + + const QSize source = reader.size(); + const QSize target = coverSizeNoUpscale(source, fit); + if (target != source && !target.isEmpty()) { + reader.setScaledSize(target); + } + + QImage image = reader.read(); + if (image.isNull()) { + return {}; + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 90)) { + return {}; + } + + return bytes; +} + +inline QByteArray videoCacheBytes(const QString& src, const QSize& fit) { + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + if (!process.waitForFinished(15000)) { + process.kill(); + process.waitForFinished(1000); + } + return qMakePair(process.exitCode(), process.readAllStandardOutput()); + }; + + double duration = 0.0; + const auto probe = runProcess( + "ffprobe", + { + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=nw=1:nk=1", + src, + }); + if (probe.first == 0) { + duration = QString::fromUtf8(probe.second).trimmed().toDouble(); + } + + QSize source; + const auto resProbe = runProcess( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + src, + }); + if (resProbe.first == 0) { + const auto parts = + QString::fromUtf8(resProbe.second).trimmed().split('x'); + if (parts.size() == 2) { + source = QSize(parts[0].toInt(), parts[1].toInt()); + } + } + + const int samples = duration > 0 ? std::min(10, std::max(4, int(duration))) + : 8; + + QSize target = coverSizeNoUpscale(source, fit); + if (target.isEmpty()) { + target = fit; + } + + target.setWidth(target.width() & ~1); + target.setHeight(target.height() & ~1); + + const QString scale = + QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2") + .arg(target.width()) + .arg(target.height()); + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return {}; + } + + int bestIndex = -1; + double bestChroma = -1.0; + double bestLuma = -1.0; + + for (int i = 0; i < samples; ++i) { + const double frac = duration > 0 ? (i + 0.5) / samples : 0.0; + const QString seek = duration > 0 + ? QString::number(frac * duration, 'f', 3) + : QStringLiteral("0"); + + const QString framePath = + tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i)); + + runProcess( + "ffmpeg", + { + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek, + "-i", + src, + "-frames:v", + "1", + "-vf", + scale, + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + framePath, + }); + + QImage frame(framePath); + if (frame.isNull()) { + continue; + } + + frame = frame.convertToFormat(QImage::Format_RGB888); + + double chroma = 0.0; + double luma = 0.0; + int count = 0; + for (int y = 0; y < frame.height(); ++y) { + const auto* line = frame.constScanLine(y); + for (int x = 0; x < frame.width(); ++x) { + const int r = line[x * 3]; + const int g = line[x * 3 + 1]; + const int b = line[x * 3 + 2]; + chroma += std::max({r, g, b}) - std::min({r, g, b}); + luma += r + g + b; + ++count; + } + } + + const int n = std::max(1, count); + chroma /= n; + luma /= 3.0 * n; + + if (chroma > bestChroma + 0.5 || + (chroma > bestChroma - 0.5 && luma > bestLuma)) { + bestChroma = chroma; + bestLuma = luma; + bestIndex = i; + } + } + + if (bestIndex < 0) { + return {}; + } + + QFile frame(tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex))); + if (!frame.open(QIODevice::ReadOnly)) { + return {}; + } + + return frame.readAll(); +} + +inline QByteArray schemeThumbBytes(const QByteArray& source, int maxSide) { + QImage image = QImage::fromData(source, "JPEG"); + if (image.isNull()) { + return {}; + } + + if (image.width() > maxSide || image.height() > maxSide) { + image = image.scaled( + maxSide, maxSide, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + image = cleanImage(image.convertToFormat(QImage::Format_RGB888)); + + QByteArray bytes; + QBuffer buffer(&bytes); + if (!buffer.open(QIODevice::WriteOnly) || + !image.save(&buffer, "JPEG", 85)) { + return {}; + } + + return bytes; +} + +} // namespace + +} // namespace ZShell::models diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index adc36c2..5c1fc01 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -1,8 +1,10 @@ import contextlib +import io import json import os import re import shutil +import sqlite3 import subprocess import sys import tempfile @@ -50,6 +52,30 @@ VIDEO_EXTENSIONS = { } +def scheme_thumb_from_db(db_path: str, wallpaper_path: str) -> bytes | None: + if not os.path.exists(db_path): + return None + + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return None + + try: + row = conn.execute( + "SELECT scheme_thumb FROM wallpapers WHERE path = ?", + (wallpaper_path,), + ).fetchone() + except sqlite3.Error: + return None + finally: + conn.close() + + if row and row[0]: + return bytes(row[0]) + return None + + def _complete_scheme_name(incomplete): schemes = [ "fruit-salad", @@ -181,6 +207,7 @@ def generate( OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails") + DB_PATH = Path(HOME + "/.local/state/zshell/wallpapers.sqlite") WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json") TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates") @@ -808,13 +835,20 @@ def generate( seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) else: image_path = image_path or Path(WALL_PATH) - thumb_path = generate_thumbnail(image_path, THUMB_DIR) - seed = seed_from_image(thumb_path) + + blob = scheme_thumb_from_db(str(DB_PATH), str(image_path)) + if blob is not None: + open_thumb = lambda: io.BytesIO(blob) + else: + thumb_path = generate_thumbnail(image_path, THUMB_DIR) + open_thumb = lambda: thumb_path + + seed = seed_from_image(open_thumb()) name = "dynamic" flavor = "default" if smart: - effective_mode = smart_mode(thumb_path) + effective_mode = smart_mode(open_thumb()) elif mode is not None: effective_mode = mode else: -- 2.47.3 From 811ad6b7f4d83520966e9b33bd8bdb8b2627baec Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 26 Oct 2026 03:03:20 +0100 Subject: [PATCH 05/15] fix: restore code removed by incorrect replacement command --- Plugins/ZShell/Models/wallpaperdb.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp index 25fe780..ce2e631 100644 --- a/Plugins/ZShell/Models/wallpaperdb.cpp +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -275,8 +275,8 @@ QQmlListProperty WallpaperDb::entries() { } QString WallpaperDb::thumbnailUrl(const QString& path) { - return QStringLiteral("image: - + QString::fromUtf8(path.toUtf8().toHex()); + return QStringLiteral("image://wallpapers/") + + QString::fromUtf8(path.toUtf8().toHex()); } QSize WallpaperDb::cacheSize() const { -- 2.47.3 From 906b15b09203c118512852a555dd40d4fd3df295 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 26 Oct 2026 03:13:33 +0100 Subject: [PATCH 06/15] fix: don't store dbPath as const --- Plugins/ZShell/Models/wallpaperdb.cpp | 50 +++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp index ce2e631..b69fd50 100644 --- a/Plugins/ZShell/Models/wallpaperdb.cpp +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -17,6 +17,8 @@ #include #include +#include +#include Q_LOGGING_CATEGORY(dbLog, "ZShell.Internal.wallpaper.db", QtWarningMsg) @@ -27,11 +29,21 @@ class WallpaperImageProvider : public QQuickImageProvider { explicit WallpaperImageProvider(const QString& dbPath) : QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {} + void setDbPath(const QString& dbPath) { + std::lock_guard lock(m_mutex); + if (m_dbPath == dbPath) { + return; + } + + m_dbPath = dbPath; + m_generation.fetch_add(1, std::memory_order_relaxed); + } + QImage requestImage( const QString& id, QSize* size, const QSize& requestedSize) override { const QString path = QString::fromUtf8(QByteArray::fromHex(id.toUtf8())); - if (path.isEmpty() || !openThreadDb()) { + if (path.isEmpty() || !openThreadDb(currentDbPath())) { return {}; } @@ -68,12 +80,31 @@ class WallpaperImageProvider : public QQuickImageProvider { } private: - bool openThreadDb() { - if (!m_threadUuid.isEmpty()) { + QString currentDbPath() const { + std::lock_guard lock(m_mutex); + return m_dbPath; + } + + bool openThreadDb(const QString& dbPath) { + const quint64 generation = m_generation.load(std::memory_order_relaxed); + + if (!m_threadUuid.isEmpty() && m_threadGeneration == generation) { return true; } - if (m_dbPath.isEmpty()) { + if (!m_threadUuid.isEmpty()) { + { + auto db = QSqlDatabase::database(m_threadUuid, false); + if (db.isValid()) { + db.close(); + } + } + + QSqlDatabase::removeDatabase(m_threadUuid); + m_threadUuid.clear(); + } + + if (dbPath.isEmpty()) { return false; } @@ -81,7 +112,7 @@ class WallpaperImageProvider : public QQuickImageProvider { auto db = QSqlDatabase::addDatabase("QSQLITE", uuid); db.setConnectOptions("QSQLITE_OPEN_READONLY"); - db.setDatabaseName(m_dbPath); + db.setDatabaseName(dbPath); if (!db.open()) { return false; @@ -91,14 +122,20 @@ class WallpaperImageProvider : public QQuickImageProvider { query.exec("PRAGMA journal_mode=WAL"); m_threadUuid = uuid; + m_threadGeneration = generation; return true; } - const QString m_dbPath; + mutable std::mutex m_mutex; + QString m_dbPath; + std::atomic m_generation = 0; + thread_local static QString m_threadUuid; + thread_local static quint64 m_threadGeneration; }; thread_local QString WallpaperImageProvider::m_threadUuid; +thread_local quint64 WallpaperImageProvider::m_threadGeneration; WallpaperEntry::WallpaperEntry( const QString& path, const QString& relativePath, QObject* parent) @@ -326,6 +363,7 @@ void WallpaperDb::setCacheSize(const QSize& cacheSize) { void WallpaperDb::registerProvider() { if (m_provider != nullptr) { + m_provider->setDbPath(m_dbPath); return; } -- 2.47.3 From 3792e9d73f464977d8726ffe7a35d41e9d84f2d3 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 26 Oct 2026 14:47:47 +0100 Subject: [PATCH 07/15] feat: add wallpaper transitions, store crop in database per-wallpaper, move CUDA context creation to async thread --- Components/Menu.qml | 8 +- Helpers/Wallpapers.qml | 102 +- Modules/Settings/Common/WallpaperCropper.qml | 6 + Modules/Settings/Pages/WallpaperPage.qml | 48 + Plugins/ZShell/Config/background.hpp | 7 +- Plugins/ZShell/Config/enums.hpp | 13 + Plugins/ZShell/Internal/CMakeLists.txt | 11 + .../shaders/wallpaper_trans_bowtie.frag | 106 ++ .../shaders/wallpaper_trans_doom_melt.frag | 72 + .../shaders/wallpaper_trans_fade.frag | 39 + .../shaders/wallpaper_trans_hexagonalize.frag | 101 ++ .../shaders/wallpaper_trans_linear_blur.frag | 58 + ...paper_trans_split_slide_in_horizontal.frag | 49 + .../shaders/wallpaper_trans_tiles_wave.frag | 89 ++ .../wallpaper_trans_window_slices.frag | 42 + .../shaders/wallpaper_trans_wipe_down.frag | 34 + .../shaders/wallpaper_trans_wipe_right.frag | 34 + Plugins/ZShell/Internal/videoplayer.cpp | 42 +- Plugins/ZShell/Internal/videoplayer.hpp | 3 +- Plugins/ZShell/Internal/wallpaperobject.cpp | 40 +- Plugins/ZShell/Internal/wallpaperobject.hpp | 4 + Plugins/ZShell/Internal/wallpaperservice.cpp | 1282 +++++++++++++++-- Plugins/ZShell/Internal/wallpaperservice.hpp | 131 +- Plugins/ZShell/Models/wallpaperdb.cpp | 126 +- Plugins/ZShell/Models/wallpaperdb.hpp | 16 +- Plugins/ZShell/Models/wallpaperutil.hpp | 85 ++ 26 files changed, 2338 insertions(+), 210 deletions(-) create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag create mode 100644 Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag diff --git a/Components/Menu.qml b/Components/Menu.qml index 1cdb710..30ee943 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -35,7 +35,7 @@ MouseArea { cursorShape: expanded ? Qt.ArrowCursor : undefined enabled: expanded hoverEnabled: expanded - layer.enabled: opacity < 1 + layer.enabled: false opacity: expanded ? 1 : 0 parent: { const win = QsWindow.window; @@ -102,8 +102,7 @@ MouseArea { yScale: root.expanded ? 1 : 0.1 Behavior on yScale { - Anim { - } + Anim {} } } @@ -140,8 +139,7 @@ MouseArea { radius: menu.radius - column.anchors.margins Behavior on radius { - Anim { - } + Anim {} } StateLayer { diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index 272e106..e8226ae 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -16,16 +16,15 @@ Searcher { id: root property string actualCurrent: WallpaperPath.currentWallpaperPath - property alias crops: adapter.monitorCrops readonly property string current: showPreview ? previewPath : actualCurrent readonly property string currentThumbnail: thumbnailFor(current) readonly property bool currentIsVideo: isVideoPath(current) - property alias monitorCrops: monitorCrops property bool pendingPreviewClear property bool previewColorLock property string previewPath property bool recentlyChanged property bool showPreview: false + property int cropVersion: 0 function getCategoryFor(w: WallpaperEntry): string { let category = w.parentDir.slice(Paths.wallsdir.length + 1); @@ -58,7 +57,7 @@ Searcher { } function getCrop(screen: string): var { - return root.crops[screen]; + return wallpapers.crop(root.current, screen); } function preview(path: string): void { @@ -75,19 +74,18 @@ Searcher { else if (zoom > 5.0) zoom = 5.0; - root.crops[screen] = { + wallpapers.setCrop(root.current, screen, { x: rect.x, y: rect.y, width: rect.width, height: rect.height, zoom: zoom - }; + }); } function setWallpaper(path: string): void { actualCurrent = path; WallpaperPath.currentWallpaperPath = path; - Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); if (Config.colors.schemeGen) Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); @@ -121,32 +119,6 @@ Searcher { target: "wallpaper" } - FileView { - id: monitorCrops - - path: `${Paths.state}/wallpaper-crops.json` - watchChanges: true - - onAdapterUpdated: cropWriteDelay.restart() - onFileChanged: reload() - - JsonAdapter { - id: adapter - - property var monitorCrops: ({}) - } - } - - Timer { - id: cropWriteDelay - - interval: 100 - repeat: false - running: false - - onTriggered: monitorCrops.writeAdapter() - } - WallpaperDb { id: wallpapers @@ -183,35 +155,89 @@ Searcher { if (path === root.current) wallpaper.invalidateCache(); } + + function onCropsChanged(path: string): void { + if (path === root.current) + root.cropVersion++; + } } Variants { + id: screenVariants model: Quickshell.screens WallpaperObject { - readonly property var crops: root.getCrop(modelData.name) + id: obj required property var modelData - cropHeight: crops?.height ?? 1.0 - cropWidth: crops?.width ?? 1.0 - cropX: crops?.x ?? 0.0 - cropY: crops?.y ?? 0.0 + // Crop animation only for edits, never for wallpaper swaps. + property bool animateCrop: false + screen: modelData.name service: wallpaper - zoom: crops?.zoom ?? 1.0 + + // Apply the wallpaper's own crop. Driven off the service's pathChanged + // (which fires after applyPath has snapshotted the outgoing frame with + // the outgoing crop) and off crop edits, never as a reactive binding on + // the wallpaper path — so a swap applies the incoming crop atomically + // after the base is captured. + function seedCrop(): void { + // Load the current wallpaper's crop without pushing: the service + // already read it from the database. This only seeds the values so + // a later edit animates from the right starting point. + obj.syncCrop(root.getCrop(modelData.name)); + } + + // Apply an edited crop: assign via the setters so the Behaviors animate + // the numbers and push each intermediate value to the service. + function editCrop(): void { + const c = root.getCrop(modelData.name); + obj.cropX = c?.x ?? 0.0; + obj.cropY = c?.y ?? 0.0; + obj.cropWidth = c?.width ?? 1.0; + obj.cropHeight = c?.height ?? 1.0; + obj.zoom = c?.zoom ?? 1.0; + } + + Component.onCompleted: seedCrop() + onScreenChanged: seedCrop() Behavior on cropHeight { + enabled: obj.animateCrop Anim {} } Behavior on cropWidth { + enabled: obj.animateCrop Anim {} } Behavior on cropX { + enabled: obj.animateCrop Anim {} } Behavior on cropY { + enabled: obj.animateCrop Anim {} } } } + + Connections { + target: wallpaper + function onPathChanged(): void { + for (const obj of screenVariants.instances) { + obj.animateCrop = false; + obj.seedCrop(); + } + } + } + + Connections { + target: root + function onCropVersionChanged(): void { + for (const obj of screenVariants.instances) { + obj.animateCrop = true; + obj.editCrop(); + } + } + } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index eb116b6..775acf9 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -14,10 +14,13 @@ Item { id: wrapper readonly property bool isVideo: Wallpapers.currentIsVideo + readonly property string currentWallpaper: Wallpapers.current readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null readonly property real maxZoom: 5.0 readonly property bool changesMade: { cropRevision; + Wallpapers.cropVersion; + wrapper.currentWallpaper; const r = cropRectLoader.item; if (!r || !currentScreen || scaledImg.status !== Image.Ready) @@ -115,6 +118,9 @@ Item { refreshScreens(); Qt.callLater(syncCropToScreen); } + onCurrentWallpaperChanged: { + Qt.callLater(syncCropToScreen); + } onSelectedScreenIndexChanged: { Qt.callLater(syncCropToScreen); } diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index a5c2445..b48c936 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -13,6 +13,42 @@ import qs.Services PageBase { id: root + readonly property list wallTransitions: [ + MenuItem { + text: "Bowtie" + }, + MenuItem { + text: "Fade" + }, + MenuItem { + text: "LinearBlur" + }, + MenuItem { + text: "WindowSlices" + }, + MenuItem { + text: "WipeRight" + }, + MenuItem { + text: "WipeDown" + }, + MenuItem { + text: "TilesWave" + }, + MenuItem { + text: "SplitSlideInHorizontal" + }, + MenuItem { + text: "Hexagonalize" + }, + MenuItem { + text: "DoomMelt" + }, + MenuItem { + text: "Random" + } + ] + title: qsTr("Wallpaper & style") SnapLayout { @@ -74,6 +110,18 @@ orientation: Qt.Vertical onToggled: Config.background.enabled = checked } + SelectRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + active: root.wallTransitions[Config.background.wallTransition] + menuItems: root.wallTransitions + menuOnTop: true + settingAnchor: "style-wallpaper-transition" + subtext: qsTr("Currently selected: %1").arg(active.text) + text: qsTr("Wallpaper transition") + + onSelected: item => Config.background.wallTransition = root.wallTransitions.indexOf(item) + } + ToggleRow { Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing checked: Colors.transparency.enabled diff --git a/Plugins/ZShell/Config/background.hpp b/Plugins/ZShell/Config/background.hpp index d079a2a..1ef73f1 100644 --- a/Plugins/ZShell/Config/background.hpp +++ b/Plugins/ZShell/Config/background.hpp @@ -1,7 +1,10 @@ #pragma once -#include "common.hpp" + #include +#include "common.hpp" +#include "enums.hpp" + namespace ZShell::config { class Background : public settings::ObjectNode { @@ -9,6 +12,8 @@ class Background : public settings::ObjectNode { CONFIG_PROPERTY(bool, enabled, true) CONFIG_PROPERTY(int, wallFadeDuration, 300) + CONFIG_ENUM_PROPERTY( + WallpaperTransition, wallTransition, WallpaperTransition::Fade) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp index 9ba61a0..f2bb9b5 100644 --- a/Plugins/ZShell/Config/enums.hpp +++ b/Plugins/ZShell/Config/enums.hpp @@ -23,6 +23,19 @@ ENUM(NotifsFullscreen, On, Off) ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) ENUM(DataUnit, Binary, Decimal) ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) +ENUM( + WallpaperTransition, + Bowtie, + Fade, + LinearBlur, + WindowSlices, + WipeRight, + WipeDown, + TilesWave, + SplitSlideInHorizontal, + Hexagonalize, + DoomMelt, + Random) #undef ENUM diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index a81311e..482460d 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -45,6 +45,7 @@ qml_module(ZShell-internal PkgConfig::AVCODEC PkgConfig::AVFORMAT PkgConfig::AVUTIL + ZShell-config ) qt_add_resources(ZShell-internal "wallpaper_shaders" @@ -53,6 +54,16 @@ qt_add_resources(ZShell-internal "wallpaper_shaders" shaders/wallpaper.vert shaders/wallpaper.frag shaders/wallpaper_video.frag + shaders/wallpaper_trans_bowtie.frag + shaders/wallpaper_trans_fade.frag + shaders/wallpaper_trans_linear_blur.frag + shaders/wallpaper_trans_window_slices.frag + shaders/wallpaper_trans_wipe_right.frag + shaders/wallpaper_trans_wipe_down.frag + shaders/wallpaper_trans_tiles_wave.frag + shaders/wallpaper_trans_split_slide_in_horizontal.frag + shaders/wallpaper_trans_hexagonalize.frag + shaders/wallpaper_trans_doom_melt.frag ) if(CUDAToolkit_FOUND) diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag new file mode 100644 index 0000000..3806896 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag @@ -0,0 +1,106 @@ +// Bow-tie wipe transition adapted from gl-transitions.com +// Author: huynx (MIT) +// Original: two triangles grow from the top and bottom edges into a bow tie +// that reveals the incoming image. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +float check(vec2 p1, vec2 p2, vec2 p3) { + return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); +} + +bool pointInTriangle(vec2 pt, vec2 p1, vec2 p2, vec2 p3) { + bool b1 = check(pt, p1, p2) < 0.0; + bool b2 = check(pt, p2, p3) < 0.0; + bool b3 = check(pt, p3, p1) < 0.0; + return (b1 == b2) && (b2 == b3); +} + +bool in_top_triangle(vec2 p) { + vec2 vertex1 = vec2(0.5, progress); + vec2 vertex2 = vec2(0.5 - progress, 0.0); + vec2 vertex3 = vec2(0.5 + progress, 0.0); + return pointInTriangle(p, vertex1, vertex2, vertex3); +} + +bool in_bottom_triangle(vec2 p) { + vec2 vertex1 = vec2(0.5, 1.0 - progress); + vec2 vertex2 = vec2(0.5 - progress, 1.0); + vec2 vertex3 = vec2(0.5 + progress, 1.0); + return pointInTriangle(p, vertex1, vertex2, vertex3); +} + +float blur_edge(vec2 bot1, vec2 bot2, vec2 top, vec2 testPt) { + vec2 lineDir = bot1 - top; + vec2 perpDir = vec2(lineDir.y, -lineDir.x); + vec2 dirToPt1 = bot1 - testPt; + float dist1 = abs(dot(normalize(perpDir), dirToPt1)); + + lineDir = bot2 - top; + perpDir = vec2(lineDir.y, -lineDir.x); + dirToPt1 = bot2 - testPt; + float min_dist = min(abs(dot(normalize(perpDir), dirToPt1)), dist1); + + if (min_dist < 0.005) + return min_dist / 0.005; + return 1.0; +} + +vec4 transition(vec2 uv) { + if (in_top_triangle(uv)) { + if (progress < 0.1) + return getFromColor(uv); + + if (uv.y < 0.5) { + vec2 vertex1 = vec2(0.5, progress); + vec2 vertex2 = vec2(0.5 - progress, 0.0); + vec2 vertex3 = vec2(0.5 + progress, 0.0); + return mix( + getFromColor(uv), + getToColor(uv), + blur_edge(vertex2, vertex3, vertex1, uv)); + } + if (progress > 0.0) + return getToColor(uv); + return getFromColor(uv); + } + + if (in_bottom_triangle(uv)) { + if (uv.y >= 0.5) { + vec2 vertex1 = vec2(0.5, 1.0 - progress); + vec2 vertex2 = vec2(0.5 - progress, 1.0); + vec2 vertex3 = vec2(0.5 + progress, 1.0); + return mix( + getFromColor(uv), + getToColor(uv), + blur_edge(vertex2, vertex3, vertex1, uv)); + } + return getFromColor(uv); + } + + return getFromColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag new file mode 100644 index 0000000..5b428cd --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag @@ -0,0 +1,72 @@ +// Doom-style melt wipe, from gl-transitions.com +// Author: Zeh Fernando (MIT) +// Description: the outgoing image melts down in uneven columns, like the Doom +// screen wipe. Fully procedural (no displacement texture). +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). Optional +// gl-transitions params are fixed here: bars = 30, amplitude = 2.0, +// noise = 0.1, frequency = 0.5, dripScale = 0.5. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const int bars = 30; +const float amplitude = 2.0; +const float noise = 0.1; +const float frequency = 0.5; +const float dripScale = 0.5; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +float rand(int num) { + return fract(mod(float(num) * 67123.313, 12.0) * sin(float(num) * 10.3) * + cos(float(num))); +} + +float wave(int num) { + float fn = float(num) * frequency * 0.1 * float(bars); + return cos(fn * 0.5) * cos(fn * 0.13) * sin((fn + 10.0) * 0.3) / 2.0 + 0.5; +} + +float drip(int num) { + return sin(float(num) / float(bars - 1) * 3.141592) * dripScale; +} + +float pos(int num) { + return (noise == 0.0 ? wave(num) + : mix(wave(num), rand(num), noise)) + + (dripScale == 0.0 ? 0.0 : drip(num)); +} + +vec4 transition(vec2 uv) { + int bar = int(uv.x * float(bars)); + float scale = 1.0 + pos(bar) * amplitude; + float phase = progress * scale; + float posY = uv.y; + + if (phase + posY < 1.0) { + return getFromColor(vec2(uv.x, uv.y + phase)); + } + return getToColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag new file mode 100644 index 0000000..34a4fce --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag @@ -0,0 +1,39 @@ +// Fade (crossfade) transition, from gl-transitions.com. +// +// Ported to ZShell's own GLSL ABI (same contract as wallpaper_trans_bowtie.frag): +// version/precision header is prepended by WallpaperService::shaderSource, the +// fullscreen triangle comes from shaders/wallpaper.vert, and getFromColor / +// getToColor sample with the crop + per-layer flip convention. +// +// uniforms: samplers `from`/`to`, vec2 size, vec4 fromCrop, vec4 toCrop, +// float fromFlip, float toFlip, float progress (0..1). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float fromFlip; +uniform float toFlip; +uniform float progress; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag new file mode 100644 index 0000000..276b777 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag @@ -0,0 +1,101 @@ +// Hexagonalize crossfade, from gl-transitions.com +// Author: Fernando Kuteken (MIT) +// Description: both images turn into hexagons, crossfade, then sharpen again. +// Hexagonal math from http://www.redblobgames.com/grids/hexagons/ +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). The +// gl-transitions `ratio` contextual value is derived from `size`. Optional +// params are fixed here: steps = 50, horizontalHexagons = 20. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const int steps = 50; +const float horizontalHexagons = 20.0; +float ratio; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +struct Hexagon { + float q; + float r; + float s; +}; + +Hexagon createHexagon(float q, float r) { + Hexagon hex; + hex.q = q; + hex.r = r; + hex.s = -q - r; + return hex; +} + +Hexagon roundHexagon(Hexagon hex) { + float q = floor(hex.q + 0.5); + float r = floor(hex.r + 0.5); + float s = floor(hex.s + 0.5); + + float deltaQ = abs(q - hex.q); + float deltaR = abs(r - hex.r); + float deltaS = abs(s - hex.s); + + if (deltaQ > deltaR && deltaQ > deltaS) + q = -r - s; + else if (deltaR > deltaS) + r = -q - s; + else + s = -q - r; + + return createHexagon(q, r); +} + +Hexagon hexagonFromPoint(vec2 point, float size) { + point.y /= ratio; + point = (point - 0.5) / size; + + float q = (sqrt(3.0) / 3.0) * point.x + (-1.0 / 3.0) * point.y; + float r = 0.0 * point.x + 2.0 / 3.0 * point.y; + + Hexagon hex = createHexagon(q, r); + return roundHexagon(hex); +} + +vec2 pointFromHexagon(Hexagon hex, float size) { + float x = (sqrt(3.0) * hex.q + (sqrt(3.0) / 2.0) * hex.r) * size + 0.5; + float y = (0.0 * hex.q + (3.0 / 2.0) * hex.r) * size + 0.5; + return vec2(x, y * ratio); +} + +vec4 transition(vec2 uv) { + float dist = 2.0 * min(progress, 1.0 - progress); + dist = steps > 0 ? ceil(dist * float(steps)) / float(steps) : dist; + + float size = (sqrt(3.0) / 3.0) * dist / horizontalHexagons; + + vec2 point = dist > 0.0 ? pointFromHexagon(hexagonFromPoint(uv, size), size) + : uv; + + return mix(getFromColor(point), getToColor(point), progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + ratio = size.x / size.y; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag new file mode 100644 index 0000000..00d8a25 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag @@ -0,0 +1,58 @@ +// Linear blur crossfade, from gl-transitions.com +// Author: gre (MIT) +// Description: a crossfade that passes through a directional blur. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag): the version +// header is prepended by WallpaperService::shaderSource, the fullscreen +// triangle comes from wallpaper.vert, and getFromColor/getToColor apply the +// crop + flip convention. `uv` is bottom-left origin per gl-transitions. +// +// Optional gl-transitions params are fixed here: intensity = 0.1. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const float intensity = 0.1; +const int passes = 6; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + vec4 c1 = vec4(0.0); + vec4 c2 = vec4(0.0); + + float disp = intensity * (0.5 - distance(0.5, progress)); + for (int xi = 0; xi < passes; xi++) { + float x = float(xi) / float(passes) - 0.5; + for (int yi = 0; yi < passes; yi++) { + float y = float(yi) / float(passes) - 0.5; + vec2 v = vec2(x, y); + c1 += getFromColor(uv + disp * v); + c2 += getToColor(uv + disp * v); + } + } + c1 /= float(passes * passes); + c2 /= float(passes * passes); + return mix(c1, c2, progress); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag new file mode 100644 index 0000000..9f6b535 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag @@ -0,0 +1,49 @@ +// Split slide-in (horizontal), from gl-transitions.com +// Author: OllyOllyOlly (MIT) +// Description: the incoming image slides in as two halves moving horizontally +// in opposite directions. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: reverse = false. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const vec2 boundMin = vec2(0.0, 0.0); +const vec2 boundMax = vec2(1.0, 1.0); + +bool inBounds(vec2 p) { + return all(lessThan(boundMin, p)) && all(lessThan(p, boundMax)); +} + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + float modifier = 1.0; + vec2 toP = (uv.y > 0.5) + ? vec2((uv.x - (progress * modifier)) + modifier, uv.y) + : vec2((uv.x + (progress * modifier)) - modifier, uv.y); + + return inBounds(toP) ? getToColor(toP) : getFromColor(uv); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag new file mode 100644 index 0000000..5aacf50 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag @@ -0,0 +1,89 @@ +// Tiles wave reveal, from gl-transitions.com +// Author: numb3r23 (MIT) +// Description: the incoming image is revealed tile by tile in a diagonal wave. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: tileCount = (8, 8), +// flipX = true, flipY = false. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const ivec2 tileCount = ivec2(8, 8); +const bool flipX = true; +const bool flipY = false; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + vec2 tileSize = 1.0 / vec2(tileCount); + vec2 posInTile = fract(uv * vec2(tileCount)); + vec2 tileNum = floor(uv * vec2(tileCount)); + float countTiles = float(tileCount.x * tileCount.y); + + // Diagonal wave from bottom-left to top-right. + float offset = (tileNum.y + tileNum.x * float(tileCount.y)) / countTiles; + float timeOffset = clamp((progress - offset) * countTiles, 0.0, 0.5); + float sinTime = 1.0 - abs(cos(fract(timeOffset) * 3.1415926)); + + vec2 texC = posInTile; + + if (sinTime <= 0.5) { + if (flipX) { + if (texC.x < sinTime || texC.x > 1.0 - sinTime) + return getFromColor(uv); + texC.x = texC.x < 0.5 + ? (texC.x - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.x - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + } + if (flipY) { + if (texC.y < sinTime || texC.y > 1.0 - sinTime) + return getFromColor(uv); + texC.y = texC.y < 0.5 + ? (texC.y - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.y - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + } + vec2 globalUV = tileNum * tileSize + texC * tileSize; + return getFromColor(globalUV); + } else { + if (flipX) { + if (texC.x > sinTime || texC.x < 1.0 - sinTime) + return getToColor(uv); + texC.x = texC.x < 0.5 + ? (texC.x - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.x - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + texC.x = 1.0 - texC.x; + } + if (flipY) { + if (texC.y > sinTime || texC.y < 1.0 - sinTime) + return getToColor(uv); + texC.y = texC.y < 0.5 + ? (texC.y - sinTime) * 0.5 / (0.5 - sinTime) + : (texC.y - 0.5) * 0.5 / (0.5 - sinTime) + 0.5; + texC.y = 1.0 - texC.y; + } + vec2 globalUV = tileNum * tileSize + texC * tileSize; + return getToColor(globalUV); + } +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag new file mode 100644 index 0000000..44d56c0 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag @@ -0,0 +1,42 @@ +// Window slices wipe, from gl-transitions.com +// Author: gre (MIT) +// Description: the incoming image appears through vertical slices that widen. +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). +// Optional gl-transitions params are fixed here: count = 10.0, +// smoothness = 0.5. + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +const float count = 10.0; +const float smoothness = 0.5; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + float pr = smoothstep(-smoothness, 0.0, uv.x - progress * (1.0 + smoothness)); + float s = step(pr, fract(count * uv.x)); + return mix(getFromColor(uv), getToColor(uv), s); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag new file mode 100644 index 0000000..fbab8d6 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag @@ -0,0 +1,34 @@ +// Hard-edged wipe from top to bottom, from gl-transitions.com +// Author: Jake Nelson (MIT) +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), step(1.0 - uv.y, progress)); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag b/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag new file mode 100644 index 0000000..51391b5 --- /dev/null +++ b/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag @@ -0,0 +1,34 @@ +// Hard-edged wipe from left to right, from gl-transitions.com +// Author: Jake Nelson (MIT) +// +// Ported to ZShell's GLSL ABI (see wallpaper_trans_bowtie.frag). + +uniform sampler2D from; +uniform sampler2D to; +uniform vec2 size; +uniform vec4 fromCrop; +uniform vec4 toCrop; +uniform float progress; +uniform float fromFlip; +uniform float toFlip; + +out vec4 outColor; + +vec4 getFromColor(vec2 uv) { + vec2 s = vec2(uv.x, fromFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(from, fromCrop.xy + s * fromCrop.zw); +} + +vec4 getToColor(vec2 uv) { + vec2 s = vec2(uv.x, toFlip > 0.5 ? 1.0 - uv.y : uv.y); + return texture(to, toCrop.xy + s * toCrop.zw); +} + +vec4 transition(vec2 uv) { + return mix(getFromColor(uv), getToColor(uv), step(0.0 + uv.x, progress)); +} + +void main() { + vec2 uv = gl_FragCoord.xy / size; + outColor = transition(uv); +} diff --git a/Plugins/ZShell/Internal/videoplayer.cpp b/Plugins/ZShell/Internal/videoplayer.cpp index ba49577..ed14b78 100644 --- a/Plugins/ZShell/Internal/videoplayer.cpp +++ b/Plugins/ZShell/Internal/videoplayer.cpp @@ -184,9 +184,13 @@ void VideoPlayer::closeDecoder() { m_streamIndex = -1; } -void VideoPlayer::decodeLoop(const QString& path) { +void VideoPlayer::decodeLoop(const QString& path, uint32_t generation) { + if (!m_threadRunning.load(std::memory_order_acquire) || + m_generation.load(std::memory_order_acquire) != generation) + return; + if (!openDecoder(path)) { - m_threadRunning = false; + m_threadRunning.store(false); return; } @@ -198,13 +202,14 @@ void VideoPlayer::decodeLoop(const QString& path) { AVFrame* frame = av_frame_alloc(); if (!frame) { - m_threadRunning = false; + m_threadRunning.store(false); return; } AVPacket pkt{}; - while (m_threadRunning) { + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation) { int ret = av_read_frame(m_fmt, &pkt); if (ret == AVERROR_EOF) { if (avformat_seek_file( @@ -230,7 +235,9 @@ void VideoPlayer::decodeLoop(const QString& path) { } av_packet_unref(&pkt); - while (m_threadRunning && avcodec_receive_frame(m_codec, frame) == 0) { + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation && + avcodec_receive_frame(m_codec, frame) == 0) { bool wasEmpty = false; { std::lock_guard lock(m_mtx); @@ -251,21 +258,34 @@ void VideoPlayer::decodeLoop(const QString& path) { av_frame_free(&frame); closeDecoder(); - m_threadRunning = false; + m_threadRunning.store(false); } bool VideoPlayer::start(const QString& path) { - stop(); + const uint32_t generation = + m_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + m_threadRunning.store(true); - if (!ensureCudaContext()) return false; + std::thread previous = std::move(m_thread); + m_thread = std::thread( + [this, path, generation, previous = std::move(previous)]() mutable { + if (previous.joinable()) previous.join(); + closeDecoder(); + + if (!ensureCudaContext()) { + m_threadRunning.store(false); + return; + } + + decodeLoop(path, generation); + }); - m_threadRunning = true; - m_thread = std::thread([this, path]() { decodeLoop(path); }); return true; } void VideoPlayer::stop() { - m_threadRunning = false; + m_generation.fetch_add(1, std::memory_order_acq_rel); + m_threadRunning.store(false); if (m_thread.joinable()) m_thread.join(); closeDecoder(); diff --git a/Plugins/ZShell/Internal/videoplayer.hpp b/Plugins/ZShell/Internal/videoplayer.hpp index b6137e0..ed7d6e4 100644 --- a/Plugins/ZShell/Internal/videoplayer.hpp +++ b/Plugins/ZShell/Internal/videoplayer.hpp @@ -46,7 +46,7 @@ class VideoPlayer { [[nodiscard]] QSize frameSize() const; private: - void decodeLoop(const QString& path); + void decodeLoop(const QString& path, uint32_t generation); bool openDecoder(const QString& path); void closeDecoder(); @@ -60,6 +60,7 @@ class VideoPlayer { std::thread m_thread; std::atomic m_threadRunning{false}; + std::atomic m_generation{0}; mutable std::mutex m_mtx; AVFrame* m_pending = nullptr; diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Internal/wallpaperobject.cpp index b11057e..b29ce9c 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.cpp +++ b/Plugins/ZShell/Internal/wallpaperobject.cpp @@ -67,8 +67,46 @@ void WallpaperObject::setZoom(qreal zoom) { push(); } +void WallpaperObject::syncCrop(const QVariantMap& crop) { + const qreal x = crop.value("x").toDouble(); + const qreal y = crop.value("y").toDouble(); + const qreal width = crop.contains("width") ? crop.value("width").toDouble() + : 1.0; + const qreal height = crop.contains("height") + ? crop.value("height").toDouble() + : 1.0; + const qreal z = + crop.contains("zoom") ? crop.value("zoom").toDouble() : 1.0; + + const bool prev = m_pushEnabled; + m_pushEnabled = false; + + if (!qFuzzyCompare(m_cropX, x)) { + m_cropX = x; + emit cropXChanged(); + } + if (!qFuzzyCompare(m_cropY, y)) { + m_cropY = y; + emit cropYChanged(); + } + if (!qFuzzyCompare(m_cropWidth, width)) { + m_cropWidth = width; + emit cropWidthChanged(); + } + if (!qFuzzyCompare(m_cropHeight, height)) { + m_cropHeight = height; + emit cropHeightChanged(); + } + if (!qFuzzyCompare(m_zoom, z)) { + m_zoom = z; + emit zoomChanged(); + } + + m_pushEnabled = prev; +} + void WallpaperObject::push() { - if (!m_service || m_screen.isEmpty()) return; + if (!m_pushEnabled || !m_service || m_screen.isEmpty()) return; QVariantMap crop; crop.insert("x", m_cropX); diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Internal/wallpaperobject.hpp index eb3d8a4..2787ded 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.hpp +++ b/Plugins/ZShell/Internal/wallpaperobject.hpp @@ -55,6 +55,8 @@ class WallpaperObject : public QObject { qreal zoom() const { return m_zoom; } void setZoom(qreal zoom); + Q_INVOKABLE void syncCrop(const QVariantMap& crop); + signals: void serviceChanged(); void screenChanged(); @@ -77,6 +79,8 @@ class WallpaperObject : public QObject { qreal m_cropWidth = 1.0; qreal m_cropHeight = 1.0; qreal m_zoom = 1.0; + + bool m_pushEnabled = true; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index 9b512e8..b8569a0 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -25,8 +25,12 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -40,6 +44,9 @@ #include +#include "config/background.hpp" +#include "config/rootnodes.hpp" + #include namespace ZShell::internal { @@ -77,6 +84,51 @@ QByteArray shaderSource(const QString& file, bool fragment) { return QByteArray(shaderHeader(fragment)) + source.readAll(); } +const char* transitionFile(ZShell::config::WallpaperTransition::Enum value) { + switch (value) { + case ZShell::config::WallpaperTransition::Bowtie: + return "wallpaper_trans_bowtie.frag"; + case ZShell::config::WallpaperTransition::Fade: + return "wallpaper_trans_fade.frag"; + case ZShell::config::WallpaperTransition::LinearBlur: + return "wallpaper_trans_linear_blur.frag"; + case ZShell::config::WallpaperTransition::WindowSlices: + return "wallpaper_trans_window_slices.frag"; + case ZShell::config::WallpaperTransition::WipeRight: + return "wallpaper_trans_wipe_right.frag"; + case ZShell::config::WallpaperTransition::WipeDown: + return "wallpaper_trans_wipe_down.frag"; + case ZShell::config::WallpaperTransition::TilesWave: + return "wallpaper_trans_tiles_wave.frag"; + case ZShell::config::WallpaperTransition::SplitSlideInHorizontal: + return "wallpaper_trans_split_slide_in_horizontal.frag"; + case ZShell::config::WallpaperTransition::Hexagonalize: + return "wallpaper_trans_hexagonalize.frag"; + case ZShell::config::WallpaperTransition::DoomMelt: + return "wallpaper_trans_doom_melt.frag"; + case ZShell::config::WallpaperTransition::Random: + break; + } + + return "wallpaper_trans_fade.frag"; +} + +const QStringList& transitionFilesList() { + static const QStringList files = { + "wallpaper_trans_bowtie.frag", + "wallpaper_trans_fade.frag", + "wallpaper_trans_linear_blur.frag", + "wallpaper_trans_window_slices.frag", + "wallpaper_trans_wipe_right.frag", + "wallpaper_trans_wipe_down.frag", + "wallpaper_trans_tiles_wave.frag", + "wallpaper_trans_split_slide_in_horizontal.frag", + "wallpaper_trans_hexagonalize.frag", + "wallpaper_trans_doom_melt.frag", + }; + return files; +} + constexpr int kWaitTimeoutMs = 20; constexpr int kFadeDurationMs = 400; @@ -133,7 +185,35 @@ QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { } // namespace -WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {} +WallpaperService::WallpaperService(QObject* parent) : QObject(parent) { + auto* background = + ZShell::config::ConfigSingleton::instance()->background(); + + { + std::lock_guard lock(m_mutex); + m_randomTransition = background->wallTransition() == + ZShell::config::WallpaperTransition::Random; + m_transitionFile = transitionFileFor(background->wallTransition()); + } + + QObject::connect( + background, + &ZShell::config::Background::wallTransitionChanged, + this, + [this, background] { + { + std::lock_guard lock(m_mutex); + m_randomTransition = + background->wallTransition() == + ZShell::config::WallpaperTransition::Random; + m_transitionFile = + transitionFileFor(background->wallTransition()); + m_transitionDirty = true; + } + + wake(); + }); +} WallpaperService::~WallpaperService() { m_stop = true; @@ -159,13 +239,27 @@ WallpaperService::~WallpaperService() { if (eglMakeCurrent( m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { if (m_texture) glDeleteTextures(1, &m_texture); - if (m_previousTexture) glDeleteTextures(1, &m_previousTexture); + for (const auto& layer : m_transitions) + if (layer.tex) glDeleteTextures(1, &layer.tex); + m_transitions.clear(); + if (m_baseTex) glDeleteTextures(1, &m_baseTex); + if (m_baseFbo) glDeleteFramebuffers(1, &m_baseFbo); + if (m_accumA) glDeleteTextures(1, &m_accumA); + if (m_accumB) glDeleteTextures(1, &m_accumB); + if (m_accumFbo) glDeleteFramebuffers(1, &m_accumFbo); for (uint32_t tex : m_orphanTextures) glDeleteTextures(1, &tex); m_orphanTextures.clear(); if (m_blackTex) glDeleteTextures(1, &m_blackTex); if (m_program) glDeleteProgram(m_program); if (m_videoProgram) glDeleteProgram(m_videoProgram); + for (const auto& p : m_transPrograms) + if (p.id) glDeleteProgram(p.id); + m_transPrograms.clear(); + m_neutralProgram = TransProgram(); + if (m_captureTex) glDeleteTextures(1, &m_captureTex); + if (m_captureFbo) glDeleteFramebuffers(1, &m_captureFbo); + if (m_posterTex) glDeleteTextures(1, &m_posterTex); #ifdef ZHAVE_VIDEO if (m_player) { m_player->stop(); @@ -237,8 +331,17 @@ void WallpaperService::applyPath(const QString& path) { { std::lock_guard lock(m_mutex); + const QString previousPath = m_path; m_path = path; + if (m_randomTransition) { + const QString next = randomTransitionFile(); + if (next != m_transitionFile) { + m_transitionFile = next; + m_transitionDirty = true; + } + } + #ifdef ZHAVE_VIDEO const bool videoBackend = m_mpv != nullptr || m_player != nullptr; #else @@ -246,28 +349,72 @@ void WallpaperService::applyPath(const QString& path) { #endif const bool wasVideo = m_isVideo && videoBackend; const bool wasImage = !m_image.isNull() && m_texture != 0; + const bool beginBurst = m_transitions.empty(); - m_previousVideo = wasVideo && !video; - - if (wasImage) { - if (m_previousTexture != 0) - m_orphanTextures.push_back(m_previousTexture); - m_previousImage = m_image; - m_previousCrops = m_crops; - m_previousTexture = 0; - } else { - m_previousImage = QImage(); + if (beginBurst) { + if (wasVideo) { + m_needCaptureBase = true; + m_baseIsVideo = true; + m_baseIsBlack = false; + m_baseImage = QImage(); + m_baseCrops = cropsForPath(previousPath); + } else if (wasImage) { + m_needCaptureBase = true; + m_baseIsVideo = false; + m_baseIsBlack = false; + m_baseImage = m_image; + m_baseCrops = cropsForPath(previousPath); + } else { + m_needCaptureBase = false; + m_baseIsVideo = false; + m_baseIsBlack = m_baseTex == 0; + m_baseImage = QImage(); + m_baseCrops = cropsForPath(previousPath); + } } + if (!beginBurst && !m_transitions.empty() && + m_transitions.back().isVideo && !m_transitions.back().started && + !m_posterReady) + m_transitions.pop_back(); + + if (!beginBurst && !m_transitions.empty() && m_texture != 0 && + !m_transitions.back().isVideo) { + TransitionLayer& newest = m_transitions.back(); + newest.tex = m_texture; + newest.image = m_image; + newest.crops = cropsForPath(previousPath); + newest.ready = true; + m_texture = 0; + m_image = QImage(); + } + + if (!beginBurst && video && !m_transitions.empty() && + m_transitions.back().isVideo) + m_collapseVideo = true; + + m_crops = cropsForPath(path); + m_isVideo = video; - m_fading = true; - m_fadeClockStarted = false; m_replacementReady = false; - m_forceDecode = true; m_imageChanged = true; m_videoReload = true; + + TransitionLayer layer; + layer.isVideo = video; + layer.transitionFile = m_transitionFile; + m_transitions.push_back(layer); + + if (static_cast(m_transitions.size()) > kMaxTransitions) + m_promoteOldest = true; + + while (static_cast(m_transitions.size()) > kMaxTransitions * 2) { + if (m_transitions.front().tex != 0) + m_orphanTextures.push_back(m_transitions.front().tex); + m_transitions.erase(m_transitions.begin()); + } } emit pathChanged(); @@ -795,13 +942,14 @@ void WallpaperService::present() { ensureImage(); + ensurePoster(); + if (m_isVideo && m_videoReady.load(std::memory_order_relaxed)) { std::lock_guard lock(m_mutex); m_replacementReady = true; } QList surfaces; - bool fading = false; bool videoReload = false; QString path; bool isVideo = false; @@ -813,12 +961,12 @@ void WallpaperService::present() { path = m_path; isVideo = m_isVideo; - fading = m_fading; + const bool animating = !m_transitions.empty(); const bool newVideoFrame = m_isVideo && m_mpvFrameReady.exchange(false, std::memory_order_relaxed); - if (fading || newVideoFrame) + if (animating || newVideoFrame) for (auto* surface : m_surfaces) surface->markDirty(); @@ -855,24 +1003,37 @@ void WallpaperService::present() { m_orphanTextures.clear(); } - if (videoReload) { - if (isVideo) + const bool holdVideoForBase = (m_needCaptureBase && m_baseIsVideo) || + m_collapseVideo; + + auto applyVideoBackendSwitch = [&]() { + if (!videoReload) return; + + std::lock_guard lock(m_mutex); + + if (isVideo) { startVideo(path); - else if (m_mpv && !m_previousVideo) + } else if (m_mpv != nullptr) { teardownMpv(); + } #ifdef ZHAVE_VIDEO - else if (m_player && !m_previousVideo) { + else if (m_player != nullptr) { m_player->deleteTexture(); stopPlayerVideo(); } #endif - } + }; + + if (!holdVideoForBase) applyVideoBackendSwitch(); #ifdef ZHAVE_VIDEO - const bool videoActive = (m_isVideo || m_previousVideo) && - (m_mpv != nullptr || m_player != nullptr); + const bool videoActive = + (m_mpv != nullptr || m_player != nullptr) && + (m_isVideo || m_baseIsVideo || m_collapseVideo || m_promoteOldest); #else - const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr; + const bool videoActive = + m_mpv != nullptr && + (m_isVideo || m_baseIsVideo || m_collapseVideo || m_promoteOldest); #endif if (videoActive && m_mpv != nullptr) pumpMpvEvents(); @@ -883,6 +1044,45 @@ void WallpaperService::present() { for (auto* surface : surfaces) drawSurface(surface); + + if (holdVideoForBase) applyVideoBackendSwitch(); + + { + std::lock_guard lock(m_mutex); + + if (m_collapseVideo) { + for (size_t i = 0; i + 1 < m_transitions.size(); ++i) + if (m_transitions[i].tex != 0) + m_orphanTextures.push_back(m_transitions[i].tex); + if (m_transitions.size() > 1) + m_transitions.erase( + m_transitions.begin(), m_transitions.end() - 1); + m_collapseVideo = false; + } + + if (m_promoteOldest && !m_transitions.empty()) { + if (m_transitions.front().tex != 0) + m_orphanTextures.push_back(m_transitions.front().tex); + m_transitions.erase(m_transitions.begin()); + m_promoteOldest = static_cast(m_transitions.size()) > + kMaxTransitions; + } + + if (m_newestDone) { + for (const auto& layer : m_transitions) + if (layer.tex != 0) m_orphanTextures.push_back(layer.tex); + m_transitions.clear(); + m_newestDone = false; + m_baseIsBlack = false; + m_baseImage = QImage(); + m_baseCrops = QVariantMap(); + } + + if (m_needCaptureBase) { + m_needCaptureBase = false; + m_baseIsVideo = false; + } + } } void WallpaperService::addSurfaces() { @@ -1066,6 +1266,164 @@ uint32_t WallpaperService::createProgram() { return program; } +WallpaperService::TransProgram WallpaperService::createTransitionProgram( + const QString& file) { + TransProgram p; + + const QByteArray vsSrc = shaderSource("wallpaper.vert", false); + const QByteArray fsSrc = shaderSource(file, true); + const char* vs = vsSrc.constData(); + const char* fs = fsSrc.constData(); + + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &vs, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &fs, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "from") < 0 || + glGetUniformLocation(program, "to") < 0 || + glGetUniformLocation(program, "fromCrop") < 0 || + glGetUniformLocation(program, "toCrop") < 0 || + glGetUniformLocation(program, "fromFlip") < 0 || + glGetUniformLocation(program, "toFlip") < 0 || + glGetUniformLocation(program, "progress") < 0) { + qWarning(wpLog) << "WallpaperService: the transition program does not " + "have the uniforms it needs"; + glDeleteProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + return p; + } + + p.id = program; + p.size = glGetUniformLocation(program, "size"); + p.progress = glGetUniformLocation(program, "progress"); + p.from = glGetUniformLocation(program, "from"); + p.to = glGetUniformLocation(program, "to"); + p.fromCrop = glGetUniformLocation(program, "fromCrop"); + p.toCrop = glGetUniformLocation(program, "toCrop"); + p.fromFlip = glGetUniformLocation(program, "fromFlip"); + p.toFlip = glGetUniformLocation(program, "toFlip"); + + glUseProgram(program); + glUniform1i(GLint(p.from), 0); + glUniform1i(GLint(p.to), 1); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return p; +} + +WallpaperService::TransProgram WallpaperService::programFor( + const QString& file) { + auto cached = m_transPrograms.constFind(file); + if (cached != m_transPrograms.constEnd()) return cached.value(); + + TransProgram p = createTransitionProgram(file); + if (p.id != 0) m_transPrograms.insert(file, p); + return p; +} + +bool WallpaperService::ensureTransitionProgram() { + if (m_neutralProgram.id == 0) + m_neutralProgram = + programFor(QStringLiteral("wallpaper_trans_fade.frag")); + + return m_neutralProgram.id != 0; +} + +QString WallpaperService::transitionFileFor( + ZShell::config::WallpaperTransition::Enum value) { + if (value == ZShell::config::WallpaperTransition::Random) + return randomTransitionFile(); + return QString(transitionFile(value)); +} + +QString WallpaperService::randomTransitionFile() { + const QStringList& files = transitionFilesList(); + if (files.isEmpty()) return QStringLiteral("wallpaper_trans_fade.frag"); + if (files.size() == 1) return files.at(0); + + if (m_transitionUses.size() != files.size()) + m_transitionUses.fill(0, files.size()); + + double total = 0.0; + QVector weights(files.size(), 0.0); + for (int i = 0; i < files.size(); ++i) { + if (files.at(i) == m_lastTransition) continue; + weights[i] = 1.0 / double(m_transitionUses.at(i) + 1); + total += weights[i]; + } + + if (total <= 0.0) return files.at(0); + + double pick = QRandomGenerator::global()->generateDouble() * total; + int chosen = -1; + for (int i = 0; i < files.size(); ++i) { + if (weights[i] <= 0.0) continue; + pick -= weights[i]; + if (pick <= 0.0) { + chosen = i; + break; + } + } + if (chosen < 0) chosen = int(files.size()) - 1; + + ++m_transitionUses[chosen]; + m_lastTransition = files.at(chosen); + return files.at(chosen); +} + +void WallpaperService::ensureCapture(const QSize& size) { + if (m_captureFbo == 0) { + glGenFramebuffers(1, &m_captureFbo); + glGenTextures(1, &m_captureTex); + + glBindTexture(GL_TEXTURE_2D, m_captureTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + + if (m_captureSize != size) { + glBindTexture(GL_TEXTURE_2D, m_captureTex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + m_captureSize = size; + } + + glBindFramebuffer(GL_FRAMEBUFFER, m_captureFbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_captureTex, 0); + + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) + qWarning(wpLog) << "WallpaperService: the capture framebuffer is " + "incomplete"; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + #ifdef ZHAVE_VIDEO uint32_t WallpaperService::createVideoProgram() { const QByteArray vsSrc = shaderSource("wallpaper.vert", false); @@ -1184,9 +1542,73 @@ void WallpaperService::stopPlayerVideo() { m_player->stop(); m_player.reset(); + + m_videoReady.store(false, std::memory_order_relaxed); + m_videoLoaded.store(false, std::memory_order_relaxed); } #endif +uint32_t WallpaperService::renderVideoIntoTexture(const QSize& size) { + ensureCapture(size); + if (m_captureFbo == 0 || m_captureTex == 0) return 0; + + glBindFramebuffer(GL_FRAMEBUFFER, m_captureFbo); + glViewport(0, 0, size.width(), size.height()); + glDisable(GL_BLEND); + +#ifdef ZHAVE_VIDEO + if (m_player) { + if (m_player->textureId() != 0) { + if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_player->frameSize(); + drawVideoTexture( + m_player->textureId(), + m_player->chromaTextureId(), + m_player->colorMatrix(), + m_player->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + float(size.width()), + float(size.height()), + src.width() > 0 ? float(src.width()) : float(size.width()), + src.height() > 0 ? float(src.height()) + : float(size.height()), + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + return m_captureTex; + } + + glViewport(0, 0, size.width(), size.height()); + + mpv_opengl_fbo fbo{ + int(m_captureFbo), size.width(), size.height(), GL_RGBA}; + + int flip = 1; + int block = 0; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + + mpv_render_context_render(m_mpvRender, params); + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + return m_captureTex; +} + + void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) { if (image.isNull()) return; @@ -1282,6 +1704,108 @@ QByteArray WallpaperService::cachedBlob(const QString& path) const { return blob; } +QByteArray WallpaperService::posterBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + QString databasePath; + { + std::lock_guard lock(m_mutex); + databasePath = m_dbPath; + } + + if (databasePath.isEmpty()) { + qWarning(wpLog) << "WallpaperService: poster lookup, no database path"; + return {}; + } + + const QFileInfo info(path); + + const QString connection = QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) { + qWarning(wpLog) + << "WallpaperService: poster lookup, database could not open"; + return {}; + } + + QSqlQuery query(database); + query.prepare("SELECT poster, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) { + qWarning(wpLog) << "WallpaperService: poster lookup, no row for" << path + << query.lastError().text(); + return {}; + } + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) { + qWarning(wpLog) << "WallpaperService: poster column empty for" << path; + return {}; + } + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + qWarning(wpLog) << "WallpaperService: poster stale for" << path; + return {}; + } + + return blob; +} + +QVariantMap WallpaperService::cropsForPath(const QString& path) const { + if (path.isEmpty()) return {}; + + QString databasePath; + { + std::lock_guard lock(m_mutex); + databasePath = m_dbPath; + } + + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + const QString connection = QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT crop FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray json = query.value(0).toByteArray(); + if (json.isEmpty()) return {}; + + const auto doc = QJsonDocument::fromJson(json); + if (!doc.isObject()) return {}; + + return doc.object().toVariantMap(); +} + QImage WallpaperService::decodeImage( const QString& path, const QSize& decodeSize) const { if (path.isEmpty()) return {}; @@ -1379,6 +1903,48 @@ void WallpaperService::ensureImage() { wake(); } +void WallpaperService::ensurePoster() { + QString path; + + { + std::lock_guard lock(m_mutex); + + if (!m_isVideo) return; + + path = m_path; + + if (m_posterPath == path && (m_posterReady || m_posterLoading)) return; + if (m_posterLoading) return; + + m_posterPath = path; + m_posterReady = false; + m_posterLoading = true; + m_posterImage = QImage(); + } + + QThreadPool::globalInstance()->start([this, path]() { + const QByteArray blob = posterBlob(path); + + QImage image; + if (!blob.isEmpty()) { + image = QImage::fromData(blob, "JPEG") + .convertToFormat(QImage::Format_RGBA8888); + } + + std::lock_guard lock(m_mutex); + m_posterImage = image; + m_posterReady = !image.isNull(); + m_posterChanged = m_posterReady; + m_posterLoading = false; + + if (!m_posterReady) + qWarning(wpLog) + << "WallpaperService: poster decode failed for" << path; + }); + + wake(); +} + void WallpaperService::invalidateCache() { { std::lock_guard lock(m_mutex); @@ -1456,6 +2022,11 @@ QRectF WallpaperService::sourceRect( } void WallpaperService::drawSurface(WallpaperSurface* surface) { + if (!m_transitions.empty()) { + drawSurfaceTransition(surface); + return; + } + if (!surface->needsDraw() || !surface->frameReady()) return; EGLSurface target = surface->eglSurface(); @@ -1505,11 +2076,9 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { #ifdef ZHAVE_VIDEO const bool videoAsBackground = - (videoNow || (m_previousVideo && m_fading)) && - (m_mpv != nullptr || m_player != nullptr); + videoNow && (m_mpv != nullptr || m_player != nullptr); #else - const bool videoAsBackground = - (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr; + const bool videoAsBackground = videoNow && m_mpv != nullptr; #endif if (videoAsBackground) { @@ -1568,107 +2137,13 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - if (m_fading && !m_fadeClockStarted) { - m_fadeClockStarted = true; - m_fadeTimer.start(); - } - - bool fade = false; - bool fadeDone = false; - qreal progress = 1.0; - - if (m_fading) { - progress = m_fadeClockStarted ? qreal(m_fadeTimer.elapsed()) / - qreal(kFadeDurationMs) - : 0.0; - - if (progress >= 1.0 && m_replacementReady) - fadeDone = true; - else - fade = true; - } - - const float previousAlpha = m_replacementReady ? float(1.0 - progress) - : 1.0f; - const float replacementAlpha = m_replacementReady ? float(progress) - : 0.0f; - const float w = float(size.width()); const float h = float(size.height()); - if (videoNow) { - if (fade && !m_previousImage.isNull()) { - if (m_previousTexture == 0) - uploadTexture(m_previousTexture, m_previousImage); - - if (m_previousTexture != 0) { - const QRectF previousRect = sourceRect( - m_previousCrops, - surface->screen(), - size, - m_previousImage.size()); - - drawTexture( - m_previousTexture, previousRect, w, h, previousAlpha); - } - } else if (fade && !m_firstFadeDone) { - if (m_blackTex == 0) createBlackTexture(m_blackTex); - - if (m_blackTex != 0) - drawTexture( - m_blackTex, - QRectF(0.0, 0.0, 1.0, 1.0), - w, - h, - previousAlpha); - } - } else { + if (!videoNow) { const QRectF rect = sourceRect(crops, surface->screen(), size, m_image.size()); - - if (fade) { - if (!m_previousVideo && !m_previousImage.isNull() && - m_previousTexture == 0) { - uploadTexture(m_previousTexture, m_previousImage); - } - - if (!m_previousVideo && m_previousTexture != 0) { - const QRectF previousRect = sourceRect( - m_previousCrops, - surface->screen(), - size, - m_previousImage.size()); - - drawTexture(m_previousTexture, previousRect, w, h, 1.0f); - } - - drawTexture(m_texture, rect, w, h, replacementAlpha); - } else { - drawTexture(m_texture, rect, w, h, 1.0f); - } - } - - if (fadeDone) { - m_fading = false; - m_firstFadeDone = true; - - if (m_previousVideo) { - teardownMpv(); -#ifdef ZHAVE_VIDEO - if (m_player) { - m_player->deleteTexture(); - stopPlayerVideo(); - } -#endif - m_previousVideo = false; - } - - if (m_previousTexture != 0) { - glDeleteTextures(1, &m_previousTexture); - m_previousTexture = 0; - } - - m_previousImage = QImage(); + drawTexture(m_texture, rect, w, h, 1.0f); } } @@ -1681,7 +2156,560 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { return; } - if ((m_isVideo || m_previousVideo) && m_mpvRender) + if (m_isVideo && m_mpvRender) mpv_render_context_report_swap(m_mpvRender); + + surface->endDraw(); +} + +void WallpaperService::ensureBaseFbo() { + if (m_baseFbo != 0) return; + + glGenFramebuffers(1, &m_baseFbo); + glGenTextures(1, &m_baseTex); + + glBindTexture(GL_TEXTURE_2D, m_baseTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); +} + +void WallpaperService::ensureAccum(const QSize& size) { + if (m_accumFbo == 0) { + glGenFramebuffers(1, &m_accumFbo); + glGenTextures(1, &m_accumA); + glGenTextures(1, &m_accumB); + + for (uint32_t tex : {m_accumA, m_accumB}) { + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + } + + if (m_accumSize != size) { + for (uint32_t tex : {m_accumA, m_accumB}) { + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + } + m_accumSize = size; + } +} + +void WallpaperService::bindRtt(uint32_t fbo, uint32_t tex, const QSize& size) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0); + glViewport(0, 0, size.width(), size.height()); +} + +void WallpaperService::drawTransitionPair( + const TransProgram& program, + uint32_t fromTex, + float fromFlip, + const QRectF& fromCrop, + uint32_t toTex, + float toFlip, + const QRectF& toCrop, + qreal progress, + const QSize& size) { + glUseProgram(program.id); + glDisable(GL_BLEND); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, fromTex); + glUniform1i(GLint(program.from), 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, toTex); + glUniform1i(GLint(program.to), 1); + + glUniform2f(GLint(program.size), float(size.width()), float(size.height())); + glUniform4f( + GLint(program.fromCrop), + GLfloat(fromCrop.x()), + GLfloat(fromCrop.y()), + GLfloat(fromCrop.width()), + GLfloat(fromCrop.height())); + glUniform4f( + GLint(program.toCrop), + GLfloat(toCrop.x()), + GLfloat(toCrop.y()), + GLfloat(toCrop.width()), + GLfloat(toCrop.height())); + glUniform1f(GLint(program.fromFlip), fromFlip); + glUniform1f(GLint(program.toFlip), toFlip); + glUniform1f(GLint(program.progress), float(progress)); + + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +uint32_t WallpaperService::targetTexFor( + const TransitionLayer& layer, int index) const { + if (layer.isVideo) return m_captureTex; + if (layer.tex != 0) return layer.tex; + Q_UNUSED(index); + return m_texture; +} + +QRectF WallpaperService::cropForLayer( + const TransitionLayer& layer, + const QString& screen, + const QSize& size) const { + if (layer.isVideo) return QRectF(0.0, 0.0, 1.0, 1.0); + if (layer.tex != 0) + return sourceRect(layer.crops, screen, size, layer.image.size()); + return sourceRect(m_crops, screen, size, m_image.size()); +} + +void WallpaperService::captureBase( + const QSize& size, + const QString& screen, + uint32_t oldTex, + float oldFlip, + const QRectF& oldCrop) { + Q_UNUSED(screen); + + ensureBaseFbo(); + if (m_baseTex == 0) return; + + Q_UNUSED(oldCrop); + if (!m_baseImage.isNull()) { + uploadTexture(m_baseTex, m_baseImage); + m_baseSize = m_baseImage.size(); + m_baseReady = true; + return; + } + + if (m_baseSize != size) { + glBindTexture(GL_TEXTURE_2D, m_baseTex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + size.width(), + size.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + nullptr); + m_baseSize = size; + } + + bindRtt(m_baseFbo, m_baseTex, size); + drawTransitionPair( + m_neutralProgram, + oldTex, + oldFlip, + QRectF(0.0, 0.0, 1.0, 1.0), + oldTex, + oldFlip, + QRectF(0.0, 0.0, 1.0, 1.0), + 1.0, + size); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + m_baseReady = true; +} + +void WallpaperService::renderComposite( + const QSize& size, + const QString& screen, + uint32_t outFbo, + uint32_t outTex, + int first, + int last, + uint32_t videoTex, + uint32_t posterTex) { + if (m_baseTex == 0) return; + + ensureAccum(size); + + float fromFlip = 0.0f; + QRectF fromCrop = QRectF(0.0, 0.0, 1.0, 1.0); + if (!m_baseImage.isNull()) { + fromFlip = 1.0f; + fromCrop = sourceRect(m_baseCrops, screen, size, m_baseImage.size()); + } + + bindRtt(m_accumFbo, m_accumA, size); + drawTransitionPair( + m_neutralProgram, + m_baseTex, + fromFlip, + fromCrop, + m_baseTex, + fromFlip, + fromCrop, + 1.0, + size); + + uint32_t read = m_accumA; + uint32_t write = m_accumB; + int drawn = 0; + + for (int i = first; i < last; ++i) { + const TransitionLayer& layer = m_transitions[size_t(i)]; + if (!layer.ready) continue; + + uint32_t toTex = 0; + float toFlip = 1.0f; + QRectF toCrop(0.0, 0.0, 1.0, 1.0); + + if (layer.isVideo) { + if (m_videoReady.load(std::memory_order_relaxed) && videoTex != 0) { + toTex = videoTex; + toFlip = 0.0f; + } else if (posterTex != 0) { + toTex = posterTex; + toFlip = 1.0f; + toCrop = + sourceRect(layer.crops, screen, size, m_posterImage.size()); + } else { + toTex = videoTex; + toFlip = 0.0f; + } + } else { + toTex = targetTexFor(layer, i); + toCrop = cropForLayer(layer, screen, size); + } + + if (toTex == 0) continue; + + const TransProgram program = layer.transitionFile.isEmpty() + ? m_neutralProgram + : programFor(layer.transitionFile); + + bindRtt(m_accumFbo, write, size); + drawTransitionPair( + program, + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + toTex, + toFlip, + toCrop, + layer.progress, + size); + + std::swap(read, write); + ++drawn; + } + + if (drawn == 0) { + if (outFbo == m_baseFbo) return; + read = m_accumA; + } + + if (outFbo == 0) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + } else { + bindRtt(outFbo, outTex, size); + } + + drawTransitionPair( + m_neutralProgram, + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + read, + 0.0f, + QRectF(0.0, 0.0, 1.0, 1.0), + 1.0, + size); +} + +void WallpaperService::drawSurfaceTransition(WallpaperSurface* surface) { + if (!surface->needsDraw() || !surface->frameReady()) return; + + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning(wpLog) << "WallpaperService: the context cannot be made " + "current, eglGetError = 0x" + << Qt::hex << unsigned(eglGetError()); + return; + } + + eglSwapInterval(m_eglDisplay, 0); + + if (m_vao == 0) { + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + } + + struct ContextRelease { + EGLDisplay display; + ~ContextRelease() { + eglMakeCurrent( + display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } + } contextRelease{m_eglDisplay}; + + { + std::lock_guard lock(m_mutex); + + const QSize size = surface->bufferSize(); + if (size.isEmpty()) return; + const QString screen = surface->screen(); + + if (m_transitions.empty()) return; + + if (!ensureTransitionProgram()) return; + if (m_blackTex == 0) createBlackTexture(m_blackTex); + + ensureBaseFbo(); + + const bool newestVideoLive = + !m_transitions.empty() && m_transitions.back().isVideo && + m_videoReady.load(std::memory_order_relaxed); + + bool anyVideoLayer = false; + for (const auto& layer : m_transitions) + if (layer.isVideo) { + anyVideoLayer = true; + break; + } + + bool needVideo = + (m_needCaptureBase && m_baseIsVideo) || + (m_videoReady.load(std::memory_order_relaxed) && anyVideoLayer); + + const uint32_t videoTex = needVideo ? renderVideoIntoTexture(size) : 0; + + if (m_needCaptureBase && m_baseIsVideo && videoTex == 0) return; + + if (m_needCaptureBase) { + uint32_t oldTex = 0; + float oldFlip = 0.0f; + QRectF oldCrop(0.0, 0.0, 1.0, 1.0); + + if (m_baseIsBlack) { + oldTex = m_blackTex; + oldFlip = 1.0f; + } else if (m_baseIsVideo) { + oldTex = videoTex; + oldFlip = 0.0f; + } else { + oldTex = m_texture; // still the previous image here + oldFlip = 1.0f; + oldCrop = + sourceRect(m_baseCrops, screen, size, m_baseImage.size()); + } + + if (oldTex != 0) + captureBase(size, screen, oldTex, oldFlip, oldCrop); + } + + if (!m_transitions.back().isVideo) { + const bool upload = m_imageChanged; + m_imageChanged = false; + if (m_texture == 0 || upload) uploadImage(); + } else { + m_imageChanged = false; + } + + if (m_isVideo && m_posterChanged) { + m_posterChanged = false; + if (!m_posterImage.isNull()) + uploadTexture(m_posterTex, m_posterImage); + } + + const bool newestVideoPoster = newestVideoLive == false && + m_transitions.back().isVideo && + m_posterReady && m_posterTex != 0; + + const bool newestReady = + m_isVideo + ? (newestVideoLive || newestVideoPoster) + : (!m_image.isNull() && m_texture != 0 && m_replacementReady); + + for (size_t i = 0; i < m_transitions.size(); ++i) { + TransitionLayer& layer = m_transitions[i]; + const bool ready = + (i + 1 == m_transitions.size()) + ? newestReady + : (layer.isVideo + ? (layer.ready || m_videoReady || m_posterReady) + : layer.tex != 0); + if (!ready) continue; + + layer.ready = true; + if (!layer.started) { + layer.started = true; + layer.timer.start(); + } + layer.progress = + qreal(layer.timer.elapsed()) / qreal(kFadeDurationMs); + if (layer.progress > 1.0) layer.progress = 1.0; + } + + const bool newestSettled = + m_transitions.back().isVideo ? newestVideoLive : true; + + const bool collapse = m_collapseVideo && m_transitions.size() > 1; + const bool promote = m_promoteOldest && !m_transitions.empty(); + + if (collapse) { + ensureAccum(size); + renderComposite( + size, + screen, + m_baseFbo, + m_baseTex, + 0, + int(m_transitions.size()) - 1, + videoTex, + m_posterTex); + } else if (promote) { + ensureAccum(size); + renderComposite( + size, screen, m_baseFbo, m_baseTex, 0, 1, videoTex, m_posterTex); + } + + int first = 0; + if (collapse) + first = int(m_transitions.size()) - 1; + else if (promote) + first = 1; + + const int last = int(m_transitions.size()); + const TransitionLayer& newest = m_transitions.back(); + + if (m_baseIsBlack) { + const qreal p = newest.ready ? newest.progress : 0.0; + const float a = p < 0.0 ? 0.0f : (p > 1.0 ? 1.0f : float(p)); + const float w = float(size.width()); + const float h = float(size.height()); + + if (m_program == 0) m_program = createProgram(); + if (m_program == 0) return; + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + + if (m_isVideo) { + if (!m_videoReady.load(std::memory_order_relaxed)) { + if (!m_posterReady || m_posterTex == 0) return; + + glUseProgram(m_program); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + drawTexture( + m_posterTex, + sourceRect(m_crops, screen, size, m_posterImage.size()), + w, + h, + a); + } else { + glDisable(GL_BLEND); + +#ifdef ZHAVE_VIDEO + if (m_player) { + if (m_player->textureId() != 0) { + if (m_videoProgram == 0) + m_videoProgram = createVideoProgram(); + + if (m_videoProgram != 0) { + glUseProgram(m_videoProgram); + const QSize src = m_player->frameSize(); + drawVideoTexture( + m_player->textureId(), + m_player->chromaTextureId(), + m_player->colorMatrix(), + m_player->colorRange(), + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + src.width() > 0 ? float(src.width()) : w, + src.height() > 0 ? float(src.height()) : h, + 1.0f); + } + } + } else +#endif + { + if (!m_mpvRender) initMpvRender(); + if (!m_mpvRender) return; + renderMpvToSurface(size); + } + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glUseProgram(m_program); + + if (m_blackTex != 0) + drawTexture( + m_blackTex, + QRectF(0.0, 0.0, 1.0, 1.0), + w, + h, + 1.0f - a); + } + } else { + if (m_texture == 0 || m_image.isNull()) return; + + glUseProgram(m_program); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + drawTexture( + m_texture, + sourceRect(m_crops, screen, size, m_image.size()), + w, + h, + a); + } + + if (newest.ready && newest.progress >= 1.0 && newestSettled) + m_newestDone = true; + } else { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, size.width(), size.height()); + + if (!m_baseReady) { + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + } else { + renderComposite( + size, screen, 0, 0, first, last, videoTex, m_posterTex); + } + + if (newest.ready && newest.progress >= 1.0 && newestSettled) + m_newestDone = true; + } + } + + surface->requestFrame(); + + if (!eglSwapBuffers(m_eglDisplay, target)) { + qWarning(wpLog) + << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" + << QString::number(unsigned(eglGetError()), 16); + return; + } + + if ((m_isVideo || m_baseIsVideo) && m_mpvRender) mpv_render_context_report_swap(m_mpvRender); surface->endDraw(); diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp index 25cbd65..dad41d8 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +30,7 @@ #include #include "wallpapersurface.hpp" +#include "config/enums.hpp" struct mpv_handle; struct mpv_render_context; @@ -131,8 +134,76 @@ class WallpaperService : public QObject { void storeGlobal(const char* interface, void* proxy); + struct TransProgram { + uint32_t id = 0; + int size = -1; + int progress = -1; + int from = -1; + int to = -1; + int fromCrop = -1; + int toCrop = -1; + int fromFlip = -1; + int toFlip = -1; + }; + [[nodiscard]] bool setupGl(); [[nodiscard]] uint32_t createProgram(); + [[nodiscard]] TransProgram createTransitionProgram(const QString& file); + [[nodiscard]] TransProgram programFor(const QString& file); + bool ensureTransitionProgram(); + QString transitionFileFor(ZShell::config::WallpaperTransition::Enum value); + [[nodiscard]] QString randomTransitionFile(); + + void ensureCapture(const QSize& size); + uint32_t renderVideoIntoTexture(const QSize& size); + + void drawSurfaceTransition(WallpaperSurface* surface); + + struct TransitionLayer { + uint32_t tex = 0; // frozen image target (older layers) + QImage image; // CPU copy for crops/sizing (image layers) + QVariantMap crops; // crop map captured when the layer was pushed + QString transitionFile; // this layer's own transition shader + bool isVideo = false; // only the newest layer may be video + bool ready = false; // target is sampleable + bool started = false; // its own clock has begun + QElapsedTimer timer; + qreal progress = 0.0; + }; + + void ensureBaseFbo(); + void ensureAccum(const QSize& size); + void bindRtt(uint32_t fbo, uint32_t tex, const QSize& size); + void drawTransitionPair( + const TransProgram& program, + uint32_t fromTex, + float fromFlip, + const QRectF& fromCrop, + uint32_t toTex, + float toFlip, + const QRectF& toCrop, + qreal progress, + const QSize& size); + uint32_t targetTexFor(const TransitionLayer& layer, int index) const; + QRectF cropForLayer( + const TransitionLayer& layer, + const QString& screen, + const QSize& size) const; + void captureBase( + const QSize& size, + const QString& screen, + uint32_t oldTex, + float oldFlip, + const QRectF& oldCrop); + void renderComposite( + const QSize& size, + const QString& screen, + uint32_t outFbo, + uint32_t outTex, + int first, + int last, + uint32_t videoTex, + uint32_t posterTex); void uploadTexture(uint32_t& texture, const QImage& image); void uploadImage(); @@ -150,8 +221,14 @@ class WallpaperService : public QObject { [[nodiscard]] QByteArray cachedBlob(const QString& path) const; + [[nodiscard]] QByteArray posterBlob(const QString& path) const; + + [[nodiscard]] QVariantMap cropsForPath(const QString& path) const; + void ensureImage(); + void ensurePoster(); + void notifyBufferSizes(); void queue(WallpaperSurface* surface); @@ -187,7 +264,6 @@ class WallpaperService : public QObject { bool m_isVideo = false; bool m_videoReload = false; - bool m_previousVideo = false; mpv_handle* m_mpv = nullptr; mpv_render_context* m_mpvRender = nullptr; uint32_t m_blackTex = 0; @@ -223,6 +299,45 @@ class WallpaperService : public QObject { int m_alphaUniform = -1; int m_sourceUniform = -1; + bool m_randomTransition = false; // wallTransition == Random + QString m_transitionFile; // the transition stamped onto new layers + bool m_transitionDirty = false; + QHash + m_transPrograms; // compiled programs, keyed by shader + TransProgram + m_neutralProgram; // fade program for identity copies (seed/base/final) + QVector + m_transitionUses; // usage counts, parallel to the transition list + QString m_lastTransition; // last random pick, so it is never repeated + + uint32_t m_captureFbo = 0; + uint32_t m_captureTex = 0; + QSize m_captureSize; + + uint32_t m_baseFbo = 0; + uint32_t m_baseTex = 0; + QSize m_baseSize; + bool m_baseReady = false; + + uint32_t m_accumFbo = 0; + uint32_t m_accumA = 0; + uint32_t m_accumB = 0; + QSize m_accumSize; + + std::vector m_transitions; // oldest .. newest + + bool m_needCaptureBase = false; + QImage m_baseImage; // old settled image, for the base capture crop + QVariantMap m_baseCrops; // old settled crop map, for the base capture crop + bool m_baseIsVideo = false; // old settled frame was a live video + bool m_baseIsBlack = false; // shell start / first load: fade in from black + bool m_collapseVideo = + false; // video pushed mid-stack: bake older into base + bool m_promoteOldest = false; // stack over the cap: promote oldest to base + bool m_newestDone = false; // newest layer finished: settle into base + + static constexpr int kMaxTransitions = 4; + uint32_t m_videoProgram = 0; uint32_t m_vao = 0; int m_videoSizeUniform = -1; @@ -240,14 +355,7 @@ class WallpaperService : public QObject { bool m_usePlayer = true; #endif - QImage m_previousImage; - QVariantMap m_previousCrops; - uint32_t m_previousTexture = 0; std::vector m_orphanTextures; - bool m_fading = false; - bool m_fadeClockStarted = false; - bool m_firstFadeDone = false; - QElapsedTimer m_fadeTimer; QList m_surfaces; @@ -264,6 +372,13 @@ class WallpaperService : public QObject { bool m_forceDecode = false; bool m_imageChanged = false; + + QImage m_posterImage; + uint32_t m_posterTex = 0; + QString m_posterPath; + bool m_posterReady = false; + bool m_posterLoading = false; + bool m_posterChanged = false; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Models/wallpaperdb.cpp b/Plugins/ZShell/Models/wallpaperdb.cpp index b69fd50..ace438d 100644 --- a/Plugins/ZShell/Models/wallpaperdb.cpp +++ b/Plugins/ZShell/Models/wallpaperdb.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -343,8 +345,8 @@ void WallpaperDb::setCacheSize(const QSize& cacheSize) { if (db.isOpen()) { QSqlQuery query(db); query.prepare( - "UPDATE wallpapers SET cache = NULL, scheme_thumb = NULL" - " WHERE path = ?"); + "UPDATE wallpapers SET cache = NULL, scheme_thumb = NULL," + " poster = NULL WHERE path = ?"); query.addBindValue(entry->path()); query.exec(); } @@ -413,7 +415,8 @@ void WallpaperDb::openDb() { query.exec( "CREATE TABLE IF NOT EXISTS wallpapers(" "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," - " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB," + " poster BLOB, crop TEXT)"); query.exec("SELECT cache FROM wallpapers LIMIT 1"); if (query.lastError().isValid()) { @@ -421,9 +424,12 @@ void WallpaperDb::openDb() { query.exec( "CREATE TABLE wallpapers(" "path TEXT PRIMARY KEY, kind TEXT, codec TEXT, hardware INTEGER," - " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB)"); + " size INTEGER, mtime INTEGER, cache BLOB, scheme_thumb BLOB," + " poster BLOB, crop TEXT)"); } else { query.exec("ALTER TABLE wallpapers ADD COLUMN scheme_thumb BLOB"); + query.exec("ALTER TABLE wallpapers ADD COLUMN poster BLOB"); + query.exec("ALTER TABLE wallpapers ADD COLUMN crop TEXT"); } registerProvider(); @@ -538,6 +544,7 @@ void WallpaperDb::applyScan(const ScanResult& result) { } delete it.value(); + m_crops.remove(it.key()); it = m_byPath.erase(it); } else { ++it; @@ -575,24 +582,28 @@ void WallpaperDb::applyScan(const ScanResult& result) { if (db.isOpen()) { QSqlQuery query(db); query.prepare( - "SELECT cache, scheme_thumb, size, mtime FROM wallpapers" - " WHERE path = ?"); + "SELECT cache, scheme_thumb, poster, size, mtime, crop" + " FROM wallpapers WHERE path = ?"); query.addBindValue(path); query.exec(); if (query.next()) { const auto cache = query.value(0).toByteArray(); const auto scheme = query.value(1).toByteArray(); - const auto storedSize = query.value(2).toLongLong(); - const auto storedMtime = query.value(3).toLongLong(); + const auto poster = query.value(2).toByteArray(); + const auto storedSize = query.value(3).toLongLong(); + const auto storedMtime = query.value(4).toLongLong(); + loadCrop(path, query.value(5).toString()); + + const bool posterOk = !isVideo || !poster.isEmpty(); if (storedSize == size && storedMtime == mtime && !cache.isEmpty()) { reused = true; entry->setThumbnailPath(thumbnailUrl(path)); - if (scheme.isEmpty()) { - queueMetadata(entry, isVideo, cache); + if (scheme.isEmpty() || !posterOk) { + queueMetadata(entry, isVideo, cache, poster); } } } @@ -602,9 +613,13 @@ void WallpaperDb::applyScan(const ScanResult& result) { if (db.isOpen()) { QSqlQuery query(db); query.prepare( - "INSERT OR REPLACE INTO wallpapers" + "INSERT INTO wallpapers" " (path, kind, hardware, size, mtime, cache)" - " VALUES (?, ?, ?, ?, ?, NULL)"); + " VALUES (?, ?, ?, ?, ?, NULL)" + " ON CONFLICT(path) DO UPDATE SET kind = excluded.kind," + " hardware = excluded.hardware, size = excluded.size," + " mtime = excluded.mtime, cache = NULL," + " scheme_thumb = NULL, poster = NULL"); query.addBindValue(path); query.addBindValue(isVideo ? "video" : "image"); query.addBindValue( @@ -626,7 +641,10 @@ void WallpaperDb::applyScan(const ScanResult& result) { } void WallpaperDb::queueMetadata( - WallpaperEntry* entry, bool isVideo, const QByteArray& existingCache) { + WallpaperEntry* entry, + bool isVideo, + const QByteArray& existingCache, + const QByteArray& existingPoster) { qCInfo( dbLog, "WallpaperDb: queued cache job for %s (%s%s)", @@ -634,7 +652,8 @@ void WallpaperDb::queueMetadata( isVideo ? "video" : "image", existingCache.isEmpty() ? "" : ", existing blob"); - m_queue.append(MetadataJob{entry, entry->path(), isVideo, existingCache}); + m_queue.append( + MetadataJob{entry, entry->path(), isVideo, existingCache, existingPoster}); if (!m_queueTimer.isActive()) { m_queueTimer.start(); @@ -672,10 +691,12 @@ void WallpaperDb::runMetadataJob(const MetadataJob& job) { struct CacheResult { QString path; QByteArray cache; + QByteArray poster; QByteArray scheme; }; const QSize cacheSize = m_cacheSize; + const bool isVideoJob = job.isVideo; QElapsedTimer clock; clock.start(); @@ -685,7 +706,7 @@ void WallpaperDb::runMetadataJob(const MetadataJob& job) { watcher, &QFutureWatcher::finished, this, - [this, watcher, clock]() { + [this, watcher, clock, isVideoJob]() { const auto result = watcher->result(); qCInfo( @@ -695,15 +716,27 @@ void WallpaperDb::runMetadataJob(const MetadataJob& job) { int(result.cache.size()), int(clock.elapsed())); + qCInfo( + dbLog, + "WallpaperDb: %s poster %d bytes", + result.path.toUtf8().constData(), + int(result.poster.size())); + + if (isVideoJob && result.poster.isEmpty()) { + qWarning(dbLog) + << "WallpaperDb: no poster produced for" << result.path; + } + if (QSqlDatabase::contains(m_uuid)) { const auto db = QSqlDatabase::database(m_uuid, false); if (db.isOpen()) { QSqlQuery query(db); query.prepare( - "UPDATE wallpapers SET cache = ?, scheme_thumb = ?" - " WHERE path = ?"); + "UPDATE wallpapers SET cache = ?, scheme_thumb = ?," + " poster = ? WHERE path = ?"); query.addBindValue(result.cache); query.addBindValue(result.scheme); + query.addBindValue(result.poster); query.addBindValue(result.path); if (!query.exec()) { qWarning(dbLog) @@ -736,7 +769,14 @@ void WallpaperDb::runMetadataJob(const MetadataJob& job) { : imageCacheBytes(job.path, cacheSize); } + QByteArray poster = job.existingPoster; + if (poster.isEmpty()) { + poster = + job.isVideo ? videoPosterBytes(job.path, cacheSize) : cache; + } + result.cache = cache; + result.poster = poster; result.scheme = cache.isEmpty() ? QByteArray() : schemeThumbBytes(cache, 128); @@ -781,4 +821,56 @@ bool WallpaperDb::entryAllowed(const WallpaperEntry* entry) const { return entry->hardware(); } +QVariantMap WallpaperDb::crop(const QString& path, const QString& screen) const { + const auto pathCrops = m_crops.constFind(path); + if (pathCrops == m_crops.constEnd()) return {}; + return pathCrops.value().value(screen).toMap(); +} + +void WallpaperDb::setCrop( + const QString& path, const QString& screen, const QVariantMap& crop) { + if (path.isEmpty() || screen.isEmpty()) return; + + QVariantMap& pathCrops = m_crops[path]; + pathCrops[screen] = crop; + storeCropJson(path); + emit cropsChanged(path); +} + +void WallpaperDb::loadCrop(const QString& path, const QString& json) { + if (json.isEmpty()) { + m_crops.remove(path); + return; + } + + const auto doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isObject()) { + m_crops.remove(path); + return; + } + + m_crops[path] = doc.object().toVariantMap(); +} + +void WallpaperDb::storeCropJson(const QString& path) { + const auto db = QSqlDatabase::database(m_uuid, false); + if (!db.isOpen()) return; + + const auto pathCrops = m_crops.value(path); + const QByteArray json = + pathCrops.isEmpty() + ? QByteArray() + : QJsonDocument(QJsonObject::fromVariantMap(pathCrops)) + .toJson(QJsonDocument::Compact); + + QSqlQuery query(db); + query.prepare("UPDATE wallpapers SET crop = ? WHERE path = ?"); + query.addBindValue(json.isEmpty() ? QVariant() : QVariant(json)); + query.addBindValue(path); + if (!query.exec()) { + qWarning(dbLog) << "WallpaperDb: could not store the crop for" << path + << query.lastError(); + } +} + } // namespace ZShell::models diff --git a/Plugins/ZShell/Models/wallpaperdb.hpp b/Plugins/ZShell/Models/wallpaperdb.hpp index 30592e4..e41053d 100644 --- a/Plugins/ZShell/Models/wallpaperdb.hpp +++ b/Plugins/ZShell/Models/wallpaperdb.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace ZShell::models { @@ -109,6 +110,11 @@ class WallpaperDb : public QObject { [[nodiscard]] static QString thumbnailUrl(const QString& path); + Q_INVOKABLE QVariantMap crop(const QString& path, const QString& screen) + const; + Q_INVOKABLE void setCrop( + const QString& path, const QString& screen, const QVariantMap& crop); + signals: void pathChanged(); void dbPathChanged(); @@ -117,6 +123,7 @@ class WallpaperDb : public QObject { void cacheSizeChanged(); void cacheStored(const QString& path); + void cropsChanged(const QString& path); private: struct FileEntry { @@ -134,6 +141,7 @@ class WallpaperDb : public QObject { QString path; bool isVideo; QByteArray existingCache; + QByteArray existingPoster; }; void openDb(); @@ -144,11 +152,15 @@ class WallpaperDb : public QObject { void queueMetadata( WallpaperEntry* entry, bool isVideo, - const QByteArray& existingCache = {}); + const QByteArray& existingCache = {}, + const QByteArray& existingPoster = {}); void processQueue(); void runMetadataJob(const MetadataJob& job); void rebuildEntries(); + void loadCrop(const QString& path, const QString& json); + void storeCropJson(const QString& path); + [[nodiscard]] bool entryAllowed(const WallpaperEntry* entry) const; QDir m_dir; @@ -163,6 +175,8 @@ class WallpaperDb : public QObject { QList m_entries; QHash m_byPath; + QHash m_crops; + QVector m_queue; QTimer m_queueTimer; bool m_jobRunning = false; diff --git a/Plugins/ZShell/Models/wallpaperutil.hpp b/Plugins/ZShell/Models/wallpaperutil.hpp index d5b3a13..447fc67 100644 --- a/Plugins/ZShell/Models/wallpaperutil.hpp +++ b/Plugins/ZShell/Models/wallpaperutil.hpp @@ -285,6 +285,91 @@ inline QByteArray videoCacheBytes(const QString& src, const QSize& fit) { return frame.readAll(); } +inline QByteArray videoPosterBytes(const QString& src, const QSize& fit) { + auto runProcess = [](const QString& program, const QStringList& args) { + QProcess process; + process.start(program, args); + if (!process.waitForFinished(15000)) { + process.kill(); + process.waitForFinished(1000); + } + return qMakePair(process.exitCode(), process.readAllStandardOutput()); + }; + + QSize source; + const auto resProbe = runProcess( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + src, + }); + if (resProbe.first == 0) { + const auto parts = + QString::fromUtf8(resProbe.second).trimmed().split('x'); + if (parts.size() == 2) { + source = QSize(parts[0].toInt(), parts[1].toInt()); + } + } + + QSize target = coverSizeNoUpscale(source, fit); + if (target.isEmpty()) { + target = fit; + } + + target.setWidth(target.width() & ~1); + target.setHeight(target.height() & ~1); + + const QString scale = + QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "scale=trunc(iw/2)*2:trunc(ih/2)*2") + .arg(target.width()) + .arg(target.height()); + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + return {}; + } + + const QString framePath = tmpDir.filePath(QStringLiteral("poster.jpg")); + + runProcess( + "ffmpeg", + { + "-hide_banner", + "-loglevel", + "error", + "-ss", + "0", + "-i", + src, + "-frames:v", + "1", + "-vf", + scale, + "-pix_fmt", + "yuvj420p", + "-q:v", + "2", + "-y", + framePath, + }); + + QFile frame(framePath); + if (!frame.open(QIODevice::ReadOnly)) { + return {}; + } + + return frame.readAll(); +} + inline QByteArray schemeThumbBytes(const QByteArray& source, int maxSide) { QImage image = QImage::fromData(source, "JPEG"); if (image.isNull()) { -- 2.47.3 From a893e6cbe6da160c51b1901e4b00ff3d729e7641 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 27 Oct 2026 02:15:16 +0100 Subject: [PATCH 08/15] feat: add vaapi path for video wallpapers on generic gpus --- -a | 3 + Helpers/Wallpapers.qml | 2 +- Plugins/CMakeLists.txt | 1 + Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Internal/CMakeLists.txt | 41 -- Plugins/ZShell/Services/dropimporter.cpp | 2 +- Plugins/ZShell/Wallpaper/CMakeLists.txt | 70 ++ .../nvidiaplayer.cpp} | 120 ++-- .../nvidiaplayer.hpp} | 48 +- .../protocols/wlr-layer-shell-unstable-v1.xml | 0 .../shaders/wallpaper.frag | 0 .../shaders/wallpaper.vert | 0 .../shaders/wallpaper_trans_bowtie.frag | 0 .../shaders/wallpaper_trans_doom_melt.frag | 0 .../shaders/wallpaper_trans_fade.frag | 0 .../shaders/wallpaper_trans_hexagonalize.frag | 0 .../shaders/wallpaper_trans_linear_blur.frag | 0 ...paper_trans_split_slide_in_horizontal.frag | 0 .../shaders/wallpaper_trans_tiles_wave.frag | 0 .../wallpaper_trans_window_slices.frag | 0 .../shaders/wallpaper_trans_wipe_down.frag | 0 .../shaders/wallpaper_trans_wipe_right.frag | 0 .../shaders/wallpaper_video.frag | 0 Plugins/ZShell/Wallpaper/vaapiplayer.cpp | 628 ++++++++++++++++++ Plugins/ZShell/Wallpaper/vaapiplayer.hpp | 83 +++ Plugins/ZShell/Wallpaper/videodecoder.cpp | 117 ++++ Plugins/ZShell/Wallpaper/videodecoder.hpp | 58 ++ Plugins/ZShell/Wallpaper/videotranscoder.cpp | 165 +++++ Plugins/ZShell/Wallpaper/videotranscoder.hpp | 33 + Plugins/ZShell/Wallpaper/wallpapercache.cpp | 170 +++++ Plugins/ZShell/Wallpaper/wallpapercache.hpp | 33 + .../wallpaperimage.cpp | 4 +- .../wallpaperimage.hpp | 4 +- .../wallpaperobject.cpp | 4 +- .../wallpaperobject.hpp | 4 +- .../wallpaperservice.cpp | 579 +++++----------- .../wallpaperservice.hpp | 91 ++- .../wallpapersurface.cpp | 4 +- .../wallpapersurface.hpp | 4 +- 39 files changed, 1670 insertions(+), 599 deletions(-) create mode 100644 -a create mode 100644 Plugins/ZShell/Wallpaper/CMakeLists.txt rename Plugins/ZShell/{Internal/videoplayer.cpp => Wallpaper/nvidiaplayer.cpp} (89%) rename Plugins/ZShell/{Internal/videoplayer.hpp => Wallpaper/nvidiaplayer.hpp} (59%) rename Plugins/ZShell/{Internal => Wallpaper}/protocols/wlr-layer-shell-unstable-v1.xml (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper.vert (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_bowtie.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_doom_melt.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_fade.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_hexagonalize.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_linear_blur.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_split_slide_in_horizontal.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_tiles_wave.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_window_slices.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_wipe_down.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_trans_wipe_right.frag (100%) rename Plugins/ZShell/{Internal => Wallpaper}/shaders/wallpaper_video.frag (100%) create mode 100644 Plugins/ZShell/Wallpaper/vaapiplayer.cpp create mode 100644 Plugins/ZShell/Wallpaper/vaapiplayer.hpp create mode 100644 Plugins/ZShell/Wallpaper/videodecoder.cpp create mode 100644 Plugins/ZShell/Wallpaper/videodecoder.hpp create mode 100644 Plugins/ZShell/Wallpaper/videotranscoder.cpp create mode 100644 Plugins/ZShell/Wallpaper/videotranscoder.hpp create mode 100644 Plugins/ZShell/Wallpaper/wallpapercache.cpp create mode 100644 Plugins/ZShell/Wallpaper/wallpapercache.hpp rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperimage.cpp (99%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperimage.hpp (98%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperobject.cpp (97%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperobject.hpp (96%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperservice.cpp (84%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpaperservice.hpp (75%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpapersurface.cpp (98%) rename Plugins/ZShell/{Internal => Wallpaper}/wallpapersurface.hpp (96%) diff --git a/-a b/-a new file mode 100644 index 0000000..34b9e9d --- /dev/null +++ b/-a @@ -0,0 +1,3 @@ + +#omz revision: d2b7f557cceaddb16ba89bbf8200d8a8df35c4d8 +#omz fpath: /home/zach/.oh-my-zsh/plugins/colored-man-pages /home/zach/.oh-my-zsh/plugins/universalarchive /home/zach/.oh-my-zsh/plugins/tldr /home/zach/.oh-my-zsh/plugins/extract /home/zach/.oh-my-zsh/plugins/copypath /home/zach/.oh-my-zsh/plugins/cp /home/zach/.oh-my-zsh/plugins/conda-env /home/zach/.oh-my-zsh/plugins/command-not-found /home/zach/.oh-my-zsh/plugins/dirhistory /home/zach/.oh-my-zsh/plugins/copybuffer /home/zach/.oh-my-zsh/plugins/copyfile /home/zach/.oh-my-zsh/custom/plugins/fast-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-autosuggestions /home/zach/.oh-my-zsh/plugins/archlinux /home/zach/.oh-my-zsh/plugins/web-search /home/zach/.oh-my-zsh/plugins/sudo /home/zach/.oh-my-zsh/plugins/git /home/zach/.oh-my-zsh/functions /home/zach/.oh-my-zsh/completions /home/zach/.oh-my-zsh/custom/functions /home/zach/.oh-my-zsh/custom/completions /home/zach/tools/ripgrep/complete /home/linuxbrew/.linuxbrew/share/zsh/site-functions /home/zach/.oh-my-zsh/plugins/colored-man-pages /home/zach/.oh-my-zsh/plugins/universalarchive /home/zach/.oh-my-zsh/plugins/tldr /home/zach/.oh-my-zsh/plugins/extract /home/zach/.oh-my-zsh/plugins/copypath /home/zach/.oh-my-zsh/plugins/cp /home/zach/.oh-my-zsh/plugins/conda-env /home/zach/.oh-my-zsh/plugins/command-not-found /home/zach/.oh-my-zsh/plugins/dirhistory /home/zach/.oh-my-zsh/plugins/copybuffer /home/zach/.oh-my-zsh/plugins/copyfile /home/zach/.oh-my-zsh/custom/plugins/fast-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting /home/zach/.oh-my-zsh/custom/plugins/zsh-autosuggestions /home/zach/.oh-my-zsh/plugins/archlinux /home/zach/.oh-my-zsh/plugins/web-search /home/zach/.oh-my-zsh/plugins/sudo /home/zach/.oh-my-zsh/plugins/git /home/zach/.oh-my-zsh/functions /home/zach/.oh-my-zsh/completions /home/zach/.oh-my-zsh/custom/functions /home/zach/.oh-my-zsh/custom/completions /home/zach/.oh-my-zsh/cache/completions /home/zach/tools/ripgrep/complete /home/linuxbrew/.linuxbrew/share/zsh/site-functions /usr/local/share/zsh/site-functions /usr/share/zsh/site-functions /usr/share/zsh/functions/Calendar /usr/share/zsh/functions/Chpwd /usr/share/zsh/functions/Completion /usr/share/zsh/functions/Completion/Base /usr/share/zsh/functions/Completion/Linux /usr/share/zsh/functions/Completion/Unix /usr/share/zsh/functions/Completion/X /usr/share/zsh/functions/Completion/Zsh /usr/share/zsh/functions/Exceptions /usr/share/zsh/functions/MIME /usr/share/zsh/functions/Math /usr/share/zsh/functions/Misc /usr/share/zsh/functions/Newuser /usr/share/zsh/functions/Prompts /usr/share/zsh/functions/TCP /usr/share/zsh/functions/VCS_Info /usr/share/zsh/functions/VCS_Info/Backends /usr/share/zsh/functions/Zftp /usr/share/zsh/functions/Zle /home/zach/.zfunc diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index e8226ae..9a0717e 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Io import QtQuick import ZShell.Models -import ZShell.Internal +import ZShell.Wallpaper import ZShell.Config import qs.Components import qs.Services diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 5536e7a..df11413 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -28,6 +28,7 @@ pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) +pkg_check_modules(LIBVA QUIET IMPORTED_TARGET libva) find_package(CUDAToolkit QUIET) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 0585b5b..9db3a5d 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -47,6 +47,7 @@ list(APPEND CMAKE_AUTOMOC_MACRO_NAMES add_subdirectory(Settings) add_subdirectory(Models) add_subdirectory(Internal) +add_subdirectory(Wallpaper) add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 482460d..ff6b832 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,8 +1,3 @@ -wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") -wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") -wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") -wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") - qml_module(ZShell-internal URI ZShell.Internal INCLUDE_PREFIX internal @@ -13,16 +8,11 @@ qml_module(ZShell-internal circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp arcgauge.hpp arcgauge.cpp - wallpaperimage.hpp wallpaperimage.cpp - wallpaperservice.hpp wallpaperservice.cpp - wallpapersurface.hpp wallpapersurface.cpp - wallpaperobject.hpp wallpaperobject.cpp lidwatcher.hpp lidwatcher.cpp visualizerbars.hpp visualizerbars.cpp linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp - videoplayer.hpp videoplayer.cpp cursor.hpp LIBRARIES Qt::Gui @@ -34,39 +24,8 @@ qml_module(ZShell-internal Qt::DBus Qt::CanvasPainter Qt::GuiPrivate - wlp-layer-shell - wlp-xdg-shell - wlp-viewporter - wlp-fractional-scale PkgConfig::EGL PkgConfig::WAYLAND_EGL PkgConfig::GL - PkgConfig::MPV - PkgConfig::AVCODEC - PkgConfig::AVFORMAT - PkgConfig::AVUTIL ZShell-config ) - -qt_add_resources(ZShell-internal "wallpaper_shaders" - PREFIX "/" - FILES - shaders/wallpaper.vert - shaders/wallpaper.frag - shaders/wallpaper_video.frag - shaders/wallpaper_trans_bowtie.frag - shaders/wallpaper_trans_fade.frag - shaders/wallpaper_trans_linear_blur.frag - shaders/wallpaper_trans_window_slices.frag - shaders/wallpaper_trans_wipe_right.frag - shaders/wallpaper_trans_wipe_down.frag - shaders/wallpaper_trans_tiles_wave.frag - shaders/wallpaper_trans_split_slide_in_horizontal.frag - shaders/wallpaper_trans_hexagonalize.frag - shaders/wallpaper_trans_doom_melt.frag -) - -if(CUDAToolkit_FOUND) - target_link_libraries(ZShell-internal PRIVATE CUDA::cuda_driver) - target_compile_definitions(ZShell-internal PRIVATE ZHAVE_VIDEO=1) -endif() diff --git a/Plugins/ZShell/Services/dropimporter.cpp b/Plugins/ZShell/Services/dropimporter.cpp index 167e941..3cac93f 100644 --- a/Plugins/ZShell/Services/dropimporter.cpp +++ b/Plugins/ZShell/Services/dropimporter.cpp @@ -93,7 +93,7 @@ int DropImporter::importAttachments( saveBytes(id, rawImage); } else if (const QString src = firstImgSrc(html); !src.isEmpty()) { if (src.startsWith(QLatin1String("data:"), Qt::CaseInsensitive)) { - const int comma = src.indexOf(QLatin1Char(',')); + const qsizetype comma = src.indexOf(QLatin1Char(',')); if (comma < 0) { failWith(id, tr("Malformed data URI")); } else { diff --git a/Plugins/ZShell/Wallpaper/CMakeLists.txt b/Plugins/ZShell/Wallpaper/CMakeLists.txt new file mode 100644 index 0000000..2a3186a --- /dev/null +++ b/Plugins/ZShell/Wallpaper/CMakeLists.txt @@ -0,0 +1,70 @@ +wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") +wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") +wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") +wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") + +qml_module(ZShell-Wallpaper + URI ZShell.Wallpaper + INCLUDE_PREFIX wallpaper + SOURCES + wallpaperservice.hpp wallpaperservice.cpp + wallpaperobject.hpp wallpaperobject.cpp + wallpapersurface.hpp wallpapersurface.cpp + wallpaperimage.hpp wallpaperimage.cpp + wallpapercache.hpp wallpapercache.cpp + videotranscoder.hpp videotranscoder.cpp + videodecoder.hpp videodecoder.cpp + nvidiaplayer.hpp nvidiaplayer.cpp + vaapiplayer.hpp vaapiplayer.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Sql + Qt::Core + Qt::GuiPrivate + Qt::WaylandClient + Qt::WaylandClientPrivate + wlp-layer-shell + wlp-xdg-shell + wlp-viewporter + wlp-fractional-scale + PkgConfig::EGL + PkgConfig::WAYLAND_EGL + PkgConfig::GL + PkgConfig::MPV + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + ZShell-config +) + +target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_FFMPEG=1) + +if(LIBVA_FOUND) + target_link_libraries(ZShell-Wallpaper PRIVATE PkgConfig::LIBVA) + target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_VAAPI=1) +endif() + +if(CUDAToolkit_FOUND) + target_link_libraries(ZShell-Wallpaper PRIVATE CUDA::cuda_driver) + target_compile_definitions(ZShell-Wallpaper PRIVATE ZHAVE_VIDEO=1) +endif() + +qt_add_resources(ZShell-Wallpaper "wallpaper_shaders" + PREFIX "/" + FILES + shaders/wallpaper.vert + shaders/wallpaper.frag + shaders/wallpaper_video.frag + shaders/wallpaper_trans_bowtie.frag + shaders/wallpaper_trans_fade.frag + shaders/wallpaper_trans_linear_blur.frag + shaders/wallpaper_trans_window_slices.frag + shaders/wallpaper_trans_wipe_right.frag + shaders/wallpaper_trans_wipe_down.frag + shaders/wallpaper_trans_tiles_wave.frag + shaders/wallpaper_trans_split_slide_in_horizontal.frag + shaders/wallpaper_trans_hexagonalize.frag + shaders/wallpaper_trans_doom_melt.frag +) diff --git a/Plugins/ZShell/Internal/videoplayer.cpp b/Plugins/ZShell/Wallpaper/nvidiaplayer.cpp similarity index 89% rename from Plugins/ZShell/Internal/videoplayer.cpp rename to Plugins/ZShell/Wallpaper/nvidiaplayer.cpp index ed14b78..1a2463c 100644 --- a/Plugins/ZShell/Internal/videoplayer.cpp +++ b/Plugins/ZShell/Wallpaper/nvidiaplayer.cpp @@ -1,7 +1,7 @@ -#include "videoplayer.hpp" - #ifdef ZHAVE_VIDEO +#include "nvidiaplayer.hpp" + #define EGL_EGLEXT_PROTOTYPES #define GL_GLEXT_PROTOTYPES @@ -24,6 +24,8 @@ extern "C" CUresult cuGraphicsGLRegisterImage( Q_LOGGING_CATEGORY(videoLog, "ZShell.Internal.wallpaper") +namespace ZShell::wallpaper { + namespace { enum AVPixelFormat chooseFormat( @@ -34,14 +36,34 @@ enum AVPixelFormat chooseFormat( return pix_fmts[0]; } +class CudaCtx { + public: + explicit CudaCtx(CUcontext ctx) : m_ctx(ctx) { + if (m_ctx) cuCtxPushCurrent(m_ctx); + } + ~CudaCtx() { + if (m_ctx) cuCtxPopCurrent(&m_prev); + } + + private: + CUcontext m_ctx; + CUcontext m_prev = nullptr; +}; + } // namespace -VideoPlayer::VideoPlayer(EGLDisplay display) : m_eglDisplay(display) {} +NvidiaPlayer::NvidiaPlayer(EGLDisplay display) { + (void)display; +} -VideoPlayer::~VideoPlayer() { +NvidiaPlayer::~NvidiaPlayer() { stop(); std::lock_guard lock(m_mtx); + + CUcontext prev = nullptr; + if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); + if (m_pending) { av_frame_unref(m_pending); av_frame_free(&m_pending); @@ -51,41 +73,39 @@ VideoPlayer::~VideoPlayer() { av_frame_free(&m_displayed); } + if (m_resY) { + cuGraphicsUnregisterResource(m_resY); + m_resY = nullptr; + } + if (m_resUV) { + cuGraphicsUnregisterResource(m_resUV); + m_resUV = nullptr; + } + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + + if (m_cuCtx) cuCtxPopCurrent(&prev); + if (m_cuCtx) { - CUcontext prev = nullptr; - cuCtxPushCurrent(m_cuCtx); - if (m_resY) { - cuGraphicsUnregisterResource(m_resY); - m_resY = nullptr; - } - if (m_resUV) { - cuGraphicsUnregisterResource(m_resUV); - m_resUV = nullptr; - } - if (m_texY) { - glDeleteTextures(1, &m_texY); - m_texY = 0; - } - if (m_texUV) { - glDeleteTextures(1, &m_texUV); - m_texUV = 0; - } - cuCtxPopCurrent(&prev); cuCtxDestroy(m_cuCtx); m_cuCtx = nullptr; } m_texSize = QSize(); } -void VideoPlayer::setReadyCallback(std::function callback) { - m_readyCb = std::move(callback); +bool NvidiaPlayer::available() { + if (cuInit(0) != CUDA_SUCCESS) return false; + CUdevice dev = 0; + return cuDeviceGet(&dev, 0) == CUDA_SUCCESS; } -void VideoPlayer::setFrameCallback(std::function callback) { - m_frameCb = std::move(callback); -} - -bool VideoPlayer::openDecoder(const QString& path) { +bool NvidiaPlayer::openDecoder(const QString& path) { if (avformat_open_input( &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { qCWarning( @@ -168,7 +188,7 @@ bool VideoPlayer::openDecoder(const QString& path) { return true; } -void VideoPlayer::closeDecoder() { +void NvidiaPlayer::closeDecoder() { if (m_codec) { avcodec_free_context(&m_codec); m_codec = nullptr; @@ -184,7 +204,7 @@ void VideoPlayer::closeDecoder() { m_streamIndex = -1; } -void VideoPlayer::decodeLoop(const QString& path, uint32_t generation) { +void NvidiaPlayer::decodeLoop(const QString& path, uint32_t generation) { if (!m_threadRunning.load(std::memory_order_acquire) || m_generation.load(std::memory_order_acquire) != generation) return; @@ -257,11 +277,16 @@ void VideoPlayer::decodeLoop(const QString& path, uint32_t generation) { } av_frame_free(&frame); - closeDecoder(); + { + CudaCtx ctxGuard(m_cuCtx); + closeDecoder(); + } m_threadRunning.store(false); } -bool VideoPlayer::start(const QString& path) { +bool NvidiaPlayer::start(const QString& path) { + if (!ensureCudaContext()) return false; + const uint32_t generation = m_generation.fetch_add(1, std::memory_order_acq_rel) + 1; m_threadRunning.store(true); @@ -271,26 +296,22 @@ bool VideoPlayer::start(const QString& path) { [this, path, generation, previous = std::move(previous)]() mutable { if (previous.joinable()) previous.join(); closeDecoder(); - - if (!ensureCudaContext()) { - m_threadRunning.store(false); - return; - } - decodeLoop(path, generation); }); return true; } -void VideoPlayer::stop() { +void NvidiaPlayer::stop() { m_generation.fetch_add(1, std::memory_order_acq_rel); m_threadRunning.store(false); if (m_thread.joinable()) m_thread.join(); + std::lock_guard lock(m_mtx); + CudaCtx ctxGuard(m_cuCtx); + closeDecoder(); - std::lock_guard lock(m_mtx); if (m_pending) { av_frame_unref(m_pending); m_pending = nullptr; @@ -303,12 +324,7 @@ void VideoPlayer::stop() { m_size = QSize(); } -QSize VideoPlayer::frameSize() const { - std::lock_guard lock(m_mtx); - return m_size; -} - -void VideoPlayer::deleteTexture() { +void NvidiaPlayer::deleteTexture() { std::lock_guard lock(m_mtx); CUcontext prev = nullptr; if (m_cuCtx) cuCtxPushCurrent(m_cuCtx); @@ -332,7 +348,7 @@ void VideoPlayer::deleteTexture() { m_texSize = QSize(); } -bool VideoPlayer::ensureCudaContext() { +bool NvidiaPlayer::ensureCudaContext() { if (m_cuCtx) return true; if (cuInit(0) != CUDA_SUCCESS) { @@ -354,7 +370,7 @@ bool VideoPlayer::ensureCudaContext() { return true; } -bool VideoPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { +bool NvidiaPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { if (!ensureCudaContext()) return false; CUcontext prev = nullptr; cuCtxPushCurrent(m_cuCtx); @@ -431,7 +447,7 @@ bool VideoPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { return true; } -bool VideoPlayer::importCurrentFrame() { +bool NvidiaPlayer::importCurrentFrame() { AVFrame* frame = nullptr; { std::lock_guard lock(m_mtx); @@ -546,4 +562,6 @@ bool VideoPlayer::importCurrentFrame() { return true; } +} // namespace ZShell::wallpaper + #endif diff --git a/Plugins/ZShell/Internal/videoplayer.hpp b/Plugins/ZShell/Wallpaper/nvidiaplayer.hpp similarity index 59% rename from Plugins/ZShell/Internal/videoplayer.hpp rename to Plugins/ZShell/Wallpaper/nvidiaplayer.hpp index ed7d6e4..823876e 100644 --- a/Plugins/ZShell/Internal/videoplayer.hpp +++ b/Plugins/ZShell/Wallpaper/nvidiaplayer.hpp @@ -1,11 +1,14 @@ #pragma once +#ifdef ZHAVE_VIDEO + +#include "videodecoder.hpp" + #include #include #include -#include #include #include @@ -23,27 +26,21 @@ extern "C" { #include } -class VideoPlayer { +namespace ZShell::wallpaper { + +class NvidiaPlayer : public VideoDecoder { public: - explicit VideoPlayer(EGLDisplay display); - ~VideoPlayer(); + explicit NvidiaPlayer(EGLDisplay display); + ~NvidiaPlayer() override; - void setReadyCallback(std::function callback); - void setFrameCallback(std::function callback); + [[nodiscard]] static bool available(); - bool start(const QString& path); - void stop(); + bool start(const QString& path) override; + void stop() override; + bool importCurrentFrame() override; + void deleteTexture() override; - - bool importCurrentFrame(); - - void deleteTexture(); - - [[nodiscard]] uint32_t textureId() const { return m_texY; } - [[nodiscard]] uint32_t chromaTextureId() const { return m_texUV; } - [[nodiscard]] int colorMatrix() const { return m_matrix; } - [[nodiscard]] int colorRange() const { return m_range; } - [[nodiscard]] QSize frameSize() const; + [[nodiscard]] const char* backendName() const override { return "nvidia"; } private: void decodeLoop(const QString& path, uint32_t generation); @@ -54,10 +51,6 @@ class VideoPlayer { bool ensureCudaContext(); - EGLDisplay m_eglDisplay; - std::function m_readyCb; - std::function m_frameCb; - std::thread m_thread; std::atomic m_threadRunning{false}; std::atomic m_generation{0}; @@ -77,12 +70,9 @@ class VideoPlayer { CUgraphicsResource m_resY = nullptr; CUgraphicsResource m_resUV = nullptr; QSize m_texSize; - - uint32_t m_texY = 0; - uint32_t m_texUV = 0; - - int m_matrix = 1; - int m_range = 0; int m_texComp = 1; - QSize m_size; }; + +} // namespace ZShell::wallpaper + +#endif // ZHAVE_VIDEO diff --git a/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml b/Plugins/ZShell/Wallpaper/protocols/wlr-layer-shell-unstable-v1.xml similarity index 100% rename from Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml rename to Plugins/ZShell/Wallpaper/protocols/wlr-layer-shell-unstable-v1.xml diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper.vert b/Plugins/ZShell/Wallpaper/shaders/wallpaper.vert similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper.vert rename to Plugins/ZShell/Wallpaper/shaders/wallpaper.vert diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_bowtie.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_bowtie.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_bowtie.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_doom_melt.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_doom_melt.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_doom_melt.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_fade.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_fade.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_fade.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_hexagonalize.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_hexagonalize.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_hexagonalize.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_linear_blur.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_linear_blur.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_linear_blur.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_split_slide_in_horizontal.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_split_slide_in_horizontal.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_split_slide_in_horizontal.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_tiles_wave.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_tiles_wave.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_tiles_wave.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_window_slices.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_window_slices.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_window_slices.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_down.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_down.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_down.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_right.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_trans_wipe_right.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_trans_wipe_right.frag diff --git a/Plugins/ZShell/Internal/shaders/wallpaper_video.frag b/Plugins/ZShell/Wallpaper/shaders/wallpaper_video.frag similarity index 100% rename from Plugins/ZShell/Internal/shaders/wallpaper_video.frag rename to Plugins/ZShell/Wallpaper/shaders/wallpaper_video.frag diff --git a/Plugins/ZShell/Wallpaper/vaapiplayer.cpp b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp new file mode 100644 index 0000000..bd6ea2c --- /dev/null +++ b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp @@ -0,0 +1,628 @@ +#define EGL_EGLEXT_PROTOTYPES +#include "vaapiplayer.hpp" + +#ifdef ZHAVE_FFMPEG + +#define GL_GLEXT_PROTOTYPES + +#include +#include +#include + +#ifdef ZHAVE_VAAPI +#include +extern "C" { +#include +#include +#include +} +#ifndef DRM_FORMAT_MOD_INVALID +#define DRM_FORMAT_MOD_INVALID 0xffffffffffffffffULL +#endif +#endif + +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(vaapiLog, "ZShell.Internal.wallpaper") + +namespace ZShell::wallpaper { + +namespace { + +enum AVPixelFormat chooseFormat( + AVCodecContext* ctx, const AVPixelFormat* pix_fmts) { + (void)ctx; + for (const AVPixelFormat* p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + if (*p == AV_PIX_FMT_VAAPI) return AV_PIX_FMT_VAAPI; + return pix_fmts[0]; +} + +#ifdef ZHAVE_VAAPI +using GLEGLImageTargetProc = void (*)(GLenum, EGLImageKHR); + +GLEGLImageTargetProc glEGLImageTarget() { + static GLEGLImageTargetProc fn = reinterpret_cast( + eglGetProcAddress("glEGLImageTargetTexture2DOES")); + return fn; +} + +EGLImageKHR createDmaImage( + EGLDisplay dpy, + int fd, + uint32_t offset, + uint32_t pitch, + uint32_t fourcc, + uint64_t modifier) { + EGLint attrs[16]; + int n = 0; + attrs[n++] = EGL_LINUX_DRM_FOURCC_EXT; + attrs[n++] = EGLint(fourcc); + attrs[n++] = EGL_DMA_BUF_PLANE0_FD_EXT; + attrs[n++] = fd; + attrs[n++] = EGL_DMA_BUF_PLANE0_OFFSET_EXT; + attrs[n++] = EGLint(offset); + attrs[n++] = EGL_DMA_BUF_PLANE0_PITCH_EXT; + attrs[n++] = EGLint(pitch); + if (modifier != DRM_FORMAT_MOD_INVALID) { + attrs[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT; + attrs[n++] = EGLint(modifier & 0xffffffffULL); + attrs[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT; + attrs[n++] = EGLint(modifier >> 32); + } + attrs[n] = EGL_NONE; + + EGLImageKHR image = eglCreateImageKHR( + dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs); + if (image == EGL_NO_IMAGE_KHR) return EGL_NO_IMAGE_KHR; + return image; +} +#endif + +} // namespace + +VaapiPlayer::VaapiPlayer(EGLDisplay display) : m_eglDisplay(display) {} + +VaapiPlayer::~VaapiPlayer() { + stop(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + av_frame_free(&m_pending); + } + if (m_displayed) { + av_frame_unref(m_displayed); + av_frame_free(&m_displayed); + } + if (m_hwDeviceCtx) { + av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + } + +#ifdef ZHAVE_VAAPI + if (m_imageY != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } +#endif +} + +bool VaapiPlayer::available() { + AVBufferRef* ref = nullptr; + if (av_hwdevice_ctx_create( + &ref, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0) < 0) { + if (ref) av_buffer_unref(&ref); + return false; + } + av_buffer_unref(&ref); + return true; +} + +bool VaapiPlayer::ensureDevice() { + if (m_hwDeviceCtx) return true; + if (av_hwdevice_ctx_create( + &m_hwDeviceCtx, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0) < 0) { + if (m_hwDeviceCtx) av_buffer_unref(&m_hwDeviceCtx); + m_hwDeviceCtx = nullptr; + qCWarning(vaapiLog, "VideoPlayer: could not create vaapi hwdevice"); + return false; + } + return true; +} + +bool VaapiPlayer::openDecoder(const QString& path) { + if (avformat_open_input( + &m_fmt, path.toUtf8().constData(), nullptr, nullptr) < 0) { + qCWarning( + vaapiLog, + "VideoPlayer: could not open %s", + path.toUtf8().constData()); + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not read stream info"); + return false; + } + + m_streamIndex = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { + m_streamIndex = int(i); + break; + } + if (m_streamIndex < 0) { + qCWarning(vaapiLog, "VideoPlayer: no video stream"); + return false; + } + + AVCodecParameters* par = m_fmt->streams[m_streamIndex]->codecpar; + const AVCodec* codec = avcodec_find_decoder(par->codec_id); + const AVCodec* hwcodec = nullptr; + void* it = nullptr; + while (const AVCodec* c = av_codec_iterate(&it)) { + if (c->id != par->codec_id || !av_codec_is_decoder(c)) continue; + for (int i = 0; + const AVCodecHWConfig* cfg = avcodec_get_hw_config(c, i); + ++i) + if (cfg->device_type == AV_HWDEVICE_TYPE_VAAPI && + (cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) { + hwcodec = c; + break; + } + if (hwcodec) break; + } + if (hwcodec) codec = hwcodec; + if (!codec) { + qCWarning( + vaapiLog, + "VideoPlayer: no decoder for codec id %d", + int(par->codec_id)); + return false; + } + + m_codec = avcodec_alloc_context3(codec); + if (!m_codec) return false; + if (avcodec_parameters_to_context(m_codec, par) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not copy codec params"); + return false; + } + + if (!ensureDevice()) { + return false; + } + m_codec->hw_device_ctx = av_buffer_ref(m_hwDeviceCtx); + m_codec->get_format = chooseFormat; + + if (avcodec_open2(m_codec, codec, nullptr) < 0) { + qCWarning(vaapiLog, "VideoPlayer: could not open codec"); + return false; + } + + return true; +} + +void VaapiPlayer::closeDecoder() { + if (m_codec) { + avcodec_free_context(&m_codec); + m_codec = nullptr; + } + if (m_fmt) { + avformat_close_input(&m_fmt); + m_fmt = nullptr; + } + m_streamIndex = -1; +} + +void VaapiPlayer::decodeLoop(const QString& path, uint32_t generation) { + if (!m_threadRunning.load(std::memory_order_acquire) || + m_generation.load(std::memory_order_acquire) != generation) + return; + + if (!openDecoder(path)) { + m_threadRunning.store(false); + return; + } + + AVRational fr = m_fmt->streams[m_streamIndex]->avg_frame_rate; + if (fr.num <= 0 || fr.den <= 0) fr = m_codec->framerate; + if (fr.num <= 0 || fr.den <= 0) fr = AVRational{30, 1}; + const int64_t intervalUs = + av_rescale_q(1, AVRational{fr.den, fr.num}, AVRational{1, 1000000}); + + AVFrame* frame = av_frame_alloc(); + if (!frame) { + m_threadRunning.store(false); + return; + } + + AVPacket pkt{}; + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation) { + int ret = av_read_frame(m_fmt, &pkt); + if (ret == AVERROR_EOF) { + if (avformat_seek_file( + m_fmt, m_streamIndex, INT64_MIN, 0, INT64_MAX, 0) < 0) { + qCWarning(vaapiLog, "VideoPlayer: loop seek failed; stopping"); + break; + } + avcodec_flush_buffers(m_codec); + continue; + } + if (ret < 0) { + qCWarning(vaapiLog, "VideoPlayer: read error; stopping"); + break; + } + if (pkt.stream_index != m_streamIndex) { + av_packet_unref(&pkt); + continue; + } + + if (avcodec_send_packet(m_codec, &pkt) < 0) { + av_packet_unref(&pkt); + continue; + } + av_packet_unref(&pkt); + + while (m_threadRunning.load(std::memory_order_acquire) && + m_generation.load(std::memory_order_acquire) == generation && + avcodec_receive_frame(m_codec, frame) == 0) { + bool wasEmpty = false; + { + std::lock_guard lock(m_mtx); + if (!m_pending) m_pending = av_frame_alloc(); + av_frame_unref(m_pending); + if (av_frame_ref(m_pending, frame) >= 0) { + wasEmpty = !m_haveFrame; + m_haveFrame = true; + } + } + if (wasEmpty && m_readyCb) m_readyCb(); + if (m_frameCb) m_frameCb(); + av_frame_unref(frame); + } + + std::this_thread::sleep_for(std::chrono::microseconds(intervalUs)); + } + + av_frame_free(&frame); + closeDecoder(); + m_threadRunning.store(false); +} + +bool VaapiPlayer::start(const QString& path) { + if (!ensureDevice()) return false; + + const uint32_t generation = + m_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + m_threadRunning.store(true); + + std::thread previous = std::move(m_thread); + m_thread = std::thread( + [this, path, generation, previous = std::move(previous)]() mutable { + if (previous.joinable()) previous.join(); + closeDecoder(); + decodeLoop(path, generation); + }); + + return true; +} + +void VaapiPlayer::stop() { + m_generation.fetch_add(1, std::memory_order_acq_rel); + m_threadRunning.store(false); + if (m_thread.joinable()) m_thread.join(); + + closeDecoder(); + + std::lock_guard lock(m_mtx); + if (m_pending) { + av_frame_unref(m_pending); + m_pending = nullptr; + } + if (m_displayed) { + av_frame_unref(m_displayed); + m_displayed = nullptr; + } + m_haveFrame = false; + m_size = QSize(); +} + +void VaapiPlayer::deleteTexture() { +#ifdef ZHAVE_VAAPI + if (m_imageY != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } +#endif + if (m_texY) { + glDeleteTextures(1, &m_texY); + m_texY = 0; + } + if (m_texUV) { + glDeleteTextures(1, &m_texUV); + m_texUV = 0; + } + m_texSize = QSize(); +} + +bool VaapiPlayer::ensureTextures(unsigned w, unsigned h, int compBytes) { + const QSize sz = QSize(int(w), int(h)); + if (m_texSize == sz && m_texComp == compBytes && m_texY != 0) return true; + + if (m_texY) glDeleteTextures(1, &m_texY); + if (m_texUV) glDeleteTextures(1, &m_texUV); + m_texY = m_texUV = 0; + + const bool wide = compBytes == 2; + auto makeTex = + [&](GLuint& tex, GLint internal, GLenum fmt, unsigned tw, unsigned th) { + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexImage2D( + GL_TEXTURE_2D, + 0, + internal, + GLint(tw), + GLint(th), + 0, + fmt, + wide ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE, + nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + makeTex(m_texY, wide ? GL_R16 : GL_R8, GL_RED, w, h); + makeTex(m_texUV, wide ? GL_RG16 : GL_RG8, GL_RG, w / 2, h / 2); + + m_texSize = sz; + m_texComp = compBytes; + return true; +} + +bool VaapiPlayer::importCurrentFrame() { + AVFrame* frame = nullptr; + { + std::lock_guard lock(m_mtx); + if (!m_pending || m_pending->format == AV_PIX_FMT_NONE) return false; + frame = m_pending; + m_pending = nullptr; + } + + if (frame->format != AV_PIX_FMT_VAAPI || !frame->hw_frames_ctx) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, + "VideoPlayer: frame not on GPU (fmt=%s hw_frames=%d)", + av_get_pix_fmt_name(AVPixelFormat(frame->format)), + frame->hw_frames_ctx != nullptr); + av_frame_unref(frame); + return false; + } + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVPixelFormat sw = hf->sw_format; + const bool tenBit = sw == AV_PIX_FMT_P010LE || sw == AV_PIX_FMT_P010BE || + sw == AV_PIX_FMT_P012LE || sw == AV_PIX_FMT_P016LE; + const int compBytes = tenBit ? 2 : 1; + const size_t compSize = size_t(compBytes); + + const unsigned w = unsigned(frame->width); + const unsigned h = unsigned(frame->height); + if (w == 0 || h == 0) { + av_frame_unref(frame); + return false; + } + + switch (frame->colorspace) { + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + m_matrix = 0; + break; + case AVCOL_SPC_BT709: + m_matrix = 1; + break; + default: + m_matrix = h >= 720 ? 1 : 0; + break; + } + m_range = frame->color_range == AVCOL_RANGE_JPEG ? 1 : 0; + m_size = QSize(int(w), int(h)); + +#ifdef ZHAVE_VAAPI + if (importEglImages(frame)) { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + return true; + } +#endif + + AVFrame* tmp = av_frame_alloc(); + if (!tmp) { + av_frame_unref(frame); + return false; + } + if (av_hwframe_transfer_data(tmp, frame, 0) < 0) { + av_frame_free(&tmp); + av_frame_unref(frame); + return false; + } + + size_t pitchY = size_t(tmp->linesize[0]); + size_t pitchUV = tmp->linesize[1] > 0 ? size_t(tmp->linesize[1]) : pitchY; + if (pitchY == 0) pitchY = size_t(w) * compSize; + + if (!ensureTextures(w, h, compBytes)) { + av_frame_free(&tmp); + av_frame_unref(frame); + return false; + } + + const GLenum type = compBytes == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_BYTE; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + + glBindTexture(GL_TEXTURE_2D, m_texY); + glPixelStorei(GL_UNPACK_ROW_LENGTH, GLint(pitchY / compSize)); + glTexSubImage2D( + GL_TEXTURE_2D, 0, 0, 0, GLint(w), GLint(h), GL_RED, type, tmp->data[0]); + + glBindTexture(GL_TEXTURE_2D, m_texUV); + glPixelStorei(GL_UNPACK_ROW_LENGTH, GLint(pitchUV / (compSize * 2))); + glTexSubImage2D( + GL_TEXTURE_2D, + 0, + 0, + 0, + GLint(w / 2), + GLint(h / 2), + GL_RG, + type, + tmp->data[1]); + + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + + av_frame_free(&tmp); + + { + std::lock_guard lock(m_mtx); + if (m_displayed) av_frame_unref(m_displayed); + m_displayed = frame; + } + + return true; +} + +#ifdef ZHAVE_VAAPI +bool VaapiPlayer::importEglImages(AVFrame* frame) { + if (m_eglDisplay == EGL_NO_DISPLAY) return false; + GLEGLImageTargetProc target = glEGLImageTarget(); + if (!target) return false; + + const AVHWFramesContext* hf = + reinterpret_cast(frame->hw_frames_ctx->data); + const AVHWDeviceContext* dev = hf->device_ctx; + const AVVAAPIDeviceContext* va = + reinterpret_cast(dev->hwctx); + if (!va->display) return false; + + const VASurfaceID surface = VASurfaceID(uintptr_t(frame->data[0])); + + VADRMPRIMESurfaceDescriptor desc{}; + VAStatus st = vaExportSurfaceHandle( + va->display, + surface, + VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2, + VA_EXPORT_SURFACE_READ_ONLY | VA_EXPORT_SURFACE_SEPARATE_LAYERS, + &desc); + if (st != VA_STATUS_SUCCESS) { + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, + "VideoPlayer: vaExportSurfaceHandle failed (%d); CPU fallback", + int(st)); + return false; + } + + if (desc.num_layers != 2 || desc.num_objects < 2) { + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + return false; + } + + const auto bindLayer = [&](uint32_t layer, GLuint tex) -> EGLImageKHR { + const auto& L = desc.layers[layer]; + if (L.num_planes < 1) return EGL_NO_IMAGE_KHR; + const auto& obj = desc.objects[L.object_index[0]]; + if (obj.fd < 0) return EGL_NO_IMAGE_KHR; + EGLImageKHR image = createDmaImage( + m_eglDisplay, + obj.fd, + L.offset[0], + L.pitch[0], + L.drm_format, + obj.drm_format_modifier); + if (image == EGL_NO_IMAGE_KHR) return EGL_NO_IMAGE_KHR; + glBindTexture(GL_TEXTURE_2D, tex); + target(GL_TEXTURE_2D, image); + return image; + }; + + const QSize sz = QSize(int(frame->width), int(frame->height)); + if (m_texSize != sz || m_texY == 0) { + if (m_imageY != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageY); + m_imageY = EGL_NO_IMAGE_KHR; + } + if (m_imageUV != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(m_eglDisplay, m_imageUV); + m_imageUV = EGL_NO_IMAGE_KHR; + } + if (m_texY == 0) glGenTextures(1, &m_texY); + if (m_texUV == 0) glGenTextures(1, &m_texUV); + glBindTexture(GL_TEXTURE_2D, m_texY); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, m_texUV); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + m_texSize = sz; + } + + EGLImageKHR imageY = bindLayer(0, m_texY); + EGLImageKHR imageUV = bindLayer(1, m_texUV); + + if (imageY == EGL_NO_IMAGE_KHR || imageUV == EGL_NO_IMAGE_KHR) { + if (imageY != EGL_NO_IMAGE_KHR) + eglDestroyImageKHR(m_eglDisplay, imageY); + if (imageUV != EGL_NO_IMAGE_KHR) + eglDestroyImageKHR(m_eglDisplay, imageUV); + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + static int logged = 0; + if (logged++ < 3) + qCWarning( + vaapiLog, "VideoPlayer: EGLImage import failed; CPU fallback"); + return false; + } + + if (m_imageY != EGL_NO_IMAGE_KHR) + eglDestroyImageKHR(m_eglDisplay, m_imageY); + if (m_imageUV != EGL_NO_IMAGE_KHR) + eglDestroyImageKHR(m_eglDisplay, m_imageUV); + + m_imageY = imageY; + m_imageUV = imageUV; + + for (uint32_t i = 0; i < desc.num_objects; ++i) + if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); + + return true; +} +#endif + +} // namespace ZShell::wallpaper + +#endif diff --git a/Plugins/ZShell/Wallpaper/vaapiplayer.hpp b/Plugins/ZShell/Wallpaper/vaapiplayer.hpp new file mode 100644 index 0000000..aacf26a --- /dev/null +++ b/Plugins/ZShell/Wallpaper/vaapiplayer.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "videodecoder.hpp" + +#include + +#include +#include +#include + +#include +#include + +#include +#ifdef ZHAVE_VAAPI +#include +#endif + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} + +namespace ZShell::wallpaper { + +class VaapiPlayer : public VideoDecoder { + public: + explicit VaapiPlayer(EGLDisplay display); + ~VaapiPlayer() override; + + [[nodiscard]] static bool available(); + + bool start(const QString& path) override; + void stop() override; + bool importCurrentFrame() override; + void deleteTexture() override; + + [[nodiscard]] const char* backendName() const override { return "vaapi"; } + + private: + void decodeLoop(const QString& path, uint32_t generation); + bool openDecoder(const QString& path); + void closeDecoder(); + + bool ensureDevice(); + bool ensureTextures(unsigned w, unsigned h, int compBytes); + +#ifdef ZHAVE_VAAPI + bool importEglImages(AVFrame* frame); +#endif + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + + std::thread m_thread; + std::atomic m_threadRunning{false}; + std::atomic m_generation{0}; + + mutable std::mutex m_mtx; + AVFrame* m_pending = nullptr; + bool m_haveFrame = false; + + AVFrame* m_displayed = nullptr; + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_codec = nullptr; + AVBufferRef* m_hwDeviceCtx = nullptr; + int m_streamIndex = -1; + + QSize m_texSize; + int m_texComp = 1; + +#ifdef ZHAVE_VAAPI + EGLImageKHR m_imageY = EGL_NO_IMAGE_KHR; + EGLImageKHR m_imageUV = EGL_NO_IMAGE_KHR; +#endif +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videodecoder.cpp b/Plugins/ZShell/Wallpaper/videodecoder.cpp new file mode 100644 index 0000000..52e5e67 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videodecoder.cpp @@ -0,0 +1,117 @@ +#include "videodecoder.hpp" + +#include + +#ifdef ZHAVE_VIDEO +#include "nvidiaplayer.hpp" +#endif + +#ifdef ZHAVE_FFMPEG +#include "vaapiplayer.hpp" +#endif + +namespace ZShell::wallpaper { + +VideoDecoder::~VideoDecoder() = default; + +void VideoDecoder::setReadyCallback(std::function callback) { + m_readyCb = std::move(callback); +} + +void VideoDecoder::setFrameCallback(std::function callback) { + m_frameCb = std::move(callback); +} + +uint32_t VideoDecoder::textureId() const { + return m_texY; +} + +uint32_t VideoDecoder::chromaTextureId() const { + return m_texUV; +} + +int VideoDecoder::colorMatrix() const { + return m_matrix; +} + +int VideoDecoder::colorRange() const { + return m_range; +} + +QSize VideoDecoder::frameSize() const { + return m_size; +} + +namespace { + +VideoBackend backendFromEnvironment() { + if (qEnvironmentVariableIsSet("ZBAR_WALLPAPER_VAAPI")) + return VideoBackend::Vaapi; + if (qEnvironmentVariableIsSet("ZBAR_WALLPAPER_NVIDIA")) + return VideoBackend::Nvidia; + return VideoBackend::Auto; +} + +VideoBackend selectBackend(VideoBackend hint) { + if (hint == VideoBackend::None) return VideoBackend::None; + if (hint == VideoBackend::Auto) hint = backendFromEnvironment(); + + if (hint == VideoBackend::Nvidia) { +#ifdef ZHAVE_VIDEO + if (NvidiaPlayer::available()) return VideoBackend::Nvidia; +#endif + return VideoBackend::None; + } + + if (hint == VideoBackend::Vaapi) { +#ifdef ZHAVE_FFMPEG + if (VaapiPlayer::available()) return VideoBackend::Vaapi; +#endif + return VideoBackend::None; + } + +#ifdef ZHAVE_VIDEO + if (NvidiaPlayer::available()) return VideoBackend::Nvidia; +#endif + +#ifdef ZHAVE_FFMPEG + if (VaapiPlayer::available()) return VideoBackend::Vaapi; +#endif + + return VideoBackend::None; +} + +} // namespace + +std::unique_ptr createVideoDecoder( + EGLDisplay display, VideoBackend hint) { + switch (selectBackend(hint)) { +#ifdef ZHAVE_VIDEO + case VideoBackend::Nvidia: + return std::make_unique(display); +#endif +#ifdef ZHAVE_FFMPEG + case VideoBackend::Vaapi: + return std::make_unique(display); +#endif + case VideoBackend::Auto: + case VideoBackend::None: + break; + } + return nullptr; +} + +const char* resolveBackendName(VideoBackend hint) { + switch (selectBackend(hint)) { + case VideoBackend::Nvidia: + return "nvidia"; + case VideoBackend::Vaapi: + return "vaapi"; + case VideoBackend::Auto: + case VideoBackend::None: + break; + } + return "mpv"; +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videodecoder.hpp b/Plugins/ZShell/Wallpaper/videodecoder.hpp new file mode 100644 index 0000000..2275aaf --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videodecoder.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + +namespace ZShell::wallpaper { + +class VideoDecoder { + public: + virtual ~VideoDecoder(); + + void setReadyCallback(std::function callback); + void setFrameCallback(std::function callback); + + virtual bool start(const QString& path) = 0; + virtual void stop() = 0; + + virtual bool importCurrentFrame() = 0; + virtual void deleteTexture() = 0; + + [[nodiscard]] uint32_t textureId() const; + [[nodiscard]] uint32_t chromaTextureId() const; + [[nodiscard]] int colorMatrix() const; + [[nodiscard]] int colorRange() const; + [[nodiscard]] QSize frameSize() const; + + [[nodiscard]] virtual const char* backendName() const = 0; + + protected: + std::function m_readyCb; + std::function m_frameCb; + + uint32_t m_texY = 0; + uint32_t m_texUV = 0; + int m_matrix = 1; + int m_range = 0; + QSize m_size; +}; + +enum class VideoBackend { + Auto, + Nvidia, + Vaapi, + None, +}; + +std::unique_ptr createVideoDecoder( + EGLDisplay display, VideoBackend hint = VideoBackend::Auto); + +const char* resolveBackendName(VideoBackend hint = VideoBackend::Auto); + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videotranscoder.cpp b/Plugins/ZShell/Wallpaper/videotranscoder.cpp new file mode 100644 index 0000000..5e998ab --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videotranscoder.cpp @@ -0,0 +1,165 @@ +#include "videotranscoder.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +Q_LOGGING_CATEGORY(transcodeLog, "ZShell.Internal.wallpaper") + +namespace ZShell::wallpaper { + +VideoTranscoder::VideoTranscoder(QObject* parent) : QObject(parent) {} + +bool VideoTranscoder::isHardwareCodec(const QString& codec) { + static const QStringList hardware{ + "av1", + "hevc", + "h264", + "vp9", + "vp8", + "vc1", + "mpeg2", + "mpeg2video", + "mpeg4", + "mjpeg", + }; + + return hardware.contains(codec); +} + +QString VideoTranscoder::codecName(const QString& path) const { + QProcess probe; + probe.start( + "ffprobe", + { + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nw=1:nk=1", + path, + }); + + if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { + qWarning(transcodeLog) << "WallpaperService: ffprobe failed for" << path + << "(exit" << probe.exitCode() << ")"; + return {}; + } + + return QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); +} + +QString VideoTranscoder::cacheDir() const { + return QDir::homePath() + "/.cache/zshell/wallpaper-videos"; +} + +QString VideoTranscoder::transcodeTarget(const QString& path) const { + const QFileInfo info(path); + + const QByteArray key = + path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' + + QByteArray::number(info.lastModified().toMSecsSinceEpoch()); + + const QString hash = QString::fromUtf8( + QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex()); + + return cacheDir() + '/' + hash + ".av1.mkv"; +} + +bool VideoTranscoder::needsTranscode(const QString& path) const { + if (path.startsWith(cacheDir())) return false; + + const QString codec = codecName(path); + if (codec.isEmpty()) return false; + + return !isHardwareCodec(codec); +} + +void VideoTranscoder::start(const QString& src, const QString& dst) { + QDir().mkpath(cacheDir()); + + if (m_process) { + m_process->kill(); + m_process->deleteLater(); + m_process = nullptr; + } + + m_process = new QProcess(this); + auto* proc = m_process; + + connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { + qWarning(transcodeLog).noquote() + << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); + }); + + connect( + proc, + &QProcess::finished, + this, + [this, proc, dst](int code, QProcess::ExitStatus status) { + const bool ok = code == 0 && status == QProcess::NormalExit && + QFile::exists(dst); + + qWarning(transcodeLog) + << "WallpaperService: transcode finished, exit" << code + << "status" << int(status) << "exists" << QFile::exists(dst) + << "->" << dst; + + if (proc == m_process) m_process = nullptr; + proc->deleteLater(); + + emit transcoded(dst, ok); + }); + + qWarning(transcodeLog) << "WallpaperService: transcoding" << src << "->" + << dst; + + proc->setProgram("nice"); + proc->setArguments({ + "-n", + "19", + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-threads", + "2", + "-i", + src, + "-map", + "0:v:0", + "-vf", + "scale=in_range=pc:out_range=tv:out_color_matrix=bt709", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + "-c:v", + "libsvtav1", + "-preset", + "6", + "-crf", + "20", + "-pix_fmt", + "yuv420p10le", + "-an", + dst, + }); + + proc->start(); +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/videotranscoder.hpp b/Plugins/ZShell/Wallpaper/videotranscoder.hpp new file mode 100644 index 0000000..9879d6f --- /dev/null +++ b/Plugins/ZShell/Wallpaper/videotranscoder.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +class QProcess; + +namespace ZShell::wallpaper { + +class VideoTranscoder : public QObject { + Q_OBJECT + + public: + explicit VideoTranscoder(QObject* parent = nullptr); + + [[nodiscard]] QString cacheDir() const; + [[nodiscard]] QString transcodeTarget(const QString& path) const; + [[nodiscard]] bool needsTranscode(const QString& path) const; + + void start(const QString& src, const QString& dst); + + static bool isHardwareCodec(const QString& codec); + + signals: + void transcoded(const QString& dst, bool ok); + + private: + [[nodiscard]] QString codecName(const QString& path) const; + + QProcess* m_process = nullptr; +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpapercache.cpp b/Plugins/ZShell/Wallpaper/wallpapercache.cpp new file mode 100644 index 0000000..a0aeb2e --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpapercache.cpp @@ -0,0 +1,170 @@ +#include "wallpapercache.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::wallpaper { + +namespace { + +QString connectionName() { + return QStringLiteral("wallpaper-cache-%1") + .arg(quintptr(QThread::currentThreadId())); +} + +QSqlDatabase openDatabase(const QString& databasePath) { + const QString connection = connectionName(); + + QSqlDatabase database = + QSqlDatabase::contains(connection) + ? QSqlDatabase::database(connection, false) + : QSqlDatabase::addDatabase("QSQLITE", connection); + + if (database.databaseName() != databasePath) { + if (database.isOpen()) database.close(); + database.setDatabaseName(databasePath); + } + + if (!database.isOpen() && !database.open()) return {}; + return database; +} + +} // namespace + +void WallpaperCache::setDbPath(const QString& path) { + std::lock_guard lock(m_mtx); + m_dbPath = path; +} + +QString WallpaperCache::dbPath() const { + std::lock_guard lock(m_mtx); + return m_dbPath; +} + +QByteArray WallpaperCache::cachedBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT cache, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QImage WallpaperCache::decodeImage( + const QString& path, const QSize& decodeSize) const { + if (path.isEmpty()) return {}; + + QImageReader probe(path); + const QSize source = probe.size(); + QSize required = source.isEmpty() ? decodeSize : source; + if (!source.isEmpty() && !decodeSize.isEmpty()) { + const QSize cover = + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding); + if (cover.width() <= source.width() && + cover.height() <= source.height()) + required = cover; + } + + const QByteArray blob = cachedBlob(path); + if (!blob.isEmpty()) { + QImage image = QImage::fromData(blob, "JPEG"); + if (!image.isNull() && image.width() >= required.width() && + image.height() >= required.height()) { + return image.convertToFormat(QImage::Format_RGBA8888); + } + } + + QImageReader reader(path); + reader.setAutoTransform(true); + + if (!decodeSize.isEmpty() && !source.isEmpty()) + reader.setScaledSize( + source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding)); + + QImage image = reader.read(); + if (image.isNull()) return {}; + + return image.convertToFormat(QImage::Format_RGBA8888); +} + +QByteArray WallpaperCache::posterBlob(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT poster, size, mtime FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray blob = query.value(0).toByteArray(); + if (blob.isEmpty()) return {}; + + const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; + if (query.value(1).toLongLong() != info.size() || + query.value(2).toLongLong() != mtime) { + return {}; + } + + return blob; +} + +QVariantMap WallpaperCache::cropsForPath(const QString& path) const { + if (path.isEmpty()) return {}; + + const QString databasePath = dbPath(); + if (databasePath.isEmpty()) return {}; + + const QFileInfo info(path); + + QSqlDatabase database = openDatabase(databasePath); + if (!database.isOpen()) return {}; + + QSqlQuery query(database); + query.prepare("SELECT crop FROM wallpapers WHERE path = ?"); + query.addBindValue(info.absoluteFilePath()); + if (!query.exec() || !query.next()) return {}; + + const QByteArray json = query.value(0).toByteArray(); + if (json.isEmpty()) return {}; + + const auto doc = QJsonDocument::fromJson(json); + if (!doc.isObject()) return {}; + + return doc.object().toVariantMap(); +} + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Wallpaper/wallpapercache.hpp b/Plugins/ZShell/Wallpaper/wallpapercache.hpp new file mode 100644 index 0000000..a0f6ad3 --- /dev/null +++ b/Plugins/ZShell/Wallpaper/wallpapercache.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell::wallpaper { + +class WallpaperCache { + public: + void setDbPath(const QString& path); + [[nodiscard]] QString dbPath() const; + + [[nodiscard]] QImage decodeImage( + const QString& path, const QSize& decodeSize) const; + + [[nodiscard]] QByteArray posterBlob(const QString& path) const; + + [[nodiscard]] QVariantMap cropsForPath(const QString& path) const; + + private: + [[nodiscard]] QByteArray cachedBlob(const QString& path) const; + + mutable std::mutex m_mtx; + QString m_dbPath; +}; + +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Wallpaper/wallpaperimage.cpp similarity index 99% rename from Plugins/ZShell/Internal/wallpaperimage.cpp rename to Plugins/ZShell/Wallpaper/wallpaperimage.cpp index f6de9de..897dcb2 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Wallpaper/wallpaperimage.cpp @@ -8,7 +8,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { QThreadPool* WallpaperImage::loadPool() { static QThreadPool* pool = [] { @@ -352,4 +352,4 @@ QSGNode* WallpaperImage::updatePaintNode( return m_rootNode; } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperimage.hpp b/Plugins/ZShell/Wallpaper/wallpaperimage.hpp similarity index 98% rename from Plugins/ZShell/Internal/wallpaperimage.hpp rename to Plugins/ZShell/Wallpaper/wallpaperimage.hpp index 6ecf562..8226759 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.hpp +++ b/Plugins/ZShell/Wallpaper/wallpaperimage.hpp @@ -14,7 +14,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperImage : public QQuickItem { Q_OBJECT @@ -144,4 +144,4 @@ class WallpaperImage : public QQuickItem { QSGImageNode* m_oldImageNode = nullptr; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Wallpaper/wallpaperobject.cpp similarity index 97% rename from Plugins/ZShell/Internal/wallpaperobject.cpp rename to Plugins/ZShell/Wallpaper/wallpaperobject.cpp index b29ce9c..143a637 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.cpp +++ b/Plugins/ZShell/Wallpaper/wallpaperobject.cpp @@ -1,6 +1,6 @@ #include "wallpaperobject.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {} @@ -118,4 +118,4 @@ void WallpaperObject::push() { m_service->setCrop(m_screen, crop); } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Wallpaper/wallpaperobject.hpp similarity index 96% rename from Plugins/ZShell/Internal/wallpaperobject.hpp rename to Plugins/ZShell/Wallpaper/wallpaperobject.hpp index 2787ded..e2052be 100644 --- a/Plugins/ZShell/Internal/wallpaperobject.hpp +++ b/Plugins/ZShell/Wallpaper/wallpaperobject.hpp @@ -9,7 +9,7 @@ #include "wallpaperservice.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperObject : public QObject { Q_OBJECT @@ -83,4 +83,4 @@ class WallpaperObject : public QObject { bool m_pushEnabled = true; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Wallpaper/wallpaperservice.cpp similarity index 84% rename from Plugins/ZShell/Internal/wallpaperservice.cpp rename to Plugins/ZShell/Wallpaper/wallpaperservice.cpp index b8569a0..7ff181f 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Wallpaper/wallpaperservice.cpp @@ -46,25 +46,41 @@ #include "config/background.hpp" #include "config/rootnodes.hpp" +#include "config/services.hpp" #include -namespace ZShell::internal { +namespace ZShell::wallpaper { Q_LOGGING_CATEGORY(wpLog, "ZShell.Internal.wallpaper") namespace { -#ifdef ZHAVE_VIDEO const bool kMpvMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_MPV"); -#endif -#ifdef ZHAVE_VIDEO const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES") || kMpvMode; -#else -const bool kGlesMode = qEnvironmentVariableIsSet("ZBAR_WALLPAPER_GLES"); -#endif + +VideoBackend backendFromConfig() { + const auto type = + ZShell::config::ConfigSingleton::instance()->services()->gpuType(); + switch (type) { + case ZShell::config::GpuType::Nvidia: + return VideoBackend::Nvidia; + case ZShell::config::GpuType::Generic: + return VideoBackend::Vaapi; + case ZShell::config::GpuType::None: + return VideoBackend::None; + case ZShell::config::GpuType::Auto: + default: + return VideoBackend::Auto; + } +} + +void logSelectedBackend() { + qInfo(wpLog) << "WallpaperService: video backend =" + << resolveBackendName(backendFromConfig()); +} const char* shaderHeader(bool fragment) { if (kGlesMode) @@ -213,6 +229,22 @@ WallpaperService::WallpaperService(QObject* parent) : QObject(parent) { wake(); }); + + m_transcoder = new VideoTranscoder(this); + connect( + m_transcoder, + &VideoTranscoder::transcoded, + this, + [this](const QString& dst, bool ok) { + if (ok) applyPath(dst); + }); + + auto* gpuConfig = ZShell::config::ConfigSingleton::instance()->services(); + logSelectedBackend(); + QObject::connect( + gpuConfig, &ZShell::config::Services::gpuTypeChanged, this, [] { + logSelectedBackend(); + }); } WallpaperService::~WallpaperService() { @@ -260,11 +292,11 @@ WallpaperService::~WallpaperService() { if (m_captureTex) glDeleteTextures(1, &m_captureTex); if (m_captureFbo) glDeleteFramebuffers(1, &m_captureFbo); if (m_posterTex) glDeleteTextures(1, &m_posterTex); -#ifdef ZHAVE_VIDEO - if (m_player) { - m_player->stop(); - m_player->deleteTexture(); - m_player.reset(); +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + m_decoder->stop(); + m_decoder->deleteTexture(); + m_decoder.reset(); } #endif } @@ -303,6 +335,8 @@ void WallpaperService::setDbPath(const QString& path) { m_forceDecode = true; } + m_cache.setDbPath(path); + emit dbPathChanged(); wake(); } @@ -310,8 +344,8 @@ void WallpaperService::setDbPath(const QString& path) { void WallpaperService::setPath(const QString& path) { if (path.isEmpty() || path == this->path()) return; - if (isVideoPath(path) && needsTranscode(path)) { - const QString dst = transcodeTarget(path); + if (isVideoPath(path) && m_transcoder->needsTranscode(path)) { + const QString dst = m_transcoder->transcodeTarget(path); if (QFile::exists(dst)) { applyPath(dst); @@ -319,7 +353,7 @@ void WallpaperService::setPath(const QString& path) { } applyPath(path); - transcodeVideo(path, dst); + m_transcoder->start(path, dst); return; } @@ -342,8 +376,8 @@ void WallpaperService::applyPath(const QString& path) { } } -#ifdef ZHAVE_VIDEO - const bool videoBackend = m_mpv != nullptr || m_player != nullptr; +#ifdef ZHAVE_FFMPEG + const bool videoBackend = m_mpv != nullptr || m_decoder != nullptr; #else const bool videoBackend = m_mpv != nullptr; #endif @@ -357,19 +391,19 @@ void WallpaperService::applyPath(const QString& path) { m_baseIsVideo = true; m_baseIsBlack = false; m_baseImage = QImage(); - m_baseCrops = cropsForPath(previousPath); + m_baseCrops = m_cache.cropsForPath(previousPath); } else if (wasImage) { m_needCaptureBase = true; m_baseIsVideo = false; m_baseIsBlack = false; m_baseImage = m_image; - m_baseCrops = cropsForPath(previousPath); + m_baseCrops = m_cache.cropsForPath(previousPath); } else { m_needCaptureBase = false; m_baseIsVideo = false; m_baseIsBlack = m_baseTex == 0; m_baseImage = QImage(); - m_baseCrops = cropsForPath(previousPath); + m_baseCrops = m_cache.cropsForPath(previousPath); } } @@ -383,7 +417,7 @@ void WallpaperService::applyPath(const QString& path) { TransitionLayer& newest = m_transitions.back(); newest.tex = m_texture; newest.image = m_image; - newest.crops = cropsForPath(previousPath); + newest.crops = m_cache.cropsForPath(previousPath); newest.ready = true; m_texture = 0; m_image = QImage(); @@ -393,7 +427,7 @@ void WallpaperService::applyPath(const QString& path) { m_transitions.back().isVideo) m_collapseVideo = true; - m_crops = cropsForPath(path); + m_crops = m_cache.cropsForPath(path); m_isVideo = video; @@ -424,153 +458,6 @@ void WallpaperService::applyPath(const QString& path) { queueAll(); } -bool WallpaperService::isHardwareCodec(const QString& codec) { - static const QStringList hardware{ - "av1", - "hevc", - "h264", - "vp9", - "vp8", - "vc1", - "mpeg2", - "mpeg2video", - "mpeg4", - "mjpeg", - }; - - return hardware.contains(codec); -} - -QString WallpaperService::codecName(const QString& path) const { - QProcess probe; - probe.start( - "ffprobe", - { - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=codec_name", - "-of", - "default=nw=1:nk=1", - path, - }); - - if (!probe.waitForFinished(2000) || probe.exitCode() != 0) { - qWarning(wpLog) << "WallpaperService: ffprobe failed for" << path - << "(exit" << probe.exitCode() << ")"; - return {}; - } - - const QString codec = - QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower(); - return codec; -} - -QString WallpaperService::cacheDir() const { - return QDir::homePath() + "/.cache/zshell/wallpaper-videos"; -} - -QString WallpaperService::transcodeTarget(const QString& path) const { - const QFileInfo info(path); - - const QByteArray key = - path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' + - QByteArray::number(info.lastModified().toMSecsSinceEpoch()); - - const QString hash = QString::fromUtf8( - QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex()); - - return cacheDir() + '/' + hash + ".av1.mkv"; -} - -bool WallpaperService::needsTranscode(const QString& path) const { - if (path.startsWith(cacheDir())) return false; - - const QString codec = codecName(path); - if (codec.isEmpty()) return false; - - return !isHardwareCodec(codec); -} - -void WallpaperService::transcodeVideo(const QString& src, const QString& dst) { - QDir().mkpath(cacheDir()); - - if (m_transcode) { - m_transcode->kill(); - m_transcode->deleteLater(); - m_transcode = nullptr; - } - - m_transcode = new QProcess(this); - auto* proc = m_transcode; - - connect(proc, &QProcess::readyReadStandardError, proc, [proc]() { - qWarning(wpLog).noquote() - << "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError()); - }); - - connect( - proc, - &QProcess::finished, - this, - [this, proc, dst](int code, QProcess::ExitStatus status) { - const bool ok = code == 0 && status == QProcess::NormalExit && - QFile::exists(dst); - - qWarning(wpLog) << "WallpaperService: transcode finished, exit" - << code << "status" << int(status) << "exists" - << QFile::exists(dst) << "->" << dst; - - if (proc == m_transcode) m_transcode = nullptr; - proc->deleteLater(); - - if (ok) applyPath(dst); - }); - - qWarning(wpLog) << "WallpaperService: transcoding" << src << "->" << dst; - - proc->setProgram("nice"); - proc->setArguments({ - "-n", - "19", - "ffmpeg", - "-hide_banner", - "-loglevel", - "error", - "-y", - "-threads", - "2", - "-i", - src, - "-map", - "0:v:0", - "-vf", - "scale=in_range=pc:out_range=tv:out_color_matrix=bt709", - "-colorspace", - "bt709", - "-color_primaries", - "bt709", - "-color_trc", - "bt709", - "-color_range", - "tv", - "-c:v", - "libsvtav1", - "-preset", - "6", - "-crf", - "20", - "-pix_fmt", - "yuv420p10le", - "-an", - dst, - }); - - proc->start(); -} - void WallpaperService::addScreen(const QString& screen) { { std::lock_guard lock(m_mutex); @@ -650,14 +537,14 @@ void WallpaperService::startVideo(const QString& path) { m_videoReady.store(false, std::memory_order_relaxed); m_videoLoaded.store(false, std::memory_order_relaxed); -#ifdef ZHAVE_VIDEO - if (m_usePlayer && !kMpvMode) { - if (startPlayerVideo(path)) return; +#ifdef ZHAVE_FFMPEG + if (m_useDecoder && !kMpvMode) { + if (startDecoderVideo(path)) return; qWarning(wpLog) << "WallpaperService: the GPU video player failed, " "falling back to mpv"; - m_usePlayer = false; - m_player.reset(); + m_useDecoder = false; + m_decoder.reset(); } #endif @@ -975,6 +862,16 @@ void WallpaperService::present() { if (m_context == EGL_NO_CONTEXT) return; + if (!m_orphanTextures.empty()) { + std::lock_guard lock(m_mutex); + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + for (uint32_t tex : m_orphanTextures) + glDeleteTextures(1, &tex); + m_orphanTextures.clear(); + } + } + EGLSurface importTarget = EGL_NO_SURFACE; for (auto* surface : surfaces) if (surface->eglSurface() != EGL_NO_SURFACE) { @@ -991,16 +888,63 @@ void WallpaperService::present() { return; } + if (!m_programsCompiled) { + m_programsCompiled = true; + std::lock_guard lock(m_mutex); + if (m_program == 0) m_program = createProgram(); + if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); + if (m_neutralProgram.id == 0) + m_neutralProgram = + programFor(QStringLiteral("wallpaper_trans_fade.frag")); + for (const QString& file : transitionFilesList()) + (void)programFor(file); + } + if (videoReload) { std::lock_guard lock(m_mutex); m_videoReload = false; } - if (!m_orphanTextures.empty()) { + { std::lock_guard lock(m_mutex); + for (uint32_t tex : m_orphanTextures) glDeleteTextures(1, &tex); m_orphanTextures.clear(); + + const bool idle = m_transitions.empty() && !m_needCaptureBase && + !m_collapseVideo && !m_isVideo && m_mpv == nullptr +#ifdef ZHAVE_FFMPEG + && m_decoder == nullptr +#endif + ; + if (idle) { + if (m_baseTex) glDeleteTextures(1, &m_baseTex); + m_baseTex = 0; + m_baseSize = QSize(); + if (m_baseFbo) glDeleteFramebuffers(1, &m_baseFbo); + m_baseFbo = 0; + m_baseReady = false; + + if (m_accumA) glDeleteTextures(1, &m_accumA); + if (m_accumB) glDeleteTextures(1, &m_accumB); + m_accumA = 0; + m_accumB = 0; + if (m_accumFbo) glDeleteFramebuffers(1, &m_accumFbo); + m_accumFbo = 0; + m_accumSize = QSize(); + + if (m_captureTex) glDeleteTextures(1, &m_captureTex); + m_captureTex = 0; + if (m_captureFbo) glDeleteFramebuffers(1, &m_captureFbo); + m_captureFbo = 0; + m_captureSize = QSize(); + + if (m_posterTex) glDeleteTextures(1, &m_posterTex); + m_posterTex = 0; + m_posterReady = false; + m_posterChanged = false; + } } const bool holdVideoForBase = (m_needCaptureBase && m_baseIsVideo) || @@ -1016,19 +960,19 @@ void WallpaperService::present() { } else if (m_mpv != nullptr) { teardownMpv(); } -#ifdef ZHAVE_VIDEO - else if (m_player != nullptr) { - m_player->deleteTexture(); - stopPlayerVideo(); +#ifdef ZHAVE_FFMPEG + else if (m_decoder != nullptr) { + m_decoder->deleteTexture(); + stopDecoderVideo(); } #endif }; if (!holdVideoForBase) applyVideoBackendSwitch(); -#ifdef ZHAVE_VIDEO +#ifdef ZHAVE_FFMPEG const bool videoActive = - (m_mpv != nullptr || m_player != nullptr) && + (m_mpv != nullptr || m_decoder != nullptr) && (m_isVideo || m_baseIsVideo || m_collapseVideo || m_promoteOldest); #else const bool videoActive = @@ -1038,8 +982,8 @@ void WallpaperService::present() { if (videoActive && m_mpv != nullptr) pumpMpvEvents(); -#ifdef ZHAVE_VIDEO - if (videoActive && m_player != nullptr) m_player->importCurrentFrame(); +#ifdef ZHAVE_FFMPEG + if (videoActive && m_decoder != nullptr) m_decoder->importCurrentFrame(); #endif for (auto* surface : surfaces) @@ -1424,7 +1368,7 @@ void WallpaperService::ensureCapture(const QSize& size) { glBindFramebuffer(GL_FRAMEBUFFER, 0); } -#ifdef ZHAVE_VIDEO +#ifdef ZHAVE_FFMPEG uint32_t WallpaperService::createVideoProgram() { const QByteArray vsSrc = shaderSource("wallpaper.vert", false); const QByteArray fsSrc = shaderSource("wallpaper_video.frag", true); @@ -1518,30 +1462,32 @@ void WallpaperService::drawVideoTexture( glDrawArrays(GL_TRIANGLES, 0, 3); } -bool WallpaperService::startPlayerVideo(const QString& path) { - if (!m_player) m_player = std::make_unique(m_eglDisplay); +bool WallpaperService::startDecoderVideo(const QString& path) { + if (!m_decoder) + m_decoder = createVideoDecoder(m_eglDisplay, backendFromConfig()); + if (!m_decoder) return false; - m_player->setReadyCallback([this]() { + m_decoder->setReadyCallback([this]() { m_videoLoaded.store(true, std::memory_order_relaxed); m_videoReady.store(true, std::memory_order_relaxed); queueAll(); }); - m_player->setFrameCallback([this]() { + m_decoder->setFrameCallback([this]() { m_videoReady.store(true, std::memory_order_relaxed); queueAll(); }); - if (!m_player->start(path)) return false; + if (!m_decoder->start(path)) return false; return true; } -void WallpaperService::stopPlayerVideo() { - if (!m_player) return; +void WallpaperService::stopDecoderVideo() { + if (!m_decoder) return; - m_player->stop(); - m_player.reset(); + m_decoder->stop(); + m_decoder.reset(); m_videoReady.store(false, std::memory_order_relaxed); m_videoLoaded.store(false, std::memory_order_relaxed); @@ -1556,19 +1502,19 @@ uint32_t WallpaperService::renderVideoIntoTexture(const QSize& size) { glViewport(0, 0, size.width(), size.height()); glDisable(GL_BLEND); -#ifdef ZHAVE_VIDEO - if (m_player) { - if (m_player->textureId() != 0) { +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); if (m_videoProgram != 0) { glUseProgram(m_videoProgram); - const QSize src = m_player->frameSize(); + const QSize src = m_decoder->frameSize(); drawVideoTexture( - m_player->textureId(), - m_player->chromaTextureId(), - m_player->colorMatrix(), - m_player->colorRange(), + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), QRectF(0.0, 0.0, 1.0, 1.0), float(size.width()), float(size.height()), @@ -1659,193 +1605,6 @@ void WallpaperService::drawTexture( glDrawArrays(GL_TRIANGLES, 0, 3); } -QByteArray WallpaperService::cachedBlob(const QString& path) const { - if (path.isEmpty()) return {}; - - QString databasePath; - { - std::lock_guard lock(m_mutex); - databasePath = m_dbPath; - } - - if (databasePath.isEmpty()) return {}; - - const QFileInfo info(path); - - const QString connection = QStringLiteral("wallpaper-cache-%1") - .arg(quintptr(QThread::currentThreadId())); - - QSqlDatabase database = - QSqlDatabase::contains(connection) - ? QSqlDatabase::database(connection, false) - : QSqlDatabase::addDatabase("QSQLITE", connection); - - if (database.databaseName() != databasePath) { - if (database.isOpen()) database.close(); - database.setDatabaseName(databasePath); - } - - if (!database.isOpen() && !database.open()) return {}; - - QSqlQuery query(database); - query.prepare("SELECT cache, size, mtime FROM wallpapers WHERE path = ?"); - query.addBindValue(info.absoluteFilePath()); - if (!query.exec() || !query.next()) return {}; - - const QByteArray blob = query.value(0).toByteArray(); - if (blob.isEmpty()) return {}; - - const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; - if (query.value(1).toLongLong() != info.size() || - query.value(2).toLongLong() != mtime) { - return {}; - } - - return blob; -} - -QByteArray WallpaperService::posterBlob(const QString& path) const { - if (path.isEmpty()) return {}; - - QString databasePath; - { - std::lock_guard lock(m_mutex); - databasePath = m_dbPath; - } - - if (databasePath.isEmpty()) { - qWarning(wpLog) << "WallpaperService: poster lookup, no database path"; - return {}; - } - - const QFileInfo info(path); - - const QString connection = QStringLiteral("wallpaper-cache-%1") - .arg(quintptr(QThread::currentThreadId())); - - QSqlDatabase database = - QSqlDatabase::contains(connection) - ? QSqlDatabase::database(connection, false) - : QSqlDatabase::addDatabase("QSQLITE", connection); - - if (database.databaseName() != databasePath) { - if (database.isOpen()) database.close(); - database.setDatabaseName(databasePath); - } - - if (!database.isOpen() && !database.open()) { - qWarning(wpLog) - << "WallpaperService: poster lookup, database could not open"; - return {}; - } - - QSqlQuery query(database); - query.prepare("SELECT poster, size, mtime FROM wallpapers WHERE path = ?"); - query.addBindValue(info.absoluteFilePath()); - if (!query.exec() || !query.next()) { - qWarning(wpLog) << "WallpaperService: poster lookup, no row for" << path - << query.lastError().text(); - return {}; - } - - const QByteArray blob = query.value(0).toByteArray(); - if (blob.isEmpty()) { - qWarning(wpLog) << "WallpaperService: poster column empty for" << path; - return {}; - } - - const qint64 mtime = info.lastModified().toMSecsSinceEpoch() / 1000; - if (query.value(1).toLongLong() != info.size() || - query.value(2).toLongLong() != mtime) { - qWarning(wpLog) << "WallpaperService: poster stale for" << path; - return {}; - } - - return blob; -} - -QVariantMap WallpaperService::cropsForPath(const QString& path) const { - if (path.isEmpty()) return {}; - - QString databasePath; - { - std::lock_guard lock(m_mutex); - databasePath = m_dbPath; - } - - if (databasePath.isEmpty()) return {}; - - const QFileInfo info(path); - - const QString connection = QStringLiteral("wallpaper-cache-%1") - .arg(quintptr(QThread::currentThreadId())); - - QSqlDatabase database = - QSqlDatabase::contains(connection) - ? QSqlDatabase::database(connection, false) - : QSqlDatabase::addDatabase("QSQLITE", connection); - - if (database.databaseName() != databasePath) { - if (database.isOpen()) database.close(); - database.setDatabaseName(databasePath); - } - - if (!database.isOpen() && !database.open()) return {}; - - QSqlQuery query(database); - query.prepare("SELECT crop FROM wallpapers WHERE path = ?"); - query.addBindValue(info.absoluteFilePath()); - if (!query.exec() || !query.next()) return {}; - - const QByteArray json = query.value(0).toByteArray(); - if (json.isEmpty()) return {}; - - const auto doc = QJsonDocument::fromJson(json); - if (!doc.isObject()) return {}; - - return doc.object().toVariantMap(); -} - -QImage WallpaperService::decodeImage( - const QString& path, const QSize& decodeSize) const { - if (path.isEmpty()) return {}; - - QImageReader probe(path); - const QSize source = probe.size(); - QSize required = source.isEmpty() ? decodeSize : source; - if (!source.isEmpty() && !decodeSize.isEmpty()) { - const QSize cover = - source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding); - if (cover.width() <= source.width() && - cover.height() <= source.height()) - required = cover; - } - - const QByteArray blob = cachedBlob(path); - if (!blob.isEmpty()) { - QImage image = QImage::fromData(blob, "JPEG"); - if (!image.isNull() && image.width() >= required.width() && - image.height() >= required.height()) { - return image.convertToFormat(QImage::Format_RGBA8888); - } - } - - QImageReader reader(path); - reader.setAutoTransform(true); - - if (!decodeSize.isEmpty() && !source.isEmpty()) - reader.setScaledSize( - source.scaled(decodeSize, Qt::KeepAspectRatioByExpanding)); - - QImage image = reader.read(); - if (image.isNull()) { - qWarning(wpLog) << "WallpaperService: could not read the image" << path; - return {}; - } - - return image.convertToFormat(QImage::Format_RGBA8888); -} - void WallpaperService::ensureImage() { QSize largest; QString path; @@ -1891,7 +1650,7 @@ void WallpaperService::ensureImage() { if (!needDecode) return; QThreadPool::globalInstance()->start([this, path, largest]() { - const QImage image = decodeImage(path, largest); + const QImage image = m_cache.decodeImage(path, largest); std::lock_guard lock(m_mutex); m_stagedImage = image; @@ -1923,7 +1682,7 @@ void WallpaperService::ensurePoster() { } QThreadPool::globalInstance()->start([this, path]() { - const QByteArray blob = posterBlob(path); + const QByteArray blob = m_cache.posterBlob(path); QImage image; if (!blob.isEmpty()) { @@ -2074,9 +1833,9 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return; -#ifdef ZHAVE_VIDEO +#ifdef ZHAVE_FFMPEG const bool videoAsBackground = - videoNow && (m_mpv != nullptr || m_player != nullptr); + videoNow && (m_mpv != nullptr || m_decoder != nullptr); #else const bool videoAsBackground = videoNow && m_mpv != nullptr; #endif @@ -2085,20 +1844,20 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) { glDisable(GL_BLEND); glViewport(0, 0, size.width(), size.height()); -#ifdef ZHAVE_VIDEO - if (m_player) { - if (m_player->textureId() != 0) { +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); if (m_videoProgram != 0) { glUseProgram(m_videoProgram); - const QSize src = m_player->frameSize(); + const QSize src = m_decoder->frameSize(); drawVideoTexture( - m_player->textureId(), - m_player->chromaTextureId(), - m_player->colorMatrix(), - m_player->colorRange(), + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), QRectF(0.0, 0.0, 1.0, 1.0), float(size.width()), float(size.height()), @@ -2506,7 +2265,7 @@ void WallpaperService::drawSurfaceTransition(WallpaperSurface* surface) { oldTex = videoTex; oldFlip = 0.0f; } else { - oldTex = m_texture; // still the previous image here + oldTex = m_texture; oldFlip = 1.0f; oldCrop = sourceRect(m_baseCrops, screen, size, m_baseImage.size()); @@ -2622,20 +2381,20 @@ void WallpaperService::drawSurfaceTransition(WallpaperSurface* surface) { } else { glDisable(GL_BLEND); -#ifdef ZHAVE_VIDEO - if (m_player) { - if (m_player->textureId() != 0) { +#ifdef ZHAVE_FFMPEG + if (m_decoder) { + if (m_decoder->textureId() != 0) { if (m_videoProgram == 0) m_videoProgram = createVideoProgram(); if (m_videoProgram != 0) { glUseProgram(m_videoProgram); - const QSize src = m_player->frameSize(); + const QSize src = m_decoder->frameSize(); drawVideoTexture( - m_player->textureId(), - m_player->chromaTextureId(), - m_player->colorMatrix(), - m_player->colorRange(), + m_decoder->textureId(), + m_decoder->chromaTextureId(), + m_decoder->colorMatrix(), + m_decoder->colorRange(), QRectF(0.0, 0.0, 1.0, 1.0), w, h, @@ -2753,4 +2512,4 @@ void WallpaperService::addSurface(const QString& screen) { notifyBufferSizes(); } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Wallpaper/wallpaperservice.hpp similarity index 75% rename from Plugins/ZShell/Internal/wallpaperservice.hpp rename to Plugins/ZShell/Wallpaper/wallpaperservice.hpp index dad41d8..60c8073 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.hpp +++ b/Plugins/ZShell/Wallpaper/wallpaperservice.hpp @@ -22,9 +22,9 @@ #include #include -#ifdef ZHAVE_VIDEO -#include "videoplayer.hpp" -#endif +#include "videodecoder.hpp" +#include "wallpapercache.hpp" +#include "videotranscoder.hpp" #include #include @@ -37,7 +37,7 @@ struct mpv_render_context; class QProcess; -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperSurface; @@ -87,14 +87,6 @@ class WallpaperService : public QObject { static bool isVideoPath(const QString& path); - static bool isHardwareCodec(const QString& codec); - - [[nodiscard]] QString codecName(const QString& path) const; - [[nodiscard]] QString cacheDir() const; - [[nodiscard]] QString transcodeTarget(const QString& path) const; - [[nodiscard]] bool needsTranscode(const QString& path) const; - void transcodeVideo(const QString& src, const QString& dst); - void applyPath(const QString& path); void startVideo(const QString& path); @@ -107,9 +99,9 @@ class WallpaperService : public QObject { static void* getMpvProcAddress(void* ctx, const char* name); static void mpvUpdate(void* ctx); -#ifdef ZHAVE_VIDEO - bool startPlayerVideo(const QString& path); - void stopPlayerVideo(); +#ifdef ZHAVE_FFMPEG + bool startDecoderVideo(const QString& path); + void stopDecoderVideo(); [[nodiscard]] uint32_t createVideoProgram(); void drawVideoTexture( uint32_t lumaTex, @@ -160,13 +152,13 @@ class WallpaperService : public QObject { void drawSurfaceTransition(WallpaperSurface* surface); struct TransitionLayer { - uint32_t tex = 0; // frozen image target (older layers) - QImage image; // CPU copy for crops/sizing (image layers) - QVariantMap crops; // crop map captured when the layer was pushed - QString transitionFile; // this layer's own transition shader - bool isVideo = false; // only the newest layer may be video - bool ready = false; // target is sampleable - bool started = false; // its own clock has begun + uint32_t tex = 0; + QImage image; + QVariantMap crops; + QString transitionFile; + bool isVideo = false; + bool ready = false; + bool started = false; QElapsedTimer timer; qreal progress = 0.0; }; @@ -216,15 +208,6 @@ class WallpaperService : public QObject { float sizeH, float alpha); - [[nodiscard]] QImage decodeImage( - const QString& path, const QSize& decodeSize) const; - - [[nodiscard]] QByteArray cachedBlob(const QString& path) const; - - [[nodiscard]] QByteArray posterBlob(const QString& path) const; - - [[nodiscard]] QVariantMap cropsForPath(const QString& path) const; - void ensureImage(); void ensurePoster(); @@ -260,6 +243,8 @@ class WallpaperService : public QObject { QStringList m_screens; QVariantMap m_crops; + WallpaperCache m_cache; + QImage m_image; bool m_isVideo = false; @@ -271,7 +256,7 @@ class WallpaperService : public QObject { std::atomic m_videoReady{false}; std::atomic m_videoLoaded{false}; - QProcess* m_transcode = nullptr; + VideoTranscoder* m_transcoder = nullptr; QSize m_decodeSize; @@ -299,16 +284,13 @@ class WallpaperService : public QObject { int m_alphaUniform = -1; int m_sourceUniform = -1; - bool m_randomTransition = false; // wallTransition == Random - QString m_transitionFile; // the transition stamped onto new layers + bool m_randomTransition = false; + QString m_transitionFile; bool m_transitionDirty = false; - QHash - m_transPrograms; // compiled programs, keyed by shader - TransProgram - m_neutralProgram; // fade program for identity copies (seed/base/final) - QVector - m_transitionUses; // usage counts, parallel to the transition list - QString m_lastTransition; // last random pick, so it is never repeated + QHash m_transPrograms; + TransProgram m_neutralProgram; + QVector m_transitionUses; + QString m_lastTransition; uint32_t m_captureFbo = 0; uint32_t m_captureTex = 0; @@ -324,17 +306,16 @@ class WallpaperService : public QObject { uint32_t m_accumB = 0; QSize m_accumSize; - std::vector m_transitions; // oldest .. newest + std::vector m_transitions; bool m_needCaptureBase = false; - QImage m_baseImage; // old settled image, for the base capture crop - QVariantMap m_baseCrops; // old settled crop map, for the base capture crop - bool m_baseIsVideo = false; // old settled frame was a live video - bool m_baseIsBlack = false; // shell start / first load: fade in from black - bool m_collapseVideo = - false; // video pushed mid-stack: bake older into base - bool m_promoteOldest = false; // stack over the cap: promote oldest to base - bool m_newestDone = false; // newest layer finished: settle into base + QImage m_baseImage; + QVariantMap m_baseCrops; + bool m_baseIsVideo = false; + bool m_baseIsBlack = false; + bool m_collapseVideo = false; + bool m_promoteOldest = false; + bool m_newestDone = false; static constexpr int kMaxTransitions = 4; @@ -350,13 +331,15 @@ class WallpaperService : public QObject { int m_videoRangeUniform = -1; int m_videoChromaOffUniform = -1; -#ifdef ZHAVE_VIDEO - std::unique_ptr m_player; - bool m_usePlayer = true; +#ifdef ZHAVE_FFMPEG + std::unique_ptr m_decoder; + bool m_useDecoder = true; #endif std::vector m_orphanTextures; + bool m_programsCompiled = false; + QList m_surfaces; std::thread m_thread; @@ -381,4 +364,4 @@ class WallpaperService : public QObject { bool m_posterChanged = false; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Wallpaper/wallpapersurface.cpp similarity index 98% rename from Plugins/ZShell/Internal/wallpapersurface.cpp rename to Plugins/ZShell/Wallpaper/wallpapersurface.cpp index 1fb7834..551b610 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.cpp +++ b/Plugins/ZShell/Wallpaper/wallpapersurface.cpp @@ -6,7 +6,7 @@ #include "wallpaperservice.hpp" -namespace ZShell::internal { +namespace ZShell::wallpaper { const QString kNamespace = "ZShell-Wallpaper"; @@ -188,4 +188,4 @@ void WallpaperSurface::updateBuffer() { m_service->notifyBufferSizes(); } -} // namespace ZShell::internal +} // namespace ZShell::wallpaper diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Wallpaper/wallpapersurface.hpp similarity index 96% rename from Plugins/ZShell/Internal/wallpapersurface.hpp rename to Plugins/ZShell/Wallpaper/wallpapersurface.hpp index bada4ee..2100023 100644 --- a/Plugins/ZShell/Internal/wallpapersurface.hpp +++ b/Plugins/ZShell/Wallpaper/wallpapersurface.hpp @@ -13,7 +13,7 @@ #include #include -namespace ZShell::internal { +namespace ZShell::wallpaper { class WallpaperService; @@ -70,4 +70,4 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, struct wl_callback* m_frameCallback = nullptr; }; -} // namespace ZShell::internal +} // namespace ZShell::wallpaper -- 2.47.3 From 4252812e824f568f96298859c608d8d2528c1722 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 27 Oct 2026 02:30:55 +0100 Subject: [PATCH 09/15] fix: don't link eglDestroyImageKHR --- Plugins/ZShell/Wallpaper/vaapiplayer.cpp | 68 ++++++++++++++++++------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/Plugins/ZShell/Wallpaper/vaapiplayer.cpp b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp index bd6ea2c..f4e6b4d 100644 --- a/Plugins/ZShell/Wallpaper/vaapiplayer.cpp +++ b/Plugins/ZShell/Wallpaper/vaapiplayer.cpp @@ -1,4 +1,3 @@ -#define EGL_EGLEXT_PROTOTYPES #include "vaapiplayer.hpp" #ifdef ZHAVE_FFMPEG @@ -50,6 +49,42 @@ GLEGLImageTargetProc glEGLImageTarget() { return fn; } +// eglCreateImageKHR/eglDestroyImageKHR are EGL extension entry points. Fetch +// them through eglGetProcAddress rather than linking them directly, so the +// shared library does not carry a hard undefined reference to symbols libEGL +// may not export on every platform. +using PfnCreateImageKHR = EGLImageKHR (*)( + EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*); +using PfnDestroyImageKHR = EGLBoolean (*)(EGLDisplay, EGLImageKHR); + +PfnCreateImageKHR eglCreateImageProc() { + static PfnCreateImageKHR fn = reinterpret_cast( + eglGetProcAddress("eglCreateImageKHR")); + return fn; +} + +PfnDestroyImageKHR eglDestroyImageProc() { + static PfnDestroyImageKHR fn = reinterpret_cast( + eglGetProcAddress("eglDestroyImageKHR")); + return fn; +} + +void destroyEglImage(EGLDisplay dpy, EGLImageKHR image) { + if (image == EGL_NO_IMAGE_KHR) return; + PfnDestroyImageKHR fn = eglDestroyImageProc(); + if (!fn) { + static bool warned = false; + if (!warned) { + warned = true; + qCWarning( + vaapiLog, + "VideoPlayer: eglDestroyImageKHR unavailable, EGL images leak"); + } + return; + } + fn(dpy, image); +} + EGLImageKHR createDmaImage( EGLDisplay dpy, int fd, @@ -57,6 +92,9 @@ EGLImageKHR createDmaImage( uint32_t pitch, uint32_t fourcc, uint64_t modifier) { + PfnCreateImageKHR fn = eglCreateImageProc(); + if (!fn) return EGL_NO_IMAGE_KHR; + EGLint attrs[16]; int n = 0; attrs[n++] = EGL_LINUX_DRM_FOURCC_EXT; @@ -75,8 +113,8 @@ EGLImageKHR createDmaImage( } attrs[n] = EGL_NONE; - EGLImageKHR image = eglCreateImageKHR( - dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs); + EGLImageKHR image = + fn(dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs); if (image == EGL_NO_IMAGE_KHR) return EGL_NO_IMAGE_KHR; return image; } @@ -105,11 +143,11 @@ VaapiPlayer::~VaapiPlayer() { #ifdef ZHAVE_VAAPI if (m_imageY != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageY); + destroyEglImage(m_eglDisplay, m_imageY); m_imageY = EGL_NO_IMAGE_KHR; } if (m_imageUV != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageUV); + destroyEglImage(m_eglDisplay, m_imageUV); m_imageUV = EGL_NO_IMAGE_KHR; } #endif @@ -339,11 +377,11 @@ void VaapiPlayer::stop() { void VaapiPlayer::deleteTexture() { #ifdef ZHAVE_VAAPI if (m_imageY != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageY); + destroyEglImage(m_eglDisplay, m_imageY); m_imageY = EGL_NO_IMAGE_KHR; } if (m_imageUV != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageUV); + destroyEglImage(m_eglDisplay, m_imageUV); m_imageUV = EGL_NO_IMAGE_KHR; } #endif @@ -569,11 +607,11 @@ bool VaapiPlayer::importEglImages(AVFrame* frame) { const QSize sz = QSize(int(frame->width), int(frame->height)); if (m_texSize != sz || m_texY == 0) { if (m_imageY != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageY); + destroyEglImage(m_eglDisplay, m_imageY); m_imageY = EGL_NO_IMAGE_KHR; } if (m_imageUV != EGL_NO_IMAGE_KHR) { - eglDestroyImageKHR(m_eglDisplay, m_imageUV); + destroyEglImage(m_eglDisplay, m_imageUV); m_imageUV = EGL_NO_IMAGE_KHR; } if (m_texY == 0) glGenTextures(1, &m_texY); @@ -595,10 +633,8 @@ bool VaapiPlayer::importEglImages(AVFrame* frame) { EGLImageKHR imageUV = bindLayer(1, m_texUV); if (imageY == EGL_NO_IMAGE_KHR || imageUV == EGL_NO_IMAGE_KHR) { - if (imageY != EGL_NO_IMAGE_KHR) - eglDestroyImageKHR(m_eglDisplay, imageY); - if (imageUV != EGL_NO_IMAGE_KHR) - eglDestroyImageKHR(m_eglDisplay, imageUV); + destroyEglImage(m_eglDisplay, imageY); + destroyEglImage(m_eglDisplay, imageUV); for (uint32_t i = 0; i < desc.num_objects; ++i) if (desc.objects[i].fd >= 0) close(desc.objects[i].fd); static int logged = 0; @@ -608,10 +644,8 @@ bool VaapiPlayer::importEglImages(AVFrame* frame) { return false; } - if (m_imageY != EGL_NO_IMAGE_KHR) - eglDestroyImageKHR(m_eglDisplay, m_imageY); - if (m_imageUV != EGL_NO_IMAGE_KHR) - eglDestroyImageKHR(m_eglDisplay, m_imageUV); + if (m_imageY != EGL_NO_IMAGE_KHR) destroyEglImage(m_eglDisplay, m_imageY); + if (m_imageUV != EGL_NO_IMAGE_KHR) destroyEglImage(m_eglDisplay, m_imageUV); m_imageY = imageY; m_imageUV = imageUV; -- 2.47.3 From ed7e977eb5635b20cd8b8b8f7c5b15b4c9d4a8ff Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 27 Oct 2026 15:15:57 +0100 Subject: [PATCH 10/15] fix: remove more uses of cliprect and finally fix workspace's layer texture --- Components/ClipRect.qml | 5 +- Modules/Bar/Bar.qml | 3 +- Modules/Bar/Components/MediaWidget.qml | 4 +- Modules/Bar/Components/Resource.qml | 20 +++---- Modules/Bar/Components/Resources.qml | 5 +- Modules/Bar/Components/Workspaces.qml | 52 ++++++++--------- Modules/Clipboard/Content.qml | 6 +- Modules/Lock/Content.qml | 3 +- Modules/Lock/Media.qml | 57 ++++++++++--------- Modules/Lock/NotifDock.qml | 17 +----- Modules/Lock/Weather/BriefInfo.qml | 7 ++- Modules/Lock/Weather/Forecast.qml | 17 ++++-- .../Sidebar/Chat/Content/ApprovalPrompt.qml | 14 ++++- Modules/Sidebar/Chat/Content/ProcessBlock.qml | 14 ++++- 14 files changed, 119 insertions(+), 105 deletions(-) diff --git a/Components/ClipRect.qml b/Components/ClipRect.qml index 39ac012..48deed0 100644 --- a/Components/ClipRect.qml +++ b/Components/ClipRect.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import ZShell.Components Item { @@ -19,7 +20,7 @@ Item { } ShaderEffectSource { - id: source + id: src hideSource: true sourceItem: contentItem @@ -27,7 +28,7 @@ Item { RoundedMask { anchors.fill: parent - source: source + source: src topLeftRadius: root.topLeftRadius topRightRadius: root.topRightRadius bottomRightRadius: root.bottomRightRadius diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index e19aba4..ba6cf14 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -249,8 +249,7 @@ SnapLayout { roleValue: "network" delegate: EntryWrapper { - NetworkWidget { - } + NetworkWidget {} } } diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 47c6d24..cd472eb 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -14,7 +14,7 @@ WidgetBase { readonly property int textWidth: Math.min(metrics.width, 200) implicitHeight: horizontal ? baseSize : layout.implicitHeight + Tokens.padding.small * 2 - implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.small * 2 : baseSize + implicitWidth: horizontal ? (layout.implicitWidth % 2 === 0 ? layout.implicitWidth : layout.implicitWidth + 1) + Tokens.padding.small * 2 : baseSize radius: Tokens.rounding.full Behavior on implicitHeight { @@ -77,6 +77,8 @@ WidgetBase { anchors.fill: parent hoverEnabled: true + onClicked: console.log(root.mapToGlobal(0, 0), layout.implicitWidth) + onContainsMouseChanged: { if (!containsMouse) { mediatext.marqueeEnabled = false; diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index f39a03d..5892391 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -13,7 +13,6 @@ GridLayout { property real animatedPercentage: 0 readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI - readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 required property bool horizontal property string icon required property color iconColor @@ -24,17 +23,12 @@ GridLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 - function snap(v) { - return Math.round(v * dpr) / dpr; - } - columnSpacing: Tokens.spacing.medium columns: horizontal ? -1 : 1 percentage: 0 Behavior on animatedPercentage { - Anim { - } + Anim {} } Component.onCompleted: animatedPercentage = percentage @@ -60,7 +54,7 @@ GridLayout { Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4 Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - radius: Math.min(width, height) / 2 + radius: Tokens.rounding.full Item { id: clipper @@ -70,18 +64,20 @@ GridLayout { anchors.left: root.horizontal ? undefined : parent.left anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter clip: true - height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2 - width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width) + implicitHeight: root.horizontal ? root.percentage * track.height : track.height * 2 + implicitWidth: root.horizontal ? track.width * 2 : root.percentage * track.width CustomRect { anchors.bottom: root.horizontal ? parent.bottom : undefined anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined anchors.left: root.horizontal ? undefined : parent.left anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + color: root.mainColor - height: track.height radius: track.radius - width: track.width + + implicitHeight: track.height + implicitWidth: track.width } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index f8979e8..ab91abe 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -24,7 +24,8 @@ WidgetBase { StateLayer { color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - onClicked: root.screenState.resources = !root.screenState.resources + // onClicked: root.screenState.resources = !root.screenState.resources + onClicked: console.log(root.mapToGlobal(0, 0)) } GridLayout { @@ -34,6 +35,7 @@ WidgetBase { anchors.horizontalCenterOffset: root.horizontal ? 0 : 1 anchors.verticalCenterOffset: root.horizontal ? 0 : -3 columnSpacing: Tokens.spacing.small + rowSpacing: 0 columns: root.horizontal ? -1 : 1 ServiceRef { @@ -52,6 +54,7 @@ WidgetBase { Layout.alignment: Qt.AlignVCenter Layout.fillHeight: root.horizontal Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "memory" iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface diff --git a/Modules/Bar/Components/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml index 9c5d448..09cebc5 100644 --- a/Modules/Bar/Components/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -12,13 +12,14 @@ import qs.Services Item { id: root - property real activeWorkspaceMargin: Math.ceil(Tokens.padding.medium / 4) + property real activeWorkspaceMargin: Tokens.padding.extraSmall / 2 + readonly property int spacing: Tokens.spacing.extraSmall / 2 readonly property int effectiveActiveWorkspaceId: monitor?.activeWorkspace?.id ?? 1 required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen readonly property real shortSize: Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) - readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 + readonly property real size: (workspaceButtonWidth * workspacesShown) + (spacing * Math.max(0, workspacesShown - 1)) + activeWorkspaceMargin * 2 property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown readonly property list workspaces: Hyprland.workspaces.values.filter(w => w.monitor === root.monitor) @@ -59,15 +60,17 @@ Item { anchors.fill: parent anchors.margins: root.activeWorkspaceMargin - columnSpacing: 0 + columnSpacing: root.spacing columns: root.horizontal ? root.workspacesShown : 1 - rowSpacing: 0 + rowSpacing: root.spacing rows: root.horizontal ? 1 : root.workspacesShown + layer.enabled: true + layer.mipmap: true Repeater { model: root.workspaces - Button { + CustomRect { id: button required property int index @@ -75,27 +78,19 @@ Item { implicitHeight: indicator.indicatorThickness implicitWidth: indicator.indicatorThickness - width: root.workspaceButtonWidth + radius: Tokens.rounding.full - background: Item { - id: workspaceButtonBackground - - implicitHeight: root.workspaceButtonWidth - implicitWidth: root.workspaceButtonWidth - - CustomText { - anchors.centerIn: parent - color: Colors.palette.m3onSecondaryContainer - elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter - text: button.modelData?.name ?? "" - verticalAlignment: Text.AlignVCenter - } + CustomText { + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: button.modelData?.name ?? "" } - onPressed: { - const ws = button.modelData.name; - Hyprland.dispatch(Hyprland.usingLua ? `hl.dsp.focus({ workspace= "${ws}"})` : `workspace ${ws}`); + StateLayer { + onPressed: { + const ws = button.modelData.name; + Hyprland.dispatch(Hyprland.usingLua ? `hl.dsp.focus({ workspace= "${ws}"})` : `workspace ${ws}`); + } } } } @@ -104,8 +99,10 @@ Item { CustomRect { id: indicator - property real indicatorLength: (Math.abs(idxPair.idx1 - idxPair.idx2) + 1) * root.workspaceButtonWidth - property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * root.workspaceButtonWidth + root.activeWorkspaceMargin + readonly property real step: root.workspaceButtonWidth + root.spacing + + property real indicatorLength: (Math.abs(idxPair.idx1 - idxPair.idx2) + 1) * root.workspaceButtonWidth + Math.abs(idxPair.idx1 - idxPair.idx2) * root.spacing + property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * step + root.activeWorkspaceMargin property real indicatorThickness: root.workspaceButtonWidth anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter @@ -129,9 +126,10 @@ Item { implicitHeight: grid.height implicitWidth: grid.width source: grid + autoPaddingEnabled: false sourceColor: Colors.palette.m3onSurface - x: root.horizontal ? -indicator.x + 3 : 0 - y: root.horizontal ? 0 : -indicator.y + 3 + x: root.horizontal ? -indicator.x + root.activeWorkspaceMargin : 0 + y: root.horizontal ? 0 : -indicator.y + root.activeWorkspaceMargin } } } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index be9d497..b77b1cc 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -55,7 +55,7 @@ Item { } } - ClipRect { + CustomRect { id: preview anchors.bottom: parent.bottom @@ -65,6 +65,7 @@ Item { color: Colors.tPalette.m3surfaceContainer implicitWidth: ClipHistory.previewIsImage ? Math.max(Math.min(imagePreview.sourceSize.width + Tokens.padding.large * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) : Math.max(Math.min(textPreviewColumn.width + textPreviewColumn.anchors.margins * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) radius: 25 + clip: true Behavior on implicitWidth { Anim {} @@ -229,11 +230,12 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - ClipRect { + Item { id: textRect Layout.fillHeight: true Layout.fillWidth: true + clip: true Item { id: textWrapper diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index 7986939..a3fc7a6 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -6,14 +6,13 @@ import qs.Components import qs.Helpers import qs.Services -ClipRect { +Item { id: root required property var lock implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth - radius: Tokens.rounding.largeIncreased SnapLayout { id: layout diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index 4d8de5e..a6d3de7 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -7,39 +7,44 @@ import qs.Components import qs.Helpers import qs.Services -ClipRect { +Item { id: root required property var lock - color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - radius: Tokens.rounding.small - - FadeImage { - id: image + ClipRect { anchors.fill: parent - asynchronous: true - fillMode: Image.PreserveAspectCrop - layer.enabled: true - opacity: status === Image.Ready ? 1 : 0 - source: Players.getArtUrl(Players.active) - sourceSize: { - const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; - return Qt.size(width * dpr, height * dpr); - } - Behavior on opacity { - Anim { - type: Anim.StandardExtraLarge - } - } + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.small + bottomLeftRadius: Tokens.rounding.largeIncreased + + FadeImage { + id: image - CustomRect { anchors.fill: parent - color: Colors.palette.m3surface - opacity: 0.7 + asynchronous: true + fillMode: Image.PreserveAspectCrop + opacity: status === Image.Ready ? 1 : 0 + source: Players.getArtUrl(Players.active) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + + Behavior on opacity { + Anim { + type: Anim.StandardExtraLarge + } + } + + CustomRect { + anchors.fill: parent + color: Colors.palette.m3surface + opacity: 0.7 + } } } @@ -79,7 +84,7 @@ ClipRect { spacing: Tokens.spacing.extraSmall IconButton { - enabled: Players.active?.canGoPrevious + enabled: Players.active?.canGoPrevious ?? false icon: "skip_previous" isRound: true shapeMorph: true @@ -90,7 +95,7 @@ ClipRect { IconButton { checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying + enabled: Players.active?.canTogglePlaying ?? false icon: Players.active?.isPlaying ? "pause" : "play_arrow" implicitWidth: implicitHeight + Tokens.padding.largeIncreased * 2 isRound: true @@ -100,7 +105,7 @@ ClipRect { } IconButton { - enabled: Players.active?.canGoNext + enabled: Players.active?.canGoNext ?? false icon: "skip_next" isRound: true shapeMorph: true diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 0af1316..652a8dd 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -31,13 +31,11 @@ SnapLayout { text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") } - ClipRect { + Item { id: clipRect Layout.fillHeight: true Layout.fillWidth: true - color: "transparent" - radius: Tokens.rounding.small Loader { active: opacity > 0 @@ -53,19 +51,6 @@ SnapLayout { spacing: Tokens.spacing.largeIncreased orientation: Qt.Vertical - Image { - asynchronous: true - fillMode: Image.PreserveAspectFit - layer.enabled: true - source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) - sourceSize.width: clipRect.width * 0.8 - - layer.effect: Coloriser { - brightness: 1 - colorizationColor: Colors.palette.m3outlineVariant - } - } - CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index eaa4e10..1923fe9 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -18,7 +18,7 @@ orientation: Qt.Vertical Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.title.large + font: Tokens.font.body.large text: Weather.description } @@ -31,7 +31,7 @@ orientation: Qt.Vertical animate: true color: Colors.palette.m3primary - font: Tokens.font.body.large + font: Tokens.font.title.large text: Weather.temp } @@ -39,6 +39,7 @@ orientation: Qt.Vertical animate: true color: Colors.palette.m3secondary text: Weather.icon + fontStyle: Tokens.font.icon.large } } @@ -55,7 +56,7 @@ orientation: Qt.Vertical Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.normal + font: Tokens.font.body.medium text: { const today = Weather.forecast[0]; return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index c6476ab..54b26aa 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -37,7 +37,7 @@ CustomRect { } } - VerticalFadeListView { + CustomListView { id: layout anchors.bottom: parent.bottom @@ -46,7 +46,13 @@ CustomRect { anchors.margins: Tokens.padding.large anchors.right: parent.right implicitHeight: contentItem.childrenRect.height - model: Weather.hourlyForecast + currentIndex: Weather.currentHourIndex + // highlightFollowsCurrentItem: true + // preferredHighlightEnd: currentItem.implicitWidth + // preferredHighlightBegin: 0 + // highlightRangeMode: ListView.ApplyRange + clip: true + model: Weather.hourlyForecast.slice(Weather.currentHourIndex - 1) orientation: VerticalFadeListView.Horizontal spacing: Tokens.spacing.medium @@ -54,6 +60,7 @@ CustomRect { orientation: Qt.Vertical id: hour + readonly property bool current: ListView.view.currentItem === this readonly property var cond: modelData required property int index required property var modelData @@ -62,7 +69,7 @@ orientation: Qt.Vertical MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.current ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided @@ -74,7 +81,7 @@ orientation: Qt.Vertical id: temp anchors.centerIn: parent - color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: hour.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font: Tokens.font.body.medium text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } @@ -98,7 +105,7 @@ orientation: Qt.Vertical Layout.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant font: Tokens.font.body.small - text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") + text: hour.current ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") } } } diff --git a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml index abb6c36..3ebc60c 100644 --- a/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml +++ b/Modules/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -344,7 +344,7 @@ Item { } } - ClipRect { + Item { id: expandedTool anchors.top: collapsedRow.bottom @@ -355,8 +355,16 @@ Item { anchors.topMargin: Tokens.spacing.medium implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 - radius: Tokens.rounding.medium - color: Colors.palette.m3surfaceContainerLow + clip: true + + CustomRect { + id: bg + + anchors.fill: parent + + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + } CustomText { id: fullToolCall diff --git a/Modules/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Sidebar/Chat/Content/ProcessBlock.qml index 71ac860..3d58418 100644 --- a/Modules/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Sidebar/Chat/Content/ProcessBlock.qml @@ -125,7 +125,7 @@ Item { current: root.current } - ClipRect { + Item { id: expandedRect anchors.left: parent.left @@ -133,11 +133,19 @@ Item { anchors.top: layout.bottom anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small - color: Colors.palette.m3surfaceContainerLow implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 - radius: Tokens.rounding.medium visible: opacity > 0 + clip: true + + CustomRect { + id: bg + + anchors.fill: parent + + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + } Behavior on opacity { Anim {} -- 2.47.3 From 858e4e5b1217aaad7f050ba398b1f4d9475a6352 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 29 Oct 2026 17:46:27 +0100 Subject: [PATCH 11/15] rebase to feat/video-wallpapers --- Modules/Bar/Components/Clock.qml | 4 +- Modules/Bar/Components/Common/BatteryIcon.qml | 2 +- Modules/Bar/Components/MediaWidget.qml | 2 - Modules/Bar/Components/Resources.qml | 3 +- Modules/Bar/Components/StatusIcons.qml | 6 +- .../Components/StatusIcons/AudioWidget.qml | 4 +- .../Bar/Components/StatusIcons/MicWidget.qml | 4 +- Modules/Lock/Content.qml | 20 +- Modules/Lock/LockSurface.qml | 2 +- Modules/Lock/NotifDock.qml | 186 +++++++++--------- Modules/Lock/WeatherInfo.qml | 4 +- Modules/Sidebar/Chat/Content/ChatHost.qml | 1 - cli/src/zshell/subcommands/shell.py | 1 + 13 files changed, 116 insertions(+), 123 deletions(-) diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index c5557ed..64d009b 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -46,7 +46,9 @@ WidgetBase { } required property SnapLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 + readonly property int roundedWidth: Math.round(timeText.implicitWidth) + readonly property int evenWidth: roundedWidth % 2 === 0 ? roundedWidth : roundedWidth + 1 + readonly property real size: horizontal ? evenWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property ScreenState screenState implicitHeight: horizontal ? baseSize : size diff --git a/Modules/Bar/Components/Common/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml index 6a78dfa..01606b2 100644 --- a/Modules/Bar/Components/Common/BatteryIcon.qml +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -24,7 +24,7 @@ Row { color: Battery.getColors(root.percentage, root.devState).bg height: root.batHeight radius: root.radius - width: root.batWidth + width: root.batWidth - 1 CustomText { color: Battery.getColors(root.percentage, root.devState).fg diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index cd472eb..88b4ee1 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -77,8 +77,6 @@ WidgetBase { anchors.fill: parent hoverEnabled: true - onClicked: console.log(root.mapToGlobal(0, 0), layout.implicitWidth) - onContainsMouseChanged: { if (!containsMouse) { mediatext.marqueeEnabled = false; diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index ab91abe..28fa27f 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -24,8 +24,7 @@ WidgetBase { StateLayer { color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - // onClicked: root.screenState.resources = !root.screenState.resources - onClicked: console.log(root.mapToGlobal(0, 0)) + onClicked: root.screenState.resources = !root.screenState.resources } GridLayout { diff --git a/Modules/Bar/Components/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml index ac131ba..feb6db9 100644 --- a/Modules/Bar/Components/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -32,7 +32,7 @@ WidgetBase { return -1; } readonly property real size: horizontal ? grid.implicitWidth + grid.anchors.leftMargin + grid.anchors.rightMargin : grid.implicitHeight + grid.anchors.bottomMargin + grid.anchors.topMargin - readonly property int spacing: Tokens.spacing.medium / 2 + readonly property int spacing: Tokens.spacing.extraSmall // Entries that can shrink to nothing, spacing included function collapsed(entry: var): bool { @@ -134,8 +134,8 @@ WidgetBase { Layout.rightMargin: root.horizontal ? Math.round(rightGap) : 0 Layout.topMargin: root.horizontal ? 0 : Math.round(leftGap) children: item - implicitHeight: item?.implicitHeight ?? 0 - implicitWidth: item?.implicitWidth ?? 0 + implicitHeight: item?.height ?? item?.implicitHeight ?? 0 + implicitWidth: item?.width ?? item?.implicitWidth ?? 0 Behavior on leftGap { Anim { diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index 8235a14..b9aeeb9 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -7,7 +7,7 @@ import qs.Components import qs.Services MaterialIcon { - id: speaker + id: root required property bool horizontal @@ -16,6 +16,8 @@ MaterialIcon { fill: 1 fontStyle: Tokens.font.icon.small text: Audio.muted ? "volume_off" : "volume_up" + width: Math.round(implicitWidth) + height: Math.round(implicitHeight) Behavior on Layout.maximumWidth { Anim {} diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index 093f45e..a42dbc3 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -7,7 +7,7 @@ import qs.Components import qs.Services MaterialIcon { - id: mic + id: root required property bool horizontal @@ -16,6 +16,8 @@ MaterialIcon { fill: 1 fontStyle: Tokens.font.icon.small text: Audio.sourceMuted ? "mic_off" : "mic" + width: Math.round(implicitWidth) + height: Math.round(implicitHeight) Behavior on Layout.maximumWidth { Anim {} diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index a3fc7a6..013c889 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -53,24 +53,10 @@ Item { lock: root.lock } - SnapLayout { + NotifDock { + Layout.fillHeight: true Layout.fillWidth: true - - spacing: Tokens.spacing.medium - orientation: Qt.Vertical - - CustomRect { - Layout.fillHeight: true - Layout.fillWidth: true - bottomRightRadius: Tokens.rounding.largeIncreased - color: Colors.tPalette.m3surfaceContainer - radius: Tokens.rounding.small - topRightRadius: Tokens.rounding.largeIncreased - - NotifDock { - lock: root.lock - } - } + lock: root.lock } } } diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index 540e704..22369bc 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -221,9 +221,9 @@ WlSessionLockSurface { anchors.fill: parent color: Colors.palette.m3surface - layer.enabled: true opacity: Colors.transparency.enabled ? Colors.transparency.base : 1 radius: lockContent.radius + layer.enabled: true layer.effect: MultiEffect { blurMax: 15 diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 652a8dd..beb73ad 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -12,114 +12,118 @@ import qs.Helpers import qs.Daemons import qs.Services -SnapLayout { +CustomRect { id: root required property var lock - anchors.fill: parent - anchors.margins: Tokens.padding.large + bottomRightRadius: Tokens.rounding.largeIncreased + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.small + topRightRadius: Tokens.rounding.largeIncreased - orientation: Qt.Vertical - spacing: Tokens.spacing.small + SnapLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large - CustomText { - Layout.fillWidth: true - 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") - } + orientation: Qt.Vertical + spacing: Tokens.spacing.small - Item { - id: clipRect - - Layout.fillHeight: true - Layout.fillWidth: true - - Loader { - active: opacity > 0 - anchors.centerIn: parent - opacity: NotifServer.list.length > 0 ? 0 : 1 - - Behavior on opacity { - Anim { - duration: Tokens.anim.durations.extraLarge - } - } - sourceComponent: SnapLayout { - spacing: Tokens.spacing.largeIncreased - orientation: Qt.Vertical - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3outlineVariant - font: Tokens.font.headline.builders.small.width(90).build() - text: qsTr("No Notifications") - } - } + CustomText { + Layout.fillWidth: true + 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") } - CustomListView { - anchors.fill: parent - clip: true - spacing: Tokens.spacing.small + Item { + Layout.fillHeight: true + Layout.fillWidth: true - add: Transition { - Anim { - from: 0 - property: "opacity" - to: 1 + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: NotifServer.list.length > 0 ? 0 : 1 + + Behavior on opacity { + Anim { + duration: Tokens.anim.durations.extraLarge + } } + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.largeIncreased - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - from: 0 - property: "scale" - to: 1 + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3outlineVariant + font: Tokens.font.headline.builders.small.width(90).build() + text: qsTr("No Notifications") + } } } - delegate: NotifGroup {} - displaced: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - property: "y" - } - } - model: ScriptModel { - values: { - const list = NotifServer.notClosed.map(n => [n.appName, null]); - return [...new Map(list).keys()]; - } - } - move: Transition { - Anim { - properties: "opacity,scale" - to: 1 - } + CustomListView { + anchors.fill: parent + clip: true + spacing: Tokens.spacing.small - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - property: "y" - } - } - remove: Transition { - Anim { - property: "opacity" - to: 0 - } + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } - Anim { - property: "scale" - to: 0.6 + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + from: 0 + property: "scale" + to: 1 + } + } + delegate: NotifGroup {} + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + property: "y" + } + } + model: ScriptModel { + values: { + const list = NotifServer.notClosed.map(n => [n.appName, null]); + return [...new Map(list).keys()]; + } + } + move: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + property: "y" + } + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + + Anim { + property: "scale" + to: 0.6 + } } } } diff --git a/Modules/Lock/WeatherInfo.qml b/Modules/Lock/WeatherInfo.qml index 3de1b1f..feaec45 100644 --- a/Modules/Lock/WeatherInfo.qml +++ b/Modules/Lock/WeatherInfo.qml @@ -19,6 +19,7 @@ CustomRect { return base + brief.anchors.topMargin; } radius: Tokens.rounding.small + topLeftRadius: Tokens.rounding.largeIncreased Timer { interval: 900000 // 15 minutes @@ -48,7 +49,6 @@ CustomRect { anchors.right: parent.right asynchronous: true - sourceComponent: Forecast { - } + sourceComponent: Forecast {} } } diff --git a/Modules/Sidebar/Chat/Content/ChatHost.qml b/Modules/Sidebar/Chat/Content/ChatHost.qml index d97d7d4..8e3eb3f 100644 --- a/Modules/Sidebar/Chat/Content/ChatHost.qml +++ b/Modules/Sidebar/Chat/Content/ChatHost.qml @@ -59,7 +59,6 @@ Item { id: container anchors.fill: parent - layer.enabled: opacity < 1 objectName: "ConversationContainer" Component.onCompleted: { diff --git a/cli/src/zshell/subcommands/shell.py b/cli/src/zshell/subcommands/shell.py index f55311a..70ca638 100644 --- a/cli/src/zshell/subcommands/shell.py +++ b/cli/src/zshell/subcommands/shell.py @@ -14,6 +14,7 @@ qt_logging_rules = ( "qt.qpa.wayland.*=false;" "scene=false;" "quickshell.service.sni.watcher=false;" + "quickshell.dbus.*=false;" "default.warning=false;" "default.info=false;" "qt.sql.qsqldatabase" -- 2.47.3 From fe0ce991da2e9ca84dd8d74e1b48de3493a5e5c0 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 29 Oct 2026 17:34:57 +0100 Subject: [PATCH 12/15] feat: initial commit for pixel-snapping components and helpers --- Modules/Lock/Content.qml | 20 +++++++++++++++++--- Modules/Lock/NotifDock.qml | 4 +++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index 013c889..a3fc7a6 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -53,10 +53,24 @@ Item { lock: root.lock } - NotifDock { - Layout.fillHeight: true + SnapLayout { Layout.fillWidth: true - lock: root.lock + + spacing: Tokens.spacing.medium + orientation: Qt.Vertical + + CustomRect { + Layout.fillHeight: true + Layout.fillWidth: true + bottomRightRadius: Tokens.rounding.largeIncreased + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.small + topRightRadius: Tokens.rounding.largeIncreased + + NotifDock { + lock: root.lock + } + } } } } diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index beb73ad..0c14a06 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -23,6 +23,7 @@ CustomRect { topRightRadius: Tokens.rounding.largeIncreased SnapLayout { + orientation: Qt.Vertical anchors.fill: parent anchors.margins: Tokens.padding.large @@ -51,7 +52,8 @@ CustomRect { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: ColumnLayout { + sourceComponent: SnapLayout { + orientation: Qt.Vertical spacing: Tokens.spacing.largeIncreased CustomText { -- 2.47.3 From 395d1dad3d39b2a4f23698066a8350f06334beea Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 29 Oct 2026 17:51:20 +0100 Subject: [PATCH 13/15] fix: remove duped property --- Modules/Lock/NotifDock.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 0c14a06..d913ba7 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -23,7 +23,6 @@ CustomRect { topRightRadius: Tokens.rounding.largeIncreased SnapLayout { - orientation: Qt.Vertical anchors.fill: parent anchors.margins: Tokens.padding.large -- 2.47.3 From 067291f478b419c09269a891c8865949d18e1acf Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 29 Oct 2026 18:43:50 +0100 Subject: [PATCH 14/15] fix: lockscreen notifdock --- Modules/Lock/Content.qml | 20 +++----------------- Modules/Lock/NotifDock.qml | 14 ++++---------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index a3fc7a6..a1140c7 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -53,24 +53,10 @@ Item { lock: root.lock } - SnapLayout { + NotifDock { Layout.fillWidth: true - - spacing: Tokens.spacing.medium - orientation: Qt.Vertical - - CustomRect { - Layout.fillHeight: true - Layout.fillWidth: true - bottomRightRadius: Tokens.rounding.largeIncreased - color: Colors.tPalette.m3surfaceContainer - radius: Tokens.rounding.small - topRightRadius: Tokens.rounding.largeIncreased - - NotifDock { - lock: root.lock - } - } + Layout.fillHeight: true + lock: root.lock } } } diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index d913ba7..1757133 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -51,16 +51,10 @@ CustomRect { duration: Tokens.anim.durations.extraLarge } } - sourceComponent: SnapLayout { - orientation: Qt.Vertical - 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") } } -- 2.47.3 From c5608dc9b94281990a22b95d5f0ffeae04c955df Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 30 Oct 2026 01:52:33 +0100 Subject: [PATCH 15/15] fix: blobs now allow to be drawn in device pixels to stay sharp --- Drawers/Panels.qml | 11 +- Modules/Bar/Bar.qml | 3 +- Modules/Bar/Popouts/ClipWrapper.qml | 8 +- Modules/Bar/Popouts/Wrapper.qml | 5 +- Modules/Dashboard/Media/LyricsInfo.qml | 8 +- Modules/Settings/Common/PageBase.qml | 2 +- Modules/Settings/Common/SectionHeader.qml | 3 +- Modules/Settings/Pages/Apps/AllApps.qml | 8 +- Plugins/ZShell/Blobs/blobgroup.cpp | 41 ++++- Plugins/ZShell/Blobs/blobgroup.hpp | 6 + Plugins/ZShell/Blobs/blobgroup2.cpp | 104 +++++++---- Plugins/ZShell/Blobs/blobgroup2.hpp | 20 +- Plugins/ZShell/Blobs/blobinvertedrect.cpp | 22 +-- Plugins/ZShell/Blobs/blobinvertedrect.hpp | 9 +- Plugins/ZShell/Blobs/blobinvertedrect2.cpp | 60 ++++++ Plugins/ZShell/Blobs/blobinvertedrect2.hpp | 56 ++++++ Plugins/ZShell/Blobs/blobmaterial.cpp | 15 -- Plugins/ZShell/Blobs/blobmaterial.hpp | 10 - Plugins/ZShell/Blobs/blobrect.cpp | 204 +++++++++++++-------- Plugins/ZShell/Blobs/blobrect.hpp | 7 +- Plugins/ZShell/Blobs/blobrect2.cpp | 159 +++++++++++----- Plugins/ZShell/Blobs/blobrect2.hpp | 26 ++- Plugins/ZShell/Blobs/blobshape.cpp | 55 ++++-- Plugins/ZShell/Blobs/blobshape.hpp | 8 +- Plugins/ZShell/Blobs/blobshape2.cpp | 8 +- Plugins/ZShell/Blobs/blobshape2.hpp | 20 +- Plugins/ZShell/Blobs/shaders/blob.frag | 129 ++++--------- Plugins/ZShell/Blobs/shaders/frag.spv | Bin 0 -> 24680 bytes Plugins/ZShell/Components/snaplayout.cpp | 184 ++++++++++++++----- Plugins/ZShell/Components/snaplayout.hpp | 38 +++- 30 files changed, 794 insertions(+), 435 deletions(-) create mode 100644 Plugins/ZShell/Blobs/shaders/frag.spv diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 0807b23..6bd1eed 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -154,8 +154,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 @@ -194,8 +196,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/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index ba6cf14..83a95c2 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -8,6 +8,7 @@ import ZShell.Components import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts +import ZShell SnapLayout { id: root @@ -23,7 +24,7 @@ SnapLayout { 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 { 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/Wrapper.qml b/Modules/Bar/Popouts/Wrapper.qml index 92f2833..be16ac1 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 diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml index c82939f..e36cad6 100644 --- a/Modules/Dashboard/Media/LyricsInfo.qml +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -17,7 +17,7 @@ Item { implicitWidth: btn.implicitWidth * 0.9 implicitHeight: btn.implicitHeight * 0.9 - BlobGroup { + BlobGroup2 { id: blobGroup color: Colors.palette.m3surfaceContainerHighest @@ -29,7 +29,7 @@ Item { } } - BlobRect { + BlobRect2 { id: btnRect anchors.fill: parent @@ -42,7 +42,7 @@ Item { } } - BlobRect { + BlobRect2 { id: rect anchors.right: parent.right @@ -143,8 +143,8 @@ Item { ] SnapLayout { -orientation: Qt.Vertical id: layout + orientation: Qt.Vertical anchors.centerIn: parent spacing: Tokens.spacing.extraSmall diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 976cfd1..bf0fe35 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -9,7 +9,6 @@ import ZShell.Config import qs.Services SnapLayout { -orientation: Qt.Vertical id: root property bool animateScroll: false @@ -69,6 +68,7 @@ orientation: Qt.Vertical } spacing: Tokens.spacing.largeIncreased + orientation: Qt.Vertical Component.onCompleted: applySearchAnchor() diff --git a/Modules/Settings/Common/SectionHeader.qml b/Modules/Settings/Common/SectionHeader.qml index 07c8acb..f0be9e4 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import ZShell.Components import ZShell.Config import qs.Components import qs.Services @@ -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/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index 48e4e50..4e16fd5 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -18,10 +18,12 @@ PageBase { title: qsTr("All apps") SnapLayout { -orientation: Qt.Vertical anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + orientation: Qt.Vertical + width: root.cappedWidth Repeater { @@ -54,6 +56,7 @@ orientation: Qt.Vertical anchors.leftMargin: Tokens.padding.largeIncreased anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium IconImage { @@ -63,8 +66,9 @@ orientation: Qt.Vertical } SnapLayout { -orientation: Qt.Vertical Layout.fillWidth: true + + orientation: Qt.Vertical spacing: 0 CustomText { 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..28f6ee2 100644 --- a/Plugins/ZShell/Blobs/blobrect.cpp +++ b/Plugins/ZShell/Blobs/blobrect.cpp @@ -7,19 +7,94 @@ 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; +} // namespace -BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {} +BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) { + m_stillSamples = k_settleStillSamples; +} BlobRect::~BlobRect() { if (m_group) m_group->removeShape(this); } +bool BlobRect::needsFrame() const { + return m_physicsActive || m_retiring || + m_stillSamples < k_settleStillSamples || + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f) > + k_identityEps; +} + +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() { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + 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 +103,80 @@ void BlobRect::updatePolish() { const float s = t * t * (3.0f - 2.0f * t); if (t >= 1.0f) { - m_dm00 = 1.0f; - m_dm01 = 0.0f; - m_dm11 = 1.0f; - m_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); + finishAtIdentity(); + return; } + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); - - if (!m_physicsActive) return; - } 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 stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f); + if (stuck > k_identityEps) { + beginRetire(); + return; + } + } + 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 +186,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; @@ -165,7 +220,7 @@ void BlobRect::updatePhysics() { emit rawDeformMatrixChanged(); updateCenteredDeformMatrix(); - checkAtRest(speed); + trySettle(speed); } void BlobRect::setTopLeftRadius(qreal r) { @@ -340,16 +395,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..24824ef 100644 --- a/Plugins/ZShell/Blobs/blobrect2.cpp +++ b/Plugins/ZShell/Blobs/blobrect2.cpp @@ -6,14 +6,59 @@ 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; } // 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 { + return m_physicsActive || m_retiring || + m_stillSamples < k_settleStillSamples || + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f) > + k_identityEps; +} + +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() { m_dm00 = 1.0f; m_dm01 = 0.0f; @@ -23,11 +68,12 @@ void BlobRect2::finishAtIdentity() { 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 +82,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, @@ -63,12 +125,16 @@ 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 stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f); + if (stuck > k_identityEps) { + beginRetire(); + return; + } + } + + if (m_physicsActive) requestPhysics(); const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); @@ -76,34 +142,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 +199,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; @@ -147,22 +233,8 @@ void BlobRect2::updatePhysics() { 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 +410,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..3885251 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,7 +43,6 @@ 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); } @@ -55,9 +53,6 @@ float smax(float a, float b, float k) { } 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); @@ -67,10 +62,6 @@ float smaxSharpA(float a, float b, float k) { void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); - // Phase 1: compute per-rect SDF, 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 +75,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 +84,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 +103,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 +134,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,7 +148,6 @@ void main() { continue; if ((excludeMask & (1 << j)) != 0) continue; - // smin only deviates from min within smoothFactor if (abs(dArr[i] - dArr[j]) >= smoothFactor) continue; mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor)); @@ -184,16 +155,12 @@ void main() { } 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,62 +170,51 @@ 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; + float preOff = smoothFactor * (1.0 / 6.0); + 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); @@ -269,37 +225,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 0000000000000000000000000000000000000000..0c52ace5daa8bfbdfe1ad0853a1f85e7704ad6ae GIT binary patch literal 24680 zcmZvk2b^71wZ<=*Ng;#~dT1dbGzAfm-UCR21`-I;q)jrDWFVOtW+o66C6OWuVo*>7 zl&DBYijATWq^dN1AfTuOrK_lP1oHm>d(Ss)?~n8De*62rwf0)OopaB=8EF|berT;_ zx!MY~VYN+KYxQg8+MwEUpcQIE>h;9Q(0EC^ZMk&Yop<1 zo^Ado(FrrRA(MGl!!LPO2bX!)fd6lvmfD(Vbc41-YGbJT=5}^%O1~SQM$C24+v+|o zwRNedS9m?@rUdqouYHxSirSW>6nr z;ghLnSNQw^+*-Q?Jbg|_SEEg9?Pu_w&iNg4D&L=LpEr|{4XKH}j_*HuzqbC~&iUZz zEUo&Kp0*jC{U@mpuKT<0J#FnQ?4bjiJE)-@HlVpD8rmTP+Ta0g657yuydh|PbGy6y zXYbQCqrcl}$JXeZd#$%)M*m*yr2e|z=)*AZ+>`cYcg-J&y*%2C?m6`UX+Iooc3a=R zT_<$(_II=ojunYkhCh*S*{NUDAOz z^?PAz5B#Q|Kz`ivxWEW?CY=ZqsQTd1Gm~d`>iqB2J-yvM++ub93N?M% zdv2Y#)ZU`*W8>L&8QA+Vu?Dkg-oLk6Yb(Gz=J#_~H~hDOw@>J;ud(;|PHx_GzLrr5sTkJj2$a8FxrTccfT?Z75}P!m7ofB4|qEO_6H z-j0s0Njzm5{W}(|ok=>3nRI+7hHus2r&j#X%DyPPvR@28X=-n`_mS-{htD72Ewvlr zxqojK_vpu-2G8j3Wx*zQ_|UcO?P#-@+(RpVPsIM>bac*|?fSRYmcmae__Ofo-QDbthX3>Mz8P(EeDK=8q1^OyJ9=kzv`=eis!&>L z!_XS*-cnm@01vKh2JVOgc{x3!AwM&)g9(WP0ue0m8gWKlJYwU~G+Dm9Xy&Y3#*6*K| z+ROUXWAKf@C(ni9;P;;~gKI0njm51o@SIt_(x%m)EqtfKXY}{h`@8{|F--O5a12|* z8N&eI4el5e?CWHBz5jKOMw`RYW>0Rb)}yu70Y9POJ#hP!HM{`c)iEoN`Fh@q(AxLm znZpFwk4xd@UcRh}U(v*`ZQ|E8@f(`>t?=HCwi!%GJ>MPL=O5D9-ai{Yr1ms?ZX0{0 zvu)0VIX$!6s`Yywz45)$P`zJYMa#Zx;mf(dyMLgO_7P~kv!)YwaQ%I6W^dc9#*c50#7Z?%I8&36kmpO@*&uu40k(3Y>XV+zgp3fp?ar>*Z4 zYR+*q-zC%*6q@f4YKscZcL%k_h30!hU1LCr=Ul2?UueE7s4YQroqRtSNAU{Zph4^W zKCth!UfO-P4_}oPb?(QpzAe<^H9*eaSjw9CJ8yDrW8u!3nnmt>#b+GIxzb<16n%_k z-?h1pwq1qVw(7QBmAbjDKI2f=u589i*^C(aZ%OgmoPB4!tte`i)$!SedMw4uHu7!j z?0void^yT$jC3M(VlM(;ebt9|3%?T1wfkt|+yt=ij>kMVQ;($>CwXoGPo=Dj-Fr%Z zwJ~J!9x;Yv_@H7ui*2m+5nFP9hC83XKzE&tyK}|eA8Pi;7`Ink|0NYRZtQo0-3!|9 zrFJaNsqy6Qr;N{dzot0;w0{81)~vVxKWf|R7yF~&_m(YNCO7Y6l(z=0J~j3y;9lWR zfsJ8*mQp*GGTyUr?h6afDxP~~$BL^P&wRUsYqbUSe8xWcV}Nf=@J)~F+FyV3_rZP6 zh4;hD`92-)voZEt7#zzzu-^Bw+zxG#}_*+ff^Hu!qkLRnBd%g;H?|HrocdvQA3b*~lg4=)3Q>EQARmnY5mE1E` zxc&D`6>j`l1vkFuso3S7r%LX5s^p%hO73~8)ER0o~=sm z*{bB8t-_r@&sHV(Y*lj4R^hI{XRC1Yd$tNUo@c9)d$uaMXRC1Ad$uaMXRDHXt}6NB zChmEvw0o8+x#y^odxk2x=ckf;b}G4Nr;>YiD!J#Tl6z(hZY?tk0d4_}mRvpGq9#-9xdRx;|>ZqTEMGoZoHeCa`)hP`^kq=d%>;w)K7DC5kr3>G!-m zasL4>HMm-t>vg!g`!c!S z04JCBI2KfYm(1q}^&@ z+j)*jyVb$!-rKfY18iIMJQvmkt6TJQUPgoMqc+Fm`=^?@{JvQW>~mpTN^-4@rXHVl zz|KSIvo2iy)T%%0fo-Qgf*#t(Fjt!~3d|s%Jr>Ob75GU70V8^!uCF9!|O+7xFfE{1yvngCX9cWLu=iv7wH@5^hPrz}{up-i`&_rZacuANa%Zs5lkD|f;A$2xtN88;DSdZ?t699i zS||rpdu0zaV|hiJP-)81_JkP6E81R_rks7hH^i96i1u-??e$ml{JsxGUu}-xvzD4; z@Lrh&b|1*~-?Y));S_2k-IuTO!?XX3$dHA~jOcAg2frQM-mpRd|HAIf*+H0?cn7obQCIS-h<5 z(=33t%)xPB*QdPq=fKtDGZ$>T(x(fqzL0)9Pu*bKsrx;-1nqdR#jmgM zE7zhQU0a@M^T4)Mk9Gps+}a%9vD9jbJsoj6{1%#e#&RLpcIweC z0-IBt<6A_nW?b))Z-X7*Nb1bncfji5mw?^ZBdTZIVlbb9e{;}ZpTztwxH;zc(9{$2 z`(XD{VqOa7GZ0gMeexXqL1o_vAM@*BJU>tnuqsMV71A+U2<=6e{f9-rSr08cq z?x)tCG5sE_mUxeW-GA!(KT53@|382mbI^DX`Xijrz+Qg>qK|DKr`Ddfe*zmX{Lf(5 zA^b03+hiP1f%(+7px9P_eT@Aiwf3}q8tgd2{|a`_!~X^@V?P7uQ;%(1{q-^SQflqu zXQ`i~B+d)q%M1P@*uLc5>?N>1>S^IHA zSq9ff{qib~FaO5U<~#5p4+C7j18ZB3qUM;ziQNJ=m*X*qT$}UJ3U)rU`CFpgxl0`D z@?7cpGHnM}Z5Pl#V-10;`Ma9&jH_n*{08Z7f!h4{7tgF>|Lx@ouzt&f?Z36ZRjxoi zg5srp#ma7-evAYg!!=xr-%4QDruRHJEpQ*C-?ebV`N^(f}7`c9GZIObpx>N)SXxPIu!Gn zU!3^k!HMrYZU}cC)iaM9fz@xT)@>88+Qt;`fe%r8nM>QI6g6{+9h>8nt0(v7U~}j9 z=Z}EZ%HKG*fU7rtCmvdVFa0RoIe3<$z5K4*60ZGZ`r`et6; zSS{kM*KT*XYp1S{_st$)^{m|luv*q`FR+)nwCzbzGnd%0IZnBHa(^7`e1`7> zwl8;8&!LH6=TZIMN}B|>owod@urFBMd&Ir;NwAmw(e??7n)$_Lf7FwIKd@_=?~wh$ z&V_pBVhUJ2ZKi_D`8xn^8}+m~5UieeooQg>sOR1EATZ77F~1>!?ZXoAr@*fFNQ{Yh zFj_-zd@mgWx4pXKKaAST@oPJjqUQL;<@nVT_tW5^6?fks0d_tz)+52?SdWG~R{av= z7_hqc%2Cwv%;$8l{>F*_46wTXZPfCNy#w4l_L*qv`nOZdcjQ#yKAi;~Lv3DTY0qHA!;bJE6O60-|UJ@-R5SS|O%@nA3agSH-untc-+$8*&QV9$8Y z%RI2$yf2{52b;H#T7Q4vJPE9x-=jYRR63f&RIon2%i6wI8zpn}MR4Zm z46rxnHtY-1b9GwZ*mRMf_H^({$O+B%`3Rbi58JMH9A=-?! z1pQpFu}-IU9NsHm1FJj6Mbz@l(bvH%;p6!1m+|z`_cUtl`CaOKaQe9eZ4&<*=-T4< z&BE{SYJTj;1?bu`|K9@JR^5E(QL81+Mc@z0cQLxQ_OPn8p zKPcai(Y3|zCxu^`@2BY6GQOXIZL4m+8>!V2=Voy7mFss4y0-Y;TKJXo`Ezt_8Q(9! zwpBOZP1I_Mb36Eh@-0Ev7QZ_RzcSx1(Y0lKcY7)pDQR4fY-hzo+68s(b%l zxcw>LAMQg_PmW)KZKrNK-vfUQ_BmJXiQk~9=Xa9(!9KtI{mp(DN1x2q17M${`q}rp zsMXTfhrr%%>FdMb=Dz+GO+7Jx2ezGh;`|>taqRme@aDchil&~v{~nya8`Bv2*v|*4 zwI}}LVDHoLCn}zP{{i0I??0lcXO5o)+fF@m{3o#eEcfM~(bUt=zkt(E`(Ye?G9OQY zon!s%`(xB<>Fd*A@A>rgui)ms{tZn%F`oh3PCap+1t*Sk{2aWw@6V&Dr|&O-(|2PU zLm&IOlv=y^Md~eynLhp<-rUE3psDBgqJM(b7SYZaFM-t(<6q$B82?67PmKS7)%@+y z7%zj>GUivn?%Di}z^h<2`x>9u;O>|Bybe~==Q8dke>ZvqZd+|>_a@l2O1rnfYGu2( z;kMP5cK-!CcWL(ySgmaLF5I@-JP$0R_VWEt+j|r>-~Ysp-H-2!zIQGM)@JQFwS~Hs z;-!6XWw*9po)v~@r#SyhfkVO0|5KFs3`0}TIdyrk?bPj?d=SNa<`<{!3Si^soIe7r z<~iSIs_&>P!p-?CMZ0Z%2OA02<~aR(PaCR)r^*_T*X( ztj%2ht*Jb@)&Q5e)`Y8-xkkg)?<{hy1y3&R$+b3ETiyrP0jpWOy!Y&ff8(kx?bZW3 z4{0|BtY-1DU3}L^a~$y<3s$q}$M&f|<2*l%Lo=2>=JjuB)r{r4)_CxFG;ojj4A~H_ zPx&3Q5nMe!8-wkC>9Yx3eIap-yD8Xq>UoBJ2y8xW$^T)n&x!bK2G>tLKAVH>WB5nl z*A#8Gfa{~4=iEoZ&c$XHu=k>V&VheFtUcq|3fw&At1H; z@plI=CVs}d2i!R+zXvD4)#I}#*f}YE_JXS~toFg)VB4u@o<0sXpSI-R2VBmGVY;pebh4thk-K(#&iz! za}Fj`YtQ&T4Q_siI08*QK1YI^=jkXk^^EUmuL(n+sP@ye_cq)ib7UaK@xPW9k5F%RV?Byg|Wx!M-cS-UoLcvIqLX#`RpF zuW|Lsd)GX$G4)G~6TrS}#pgu0e(Ld=53WB;s_&DN;AKBP126mWS$O)Pul>*`IZg%} zQ@`Z+9N71{_tNglev3U1td`$mwW*cw#4Q!i`?GcWbw0MTpWlF=hCTP-H{ojf**|S+Y5y&- z+9}okz7Vda?*-KI_+AX|EpmSwu224r<~v}0)I9@ym)gtsY;B7v-={by;>7(P*tq7r z1T1&_w%6A-@%;ffzL$dKv0ViS%scl?mB9D`hFw0x$i$hQ`i3n zYI*wpWAGUIZVqE<*U#MAlJh5EbB6yET>c%w&*1u~yPh{wd%2$4Zlb8UMq+dLcL2A5 zouAmO<;Rm>U+awR=h&LZ_6s!i_}o_ctjZ0SzTU1z$^Ezlocq!C+U?J+)Y{VSmtg1N zp=!V130I5#uFC#!WxpG)mcJ>#2W%Vld`I33Hus(s?dj96D&6mIeeR>!r#q;%CFiff z_9^@~aQhv8KiD>T<~;!R_XG9hdJvrPYxnn=JOdtr8&jX0;U5N@SDS15J8Cc2R@-kW zYOa^~9iofXT$iQzJ_>g29-+3cKHq*1R=*A(e}{Mstmd9D&lA*M=F#>zMa|ga#Q6i* zy_8&kgg57U5=}k-2K6Vf?bO|GiEEwzJ;k5F=5-wUo6~+hMeSw3^!W=#&3=h9ezmO2 zU%}v`}jGqYv)-`yWfNPx1gut+TAzLSGL?2FTkCX z^1gTxO+D-U5?IZcj`JU2FUP6v?-Vu1Db9TV6Wl!CFQchnRjuv6z-o^Y&-qRq>&*AR z!RB$^^f!n7c!k=_e(3WbikkfpXO3S3JI5LG>+t3=zk#Nnd4CgZJN3-*Tj0#G_HvG2 zg=hFkCdGPLsF7?$(UKuOy{VB4!_ z?bV#;%#(Hc(~8Z#5H#S`bWTO7T46A{w=O;wK*^LSuJ@!3Rd$wSpTgl_Q$?z^DLTogpYx> z?Mczk^>J>tqZp(7jcNNzkI#;kPkGkf2~9oU|2u<|+xFUH+oiIZM?ar^j?p#Um15uY z$=vJ)c5ZSH>|VDy2mWt;2i4@|z8DSnT9uN0v08ywFYp=_UWIy1iu+gY3w1!D^Y~PlLUjV{M01j-og>;>11zoc=gY_wpWQ*R&f=JxhL z+&qW#(bV(&I|*!Cb)SFo^7%Ig?zJ|>=illAq10E8mdKI)l+g<$8$eYJoR-!FiTuRUvVDmZhgJ!|2dokp=QdAB|ttiQT@ z$G)qjZ)bqZzJ3vIUz6)hu(@1!eYL0Um%zpg|8m9ietH&MANBa0-Q@EXxPI#KIS1?* z66dR6{nVqK3szr5$y|L6Y=5=6?(%Zo$HTqKbsr1Q9vD~P4ZyDZhSXX2^TDor_$3uj zzrO*uUHCV_V+(!(*c|Eax4`*!bGBHW!2S^?ub}?t^c`?MwE- zcfk6qyAK?TTKc*etQP*=if6vR2VYE4kI(nPwu#TBVExqN^8>K`h|gtU{nVpf4pw(9 z?VIzemKaxn%NSR|)vhdjt_F{#9UJG`vwxT#s#?U7@eh4;i`g9#w&35UN qTKumEk1hBO;Bl0!&5dC5xi #include #include +#include #include Q_LOGGING_CATEGORY(lcQuickLayouts, "qt.quick.layouts") @@ -54,49 +55,13 @@ SnapGridLayoutItem::SnapGridLayoutItem( int column, int rowSpan, int columnSpan, - Qt::Alignment alignment, - SnapLayout* layout) - : QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment) - , m_layout(layout) {} + Qt::Alignment alignment) + : QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment) {} void SnapGridLayoutItem::setGeometry(const QRectF& rect) { QQuickLayoutAttached* info = attachedLayoutObject(m_item, false); - QRectF placed = info ? rect.marginsRemoved(info->effectiveQMargins()) - : rect; - - const QQuickWindow* win = m_item->window(); - const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; - - if (dpr > 0.0) { - const QPointF origin = absoluteOrigin(m_layout); - const qreal left = (origin.x() + placed.x()) * dpr; - const qreal top = (origin.y() + placed.y()) * dpr; - const qreal right = (origin.x() + placed.right()) * dpr; - const qreal bottom = (origin.y() + placed.bottom()) * dpr; - - const qint64 nearX = std::llround(left); - const qint64 nearY = std::llround(top); - const qint64 farX = std::llround(right); - const qint64 farY = std::llround(bottom); - - placed = QRectF( - qreal(nearX) / dpr - origin.x(), - qreal(nearY) / dpr - origin.y(), - qreal(farX - nearX) / dpr, - qreal(farY - nearY) / dpr); - } - - const QSizeF oldSize(m_item->width(), m_item->height()); - const QSizeF newSize = placed.size(); - - m_item->setPosition(placed.topLeft()); - if (newSize == oldSize) { - if (QQuickLayout* lay = qobject_cast(m_item)) { - if (lay->invalidatedArrangement()) lay->rearrange(newSize); - } - } else { - m_item->setSize(newSize); - } + engineRect = info ? rect.marginsRemoved(info->effectiveQMargins()) : rect; + placed = true; } void SnapLayoutPrivate::mirrorChange() { @@ -119,6 +84,7 @@ SnapLayout::~SnapLayout() { void SnapLayout::componentComplete() { QQuickLayout::componentComplete(); + applySpacing(); invalidate(); ensureLayoutItemsUpdated(QQuickLayout::ApplySizeHints); @@ -128,8 +94,12 @@ void SnapLayout::componentComplete() { void SnapLayout::itemChange(ItemChange change, const ItemChangeData& value) { QQuickLayout::itemChange(change, value); - if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) + if (change == ItemChildRemovedChange && value.item) + d_func()->childSizes.remove(value.item); + if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) { + applySpacing(); invalidate(); + } } QSizeF SnapLayout::sizeHint(Qt::SizeHint whichSizeHint) const { @@ -169,16 +139,33 @@ Qt::LayoutDirection SnapLayout::effectiveLayoutDirection() const { qreal SnapLayout::spacing() const { Q_D(const SnapLayout); - return d->engine.spacing(d->orientation, d->styleInfo); + 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) || spacing() == space) return; + if (std::isnan(space) || d->spacingRequest == space) return; #pragma GCC diagnostic pop - d->engine.setSpacing(space, Qt::Horizontal | Qt::Vertical); + d->spacingRequest = space; + applySpacing(); invalidate(); emit spacingChanged(); } @@ -242,6 +229,14 @@ 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(); } @@ -286,8 +281,8 @@ void SnapLayout::insertLayoutItems() { int gridRow = 0; int gridColumn = index; if (d->orientation == Qt::Vertical) qSwap(gridRow, gridColumn); - QGridLayoutItem* layoutItem = new SnapGridLayoutItem( - child, gridRow, gridColumn, 1, 1, alignment, this); + QGridLayoutItem* layoutItem = + new SnapGridLayoutItem(child, gridRow, gridColumn, 1, 1, alignment); if (hStretch >= 0) { if (!fillWidth) @@ -315,6 +310,7 @@ void SnapLayout::removeGridItem(QGridLayoutItem* gridItem) { 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; @@ -381,6 +377,98 @@ void SnapLayout::clearAncestorListeners() { 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; + + 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 mainFarDev = std::llround(mainExact + mainSizeExact); + + const auto stored = d->childSizes.constFind(child); + qint64 width = engineWidth; + qint64 height = engineHeight; + if (horiz) + width = std::max(mainFarDev - mainNear, qint64(0)); + else + height = std::max(mainFarDev - mainNear, qint64(0)); + + 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; + } else { + 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; @@ -408,7 +496,11 @@ void SnapLayout::rearrange(const QSizeF& size) { } 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)) diff --git a/Plugins/ZShell/Components/snaplayout.hpp b/Plugins/ZShell/Components/snaplayout.hpp index 150ae02..78bccc9 100644 --- a/Plugins/ZShell/Components/snaplayout.hpp +++ b/Plugins/ZShell/Components/snaplayout.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -20,6 +22,20 @@ class SnapLayoutStyleInfo : public QAbstractLayoutStyleInfo { 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( @@ -28,13 +44,12 @@ class SnapGridLayoutItem : public QQuickGridLayoutItem { int column, int rowSpan, int columnSpan, - Qt::Alignment alignment, - SnapLayout* layout); + Qt::Alignment alignment); void setGeometry(const QRectF& rect) override; - private: - SnapLayout* m_layout; + QRectF engineRect; + bool placed = false; }; class SnapLayout : public QQuickLayout { @@ -101,6 +116,10 @@ class SnapLayout : public QQuickLayout { void insertLayoutItems(); void removeGridItem(QGridLayoutItem* gridItem); + void arrangeChildren(); + + void applySpacing(); + void updateAncestorListeners(); void clearAncestorListeners(); }; @@ -113,10 +132,11 @@ class SnapLayoutPrivate : public QQuickLayoutPrivate { void mirrorChange() override; - QQuickGridLayoutEngine engine; + 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; @@ -124,6 +144,14 @@ class SnapLayoutPrivate : public QQuickLayoutPrivate { QVector> ancestorListeners; 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 -- 2.47.3