cc: implement undo functionality ctrl+z & drop rings w/misc qol

This commit is contained in:
purian23
2026-10-10 19:08:05 -04:00
parent d7d7f73420
commit f372777829
7 changed files with 174 additions and 33 deletions
@@ -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
@@ -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"),
@@ -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)));
@@ -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)
@@ -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") : []
@@ -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)
@@ -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
}