control center: let grid drops land where dropped

This commit is contained in:
bbedward
2026-10-07 13:45:21 -04:00
parent e9f66d6256
commit 6f43ece99f
5 changed files with 75 additions and 50 deletions
+21 -32
View File
@@ -131,44 +131,33 @@ function cellAt(layout, x, y, cols, rows) {
};
}
// 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) {
// With gravity, a tile dropped onto the stack it came from rises back toward the hole it left. So the drop tries
// the target row and the rows below it, packing each, and keeps the one that settles nearest the target.
function dropInto(items, cells, index, target, pack, step = 1) {
const origin = cells[index];
if (!origin || !target)
if (!origin || !target || !pack)
return items;
const box = cell => ({
"x": cell.col,
"y": cell.row,
"w": cell.cols,
"h": cell.rows
});
const moved = {
"x": target.col,
"y": target.row,
"w": origin.cols,
"h": origin.rows
};
if (overlaps([box(origin)], moved.x, moved.y, moved.w, moved.h))
if (target.col < origin.col + origin.cols && origin.col < target.col + origin.cols && target.row < origin.row + origin.rows && origin.row < target.row + origin.rows)
return items;
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, {
const limit = cells.reduce((bottom, cell) => cell ? Math.max(bottom, cell.row + cell.rows) : bottom, 0) + origin.rows;
let best = null;
for (let row = target.row; row <= limit; row += step) {
const trial = items.map((item, i) => i === index ? Object.assign({}, item, {
"row": row
}) : item);
const landed = pack(trial)[index]?.row;
if (landed === undefined)
return items;
const distance = Math.abs(landed - target.row);
if (!best || distance < best.distance || (distance === best.distance && landed >= target.row))
best = {
"trial": trial,
"distance": distance
};
if (landed >= target.row)
break;
}
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) => cells[i] === other ? Object.assign({}, item, {
"col": origin.col,
"row": origin.row
}) : item);
return best.trial;
}
function placedItems(items, slots) {
@@ -38,12 +38,18 @@ DEditableGrid {
readonly property var shownIndices: savedWidgets.reduce((indices, widget, i) => !WidgetUtils.inFooter(widget) && WidgetUtils.isShown(widget) && model?.componentForWidget(widget) ? indices.concat([i]) : indices, [])
sourceItems: shownIndices.map(i => Object.assign({}, savedWidgets[i], sizeWithHiddenTwin(i)))
slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep, true)
swapDrags: true
slotLayout: GridUtils.packCards(clamped(layoutItems), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep, true)
gravity: true
rowStep: CcMetrics.gridStep
packer: items => GridUtils.packCells(clamped(items), placementOrder, columns, null, CcMetrics.gridStep, true).cells
placeholderRadius: draggingSlot?.small ? Theme.fullRadius(draggingSlot.width, draggingSlot.height) : (draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight))
onLayoutCommitted: items => model.setLayout(withHidden(items))
function clamped(items) {
return items.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows)));
}
function sizeWithHiddenTwin(index) {
const widget = savedWidgets[index];
const size = WidgetUtils.clampSize(widget, Infinity);
@@ -184,12 +184,18 @@ FocusScope {
}
// 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.
// 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 footerTakesGridDrag(tile) {
if (gridDragWidget === null || footer.freeCells() < WidgetUtils.footerMinCells(gridDragWidget.id))
return false;
const anchor = tile.y + Math.min(tile.height, widgetGrid.slotLayout.rowUnit) / 2;
return footerOnTop ? anchor < -CcMetrics.gridGap / 2 : anchor > widgetGrid.pinnedHeight + CcMetrics.gridGap / 2;
const rowUnit = widgetGrid.slotLayout.rowUnit;
const anchor = tile.y + Math.min(tile.height, rowUnit) / 2;
if (footerOnTop)
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;
}
// Drops commit a tick later: committing inside the release handler would destroy the dragged item mid-signal.
+7 -2
View File
@@ -32,7 +32,12 @@ Item {
property var dropHandler: null
// An item hovering from outside, placed first so the grid makes room for it.
property var externalItem: null
property bool swapDrags: false
// The layout compacts upward, so a drop has to account for the hole the dragged item leaves.
property bool gravity: false
// Takes the items with the dragged one at its target and returns their packed cells, so a drop can tell
// whether gravity would lift the dragged item back above where it was dropped.
property var packer: null
property real rowStep: 1
readonly property alias tileModel: tiles
readonly property bool interacting: draggingSourceIndex >= 0 || sizePreview !== null || externalItem !== null
readonly property real layoutHeight: interacting ? Math.max(heldHeight, slotLayout.totalHeight) : slotLayout.totalHeight
@@ -54,7 +59,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.dropInto(changed, pinnedCells, draggingSourceIndex, dragCell) : changed;
return gravity && dragCell && draggingSourceIndex >= 0 ? GridUtils.dropInto(changed, pinnedCells, draggingSourceIndex, dragCell, packer, rowStep) : changed;
}
signal layoutCommitted(var items)
+30 -11
View File
@@ -81,18 +81,37 @@ test("panel step follows the pointer and holds on a pinned edge", () => {
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 dropped = (cells, index, target, columns = 2, step = 1) => {
const items = cells.map((cell, i) => ({ id: String(i), w: cell.cols, h: cell.rows, col: cell.col, row: cell.row }));
const moved = items.map((item, i) => i === index ? Object.assign({}, item, target) : item);
const order = [index].concat(items.map((item, i) => i).filter(i => i !== index));
const pack = trial => grid.packCells(trial, order, columns, null, step, true).cells;
return plain(pack(grid.dropInto(moved, cells, index, target, pack, step)).map(cell => [cell.col, cell.row]));
};
test("a tile dragged down its own stack lands where dropped instead of back above the tile it covers", () => {
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" }]);
assert.deepEqual(dropped(cells, 0, { col: 0, row: 1 }), [[0, 1], [0, 0], [0, 2]]);
assert.deepEqual(dropped(cells, 0, { col: 0, row: 2 }), [[0, 3], [0, 0], [0, 1]]);
assert.deepEqual(dropped(cells, 2, { col: 0, row: 0 }), [[0, 2], [0, 3], [0, 0]]);
assert.deepEqual(dropped(cells, 1, { col: 0, row: 2 }), [[0, 0], [0, 3], [0, 1]]);
});
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" }]);
test("a half-width tile dragged down beside a blocked stack moves one step per half row", () => {
const cells = [{ col: 0, row: 0, cols: 1, rows: 1 }, { col: 1, row: 0, cols: 0.5, rows: 1 }, { col: 1.5, row: 0, cols: 0.5, rows: 1 }, { col: 0, row: 1, cols: 2, rows: 1 }, { col: 0, row: 2, cols: 2, rows: 1 }];
const rowOf = target => dropped(cells, 1, { col: 1, row: target }, 2, 0.5)[1][1];
assert.deepEqual([1, 1.5, 2, 2.5, 3].map(rowOf), [2, 2, 2, 3, 3]);
});
test("a tile dragged down from another column lands where dropped unless gravity would lift it back", () => {
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: 1 }, { col: 1, row: 0, cols: 1, rows: 1 }];
const items = cells.map((cell, i) => ({ id: "abcd"[i], col: cell.col, row: cell.row }));
const drop = (index, target) => {
const moved = items.map((item, i) => i === index ? Object.assign({}, item, target) : item);
const order = [index].concat(items.map((item, i) => i).filter(i => i !== index));
const pack = trial => grid.packCells(trial, order, 2, null, 1, true).cells;
return plain(grid.packCells(grid.dropInto(moved, cells, index, target, pack), order, 2, null, 1, true).cells.map(cell => [cell.col, cell.row]));
};
assert.deepEqual(drop(3, { col: 0, row: 1 }), [[0, 0], [0, 2], [0, 3], [0, 1]]);
assert.deepEqual(drop(0, { col: 0, row: 1 }), [[0, 1], [0, 0], [0, 2], [1, 0]]);
});