From 75de349ff4765f5067e5b9a290220c20c55964b6 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 00:15:52 +0200 Subject: [PATCH] MD3 wallpaper launcher carousel --- Modules/Launcher/CarouselView.qml | 327 +++++++++++++++++++++++ Modules/Launcher/ContentList.qml | 4 +- Modules/Launcher/Items/WallpaperItem.qml | 95 ------- Modules/Launcher/Items/WallpaperTile.qml | 67 +++++ Modules/Launcher/WallpaperList.qml | 93 ------- 5 files changed, 396 insertions(+), 190 deletions(-) create mode 100755 Modules/Launcher/CarouselView.qml delete mode 100644 Modules/Launcher/Items/WallpaperItem.qml create mode 100755 Modules/Launcher/Items/WallpaperTile.qml delete mode 100644 Modules/Launcher/WallpaperList.qml diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml new file mode 100755 index 0000000..695a048 --- /dev/null +++ b/Modules/Launcher/CarouselView.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import ZShell.Config +import qs.Modules.Launcher.Items +import qs.Components +import qs.Helpers +import qs.Services + +Item { + id: root + + readonly property var arrangement: { + const W = width; + const F = focalWidth; + const M = minEdgeWidth; + const sp = itemSpacing; + + if (W <= 0 || F <= 0) + return { + n: 0, + sizes: [F], + effectiveExtraWidth: 0 + }; + + let bestN = 0; + let bestSizes = [F]; + let bestExtraWidth = 0; + + for (let n = 1; ; n++) { + const ratio = Math.pow(M / F, 1 / n); + const sizes = [F]; + + for (let i = 1; i <= n; i++) { + sizes.push(i === n ? M : F * Math.pow(ratio, i)); + } + + let decreasedWidth = 0; + + for (let i = 1; i <= n; i++) + decreasedWidth += pitch - sizes[i]; + + const effectiveWidth = W + decreasedWidth * 2; + const requiredWidth = (2 * n + 1) * pitch; + + if (requiredWidth > effectiveWidth) + break; + + bestN = n; + bestSizes = sizes; + bestExtraWidth = decreasedWidth; + } + + return { + n: bestN, + sizes: bestSizes, + effectiveExtraWidth: bestExtraWidth + }; + } + readonly property int centerBlockIndex: Math.floor(loopMultiplier / 2) + readonly property int centerBlockStart: centerBlockIndex * realCount + readonly property int currentRealIndex: realIndexOf(view.currentIndex) + readonly property real focalWidth: Config.launcher.sizes.wallpaperWidth + readonly property real itemSpacing: Tokens.spacing.small + readonly property var keylines: { + const sp = itemSpacing; + const sizes = arrangement.sizes; + const n = arrangement.n; + + const right = [ + { + io: 0, + lo: 0, + sz: sizes[0] + } + ]; + + let lo = sizes[0] / 2; + + for (let i = 1; i <= n; i++) { + const size = sizes[i]; + + lo += sp + size / 2; + + right.push({ + io: i, + lo: lo, + sz: size + }); + + lo += size / 2; + } + + right.push({ + io: n + 1, + lo: width / 2 - sp, + sz: 0 + }); + + const k = []; + + for (let i = right.length - 1; i >= 1; i--) { + k.push({ + io: -right[i].io, + lo: -right[i].lo, + sz: right[i].sz + }); + } + + for (const item of right) + k.push(item); + + for (let i = 0; i < k.length; i++) + k[i].loc = k[i].io * pitch; + + return k; + } + readonly property int loopMultiplier: 401 + readonly property real minEdgeWidth: 56 + required property var panels + readonly property real pitch: focalWidth + itemSpacing + readonly property int realCount: values.length + readonly property var screen: QsWindow.window?.screen + required property SearchBar search + readonly property real shrinkRatio: 0.63 + readonly property real tileHeight: focalWidth / 16 * 9 + readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) + readonly property int virtualCount: realCount > 0 ? realCount * loopMultiplier : 0 + required property var visibilities + + function contentXForIndex(index) { + return index * pitch + pitch / 2 - width / 2; + } + + function decrementCurrentIndex() { + if (realCount === 0) + return; + + goTo(view.currentIndex - 1); + } + + function glideTo(dest) { + if (Math.abs(dest - view.contentX) < 0.5) + return; + view.cancelFlick(); + snapAnim.stop(); + snapAnim.from = view.contentX; + snapAnim.to = dest; + snapAnim.start(); + } + + function goTo(virtualIndex) { + glideTo(contentXForIndex(virtualIndex)); + } + + function incrementCurrentIndex() { + if (realCount === 0) + return; + + goTo(view.currentIndex + 1); + } + + function indexForContentX(x) { + return Math.round((x + width / 2 - pitch / 2) / pitch); + } + + function realIndexOf(virtualIndex) { + if (realCount === 0) + return -1; + return ((virtualIndex % realCount) + realCount) % realCount; + } + + function sample(childLoc) { + const k = keylines; + + if (childLoc <= k[0].loc) + return { + c: k[0].lo, + s: 0 + }; + + if (childLoc >= k[k.length - 1].loc) + return { + c: k[k.length - 1].lo, + s: 0 + }; + + for (let i = 0; i < k.length - 1; i++) { + const a = k[i]; + const b = k[i + 1]; + + if (childLoc > b.loc) + continue; + + const span = b.loc - a.loc; + const t = span > 0 ? (childLoc - a.loc) / span : 0; + + return { + c: a.lo + (b.lo - a.lo) * t, + s: a.sz + (b.sz - a.sz) * t + }; + } + + return { + c: 0, + s: 0 + }; + } + + function selectReal(realIdx) { + if (realCount === 0) + return; + + const curBlock = Math.floor(view.currentIndex / realCount); + const current = view.currentIndex; + + let target = curBlock * realCount + realIdx; + + if (target - current > realCount / 2) + target -= realCount; + else if (current - target > realCount / 2) + target += realCount; + + goTo(target); + } + + function snapTarget(x) { + return contentXForIndex(indexForContentX(x)); + } + + function snapToNearest() { + glideTo(snapTarget(view.contentX)); + } + + implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal + implicitWidth: screen ? Math.max(0, screen.width - Config.bar.rounding * 50 - panels.bar.implicitWidth * 2) : 0 + + Component.onCompleted: { + const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); + + if (idx >= 0) + view.contentX = contentXForIndex(centerBlockStart + idx); + } + Component.onDestruction: Wallpapers.stopPreview() + + ListView { + id: view + + anchors.fill: parent + boundsBehavior: Flickable.StopAtBounds + cacheBuffer: root.width * 10 + clip: false + currentIndex: root.centerBlockStart + displayMarginBeginning: root.arrangement.effectiveExtraWidth + displayMarginEnd: root.arrangement.effectiveExtraWidth + interactive: true + model: root.virtualCount + orientation: ListView.Horizontal + snapMode: ListView.NoSnap + + Anim on contentX { + id: snapAnim + + running: false + type: Anim.Standard + } + delegate: Item { + id: slot + + readonly property real childLoc: index * root.pitch + root.pitch / 2 - view.contentX - view.width / 2 + required property int index + readonly property bool isCurrent: index === view.currentIndex + readonly property var keyline: root.sample(childLoc) + readonly property var modelData: realIdx >= 0 ? root.values[realIdx] : null + readonly property int realIdx: root.realIndexOf(index) + readonly property real tileWidth: keyline.s + + height: view.height + visible: modelData !== null + width: root.pitch + + WallpaperTile { + anchors.verticalCenter: parent.verticalCenter + isCurrent: slot.isCurrent + modelData: slot.modelData + tileHeight: root.tileHeight + tileWidth: slot.tileWidth + visibilities: root.visibilities + x: (slot.keyline.c - slot.childLoc) + (slot.width - width) / 2 + + onRequestActivate: { + if (slot.isCurrent) { + Wallpapers.setWallpaper(slot.modelData.path); + root.visibilities.launcher = false; + } else { + root.goTo(slot.index); + } + } + } + } + + onContentXChanged: { + if (root.realCount === 0) + return; + const blockWidth = root.realCount * root.pitch; + const centerX = root.contentXForIndex(root.centerBlockStart); + const blocksFromCenter = Math.round((contentX - centerX) / blockWidth); + if (Math.abs(blocksFromCenter) >= 2) { + const shift = blocksFromCenter * blockWidth; + + contentX -= shift; + + if (snapAnim.running) + snapAnim.to -= shift; + } + currentIndex = Math.round((contentX + width / 2 - root.pitch / 2) / root.pitch); + } + onCurrentIndexChanged: { + const realIdx = root.realIndexOf(currentIndex); + if (realIdx >= 0) + Wallpapers.preview(root.values[realIdx].path); + } + onFlickEnded: root.snapToNearest() + onMovementEnded: root.snapToNearest() + } +} diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 6b62f2e..5ccdea4 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -110,8 +110,8 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - sourceComponent: WallpaperList { - content: root.content + sourceComponent: CarouselView { + // content: root.content panels: root.panels search: root.search visibilities: root.visibilities diff --git a/Modules/Launcher/Items/WallpaperItem.qml b/Modules/Launcher/Items/WallpaperItem.qml deleted file mode 100644 index 024633c..0000000 --- a/Modules/Launcher/Items/WallpaperItem.qml +++ /dev/null @@ -1,95 +0,0 @@ -import ZShell.Models -import Quickshell -import QtQuick -import qs.Components -import qs.Helpers -import qs.Config -import qs.Modules - -Item { - id: root - - required property FileSystemEntry modelData - required property PersistentProperties visibilities - - implicitHeight: image.height + label.height + Appearance.spacing.small / 2 + Appearance.padding.large + Appearance.padding.normal - implicitWidth: image.width + Appearance.padding.larger * 2 - opacity: 0 - scale: 0.5 - z: PathView.z ?? 0 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - Component.onCompleted: { - scale = Qt.binding(() => PathView.isCurrentItem ? 1 : PathView.onPath ? 0.8 : 0); - opacity = Qt.binding(() => PathView.onPath ? 1 : 0); - } - - StateLayer { - radius: Appearance.rounding.normal - - onClicked: { - Wallpapers.setWallpaper(root.modelData.path); - root.visibilities.launcher = false; - } - } - - Elevation { - anchors.fill: image - level: 4 - opacity: root.PathView.isCurrentItem ? 1 : 0 - radius: image.radius - - Behavior on opacity { - Anim { - } - } - } - - CustomClippingRect { - id: image - - anchors.horizontalCenter: parent.horizontalCenter - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: implicitWidth / 16 * 9 - implicitWidth: Config.launcher.sizes.wallpaperWidth - radius: Appearance.rounding.small - y: Appearance.padding.large - - MaterialIcon { - anchors.centerIn: parent - color: DynamicColors.tPalette.m3outline - font.pointSize: Appearance.font.size.extraLarge * 2 - font.weight: 600 - text: "image" - } - - CachingImage { - anchors.fill: parent - cache: true - path: root.modelData.path - smooth: !root.PathView.view.moving - } - } - - CustomText { - id: label - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: image.bottom - anchors.topMargin: Appearance.spacing.small / 2 - elide: Text.ElideRight - font.pointSize: Appearance.font.size.normal - horizontalAlignment: Text.AlignHCenter - renderType: Text.QtRendering - text: root.modelData.relativePath - width: image.width - Appearance.padding.normal * 2 - } -} diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml new file mode 100755 index 0000000..ae5f103 --- /dev/null +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -0,0 +1,67 @@ +import Quickshell +import QtQuick +import ZShell.Models +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules +import qs.Services + +Item { + id: root + + required property bool isCurrent + required property var modelData // FileSystemEntry-like {path, relativePath} + required property real tileHeight + required property real tileWidth + required property PersistentProperties visibilities + + // Emitted on click: caller decides whether that means "activate" (if + // already current) or "bring to center" (if not) - see CarouselView. + signal requestActivate + + implicitHeight: tileHeight + Tokens.padding.large * 2 + implicitWidth: tileWidth + + CustomClippingRect { + id: image + + anchors.horizontalCenter: parent.horizontalCenter + color: Colors.tPalette.m3surfaceContainer + implicitHeight: root.tileHeight + implicitWidth: root.tileWidth + radius: Tokens.rounding.small + y: Tokens.padding.large + + Behavior on implicitWidth { + enabled: !root.visibilities.launcher // avoid fighting drag-driven updates + + Anim { + } + } + + MaterialIcon { + anchors.centerIn: parent + color: Colors.tPalette.m3outline + font.pointSize: Tokens.font.size.extraLarge * 2 + font.weight: 600 + text: "image" + } + + CachingImage { + 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 : "" + smooth: root.isCurrent + width: root.tileHeight * (16 / 9) + } + + StateLayer { + onClicked: root.requestActivate() + } + } +} diff --git a/Modules/Launcher/WallpaperList.qml b/Modules/Launcher/WallpaperList.qml deleted file mode 100644 index 735191f..0000000 --- a/Modules/Launcher/WallpaperList.qml +++ /dev/null @@ -1,93 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import QtQuick -import qs.Components -import qs.Helpers -import qs.Config -import qs.Modules.Launcher.Items - -PathView { - id: root - - required property var content - readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.9 + Appearance.padding.larger * 2 - readonly property int numItems: { - const screen = QsWindow.window?.screen; - if (!screen) - return 0; - - // Screen width - 4x outer rounding - 2x max side thickness (cause centered) - const barMargins = panels.bar.implicitWidth; - let outerMargins = 0; - if ((visibilities.utilities || visibilities.sidebar) && panels.utilities.implicitWidth > outerMargins) - outerMargins = panels.utilities.implicitWidth; - const maxWidth = screen.width - Config.bar.rounding * 4 - (barMargins + outerMargins) * 2; - - if (maxWidth <= 0) - return 0; - - const maxItemsOnScreen = Math.floor(maxWidth / itemWidth); - const visible = Math.min(maxItemsOnScreen, Config.launcher.maxWallpapers, scriptModel.values.length); - - if (visible === 2) - return 1; - if (visible > 1 && visible % 2 === 0) - return visible - 1; - return visible; - } - required property var panels - required property SearchBar search - required property var visibilities - - cacheItemCount: 4 - highlightRangeMode: PathView.StrictlyEnforceRange - implicitWidth: Math.min(numItems, count) * itemWidth - pathItemCount: numItems - preferredHighlightBegin: 0.5 - preferredHighlightEnd: 0.5 - snapMode: PathView.SnapToItem - - delegate: WallpaperItem { - visibilities: root.visibilities - } - model: ScriptModel { - id: scriptModel - - readonly property string search: root.search.text.split(" ").slice(1).join(" ") - - values: Wallpapers.query(search) - - onValuesChanged: root.currentIndex = search ? 0 : values.findIndex(w => w.path === Wallpapers.actualCurrent) - } - path: Path { - startY: root.height / 2 - - PathAttribute { - name: "z" - value: 0 - } - - PathLine { - relativeY: 0 - x: root.width / 2 - } - - PathAttribute { - name: "z" - value: 1 - } - - PathLine { - relativeY: 0 - x: root.width - } - } - - Component.onCompleted: currentIndex = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent) - Component.onDestruction: Wallpapers.stopPreview() - onCurrentItemChanged: { - if (currentItem) - Wallpapers.preview(currentItem.modelData.path); - } -}