diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index 8f67be71c..7c4131277 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -53,6 +53,7 @@ Singleton { readonly property real iconBoxActiveRadius: Theme.cornerRadiusL readonly property real iconBoxIconSize: Theme.iconSize * iconScale readonly property real tallMeterThickness: 28 + readonly property real pillMeterThickness: Math.round(18 * iconScale) readonly property real tileTextGap: Theme.spacingM readonly property real footerHeight: iconBoxSize readonly property real footerGap: Theme.spacingS @@ -120,6 +121,10 @@ Singleton { readonly property color tileInactiveContent: Theme.surfaceText readonly property color tileInactiveSubtitle: Theme.surfaceVariantText readonly property color tileInactiveIcon: Theme.primary + readonly property color bareActiveIcon: Theme.primary + readonly property real quickBarePadding: Theme.spacingL + readonly property real quickBareWidth: iconBoxIconSize + quickBarePadding + readonly property real quickTileGap: Theme.spacingXS readonly property bool animationsEnabled: !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 34b8c13f9..c346b60a1 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -33,9 +33,11 @@ DEditableGrid { readonly property real gridHeight: layoutHeight readonly property real cellWidth: (width + CcMetrics.gridGap) / columns readonly property CcTileSlot draggingSlot: tileRepeater.itemAt(draggingSourceIndex) as CcTileSlot + readonly property string hoverGroup: heldOutside && draggingSourceIndex >= 0 ? (groupAt(dragScenePoint, draggingSourceIndex)?.groupId ?? "") : "" readonly property var savedWidgets: SettingsData.controlCenterWidgets || [] - readonly property var shownIndices: savedWidgets.reduce((indices, widget, i) => !WidgetUtils.inFooter(widget) && WidgetUtils.isShown(widget) && model?.componentForWidget(widget) ? indices.concat([i]) : indices, []) + readonly property var groupIds: WidgetUtils.groupIds(savedWidgets) + readonly property var shownIndices: savedWidgets.reduce((indices, widget, i) => !WidgetUtils.inFooter(widget) && !WidgetUtils.inGroup(widget, groupIds) && WidgetUtils.isShown(widget) && model?.componentForWidget(widget) ? indices.concat([i]) : indices, []) sourceItems: shownIndices.map(i => Object.assign({}, savedWidgets[i], sizeWithHiddenTwin(i))) slotLayout: GridUtils.packCards(clamped(layoutItems), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep, true) @@ -68,6 +70,17 @@ DEditableGrid { return size; } + function groupAt(scenePoint, excludeIndex) { + for (let i = 0; i < tileRepeater.count; i++) { + const slot = tileRepeater.itemAt(i) as CcTileSlot; + if (i === excludeIndex || slot?.widgetData.id !== WidgetUtils.GROUP_ID) + continue; + if (slot.contains(slot.mapFromItem(null, scenePoint.x, scenePoint.y))) + return slot.tileItem; + } + return null; + } + function savedIndex(index) { return shownIndices[index] ?? -1; } diff --git a/quickshell/Modules/ControlCenter/Components/CcTileLoader.qml b/quickshell/Modules/ControlCenter/Components/CcTileLoader.qml index 8429c9014..78bfa2705 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileLoader.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileLoader.qml @@ -11,6 +11,7 @@ Loader { property bool compact: false property bool small: false property bool docked: false + property bool bare: false sourceComponent: grid.model ? grid.model.componentForWidget(widgetData) : null @@ -84,6 +85,13 @@ Loader { when: root.item !== null && "docked" in root.item } + Binding { + target: root.item + property: "bare" + value: root.bare + when: root.item !== null && "bare" in root.item + } + Connections { target: root.item ignoreUnknownSignals: true diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index a0ccf6e77..07ccce22a 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -20,6 +20,7 @@ DEditableGridSlot { readonly property real smallSpanLimit: (1 + CcMetrics.smallRowFraction) / 2 passthrough: tileItem?.passthrough ?? null + z: dragging || resizing || (tileItem?.memberDragging ?? false) ? 1 : 0 function reanchor(small) { const shift = ((small ? CcMetrics.smallRowFraction : 1) - smallSpanLimit) * grid.cellWidth; diff --git a/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml b/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml new file mode 100644 index 000000000..b1a128f84 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Components/QuickTilesGroup.qml @@ -0,0 +1,273 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.ControlCenter +import qs.DCommon.Widgets +import "../utils/widgets.js" as WidgetUtils + +Item { + id: root + + property var widgetData: ({}) + property var widgetDef: null + property var host: null + property bool live: true + property bool interactive: true + property real columns: 4 + property real rows: 1 + property bool compact: false + + readonly property string groupId: widgetData?.instanceId ?? "" + // Not `bare`: the grid's tile loader writes that name into any tile that has it. + readonly property bool membersBare: widgetData?.background === false + readonly property real gap: membersBare ? 0 : CcMetrics.quickTileGap + readonly property bool editMode: host?.editMode ?? false + readonly property var members: WidgetUtils.groupMembers(SettingsData.controlCenterWidgets || [], groupId).filter(member => !!host?.model?.componentForWidget(member.widget)) + readonly property bool receiving: groupId !== "" && host?.hoverGroup === groupId + // Members keep their own input in edit mode so they can be dragged out instead of moving the group. + readonly property Item passthrough: memberRow + readonly property real bodyRadius: Theme.fullRadius(width, Math.min(height, CcMetrics.tileHeight)) + readonly property bool memberDragging: liftedIndex >= 0 + + property int liftedIndex: -1 + property real liftedWidth: 0 + property point liftPoint: Qt.point(0, 0) + property int dropSlot: -1 + readonly property int incomingSlot: receiving ? slotAt(mapFromItem(null, host.dragScenePoint.x, host.dragScenePoint.y).x) : -1 + readonly property int gapSlot: liftedIndex >= 0 ? dropSlot : incomingSlot + readonly property real gapWidth: liftedIndex >= 0 ? liftedWidth : (membersBare ? CcMetrics.quickBareWidth : CcMetrics.iconBoxSize) + + width: parent?.width ?? 0 + height: parent?.height ?? 0 + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + function others() { + const items = []; + for (let i = 0; i < memberRepeater.count; i++) { + const item = memberRepeater.itemAt(i); + if (item && item.modelData.index !== liftedIndex) + items.push(item); + } + return items; + } + + // Measured against the row as it sits without a gap, so opening the gap cannot move the target. + function slotAt(x) { + const items = others(); + const total = items.reduce((sum, item) => sum + item.ownWidth + gap, 0) - gap; + const along = I18n.isRtl ? width - x : x; + let start = (width - total) / 2; + for (let k = 0; k < items.length; k++) { + if (along < start + items[k].ownWidth / 2) + return k; + start += items[k].ownWidth + gap; + } + return items.length; + } + + function savedIndexAtSlot(slot) { + return others()[slot]?.modelData.index ?? -1; + } + + function lift(member, scenePoint) { + liftedWidth = member.ownWidth; + liftedIndex = member.modelData.index; + moveLifted(member, scenePoint); + } + + function moveLifted(member, scenePoint) { + const point = mapFromItem(null, scenePoint.x, scenePoint.y); + liftPoint = Qt.point(point.x - member.grabOffset.x, point.y - member.grabOffset.y); + if (contains(point)) { + dropSlot = slotAt(point.x); + host.clearExternal(); + return; + } + dropSlot = -1; + const size = WidgetUtils.clampSize(member.modelData.widget, host.columns, host.maximumRows); + const gridPoint = host.mapFromItem(null, scenePoint.x, scenePoint.y); + host.previewExternal({ + "id": member.modelData.widget.id, + "w": size.w, + "h": size.h + }, gridPoint.x - host.cellWidth * size.w / 2, gridPoint.y - host.slotLayout.rowUnit * size.h / 2); + } + + function drop(member) { + const savedIndex = member.modelData.index; + const slot = dropSlot; + const before = savedIndexAtSlot(slot); + const position = members.findIndex(other => other.index === savedIndex); + const unchanged = before === (members[position + 1]?.index ?? -1); + liftedIndex = -1; + dropSlot = -1; + if (slot >= 0) { + host.clearExternal(); + if (!unchanged) + Qt.callLater(() => WidgetUtils.moveToGroup(savedIndex, groupId, before)); + return; + } + if (!host.externalItem) + return; + const placed = host.committedItems(); + const cell = placed.pop(); + const widgets = WidgetUtils.placeFromGroup(host.withHidden(placed), savedIndex, cell.col, cell.row); + Qt.callLater(() => { + WidgetUtils.setLayout(widgets); + host.cancelInteraction(); + }); + } + + Rectangle { + anchors.fill: parent + radius: root.bodyRadius + color: root.receiving ? Theme.withAlpha(Theme.primary, Theme.stateLayerDrag) : "transparent" + visible: root.editMode + } + + StyledText { + anchors.fill: parent + anchors.margins: Theme.spacingS + text: I18n.tr("Drag small tiles in to group them", "control center quick tiles widget description") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + elide: Text.ElideRight + visible: root.editMode && root.members.length === 0 && root.gapSlot < 0 + } + + Row { + id: memberRow + + // Leaves the bottom corners to the edit chrome's resize grip. + readonly property real cornerSize: Theme.minimumTouchTargetSize / 2 + + // Every member carries its trailing gap, which sits on the far side in RTL. + x: (root.width - Math.max(0, implicitWidth - root.gap)) / 2 - (I18n.isRtl ? root.gap : 0) + anchors.verticalCenter: parent.verticalCenter + height: CcMetrics.iconBoxSize + containmentMask: QtObject { + function contains(point: point): bool { + if (point.x < 0 || point.y < 0 || point.x >= memberRow.width || point.y >= memberRow.height) + return false; + const local = memberRow.mapToItem(root, point.x, point.y); + return local.y < root.height - memberRow.cornerSize || (local.x >= memberRow.cornerSize && local.x < root.width - memberRow.cornerSize); + } + } + + Repeater { + id: memberRepeater + + model: root.members + + Item { + id: member + + required property var modelData + required property int index + readonly property bool lifted: root.liftedIndex === modelData.index + readonly property int position: index - (root.liftedIndex >= 0 && index > root.members.findIndex(other => other.index === root.liftedIndex) ? 1 : 0) + readonly property bool gapBefore: !lifted && root.gapSlot === position + readonly property real ownWidth: loader.item?.quickWidth ?? CcMetrics.iconBoxSize + property point grabOffset: Qt.point(0, 0) + + width: (gapBefore ? root.gapWidth + root.gap : 0) + (lifted ? 0 : ownWidth + root.gap) + height: memberRow.height + z: lifted ? 1 : 0 + + Behavior on width { + enabled: CcMetrics.animationsEnabled && (root.memberDragging || root.receiving) + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + + Rectangle { + x: I18n.isRtl ? member.ownWidth + root.gap * 2 : 0 + width: root.gapWidth + height: parent.height + radius: Theme.fullRadius(width, height) + color: Theme.withAlpha(Theme.primary, Theme.stateLayerDrag) + visible: member.gapBefore + } + + Item { + id: body + + x: { + if (member.lifted) + return root.liftPoint.x - memberRow.x - member.x; + if (I18n.isRtl) + return root.gap; + return member.gapBefore ? root.gapWidth + root.gap : 0; + } + y: member.lifted ? root.liftPoint.y - memberRow.y : 0 + width: member.ownWidth + height: memberRow.height + + Behavior on x { + enabled: CcMetrics.animationsEnabled && !member.lifted + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + + CcTileLoader { + id: loader + + anchors.fill: parent + grid: root.host + widgetData: member.modelData.widget + savedIndex: member.modelData.index + compact: true + small: true + bare: root.membersBare + } + + DragHandler { + id: drag + + target: null + enabled: root.editMode + cursorShape: Qt.ClosedHandCursor + onActiveChanged: { + if (active) { + member.grabOffset = centroid.pressPosition; + root.lift(member, centroid.scenePosition); + return; + } + if (member.lifted) + root.drop(member); + } + onCentroidChanged: { + if (active) + root.moveLifted(member, centroid.scenePosition); + } + } + } + } + } + + Item { + width: root.gapSlot >= 0 && root.gapSlot === root.members.length - (root.liftedIndex >= 0 ? 1 : 0) ? root.gapWidth + root.gap : 0 + height: memberRow.height + + Rectangle { + x: I18n.isRtl ? root.gap : 0 + width: root.gapWidth + height: parent.height + radius: Theme.fullRadius(width, height) + color: Theme.withAlpha(Theme.primary, Theme.stateLayerDrag) + visible: parent.width > 0 + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml index fe89f64c9..7ae26af49 100644 --- a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml +++ b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml @@ -31,6 +31,15 @@ Item { readonly property bool isDisk: widgetId === "diskUsage" readonly property bool isIdleInhibitor: widgetId === "idleInhibitor" readonly property bool isUser: widgetId === "user" + readonly property bool isGroup: widgetId === WidgetUtils.GROUP_ID + // Grouped tiles have no menu of their own, so the group's menu styles its battery. + readonly property int batteryIndex: { + if (widgetId === "battery") + return widgetIndex; + if (!isGroup) + return -1; + return WidgetUtils.groupMembers(SettingsData.controlCenterWidgets || [], widgetData.instanceId).find(member => member.widget.id === "battery")?.index ?? -1; + } visible: widgetIndex >= 0 || contextMenu.renderActive @@ -101,12 +110,32 @@ Item { } CcToggleRow { - visible: root.isUser + visible: root.isUser || root.isGroup text: I18n.tr("Background") - checked: root.widgetData?.background === true + checked: root.isUser ? root.widgetData?.background === true : root.widgetData?.background !== false onToggled: checked => root.persistOption("background", checked) } + CcListRow { + visible: root.batteryIndex >= 0 + iconName: "battery_std" + title: I18n.tr("Battery style") + body: DDropdown { + readonly property var labels: [I18n.tr("Icon", "battery widget: system battery glyph"), I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style"), I18n.tr("Duo", "battery meter style: open battery arc around the network glyph")] + + compactMode: true + dropdownWidth: parent.width + transientSurfaceTracker: contextMenu.transientSurfaceTracker + currentValue: labels[Math.max(0, WidgetUtils.BATTERY_STYLES.indexOf(SettingsData.controlCenterWidgets[root.batteryIndex]?.batteryStyle ?? "icon"))] + options: labels + onValueChanged: value => { + const style = WidgetUtils.BATTERY_STYLES[labels.indexOf(value)]; + if (style) + WidgetUtils.setOption(root.batteryIndex, "batteryStyle", style); + } + } + } + Repeater { model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : [] diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 8327ceff2..1bbed746e 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -24,7 +24,10 @@ FocusScope { readonly property bool pageOpen: (host.expandedSection ?? "") !== "" readonly property real gridHeight: widgetGrid.gridHeight - readonly property real chromeHeight: Theme.spacingS + footer.height + // The row cap always reserves the footer so showing it in edit mode never reflows tiles. + readonly property real footerReserve: Theme.spacingS + CcMetrics.footerHeight + readonly property bool footerShown: root.host.editMode || footerItems.length > 0 + readonly property real chromeHeight: footerShown ? footerReserve : 0 readonly property bool widgetSheetOpen: widgetSheetLoader.item?.active ?? false readonly property real coveredAmount: Math.max(detailPage.opacity, widgetSheetLoader.item?.progress ?? 0) property bool widgetSheetRequested: false @@ -58,7 +61,7 @@ FocusScope { readonly property vector4d surfaceCornerRadii: host.surfaceCornerRadii ?? Qt.vector4d(Theme.windowRadius, Theme.windowRadius, Theme.windowRadius, Theme.windowRadius) readonly property int gridColumnCap: host.gridColumnCap ?? CcMetrics.columnCapFor((host.triggerScreen?.width ?? CcMetrics.sheetWidthDefault + Theme.spacingL * 2) - Theme.spacingL * 2) readonly property int gridColumns: host.gridColumns ?? Math.min(CcMetrics.gridColumns, gridColumnCap) - readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - chromeHeight + readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - footerReserve readonly property vector4d chromeRoom: host.chromeRoom ?? Qt.vector4d(Infinity, Infinity, Infinity, Infinity) readonly property DPanelResizer panelResizer: DPanelResizer { popout: root.host @@ -187,7 +190,7 @@ FocusScope { // 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)) + if (gridDragWidget === null || gridDragWidget.id === WidgetUtils.GROUP_ID || footer.freeCells() < WidgetUtils.footerMinCells(gridDragWidget.id)) return false; const rowUnit = widgetGrid.slotLayout.rowUnit; const anchor = tile.y + Math.min(tile.height, rowUnit) / 2; @@ -198,6 +201,10 @@ FocusScope { return anchor > widgetGrid.pinnedHeight + room + CcMetrics.gridGap / 2; } + function groupTakesGridDrag(scenePoint) { + return WidgetUtils.canGroup(gridDragWidget) && widgetGrid.groupAt(scenePoint, widgetGrid.draggingSourceIndex) !== null; + } + // Drops commit a tick later: committing inside the release handler would destroy the dragged item mid-signal. function dropFromGrid(index, scenePoint) { if (!widgetGrid.heldOutside) @@ -210,6 +217,16 @@ FocusScope { }); return true; } + const group = widgetGrid.hoverGroup !== "" ? widgetGrid.groupAt(scenePoint, index) : null; + if (group) { + const groupId = group.groupId; + const before = group.savedIndexAtSlot(group.incomingSlot); + Qt.callLater(() => { + WidgetUtils.moveToGroup(savedIndex, groupId, before); + widgetGrid.cancelInteraction(); + }); + return true; + } const slot = footer.insertionAt(scenePoint); const before = footer.savedIndexAt(slot); const cells = Math.min(WidgetUtils.footerCells(gridDragWidget), footer.freeCells()); @@ -408,9 +425,9 @@ FocusScope { anchors.left: parent.left anchors.right: parent.right anchors.top: root.footerOnTop ? footer.bottom : parent.top - anchors.topMargin: root.footerOnTop ? Theme.spacingS : 0 + anchors.topMargin: root.footerOnTop && root.footerShown ? Theme.spacingS : 0 anchors.bottom: root.footerOnTop ? parent.bottom : footer.top - anchors.bottomMargin: root.footerOnTop ? 0 : Theme.spacingS + anchors.bottomMargin: !root.footerOnTop && root.footerShown ? Theme.spacingS : 0 clip: contentHeight > height contentWidth: width contentHeight: Math.max(height, mainColumn.implicitHeight + CcMetrics.sheetPadding) @@ -444,7 +461,7 @@ FocusScope { screenName: root.host.triggerScreen?.name || "" tapToClose: root.host.headerTogglesClose ?? false runningToplevels: root.runningToplevels - dragsOutside: (index, scenePoint, tile) => footer.trashContains(scenePoint) || root.footerTakesGridDrag(tile) + dragsOutside: (index, scenePoint, tile) => footer.trashContains(scenePoint) || root.groupTakesGridDrag(scenePoint) || root.footerTakesGridDrag(tile) dropHandler: (index, scenePoint) => root.dropFromGrid(index, scenePoint) onExpandClicked: widgetData => root.openWidgetPage(widgetData) onRemoveWidget: index => widgetModel.removeWidget(index) @@ -479,6 +496,8 @@ FocusScope { x: CcMetrics.sheetPadding y: root.footerOnTop ? CcMetrics.sheetPadding : root.height - CcMetrics.sheetPadding - height width: root.sheetContentWidth - CcMetrics.sheetPadding * 2 + height: root.footerShown ? implicitHeight : 0 + visible: root.footerShown grid: widgetGrid editMode: root.host.editMode onTop: root.footerOnTop @@ -487,7 +506,7 @@ FocusScope { gridDragging: root.gridDragWidget !== null gridDragPoint: widgetGrid.dragScenePoint incomingRemovable: WidgetUtils.isRemovable(root.gridDragWidget) - incoming: root.gridDragWidget !== null && widgetGrid.heldOutside && !footer.overTrash ? Object.assign({ + incoming: root.gridDragWidget !== null && widgetGrid.heldOutside && widgetGrid.hoverGroup === "" && !footer.overTrash ? Object.assign({ "cells": Math.min(WidgetUtils.footerCells(root.gridDragWidget), footer.freeCells()) }, footer.gridSlot) : null dropHandler: (index, cells, scenePoint) => root.dropFromFooter(index, scenePoint) diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index 26be93c7f..e3f3f924c 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -5,6 +5,7 @@ import qs.Common import qs.Services import qs.Modules.ControlCenter import qs.Modules.ControlCenter.BuiltinPlugins +import qs.Modules.ControlCenter.Components import qs.Modules.ControlCenter.Widgets import "../utils/widgets.js" as WidgetUtils @@ -113,6 +114,9 @@ QtObject { readonly property Component runningAppsTile: Component { RunningAppsTile {} } + readonly property Component quickTilesGroup: Component { + QuickTilesGroup {} + } readonly property Component pluginTile: Component { PluginTile {} } @@ -175,6 +179,8 @@ QtObject { return actionTile; case "runningApps": return runningAppsTile; + case "quickTiles": + return quickTilesGroup; default: return null; } @@ -247,6 +253,16 @@ QtObject { "category": "system", "enabled": true }, + { + "id": "quickTiles", + "text": I18n.tr("Quick tiles", "control center widget that holds small tiles in one row"), + "description": I18n.tr("Drag small tiles in to group them", "control center quick tiles widget description"), + "icon": "view_module", + "type": "action", + "category": "system", + "enabled": true, + "allowMultiple": true + }, { "id": "nightMode", "text": I18n.tr("Night mode"), diff --git a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml index dc1fe8147..570c79ca9 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml @@ -11,6 +11,9 @@ CcTile { property bool batteryRefHeld: false readonly property bool available: BatteryService.batteryAvailable readonly property bool profileMode: !available && PowerProfileWatcher.available + readonly property string meterStyle: widgetData?.batteryStyle ?? "icon" + readonly property bool showsMeter: available && meterStyle !== "icon" && (small || compact) && !docked + property real meterWidth: 0 iconName: profileMode ? Theme.getPowerProfileIcon(PowerProfileWatcher.currentProfile) : BatteryService.getBatteryIcon() title: { @@ -45,9 +48,35 @@ CcTile { onLiveChanged: syncBatteryRef(live) Component.onCompleted: syncBatteryRef(live) Component.onDestruction: syncBatteryRef(false) - active: available && (BatteryService.isCharging || BatteryService.isPluggedIn) + // The meter carries its own charging bolt and would vanish into an active body. + active: available && (BatteryService.isCharging || BatteryService.isPluggedIn) && !showsMeter + bodyContent: showsMeter ? meterPill : null + quickContentWidth: showsMeter ? meterWidth : 0 opensPage: !profileMode showExpand: profileMode + + Component { + id: meterPill + + Item { + BatteryMeter { + id: meter + + anchors.centerIn: parent + meterStyle: root.meterStyle + levelColors: true + thickness: CcMetrics.pillMeterThickness + scale: Math.min(1, (parent.width - Theme.spacingXS * 2) / Math.max(1, implicitWidth)) + } + + Binding { + target: root + property: "meterWidth" + value: meter.implicitWidth + } + } + } + tallContent: Component { Item { BatteryMeter { diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml index 4f7e65b25..510e0a199 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml @@ -24,6 +24,10 @@ Item { property bool small: false property bool docked: false property string dockedText: title + // Set by a background-less Quick tiles group. + property bool bare: false + property real quickContentWidth: 0 + readonly property real quickWidth: Math.max(bare ? CcMetrics.quickBareWidth : CcMetrics.iconBoxSize, quickContentWidth + (bare ? CcMetrics.quickBarePadding : Theme.spacingM * 2)) readonly property real bodyInset: small ? Math.max(0, (height - CcMetrics.iconBoxSize) / 2) : 0 readonly property real bodyHeight: height - bodyInset * 2 property bool toggle: !opensPage @@ -69,8 +73,10 @@ Item { return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, bodyHeight); } property bool acceptsInput: interactive && enabled - readonly property bool bodyActive: showsActive && !hasIconBox + readonly property bool bodyActive: showsActive && !hasIconBox && !bare readonly property color bodyColor: { + if (bare) + return "transparent"; if (!available) return Theme.onSurface_12; return bodyActive ? CcMetrics.tileActiveColor : CcMetrics.tileInactiveColor; @@ -90,6 +96,11 @@ Item { return Theme.onSurface_38; if (hasIconBox) return showsActive ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveContent; + // Without a body, toggles show their state in the icon tint alone. + if (bare && showsActive) + return CcMetrics.bareActiveIcon; + if (bare && toggle) + return CcMetrics.tileInactiveContent; return bodyActive ? CcMetrics.tileActiveContent : root.restIconColor; } readonly property color iconBoxColor: { @@ -177,7 +188,7 @@ Item { anchors.bottomMargin: root.bodyInset radius: root.bodyRadius color: root.bodyColor - border.width: Theme.layerOutlineWidth + border.width: root.bare ? 0 : Theme.layerOutlineWidth border.color: Theme.outlineMedium Behavior on color { diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 382dbc3ef..2af7afd99 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -4,7 +4,9 @@ .import "../../../Common/GridLayout.js" as GridLayout .import "../../../Common/settings/SettingsSpec.js" as Spec -var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user"]; +var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user", "battery", "quickTiles"]; +var GROUP_ID = "quickTiles"; +var BATTERY_STYLES = ["icon", "solid", "outline", "ring", "duo"]; var ACTION_IDS = ["settings", "lock", "power"]; var EDIT_ID = "edit"; var BUTTON_IDS = ACTION_IDS.concat([EDIT_ID]); @@ -29,6 +31,26 @@ function isSmall(widget, rows) { return widget?.small === true && rows === 1 && canShrink(widget.id); } +function canGroup(widget) { + return canShrink(widget?.id) && widget?.id !== GROUP_ID; +} + +// Members of a group that no longer exists fall back to the grid. +function groupIds(widgets) { + return widgets.filter(widget => widget?.id === GROUP_ID).map(widget => widget.instanceId); +} + +function inGroup(widget, ids) { + return !!widget?.group && ids.includes(widget.group); +} + +function groupMembers(widgets, groupId) { + return widgets.reduce((members, widget, index) => widget?.group === groupId && isShown(widget) ? members.concat([{ + "index": index, + "widget": widget + }]) : members, []); +} + function inFooter(widget) { return !!widget?.footer; } @@ -174,6 +196,9 @@ function addWidget(widgetId, columns) { widget.showMountPath = true; } + if (widgetId === GROUP_ID) + widget.instanceId = generateUniqueId(); + if (widgetId === "brightnessSlider") { widget.instanceId = generateUniqueId(); widget.deviceName = ""; @@ -191,10 +216,52 @@ function removeWidget(index) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); if (index < 0 || index >= widgets.length || !isRemovable(widgets[index])) return; + const [removed] = widgets.splice(index, 1); + Common.SettingsData.set("controlCenterWidgets", removed.id === GROUP_ID ? ungroup(widgets, removed.instanceId) : widgets); +} + +function ungroup(widgets, groupId) { + return widgets.map(widget => { + if (widget?.group !== groupId) + return widget; + const freed = Object.assign({}, widget); + delete freed.group; + return freed; + }); +} + +// Lands just before `beforeIndex`, or after the group's last member. +function moveToGroup(index, groupId, beforeIndex) { + const widgets = Common.SettingsData.controlCenterWidgets.slice(); + const widget = widgets[index]; + if (!widget || !canGroup(widget)) + return; + const before = beforeIndex === index ? null : widgets[beforeIndex] ?? null; widgets.splice(index, 1); + const lastMember = widgets.reduce((last, other, i) => other?.group === groupId ? i : last, -1); + const at = before ? widgets.indexOf(before) : lastMember >= 0 ? lastMember + 1 : widgets.length; + const moved = Object.assign({}, widget, { + "group": groupId + }); + delete moved.col; + delete moved.row; + delete moved.footer; + delete moved.footerEnd; + widgets.splice(at, 0, moved); Common.SettingsData.set("controlCenterWidgets", widgets); } +function placeFromGroup(widgets, index, col, row) { + const tile = Object.assign({}, widgets[index], { + "col": col, + "row": row + }); + delete tile.group; + const placed = widgets.slice(); + placed[index] = tile; + return placed; +} + function setOption(index, key, value) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); if (index < 0 || index >= widgets.length) diff --git a/quickshell/Widgets/DGridEditChrome.qml b/quickshell/Widgets/DGridEditChrome.qml index 980a8ce71..27764b992 100644 --- a/quickshell/Widgets/DGridEditChrome.qml +++ b/quickshell/Widgets/DGridEditChrome.qml @@ -29,8 +29,10 @@ Item { readonly property real handleThickness: Theme.spacingM readonly property real touchTargetSize: Math.max(Theme.minimumTouchTargetSize, buttonSize) readonly property real contentInset: touchTargetSize / 2 - readonly property bool showOptionsButton: hasOptions && width - contentInset * 2 >= touchTargetSize * (1 + (removable ? 1 : 0) + (horizontalResize ? 1 : 0)) - readonly property int chromeButtons: (removable ? 1 : 0) + (showOptionsButton ? 1 : 0) + readonly property bool showOptionsButton: hasOptions + // Too narrow to sit beside the remove button: both move onto the top corners instead. + readonly property bool cornerButtons: hasOptions && width - contentInset * 2 < touchTargetSize * (1 + (removable ? 1 : 0) + (horizontalResize ? 1 : 0)) + readonly property int chromeButtons: (removable ? 1 : 0) + (showOptionsButton && !cornerButtons ? 1 : 0) readonly property rect hitBounds: Qt.rect(contentInset - hitOverflow, contentInset - hitOverflow, width - (contentInset - hitOverflow) * 2, height - (contentInset - hitOverflow) * 2) signal removeRequested @@ -60,22 +62,36 @@ Item { border.width: Theme.outlineWidthFocused } - Rectangle { - x: I18n.isRtl ? root.width - root.contentInset - width - (root.touchTargetSize - root.buttonSize) / 2 : root.contentInset + (root.touchTargetSize - root.buttonSize) / 2 - y: root.contentInset - height / 2 - width: root.buttonSize + root.touchTargetSize * Math.max(0, root.chromeButtons - 1) + component ButtonChip: Rectangle { height: root.buttonSize radius: Theme.fullRadius(width, height) color: Theme.chipSurface border.color: Theme.primary border.width: Theme.outlineWidth + } + + ButtonChip { + x: root.cornerButtons ? removeButton.x + (root.touchTargetSize - root.buttonSize) / 2 : I18n.isRtl ? root.width - root.contentInset - width - (root.touchTargetSize - root.buttonSize) / 2 : root.contentInset + (root.touchTargetSize - root.buttonSize) / 2 + y: root.contentInset - height / 2 + width: root.buttonSize + root.touchTargetSize * Math.max(0, root.chromeButtons - 1) visible: root.chromeButtons > 0 } + ButtonChip { + x: optionsButton.x + (root.touchTargetSize - root.buttonSize) / 2 + y: root.contentInset - height / 2 + width: root.buttonSize + visible: root.cornerButtons + } + DActionButton { id: removeButton - x: I18n.isRtl ? root.width - root.contentInset - width : root.contentInset + x: { + if (root.cornerButtons) + return I18n.isRtl ? root.width - width : 0; + return I18n.isRtl ? root.width - root.contentInset - width : root.contentInset; + } y: 0 width: root.touchTargetSize height: root.touchTargetSize @@ -109,7 +125,11 @@ Item { readonly property real slotOffset: root.removable ? root.touchTargetSize : 0 - x: I18n.isRtl ? root.width - root.contentInset - slotOffset - width : root.contentInset + slotOffset + x: { + if (root.cornerButtons) + return I18n.isRtl ? 0 : root.width - width; + return I18n.isRtl ? root.width - root.contentInset - slotOffset - width : root.contentInset + slotOffset; + } y: 0 width: root.touchTargetSize height: root.touchTargetSize