settings: some changes ref #3635

cc: add trash icon to drag and crush things
cc: allow anchoring footer items to start or end
This commit is contained in:
bbedward
2026-09-30 16:00:48 -04:00
parent 9e7f35de0c
commit 3bbf02f502
6 changed files with 261 additions and 63 deletions
+17 -5
View File
@@ -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
@@ -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
}
}
@@ -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
@@ -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;
@@ -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
@@ -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)