From 77e60313efaa8e4ed140e440ab35275dcafd442e Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 19 Sep 2026 01:52:24 -0400 Subject: [PATCH] feat(control center): add fractional step sizes to pill widgets --- quickshell/Common/GridLayout.js | 29 ++++++++++--------- .../Modules/ControlCenter/CcMetrics.qml | 1 + .../ControlCenter/Components/CcTileGrid.qml | 2 +- .../ControlCenter/Components/CcTileSlot.qml | 11 +++---- .../ControlCenter/Widgets/CcSliderRow.qml | 4 +-- .../Modules/ControlCenter/Widgets/CcTile.qml | 4 +-- .../ControlCenter/Widgets/CcTileContent.qml | 4 +-- .../Modules/ControlCenter/utils/widgets.js | 10 ++++--- quickshell/tests/qml/control-center-sizes.qml | 20 +++++++++++++ 9 files changed, 55 insertions(+), 30 deletions(-) diff --git a/quickshell/Common/GridLayout.js b/quickshell/Common/GridLayout.js index 8f829ea56..a1bf93047 100644 --- a/quickshell/Common/GridLayout.js +++ b/quickshell/Common/GridLayout.js @@ -1,7 +1,8 @@ -function packCells(cards, order, columns, isAvailable) { +function packCells(cards, order, columns, isAvailable, step = 1) { + const steps = Math.round(columns / step); const cells = []; const heights = []; - for (let c = 0; c < columns; c++) + for (let c = 0; c < steps; c++) heights.push(0); let rows = 0; @@ -15,11 +16,11 @@ function packCells(cards, order, columns, isAvailable) { continue; } - const w = Math.max(1, Math.min(columns, card.w || 1)); - const h = Math.max(1, card.h || 1); + const w = Math.max(1, Math.min(steps, Math.round((card.w || 1) / step))); + const h = Math.max(1, Math.round((card.h || 1) / step)); let bestX = 0; let bestY = Infinity; - for (let x = 0; x + w <= columns; x++) { + for (let x = 0; x + w <= steps; x++) { let y = 0; for (let c = x; c < x + w; c++) y = Math.max(y, heights[c]); @@ -32,21 +33,21 @@ function packCells(cards, order, columns, isAvailable) { heights[c] = bestY + h; rows = Math.max(rows, bestY + h); cells[sourceIndex] = { - "col": bestX, - "row": bestY, - "cols": w, - "rows": h + "col": bestX * step, + "row": bestY * step, + "cols": w * step, + "rows": h * step }; } return { "cells": cells, - "rows": rows + "rows": rows * step }; } -function packCards(cards, order, columns, width, gap, rowUnit, mirror, isAvailable) { - const packed = packCells(cards, order, columns, isAvailable); +function packCards(cards, order, columns, width, gap, rowUnit, mirror, isAvailable, step = 1) { + const packed = packCells(cards, order, columns, isAvailable, step); const colW = (width - gap * (columns - 1)) / columns; const slots = packed.cells.map(cell => { if (!cell) @@ -160,8 +161,8 @@ function crossGap(a, aSize, b, bSize) { return Math.max(0, b - (a + aSize), a - (b + bSize)); } -function dimension(value, minimum, maximum, fallback) { +function dimension(value, minimum, maximum, fallback, step = 1) { const min = minimum ?? 1; const max = Math.max(min, maximum ?? fallback ?? min); - return Math.max(min, Math.min(max, Number.isInteger(value) ? value : (fallback ?? min))); + return Math.max(min, Math.min(max, typeof value === "number" && Number.isInteger(value / step) ? value : (fallback ?? min))); } diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index eb1d3b8c8..8d08db017 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -10,6 +10,7 @@ Singleton { readonly property int minimumColumns: 6 readonly property real sheetPadding: PopoutMetrics.contentPadding readonly property real columnWidth: tileHeight + readonly property real gridStep: 0.5 property int columnPreview: 0 readonly property int gridColumns: columnPreview > 0 ? columnPreview : clampColumns(SettingsData.controlCenterColumns) readonly property real sheetWidth: sheetWidthFor(gridColumns) diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index aaa54e8c2..b67205919 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -27,7 +27,7 @@ DankEditableGrid { readonly property CcTileSlot draggingSlot: tileRepeater.itemAt(draggingSourceIndex) as CcTileSlot sourceItems: (SettingsData.controlCenterWidgets || []).map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, Infinity))) - slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), visualOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl) + slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), visualOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep) placeholderRadius: draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight) onReorderCommitted: items => model.reorderWidgets(items) diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index c810cf806..f96df3fd3 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -10,8 +10,8 @@ DankEditableGridSlot { readonly property var widgetData: JSON.parse(json) readonly property var sizeSpec: WidgetUtils.sizeSpec(widgetData.id || "", grid.columns, grid.maximumRows) - readonly property int cols: slot?.cols ?? 1 - readonly property int rows: slot?.rows ?? 1 + readonly property real cols: slot?.cols ?? 1 + readonly property real rows: slot?.rows ?? 1 readonly property bool compact: cols <= 2 && rows === 1 readonly property var tileItem: tileLoader.item @@ -22,10 +22,11 @@ DankEditableGridSlot { } onResizeRequested: (requestedWidth, requestedHeight) => { - let width = GridUtils.dimension(Math.round((requestedWidth + CcMetrics.gridGap) / grid.cellWidth), sizeSpec.minW, sizeSpec.maxW, sizeSpec.w); - let height = GridUtils.dimension(Math.round((requestedHeight + CcMetrics.gridGap) / grid.cellWidth), sizeSpec.minH, sizeSpec.maxH, sizeSpec.h); + const step = sizeSpec.step; + let width = GridUtils.dimension(Math.round((requestedWidth + CcMetrics.gridGap) / grid.cellWidth / step) * step, sizeSpec.minW, sizeSpec.maxW, sizeSpec.w, step); + let height = GridUtils.dimension(Math.round((requestedHeight + CcMetrics.gridGap) / grid.cellWidth / step) * step, sizeSpec.minH, sizeSpec.maxH, sizeSpec.h, step); const current = WidgetUtils.clampSize(widgetData, grid.columns, grid.maximumRows); - if (WidgetUtils.isSliderWidget(widgetData.id) && width === 1 && height === 1) { + if (WidgetUtils.isSliderWidget(widgetData.id) && width < 2 && height < 2) { if (width !== current.w && sizeSpec.maxH > 1) height = 2; else diff --git a/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml index f821ab9c0..517356102 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml @@ -14,8 +14,8 @@ Item { property var host: null property bool live: true property bool interactive: true - property int columns: 4 - property int rows: 1 + property real columns: 4 + property real rows: 1 property bool compact: columns <= 2 && rows === 1 property string iconName: "" property string iconLabel: "" diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml index 5ed39bb69..7a32588e5 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml @@ -17,8 +17,8 @@ Item { property string title: "" property string subtitle: "" property bool active: false - property int columns: 4 - property int rows: 1 + property real columns: 4 + property real rows: 1 property bool compact: columns <= 2 && rows === 1 property bool toggle: !opensPage property Component expandedContent: null diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTileContent.qml b/quickshell/Modules/ControlCenter/Widgets/CcTileContent.qml index 772c0c642..310397e96 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTileContent.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTileContent.qml @@ -4,8 +4,8 @@ import qs.Common Item { property CcTile tile: null readonly property bool live: tile?.live ?? false - readonly property int columns: tile?.columns ?? 4 - readonly property int rows: tile?.rows ?? 1 + readonly property real columns: tile?.columns ?? 4 + readonly property real rows: tile?.rows ?? 1 readonly property color contentColor: tile?.contentColor ?? Theme.surfaceText readonly property color subtitleColor: tile?.subtitleColor ?? Theme.surfaceVariantText } diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 79f72f82c..dba7ced05 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -1,4 +1,5 @@ .import qs.Common as Common +.import qs.Modules.ControlCenter as ControlCenter .import "../../../Common/GridLayout.js" as GridLayout function isSliderWidget(id) { @@ -12,17 +13,18 @@ function sizeSpec(id, columns, rows = Infinity) { "minW": 1, "maxW": columns, "minH": 1, - "maxH": rows + "maxH": rows, + "step": ControlCenter.CcMetrics.gridStep }; } function clampSize(widget, columns, rows = Infinity) { const spec = sizeSpec(widget.id || "", columns, rows); const size = { - "w": GridLayout.dimension(widget.w, spec.minW, spec.maxW, spec.w), - "h": GridLayout.dimension(widget.h, spec.minH, spec.maxH, spec.h) + "w": GridLayout.dimension(widget.w, spec.minW, spec.maxW, spec.w, spec.step), + "h": GridLayout.dimension(widget.h, spec.minH, spec.maxH, spec.h, spec.step) }; - if (!isSliderWidget(widget.id) || size.w > 1 || size.h > 1) + if (!isSliderWidget(widget.id) || size.w >= 2 || size.h >= 2) return size; if (rows > 1) return { diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml index 7466b6801..dead1376d 100644 --- a/quickshell/tests/qml/control-center-sizes.qml +++ b/quickshell/tests/qml/control-center-sizes.qml @@ -209,6 +209,23 @@ ShellRoot { }); grid.commitSize(); check(SettingsData.controlCenterWidgets[1].w === 6 && SettingsData.controlCenterWidgets[1].h === 12, "width edits preserve the saved height on a smaller screen"); + grid.previewSize(1, { + w: 2.5 + }); + grid.commitSize(); + grid.previewSize(1, { + h: 1.5 + }); + grid.commitSize(); + const half = grid.slotLayout.slots[1]; + const cell = grid.cellWidth - CcMetrics.gridGap; + check(SettingsData.controlCenterWidgets[1].w === 2.5 && SettingsData.controlCenterWidgets[1].h === 1.5 && half.cols === 2.5 && half.rows === 1.5, "half steps are saved on both axes"); + check(Math.abs(half.w - (cell * 2.5 + CcMetrics.gridGap * 1.5)) < 0.01 && Math.abs(half.h - (cell * 1.5 + CcMetrics.gridGap * 0.5)) < 0.01, "half spans pack at half pitch"); + check(WidgetUtils.clampSize({ + id: "volumeSlider", + w: 1.5, + h: 1 + }, 8, 1).w === 2, "sliders under two cells on both axes keep a usable track"); check(WidgetUtils.clampSize({ id: "volumeSlider", w: 1, @@ -230,6 +247,9 @@ ShellRoot { sliderSlot.resizeRequested(grid.cellWidth - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap); grid.commitSize(); check(SettingsData.controlCenterWidgets[0].w === 2 && SettingsData.controlCenterWidgets[0].h === 1, "shortening a slider retains a usable strip layout"); + sliderSlot.resizeRequested(grid.cellWidth * 3.5 - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap); + grid.commitSize(); + check(SettingsData.controlCenterWidgets[0].w === 3.5 && sliderSlot.tileItem.columns === 3.5, "resizing snaps to half columns"); grid.destroy(); wait(0); const tile = tileComponent.createObject(scene);