diff --git a/quickshell/Common/GridLayout.js b/quickshell/Common/GridLayout.js index 02f91c2a3..af1b9dcf4 100644 --- a/quickshell/Common/GridLayout.js +++ b/quickshell/Common/GridLayout.js @@ -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) { diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 49783dc3a..34b8c13f9 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -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); diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index cdf649eb1..8327ceff2 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -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. diff --git a/quickshell/Widgets/DEditableGrid.qml b/quickshell/Widgets/DEditableGrid.qml index 3c69e3cc1..b461c7907 100644 --- a/quickshell/Widgets/DEditableGrid.qml +++ b/quickshell/Widgets/DEditableGrid.qml @@ -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) diff --git a/quickshell/tests/dash-grid.test.mjs b/quickshell/tests/dash-grid.test.mjs index d4f2726a3..cadcbc2d0 100644 --- a/quickshell/tests/dash-grid.test.mjs +++ b/quickshell/tests/dash-grid.test.mjs @@ -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]]); });