From 439a14d29e1774a855967247cc05634e153edfba Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 12 Aug 2026 01:35:23 +0200 Subject: [PATCH 01/18] Initial commit implementing reveal on hover in fullscreen --- Config/BarConfig.qml | 2 ++ Drawers/Exclusions.qml | 4 ++++ Drawers/Interactions.qml | 18 ++++++++++++++++- Drawers/Regions.qml | 2 +- Drawers/Windows.qml | 9 +++++++-- Modules/Bar/Bar.qml | 19 +++++++++--------- Modules/Bar/BarLoader.qml | 3 ++- Modules/Settings/Pages/Panels/BarPanel.qml | 23 +++++++++++++++++++++- 8 files changed, 65 insertions(+), 15 deletions(-) diff --git a/Config/BarConfig.qml b/Config/BarConfig.qml index 4f3697f..2395ee6 100644 --- a/Config/BarConfig.qml +++ b/Config/BarConfig.qml @@ -57,10 +57,12 @@ JsonObject { enabled: true }, ] + property bool fullscreenReveal: true property int height: 34 property bool hideWhenNotif: false property Popouts popouts: Popouts { } + property int revealDelay: 300 property int rounding: 8 property int smoothing: 32 property Tray tray: Tray { diff --git a/Drawers/Exclusions.qml b/Drawers/Exclusions.qml index 3373e34..00cdd10 100644 --- a/Drawers/Exclusions.qml +++ b/Drawers/Exclusions.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import Quickshell +import Quickshell.Wayland import QtQuick import qs.Config import qs.Components @@ -14,6 +15,9 @@ Scope { ExclusionZone { id: top + WlrLayershell.layer: WlrLayer.Overlay + anchors.left: true + anchors.right: true anchors.top: true exclusiveZone: root.bar.exclusiveZone } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index c72f55f..39a63c4 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -158,6 +158,19 @@ Item { } } + Timer { + id: unhideTimer + + interval: Config.bar.revealDelay + repeat: false + running: false + + onTriggered: { + if (hoverHandler.hovered) + root.bar.isHovered = true; + } + } + HoverHandler { id: hoverHandler @@ -174,7 +187,7 @@ Item { root.popouts.hasCurrent = false; } - if (Config.bar.autoHide) + if (Config.bar.autoHide || root.bar.fullscreen) root.bar.isHovered = false; } } @@ -196,6 +209,9 @@ Item { if (!root.visibilities.bar && Config.bar.autoHide && y < root.bar.implicitHeight) root.bar.isHovered = true; + if (root.bar.fullscreen && !root.bar.isHovered) + unhideTimer.start(); + if (root.panels.sidebar.offsetScale === 1) { const showOsd = root.inRightPanel(root.panels.osdWrapper, x, y); diff --git a/Drawers/Regions.qml b/Drawers/Regions.qml index e5c3757..decebf9 100644 --- a/Drawers/Regions.qml +++ b/Drawers/Regions.qml @@ -17,7 +17,7 @@ Region { intersection: Intersection.Xor width: win.width - win.borderThickness * 2 - win.dragMaskPadding * 2 x: win.borderThickness + win.dragMaskPadding - y: bar.implicitHeight + win.dragMaskPadding + y: bar.implicitHeight + win.dragMaskPadding + (bar.fullscreen ? 1 : 0) R { panel: root.panels.dashboardWrapper diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 23f9028..fa53995 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -49,7 +49,7 @@ CustomWindow { readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) property var root: Quickshell.shellDir readonly property real sdfBorderOffset: 2 * fsTransitionProg - readonly property real shadowOpacity: 0.7 * (1 - fsTransitionProg) + readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: DynamicColors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore @@ -57,7 +57,7 @@ CustomWindow { WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true - mask: visibilities.isDrawing ? null : (hasFullscreen ? emptyRegion : regions) + mask: visibilities.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { @@ -108,6 +108,11 @@ CustomWindow { x: root.width - width y: panels.osdWrapper.y + bar.implicitHeight } + + Region { + height: 1 + width: root.screen.width + } } Regions { diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index b2a1842..274cc9f 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -13,6 +13,7 @@ RowLayout { id: root required property bool fullscreen + required property bool isHovered required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen @@ -85,7 +86,7 @@ RowLayout { delegate: EntryWrapper { HyprsunsetWidget { - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -104,7 +105,7 @@ RowLayout { delegate: EntryWrapper { Workspaces { screen: root.screen - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -116,7 +117,7 @@ RowLayout { TrayIcons { loader: root popouts: root.popouts - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -136,7 +137,7 @@ RowLayout { delegate: EntryWrapper { Resources { visibilities: root.visibilities - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -146,7 +147,7 @@ RowLayout { delegate: EntryWrapper { UpdatesWidget { - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -158,7 +159,7 @@ RowLayout { NotifBell { popouts: root.popouts visibilities: root.visibilities - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -171,7 +172,7 @@ RowLayout { loader: root popouts: root.popouts visibilities: root.visibilities - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -182,7 +183,7 @@ RowLayout { delegate: EntryWrapper { WindowTitle { bar: root - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } @@ -201,7 +202,7 @@ RowLayout { delegate: EntryWrapper { MediaWidget { - visible: !root.fullscreen + visible: !root.fullscreen || root.isHovered } } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index f5baba8..d850e53 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -23,7 +23,7 @@ Item { required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen - readonly property bool shouldBeVisible: !fullscreen && (!Config.bar.autoHide || visibilities.bar || isHovered) + readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || visibilities.bar)) || isHovered readonly property int vPadding: 6 required property PersistentProperties visibilities @@ -78,6 +78,7 @@ Item { sourceComponent: Bar { fullscreen: root.fullscreen height: root.contentHeight + isHovered: root.isHovered popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 9b344eb..be8b637 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -25,7 +25,6 @@ PageBase { ToggleRow { checked: Config.bar.autoHide first: true - last: true settingAnchor: "bar-autohide" subtext: qsTr("Hide the bar, reveal on hover") text: qsTr("Auto hide") @@ -33,6 +32,28 @@ PageBase { onToggled: Config.bar.autoHide = checked } + ToggleRow { + checked: Config.bar.fullscreenReveal + settingAnchor: "bar-fullscreen-reveal" + subtext: qsTr("Hover top edge to show bar in fullscreen") + text: qsTr("Reveal on edge") + + onToggled: Config.bar.fullscreenReveal = checked + } + + SpinRow { + from: 0 + last: true + settingAnchor: "bar-reveal-delay" + stepSize: 100 + subtext: qsTr("The delay before bar is revealed when cursor is at edge") + text: qsTr("Reveal delay") + to: 2000 + value: Config.bar.revealDelay + + onMoved: v => Config.bar.revealDelay = v + } + // Components SectionHeader { text: qsTr("Components") From 75de349ff4765f5067e5b9a290220c20c55964b6 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 00:15:52 +0200 Subject: [PATCH 02/18] 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); - } -} From 88a2808a47f98b85d523d0a9da6f311c665ee615 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 19:23:47 +0200 Subject: [PATCH 03/18] clean up imports --- Modules/Launcher/CarouselView.qml | 1 - Modules/Launcher/Items/WallpaperTile.qml | 2 -- 2 files changed, 3 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index 695a048..c28d0a9 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -6,7 +6,6 @@ import ZShell.Config import qs.Modules.Launcher.Items import qs.Components import qs.Helpers -import qs.Services Item { id: root diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index ae5f103..1c640a8 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -1,10 +1,8 @@ import Quickshell import QtQuick -import ZShell.Models import ZShell.Config import qs.Components import qs.Helpers -import qs.Modules import qs.Services Item { From ec80adf0a59ec8987b3dea2c0669d520633e8be4 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 19:49:48 +0200 Subject: [PATCH 04/18] improve image caching for better performance --- Modules/Launcher/CarouselView.qml | 16 +- Modules/Launcher/Items/WallpaperTile.qml | 2 + .../ZShell/Internal/cachingimagemanager.cpp | 186 +++++++++++++----- 3 files changed, 150 insertions(+), 54 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index c28d0a9..f6a3c04 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -233,7 +233,19 @@ Item { } 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 + implicitWidth: { + if (!screen) + return 0; + + const barMargins = Config.bar.border * 2; + let margins = 0; + if (visibilities.utilities || visibilities.sidebar) + margins = panels.utilities.implicitWidth; + const maxWidth = screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; + + return maxWidth; + } + width: implicitWidth Component.onCompleted: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); @@ -248,7 +260,7 @@ Item { anchors.fill: parent boundsBehavior: Flickable.StopAtBounds - cacheBuffer: root.width * 10 + cacheBuffer: root.width clip: false currentIndex: root.centerBlockStart displayMarginBeginning: root.arrangement.effectiveExtraWidth diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 1c640a8..4923ef9 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -55,6 +55,8 @@ Item { height: root.tileHeight path: root.modelData ? root.modelData.path : "" smooth: root.isCurrent + sourceSize.height: root.tileHeight + sourceSize.width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9) } diff --git a/Plugins/ZShell/Internal/cachingimagemanager.cpp b/Plugins/ZShell/Internal/cachingimagemanager.cpp index 58e3112..7c58c72 100644 --- a/Plugins/ZShell/Internal/cachingimagemanager.cpp +++ b/Plugins/ZShell/Internal/cachingimagemanager.cpp @@ -1,5 +1,7 @@ #include "cachingimagemanager.hpp" +#include + #include #include #include @@ -7,10 +9,27 @@ #include #include #include +#include +#include #include namespace ZShell::internal { +namespace { + +QThreadPool* imageIoPool() { + static QThreadPool pool; + static const bool init = [] { + pool.setMaxThreadCount(std::max(2, QThread::idealThreadCount() / 2)); + return true; + }(); + (void)init; + + return &pool; +} + +} // namespace + qreal CachingImageManager::effectiveScale() const { if (m_item && m_item->window()) { return m_item->window()->devicePixelRatio(); @@ -101,14 +120,13 @@ void CachingImageManager::updateSource() { void CachingImageManager::updateSource(const QString& path) { if (path.isEmpty() || path == m_shaPath) { - // Path is empty or already calculating sha for path return; } m_shaPath = path; const auto future = - QtConcurrent::run(&CachingImageManager::sha256sum, path); + QtConcurrent::run(imageIoPool(), &CachingImageManager::sha256sum, path); const auto watcher = new QFutureWatcher(this); @@ -118,7 +136,16 @@ void CachingImageManager::updateSource(const QString& path) { this, [watcher, path, this]() { if (m_path != path) { - // Object is destroyed or path has changed, ignore + watcher->deleteLater(); + return; + } + + const QString key = watcher->result(); + + if (key.isEmpty()) { + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -126,19 +153,29 @@ void CachingImageManager::updateSource(const QString& path) { const QSize size = effectiveSize(); if (!m_item || !size.width() || !size.height()) { + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } const QString fillMode = m_item->property("fillMode").toString(); - // clang-format off - const QString filename = QString("%1@%2x%3-%4.png") - .arg(watcher->result()).arg(size.width()).arg(size.height()) - .arg(fillMode == "PreserveAspectCrop" ? "crop" : fillMode == "PreserveAspectFit" ? "fit" : "stretch"); - // clang-format on + const QString filename = + QString("%1@%2x%3-%4.png") + .arg(key) + .arg(size.width()) + .arg(size.height()) + .arg( + fillMode == "PreserveAspectCrop" ? "crop" + : fillMode == "PreserveAspectFit" ? "fit" + : "stretch"); const QUrl cache = m_cacheDir.resolved(QUrl(filename)); if (m_cachePath == cache) { + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -149,6 +186,9 @@ void CachingImageManager::updateSource(const QString& path) { if (!cache.isLocalFile()) { qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file"; + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -161,7 +201,6 @@ void CachingImageManager::updateSource(const QString& path) { createCache(path, cache.toLocalFile(), fillMode, size); } - // Clear current running sha if same if (m_shaPath == path) { m_shaPath = QString(); } @@ -170,7 +209,6 @@ void CachingImageManager::updateSource(const QString& path) { }); watcher->setFuture(future); - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } QUrl CachingImageManager::cachePath() const { @@ -182,61 +220,105 @@ void CachingImageManager::createCache( const QString& cache, const QString& fillMode, const QSize& size) const { - QThreadPool::globalInstance()->start([path, cache, fillMode, size] { - QImage image(path); + auto* watcher = + new QFutureWatcher(const_cast(this)); - if (image.isNull()) { - qWarning() << "CachingImageManager::createCache: failed to read" - << path; - return; - } + connect( + watcher, + &QFutureWatcher::finished, + this, + [this, watcher, cache, path]() { + if (watcher->result() && m_item && m_path == path && + m_cachePath.toLocalFile() == cache) { + m_item->setProperty("source", QUrl::fromLocalFile(cache)); + } - image.convertTo(QImage::Format_ARGB32); + watcher->deleteLater(); + }); - if (fillMode == "PreserveAspectCrop") { - image = image.scaled( - size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - } else if (fillMode == "PreserveAspectFit") { - image = image.scaled( - size, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } else { - image = image.scaled( - size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } + const auto future = + QtConcurrent::run( + imageIoPool(), [path, cache, fillMode, size]() -> bool { + QImageReader reader(path); - if (fillMode == "PreserveAspectCrop" || - fillMode == "PreserveAspectFit") { - QImage canvas(size, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); + if (reader.supportsOption(QImageIOHandler::ScaledSize)) { + const QSize full = reader.size(); + if (full.isValid() && !full.isEmpty()) { + const QSize hint = size * 2; + QSize scaled = full; + scaled.scale(hint, Qt::KeepAspectRatioByExpanding); + scaled = QSize( + std::min(scaled.width(), full.width()), + std::min(scaled.height(), full.height())); + reader.setScaledSize(scaled); + } + } - QPainter painter(&canvas); - painter.drawImage( - (size.width() - image.width()) / 2, - (size.height() - image.height()) / 2, - image); - painter.end(); + QImage image = reader.read(); - image = canvas; - } + if (image.isNull()) { + qWarning() + << "CachingImageManager::createCache: failed to read" + << path << reader.errorString(); + return false; + } - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent) || !image.save(cache)) { - qWarning() << "CachingImageManager::createCache: failed to save to" - << cache; - } - }); + image.convertTo(QImage::Format_ARGB32); + + if (fillMode == "PreserveAspectCrop") { + image = image.scaled( + size, + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + } else if (fillMode == "PreserveAspectFit") { + image = image.scaled( + size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } else { + image = image.scaled( + size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + + if (fillMode == "PreserveAspectCrop" || + fillMode == "PreserveAspectFit") { + QImage canvas(size, QImage::Format_ARGB32); + canvas.fill(Qt::transparent); + + QPainter painter(&canvas); + painter.drawImage( + (size.width() - image.width()) / 2, + (size.height() - image.height()) / 2, + image); + painter.end(); + + image = canvas; + } + + const QString parent = QFileInfo(cache).absolutePath(); + if (!QDir().mkpath(parent) || !image.save(cache)) { + qWarning() + << "CachingImageManager::createCache: failed to save to" + << cache; + return false; + } + + return true; + }); + + watcher->setFuture(future); } QString CachingImageManager::sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "CachingImageManager::sha256sum: failed to open" << path; - return ""; + const QFileInfo info(path); + + if (!info.exists() || !info.isFile()) { + qWarning() << "CachingImageManager::sha256sum: failed to stat" << path; + return QString(); } QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); + hash.addData(path.toUtf8()); + hash.addData(QByteArray::number(info.size())); + hash.addData(QByteArray::number(info.lastModified().toMSecsSinceEpoch())); return hash.result().toHex(); } From a41c0adb3e25f07e5e84e191559455915262c872 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 17:59:23 +0200 Subject: [PATCH 05/18] move carouselview to standalone c++ component, fix crash due to memory leak in wallpaper object creation --- Modules/Launcher/CarouselView.qml | 331 +-------- Modules/Launcher/Items/WallpaperTile.qml | 9 - Modules/Wallpaper/WallBackground.qml | 101 +-- Plugins/ZShell/Components/CMakeLists.txt | 1 + Plugins/ZShell/Components/carouselview.cpp | 785 +++++++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 220 ++++++ Plugins/ZShell/Internal/wallpaperimage.cpp | 285 +++++--- Plugins/ZShell/Internal/wallpaperimage.hpp | 42 +- 8 files changed, 1285 insertions(+), 489 deletions(-) create mode 100644 Plugins/ZShell/Components/carouselview.cpp create mode 100644 Plugins/ZShell/Components/carouselview.hpp diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index f6a3c04..ca11095 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -1,338 +1,55 @@ -pragma ComponentBehavior: Bound - import Quickshell import QtQuick +import ZShell.Components import ZShell.Config import qs.Modules.Launcher.Items import qs.Components import qs.Helpers -Item { +CarouselView { 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)); - } - + // Forwarded verbatim as a property of the same name onto every delegate + // instance (the delegate declares `required property PersistentProperties visibilities`). + delegateProperties: ({ + visibilities: root.visibilities + }) + focalWidth: Config.launcher.sizes.wallpaperWidth + glideDuration: 250 + glideEasingType: Easing.OutCubic implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal - implicitWidth: { + itemSpacing: Tokens.spacing.small + maxWidth: { if (!screen) return 0; - const barMargins = Config.bar.border * 2; let margins = 0; if (visibilities.utilities || visibilities.sidebar) margins = panels.utilities.implicitWidth; - const maxWidth = screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; - - return maxWidth; + return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; + } + minEdgeWidth: 56 + model: values + tileHeight: focalWidth / 16 * 9 + + delegate: Component { + WallpaperTile { + } // your existing delegate file, unchanged } - width: implicitWidth Component.onCompleted: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); - if (idx >= 0) - view.contentX = contentXForIndex(centerBlockStart + idx); + jumpToIndex(idx); } Component.onDestruction: Wallpapers.stopPreview() - - ListView { - id: view - - anchors.fill: parent - boundsBehavior: Flickable.StopAtBounds - cacheBuffer: root.width - 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() + onPreviewIndexChanged: (realIndex, modelData) => { + Wallpapers.preview(modelData.path); } } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 4923ef9..806f89e 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -14,8 +14,6 @@ Item { 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 @@ -31,13 +29,6 @@ Item { 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 diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml index a7851e1..76e44dd 100644 --- a/Modules/Wallpaper/WallBackground.qml +++ b/Modules/Wallpaper/WallBackground.qml @@ -5,103 +5,46 @@ import Quickshell.Hyprland import QtQuick import qs.Components import qs.Helpers -import ZShell.Config import ZShell.Internal Item { id: root - property bool completed - property real cropHeight: displayData?.height ?? 1.0 - property real cropWidth: displayData?.width ?? 1.0 - property real cropX: displayData?.x ?? 0.0 - property real cropY: displayData?.y ?? 0.0 - property WallpaperImage current readonly property var displayData: Wallpapers.getCrop(screen.name) required property ShellScreen screen property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale) property real screenScale: Hyprland.monitorFor(screen).scale - property string source: Wallpapers.current anchors.fill: parent - Component.onCompleted: { - Hyprland.refreshMonitors(); + Component.onCompleted: Hyprland.refreshMonitors() - if (source) - Qt.callLater(() => { - current = imgComp.createObject(this, { - source - }); - completed = true; - }); - } - onSourceChanged: { - if (!source) - current = null; - else - current = imgComp.createObject(this, { - source: source - }); - } + WallpaperImage { + id: img - Component { - id: imgComp + anchors.fill: parent + cropHeight: root.displayData?.height ?? 1.0 + cropWidth: root.displayData?.width ?? 1.0 + cropX: root.displayData?.x ?? 0.0 + cropY: root.displayData?.y ?? 0.0 + fadeDuration: 150 + screenResolution: root.screenResolution + source: Wallpapers.current - WallpaperImage { - id: img - - anchors.fill: parent - cropHeight: root.cropHeight - cropWidth: root.cropWidth - cropX: root.cropX - cropY: root.cropY - opacity: 0 - screenResolution: root.screenResolution - source: root.source - - Behavior on cropHeight { - Anim { - id: heightAnim - } + Behavior on cropHeight { + Anim { } - Behavior on cropWidth { - Anim { - id: widthAnim - } + } + Behavior on cropWidth { + Anim { } - Behavior on cropX { - Anim { - id: xAnim - } + } + Behavior on cropX { + Anim { } - Behavior on cropY { - Anim { - id: yAnim - } - } - Anim on opacity { - id: anim - - from: 0 - running: false - to: 1 - type: Anim.SlowEffects - } - - onStatusChanged: { - if (status === Image.Ready) { - anim.start(); - } - } - - Timer { - id: destroyTimer - - interval: anim.duration * 2 - running: root.current !== img && root.current?.status === Image.Ready - - onTriggered: Qt.callLater(() => img.destroy()) + } + Behavior on cropY { + Anim { } } } diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3edbffc..3d3e4a7 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -4,6 +4,7 @@ qml_module(ZShell-components lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp + carouselview.hpp carouselview.cpp LIBRARIES Qt::Quick ) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp new file mode 100644 index 0000000..e896c39 --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -0,0 +1,785 @@ +#include "carouselview.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +namespace { +constexpr qreal kDragThreshold = 4.0; // px before a press becomes a drag +constexpr qreal kFlickFriction = 0.0022; // px/ms^2 deceleration +constexpr qreal kMinFlickVelocity = 60.0; // px/sec below which we just snap +constexpr qreal kMaxFlickVelocity = 6000.0; +} // namespace + +CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { + setAcceptedMouseButtons(Qt::LeftButton); + setFlag(QQuickItem::ItemHasContents, false); + setClip(false); + + m_glideAnim = new QVariantAnimation(this); + m_glideAnim->setEasingCurve(m_glideEasingType); + connect( + m_glideAnim, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant& v) { + m_contentX = v.toReal(); + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + }); + connect(m_glideAnim, &QVariantAnimation::finished, this, [this]() { + updateCurrentIndexFromContentX(true); + }); + + m_flickAnim = new QVariantAnimation(this); + m_flickAnim->setEasingCurve(QEasingCurve::Linear); + connect( + m_flickAnim, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant& v) { + m_contentX = v.toReal(); + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + }); + connect(m_flickAnim, &QVariantAnimation::finished, this, [this]() { + snapToNearest(); + }); +} + +CarouselView::~CarouselView() { + releaseAllItems(); +} + +// ---------------------------------------------------------------- properties + +void CarouselView::setDelegate(QQmlComponent* c) { + if (m_delegate == c) return; + m_delegate = c; + releaseAllItems(); + emit delegateChanged(); + relayout(); +} + +void CarouselView::setModel(const QVariantList& m) { + m_model = m; + emit modelChanged(); + + // Re-anchor on the currently selected path if possible, otherwise clamp. + int newCount = m_model.size(); + if (newCount == 0) { + m_currentRealIndex = -1; + releaseAllItems(); + rebuildArrangement(); + return; + } + if (!m_initializedLayout) { + m_centerBlockStart = (kLoopMultiplier / 2) * newCount; + m_currentVirtualIndex = m_centerBlockStart; + m_contentX = contentXForIndex(m_currentVirtualIndex); + m_currentRealIndex = realIndexOf(m_currentVirtualIndex); + m_previewRealIndex = m_currentRealIndex; + m_initializedLayout = true; + rebuildArrangement(); + relayout(); + return; + } + + // Model contents changed under us (e.g. search text filtered the list). + // Re-anchor instantly on the clamped selection -- no glide, since this + // isn't a user-initiated navigation. + rebuildArrangement(); + centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); +} + +void CarouselView::setFocalWidth(qreal v) { + if (qFuzzyCompare(m_focalWidth, v)) return; + m_focalWidth = v; + emit focalWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setMinEdgeWidth(qreal v) { + if (qFuzzyCompare(m_minEdgeWidth, v)) return; + m_minEdgeWidth = v; + emit minEdgeWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setItemSpacing(qreal v) { + if (qFuzzyCompare(m_itemSpacing, v)) return; + m_itemSpacing = v; + emit itemSpacingChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setTileHeight(qreal v) { + if (qFuzzyCompare(m_tileHeight, v)) return; + m_tileHeight = v; + emit tileHeightChanged(); + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.item) + QQmlProperty::write( + s.item, QStringLiteral("tileHeight"), m_tileHeight); + } +} + +void CarouselView::setMaxWidth(qreal v) { + if (qFuzzyCompare(m_maxWidth, v)) return; + m_maxWidth = v; + emit maxWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setGlideDuration(int v) { + if (m_glideDuration == v) return; + m_glideDuration = v; + emit glideDurationChanged(); +} + +void CarouselView::setGlideEasingType(QEasingCurve::Type t) { + if (m_glideEasingType == t) return; + m_glideEasingType = t; + m_glideAnim->setEasingCurve(t); + emit glideEasingTypeChanged(); +} + +void CarouselView::setDelegateProperties(const QVariantMap& v) { + m_delegateProperties = v; + emit delegatePropertiesChanged(); + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.item) { + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) + QQmlProperty::write(s.item, it.key(), it.value()); + } + } +} + +void CarouselView::setCurrentIndex(int idx) { + goToIndex(idx); +} + +// ---------------------------------------------------------------- geometry + +void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { + QQuickItem::geometryChange(newGeo, oldGeo); + if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || + !qFuzzyCompare(newGeo.height(), oldGeo.height())) { + // Recentre on the same virtual index rather than letting contentX + // drift, exactly like the QML version's onWidthChanged handler -- + // but here it happens in the same pass as the arrangement rebuild, + // so x/width for every slot are recomputed together. + rebuildArrangement(); + if (m_initializedLayout) + m_contentX = contentXForIndex(m_currentVirtualIndex); + relayout(); + } +} + +// ---------------------------------------------------------------- arrangement math +// Direct port of the QML `arrangement` computed property + keylineCenter/ +// keylineSize/sampleCenter/sampleSize helpers. Kept numerically identical on +// purpose so behaviour doesn't change, just *when* it's applied. + +void CarouselView::rebuildArrangement() { + Arrangement& a = m_arrangement; + a = Arrangement{}; + + // IMPORTANT: this must be the external constraint (m_maxWidth), never + // width() -- width() is a *result* of this computation (see below), and + // using it here would make the arrangement depend on its own previous + // output. On first layout, before width() has ever been set, that + // bootstraps to W==0 -> n==0 -> every side item falls back to a flat + // minEdgeWidth with no taper at all, which is what caused items to slide + // off looking "pushed out" instead of shrinking. + const qreal W = m_maxWidth; + const qreal F = m_focalWidth; + const qreal M = m_minEdgeWidth; + const qreal sp = m_itemSpacing; + + if (W <= 0 || F <= 0) { + a.sizes = {F}; + a.centers = {0}; + return; + } + + int bestN = 0; + QVector bestSizes = {F}; + qreal bestWidth = F; + + for (int n = 1;; ++n) { + const qreal ratio = std::pow(M / F, 1.0 / n); + QVector sizes = {F}; + qreal sideWidth = 0; + + for (int i = 1; i <= n; ++i) { + const qreal size = (i == n) ? M : F * std::pow(ratio, i); + sizes.push_back(size); + sideWidth += size; + } + + const qreal requiredWidth = F + 2 * n * sp + 2 * sideWidth; + if (requiredWidth > W) break; + + bestN = n; + bestSizes = sizes; + bestWidth = requiredWidth; + + // Safety valve: arrangement size is bounded by how many halvings fit, + // this just guards against pathological inputs (F <= M etc). + if (n > 64) break; + } + + a.n = bestN; + a.sizes = bestSizes; + a.width = bestWidth; + + QVector centers = {0}; + qreal center = 0; + for (int i = 1; i <= bestN; ++i) { + center += bestSizes[i - 1] / 2.0 + sp + bestSizes[i] / 2.0; + centers.push_back(center); + } + a.centers = centers; + + qreal extra = 0; + for (int i = 1; i <= bestN; ++i) + extra += pitch() - bestSizes[i]; + a.effectiveExtraWidth = extra; + + setImplicitWidth(m_maxWidth); + // This mirrors the original `width: arrangement.width` binding. It's + // safe against feedback because the arrangement above only ever reads + // m_maxWidth, never width() -- so if a.width doesn't change, setWidth() + // here is a no-op and geometryChange() won't recurse. + setWidth(a.width); +} + +qreal CarouselView::keylineCenter(int io) const { + const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return 0; + const int sign = io < 0 ? -1 : 1; + + if (i <= n) return sign * m_arrangement.centers[i]; + + if (i == n + 1) { + const qreal lastCenter = m_arrangement.centers[n]; + const qreal lastSize = m_arrangement.sizes[n]; + return sign * (lastCenter + lastSize / 2.0 + m_itemSpacing + + m_minEdgeWidth / 2.0); + } + + const qreal outgoingCenter = std::abs(keylineCenter(sign * (n + 1))); + return sign * (outgoingCenter + m_minEdgeWidth / 2.0 + m_itemSpacing); +} + +qreal CarouselView::keylineSize(int io) const { + const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return m_focalWidth; + if (i <= n) return m_arrangement.sizes[i]; + if (i == n + 1) return m_minEdgeWidth; + return 0; +} + +qreal CarouselView::sampleCenter(qreal childLoc) const { + const int n = m_arrangement.n; + if (n <= 0) return 0; + + const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); + + const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineCenter(minIo); + if (a >= maxIo) return keylineCenter(maxIo); + + const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineCenter(a) + (keylineCenter(b) - keylineCenter(a)) * t; +} + +qreal CarouselView::sampleSize(qreal childLoc) const { + const int n = m_arrangement.n; + if (n <= 0) return 0; + + const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); + + const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineSize(minIo); + if (a >= maxIo) return keylineSize(maxIo); + + const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; +} + +// ---------------------------------------------------------------- index math + +int CarouselView::realIndexOf(int virtualIndex) const { + const int count = m_model.size(); + if (count == 0) return -1; + return ((virtualIndex % count) + count) % count; +} + +qreal CarouselView::contentXForIndex(qreal index) const { + return index * pitch() + pitch() / 2.0 - width() / 2.0; +} + +int CarouselView::indexForContentX(qreal x) const { + return static_cast( + std::round((x + width() / 2.0 - pitch() / 2.0) / pitch())); +} + +qreal CarouselView::snapTargetX(qreal x) const { + return contentXForIndex(indexForContentX(x)); +} + +void CarouselView::wrapContentIfNeeded() { + const int count = m_model.size(); + if (count == 0) return; + + const qreal blockWidth = count * pitch(); + const qreal centerX = contentXForIndex(m_centerBlockStart); + const int blocksFromCenter = + static_cast(std::round((m_contentX - centerX) / blockWidth)); + + if (std::abs(blocksFromCenter) >= 2) { + const qreal shift = blocksFromCenter * blockWidth; + m_contentX -= shift; + + if (m_glideAnim->state() == QAbstractAnimation::Running) { + const qreal newEnd = m_glideAnim->endValue().toReal() - shift; + m_glideAnim->setEndValue(newEnd); + } + if (m_flickAnim->state() == QAbstractAnimation::Running) { + const qreal newEnd = m_flickAnim->endValue().toReal() - shift; + m_flickAnim->setEndValue(newEnd); + } + // Also shift every currently-live slot's virtual index bookkeeping so + // it doesn't think it needs to be recycled just because of the wrap. + const int indexShift = static_cast(std::round(shift / pitch())); + for (Slot& s : m_slots) { + if (s.active) s.virtualIndex -= indexShift; + } + } +} + +void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { + const int count = m_model.size(); + if (count == 0) return; + + const int newVirtual = static_cast( + std::round((m_contentX + width() / 2.0 - pitch() / 2.0) / pitch())); + if (newVirtual == m_currentVirtualIndex) return; + + m_currentVirtualIndex = newVirtual; + const int newReal = realIndexOf(newVirtual); + + if (newReal != m_currentRealIndex) { + m_currentRealIndex = newReal; + emit currentIndexChanged(); + } + if (emitPreview && newReal != m_previewRealIndex) { + m_previewRealIndex = newReal; + if (newReal >= 0) + emit previewIndexChanged(newReal, m_model.at(newReal)); + } +} + +// ---------------------------------------------------------------- pool / delegates + +QQuickItem* CarouselView::acquireItem() { + if (!m_delegate) return nullptr; + + QQmlContext* ctx = QQmlEngine::contextForObject(this); + QVariantMap initial; + initial.insert(QStringLiteral("isCurrent"), false); + initial.insert(QStringLiteral("modelData"), QVariant()); + initial.insert(QStringLiteral("tileHeight"), m_tileHeight); + initial.insert(QStringLiteral("tileWidth"), m_focalWidth); + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) + initial.insert(it.key(), it.value()); + + QObject* obj = m_delegate->createWithInitialProperties(initial, ctx); + if (!obj) { + qWarning( + "CarouselView: failed to create delegate instance: %s", + qPrintable(m_delegate->errorString())); + return nullptr; + } + QQuickItem* item = qobject_cast(obj); + if (!item) { + qWarning("CarouselView: delegate root is not an Item"); + delete obj; + return nullptr; + } + + item->setParentItem(this); + QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); + + const QMetaMethod activateSignal = item->metaObject()->method( + item->metaObject()->indexOfSignal("requestActivate()")); + if (activateSignal.isValid()) { + static const QMetaMethod slot = metaObject()->method( + metaObject()->indexOfSlot("handleDelegateActivate()")); + connect(item, activateSignal, this, slot); + } + + return item; +} + +void CarouselView::applyPropertiesToItem(QQuickItem* item, int realIndex) const { + if (!item) return; + const bool isCurrent = (realIndex == m_currentRealIndex); + QQmlProperty::write(item, QStringLiteral("isCurrent"), isCurrent); + QQmlProperty::write( + item, + QStringLiteral("modelData"), + realIndex >= 0 && realIndex < m_model.size() ? m_model.at(realIndex) + : QVariant()); + QQmlProperty::write(item, QStringLiteral("tileHeight"), m_tileHeight); +} + +void CarouselView::updateSlotContent(Slot& slot, int virtualIndex) { + if (!slot.item) slot.item = acquireItem(); + if (!slot.item) return; + + slot.virtualIndex = virtualIndex; + slot.realIndex = realIndexOf(virtualIndex); + slot.active = slot.realIndex >= 0; + slot.item->setVisible(slot.active); + if (slot.active) applyPropertiesToItem(slot.item, slot.realIndex); +} + +void CarouselView::positionSlot(const Slot& slot) const { + if (!slot.item || !slot.active) return; + + const qreal childLoc = slot.virtualIndex * pitch() + pitch() / 2.0 - + m_contentX - width() / 2.0; + const qreal center = sampleCenter(childLoc); + const qreal size = sampleSize(childLoc); + + // slot's "column" spans one pitch, centred at width()/2 + center; item is + // centred within that column, matching the QML delegate's x expression. + const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; + const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; + + QQmlProperty::write(slot.item, QStringLiteral("tileWidth"), size); + slot.item->setX(itemX); + slot.item->setY(0); + slot.item->setVisible(size > 0.5); +} + +void CarouselView::releaseAllItems() { + for (Slot& s : m_slots) { + if (s.item) s.item->deleteLater(); + } + m_slots.clear(); +} + +// ---------------------------------------------------------------- layout pass + +void CarouselView::relayout() { + const int count = m_model.size(); + if (count == 0 || width() <= 0 || m_focalWidth <= 0) { + for (Slot& s : m_slots) + if (s.item) s.item->setVisible(false); + return; + } + + const int n = m_arrangement.n; + // Window of virtual indices that can ever be visible, plus a small buffer + // so items don't pop in/out right at the edge during a fast flick. + const int half = n + 3; + const int lo = m_currentVirtualIndex - half; + const int hi = m_currentVirtualIndex + half; + const int needed = hi - lo + 1; + + // Grow the pool if needed (shrinking is unnecessary: idle items are just + // marked inactive/invisible, which is cheap and avoids churn). + while (m_slots.size() < needed) + m_slots.append(Slot{}); + + // Figure out which virtual indices are already backed by a slot. + QHash virtualToSlot; // virtualIndex -> slot list position + virtualToSlot.reserve(m_slots.size()); + for (int i = 0; i < m_slots.size(); ++i) { + if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); + } + + QVector slotUsedThisPass(m_slots.size(), false); + + for (int v = lo; v <= hi; ++v) { + auto it = virtualToSlot.find(v); + if (it != virtualToSlot.end()) { + slotUsedThisPass[it.value()] = true; + // Content (isCurrent / modelData) may still need refreshing if + // currentRealIndex changed since last pass. + applyPropertiesToItem( + m_slots[it.value()].item, m_slots[it.value()].realIndex); + continue; + } + + // find a free slot: one not used this pass and not already at a + // virtual index inside [lo, hi] (those are all still needed). + int freeSlot = -1; + for (int i = 0; i < m_slots.size(); ++i) { + if (slotUsedThisPass[i]) continue; + if (m_slots[i].active && m_slots[i].virtualIndex >= lo && + m_slots[i].virtualIndex <= hi) + continue; + freeSlot = i; + break; + } + if (freeSlot < 0) { + // Shouldn't happen given the pool growth above, but guard anyway. + m_slots.append(Slot{}); + slotUsedThisPass.append(false); + freeSlot = m_slots.size() - 1; + } + + updateSlotContent(m_slots[freeSlot], v); + slotUsedThisPass[freeSlot] = true; + } + + for (int i = 0; i < m_slots.size(); ++i) { + if (!slotUsedThisPass[i] && m_slots[i].item) { + m_slots[i].active = false; + m_slots[i].item->setVisible(false); + } + } + + for (const Slot& s : std::as_const(m_slots)) + positionSlot(s); +} + +// ---------------------------------------------------------------- animation + +void CarouselView::cancelAnimations() { + if (m_glideAnim->state() == QAbstractAnimation::Running) + m_glideAnim->stop(); + if (m_flickAnim->state() == QAbstractAnimation::Running) + m_flickAnim->stop(); +} + +void CarouselView::glideTo(qreal dest) { + if (std::abs(dest - m_contentX) < 0.5) return; + + cancelAnimations(); + m_glideAnim->setDuration(m_glideDuration); + m_glideAnim->setStartValue(m_contentX); + m_glideAnim->setEndValue(dest); + m_glideAnim->start(); +} + +void CarouselView::snapToNearest() { + glideTo(snapTargetX(m_contentX)); +} + +// ---------------------------------------------------------------- public API + +void CarouselView::goToIndex(int realIndex) { + const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); + + const int curBlock = + static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; + + if (target - current > count / 2) + target -= count; + else if (current - target > count / 2) + target += count; + + glideTo(contentXForIndex(target)); +} + +void CarouselView::incrementCurrentIndex() { + if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex + 1)); +} + +void CarouselView::decrementCurrentIndex() { + if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex - 1)); +} + +void CarouselView::jumpToIndex(int realIndex) { + centerInstantlyOnReal(realIndex); +} + +void CarouselView::centerInstantlyOnReal(int realIndex) { + const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); + + cancelAnimations(); + + const int curBlock = + static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; + + if (target - current > count / 2) + target -= count; + else if (current - target > count / 2) + target += count; + + m_currentVirtualIndex = target; + m_contentX = contentXForIndex(target); + + const bool changed = (realIndex != m_currentRealIndex); + m_currentRealIndex = realIndex; + m_previewRealIndex = realIndex; + if (changed) emit currentIndexChanged(); + emit previewIndexChanged(realIndex, m_model.at(realIndex)); + + relayout(); +} + +// ---------------------------------------------------------------- input + +void CarouselView::mousePressEvent(QMouseEvent* event) { + // Deliberately do NOT cancel animations here. A press that turns out to + // be a plain tap (never crosses the drag threshold) should leave any + // in-flight glide/flick completely untouched. We only touch the + // animation once we know it's actually a drag (see mouseMoveEvent). + m_dragging = true; + m_dragActive = false; + m_pressPos = event->position(); + // Capture wherever contentX currently is, even mid-animation -- if this + // does turn into a drag, it should pick up smoothly from the visual + // position, not teleport to some rest position first. + m_pressContentX = m_contentX; + m_dragTimer.start(); + m_lastMoveX = event->position().x(); + m_lastMoveT = 0; + m_velocity = 0; + event->accept(); +} + +void CarouselView::mouseMoveEvent(QMouseEvent* event) { + if (!m_dragging) return; + + const qreal dx = event->position().x() - m_pressPos.x(); + if (!m_dragActive && std::abs(dx) > kDragThreshold) { + m_dragActive = true; + m_pressContentX = + m_contentX; // re-anchor to the live (possibly mid-animation) value + cancelAnimations(); + grabMouse(); + } + if (!m_dragActive) return; + + m_contentX = m_pressContentX - dx; + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + + const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + if (dt > 0) { + const qreal instVel = + (m_lastMoveX - event->position().x()) / (dt / 1000.0); + // Light smoothing so a single jittery sample doesn't dominate the flick. + m_velocity = m_velocity * 0.7 + instVel * 0.3; + } + m_lastMoveX = event->position().x(); + m_lastMoveT = t; + + event->accept(); +} + +void CarouselView::mouseReleaseEvent(QMouseEvent* event) { + if (!m_dragging) return; + m_dragging = false; + ungrabMouse(); + + if (!m_dragActive) { + // A plain click/tap with no drag: let it fall through to the + // delegate's own StateLayer/MouseArea via requestActivate(). + event->accept(); + return; + } + m_dragActive = false; + + qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + if (std::abs(v) < kMinFlickVelocity) { + snapToNearest(); + event->accept(); + return; + } + + // Simple constant-deceleration flick: distance = v^2 / (2*friction). + const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms + const qreal distance = + (v * (duration / 1000.0)) / 2.0; // average-velocity approximation + const qreal rawDest = m_contentX + distance; + const qreal dest = snapTargetX(rawDest); + + cancelAnimations(); + m_flickAnim->setDuration( + static_cast(qBound(120, duration, 900))); + m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); + m_flickAnim->setStartValue(m_contentX); + m_flickAnim->setEndValue(dest); + m_flickAnim->start(); + + event->accept(); +} + +void CarouselView::mouseUngrabEvent() { + m_dragging = false; + m_dragActive = false; +} + +void CarouselView::wheelEvent(QWheelEvent* event) { + const int steps = event->angleDelta().y() / 120; + if (steps > 0) + decrementCurrentIndex(); + else if (steps < 0) + incrementCurrentIndex(); + event->accept(); +} + +// ---------------------------------------------------------------- delegate signal + +void CarouselView::handleDelegateActivate() { + QQuickItem* item = qobject_cast(sender()); + if (!item) return; + + for (const Slot& s : std::as_const(m_slots)) { + if (s.item == item && s.active) { + if (s.realIndex == m_currentRealIndex) { + emit activated(s.realIndex, m_model.at(s.realIndex)); + } else { + goToIndex(s.realIndex); + } + return; + } + } +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp new file mode 100644 index 0000000..23a8e36 --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -0,0 +1,220 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class CarouselView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged) + Q_PROPERTY(QVariantList model READ model WRITE setModel NOTIFY modelChanged) + + Q_PROPERTY( + qreal focalWidth READ focalWidth WRITE setFocalWidth NOTIFY + focalWidthChanged) + Q_PROPERTY( + qreal minEdgeWidth READ minEdgeWidth WRITE setMinEdgeWidth NOTIFY + minEdgeWidthChanged) + Q_PROPERTY( + qreal itemSpacing READ itemSpacing WRITE setItemSpacing NOTIFY + itemSpacingChanged) + Q_PROPERTY( + qreal tileHeight READ tileHeight WRITE setTileHeight NOTIFY + tileHeightChanged) + Q_PROPERTY( + qreal maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged) + + Q_PROPERTY( + int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY + currentIndexChanged) + Q_PROPERTY( + int glideDuration READ glideDuration WRITE setGlideDuration NOTIFY + glideDurationChanged) + Q_PROPERTY( + QEasingCurve::Type glideEasingType READ glideEasingType WRITE + setGlideEasingType NOTIFY glideEasingTypeChanged) + + // Extra properties (e.g. { visibilities: root.visibilities }) forwarded + // verbatim onto every delegate instance as a property of the same name. + Q_PROPERTY( + QVariantMap delegateProperties READ delegateProperties WRITE + setDelegateProperties NOTIFY delegatePropertiesChanged) + + public: + explicit CarouselView(QQuickItem* parent = nullptr); + ~CarouselView() override; + + QQmlComponent* delegate() const { return m_delegate; } + void setDelegate(QQmlComponent* c); + + QVariantList model() const { return m_model; } + void setModel(const QVariantList& m); + + qreal focalWidth() const { return m_focalWidth; } + void setFocalWidth(qreal v); + + qreal minEdgeWidth() const { return m_minEdgeWidth; } + void setMinEdgeWidth(qreal v); + + qreal itemSpacing() const { return m_itemSpacing; } + void setItemSpacing(qreal v); + + qreal tileHeight() const { return m_tileHeight; } + void setTileHeight(qreal v); + + qreal maxWidth() const { return m_maxWidth; } + void setMaxWidth(qreal v); + + int currentIndex() const { return m_currentRealIndex; } + void setCurrentIndex(int idx); // jumps (animated glide) to a real index + + int glideDuration() const { return m_glideDuration; } + void setGlideDuration(int v); + + QEasingCurve::Type glideEasingType() const { return m_glideEasingType; } + void setGlideEasingType(QEasingCurve::Type t); + + QVariantMap delegateProperties() const { return m_delegateProperties; } + void setDelegateProperties(const QVariantMap& v); + + // Animated (glides). + Q_INVOKABLE void goToIndex(int realIndex); + Q_INVOKABLE void incrementCurrentIndex(); + Q_INVOKABLE void decrementCurrentIndex(); + + // Instant, no animation -- use for initial placement (e.g. restoring the + // saved wallpaper on Component.onCompleted) where a glide would be wrong. + Q_INVOKABLE void jumpToIndex(int realIndex); + + signals: + void delegateChanged(); + void modelChanged(); + void focalWidthChanged(); + void minEdgeWidthChanged(); + void itemSpacingChanged(); + void tileHeightChanged(); + void maxWidthChanged(); + void currentIndexChanged(); + void glideDurationChanged(); + void glideEasingTypeChanged(); + void delegatePropertiesChanged(); + + // Emitted when the currently-centred delegate is clicked while already + // centred (i.e. "activate/select this one"). + void activated(int realIndex, QVariant modelData); + // Emitted continuously as the centred item changes, for preview-on-scroll + // behaviour (used to drive Wallpapers.preview() from QML). + void previewIndexChanged(int realIndex, QVariant modelData); + + protected: + void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void mouseUngrabEvent() override; + void wheelEvent(QWheelEvent* event) override; + + private slots: + void handleDelegateActivate(); + + private: + struct Arrangement { + int n = 0; + qreal width = 0; + QVector sizes; // sizes[0] = focal, sizes[i] = i-th side item + QVector + centers; // centers[0] = 0, centers[i] = i-th side centre offset + qreal effectiveExtraWidth = 0; + }; + + struct Slot { + QQuickItem* item = nullptr; + int virtualIndex = + 0; // which "virtual" (unbounded) index this slot currently renders + int realIndex = -1; // resolved model index, -1 if none / hidden + bool active = false; + }; + + void rebuildArrangement(); + void relayout(); // recomputes window + positions every slot (one pass) + void updateSlotContent(Slot& slot, int virtualIndex); + void positionSlot(const Slot& slot) const; + void applyPropertiesToItem(QQuickItem* item, int realIndex) const; + + qreal pitch() const { return m_focalWidth + m_itemSpacing; } + qreal contentXForIndex(qreal index) const; + int indexForContentX(qreal x) const; + qreal snapTargetX(qreal x) const; + void snapToNearest(); + void glideTo(qreal dest); + void cancelAnimations(); + + qreal keylineCenter(int io) const; + qreal keylineSize(int io) const; + qreal sampleCenter(qreal childLoc) const; + qreal sampleSize(qreal childLoc) const; + + int realIndexOf(int virtualIndex) const; + void updateCurrentIndexFromContentX(bool emitPreview); + void wrapContentIfNeeded(); + void centerInstantlyOnReal(int realIndex); // no animation, no glide + + QQuickItem* acquireItem(); + void releaseAllItems(); + + QQmlComponent* m_delegate = nullptr; + QVariantList m_model; + QVariantMap m_delegateProperties; + + qreal m_focalWidth = 0; + qreal m_minEdgeWidth = 56; + qreal m_itemSpacing = 8; + qreal m_tileHeight = 0; + qreal m_maxWidth = 0; + + Arrangement m_arrangement; + + qreal m_contentX = 0; + int m_currentVirtualIndex = 0; + int m_currentRealIndex = -1; + int m_previewRealIndex = -1; + + static constexpr int kLoopMultiplier = 401; + int m_centerBlockStart = 0; + + QList + m_slots; // pooled delegate slots, unordered; window recomputed each relayout + + int m_glideDuration = 250; + QEasingCurve::Type m_glideEasingType = QEasingCurve::OutCubic; + QVariantAnimation* m_glideAnim = nullptr; + + // Drag / flick state + bool m_dragging = false; + bool m_dragActive = false; // exceeded the drag threshold + QPointF m_pressPos; + qreal m_pressContentX = 0; + QElapsedTimer m_dragTimer; + qreal m_lastMoveX = 0; + qint64 m_lastMoveT = 0; + qreal m_velocity = 0; // px/sec + QVariantAnimation* m_flickAnim = nullptr; + + bool m_initializedLayout = false; + bool m_relayoutQueued = false; +}; + +}; // namespace ZShell::components diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Internal/wallpaperimage.cpp index e0e6da9..b6ad891 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Internal/wallpaperimage.cpp @@ -4,14 +4,31 @@ #include #include #include +#include #include -#include #include namespace ZShell::internal { +QThreadPool* WallpaperImage::loadPool() { + static QThreadPool* pool = [] { + auto* p = new QThreadPool(); + p->setMaxThreadCount(4); + return p; + }(); + return pool; +} + WallpaperImage::WallpaperImage(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents, true); + + m_loadDebounceTimer.setSingleShot(true); + connect( + &m_loadDebounceTimer, + &QTimer::timeout, + this, + &WallpaperImage::beginLoad); + connect( &m_imageWatcher, &QFutureWatcher::finished, @@ -20,12 +37,15 @@ WallpaperImage::WallpaperImage(QQuickItem* parent) : QQuickItem(parent) { } WallpaperImage::~WallpaperImage() { + m_requestGeneration->fetch_add(1, std::memory_order_relaxed); + m_imageWatcher.disconnect(); + if (m_texture) delete m_texture; + if (m_previousTexture) delete m_previousTexture; } void WallpaperImage::setStatus(const Status s) { if (m_status == s) return; - m_status = s; emit statusChanged(); } @@ -36,14 +56,14 @@ void WallpaperImage::setSource(const QUrl& source) { emit sourceChanged(); setStatus(Loading); - loadImage(); + m_loadDebounceTimer.start(80); } void WallpaperImage::setScreenResolution(const QSize& screenResolution) { if (m_screenResolution == screenResolution) return; m_screenResolution = screenResolution; emit screenResolutionChanged(); - loadImage(); + beginLoad(); } void WallpaperImage::setZoom(qreal zoom) { @@ -53,6 +73,13 @@ void WallpaperImage::setZoom(qreal zoom) { update(); } +void WallpaperImage::setFadeDuration(int ms) { + if (ms < 0) ms = 0; + if (m_fadeDuration == ms) return; + m_fadeDuration = ms; + emit fadeDurationChanged(); +} + void WallpaperImage::setCropX(qreal x) { if (qFuzzyCompare(m_cropX, x)) return; m_cropX = x; @@ -100,143 +127,227 @@ QString WallpaperImage::getCacheFilePath() const { return cachePath + "/" + hash + ".png"; } -void WallpaperImage::loadImage() { +void WallpaperImage::beginLoad() { if (m_source.isEmpty()) { setStatus(Null); return; } + const quint64 requestId = + m_requestGeneration->fetch_add(1, std::memory_order_relaxed) + 1; + QString cacheFile = getCacheFilePath(); QString sourceFile = m_source.isLocalFile() ? m_source.toLocalFile() : m_source.toString(); - - if (sourceFile.startsWith("qrc:/")) { - sourceFile = sourceFile.mid(3); - } + if (sourceFile.startsWith("qrc:/")) sourceFile = sourceFile.mid(3); QSize targetRes = m_screenResolution; + auto generation = m_requestGeneration; + + QFuture future = QtConcurrent::run( + loadPool(), + [sourceFile, cacheFile, targetRes, requestId, generation]() -> QImage { + auto stale = [&] { + return generation->load(std::memory_order_relaxed) != requestId; + }; + + if (stale()) return QImage(); - QFuture future = - QtConcurrent::run([sourceFile, cacheFile, targetRes]() -> QImage { if (!targetRes.isEmpty() && !cacheFile.isEmpty() && QFileInfo::exists(cacheFile)) { QImage cached(cacheFile); if (!cached.isNull()) return cached; } - QImage original(sourceFile); + if (stale()) return QImage(); + + QImage original; + QImageReader reader(sourceFile); + if (!targetRes.isEmpty() && + reader.supportsOption(QImageIOHandler::Size)) { + QSize actual = reader.size(); + if (actual.isValid() && + (actual.width() > targetRes.width() || + actual.height() > targetRes.height())) { + QSize scaledSize = + actual.scaled(targetRes, Qt::KeepAspectRatioByExpanding); + reader.setScaledSize(scaledSize); + } + original = reader.read(); + } else { + original = QImage(sourceFile); + } if (original.isNull()) return QImage(); - if (targetRes.isEmpty()) { - return original; - } + if (stale()) return QImage(); - if (original.width() > targetRes.width() || - original.height() > targetRes.height()) { - QImage scaled = original.scaled( - targetRes, - Qt::KeepAspectRatioByExpanding, - Qt::SmoothTransformation); - if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG"); - return scaled; - } + if (targetRes.isEmpty()) return original; - if (!cacheFile.isEmpty()) original.save(cacheFile, "PNG"); - return original; + QImage scaled = (original.width() > targetRes.width() || + original.height() > targetRes.height()) + ? original.scaled( + targetRes, + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation) + : original; + + if (stale()) return scaled; + + if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG"); + return scaled; }); m_imageWatcher.setFuture(future); } void WallpaperImage::handleImageLoaded() { - m_image = m_imageWatcher.result(); + QImage result = m_imageWatcher.result(); + if (result.isNull()) { + setStatus(Error); + return; + } - setStatus(m_image.isNull() ? Error : Ready); - - m_textureDirty = true; + m_pendingImage = result; + m_hasPending = true; + setStatus(Ready); update(); } -QSGNode* WallpaperImage::updatePaintNode( - QSGNode* oldNode, UpdatePaintNodeData*) { - auto* node = static_cast(oldNode); +QRectF WallpaperImage::computeSourceRect(QSGTexture* texture) const { + QSize texSize = texture->textureSize(); - if (m_image.isNull()) { - delete node; - return nullptr; + qreal cW = m_cropWidth / m_zoom; + qreal cH = m_cropHeight / m_zoom; + + QRectF reqRect( + m_cropX * texSize.width(), + m_cropY * texSize.height(), + cW * texSize.width(), + cH * texSize.height()); + + QRectF bounds = boundingRect(); + if (bounds.isEmpty() || reqRect.isEmpty()) return reqRect; + + qreal targetRatio = bounds.width() / bounds.height(); + qreal reqRatio = reqRect.width() / reqRect.height(); + + QRectF sourceRect = reqRect; + + if (reqRatio > targetRatio) { + qreal newWidth = reqRect.height() * targetRatio; + qreal xOffset = (reqRect.width() - newWidth) / 2.0; + sourceRect.setX(reqRect.x() + xOffset); + sourceRect.setWidth(newWidth); + } else if (reqRatio < targetRatio) { + qreal newHeight = reqRect.width() / targetRatio; + qreal yOffset = (reqRect.height() - newHeight) / 2.0; + sourceRect.setY(reqRect.y() + yOffset); + sourceRect.setHeight(newHeight); } - if (!node) { - node = window()->createImageNode(); - } + return sourceRect; +} - if (m_textureDirty) { - if (m_texture) delete m_texture; - m_texture = window()->createTextureFromImage( - m_image, QQuickWindow::TextureHasAlphaChannel); - m_textureDirty = false; - } +void WallpaperImage::configureImageNode( + QSGImageNode* node, QSGTexture* texture) { + node->setTexture(texture); + node->setRect(boundingRect()); + node->setFiltering(QSGTexture::Linear); - if (m_texture) { - node->setTexture(m_texture); - node->setRect(boundingRect()); - node->setFiltering(QSGTexture::Linear); - - qreal cW = m_cropWidth / m_zoom; - qreal cH = m_cropHeight / m_zoom; - - QRectF reqRect( - m_cropX * m_texture->textureSize().width(), - m_cropY * m_texture->textureSize().height(), - cW * m_texture->textureSize().width(), - cH * m_texture->textureSize().height()); - - QRectF bounds = boundingRect(); - if (bounds.isEmpty() || reqRect.isEmpty()) return node; - - qreal targetRatio = bounds.width() / bounds.height(); - qreal reqRatio = reqRect.width() / reqRect.height(); - - QRectF sourceRect = reqRect; - - if (reqRatio > targetRatio) { - qreal newWidth = reqRect.height() * targetRatio; - qreal xOffset = (reqRect.width() - newWidth) / 2.0; - sourceRect.setX(reqRect.x() + xOffset); - sourceRect.setWidth(newWidth); - } else if (reqRatio < targetRatio) { - qreal newHeight = reqRect.width() / targetRatio; - qreal yOffset = (reqRect.height() - newHeight) / 2.0; - sourceRect.setY(reqRect.y() + yOffset); - sourceRect.setHeight(newHeight); - } + QRectF sourceRect = computeSourceRect(texture); + node->setSourceRect(sourceRect); + if (texture == m_texture) { + QSize texSize = texture->textureSize(); QRectF normalizedActual( - sourceRect.x() / m_texture->textureSize().width(), - sourceRect.y() / m_texture->textureSize().height(), - sourceRect.width() / m_texture->textureSize().width(), - sourceRect.height() / m_texture->textureSize().height()); + sourceRect.x() / texSize.width(), + sourceRect.y() / texSize.height(), + sourceRect.width() / texSize.width(), + sourceRect.height() / texSize.height()); bool changed = false; - auto updateIfChanged = [&](qreal& dst, qreal value) { if (!qFuzzyCompare(dst, value)) { dst = value; changed = true; } }; - updateIfChanged(m_actualCropX, normalizedActual.x()); updateIfChanged(m_actualCropY, normalizedActual.y()); updateIfChanged(m_actualCropWidth, normalizedActual.width()); updateIfChanged(m_actualCropHeight, normalizedActual.height()); - if (changed) emit actualCropChanged(); + } +} - node->setSourceRect(sourceRect); +QSGNode* WallpaperImage::updatePaintNode( + QSGNode* oldNode, UpdatePaintNodeData*) { + Q_UNUSED(oldNode); + + if (m_hasPending) { + if (m_previousTexture) { + delete m_previousTexture; + m_previousTexture = nullptr; + } + m_previousTexture = m_texture; + m_texture = window()->createTextureFromImage( + m_pendingImage, QQuickWindow::TextureHasAlphaChannel); + m_pendingImage = QImage(); + m_hasPending = false; + + if (m_previousTexture && m_fadeDuration > 0) { + m_fading = true; + m_fadeTimer.restart(); + } else { + if (m_previousTexture) { + delete m_previousTexture; + m_previousTexture = nullptr; + } + m_fading = false; + } } - return node; + if (!m_texture) { + return m_rootNode; + } + + if (!m_rootNode) { + m_rootNode = new QSGNode(); + + m_newOpacityNode = new QSGOpacityNode(); + m_newImageNode = window()->createImageNode(); + m_newOpacityNode->appendChildNode(m_newImageNode); + m_rootNode->appendChildNode(m_newOpacityNode); + + m_oldOpacityNode = new QSGOpacityNode(); + m_oldImageNode = window()->createImageNode(); + m_oldOpacityNode->appendChildNode(m_oldImageNode); + m_rootNode->appendChildNode(m_oldOpacityNode); + } + + configureImageNode(m_newImageNode, m_texture); + m_newOpacityNode->setOpacity(1.0); + + if (m_fading && m_previousTexture) { + qreal progress = + qBound(0.0, qreal(m_fadeTimer.elapsed()) / m_fadeDuration, 1.0); + + configureImageNode(m_oldImageNode, m_previousTexture); + m_oldOpacityNode->setOpacity(1.0 - progress); + + if (progress >= 1.0) { + m_fading = false; + delete m_previousTexture; + m_previousTexture = nullptr; + m_oldOpacityNode->setOpacity(0.0); + } else { + update(); + } + } else { + m_oldOpacityNode->setOpacity(0.0); + } + + return m_rootNode; } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperimage.hpp b/Plugins/ZShell/Internal/wallpaperimage.hpp index 9c0d2a9..6ecf562 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.hpp +++ b/Plugins/ZShell/Internal/wallpaperimage.hpp @@ -4,9 +4,15 @@ #include #include #include +#include +#include #include +#include +#include +#include #include #include +#include namespace ZShell::internal { @@ -19,6 +25,9 @@ class WallpaperImage : public QQuickItem { NOTIFY screenResolutionChanged) Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) Q_PROPERTY(Status status READ status NOTIFY statusChanged) + Q_PROPERTY( + int fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) Q_PROPERTY(qreal actualCropX READ actualCropX NOTIFY actualCropChanged) Q_PROPERTY(qreal actualCropY READ actualCropY NOTIFY actualCropChanged) @@ -54,12 +63,12 @@ class WallpaperImage : public QQuickItem { qreal zoom() const { return m_zoom; } void setZoom(qreal zoom); + int fadeDuration() const { return m_fadeDuration; } + void setFadeDuration(int ms); + qreal actualCropX() const { return m_actualCropX; } - qreal actualCropY() const { return m_actualCropY; } - qreal actualCropWidth() const { return m_actualCropWidth; } - qreal actualCropHeight() const { return m_actualCropHeight; } qreal cropX() const { return m_cropX; } @@ -82,6 +91,7 @@ class WallpaperImage : public QQuickItem { void sourceChanged(); void screenResolutionChanged(); void zoomChanged(); + void fadeDurationChanged(); void actualCropChanged(); void cropXChanged(); void cropYChanged(); @@ -90,15 +100,18 @@ class WallpaperImage : public QQuickItem { void statusChanged(); private: - void loadImage(); + void beginLoad(); void handleImageLoaded(); QString getCacheFilePath() const; void setStatus(const Status s); + QRectF computeSourceRect(QSGTexture* texture) const; + void configureImageNode(QSGImageNode* node, QSGTexture* texture); Status m_status = Null; QUrl m_source; QSize m_screenResolution; qreal m_zoom = 1.0; + int m_fadeDuration = 400; qreal m_actualCropX = 0.0; qreal m_actualCropY = 0.0; @@ -110,10 +123,25 @@ class WallpaperImage : public QQuickItem { qreal m_cropWidth = 1.0; qreal m_cropHeight = 1.0; - QImage m_image; - QSGTexture* m_texture = nullptr; - bool m_textureDirty = false; + QTimer m_loadDebounceTimer; QFutureWatcher m_imageWatcher; + std::shared_ptr> m_requestGeneration = + std::make_shared>(0); + static QThreadPool* loadPool(); + QImage m_pendingImage; + bool m_hasPending = false; + + QSGTexture* m_texture = nullptr; + QSGTexture* m_previousTexture = nullptr; + + bool m_fading = false; + QElapsedTimer m_fadeTimer; + + QSGNode* m_rootNode = nullptr; + QSGOpacityNode* m_newOpacityNode = nullptr; + QSGOpacityNode* m_oldOpacityNode = nullptr; + QSGImageNode* m_newImageNode = nullptr; + QSGImageNode* m_oldImageNode = nullptr; }; } // namespace ZShell::internal From 654632128f411807d7429dc7bf9608687604bed5 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 18:14:04 +0200 Subject: [PATCH 06/18] clean-up plugin files --- Plugins/ZShell/Components/carouselview.cpp | 83 ++-------------------- Plugins/ZShell/Components/carouselview.hpp | 33 +++------ 2 files changed, 16 insertions(+), 100 deletions(-) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index e896c39..e7a8bda 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -11,9 +11,9 @@ namespace ZShell::components { namespace { -constexpr qreal kDragThreshold = 4.0; // px before a press becomes a drag -constexpr qreal kFlickFriction = 0.0022; // px/ms^2 deceleration -constexpr qreal kMinFlickVelocity = 60.0; // px/sec below which we just snap +constexpr qreal kDragThreshold = 4.0; +constexpr qreal kFlickFriction = 0.0022; +constexpr qreal kMinFlickVelocity = 60.0; constexpr qreal kMaxFlickVelocity = 6000.0; } // namespace @@ -59,8 +59,6 @@ CarouselView::~CarouselView() { releaseAllItems(); } -// ---------------------------------------------------------------- properties - void CarouselView::setDelegate(QQmlComponent* c) { if (m_delegate == c) return; m_delegate = c; @@ -73,7 +71,6 @@ void CarouselView::setModel(const QVariantList& m) { m_model = m; emit modelChanged(); - // Re-anchor on the currently selected path if possible, otherwise clamp. int newCount = m_model.size(); if (newCount == 0) { m_currentRealIndex = -1; @@ -93,9 +90,6 @@ void CarouselView::setModel(const QVariantList& m) { return; } - // Model contents changed under us (e.g. search text filtered the list). - // Re-anchor instantly on the clamped selection -- no glide, since this - // isn't a user-initiated navigation. rebuildArrangement(); centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); } @@ -173,16 +167,10 @@ void CarouselView::setCurrentIndex(int idx) { goToIndex(idx); } -// ---------------------------------------------------------------- geometry - void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || !qFuzzyCompare(newGeo.height(), oldGeo.height())) { - // Recentre on the same virtual index rather than letting contentX - // drift, exactly like the QML version's onWidthChanged handler -- - // but here it happens in the same pass as the arrangement rebuild, - // so x/width for every slot are recomputed together. rebuildArrangement(); if (m_initializedLayout) m_contentX = contentXForIndex(m_currentVirtualIndex); @@ -190,22 +178,10 @@ void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { } } -// ---------------------------------------------------------------- arrangement math -// Direct port of the QML `arrangement` computed property + keylineCenter/ -// keylineSize/sampleCenter/sampleSize helpers. Kept numerically identical on -// purpose so behaviour doesn't change, just *when* it's applied. - void CarouselView::rebuildArrangement() { Arrangement& a = m_arrangement; a = Arrangement{}; - // IMPORTANT: this must be the external constraint (m_maxWidth), never - // width() -- width() is a *result* of this computation (see below), and - // using it here would make the arrangement depend on its own previous - // output. On first layout, before width() has ever been set, that - // bootstraps to W==0 -> n==0 -> every side item falls back to a flat - // minEdgeWidth with no taper at all, which is what caused items to slide - // off looking "pushed out" instead of shrinking. const qreal W = m_maxWidth; const qreal F = m_focalWidth; const qreal M = m_minEdgeWidth; @@ -239,8 +215,6 @@ void CarouselView::rebuildArrangement() { bestSizes = sizes; bestWidth = requiredWidth; - // Safety valve: arrangement size is bounded by how many halvings fit, - // this just guards against pathological inputs (F <= M etc). if (n > 64) break; } @@ -262,10 +236,6 @@ void CarouselView::rebuildArrangement() { a.effectiveExtraWidth = extra; setImplicitWidth(m_maxWidth); - // This mirrors the original `width: arrangement.width` binding. It's - // safe against feedback because the arrangement above only ever reads - // m_maxWidth, never width() -- so if a.width doesn't change, setWidth() - // here is a no-op and geometryChange() won't recurse. setWidth(a.width); } @@ -331,8 +301,6 @@ qreal CarouselView::sampleSize(qreal childLoc) const { return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; } -// ---------------------------------------------------------------- index math - int CarouselView::realIndexOf(int virtualIndex) const { const int count = m_model.size(); if (count == 0) return -1; @@ -373,8 +341,6 @@ void CarouselView::wrapContentIfNeeded() { const qreal newEnd = m_flickAnim->endValue().toReal() - shift; m_flickAnim->setEndValue(newEnd); } - // Also shift every currently-live slot's virtual index bookkeeping so - // it doesn't think it needs to be recycled just because of the wrap. const int indexShift = static_cast(std::round(shift / pitch())); for (Slot& s : m_slots) { if (s.active) s.virtualIndex -= indexShift; @@ -404,8 +370,6 @@ void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { } } -// ---------------------------------------------------------------- pool / delegates - QQuickItem* CarouselView::acquireItem() { if (!m_delegate) return nullptr; @@ -479,8 +443,6 @@ void CarouselView::positionSlot(const Slot& slot) const { const qreal center = sampleCenter(childLoc); const qreal size = sampleSize(childLoc); - // slot's "column" spans one pitch, centred at width()/2 + center; item is - // centred within that column, matching the QML delegate's x expression. const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; @@ -497,8 +459,6 @@ void CarouselView::releaseAllItems() { m_slots.clear(); } -// ---------------------------------------------------------------- layout pass - void CarouselView::relayout() { const int count = m_model.size(); if (count == 0 || width() <= 0 || m_focalWidth <= 0) { @@ -508,20 +468,15 @@ void CarouselView::relayout() { } const int n = m_arrangement.n; - // Window of virtual indices that can ever be visible, plus a small buffer - // so items don't pop in/out right at the edge during a fast flick. const int half = n + 3; const int lo = m_currentVirtualIndex - half; const int hi = m_currentVirtualIndex + half; const int needed = hi - lo + 1; - // Grow the pool if needed (shrinking is unnecessary: idle items are just - // marked inactive/invisible, which is cheap and avoids churn). while (m_slots.size() < needed) m_slots.append(Slot{}); - // Figure out which virtual indices are already backed by a slot. - QHash virtualToSlot; // virtualIndex -> slot list position + QHash virtualToSlot; virtualToSlot.reserve(m_slots.size()); for (int i = 0; i < m_slots.size(); ++i) { if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); @@ -533,15 +488,11 @@ void CarouselView::relayout() { auto it = virtualToSlot.find(v); if (it != virtualToSlot.end()) { slotUsedThisPass[it.value()] = true; - // Content (isCurrent / modelData) may still need refreshing if - // currentRealIndex changed since last pass. applyPropertiesToItem( m_slots[it.value()].item, m_slots[it.value()].realIndex); continue; } - // find a free slot: one not used this pass and not already at a - // virtual index inside [lo, hi] (those are all still needed). int freeSlot = -1; for (int i = 0; i < m_slots.size(); ++i) { if (slotUsedThisPass[i]) continue; @@ -552,7 +503,6 @@ void CarouselView::relayout() { break; } if (freeSlot < 0) { - // Shouldn't happen given the pool growth above, but guard anyway. m_slots.append(Slot{}); slotUsedThisPass.append(false); freeSlot = m_slots.size() - 1; @@ -573,8 +523,6 @@ void CarouselView::relayout() { positionSlot(s); } -// ---------------------------------------------------------------- animation - void CarouselView::cancelAnimations() { if (m_glideAnim->state() == QAbstractAnimation::Running) m_glideAnim->stop(); @@ -596,8 +544,6 @@ void CarouselView::snapToNearest() { glideTo(snapTargetX(m_contentX)); } -// ---------------------------------------------------------------- public API - void CarouselView::goToIndex(int realIndex) { const int count = m_model.size(); if (count == 0) return; @@ -659,19 +605,10 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { relayout(); } -// ---------------------------------------------------------------- input - void CarouselView::mousePressEvent(QMouseEvent* event) { - // Deliberately do NOT cancel animations here. A press that turns out to - // be a plain tap (never crosses the drag threshold) should leave any - // in-flight glide/flick completely untouched. We only touch the - // animation once we know it's actually a drag (see mouseMoveEvent). m_dragging = true; m_dragActive = false; m_pressPos = event->position(); - // Capture wherever contentX currently is, even mid-animation -- if this - // does turn into a drag, it should pick up smoothly from the visual - // position, not teleport to some rest position first. m_pressContentX = m_contentX; m_dragTimer.start(); m_lastMoveX = event->position().x(); @@ -686,8 +623,7 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { const qreal dx = event->position().x() - m_pressPos.x(); if (!m_dragActive && std::abs(dx) > kDragThreshold) { m_dragActive = true; - m_pressContentX = - m_contentX; // re-anchor to the live (possibly mid-animation) value + m_pressContentX = m_contentX; cancelAnimations(); grabMouse(); } @@ -703,7 +639,6 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { if (dt > 0) { const qreal instVel = (m_lastMoveX - event->position().x()) / (dt / 1000.0); - // Light smoothing so a single jittery sample doesn't dominate the flick. m_velocity = m_velocity * 0.7 + instVel * 0.3; } m_lastMoveX = event->position().x(); @@ -718,8 +653,6 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { ungrabMouse(); if (!m_dragActive) { - // A plain click/tap with no drag: let it fall through to the - // delegate's own StateLayer/MouseArea via requestActivate(). event->accept(); return; } @@ -732,10 +665,8 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { return; } - // Simple constant-deceleration flick: distance = v^2 / (2*friction). const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms - const qreal distance = - (v * (duration / 1000.0)) / 2.0; // average-velocity approximation + const qreal distance = (v * (duration / 1000.0)) / 2.0; const qreal rawDest = m_contentX + distance; const qreal dest = snapTargetX(rawDest); @@ -764,8 +695,6 @@ void CarouselView::wheelEvent(QWheelEvent* event) { event->accept(); } -// ---------------------------------------------------------------- delegate signal - void CarouselView::handleDelegateActivate() { QQuickItem* item = qobject_cast(sender()); if (!item) return; diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 23a8e36..2925cf9 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -47,8 +47,6 @@ class CarouselView : public QQuickItem { QEasingCurve::Type glideEasingType READ glideEasingType WRITE setGlideEasingType NOTIFY glideEasingTypeChanged) - // Extra properties (e.g. { visibilities: root.visibilities }) forwarded - // verbatim onto every delegate instance as a property of the same name. Q_PROPERTY( QVariantMap delegateProperties READ delegateProperties WRITE setDelegateProperties NOTIFY delegatePropertiesChanged) @@ -79,7 +77,7 @@ class CarouselView : public QQuickItem { void setMaxWidth(qreal v); int currentIndex() const { return m_currentRealIndex; } - void setCurrentIndex(int idx); // jumps (animated glide) to a real index + void setCurrentIndex(int idx); int glideDuration() const { return m_glideDuration; } void setGlideDuration(int v); @@ -90,13 +88,10 @@ class CarouselView : public QQuickItem { QVariantMap delegateProperties() const { return m_delegateProperties; } void setDelegateProperties(const QVariantMap& v); - // Animated (glides). Q_INVOKABLE void goToIndex(int realIndex); Q_INVOKABLE void incrementCurrentIndex(); Q_INVOKABLE void decrementCurrentIndex(); - // Instant, no animation -- use for initial placement (e.g. restoring the - // saved wallpaper on Component.onCompleted) where a glide would be wrong. Q_INVOKABLE void jumpToIndex(int realIndex); signals: @@ -112,11 +107,7 @@ class CarouselView : public QQuickItem { void glideEasingTypeChanged(); void delegatePropertiesChanged(); - // Emitted when the currently-centred delegate is clicked while already - // centred (i.e. "activate/select this one"). void activated(int realIndex, QVariant modelData); - // Emitted continuously as the centred item changes, for preview-on-scroll - // behaviour (used to drive Wallpapers.preview() from QML). void previewIndexChanged(int realIndex, QVariant modelData); protected: @@ -134,22 +125,20 @@ class CarouselView : public QQuickItem { struct Arrangement { int n = 0; qreal width = 0; - QVector sizes; // sizes[0] = focal, sizes[i] = i-th side item - QVector - centers; // centers[0] = 0, centers[i] = i-th side centre offset + QVector sizes; + QVector centers; qreal effectiveExtraWidth = 0; }; struct Slot { QQuickItem* item = nullptr; - int virtualIndex = - 0; // which "virtual" (unbounded) index this slot currently renders - int realIndex = -1; // resolved model index, -1 if none / hidden + int virtualIndex = 0; + int realIndex = -1; bool active = false; }; void rebuildArrangement(); - void relayout(); // recomputes window + positions every slot (one pass) + void relayout(); void updateSlotContent(Slot& slot, int virtualIndex); void positionSlot(const Slot& slot) const; void applyPropertiesToItem(QQuickItem* item, int realIndex) const; @@ -170,7 +159,7 @@ class CarouselView : public QQuickItem { int realIndexOf(int virtualIndex) const; void updateCurrentIndexFromContentX(bool emitPreview); void wrapContentIfNeeded(); - void centerInstantlyOnReal(int realIndex); // no animation, no glide + void centerInstantlyOnReal(int realIndex); QQuickItem* acquireItem(); void releaseAllItems(); @@ -195,22 +184,20 @@ class CarouselView : public QQuickItem { static constexpr int kLoopMultiplier = 401; int m_centerBlockStart = 0; - QList - m_slots; // pooled delegate slots, unordered; window recomputed each relayout + QList m_slots; int m_glideDuration = 250; QEasingCurve::Type m_glideEasingType = QEasingCurve::OutCubic; QVariantAnimation* m_glideAnim = nullptr; - // Drag / flick state bool m_dragging = false; - bool m_dragActive = false; // exceeded the drag threshold + bool m_dragActive = false; QPointF m_pressPos; qreal m_pressContentX = 0; QElapsedTimer m_dragTimer; qreal m_lastMoveX = 0; qint64 m_lastMoveT = 0; - qreal m_velocity = 0; // px/sec + qreal m_velocity = 0; QVariantAnimation* m_flickAnim = nullptr; bool m_initializedLayout = false; From 4e75f2144613756171f5cc8dbc03c7e2d442a576 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:28:21 +0200 Subject: [PATCH 07/18] improve sizing and dynamic width for view --- Modules/Launcher/CarouselView.qml | 8 +- Modules/Launcher/ContentList.qml | 2 +- Plugins/ZShell/Components/carouselview.cpp | 814 +++++++++++++++++---- Plugins/ZShell/Components/carouselview.hpp | 37 +- 4 files changed, 701 insertions(+), 160 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index ca11095..595dfa2 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -15,8 +15,6 @@ CarouselView { readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) required property var visibilities - // Forwarded verbatim as a property of the same name onto every delegate - // instance (the delegate declares `required property PersistentProperties visibilities`). delegateProperties: ({ visibilities: root.visibilities }) @@ -29,9 +27,9 @@ CarouselView { if (!screen) return 0; const barMargins = Config.bar.border * 2; - let margins = 0; + let margins = Math.round(screen.width / 6); if (visibilities.utilities || visibilities.sidebar) - margins = panels.utilities.implicitWidth; + margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } minEdgeWidth: 56 @@ -40,7 +38,7 @@ CarouselView { delegate: Component { WallpaperTile { - } // your existing delegate file, unchanged + } } Component.onCompleted: { diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index c9ab7f1..f34d16f 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -110,9 +110,9 @@ Item { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + width: root.implicitWidth sourceComponent: CarouselView { - // content: root.content panels: root.panels search: root.search visibilities: root.visibilities diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index e7a8bda..73ba4f4 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -4,17 +4,19 @@ #include #include #include -#include -#include + #include namespace ZShell::components { namespace { + constexpr qreal kDragThreshold = 4.0; constexpr qreal kFlickFriction = 0.0022; constexpr qreal kMinFlickVelocity = 60.0; constexpr qreal kMaxFlickVelocity = 6000.0; +constexpr int kMaxLayoutCapacity = 64; + } // namespace CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { @@ -24,32 +26,39 @@ CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { m_glideAnim = new QVariantAnimation(this); m_glideAnim->setEasingCurve(m_glideEasingType); + connect( m_glideAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { m_contentX = v.toReal(); + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); }); + connect(m_glideAnim, &QVariantAnimation::finished, this, [this]() { updateCurrentIndexFromContentX(true); + relayout(); }); m_flickAnim = new QVariantAnimation(this); m_flickAnim->setEasingCurve(QEasingCurve::Linear); + connect( m_flickAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { m_contentX = v.toReal(); + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); }); + connect(m_flickAnim, &QVariantAnimation::finished, this, [this]() { snapToNearest(); }); @@ -61,104 +70,186 @@ CarouselView::~CarouselView() { void CarouselView::setDelegate(QQmlComponent* c) { if (m_delegate == c) return; + m_delegate = c; + releaseAllItems(); + emit delegateChanged(); + relayout(); } void CarouselView::setModel(const QVariantList& m) { m_model = m; + emit modelChanged(); - int newCount = m_model.size(); + const int newCount = m_model.size(); + if (newCount == 0) { m_currentRealIndex = -1; + releaseAllItems(); rebuildArrangement(); - return; - } - if (!m_initializedLayout) { - m_centerBlockStart = (kLoopMultiplier / 2) * newCount; - m_currentVirtualIndex = m_centerBlockStart; - m_contentX = contentXForIndex(m_currentVirtualIndex); - m_currentRealIndex = realIndexOf(m_currentVirtualIndex); - m_previewRealIndex = m_currentRealIndex; - m_initializedLayout = true; - rebuildArrangement(); - relayout(); + return; } + if (!m_initializedLayout) { + m_centerBlockStart = 0; + m_currentVirtualIndex = 0; + + m_contentX = contentXForIndex(0); + + m_currentRealIndex = realIndexOf(m_currentVirtualIndex); + + m_previewRealIndex = m_currentRealIndex; + + m_initializedLayout = true; + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + + rebuildArrangement(); + relayout(); + + return; + } + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); + centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); } void CarouselView::setFocalWidth(qreal v) { if (qFuzzyCompare(m_focalWidth, v)) return; + m_focalWidth = v; + emit focalWidthChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setMinEdgeWidth(qreal v) { if (qFuzzyCompare(m_minEdgeWidth, v)) return; + m_minEdgeWidth = v; + emit minEdgeWidthChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setItemSpacing(qreal v) { if (qFuzzyCompare(m_itemSpacing, v)) return; + m_itemSpacing = v; + emit itemSpacingChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setTileHeight(qreal v) { if (qFuzzyCompare(m_tileHeight, v)) return; + m_tileHeight = v; + emit tileHeightChanged(); + for (const Slot& s : std::as_const(m_slots)) { - if (s.active && s.item) + if (s.active && s.item) { QQmlProperty::write( s.item, QStringLiteral("tileHeight"), m_tileHeight); + } } } void CarouselView::setMaxWidth(qreal v) { if (qFuzzyCompare(m_maxWidth, v)) return; + m_maxWidth = v; + emit maxWidthChanged(); + + /* + * Advertise the target width immediately. + * + * The parent QML object can then animate its own implicitWidth + * immediately instead of waiting for the delegate geometry. + */ + const qreal targetWidth = targetWidthForMaxWidth(m_maxWidth); + + setImplicitWidth(targetWidth); + + /* + * Grow the instantiated delegate pool if the new maximum needs + * more items. Never throw them away during a resize. + */ + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setGlideDuration(int v) { if (m_glideDuration == v) return; + m_glideDuration = v; + emit glideDurationChanged(); } void CarouselView::setGlideEasingType(QEasingCurve::Type t) { if (m_glideEasingType == t) return; + m_glideEasingType = t; + m_glideAnim->setEasingCurve(t); + emit glideEasingTypeChanged(); } void CarouselView::setDelegateProperties(const QVariantMap& v) { m_delegateProperties = v; + emit delegatePropertiesChanged(); + for (const Slot& s : std::as_const(m_slots)) { - if (s.active && s.item) { - for (auto it = m_delegateProperties.constBegin(); - it != m_delegateProperties.constEnd(); - ++it) - QQmlProperty::write(s.item, it.key(), it.value()); + if (!s.active || !s.item) continue; + + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) { + QQmlProperty::write(s.item, it.key(), it.value()); } } } @@ -169,141 +260,319 @@ void CarouselView::setCurrentIndex(int idx) { void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); - if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || - !qFuzzyCompare(newGeo.height(), oldGeo.height())) { - rebuildArrangement(); - if (m_initializedLayout) - m_contentX = contentXForIndex(m_currentVirtualIndex); - relayout(); + + const qreal oldWidth = oldGeo.width(); + + const qreal newWidth = newGeo.width(); + + if (qFuzzyCompare(oldWidth, newWidth)) { + return; } + + /* + * The carousel is horizontally centered. + * + * Changing its width therefore moves the local coordinate system + * by half the width delta. Keep the logical content stationary by + * moving contentX by the opposite amount. + * + * Crucially, do this directly instead of recomputing a logical + * index and converting it back. That avoids quantization and + * floating-point drift on every animation frame. + */ + const qreal delta = (newWidth - oldWidth) / 2.0; + + m_contentX -= delta; + + /* + * A running glide/flick animation is expressed in contentX + * coordinates too. Shift both endpoints by exactly the same + * amount so the animation continues seamlessly in the new + * geometry. + */ + auto shiftAnimation = [delta](QVariantAnimation* anim) { + if (anim->state() != QAbstractAnimation::Running) { + return; + } + + anim->setStartValue(anim->startValue().toReal() - delta); + + anim->setEndValue(anim->endValue().toReal() - delta); + }; + + shiftAnimation(m_glideAnim); + shiftAnimation(m_flickAnim); + + rebuildArrangement(); + relayout(); +} + +CarouselView::Arrangement CarouselView::arrangementForCount(int n) const { + Arrangement a; + + const qreal F = m_focalWidth; + + const qreal M = m_minEdgeWidth; + + const qreal sp = m_itemSpacing; + + if (F <= 0) { + a.n = 0; + a.width = 0; + a.outerSpacing = 0; + a.sizes = {F}; + a.centers = {0}; + + return a; + } + + if (n <= 0) { + a.n = 0; + a.width = F; + a.outerSpacing = 0; + + a.sizes = {F}; + a.centers = {0}; + + return a; + } + + a.n = n; + a.outerSpacing = sp; + + a.sizes.reserve(n + 1); + a.centers.reserve(n + 1); + + a.sizes.append(F); + + const qreal ratio = std::pow(M / F, 1.0 / n); + + qreal sideWidth = 0; + + for (int i = 1; i <= n; ++i) { + const qreal size = (i == n) ? M : F * std::pow(ratio, i); + + a.sizes.append(size); + sideWidth += size; + } + + /* + * This is exactly the original full arrangement width. + */ + a.width = F + 2.0 * n * sp + 2.0 * sideWidth; + + a.centers.append(0); + + qreal center = 0; + + for (int i = 1; i <= n; ++i) { + center += a.sizes[i - 1] / 2.0 + sp + a.sizes[i] / 2.0; + + a.centers.append(center); + } + + a.effectiveExtraWidth = 0; + + for (int i = 1; i <= n; ++i) { + a.effectiveExtraWidth += pitch() - a.sizes[i]; + } + + return a; +} + +int CarouselView::layoutCapacityForWidth(qreal width) const { + if (width <= 0 || m_focalWidth <= 0) { + return 0; + } + + int bestN = 0; + + for (int n = 1; n <= kMaxLayoutCapacity; ++n) { + const Arrangement a = arrangementForCount(n); + + if (a.width > width) break; + + bestN = n; + } + + return bestN; +} + +qreal CarouselView::targetWidthForMaxWidth(qreal maxWidth) const { + if (maxWidth <= 0 || m_focalWidth <= 0) { + return 0; + } + + /* + * This deliberately preserves the old behavior: + * + * choose the largest complete arrangement whose outermost + * delegate can remain at minEdgeWidth. + * + * Therefore the component's desired width can be smaller than + * maxWidth. + */ + const int n = layoutCapacityForWidth(maxWidth); + + return arrangementForCount(n).width; } void CarouselView::rebuildArrangement() { Arrangement& a = m_arrangement; + a = Arrangement{}; - const qreal W = m_maxWidth; - const qreal F = m_focalWidth; - const qreal M = m_minEdgeWidth; - const qreal sp = m_itemSpacing; + const qreal W = width() > 0 ? width() : targetWidthForMaxWidth(m_maxWidth); + + if (W <= 0 || m_focalWidth <= 0) { + a = arrangementForCount(0); + a.width = W; - if (W <= 0 || F <= 0) { - a.sizes = {F}; - a.centers = {0}; return; } - int bestN = 0; - QVector bestSizes = {F}; - qreal bestWidth = F; + if (m_layoutCapacity <= 0) { + m_layoutCapacity = layoutCapacityForWidth(m_maxWidth); + } - for (int n = 1;; ++n) { - const qreal ratio = std::pow(M / F, 1.0 / n); - QVector sizes = {F}; - qreal sideWidth = 0; + if (m_layoutCapacity <= 0) { + a = arrangementForCount(0); + a.width = W; - for (int i = 1; i <= n; ++i) { - const qreal size = (i == n) ? M : F * std::pow(ratio, i); - sizes.push_back(size); - sideWidth += size; + return; + } + + const Arrangement full = arrangementForCount(m_layoutCapacity); + + /* + * If the current width can display the complete capacity + * arrangement, use that exact geometry. + */ + if (W >= full.width) { + a = full; + return; + } + + /* + * Otherwise find the complete arrangement immediately above W. + * + * Example: + * + * width(8) >= W >= width(7) + * + * means we're in the 8 -> 7 transition. + */ + int highN = 1; + + for (int n = 1; n <= m_layoutCapacity; ++n) { + const Arrangement candidate = arrangementForCount(n); + + if (W < candidate.width) { + highN = n; + break; } - - const qreal requiredWidth = F + 2 * n * sp + 2 * sideWidth; - if (requiredWidth > W) break; - - bestN = n; - bestSizes = sizes; - bestWidth = requiredWidth; - - if (n > 64) break; } - a.n = bestN; - a.sizes = bestSizes; - a.width = bestWidth; + const int lowN = qMax(0, highN - 1); + + const Arrangement high = arrangementForCount(highN); + + const Arrangement low = arrangementForCount(lowN); + + if (high.width <= low.width) { + a = low; + return; + } + + qreal t = (high.width - W) / (high.width - low.width); + + t = qBound(0.0, t, 1.0); + + /* + * Keep the outgoing slot instantiated and visible while it + * smoothly transitions to zero. + */ + a.n = highN; + a.width = W; + + a.sizes.resize(highN + 1); + + a.sizes[0] = m_focalWidth; + + for (int i = 1; i < highN; ++i) { + a.sizes[i] = high.sizes[i] + (low.sizes[i] - high.sizes[i]) * t; + } + + a.sizes[highN] = high.sizes[highN] * (1.0 - t); + + /* + * All normal gaps stay constant. + * + * Only the gap immediately before the disappearing delegate + * collapses together with that delegate. + */ + a.outerSpacing = m_itemSpacing * (1.0 - t); + + a.centers.append(0); - QVector centers = {0}; qreal center = 0; - for (int i = 1; i <= bestN; ++i) { - center += bestSizes[i - 1] / 2.0 + sp + bestSizes[i] / 2.0; - centers.push_back(center); - } - a.centers = centers; - qreal extra = 0; - for (int i = 1; i <= bestN; ++i) - extra += pitch() - bestSizes[i]; - a.effectiveExtraWidth = extra; + for (int i = 1; i <= highN; ++i) { + const qreal gap = i == highN ? a.outerSpacing : m_itemSpacing; - setImplicitWidth(m_maxWidth); - setWidth(a.width); -} + center += a.sizes[i - 1] / 2.0 + gap + a.sizes[i] / 2.0; -qreal CarouselView::keylineCenter(int io) const { - const int n = m_arrangement.n; - const int i = std::abs(io); - if (i == 0) return 0; - const int sign = io < 0 ? -1 : 1; - - if (i <= n) return sign * m_arrangement.centers[i]; - - if (i == n + 1) { - const qreal lastCenter = m_arrangement.centers[n]; - const qreal lastSize = m_arrangement.sizes[n]; - return sign * (lastCenter + lastSize / 2.0 + m_itemSpacing + - m_minEdgeWidth / 2.0); + a.centers.append(center); } - const qreal outgoingCenter = std::abs(keylineCenter(sign * (n + 1))); - return sign * (outgoingCenter + m_minEdgeWidth / 2.0 + m_itemSpacing); + a.effectiveExtraWidth = 0; + + for (int i = 1; i <= highN; ++i) { + a.effectiveExtraWidth += pitch() - a.sizes[i]; + } } qreal CarouselView::keylineSize(int io) const { const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return m_focalWidth; + if (i <= n) return m_arrangement.sizes[i]; - if (i == n + 1) return m_minEdgeWidth; + return 0; } -qreal CarouselView::sampleCenter(qreal childLoc) const { - const int n = m_arrangement.n; - if (n <= 0) return 0; - - const int minIo = -(n + 2); - const int maxIo = n + 2; - const qreal p = pitch(); - - const int a = static_cast(std::floor(childLoc / p)); - if (a < minIo) return keylineCenter(minIo); - if (a >= maxIo) return keylineCenter(maxIo); - - const int b = a + 1; - const qreal t = (childLoc - a * p) / p; - return keylineCenter(a) + (keylineCenter(b) - keylineCenter(a)) * t; -} - qreal CarouselView::sampleSize(qreal childLoc) const { const int n = m_arrangement.n; + if (n <= 0) return 0; const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineSize(minIo); + if (a >= maxIo) return keylineSize(maxIo); const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; } int CarouselView::realIndexOf(int virtualIndex) const { const int count = m_model.size(); + if (count == 0) return -1; + return ((virtualIndex % count) + count) % count; } @@ -322,51 +591,71 @@ qreal CarouselView::snapTargetX(qreal x) const { void CarouselView::wrapContentIfNeeded() { const int count = m_model.size(); + if (count == 0) return; const qreal blockWidth = count * pitch(); + const qreal centerX = contentXForIndex(m_centerBlockStart); + const int blocksFromCenter = static_cast(std::round((m_contentX - centerX) / blockWidth)); - if (std::abs(blocksFromCenter) >= 2) { - const qreal shift = blocksFromCenter * blockWidth; - m_contentX -= shift; + if (std::abs(blocksFromCenter) < 2) return; - if (m_glideAnim->state() == QAbstractAnimation::Running) { - const qreal newEnd = m_glideAnim->endValue().toReal() - shift; - m_glideAnim->setEndValue(newEnd); - } - if (m_flickAnim->state() == QAbstractAnimation::Running) { - const qreal newEnd = m_flickAnim->endValue().toReal() - shift; - m_flickAnim->setEndValue(newEnd); - } - const int indexShift = static_cast(std::round(shift / pitch())); - for (Slot& s : m_slots) { - if (s.active) s.virtualIndex -= indexShift; + const int indexShift = blocksFromCenter * count; + + const qreal shift = indexShift * pitch(); + + m_contentX -= shift; + m_currentVirtualIndex -= indexShift; + + auto shiftAnimation = [shift](QVariantAnimation* anim) { + if (anim->state() != QAbstractAnimation::Running) { + return; } + + anim->setStartValue(anim->startValue().toReal() - shift); + + anim->setEndValue(anim->endValue().toReal() - shift); + }; + + shiftAnimation(m_glideAnim); + shiftAnimation(m_flickAnim); + + for (Slot& s : m_slots) { + if (s.active) s.virtualIndex -= indexShift; } } void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { const int count = m_model.size(); + if (count == 0) return; const int newVirtual = static_cast( std::round((m_contentX + width() / 2.0 - pitch() / 2.0) / pitch())); - if (newVirtual == m_currentVirtualIndex) return; + + if (newVirtual == m_currentVirtualIndex) { + return; + } m_currentVirtualIndex = newVirtual; + const int newReal = realIndexOf(newVirtual); if (newReal != m_currentRealIndex) { m_currentRealIndex = newReal; + emit currentIndexChanged(); } + if (emitPreview && newReal != m_previewRealIndex) { m_previewRealIndex = newReal; - if (newReal >= 0) + + if (newReal >= 0) { emit previewIndexChanged(newReal, m_model.at(newReal)); + } } } @@ -374,38 +663,54 @@ QQuickItem* CarouselView::acquireItem() { if (!m_delegate) return nullptr; QQmlContext* ctx = QQmlEngine::contextForObject(this); + QVariantMap initial; + initial.insert(QStringLiteral("isCurrent"), false); + initial.insert(QStringLiteral("modelData"), QVariant()); + initial.insert(QStringLiteral("tileHeight"), m_tileHeight); + initial.insert(QStringLiteral("tileWidth"), m_focalWidth); + for (auto it = m_delegateProperties.constBegin(); it != m_delegateProperties.constEnd(); - ++it) + ++it) { initial.insert(it.key(), it.value()); + } QObject* obj = m_delegate->createWithInitialProperties(initial, ctx); + if (!obj) { qWarning( "CarouselView: failed to create delegate instance: %s", qPrintable(m_delegate->errorString())); + return nullptr; } + QQuickItem* item = qobject_cast(obj); + if (!item) { qWarning("CarouselView: delegate root is not an Item"); + delete obj; + return nullptr; } item->setParentItem(this); + QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); const QMetaMethod activateSignal = item->metaObject()->method( item->metaObject()->indexOfSignal("requestActivate()")); + if (activateSignal.isValid()) { static const QMetaMethod slot = metaObject()->method( metaObject()->indexOfSlot("handleDelegateActivate()")); + connect(item, activateSignal, this, slot); } @@ -414,39 +719,172 @@ QQuickItem* CarouselView::acquireItem() { void CarouselView::applyPropertiesToItem(QQuickItem* item, int realIndex) const { if (!item) return; - const bool isCurrent = (realIndex == m_currentRealIndex); + + const bool isCurrent = realIndex == m_currentRealIndex; + QQmlProperty::write(item, QStringLiteral("isCurrent"), isCurrent); + QQmlProperty::write( item, QStringLiteral("modelData"), realIndex >= 0 && realIndex < m_model.size() ? m_model.at(realIndex) : QVariant()); + QQmlProperty::write(item, QStringLiteral("tileHeight"), m_tileHeight); } void CarouselView::updateSlotContent(Slot& slot, int virtualIndex) { if (!slot.item) slot.item = acquireItem(); + if (!slot.item) return; slot.virtualIndex = virtualIndex; + slot.realIndex = realIndexOf(virtualIndex); + slot.active = slot.realIndex >= 0; + slot.item->setVisible(slot.active); - if (slot.active) applyPropertiesToItem(slot.item, slot.realIndex); + + if (slot.active) { + applyPropertiesToItem(slot.item, slot.realIndex); + } +} + +qreal CarouselView::continuousIndex() const { + return (m_contentX + width() / 2.0 - pitch() / 2.0) / pitch(); } void CarouselView::positionSlot(const Slot& slot) const { if (!slot.item || !slot.active) return; - const qreal childLoc = slot.virtualIndex * pitch() + pitch() / 2.0 - - m_contentX - width() / 2.0; - const qreal center = sampleCenter(childLoc); + const qreal current = continuousIndex(); + + const int leftIndex = static_cast(std::floor(current)); + + const qreal fraction = current - leftIndex; + + const int v = slot.virtualIndex; + + const qreal childLoc = (qreal(v) - current) * pitch(); + const qreal size = sampleSize(childLoc); - const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; - const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; + if (size <= 0.5) { + slot.item->setVisible(false); + return; + } + + const int n = m_arrangement.n; + + const auto rightGap = [this, n](int rightIndex) { + return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing; + }; + + const auto leftGap = [this, n](int leftIndex) { + return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; + }; + + qreal center = 0; + + if (std::abs(fraction) < 1e-6) { + if (v == leftIndex) { + center = 0; + } else if (v < leftIndex) { + qreal x = 0; + + qreal previousSize = sampleSize(0); + + for (int i = leftIndex - 1; i >= v; --i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = leftGap(i); + + x -= previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } else { + qreal x = 0; + + qreal previousSize = sampleSize(0); + + for (int i = leftIndex + 1; i <= v; ++i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = rightGap(i); + + x += previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } + } else { + const qreal leftSize = + sampleSize((qreal(leftIndex) - current) * pitch()); + + const qreal rightSize = + sampleSize((qreal(leftIndex + 1) - current) * pitch()); + + qreal centerGap = m_itemSpacing; + + if (leftIndex == -n || leftIndex + 1 == n) { + centerGap = m_arrangement.outerSpacing; + } + + const qreal distance = leftSize / 2.0 + centerGap + rightSize / 2.0; + + const qreal leftCenter = -fraction * distance; + + const qreal rightCenter = (1.0 - fraction) * distance; + + if (v == leftIndex) { + center = leftCenter; + } else if (v == leftIndex + 1) { + center = rightCenter; + } else if (v < leftIndex) { + qreal x = leftCenter; + + qreal previousSize = leftSize; + + for (int i = leftIndex - 1; i >= v; --i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = leftGap(i); + + x -= previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } else { + qreal x = rightCenter; + + qreal previousSize = rightSize; + + for (int i = leftIndex + 2; i <= v; ++i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = rightGap(i); + + x += previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } + } + + const qreal itemX = width() / 2.0 + center - size / 2.0; QQmlProperty::write(slot.item, QStringLiteral("tileWidth"), size); + slot.item->setX(itemX); slot.item->setY(0); slot.item->setVisible(size > 0.5); @@ -456,59 +894,83 @@ void CarouselView::releaseAllItems() { for (Slot& s : m_slots) { if (s.item) s.item->deleteLater(); } + m_slots.clear(); } void CarouselView::relayout() { const int count = m_model.size(); + if (count == 0 || width() <= 0 || m_focalWidth <= 0) { - for (Slot& s : m_slots) + for (Slot& s : m_slots) { if (s.item) s.item->setVisible(false); + } + return; } - const int n = m_arrangement.n; - const int half = n + 3; + /* + * Keep enough slots for the largest layout we've needed. + * The currently visible count is determined by m_arrangement. + */ + const int half = m_layoutCapacity + 2; + const int lo = m_currentVirtualIndex - half; + const int hi = m_currentVirtualIndex + half; + const int needed = hi - lo + 1; while (m_slots.size() < needed) m_slots.append(Slot{}); QHash virtualToSlot; + virtualToSlot.reserve(m_slots.size()); + for (int i = 0; i < m_slots.size(); ++i) { - if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); + if (m_slots[i].active) { + virtualToSlot.insert(m_slots[i].virtualIndex, i); + } } QVector slotUsedThisPass(m_slots.size(), false); for (int v = lo; v <= hi; ++v) { auto it = virtualToSlot.find(v); + if (it != virtualToSlot.end()) { slotUsedThisPass[it.value()] = true; + applyPropertiesToItem( m_slots[it.value()].item, m_slots[it.value()].realIndex); + continue; } int freeSlot = -1; + for (int i = 0; i < m_slots.size(); ++i) { if (slotUsedThisPass[i]) continue; + if (m_slots[i].active && m_slots[i].virtualIndex >= lo && - m_slots[i].virtualIndex <= hi) + m_slots[i].virtualIndex <= hi) { continue; + } + freeSlot = i; break; } + if (freeSlot < 0) { m_slots.append(Slot{}); slotUsedThisPass.append(false); + freeSlot = m_slots.size() - 1; } updateSlotContent(m_slots[freeSlot], v); + slotUsedThisPass[freeSlot] = true; } @@ -519,39 +981,53 @@ void CarouselView::relayout() { } } - for (const Slot& s : std::as_const(m_slots)) + for (const Slot& s : std::as_const(m_slots)) { positionSlot(s); + } } void CarouselView::cancelAnimations() { - if (m_glideAnim->state() == QAbstractAnimation::Running) + if (m_glideAnim->state() == QAbstractAnimation::Running) { m_glideAnim->stop(); - if (m_flickAnim->state() == QAbstractAnimation::Running) + } + + if (m_flickAnim->state() == QAbstractAnimation::Running) { m_flickAnim->stop(); + } } void CarouselView::glideTo(qreal dest) { if (std::abs(dest - m_contentX) < 0.5) return; cancelAnimations(); + m_glideAnim->setDuration(m_glideDuration); + m_glideAnim->setStartValue(m_contentX); + m_glideAnim->setEndValue(dest); + m_glideAnim->start(); } void CarouselView::snapToNearest() { - glideTo(snapTargetX(m_contentX)); + if (m_model.isEmpty()) return; + + glideTo(contentXForIndex(m_currentVirtualIndex)); } void CarouselView::goToIndex(int realIndex) { const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); const int curBlock = static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; if (target - current > count / 2) @@ -564,11 +1040,13 @@ void CarouselView::goToIndex(int realIndex) { void CarouselView::incrementCurrentIndex() { if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex + 1)); } void CarouselView::decrementCurrentIndex() { if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex - 1)); } @@ -578,14 +1056,18 @@ void CarouselView::jumpToIndex(int realIndex) { void CarouselView::centerInstantlyOnReal(int realIndex) { const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); cancelAnimations(); const int curBlock = static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; if (target - current > count / 2) @@ -594,12 +1076,17 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { target += count; m_currentVirtualIndex = target; + m_contentX = contentXForIndex(target); - const bool changed = (realIndex != m_currentRealIndex); + const bool changed = realIndex != m_currentRealIndex; + m_currentRealIndex = realIndex; + m_previewRealIndex = realIndex; + if (changed) emit currentIndexChanged(); + emit previewIndexChanged(realIndex, m_model.at(realIndex)); relayout(); @@ -608,12 +1095,18 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { void CarouselView::mousePressEvent(QMouseEvent* event) { m_dragging = true; m_dragActive = false; + m_pressPos = event->position(); + m_pressContentX = m_contentX; + m_dragTimer.start(); + m_lastMoveX = event->position().x(); + m_lastMoveT = 0; m_velocity = 0; + event->accept(); } @@ -621,27 +1114,36 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { if (!m_dragging) return; const qreal dx = event->position().x() - m_pressPos.x(); + if (!m_dragActive && std::abs(dx) > kDragThreshold) { m_dragActive = true; m_pressContentX = m_contentX; + cancelAnimations(); grabMouse(); } + if (!m_dragActive) return; m_contentX = m_pressContentX - dx; + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + if (dt > 0) { const qreal instVel = (m_lastMoveX - event->position().x()) / (dt / 1000.0); + m_velocity = m_velocity * 0.7 + instVel * 0.3; } + m_lastMoveX = event->position().x(); + m_lastMoveT = t; event->accept(); @@ -649,33 +1151,49 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { void CarouselView::mouseReleaseEvent(QMouseEvent* event) { if (!m_dragging) return; + m_dragging = false; + ungrabMouse(); if (!m_dragActive) { + cancelAnimations(); + snapToNearest(); + event->accept(); return; } + m_dragActive = false; qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + if (std::abs(v) < kMinFlickVelocity) { snapToNearest(); + event->accept(); return; } - const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms + const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); + const qreal distance = (v * (duration / 1000.0)) / 2.0; + const qreal rawDest = m_contentX + distance; - const qreal dest = snapTargetX(rawDest); + + const qreal dest = contentXForIndex(indexForContentX(rawDest)); cancelAnimations(); + m_flickAnim->setDuration( static_cast(qBound(120, duration, 900))); + m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); + m_flickAnim->setStartValue(m_contentX); + m_flickAnim->setEndValue(dest); + m_flickAnim->start(); event->accept(); @@ -688,26 +1206,32 @@ void CarouselView::mouseUngrabEvent() { void CarouselView::wheelEvent(QWheelEvent* event) { const int steps = event->angleDelta().y() / 120; + if (steps > 0) decrementCurrentIndex(); else if (steps < 0) incrementCurrentIndex(); + event->accept(); } void CarouselView::handleDelegateActivate() { QQuickItem* item = qobject_cast(sender()); + if (!item) return; for (const Slot& s : std::as_const(m_slots)) { - if (s.item == item && s.active) { - if (s.realIndex == m_currentRealIndex) { - emit activated(s.realIndex, m_model.at(s.realIndex)); - } else { - goToIndex(s.realIndex); - } - return; + if (s.item != item || !s.active) { + continue; } + + if (s.realIndex == m_currentRealIndex) { + emit activated(s.realIndex, m_model.at(s.realIndex)); + } else { + goToIndex(s.realIndex); + } + + return; } } diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 2925cf9..a5b2ef9 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -1,15 +1,15 @@ #pragma once +#include +#include +#include +#include #include #include #include -#include #include -#include -#include -#include +#include #include -#include namespace ZShell::components { @@ -112,6 +112,7 @@ class CarouselView : public QQuickItem { protected: void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; + void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; @@ -125,8 +126,11 @@ class CarouselView : public QQuickItem { struct Arrangement { int n = 0; qreal width = 0; + qreal outerSpacing = 0; + QVector sizes; QVector centers; + qreal effectiveExtraWidth = 0; }; @@ -139,25 +143,36 @@ class CarouselView : public QQuickItem { void rebuildArrangement(); void relayout(); + + Arrangement arrangementForCount(int n) const; + + int layoutCapacityForWidth(qreal width) const; + qreal targetWidthForMaxWidth(qreal maxWidth) const; + void updateSlotContent(Slot& slot, int virtualIndex); + void positionSlot(const Slot& slot) const; + void applyPropertiesToItem(QQuickItem* item, int realIndex) const; qreal pitch() const { return m_focalWidth + m_itemSpacing; } + qreal contentXForIndex(qreal index) const; int indexForContentX(qreal x) const; qreal snapTargetX(qreal x) const; + void snapToNearest(); void glideTo(qreal dest); void cancelAnimations(); - qreal keylineCenter(int io) const; qreal keylineSize(int io) const; - qreal sampleCenter(qreal childLoc) const; qreal sampleSize(qreal childLoc) const; + qreal continuousIndex() const; int realIndexOf(int virtualIndex) const; + void updateCurrentIndexFromContentX(bool emitPreview); + void wrapContentIfNeeded(); void centerInstantlyOnReal(int realIndex); @@ -176,12 +191,13 @@ class CarouselView : public QQuickItem { Arrangement m_arrangement; + int m_layoutCapacity = 0; + qreal m_contentX = 0; int m_currentVirtualIndex = 0; int m_currentRealIndex = -1; int m_previewRealIndex = -1; - static constexpr int kLoopMultiplier = 401; int m_centerBlockStart = 0; QList m_slots; @@ -192,16 +208,19 @@ class CarouselView : public QQuickItem { bool m_dragging = false; bool m_dragActive = false; + QPointF m_pressPos; qreal m_pressContentX = 0; + QElapsedTimer m_dragTimer; qreal m_lastMoveX = 0; qint64 m_lastMoveT = 0; qreal m_velocity = 0; + QVariantAnimation* m_flickAnim = nullptr; bool m_initializedLayout = false; bool m_relayoutQueued = false; }; -}; // namespace ZShell::components +} // namespace ZShell::components From 43f070f887e9339b35a6c62cfc3a2c96eadb366d Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:29:48 +0200 Subject: [PATCH 08/18] clean-up plugin files --- Plugins/ZShell/Components/carouselview.cpp | 66 ---------------------- 1 file changed, 66 deletions(-) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 73ba4f4..027b0aa 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -199,20 +199,10 @@ void CarouselView::setMaxWidth(qreal v) { emit maxWidthChanged(); - /* - * Advertise the target width immediately. - * - * The parent QML object can then animate its own implicitWidth - * immediately instead of waiting for the delegate geometry. - */ const qreal targetWidth = targetWidthForMaxWidth(m_maxWidth); setImplicitWidth(targetWidth); - /* - * Grow the instantiated delegate pool if the new maximum needs - * more items. Never throw them away during a resize. - */ m_layoutCapacity = qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); @@ -269,27 +259,10 @@ void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { return; } - /* - * The carousel is horizontally centered. - * - * Changing its width therefore moves the local coordinate system - * by half the width delta. Keep the logical content stationary by - * moving contentX by the opposite amount. - * - * Crucially, do this directly instead of recomputing a logical - * index and converting it back. That avoids quantization and - * floating-point drift on every animation frame. - */ const qreal delta = (newWidth - oldWidth) / 2.0; m_contentX -= delta; - /* - * A running glide/flick animation is expressed in contentX - * coordinates too. Shift both endpoints by exactly the same - * amount so the animation continues seamlessly in the new - * geometry. - */ auto shiftAnimation = [delta](QVariantAnimation* anim) { if (anim->state() != QAbstractAnimation::Running) { return; @@ -356,9 +329,6 @@ CarouselView::Arrangement CarouselView::arrangementForCount(int n) const { sideWidth += size; } - /* - * This is exactly the original full arrangement width. - */ a.width = F + 2.0 * n * sp + 2.0 * sideWidth; a.centers.append(0); @@ -403,15 +373,6 @@ qreal CarouselView::targetWidthForMaxWidth(qreal maxWidth) const { return 0; } - /* - * This deliberately preserves the old behavior: - * - * choose the largest complete arrangement whose outermost - * delegate can remain at minEdgeWidth. - * - * Therefore the component's desired width can be smaller than - * maxWidth. - */ const int n = layoutCapacityForWidth(maxWidth); return arrangementForCount(n).width; @@ -444,24 +405,11 @@ void CarouselView::rebuildArrangement() { const Arrangement full = arrangementForCount(m_layoutCapacity); - /* - * If the current width can display the complete capacity - * arrangement, use that exact geometry. - */ if (W >= full.width) { a = full; return; } - /* - * Otherwise find the complete arrangement immediately above W. - * - * Example: - * - * width(8) >= W >= width(7) - * - * means we're in the 8 -> 7 transition. - */ int highN = 1; for (int n = 1; n <= m_layoutCapacity; ++n) { @@ -488,10 +436,6 @@ void CarouselView::rebuildArrangement() { t = qBound(0.0, t, 1.0); - /* - * Keep the outgoing slot instantiated and visible while it - * smoothly transitions to zero. - */ a.n = highN; a.width = W; @@ -505,12 +449,6 @@ void CarouselView::rebuildArrangement() { a.sizes[highN] = high.sizes[highN] * (1.0 - t); - /* - * All normal gaps stay constant. - * - * Only the gap immediately before the disappearing delegate - * collapses together with that delegate. - */ a.outerSpacing = m_itemSpacing * (1.0 - t); a.centers.append(0); @@ -909,10 +847,6 @@ void CarouselView::relayout() { return; } - /* - * Keep enough slots for the largest layout we've needed. - * The currently visible count is determined by m_arrangement. - */ const int half = m_layoutCapacity + 2; const int lo = m_currentVirtualIndex - half; From 43647dd4863af5de1225cd10bdd7c73e44510eab Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:45:55 +0200 Subject: [PATCH 09/18] fix currentItem property and signals --- Plugins/ZShell/Components/carouselview.cpp | 21 +++++++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 11 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 027b0aa..9428c1a 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -89,6 +89,8 @@ void CarouselView::setModel(const QVariantList& m) { if (newCount == 0) { m_currentRealIndex = -1; + m_currentItem = nullptr; + emit currentItemChanged(); releaseAllItems(); rebuildArrangement(); @@ -248,6 +250,22 @@ void CarouselView::setCurrentIndex(int idx) { goToIndex(idx); } +void CarouselView::updateCurrentItem() { + QQuickItem* item = nullptr; + + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.virtualIndex == m_currentVirtualIndex) { + item = s.item; + break; + } + } + + if (item == m_currentItem) return; + + m_currentItem = item; + emit currentItemChanged(); +} + void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); @@ -844,6 +862,7 @@ void CarouselView::relayout() { if (s.item) s.item->setVisible(false); } + updateCurrentItem(); return; } @@ -918,6 +937,8 @@ void CarouselView::relayout() { for (const Slot& s : std::as_const(m_slots)) { positionSlot(s); } + + updateCurrentItem(); } void CarouselView::cancelAnimations() { diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index a5b2ef9..d822c79 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -40,6 +40,10 @@ class CarouselView : public QQuickItem { Q_PROPERTY( int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) + + Q_PROPERTY( + QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged) + Q_PROPERTY( int glideDuration READ glideDuration WRITE setGlideDuration NOTIFY glideDurationChanged) @@ -79,6 +83,8 @@ class CarouselView : public QQuickItem { int currentIndex() const { return m_currentRealIndex; } void setCurrentIndex(int idx); + QQuickItem* currentItem() const { return m_currentItem; } + int glideDuration() const { return m_glideDuration; } void setGlideDuration(int v); @@ -103,6 +109,7 @@ class CarouselView : public QQuickItem { void tileHeightChanged(); void maxWidthChanged(); void currentIndexChanged(); + void currentItemChanged(); void glideDurationChanged(); void glideEasingTypeChanged(); void delegatePropertiesChanged(); @@ -173,6 +180,8 @@ class CarouselView : public QQuickItem { void updateCurrentIndexFromContentX(bool emitPreview); + void updateCurrentItem(); + void wrapContentIfNeeded(); void centerInstantlyOnReal(int realIndex); @@ -198,6 +207,8 @@ class CarouselView : public QQuickItem { int m_currentRealIndex = -1; int m_previewRealIndex = -1; + QQuickItem* m_currentItem = nullptr; + int m_centerBlockStart = 0; QList m_slots; From 69c2bfae9d5b7effd44d5a4b810cd74d0da62a83 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 23:13:37 +0200 Subject: [PATCH 10/18] fix search trickle through actions and variants view and mouse event stealing by wallpapertile --- Helpers/Searcher.qml | 1 + Modules/Launcher/AppList.qml | 111 +++++++++++++++------ Plugins/ZShell/Components/carouselview.cpp | 84 ++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 2 + 4 files changed, 168 insertions(+), 30 deletions(-) diff --git a/Helpers/Searcher.qml b/Helpers/Searcher.qml index 23f15e5..d03bc41 100644 --- a/Helpers/Searcher.qml +++ b/Helpers/Searcher.qml @@ -26,6 +26,7 @@ Singleton { function query(search: string): list { search = transformSearch(search); + console.log(!search, search, ...list); if (!search) return [...list]; diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 497bbec..4bb7921 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -1,28 +1,36 @@ pragma ComponentBehavior: Bound -import Quickshell import QtQuick -import qs.Modules.Launcher.Services -import qs.Modules.Launcher.Items -import qs.Components +import Quickshell import ZShell.Config +import qs.Components import qs.Services +import qs.Modules.Launcher.Items +import qs.Modules.Launcher.Services CustomListView { id: root + readonly property string displayState: stateForText(displayText) + property string displayText + readonly property string requestedState: stateForText(search.text) required property SearchBar search required property PersistentProperties visibilities - highlightFollowsCurrentItem: false - highlightRangeMode: ListView.ApplyRange - implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing - orientation: Qt.Vertical - preferredHighlightBegin: 0 - preferredHighlightEnd: height - spacing: Tokens.spacing.small - state: { - const text = search.text; + function resultsForText(text: string): var { + switch (stateForText(text)) { + case "actions": + return Actions.query(text); + case "calc": + return [0]; + case "variant": + return SchemeVariants.query(text); + default: + return Apps.search(text); + } + } + + function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { for (const action of ["calc", "scheme", "variant"]) @@ -34,6 +42,20 @@ CustomListView { return "apps"; } + + function syncDisplayText(): void { + if (visibilities.launcher && requestedState === displayState) + displayText = search.text; + } + + highlightFollowsCurrentItem: false + highlightRangeMode: ListView.ApplyRange + implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing + orientation: Qt.Vertical + preferredHighlightBegin: 0 + preferredHighlightEnd: height + spacing: Tokens.spacing.small + state: visibilities.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -44,19 +66,21 @@ CustomListView { Anim { from: 0 - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } addDisplaced: Transition { Anim { - duration: Tokens.anim.durations.small property: "y" + type: Anim.StandardSmall } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } displaced: Transition { @@ -65,8 +89,9 @@ CustomListView { } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } highlight: CustomRect { @@ -85,7 +110,7 @@ CustomListView { } } model: ScriptModel { - id: model + values: root.resultsForText(root.displayText) onValuesChanged: root.currentIndex = 0 } @@ -95,8 +120,9 @@ CustomListView { } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } remove: Transition { @@ -104,8 +130,9 @@ CustomListView { Anim { from: 1 - properties: "opacity,scale" + property: "opacity" to: 0 + type: Anim.DefaultEffects } } states: [ @@ -113,7 +140,6 @@ CustomListView { name: "apps" PropertyChanges { - model.values: Apps.search(search.text) root.delegate: appItem } }, @@ -121,7 +147,6 @@ CustomListView { name: "actions" PropertyChanges { - model.values: Actions.query(search.text) root.delegate: actionItem } }, @@ -129,7 +154,6 @@ CustomListView { name: "calc" PropertyChanges { - model.values: [0] root.delegate: calcItem } }, @@ -137,7 +161,6 @@ CustomListView { name: "variant" PropertyChanges { - model.values: SchemeVariants.query(search.text) root.delegate: variantItem } } @@ -147,7 +170,7 @@ CustomListView { ParallelAnimation { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardAccel from: 1 property: "opacity" target: root @@ -156,7 +179,7 @@ CustomListView { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardAccel from: 1 property: "scale" target: root @@ -165,14 +188,24 @@ CustomListView { } PropertyAction { - properties: "values,delegate" - targets: [model, root] + property: "delegate" + target: root + value: null + } + + ScriptAction { + script: root.displayText = root.search.text + } + + PropertyAction { + property: "delegate" + target: root } ParallelAnimation { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardDecel from: 0 property: "opacity" target: root @@ -181,7 +214,7 @@ CustomListView { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardDecel from: 0.9 property: "scale" target: root @@ -197,6 +230,8 @@ CustomListView { } } + Component.onCompleted: displayText = search.text + Component { id: appItem @@ -228,4 +263,20 @@ CustomListView { list: root } } + + Connections { + function onTextChanged() { + root.syncDisplayText(); + } + + target: root.search + } + + Connections { + function onLauncherChanged() { + root.syncDisplayText(); + } + + target: root.visibilities + } } diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 9428c1a..c1341d5 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -21,6 +21,7 @@ constexpr int kMaxLayoutCapacity = 64; CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { setAcceptedMouseButtons(Qt::LeftButton); + setFiltersChildMouseEvents(true); setFlag(QQuickItem::ItemHasContents, false); setClip(false); @@ -80,6 +81,89 @@ void CarouselView::setDelegate(QQmlComponent* c) { relayout(); } +bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { + Q_UNUSED(item); + + auto* mouseEvent = dynamic_cast(event); + if (!mouseEvent) return false; + + const QPointF pos = mapFromItem(item, mouseEvent->position()); + + switch (mouseEvent->type()) { + case QEvent::MouseButtonPress: { + if (mouseEvent->button() != Qt::LeftButton) return false; + + m_dragging = true; + m_dragActive = false; + + m_pressPos = pos; + m_pressContentX = m_contentX; + + m_dragTimer.start(); + + m_lastMoveX = pos.x(); + m_lastMoveT = 0; + m_velocity = 0; + + return false; + } + + case QEvent::MouseMove: { + if (!m_dragging) return false; + + const qreal dx = pos.x() - m_pressPos.x(); + + if (!m_dragActive && std::abs(dx) > kDragThreshold) { + m_dragActive = true; + m_pressContentX = m_contentX; + + cancelAnimations(); + grabMouse(); + } + + if (!m_dragActive) return false; + + m_contentX = m_pressContentX - dx; + + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + + const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + + if (dt > 0) { + const qreal instVel = (m_lastMoveX - pos.x()) / (dt / 1000.0); + + m_velocity = m_velocity * 0.7 + instVel * 0.3; + } + + m_lastMoveX = pos.x(); + m_lastMoveT = t; + + mouseEvent->accept(); + return true; + } + + case QEvent::MouseButtonRelease: { + if (!m_dragging) return false; + + if (!m_dragActive) { + m_dragging = false; + return false; + } + + return false; + } + + case QEvent::MouseButtonDblClick: + return false; + + default: + return false; + } +} + void CarouselView::setModel(const QVariantList& m) { m_model = m; diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index d822c79..14dea74 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -118,6 +118,8 @@ class CarouselView : public QQuickItem { void previewIndexChanged(int realIndex, QVariant modelData); protected: + bool childMouseEventFilter(QQuickItem* item, QEvent* event) override; + void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; void mousePressEvent(QMouseEvent* event) override; From babfe4b3de29beacd7d237db21c42dc13c44e5b3 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 10:37:00 +0200 Subject: [PATCH 11/18] flick velocity and deceleration --- Helpers/Searcher.qml | 1 - Plugins/ZShell/Components/carouselview.cpp | 77 ++++++++++------------ Plugins/ZShell/Components/carouselview.hpp | 16 ++++- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Helpers/Searcher.qml b/Helpers/Searcher.qml index d03bc41..23f15e5 100644 --- a/Helpers/Searcher.qml +++ b/Helpers/Searcher.qml @@ -26,7 +26,6 @@ Singleton { function query(search: string): list { search = transformSearch(search); - console.log(!search, search, ...list); if (!search) return [...list]; diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index c1341d5..70776fe 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -46,7 +46,7 @@ CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { }); m_flickAnim = new QVariantAnimation(this); - m_flickAnim->setEasingCurve(QEasingCurve::Linear); + m_flickAnim->setEasingCurve(QEasingCurve::OutQuad); connect( m_flickAnim, @@ -101,9 +101,8 @@ bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { m_dragTimer.start(); - m_lastMoveX = pos.x(); - m_lastMoveT = 0; - m_velocity = 0; + m_velocitySamples.clear(); + addVelocitySample(0, pos.x()); return false; } @@ -129,17 +128,7 @@ bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { updateCurrentIndexFromContentX(true); relayout(); - const qint64 t = m_dragTimer.elapsed(); - const qint64 dt = t - m_lastMoveT; - - if (dt > 0) { - const qreal instVel = (m_lastMoveX - pos.x()) / (dt / 1000.0); - - m_velocity = m_velocity * 0.7 + instVel * 0.3; - } - - m_lastMoveX = pos.x(); - m_lastMoveT = t; + addVelocitySample(m_dragTimer.elapsed(), pos.x()); mouseEvent->accept(); return true; @@ -1141,10 +1130,8 @@ void CarouselView::mousePressEvent(QMouseEvent* event) { m_dragTimer.start(); - m_lastMoveX = event->position().x(); - - m_lastMoveT = 0; - m_velocity = 0; + m_velocitySamples.clear(); + addVelocitySample(0, event->position().x()); event->accept(); } @@ -1170,32 +1157,43 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { updateCurrentIndexFromContentX(true); relayout(); - const qint64 t = m_dragTimer.elapsed(); - - const qint64 dt = t - m_lastMoveT; - - if (dt > 0) { - const qreal instVel = - (m_lastMoveX - event->position().x()) / (dt / 1000.0); - - m_velocity = m_velocity * 0.7 + instVel * 0.3; - } - - m_lastMoveX = event->position().x(); - - m_lastMoveT = t; + addVelocitySample(m_dragTimer.elapsed(), event->position().x()); event->accept(); } +void CarouselView::addVelocitySample(qint64 t, qreal x) { + m_velocitySamples.append({t, x}); + + while (m_velocitySamples.size() > 1 && + t - m_velocitySamples.first().t > kVelocitySampleWindowMs) { + m_velocitySamples.removeFirst(); + } +} + +qreal CarouselView::sampledVelocity() const { + if (m_velocitySamples.size() < 2) return 0; + + const VelocitySample& oldest = m_velocitySamples.first(); + const VelocitySample& newest = m_velocitySamples.last(); + + const qreal dt = newest.t - oldest.t; + + if (dt < 8) return 0; + + return (oldest.x - newest.x) / (dt / 1000.0); +} + void CarouselView::mouseReleaseEvent(QMouseEvent* event) { if (!m_dragging) return; m_dragging = false; + const bool wasDragActive = m_dragActive; + ungrabMouse(); - if (!m_dragActive) { + if (!wasDragActive) { cancelAnimations(); snapToNearest(); @@ -1205,7 +1203,7 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { m_dragActive = false; - qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + qreal v = qBound(-kMaxFlickVelocity, sampledVelocity(), kMaxFlickVelocity); if (std::abs(v) < kMinFlickVelocity) { snapToNearest(); @@ -1215,24 +1213,17 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { } const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); - const qreal distance = (v * (duration / 1000.0)) / 2.0; - const qreal rawDest = m_contentX + distance; - const qreal dest = contentXForIndex(indexForContentX(rawDest)); cancelAnimations(); m_flickAnim->setDuration( static_cast(qBound(120, duration, 900))); - - m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); - + m_flickAnim->setEasingCurve(QEasingCurve::OutQuad); m_flickAnim->setStartValue(m_contentX); - m_flickAnim->setEndValue(dest); - m_flickAnim->start(); event->accept(); diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 14dea74..3f98b35 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include namespace ZShell::components { @@ -150,6 +152,11 @@ class CarouselView : public QQuickItem { bool active = false; }; + struct VelocitySample { + qint64 t; + qreal x; + }; + void rebuildArrangement(); void relayout(); @@ -190,6 +197,9 @@ class CarouselView : public QQuickItem { QQuickItem* acquireItem(); void releaseAllItems(); + void addVelocitySample(qint64 t, qreal x); + qreal sampledVelocity() const; + QQmlComponent* m_delegate = nullptr; QVariantList m_model; QVariantMap m_delegateProperties; @@ -226,9 +236,9 @@ class CarouselView : public QQuickItem { qreal m_pressContentX = 0; QElapsedTimer m_dragTimer; - qreal m_lastMoveX = 0; - qint64 m_lastMoveT = 0; - qreal m_velocity = 0; + + QVector m_velocitySamples; + static constexpr qint64 kVelocitySampleWindowMs = 80; QVariantAnimation* m_flickAnim = nullptr; From abb180c947eabab3b979d7981dcee29283fe15dd Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 13:24:08 +0200 Subject: [PATCH 12/18] fix status icons visibility in fullscreen + wrong property name --- Modules/Bar/Bar.qml | 1 + Modules/Bar/BarLoader.qml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index f898ae6..fe33739 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -138,6 +138,7 @@ GridLayout { delegate: EntryWrapper { StatusIcons { horizontal: root.horizontal + visible: !root.fullscreen || root.isHovered } } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index e34459f..fcdd178 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -79,7 +79,7 @@ Item { sourceComponent: Bar { fullscreen: root.fullscreen - height: root.contentHeight + height: root.contentThickness horizontal: root.horizontal isHovered: root.isHovered popouts: root.popouts From 177b0f319cf40b08542f1ef257b59176b3bab785 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 14:23:24 +0200 Subject: [PATCH 13/18] proper color scheme preview to avoid watcher creation spam --- Helpers/WallpaperPath.qml | 5 +++- Helpers/Wallpapers.qml | 34 +++++++++++++++++++++++----- Modules/Launcher/Content.qml | 2 +- Services/Colors.qml | 2 +- cli/src/zshell/subcommands/scheme.py | 31 ++++++++++++++++++++----- 5 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Helpers/WallpaperPath.qml b/Helpers/WallpaperPath.qml index 89e9a06..d6beb9f 100644 --- a/Helpers/WallpaperPath.qml +++ b/Helpers/WallpaperPath.qml @@ -17,7 +17,10 @@ Singleton { watchChanges: true onAdapterUpdated: writeAdapter() - onFileChanged: reload() + onFileChanged: { + reload(); + Wallpapers.previewColorLock = false; + } JsonAdapter { id: adapter diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index 1334f06..ba4b64c 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -6,7 +6,7 @@ import Quickshell.Io import QtQuick import ZShell.Models import ZShell.Config -import qs.Modules +import qs.Services import qs.Helpers import qs.Paths @@ -17,6 +17,8 @@ Searcher { property alias crops: adapter.monitorCrops readonly property string current: showPreview ? previewPath : actualCurrent property alias monitorCrops: monitorCrops + property bool pendingPreviewClear + property bool previewColorLock property string previewPath property bool recentlyChanged property bool showPreview: false @@ -34,9 +36,10 @@ Searcher { function preview(path: string): void { previewPath = path; - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${previewPath}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); showPreview = true; + + if (Config.general.color.schemeGeneration) + previewColorsProc.running = true; } function setCrop(screen: string, rect: rect, zoom: real): void { @@ -52,7 +55,6 @@ Searcher { height: rect.height, zoom: zoom }; - // root.crops = updated; } function setWallpaper(path: string): void { @@ -66,8 +68,10 @@ Searcher { function stopPreview(): void { showPreview = false; - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); + if (previewColorLock) + pendingPreviewClear = true; + else + Colors.showPreview = false; } extraOpts: useFuzzy ? ({}) : ({ @@ -77,6 +81,11 @@ Searcher { list: wallpapers.entries useFuzzy: true + onPreviewColorLockChanged: { + if (!previewColorLock && pendingPreviewClear) + Colors.showPreview = false; + } + IpcHandler { function set(path: string): void { root.setWallpaper(path); @@ -118,4 +127,17 @@ Searcher { path: Config.general.wallpaperPath recursive: true } + + Process { + id: previewColorsProc + + command: ["zshell-cli", "scheme", "generate", "-p", "-i", root.previewPath] + + stdout: StdioCollector { + onStreamFinished: { + Colors.load(text, true); + Colors.showPreview = true; + } + } + } } diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 88d776b..da6ac31 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -79,7 +79,7 @@ Item { if (currentItem) { if (list.showWallpapers) { if (Colors.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) - Wallpapers.previewColourLock = true; + Wallpapers.previewColorLock = true; Wallpapers.setWallpaper(currentItem.modelData.path); root.visibilities.launcher = false; } else if (text.startsWith(Config.launcher.actionPrefix)) { diff --git a/Services/Colors.qml b/Services/Colors.qml index bff79f1..863c03f 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -69,7 +69,7 @@ Singleton { } for (const [name, color] of Object.entries(scheme.colors)) { - const propName = name.startsWith("term") ? name : `m3${name}`; + const propName = `m3${name}`; if (colors.hasOwnProperty(propName)) colors[propName] = `${color}`; } diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index 7ba1dd1..ed60e8b 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -116,10 +116,15 @@ def list_presets( @app.command() def generate( image_path: Path | None = typer.Option( - None, help="Path to source image. Required for image mode." + None, + "--image-path", + "-i", + help="Path to source image. Required for image mode.", ), scheme: str | None = typer.Option( None, + "--scheme", + "-s", help="Color scheme algorithm to use for image mode. Ignored in preset mode.", autocompletion=_complete_scheme_name, ), @@ -130,16 +135,26 @@ def generate( ), mode: str | None = typer.Option( None, + "--mode", + "-m", help="Mode of the preset scheme (dark or light).", autocompletion=_complete_mode, ), accent: str | None = typer.Option( None, + "--accent", + "-a", help="Accent for schemes that support it (e.g. mauve).", autocompletion=_complete_accent, ), + preview: bool = typer.Option( + False, + "--preview", + "-p", + help="Preview scheme - internal use", + ), ): - if not any([image_path, scheme, preset, mode, accent]): + if not any([image_path, scheme, preset, mode, accent, preview]): print( "Hint: use --preset : or --image-path ", file=sys.stderr, @@ -685,10 +700,6 @@ def generate( variant_val = scheme if not preset else p_variant - if smart and not preset: - apply_gtk_mode(effective_mode) - apply_qt_mode(effective_mode, HOME) - output_dict = { "name": name, "flavor": flavor, @@ -698,6 +709,14 @@ def generate( "seed": seed.to_int(), } + if preview: + print(json.dumps(output_dict)) + return + + if smart and not preset: + apply_gtk_mode(effective_mode) + apply_qt_mode(effective_mode, HOME) + if TEMPLATE_DIR is not None: wp = str(WALL_PATH) ctx = build_template_context( From c7bf7cdc0b38dfad20f39e4a721c55c5e0e7b9b7 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 16:53:47 +0200 Subject: [PATCH 14/18] fix recursive wallpaper model in settings + clock font --- Greeter/Components/CustomTextInput.qml | 3 +- Modules/Bar/Components/Clock.qml | 4 +-- Modules/Notifications/Notification.qml | 6 +--- .../Pages/Wallpaper/WallpaperSelect.qml | 31 +++---------------- Modules/Settings/Wrapper.qml | 6 ++-- 5 files changed, 13 insertions(+), 37 deletions(-) diff --git a/Greeter/Components/CustomTextInput.qml b/Greeter/Components/CustomTextInput.qml index 2a6dcc6..42c6ead6 100644 --- a/Greeter/Components/CustomTextInput.qml +++ b/Greeter/Components/CustomTextInput.qml @@ -1,5 +1,4 @@ import QtQuick -import QtQuick.Controls import ZShell.Config import qs.Services @@ -11,6 +10,6 @@ TextInput { font { family: Config.appearance?.font.family.sans ?? "sans-serif" hintingPreference: Font.PreferFullHinting - pixelSize: Config.appearance?.font.size.normal ?? 15 + pixelSize: Tokens?.font.size.normal ?? 15 } } diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 0125670..be0b5bd 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -114,7 +114,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type text: { if (modelData.includes("h")) return Time.hourStr; @@ -135,7 +135,7 @@ WidgetBase { CustomText { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type text: Qt.formatDateTime(Time.date, "AP") visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index f0ffc6a..a4ea654 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -464,11 +464,7 @@ CustomRect { enabled: root.expanded fillWidth: true - font: { - const f = Qt.font(font); - f.pointSize = Tokens.font.size.small; - return f; - } + font.pointSize: Tokens.font.size.small implicitWidth: label.implicitWidth inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 8235079..c1866f7 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -40,40 +40,19 @@ PageBase { id: localWalls model: { - const walls = Wallpapers.list; + const walls = [...Wallpapers.list]; var baseDir = Paths.wallsdir; - const categories = {}; - const list = []; - for (const w of walls) { - var parentDir = w.parentDir; - if (!parentDir.endsWith("/")) - parentDir = parentDir + "/"; + walls.sort((a, b) => ((a.parentDir === baseDir) - (b.parentDir === baseDir)) || a.name.localeCompare(b.name)); + while (walls.length < 4) + walls.push(null); - if (!baseDir.endsWith("/")) - baseDir = baseDir + "/"; - - if (parentDir !== baseDir) { - const category = Wallpapers.getCategoryFor(w); - if (category && (!(category in categories) || categories[category].name.localeCompare(w.name) > 0)) - categories[category] = w; - } else { - list.push(w); - } - } - list.push(...Object.values(categories)); - list.sort((a, b) => ((a.parentDir === baseDir) - (b.parentDir === baseDir)) || a.name.localeCompare(b.name)); - while (list.length < 4) - list.push(null); - - return list; + return walls; } WallItem { required property FileSystemEntry modelData enabled: modelData - - // Empty placeholders for sizing opacity: modelData ? 1 : 0 source: String(modelData?.path ?? "") diff --git a/Modules/Settings/Wrapper.qml b/Modules/Settings/Wrapper.qml index 12c48ca..2c03246 100644 --- a/Modules/Settings/Wrapper.qml +++ b/Modules/Settings/Wrapper.qml @@ -1,14 +1,16 @@ +pragma ComponentBehavior: Bound + import Quickshell import QtQuick import qs.Components import ZShell.Config -import qs.Helpers Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 required property var panels + property string queuedMode: "wallpaper" required property ShellScreen screen readonly property bool shouldBeActive: visibilities.settings required property PersistentProperties visibilities @@ -37,7 +39,7 @@ Item { sourceComponent: Content { sState.animatingContainer: content.opacity < 1 - sState.currentPageIdx: ["wallpaper"][0] + sState.currentPageIdx: ["wallpaper"].indexOf(root.queuedMode) sState.screen: root.screen } } From 2c62ad093c7d7ff555492af7c0bf8478b1cccae8 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 18:30:43 +0200 Subject: [PATCH 15/18] fix incorrect font property for other text objects in clock --- Modules/Bar/Components/Clock.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index be0b5bd..b6dd07d 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -57,7 +57,7 @@ WidgetBase { anchors.centerIn: parent color: root.contentColor - font: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type height: implicitHeight text: Time.dateStr visible: root.horizontal @@ -84,7 +84,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type text: Qt.formatDateTime(Time.date, modelData) Behavior on color { From db76ae7dc61f1f7b1cab0bfcf04c1e406a713cb5 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 18:49:47 +0200 Subject: [PATCH 16/18] fix dash currentTab property --- Modules/Dashboard/Content.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 1bc0ca1..fc465b2 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -41,7 +41,7 @@ Item { Item { id: view - readonly property int currentIndex: root.state.currentTab + readonly property int currentIndex: root.dashState?.currentTab readonly property Item currentItem: row.children[currentIndex] anchors.fill: parent From 98fc9aa97a8a1501fcd54d92157b18ccb5456261 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 21:01:04 +0200 Subject: [PATCH 17/18] add wavyline slider to dash media widget --- Components/CustomSlider.qml | 7 +- Modules/Dashboard/Dash/Media.qml | 287 +++++++++++++++---------------- 2 files changed, 145 insertions(+), 149 deletions(-) diff --git a/Components/CustomSlider.qml b/Components/CustomSlider.qml index 755d21f..3c906d8 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -13,11 +13,13 @@ Slider { property bool animateWave property color bgColor: enabled ? Colors.palette.m3secondaryContainer : Qt.alpha(Colors.palette.m3onSurface, 0.1) + readonly property bool dragging: mouse.pressed property color fgColor: enabled ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3onSurface, 0.38) property real filledWidth property alias inset: inset property color insetColor: enabled ? (inset.attached ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onPrimary) : Colors.palette.m3onSurface property string insetIcon: "" + property bool interactionOnMove: true property real pos: visualPosition property int radius: Tokens.rounding.small property int waveDuration: 1000 @@ -139,7 +141,7 @@ Slider { CAnim { } } - Anim on waveProgress { + NumberAnimation on waveProgress { duration: root.waveDuration easing.type: Easing.Linear from: 0 @@ -186,7 +188,8 @@ Slider { onPositionChanged: e => { dragMovement = (e.x - pressStartX) / width; - root.interaction(posBinding.value); + if (root.interactionOnMove) + root.interaction(posBinding.value); } onPressed: e => { widthBehavior.enabled = false; diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml index 67a2384..775db58 100644 --- a/Modules/Dashboard/Dash/Media.qml +++ b/Modules/Dashboard/Dash/Media.qml @@ -1,10 +1,12 @@ pragma ComponentBehavior: Bound import Quickshell +import Quickshell.Services.Mpris import QtQuick import QtQuick.Layouts import QtQuick.Shapes import QtQuick.Effects +import ZShell.Components import ZShell.Services import ZShell.Config import qs.Daemons @@ -14,7 +16,7 @@ import qs.Services Item { id: root - + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 property real playerProgress: { const active = Players.active; return active?.length ? (active.position % active.length) / active.length : 0; @@ -23,7 +25,19 @@ Item { anchors.left: parent.left anchors.right: parent.right - implicitHeight: cover.height + rowHeight * 2 + implicitHeight: layout.implicitHeight + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } Behavior on playerProgress { Anim { @@ -32,11 +46,10 @@ Item { } Timer { - interval: Config.dashboard.mediaUpdateInterval - repeat: true running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval triggeredOnStart: true - + repeat: true onTriggered: Players.active?.positionChanged() } @@ -72,7 +85,7 @@ Item { const n = values.length; if (n < 2) - return ""; + return ""; const h = layout.height + shape.strokeWidth / 2; const w = layout.width + shape.strokeWidth; @@ -126,58 +139,11 @@ Item { } } - Shape { - preferredRendererType: Shape.CurveRenderer - - ShapePath { - capStyle: Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - - Behavior on strokeColor { - CAnim { - } - } - - PathAngleArc { - centerX: cover.x + cover.width / 2 - centerY: cover.y + cover.height / 2 - radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 - sweepAngle: Config.dashboard.sizes.mediaProgressSweep - } - } - - ShapePath { - capStyle: Tokens.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: Colors.palette.m3primary - strokeWidth: Config.dashboard.sizes.mediaProgressThickness - - Behavior on strokeColor { - CAnim { - } - } - - PathAngleArc { - centerX: cover.x + cover.width / 2 - centerY: cover.y + cover.height / 2 - radiusX: (cover.width + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - radiusY: (cover.height + Config.dashboard.sizes.mediaProgressThickness) / 2 + Tokens.spacing.small - startAngle: -90 - Config.dashboard.sizes.mediaProgressSweep / 2 - sweepAngle: Config.dashboard.sizes.mediaProgressSweep * root.playerProgress - } - } - } - RowLayout { id: layout anchors.left: parent.left anchors.right: parent.right - implicitHeight: root.implicitHeight CustomClippingRect { id: cover @@ -211,147 +177,174 @@ Item { } } - CustomRect { + ColumnLayout { Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.large + Layout.rightMargin: Tokens.spacing.large Layout.preferredHeight: childrenRect.height + spacing: Tokens.spacing.normal + MarqueeText { id: title - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3primary font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: album - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: title.bottom - anchors.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: artist - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: album.bottom - anchors.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3secondary horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - width: parent.width - Tokens.padding.large * 4 + implicitWidth: parent.width - Tokens.padding.large * 4 } RowLayout { - id: controls - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: artist.bottom - anchors.topMargin: Tokens.spacing.smaller + id: positionSliderLayout + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true spacing: Tokens.spacing.small - Control { - function onClicked(): void { - Players.active?.previous(); - } + TextMetrics { + id: timeMetrics - canUse: Players.active?.canGoPrevious ?? false + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + Layout.leftMargin: positionSliderLayout.implicitWidth / 4 + Layout.rightMargin: positionSliderLayout.implicitWidth / 4 + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal icon: "skip_previous" + isRound: true + shapeMorph: true + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() } - Control { - function onClicked(): void { - Players.active?.togglePlaying(); - } + IconButton { + id: playPauseBtn - canUse: Players.active?.canTogglePlaying ?? false icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() } - Control { - function onClicked(): void { - Players.active?.next(); - } + IconButton { + id: nextBtn - canUse: Players.active?.canGoNext ?? false + type: IconButton.Tonal icon: "skip_next" + isRound: true + shapeMorph: true + 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 + 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) } } } } - - component Control: CustomRect { - id: control - - required property bool canUse - required property string icon - property int level: 1 - property string set_color: "Secondary" - - function onClicked(): void { - } - - Layout.preferredWidth: implicitWidth + (controlState.pressed ? Tokens.padding.normal * 2 : 0) - color: canUse ? Colors.palette[`m3${set_color.toLowerCase()}`] : Colors.palette[`m3${set_color.toLowerCase()}Container`] - implicitHeight: implicitWidth - implicitWidth: Math.max(icon.implicitHeight, icon.implicitHeight) + Tokens.padding.small - radius: Tokens.rounding.full - - Behavior on Layout.preferredWidth { - Anim { - duration: Tokens.anim.durations.expressiveFastSpatial - easing.bezierCurve: Tokens.anim.curves.expressiveFastSpatial - } - } - Behavior on radius { - Anim { - duration: Tokens.anim.durations.expressiveFastSpatial - easing.bezierCurve: Tokens.anim.curves.expressiveFastSpatial - } - } - - Elevation { - anchors.fill: parent - level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level - radius: parent.radius - z: -1 - } - - StateLayer { - id: controlState - - color: control.canUse ? Colors.palette[`m3on${control.set_color}`] : Colors.palette[`m3on${control.set_color}Container`] - enabled: control.canUse - - onClicked: { - control.onClicked(); - } - // radius: Tokens.rounding.full - } - - MaterialIcon { - id: icon - - anchors.centerIn: parent - anchors.verticalCenterOffset: font.pointSize * 0.05 - animate: true - color: control.canUse ? Colors.palette[`m3on${control.set_color}`] : Colors.palette[`m3on${control.set_color}Container`] - fill: control.canUse ? 1 : 0 - font.pointSize: Tokens.font.size.large - text: control.icon - } - } } From 46855b6a7f21051e6b164da2086a50897db6fb40 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 21:21:51 +0200 Subject: [PATCH 18/18] fix new scheme preview bug with wallpaper carousel --- Modules/Launcher/CarouselView.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index 595dfa2..c5cae5d 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -47,7 +47,8 @@ CarouselView { jumpToIndex(idx); } Component.onDestruction: Wallpapers.stopPreview() - onPreviewIndexChanged: (realIndex, modelData) => { - Wallpapers.preview(modelData.path); + onCurrentItemChanged: { + if (currentItem) + Wallpapers.preview(currentItem.modelData.path); } }