From 3bbf02f502d0581ee467baa81ff76b773eafc41e Mon Sep 17 00:00:00 2001 From: bbedward Date: Wed, 30 Sep 2026 16:00:48 -0400 Subject: [PATCH] settings: some changes ref #3635 cc: add trash icon to drag and crush things cc: allow anchoring footer items to start or end --- .../Modals/Settings/SettingsSidebar.qml | 22 +- .../ControlCenter/Components/CcFooter.qml | 225 ++++++++++++++---- .../ControlCenter/ControlCenterContent.qml | 37 ++- .../Modules/ControlCenter/utils/widgets.js | 16 +- .../Settings/Widgets/SettingsMetrics.qml | 2 + .../Settings/Widgets/SettingsSidebarItem.qml | 22 +- 6 files changed, 261 insertions(+), 63 deletions(-) diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index d0488afb1..af864c4b0 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -180,13 +180,25 @@ Item { DankSearchField { id: searchField + + property real sideInset: root.searchActive ? Theme.spacingS : Theme.spacingL + + Behavior on sideInset { + enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.leftMargin: Theme.spacingL - anchors.rightMargin: Theme.spacingL - anchors.topMargin: Theme.spacingM - height: Theme.iconButtonSize + Theme.spacingM + anchors.leftMargin: sideInset + anchors.rightMargin: sideInset + anchors.topMargin: SettingsMetrics.searchBarGap + height: SettingsMetrics.searchBarHeight placeholderText: I18n.tr("Search settings", "settings search field placeholder") onFocusStateChanged: hasFocus => root.searchFocused = hasFocus onTextChanged: { @@ -262,7 +274,7 @@ Item { anchors.right: parent.right anchors.top: searchField.bottom anchors.bottom: parent.bottom - anchors.topMargin: SettingsMetrics.sidebarGroupGap + anchors.topMargin: SettingsMetrics.searchBarGap clip: true contentHeight: sidebarColumn.height diff --git a/quickshell/Modules/ControlCenter/Components/CcFooter.qml b/quickshell/Modules/ControlCenter/Components/CcFooter.qml index 5b5aa9b0e..4e4313f43 100644 --- a/quickshell/Modules/ControlCenter/Components/CcFooter.qml +++ b/quickshell/Modules/ControlCenter/Components/CcFooter.qml @@ -19,15 +19,23 @@ Item { property var items: [] property bool onTop: false property var transientSurfaceTracker: null - // A grid tile hovering the row: {"cells", "at"}. + // A grid tile hovering the row: {"cells", "end", "at"}, with `at` counted within the start or end group. property var incoming: null // Takes (savedIndex, cells, scenePoint) and returns true when it takes the dropped item. property var dropHandler: null // Takes the dragged item's scene rect and returns true while something outside the row will take it. property var leavesRow: null property int liftedIndex: -1 - property int liftedAt: -1 + property var liftedSlot: null + property point liftedPoint: Qt.point(0, 0) + property bool gridDragging: false + property point gridDragPoint: Qt.point(0, 0) + // Set from the pointer, not bound: the slot reads the widths that the incoming tile changes. + property var gridSlot: null property var resizePreview: null + property Item flyingItem: null + readonly property bool dragging: gridDragging || liftedIndex >= 0 + readonly property bool overTrash: trashContains(liftedIndex >= 0 ? liftedPoint : gridDragPoint) signal addWidgetRequested signal resetRequested @@ -51,6 +59,7 @@ Item { readonly property int capacity: Math.max(0, Math.floor((trackWidth + spacing) / pitch)) readonly property var fills: items.map(item => resizePreview?.index === item.index ? resizePreview.fill : WidgetUtils.footerFills(item.widget)) readonly property var mins: items.map(item => WidgetUtils.footerMinCells(item.widget.id)) + readonly property var ends: items.map(item => WidgetUtils.footerEnds(item.widget)) // Fill items count at their minimum, so a drop or a neighbour growing takes room from them first. readonly property var fixedCells: WidgetUtils.fitFooterCells(items.map((item, i) => fills[i] ? mins[i] : (resizePreview?.index === item.index ? resizePreview.cells : WidgetUtils.footerCells(item.widget))), mins, capacity) readonly property int fixedUsed: fixedCells.reduce((sum, count) => sum + count, 0) @@ -68,32 +77,25 @@ Item { }); } readonly property var layout: { + const shown = groups(); const lifted = items.findIndex(item => item.index === liftedIndex); - const order = items.map((item, i) => i).filter(i => i !== lifted && cells[i] > 0); - const gapAt = lifted >= 0 ? liftedAt : (incoming?.at ?? -1); + const gap = lifted >= 0 ? liftedSlot : incoming; const gapCells = lifted >= 0 ? cells[lifted] : (incoming?.cells ?? 0); - const offsets = []; - let offset = 0; - let gapOffset = -1; - for (let k = 0; k <= order.length; k++) { - if (k === gapAt) { - gapOffset = offset; - offset += gapCells * pitch; - } - if (k === order.length) - break; - offsets[order[k]] = offset; - offset += cells[order[k]] * pitch + (order[k] === slackIndex ? fillSlack : 0); - } + const endGapAt = gap?.end ? gap.at : -1; + const endFrom = trackWidth + spacing - groupWidth(shown.ends) - (endGapAt >= 0 ? gapCells * pitch : 0); + const start = placeGroup(shown.starts, 0, gap && !gap.end ? gap.at : -1, gapCells); + const end = placeGroup(shown.ends, endFrom, endGapAt, gapCells); return { - "offsets": offsets, - "gapOffset": gapOffset, + "offsets": Object.assign(start.offsets, end.offsets), + "gapOffset": Math.max(start.gapOffset, end.gapOffset), "gapCells": gapCells }; } implicitHeight: CcMetrics.footerHeight + onGridDragPointChanged: gridSlot = gridDragging ? insertionAt(gridDragPoint) : null + function spanWidth(count) { return count * pitch - spacing; } @@ -106,38 +108,106 @@ Item { return capacity - fixedUsed; } + function trashContains(scenePosition) { + if (!editMode || !dragging) + return false; + return trash.contains(trash.mapFromItem(null, scenePosition.x, scenePosition.y)); + } + function containsScene(scenePosition) { const point = mapFromItem(null, scenePosition.x, scenePosition.y); return point.x >= 0 && point.x <= width && point.y >= -CcMetrics.gridGap && point.y <= height + CcMetrics.gridGap; } + function spanAt(i) { + return cells[i] * pitch + (i === slackIndex ? fillSlack : 0); + } + + function groupWidth(group) { + return group.reduce((sum, i) => sum + spanAt(i), 0); + } + + // Shown items other than the lifted one, split into the start and end groups in saved order. + function groups() { + const shown = items.map((item, i) => i).filter(i => items[i].index !== liftedIndex && cells[i] > 0); + return { + "starts": shown.filter(i => !ends[i]), + "ends": shown.filter(i => ends[i]) + }; + } + + function placeGroup(group, from, gapAt, gapCells) { + const offsets = []; + let gapOffset = -1; + let offset = from; + for (let k = 0; k <= group.length; k++) { + if (k === gapAt) { + gapOffset = offset; + offset += gapCells * pitch; + } + if (k === group.length) + break; + offsets[group[k]] = offset; + offset += spanAt(group[k]); + } + return { + "offsets": offsets, + "gapOffset": gapOffset + }; + } + + // The free space between the groups splits at its middle: the start half appends to the start group, the rest joins the end group. function insertionAt(scenePosition) { const local = track.mapFromItem(null, scenePosition.x, scenePosition.y).x - track.overhang; const distance = I18n.isRtl ? trackWidth - local : local; + const shown = groups(); let offset = 0; - let position = 0; - for (let i = 0; i < items.length; i++) { - if (items[i].index === liftedIndex || cells[i] === 0) - continue; - const span = cells[i] * pitch; + for (let k = 0; k < shown.starts.length; k++) { + const span = spanAt(shown.starts[k]); if (distance < offset + span / 2) - return position; + return { + "end": false, + "at": k + }; offset += span; - position++; } - return position; + const endFrom = trackWidth + spacing - groupWidth(shown.ends); + if (distance < (offset + endFrom) / 2) + return { + "end": false, + "at": shown.starts.length + }; + offset = endFrom; + for (let k = 0; k < shown.ends.length; k++) { + const span = spanAt(shown.ends[k]); + if (distance < offset + span / 2) + return { + "end": true, + "at": k + }; + offset += span; + } + return { + "end": true, + "at": shown.ends.length + }; } - // The saved index of the item a drop at `position` lands before, or -1 for the end. - function savedIndexAt(position) { - const shown = items.filter((item, i) => item.index !== liftedIndex && cells[i] > 0); - return shown[position]?.index ?? -1; + // The saved index a drop at `slot` goes before, or -1 for the end of the list. + function savedIndexAt(slot) { + const group = groups()[slot.end ? "ends" : "starts"]; + if (slot.at < group.length) + return items[group[slot.at]].index; + return group.length > 0 ? items[group[group.length - 1]].index + 1 : -1; } - function followingIndex(savedIndex) { - const shown = items.filter((item, i) => cells[i] > 0); - const at = shown.findIndex(item => item.index === savedIndex); - return shown[at + 1]?.index ?? -1; + function slotOf(savedIndex) { + const at = items.findIndex(item => item.index === savedIndex); + const end = ends[at] ?? false; + return { + "end": end, + "at": groups()[end ? "ends" : "starts"].filter(i => i < at).length + }; } Item { @@ -218,6 +288,8 @@ Item { return Qt.point(point.x - grab.x, point.y - grab.y); } property bool landed: false + readonly property bool flying: lifted || landed + readonly property bool atEnd: root.ends[index] ?? false x: lifted || landed ? dragPosition.x : restX y: lifted || landed ? dragPosition.y : track.overhang @@ -226,8 +298,17 @@ Item { height: CcMetrics.footerHeight visible: cells > 0 - // A reorder moves this entry, which is the moment a dropped item leaves the pointer for its slot. + // A reorder or a switch of side is the moment a dropped item leaves the pointer for its slot. onIndexChanged: landed = false + onAtEndChanged: landed = false + onFlyingChanged: { + if (flying) { + root.flyingItem = footerItem; + return; + } + if (root.flyingItem === footerItem) + root.flyingItem = null; + } Behavior on x { enabled: !footerItem.lifted && !footerItem.landed && CcMetrics.animationsEnabled @@ -315,21 +396,23 @@ Item { if (active) { footerItem.grab = footerItem.mapFromItem(null, centroid.scenePressPosition.x, centroid.scenePressPosition.y); footerItem.dragScene = centroid.scenePosition; + root.liftedPoint = centroid.scenePosition; root.liftedIndex = footerItem.entry.index; - root.liftedAt = root.insertionAt(centroid.scenePosition); + root.liftedSlot = root.insertionAt(centroid.scenePosition); return; } footerItem.landed = root.dropHandler?.(footerItem.entry.index, footerItem.cells, footerItem.dragScene) ?? false; root.liftedIndex = -1; - root.liftedAt = -1; + root.liftedSlot = null; } onCentroidChanged: { if (!active) return; footerItem.dragScene = centroid.scenePosition; + root.liftedPoint = centroid.scenePosition; const visual = footerItem.mapToItem(null, 0, 0, footerItem.width, footerItem.height); - const leaving = root.leavesRow?.(visual) ?? false; - root.liftedAt = leaving ? -1 : root.insertionAt(footerItem.dragScene); + const leaving = !root.overTrash && (root.leavesRow?.(visual) ?? false); + root.liftedSlot = leaving || root.overTrash ? null : root.insertionAt(footerItem.dragScene); root.itemMoved(footerItem.entry.index, visual, leaving); } } @@ -342,7 +425,7 @@ Item { anchors.rightMargin: CcMetrics.footerGap anchors.verticalCenter: parent.verticalCenter spacing: root.spacing - visible: root.editMode + visible: root.editMode && !root.dragging DankActionButton { buttonSize: Theme.iconButtonSize @@ -369,6 +452,36 @@ Item { } } + StyledRect { + id: trash + + anchors.right: trailing.left + anchors.rightMargin: CcMetrics.footerGap + anchors.verticalCenter: parent.verticalCenter + width: root.editActionsWidth + height: CcMetrics.footerHeight + radius: Theme.fullRadius(width, height) + color: root.overTrash ? Theme.errorContainer : CcMetrics.tileInactiveColor + visible: root.editMode && root.dragging + Accessible.role: Accessible.Graphic + Accessible.name: I18n.tr("Remove") + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + DankIcon { + anchors.centerIn: parent + name: "delete" + size: CcMetrics.iconBoxIconSize + filled: root.overTrash + color: root.overTrash ? Theme.onErrorContainer : CcMetrics.tileInactiveContent + } + } + CcMenu { id: editMenu @@ -403,10 +516,12 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: CcMetrics.footerHeight + // One column, the same pill as a small button docked beside it. + width: root.spanWidth(1) height: CcMetrics.footerHeight DankActionButton { + anchors.fill: parent buttonSize: CcMetrics.footerHeight iconName: "check" iconSize: CcMetrics.iconBoxIconSize @@ -418,6 +533,7 @@ Item { } DankActionButton { + anchors.fill: parent buttonSize: CcMetrics.footerHeight iconName: "edit" iconSize: CcMetrics.iconBoxIconSize @@ -430,4 +546,29 @@ Item { onClicked: root.editToggled() } } + + // The track's layer only draws inside the row, so a lifted item is shown from here while it travels. + ShaderEffectSource { + readonly property real pad: PopoutMetrics.editOverflow + readonly property point at: { + const item = root.flyingItem; + if (!item) + return Qt.point(0, 0); + item.x; + item.y; + trackScale.xScale; + return root.mapFromItem(item, -pad, -pad); + } + + visible: root.flyingItem !== null + sourceItem: root.flyingItem + hideSource: true + smooth: true + mipmap: true + sourceRect: root.flyingItem ? Qt.rect(-pad, -pad, root.flyingItem.width + pad * 2, root.flyingItem.height + pad * 2) : Qt.rect(0, 0, 0, 0) + x: at.x + y: at.y + width: sourceRect.width * trackScale.xScale + height: sourceRect.height * trackScale.yScale + } } diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 379507a8f..6e17e029f 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -174,10 +174,18 @@ FocusScope { if (!widgetGrid.heldOutside) return false; const savedIndex = widgetGrid.savedIndex(index); - const before = footer.savedIndexAt(footer.insertionAt(scenePoint)); + if (footer.trashContains(scenePoint)) { + Qt.callLater(() => { + widgetModel.removeWidget(savedIndex); + widgetGrid.cancelInteraction(); + }); + return true; + } + const slot = footer.insertionAt(scenePoint); + const before = footer.savedIndexAt(slot); const cells = Math.min(WidgetUtils.footerCells(gridDragWidget), footer.freeCells()); Qt.callLater(() => { - WidgetUtils.moveToFooter(savedIndex, before, cells); + WidgetUtils.moveToFooter(savedIndex, before, cells, slot.end); widgetGrid.cancelInteraction(); }); return true; @@ -205,6 +213,10 @@ FocusScope { } function dropFromFooter(index, scenePoint) { + if (footer.trashContains(scenePoint)) { + Qt.callLater(() => widgetModel.removeWidget(index)); + return true; + } if (widgetGrid.externalItem) { const placed = widgetGrid.committedItems(); const cell = placed.pop(); @@ -215,13 +227,15 @@ FocusScope { }); return true; } - if (footer.liftedAt < 0) + const slot = footer.liftedSlot; + if (!slot) return false; - const before = footer.savedIndexAt(footer.liftedAt); - if (before === footer.followingIndex(index)) + const current = footer.slotOf(index); + if (slot.end === current.end && slot.at === current.at) return false; + const before = footer.savedIndexAt(slot); const cells = WidgetUtils.footerCells(SettingsData.controlCenterWidgets[index]); - Qt.callLater(() => WidgetUtils.moveToFooter(index, before, cells)); + Qt.callLater(() => WidgetUtils.moveToFooter(index, before, cells, slot.end)); return true; } @@ -394,7 +408,7 @@ FocusScope { screenName: root.host.triggerScreen?.name || "" tapToClose: root.host.headerTogglesClose ?? false runningToplevels: root.runningToplevels - dragsOutside: (index, scenePoint, tile) => root.footerTakesGridDrag(tile) + dragsOutside: (index, scenePoint, tile) => footer.trashContains(scenePoint) || root.footerTakesGridDrag(tile) dropHandler: (index, scenePoint) => root.dropFromGrid(index, scenePoint) onExpandClicked: widgetData => root.openWidgetPage(widgetData) onRemoveWidget: index => widgetModel.removeWidget(index) @@ -433,10 +447,11 @@ FocusScope { onTop: root.footerOnTop transientSurfaceTracker: root.host.transientSurfaceTracker items: root.footerItems - incoming: root.gridDragWidget !== null && widgetGrid.heldOutside ? { - "cells": Math.min(WidgetUtils.footerCells(root.gridDragWidget), footer.freeCells()), - "at": footer.insertionAt(widgetGrid.dragScenePoint) - } : null + gridDragging: root.gridDragWidget !== null + gridDragPoint: widgetGrid.dragScenePoint + incoming: root.gridDragWidget !== null && widgetGrid.heldOutside && !footer.overTrash ? Object.assign({ + "cells": Math.min(WidgetUtils.footerCells(root.gridDragWidget), footer.freeCells()) + }, footer.gridSlot) : null dropHandler: (index, cells, scenePoint) => root.dropFromFooter(index, scenePoint) leavesRow: visual => root.gridTakesFooterDrag(visual) opacity: body.opacity diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 2195bf65f..5a91e10ea 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -43,6 +43,10 @@ function footerFills(widget) { return widget?.footerFill === true; } +function footerEnds(widget) { + return widget?.footerEnd === true; +} + function fitFooterCells(sizes, mins, capacity) { const fitted = sizes.slice(); let over = fitted.reduce((sum, cells) => sum + cells, 0) - capacity; @@ -179,7 +183,7 @@ function setOption(index, key, value) { } // Footer order is the saved order of footer entries, so the moved entry lands just before `beforeIndex`. -function moveToFooter(index, beforeIndex, cells) { +function moveToFooter(index, beforeIndex, cells, end) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); const widget = widgets[index]; if (!widget) @@ -187,10 +191,15 @@ function moveToFooter(index, beforeIndex, cells) { const before = beforeIndex === index ? null : widgets[beforeIndex] ?? null; widgets.splice(index, 1); const at = before ? widgets.indexOf(before) : widgets.length; - widgets.splice(at, 0, Object.assign({}, widget, { + const moved = Object.assign({}, widget, { "footer": true, "footerW": cells - })); + }); + if (end) + moved.footerEnd = true; + else + delete moved.footerEnd; + widgets.splice(at, 0, moved); Common.SettingsData.set("controlCenterWidgets", widgets); } @@ -230,6 +239,7 @@ function placeFromFooter(widgets, index, col, row) { "row": row }); delete tile.footer; + delete tile.footerEnd; const placed = widgets.slice(); placed[index] = tile; return placed; diff --git a/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml b/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml index d27f3ea94..5f6e713a0 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml @@ -27,6 +27,8 @@ Singleton { readonly property real navIconSize: Theme.avatarSize readonly property real navItemMinHeight: Theme.listItemHeight readonly property real sidebarGroupGap: Theme.spacingS + readonly property real searchBarHeight: 56 + readonly property real searchBarGap: Theme.spacingM readonly property real avatarSize: 64 readonly property real splitDividerHeight: Theme.iconSize readonly property real buttonGroupCompactThreshold: 200 diff --git a/quickshell/Modules/Settings/Widgets/SettingsSidebarItem.qml b/quickshell/Modules/Settings/Widgets/SettingsSidebarItem.qml index 99b22ca17..d0a0ded67 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsSidebarItem.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsSidebarItem.qml @@ -36,8 +36,26 @@ Rectangle { readonly property color glyphColor: accentPair?.onContainer ?? Theme.primary property bool isFirstInGroup: true property bool isLastInGroup: true - readonly property real topRadius: isFirstInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius - readonly property real bottomRadius: isLastInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius + property real topRadius: active || isFirstInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius + property real bottomRadius: active || isLastInGroup ? Theme.groupedListOuterRadius : Theme.groupedListInnerRadius + + Behavior on topRadius { + enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + + Behavior on bottomRadius { + enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } width: parent?.width ?? 0 height: Math.max(SettingsMetrics.navItemMinHeight, textColumn.implicitHeight + Theme.spacingS * 2)