diff --git a/quickshell/Common/GridLayout.js b/quickshell/Common/GridLayout.js index 47f18090e..02f91c2a3 100644 --- a/quickshell/Common/GridLayout.js +++ b/quickshell/Common/GridLayout.js @@ -131,9 +131,10 @@ function cellAt(layout, x, y, cols, rows) { }; } -// With gravity, a tile dropped onto the one below it gets lifted straight back into the hole it left, so a tile -// fully covering exactly one other that fits where it came from trades places with it instead. -function swapInto(items, cells, index, target) { +// With gravity, a tile dragged down onto another and dropped at its row gets lifted straight back above it. So a +// tile coming from above lands just past the tiles it overlaps, and one fully covering a single tile that fits +// where it came from trades places with it. +function dropInto(items, cells, index, target) { const origin = cells[index]; if (!origin || !target) return items; @@ -151,12 +152,20 @@ function swapInto(items, cells, index, target) { }; if (overlaps([box(origin)], moved.x, moved.y, moved.w, moved.h)) return items; - const hits = cells.reduce((found, cell, i) => i !== index && cell && overlaps([box(cell)], moved.x, moved.y, moved.w, moved.h) ? found.concat([i]) : found, []); - const other = hits.length === 1 ? cells[hits[0]] : null; + const hits = cells.filter((cell, i) => i !== index && cell && overlaps([box(cell)], moved.x, moved.y, moved.w, moved.h)); + if (hits.length === 0) + return items; + if (hits.every(cell => cell.row >= origin.row + origin.rows)) { + const row = hits.reduce((bottom, cell) => Math.max(bottom, cell.row + cell.rows), moved.y); + return items.map((item, i) => i === index ? Object.assign({}, item, { + "row": row + }) : item); + } + const other = hits.length === 1 ? hits[0] : null; const covered = other && other.col >= moved.x && other.row >= moved.y && other.col + other.cols <= moved.x + moved.w && other.row + other.rows <= moved.y + moved.h; if (!covered || other.cols > origin.cols || other.rows > origin.rows) return items; - return items.map((item, i) => i === hits[0] ? Object.assign({}, item, { + return items.map((item, i) => cells[i] === other ? Object.assign({}, item, { "col": origin.col, "row": origin.row }) : item); diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index bb7721bef..3dddd416b 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -21,7 +21,7 @@ Singleton { id: root readonly property var log: Log.scoped("SettingsData") - readonly property int settingsConfigVersion: 36 + readonly property int settingsConfigVersion: 37 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 85e187f58..39046ad2b 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -544,6 +544,15 @@ var LOCAL_SPEC = { w: 4, h: 1, footer: true + }, + { + id: "edit", + enabled: true, + w: 1, + h: 1, + small: true, + footer: true, + footerEnd: true } ] }, diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index 32958b05b..31fbcd444 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -707,6 +707,12 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 35; } + if (currentVersion < 37 && targetVersion >= 37) { + if (Array.isArray(settings.controlCenterWidgets) && !settings.controlCenterWidgets.some(widget => widget?.id === "edit")) + settings.controlCenterWidgets = settings.controlCenterWidgets.concat([Object.assign({}, SpecModule.SPEC.controlCenterWidgets.def.find(widget => widget.id === "edit"))]); + settings.configVersion = 37; + } + return settings; } diff --git a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml index dcc4b54b8..a6ef5da71 100644 --- a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml +++ b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml @@ -12,6 +12,7 @@ DankGridEditChrome { signal configRequested(var anchor) hasOptions: WidgetUtils.hasOptions(widgetData.id) + removable: WidgetUtils.isRemovable(widgetData) buttonSize: Theme.iconSize hitOverflow: CcMetrics.gridGap / 2 iconSize: PopoutMetrics.chromeIconSize diff --git a/quickshell/Modules/ControlCenter/Components/CcFooter.qml b/quickshell/Modules/ControlCenter/Components/CcFooter.qml index 4e4313f43..0494638bb 100644 --- a/quickshell/Modules/ControlCenter/Components/CcFooter.qml +++ b/quickshell/Modules/ControlCenter/Components/CcFooter.qml @@ -34,38 +34,43 @@ Item { property var gridSlot: null property var resizePreview: null property Item flyingItem: null + property bool incomingRemovable: true readonly property bool dragging: gridDragging || liftedIndex >= 0 + readonly property bool dragRemovable: liftedIndex >= 0 ? WidgetUtils.isRemovable(items.find(item => item.index === liftedIndex)?.widget) : incomingRemovable readonly property bool overTrash: trashContains(liftedIndex >= 0 ? liftedPoint : gridDragPoint) signal addWidgetRequested signal resetRequested signal clearRequested signal moveRequested - signal editToggled + signal finishRequested signal cancelRequested signal removeRequested(int index) signal configRequested(int index, var widgetData, var anchor) signal itemMoved(int index, rect sceneRect, bool leaving) - signal resized(int index, int cells, bool fill) + signal resized(int index, real cells, bool fill) // Grid columns, so footer widths line up with the tiles above. readonly property real spacing: CcMetrics.gridGap readonly property real pitch: grid.cellWidth - readonly property real editActionsWidth: Theme.iconButtonSize * 2 + spacing - readonly property real trackWidth: Math.max(0, width - trailing.width - CcMetrics.footerGap) - // Edit mode shows the whole row scaled into the room beside the edit buttons, so every width stays reachable. - readonly property real editScale: trackWidth > 0 ? Math.min(1, (trackWidth - editActionsWidth - CcMetrics.footerGap) / trackWidth) : 1 + readonly property real editActionsWidth: Theme.iconButtonSize * 3 + spacing * 2 + // The row owns its full width in both modes, so capacity never changes under the user's feet; edit mode + // shows the whole row scaled into the room beside the edit buttons, so every width stays reachable. + readonly property real trackWidth: width + readonly property real editScale: width > 0 ? Math.min(1, (width - editActionsWidth - CcMetrics.footerGap) / width) : 1 readonly property real trackScale: editMode ? editScale : 1 - readonly property int capacity: Math.max(0, Math.floor((trackWidth + spacing) / pitch)) + readonly property real step: CcMetrics.gridStep + readonly property real capacity: Math.max(0, Math.floor((trackWidth + spacing) / pitch / step + 1e-6) * step) 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)) + readonly property var keeps: items.map(item => !WidgetUtils.isRemovable(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) - readonly property var cells: WidgetUtils.spreadFooterFill(fixedCells, fills, capacity - fixedUsed - (incoming?.cells ?? 0)) + 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, keeps, step) + readonly property real fixedUsed: fixedCells.reduce((sum, count) => sum + count, 0) + readonly property var cells: WidgetUtils.spreadFooterFill(fixedCells, fills, capacity - fixedUsed - (incoming?.cells ?? 0), step) readonly property int slackIndex: fills.findIndex((fill, i) => fill && cells[i] > 0) - // The first fill item also takes the part of a cell left over, so it ends exactly at the next button. + // The first fill item also takes the part of a cell left over, so it ends exactly at the next item or the edge. readonly property real fillSlack: slackIndex < 0 ? 0 : Math.max(0, trackWidth + spacing - (cells.reduce((sum, count) => sum + count, 0) + (incoming?.cells ?? 0)) * pitch) // Stable keys keep delegates alive across resizes and reorders, so they update in place instead of rebuilding. readonly property var keys: { @@ -109,7 +114,7 @@ Item { } function trashContains(scenePosition) { - if (!editMode || !dragging) + if (!editMode || !dragging || !dragRemovable) return false; return trash.contains(trash.mapFromItem(null, scenePosition.x, scenePosition.y)); } @@ -275,7 +280,7 @@ Item { "index": -1, "widget": ({}) }) - readonly property int cells: root.cells[index] ?? 0 + readonly property real cells: root.cells[index] ?? 0 readonly property bool lifted: root.liftedIndex === entry.index readonly property real span: root.spanWidth(Math.max(1, cells)) + (index === root.slackIndex ? root.fillSlack : 0) readonly property real restX: root.xFor(root.layout.offsets[index] ?? 0, span) @@ -337,7 +342,7 @@ Item { id: chrome property real startX: 0 - property int startCells: 0 + property real startCells: 0 property bool startFill: false anchors.fill: parent @@ -361,12 +366,12 @@ Item { "fill": startFill }; } - // Pulling past the edit buttons turns the item into a fill, which runs on to the pencil outside edit mode. + // Pulling past the edit buttons turns the item into a fill, which runs on to the row's edge outside edit mode. onResizeMoved: (px, py) => { const delta = (mapToItem(null, px, py).x - startX) * (I18n.isRtl ? -1 : 1) / root.trackScale; const min = root.mins[footerItem.index]; const max = Math.max(min, root.capacity - (root.fixedUsed - root.fixedCells[footerItem.index])); - const wanted = Math.round(startCells + delta / root.pitch); + const wanted = Math.round((startCells + delta / root.pitch) / root.step) * root.step; root.resizePreview = { "index": footerItem.entry.index, "cells": Math.max(min, Math.min(max, wanted)), @@ -420,65 +425,85 @@ Item { } } - Row { - anchors.right: trailing.left - anchors.rightMargin: CcMetrics.footerGap - anchors.verticalCenter: parent.verticalCenter - spacing: root.spacing - visible: root.editMode && !root.dragging + Item { + id: editActions - DankActionButton { - buttonSize: Theme.iconButtonSize - iconName: "add" - iconSize: CcMetrics.iconBoxIconSize - iconColor: Theme.onSecondaryContainer - backgroundColor: Theme.secondaryContainer - tooltipText: I18n.tr("Add widget") - onClicked: root.addWidgetRequested() - } - - DankActionButton { - id: moreButton - - buttonSize: Theme.iconButtonSize - iconName: "more_horiz" - iconSize: CcMetrics.iconBoxIconSize - iconColor: CcMetrics.tileInactiveContent - backgroundColor: CcMetrics.tileInactiveColor - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium - tooltipText: I18n.tr("More") - onClicked: editMenu.openAt(moreButton) - } - } - - StyledRect { - id: trash - - anchors.right: trailing.left - anchors.rightMargin: CcMetrics.footerGap + anchors.right: parent.right 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") + visible: root.editMode - Behavior on color { - ColorAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + Row { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: root.spacing + visible: !root.dragging + + DankActionButton { + buttonSize: Theme.iconButtonSize + iconName: "add" + iconSize: CcMetrics.iconBoxIconSize + iconColor: Theme.onSecondaryContainer + backgroundColor: Theme.secondaryContainer + tooltipText: I18n.tr("Add widget") + onClicked: root.addWidgetRequested() + } + + DankActionButton { + id: moreButton + + buttonSize: Theme.iconButtonSize + iconName: "more_horiz" + iconSize: CcMetrics.iconBoxIconSize + iconColor: CcMetrics.tileInactiveContent + backgroundColor: CcMetrics.tileInactiveColor + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + tooltipText: I18n.tr("More") + onClicked: editMenu.openAt(moreButton) } } - DankIcon { - anchors.centerIn: parent - name: "delete" - size: CcMetrics.iconBoxIconSize - filled: root.overTrash - color: root.overTrash ? Theme.onErrorContainer : CcMetrics.tileInactiveContent + StyledRect { + id: trash + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: root.editActionsWidth - Theme.iconButtonSize - root.spacing + height: CcMetrics.footerHeight + radius: Theme.fullRadius(width, height) + color: root.overTrash ? Theme.errorContainer : CcMetrics.tileInactiveColor + visible: root.dragging && root.dragRemovable + 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 + } + } + + DankActionButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + buttonSize: Theme.iconButtonSize + iconName: "check" + iconSize: CcMetrics.iconBoxIconSize + iconColor: Theme.onPrimary + backgroundColor: Theme.primary + tooltipText: I18n.tr("Save") + onClicked: root.finishRequested() } } @@ -489,7 +514,7 @@ Item { items: [ { "iconName": root.onTop ? "vertical_align_bottom" : "vertical_align_top", - "label": root.onTop ? I18n.tr("Move down") : I18n.tr("Move up"), + "label": root.onTop ? I18n.tr("Move row to footer") : I18n.tr("Move row to header"), "action": () => root.moveRequested() }, { @@ -511,42 +536,6 @@ Item { ] } - Item { - id: trailing - - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - // 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 - iconColor: Theme.onPrimary - backgroundColor: Theme.primary - tooltipText: I18n.tr("Save") - visible: root.editMode - onClicked: root.editToggled() - } - - DankActionButton { - anchors.fill: parent - buttonSize: CcMetrics.footerHeight - iconName: "edit" - iconSize: CcMetrics.iconBoxIconSize - iconColor: CcMetrics.tileInactiveContent - backgroundColor: CcMetrics.tileInactiveColor - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium - tooltipText: I18n.tr("Edit") - visible: !root.editMode - 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 diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 20e25a3ad..a40d0311a 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -24,6 +24,7 @@ DankEditableGrid { signal lockRequested signal powerRequested signal settingsRequested + signal editRequested signal accountsRequested signal closeRequested property bool tapToClose: false diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index ef48796c6..20df5bd2e 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -88,7 +88,7 @@ DankEditableGridSlot { dragging: root.dragging resizing: root.resizing cornerRadius: root.small ? Theme.fullRadius(root.width, root.height) : (root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height)) - sizeText: root.cols + "×" + root.rows + sizeText: root.cols + "×" + (root.small ? CcMetrics.smallRowFraction : root.rows) onResizeStarted: (px, py) => { root.biasH = root.small ? (CcMetrics.smallRowFraction - 1) * root.grid.cellWidth : 0; root.beginResize(px, py); diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 6e17e029f..037b88a4e 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -74,6 +74,9 @@ FocusScope { implicitHeight: targetImplicitHeight focus: true + Component.onCompleted: WidgetUtils.ensureEditButton() + onPlacedWidgetIdsChanged: WidgetUtils.ensureEditButton() + function navigateTo(section) { if (section === host.expandedSection) return; @@ -279,6 +282,13 @@ FocusScope { event.accepted = true; } + Shortcut { + sequences: ["F2", "Ctrl+E"] + enabled: root.host.shouldBeVisible && !root.host.editMode && !root.pageOpen + context: Qt.WindowShortcut + onActivated: root.host.editMode = true + } + readonly property string expandedSection: host.expandedSection ?? "" readonly property bool editMode: host.editMode @@ -416,6 +426,7 @@ FocusScope { onColorPickerRequested: root.host.openColorPicker() onCloseRequested: root.host.close() onSettingsRequested: root.host.openSettings() + onEditRequested: root.host.editMode = true onAccountsRequested: root.host.openAccounts() onLockRequested: { root.host.close(); @@ -449,6 +460,7 @@ FocusScope { items: root.footerItems gridDragging: root.gridDragWidget !== null gridDragPoint: widgetGrid.dragScenePoint + incomingRemovable: WidgetUtils.isRemovable(root.gridDragWidget) incoming: root.gridDragWidget !== null && widgetGrid.heldOutside && !footer.overTrash ? Object.assign({ "cells": Math.min(WidgetUtils.footerCells(root.gridDragWidget), footer.freeCells()) }, footer.gridSlot) : null @@ -463,7 +475,7 @@ FocusScope { onConfigRequested: (index, widgetData, anchor) => root.openConfigOverlay(index, widgetData, anchor) onItemMoved: (index, sceneRect, leaving) => root.previewFooterDrag(index, sceneRect, leaving) onResized: (index, cells, fill) => WidgetUtils.setFooterSize(index, cells, fill) - onEditToggled: root.host.editMode = !root.host.editMode + onFinishRequested: root.host.editMode = false onCancelRequested: root.cancelEdit() } diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index 1b25db303..26be93c7f 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -171,6 +171,7 @@ QtObject { case "lock": case "power": case "settings": + case "edit": return actionTile; case "runningApps": return runningAppsTile; @@ -228,6 +229,15 @@ QtObject { "category": "system", "enabled": true }, + { + "id": "edit", + "text": I18n.tr("Edit"), + "description": I18n.tr("Edit widgets"), + "icon": "edit", + "type": "action", + "category": "system", + "enabled": true + }, { "id": "runningApps", "text": I18n.tr("Running apps"), diff --git a/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml index c9f43dd6f..c4eca7e3f 100644 --- a/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml @@ -23,6 +23,9 @@ CcTile { case "settings": host?.settingsRequested(); return; + case "edit": + host?.editRequested(); + return; } } } diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 5a91e10ea..382dbc3ef 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -2,9 +2,12 @@ .import qs.Services as Services .import qs.Modules.ControlCenter as ControlCenter .import "../../../Common/GridLayout.js" as GridLayout +.import "../../../Common/settings/SettingsSpec.js" as Spec var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user"]; var ACTION_IDS = ["settings", "lock", "power"]; +var EDIT_ID = "edit"; +var BUTTON_IDS = ACTION_IDS.concat([EDIT_ID]); var FOOTER_CELLS = { "runningApps": 4, "user": 3, @@ -30,13 +33,31 @@ function inFooter(widget) { return !!widget?.footer; } +function isRemovable(widget) { + return widget?.id !== EDIT_ID; +} + +// A saved list without the pencil gets it back whatever state the migration left it in. +function ensureEditButton() { + const widgets = Common.SettingsData.controlCenterWidgets; + if (!Array.isArray(widgets) || widgets.some(widget => widget?.id === EDIT_ID)) + return; + const fallback = Spec.SPEC.controlCenterWidgets.def.find(widget => widget.id === EDIT_ID); + Common.SettingsData.set("controlCenterWidgets", widgets.concat([Object.assign({}, fallback)])); +} + function footerMinCells(id) { return isSliderWidget(id) ? 3 : 1; } +function footerStep(value) { + const step = ControlCenter.CcMetrics.gridStep; + return Math.round(value / step) * step; +} + // `footerW` outlives the footer flag, so an item dragged back in returns at the width it left with. function footerCells(widget) { - return Math.max(footerMinCells(widget?.id), Math.round(Number(widget?.footerW) || FOOTER_CELLS[widget?.id] || 1)); + return Math.max(footerMinCells(widget?.id), footerStep(Number(widget?.footerW) || FOOTER_CELLS[widget?.id] || 1)); } function footerFills(widget) { @@ -47,17 +68,19 @@ function footerEnds(widget) { return widget?.footerEnd === true; } -function fitFooterCells(sizes, mins, capacity) { +function fitFooterCells(sizes, mins, capacity, keep, step) { const fitted = sizes.slice(); let over = fitted.reduce((sum, cells) => sum + cells, 0) - capacity; while (over > 0) { const widest = fitted.reduce((best, cells, i) => cells > mins[i] && (best < 0 || cells > fitted[best]) ? i : best, -1); if (widest < 0) break; - fitted[widest]--; - over--; + fitted[widest] -= step; + over -= step; } for (let i = fitted.length - 1; over > 0 && i >= 0; i--) { + if (keep[i]) + continue; over -= fitted[i]; fitted[i] = 0; } @@ -115,7 +138,7 @@ function sizeSpec(widget, columns, rows = Infinity) { }; if (widget?.id === "user") spec.w = (Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns) - ACTION_IDS.length; - if (ACTION_IDS.includes(widget?.id)) + if (BUTTON_IDS.includes(widget?.id)) spec.w = 1; return spec; } @@ -142,7 +165,7 @@ function clampSize(widget, columns, rows = Infinity) { function addWidget(widgetId, columns) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); const widget = defaultWidget(widgetId, columns); - if (ACTION_IDS.includes(widgetId)) + if (BUTTON_IDS.includes(widgetId)) widget.small = true; if (widgetId === "diskUsage") { @@ -166,7 +189,7 @@ function generateUniqueId() { function removeWidget(index) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); - if (index < 0 || index >= widgets.length) + if (index < 0 || index >= widgets.length || !isRemovable(widgets[index])) return; widgets.splice(index, 1); Common.SettingsData.set("controlCenterWidgets", widgets); @@ -204,7 +227,7 @@ function moveToFooter(index, beforeIndex, cells, end) { } // Hands spare cells to fill items, the earlier ones taking the remainder. -function spreadFooterFill(cells, fills, spare) { +function spreadFooterFill(cells, fills, spare, step) { const count = fills.filter(Boolean).length; if (count === 0 || spare <= 0) return cells; @@ -212,7 +235,7 @@ function spreadFooterFill(cells, fills, spare) { return cells.map((size, i) => { if (!fills[i] || size === 0) return size; - const share = Math.ceil(left / count); + const share = Math.ceil(left / count / step) * step; left -= share; return size + Math.max(0, share); }); @@ -254,5 +277,5 @@ function resetToDefault() { } function clearAll() { - Common.SettingsData.set("controlCenterWidgets", []); + Common.SettingsData.set("controlCenterWidgets", Common.SettingsData.controlCenterWidgets.filter(widget => !isRemovable(widget))); } diff --git a/quickshell/Widgets/DankEditableGrid.qml b/quickshell/Widgets/DankEditableGrid.qml index bc201200b..3c69e3cc1 100644 --- a/quickshell/Widgets/DankEditableGrid.qml +++ b/quickshell/Widgets/DankEditableGrid.qml @@ -54,7 +54,7 @@ Item { if (interactingIndex < 0 || !changes) return items; const changed = items.map((item, i) => i === interactingIndex ? Object.assign({}, item, changes) : item); - return swapDrags && dragCell && draggingSourceIndex >= 0 ? GridUtils.swapInto(changed, pinnedCells, draggingSourceIndex, dragCell) : changed; + return swapDrags && dragCell && draggingSourceIndex >= 0 ? GridUtils.dropInto(changed, pinnedCells, draggingSourceIndex, dragCell) : changed; } signal layoutCommitted(var items) diff --git a/quickshell/tests/dash-grid.test.mjs b/quickshell/tests/dash-grid.test.mjs index cd032d0a4..d4f2726a3 100644 --- a/quickshell/tests/dash-grid.test.mjs +++ b/quickshell/tests/dash-grid.test.mjs @@ -80,3 +80,19 @@ test("panel step follows the pointer and holds on a pinned edge", () => { assert.equal(grid.nearestStep(rightEdge, 8, 6, 30, rightEdge(8) + 50), 9); assert.equal(grid.nearestStep(rightEdge, 20, 6, 30, 1400), 20); }); + +test("a tile dragged down lands past the tile it covers instead of back above it", () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const cells = [{ col: 0, row: 0, cols: 1, rows: 1 }, { col: 0, row: 1, cols: 1, rows: 1 }, { col: 0, row: 2, cols: 1, rows: 2 }]; + assert.deepEqual(plain(grid.dropInto(items, cells, 0, { col: 0, row: 1 })), [{ id: "a", row: 2 }, { id: "b" }, { id: "c" }]); + assert.deepEqual(plain(grid.dropInto(items, cells, 0, { col: 0, row: 2 })), [{ id: "a", row: 4 }, { id: "b" }, { id: "c" }]); + assert.deepEqual(plain(grid.dropInto(items, cells, 2, { col: 0, row: 0 })), items); + assert.deepEqual(plain(grid.dropInto(items, cells, 1, { col: 0, row: 2 })), [{ id: "a" }, { id: "b", row: 4 }, { id: "c" }]); +}); + +test("a tile dropped sideways or up onto a same-size tile trades places with it", () => { + const items = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const cells = [{ col: 0, row: 0, cols: 1, rows: 1 }, { col: 1, row: 0, cols: 1, rows: 1 }, { col: 1, row: 1, cols: 1, rows: 1 }]; + assert.deepEqual(plain(grid.dropInto(items, cells, 0, { col: 1, row: 0 })), [{ id: "a" }, { id: "b", col: 0, row: 0 }, { id: "c" }]); + assert.deepEqual(plain(grid.dropInto(items, cells, 2, { col: 1, row: 0 })), [{ id: "a" }, { id: "b", col: 1, row: 1 }, { id: "c" }]); +}); diff --git a/quickshell/tests/settings-cc-grid.test.mjs b/quickshell/tests/settings-cc-grid.test.mjs index 71fbb210d..bbd708496 100644 --- a/quickshell/tests/settings-cc-grid.test.mjs +++ b/quickshell/tests/settings-cc-grid.test.mjs @@ -30,3 +30,12 @@ test("header entries become a user row and lock, power and settings tiles", () = const released = store.migrateToVersion({ configVersion: 18, controlCenterWidgets: [{ id: "wifi", width: 50 }] }, 35); assert.deepEqual(JSON.parse(JSON.stringify(released.controlCenterWidgets.map(w => [w.id, w.w, w.h, !!w.footer]))), [["user", 5, 1, false], ["settings", 1, 1, false], ["lock", 1, 1, false], ["power", 1, 1, false], ["wifi", 4, 1, false], ["runningApps", 4, 1, true]]); }); + +test("the edit button joins the footer end once and keeps a placed one", () => { + const migrated = store.migrateToVersion({ configVersion: 36, controlCenterWidgets: [{ id: "wifi", w: 4, h: 1 }] }, 37); + assert.equal(migrated.configVersion, 37); + assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)), [{ id: "wifi", w: 4, h: 1 }, { id: "edit", enabled: true, w: 1, h: 1, small: true, footer: true, footerEnd: true }]); + const placed = store.migrateToVersion({ configVersion: 36, controlCenterWidgets: [{ id: "edit", w: 1, h: 1, col: 2, row: 0 }] }, 37); + assert.deepEqual(JSON.parse(JSON.stringify(placed.controlCenterWidgets)), [{ id: "edit", w: 1, h: 1, col: 2, row: 0 }]); + assert.equal(store.migrateToVersion({ configVersion: 36 }, 37).controlCenterWidgets, undefined); +});