diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index 7c4131277..f2dfebc1d 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -57,6 +57,10 @@ Singleton { readonly property real tileTextGap: Theme.spacingM readonly property real footerHeight: iconBoxSize readonly property real footerGap: Theme.spacingS + // Half the gap, so the grid and row rings meet on one line instead of drawing two. + readonly property real dropZoneInset: gridGap / 2 + readonly property real dropZoneOuterInset: Theme.spacingS + readonly property real dropZoneRadius: Theme.cornerRadiusL readonly property real runningAppsIconSize: Theme.iconSize * iconScale // The resize corner counts a small tile as this many rows and snaps to the nearer of this and a full row. readonly property real smallRowFraction: 0.5 diff --git a/quickshell/Modules/ControlCenter/Components/CcFooter.qml b/quickshell/Modules/ControlCenter/Components/CcFooter.qml index 5e4e6bdf0..3468f66a2 100644 --- a/quickshell/Modules/ControlCenter/Components/CcFooter.qml +++ b/quickshell/Modules/ControlCenter/Components/CcFooter.qml @@ -39,7 +39,10 @@ Item { 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) + property bool canUndo: false + signal addWidgetRequested + signal undoRequested signal resetRequested signal clearRequested signal moveRequested @@ -217,6 +220,29 @@ Item { }; } + Rectangle { + readonly property real inset: CcMetrics.dropZoneInset + readonly property bool receiving: root.incoming !== null || (root.liftedIndex >= 0 && root.liftedSlot !== null) + + x: (I18n.isRtl !== root.editAtEnd ? root.trackWidth * (1 - trackScale.xScale) : 0) - inset + y: -inset + width: root.trackWidth * trackScale.xScale + inset * 2 + height: CcMetrics.footerHeight + inset * 2 + radius: CcMetrics.dropZoneRadius + color: "transparent" + border.width: Theme.outlineWidth + border.color: receiving ? Theme.primary : Theme.outlineVariant + visible: root.editMode + + Behavior on border.color { + enabled: CcMetrics.animationsEnabled + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + Item { id: track @@ -435,8 +461,8 @@ Item { Item { id: editActions - anchors.left: root.editAtEnd ? parent.left : undefined - anchors.right: root.editAtEnd ? undefined : parent.right + // A side anchor set to undefined is never released, so flipping sides stretched the block across the row. + x: root.editAtEnd !== I18n.isRtl ? 0 : root.width - width anchors.verticalCenter: parent.verticalCenter width: root.editActionsWidth height: CcMetrics.footerHeight @@ -464,10 +490,8 @@ Item { buttonSize: Theme.iconButtonSize iconName: "more_horiz" iconSize: CcMetrics.iconBoxIconSize - iconColor: CcMetrics.tileInactiveContent - backgroundColor: CcMetrics.tileInactiveColor - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium + iconColor: Theme.onSecondaryContainer + backgroundColor: Theme.secondaryContainer tooltipText: I18n.tr("More") onClicked: editMenu.openAt(moreButton) } @@ -476,12 +500,11 @@ Item { StyledRect { id: trash - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: root.editActionsWidth - Theme.iconButtonSize - root.spacing - height: CcMetrics.footerHeight + anchors.fill: parent radius: Theme.fullRadius(width, height) color: root.overTrash ? Theme.errorContainer : CcMetrics.tileInactiveColor + border.width: Theme.outlineWidth + border.color: root.overTrash ? Theme.error : Theme.outlineVariant visible: root.dragging && root.dragRemovable Accessible.role: Accessible.Graphic Accessible.name: I18n.tr("Remove") @@ -508,6 +531,12 @@ Item { transientSurfaceTracker: root.transientSurfaceTracker items: [ + { + "iconName": "undo", + "label": I18n.tr("Undo"), + "enabled": root.canUndo, + "action": () => root.undoRequested() + }, { "iconName": root.onTop ? "vertical_align_bottom" : "vertical_align_top", "label": root.onTop ? I18n.tr("Move row to footer", "control center edit menu, moves the row from the header to the footer") : I18n.tr("Move row to header", "control center edit menu, moves the row from the footer to the header"), diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index b4925cab9..1a7549fb7 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -29,6 +29,8 @@ DEditableGrid { signal closeRequested property bool tapToClose: false property var runningToplevels: [] + // A member drag inside a quick-tiles group never reaches the grid's own interaction state. + property bool memberDragging: false readonly property real gridHeight: layoutHeight readonly property real cellWidth: (width + CcMetrics.gridGap) / columns @@ -47,6 +49,10 @@ DEditableGrid { placeholderRadius: draggingSlot?.small ? Theme.fullRadius(draggingSlot.width, draggingSlot.height) : (draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight)) commitHandler: items => model.setLayout(withHidden(items)) + onEditModeChanged: { + if (!editMode) + memberDragging = false; + } function clamped(items) { return items.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))); diff --git a/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml b/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml index f561ebadf..368118930 100644 --- a/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml +++ b/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml @@ -71,9 +71,15 @@ Item { return others()[slot]?.modelData.index ?? -1; } + Component.onDestruction: { + if (liftedIndex >= 0) + host.memberDragging = false; + } + function lift(member, scenePoint) { liftedWidth = member.ownWidth; liftedIndex = member.modelData.index; + host.memberDragging = true; moveLifted(member, scenePoint); } @@ -103,6 +109,7 @@ Item { const unchanged = before === (members[position + 1]?.index ?? -1); liftedIndex = -1; dropSlot = -1; + host.memberDragging = false; if (slot >= 0) { host.clearExternal(); if (!unchanged) diff --git a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml index 7ae26af49..5cd6a7cbe 100644 --- a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml +++ b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml @@ -136,6 +136,22 @@ Item { } } + CcListRow { + visible: root.batteryIndex >= 0 && (SettingsData.controlCenterWidgets[root.batteryIndex]?.batteryStyle ?? "icon") !== "icon" + iconName: "palette" + title: I18n.tr("Color") + body: DDropdown { + readonly property var labels: [I18n.tr("Theme", "battery settings: theme accent indicator colors"), I18n.tr("Level", "battery settings: charge level indicator colors")] + + compactMode: true + dropdownWidth: parent.width + transientSurfaceTracker: contextMenu.transientSurfaceTracker + currentValue: labels[(SettingsData.controlCenterWidgets[root.batteryIndex]?.batteryColorMode ?? "level") === "level" ? 1 : 0] + options: labels + onValueChanged: value => WidgetUtils.setOption(root.batteryIndex, "batteryColorMode", labels.indexOf(value) === 1 ? "level" : "theme") + } + } + Repeater { model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : [] diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 2ad65cbf0..2322306f3 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -55,7 +55,19 @@ FocusScope { } property Item detailReturnFocus: null property var pageHistory: [] - property var editSnapshot: null + // One string so Discard and undo can compare and restore the whole session state at once. + readonly property string editState: JSON.stringify({ + "widgets": SettingsData.controlCenterWidgets || [], + "columns": SettingsData.controlCenterColumns, + "footerPosition": SettingsData.controlCenterFooterPosition + }) + property string editSnapshot: "" + property string undoBase: "" + property var undoStack: [] + readonly property int undoLimit: 50 + property bool undoBatching: false + readonly property bool editIdle: !widgetGrid.interacting && !widgetGrid.memberDragging && footer.liftedIndex < 0 && footer.resizePreview === null && !panelResizing + readonly property bool canUndo: host.editMode && undoStack.length > 0 && editIdle && !widgetSheetOpen && !pageOpen readonly property bool panelResizing: panelResizer.resizing readonly property real sheetContentWidth: host.sheetContentWidth ?? CcMetrics.sheetWidthFor(gridColumns) readonly property vector4d surfaceCornerRadii: host.surfaceCornerRadii ?? Qt.vector4d(Theme.windowRadius, Theme.windowRadius, Theme.windowRadius, Theme.windowRadius) @@ -172,22 +184,58 @@ FocusScope { configOverlayLoader.active = false; } + function applyEditState(state) { + if (state === "") + return; + const wanted = JSON.parse(state); + if (JSON.stringify(SettingsData.controlCenterWidgets || []) !== JSON.stringify(wanted.widgets)) + SettingsData.set("controlCenterWidgets", wanted.widgets); + if (SettingsData.controlCenterColumns !== wanted.columns) + SettingsData.set("controlCenterColumns", wanted.columns); + if (SettingsData.controlCenterFooterPosition !== wanted.footerPosition) + SettingsData.set("controlCenterFooterPosition", wanted.footerPosition); + } + function cancelEdit() { const snapshot = editSnapshot; host.editMode = false; - if (!snapshot) - return; - if (JSON.stringify(SettingsData.controlCenterWidgets) !== snapshot.widgets) - SettingsData.set("controlCenterWidgets", JSON.parse(snapshot.widgets)); - if (SettingsData.controlCenterColumns !== snapshot.columns) - SettingsData.set("controlCenterColumns", snapshot.columns); - if (SettingsData.controlCenterFooterPosition !== snapshot.footerPosition) - SettingsData.set("controlCenterFooterPosition", snapshot.footerPosition); + if (snapshot !== "") + applyEditState(snapshot); } - // Goes by the dragged tile, not the pointer: the middle of its top row has to cross the grid's edge as it - // was at drag start, which the grid growing under the drag cannot move. Below the grid that edge sits one - // row lower while the panel has room to grow, so a tile can still be dropped into a new bottom row. + function undoEdit() { + if (!canUndo) + return; + const previous = undoStack[undoStack.length - 1]; + undoStack = undoStack.slice(0, -1); + undoBase = previous; + undoBatching = true; + applyEditState(previous); + undoBatching = false; + } + + onEditStateChanged: { + if (!host.editMode || undoBatching || editState === undoBase) + return; + undoStack = undoStack.concat([undoBase]).slice(-undoLimit); + undoBase = editState; + } + + // Keeps the intermediate states of a multi-key write out of the undo stack. + function batchEdit(change) { + const before = editState; + undoBatching = true; + change(); + undoBatching = false; + if (editState === before) + return; + undoStack = undoStack.concat([before]).slice(-undoLimit); + undoBase = editState; + } + + // Goes by the dragged tile, not the pointer, against the grid edge as it was at drag start so the grid growing + // under the drag cannot move it. Below the grid the edge sits at the middle of the row that opens while the + // panel has room: the upper half still drops there, past it the footer takes the tile instead of running ahead. function footerTakesGridDrag(tile) { if (gridDragWidget === null || gridDragWidget.id === WidgetUtils.GROUP_ID || footer.freeCells() < WidgetUtils.footerMinCells(gridDragWidget.id)) return false; @@ -197,7 +245,7 @@ FocusScope { return anchor < -CcMetrics.gridGap / 2; const newRow = CcMetrics.gridGap + rowUnit; const room = widgetGrid.pinnedHeight + newRow <= availableGridHeight ? newRow : 0; - return anchor > widgetGrid.pinnedHeight + room + CcMetrics.gridGap / 2; + return anchor > widgetGrid.pinnedHeight + room / 2 + CcMetrics.gridGap / 2; } function groupTakesGridDrag(scenePoint) { @@ -331,6 +379,13 @@ FocusScope { onActivated: root.host.editMode = true } + Shortcut { + sequences: ["Ctrl+Z"] + enabled: root.host.shouldBeVisible && root.canUndo + context: Qt.WindowShortcut + onActivated: root.undoEdit() + } + readonly property string expandedSection: host.expandedSection ?? "" readonly property bool editMode: host.editMode @@ -343,16 +398,15 @@ FocusScope { onEditModeChanged: { if (editMode) { host.collapseAll(); - editSnapshot = { - "widgets": JSON.stringify(SettingsData.controlCenterWidgets), - "columns": SettingsData.controlCenterColumns, - "footerPosition": SettingsData.controlCenterFooterPosition - }; + editSnapshot = editState; + undoBase = editState; } else { panelResizer.cancel(); - editSnapshot = null; + editSnapshot = ""; + undoBase = ""; widgetSheetRequested = false; } + undoStack = []; if (!activeFocus) forceActiveFocus(); } @@ -443,6 +497,28 @@ FocusScope { height: root.gridHeight opacity: CcMetrics.hideCoveredContent ? 1 - root.coveredAmount : 1 + Rectangle { + readonly property bool receiving: widgetGrid.externalItem !== null || (widgetGrid.draggingSourceIndex >= 0 && !widgetGrid.heldOutside) + + anchors.fill: parent + anchors.margins: -CcMetrics.dropZoneOuterInset + anchors.topMargin: root.footerOnTop && root.footerShown ? -CcMetrics.dropZoneInset : -CcMetrics.dropZoneOuterInset + anchors.bottomMargin: !root.footerOnTop && root.footerShown ? -CcMetrics.dropZoneInset : -CcMetrics.dropZoneOuterInset + radius: CcMetrics.dropZoneRadius + color: "transparent" + border.width: Theme.outlineWidth + border.color: receiving ? Theme.primary : Theme.outlineVariant + visible: root.host.editMode + + Behavior on border.color { + enabled: CcMetrics.animationsEnabled + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + CcTileGrid { id: widgetGrid columns: root.gridColumns @@ -507,8 +583,10 @@ FocusScope { dropHandler: (index, cells, scenePoint) => root.dropFromFooter(index, scenePoint) leavesRow: visual => root.gridTakesFooterDrag(visual) opacity: body.opacity + canUndo: root.canUndo onAddWidgetRequested: root.openWidgetSheet() - onResetRequested: widgetModel.resetToDefault() + onUndoRequested: root.undoEdit() + onResetRequested: root.batchEdit(() => widgetModel.resetToDefault()) onClearRequested: widgetModel.clearAll() onMoveRequested: SettingsData.set("controlCenterFooterPosition", root.footerOnTop ? "bottom" : "top") onRemoveRequested: index => widgetModel.removeWidget(index) diff --git a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml index 8fac33278..760caf9be 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml @@ -12,6 +12,7 @@ CcTile { readonly property bool available: BatteryService.batteryAvailable readonly property bool profileMode: !available && PowerProfileWatcher.available readonly property string meterStyle: widgetData?.batteryStyle ?? "icon" + readonly property bool levelColors: (widgetData?.batteryColorMode ?? "level") === "level" readonly property bool showsMeter: available && meterStyle !== "icon" property real meterWidth: 0 @@ -60,7 +61,7 @@ CcTile { BatteryMeter { meterStyle: root.meterStyle - levelColors: true + levelColors: root.levelColors thickness: CcMetrics.pillMeterThickness scale: Math.min(1, Math.max(0, root.iconExtent - Theme.spacingXS * 2) / Math.max(1, implicitWidth, implicitHeight)) } @@ -75,7 +76,7 @@ CcTile { anchors.centerIn: parent meterStyle: root.meterStyle - levelColors: true + levelColors: root.levelColors thickness: CcMetrics.pillMeterThickness scale: Math.min(1, (parent.width - Theme.spacingXS * 2) / Math.max(1, implicitWidth)) } @@ -95,7 +96,7 @@ CcTile { anchors.verticalCenter: parent.verticalCenter thickness: CcMetrics.tallMeterThickness meterStyle: root.showsMeter ? root.meterStyle : "solid" - levelColors: true + levelColors: root.levelColors showNumber: false visible: root.available }