diff --git a/quickshell/Common/GridLayout.js b/quickshell/Common/GridLayout.js index e8767febe..0d737641c 100644 --- a/quickshell/Common/GridLayout.js +++ b/quickshell/Common/GridLayout.js @@ -94,36 +94,41 @@ function firstFit(taken, steps, w, h) { function packCards(cards, order, columns, width, gap, rowUnit, mirror, isAvailable, step = 1, gravity = false) { const packed = packCells(cards, order, columns, isAvailable, step, gravity); - const colW = (width - gap * (columns - 1)) / columns; - const slots = packed.cells.map(cell => { - if (!cell) - return null; - const px = cell.col * (colW + gap); - const pw = cell.cols * colW + (cell.cols - 1) * gap; - return { - "x": mirror ? width - px - pw : px, - "y": cell.row * (rowUnit + gap), - "w": pw, - "h": cell.rows * rowUnit + (cell.rows - 1) * gap, - "col": cell.col, - "row": cell.row, - "cols": cell.cols, - "rows": cell.rows - }; - }); - - return { - "slots": slots, + const layout = { + "slots": [], "rows": packed.rows, "totalHeight": packed.rows > 0 ? packed.rows * rowUnit + (packed.rows - 1) * gap : 0, "columns": columns, "width": width, - "colW": colW, + "colW": (width - gap * (columns - 1)) / columns, "rowUnit": rowUnit, "gap": gap, "step": step, "mirror": mirror }; + layout.slots = packed.cells.map(cell => cell ? Object.assign(slotRect(layout, cell.col, cell.row, cell.cols, cell.rows), cell) : null); + return layout; +} + +function slotRect(layout, col, row, cols, rows) { + const px = col * (layout.colW + layout.gap); + const pw = cols * layout.colW + (cols - 1) * layout.gap; + return { + "x": layout.mirror ? layout.width - px - pw : px, + "y": row * (layout.rowUnit + layout.gap), + "w": pw, + "h": rows * layout.rowUnit + (rows - 1) * layout.gap + }; +} + +function cellOccupied(layout, col, row) { + const taken = layout.slots.filter(Boolean).map(slot => ({ + "x": slot.col, + "y": slot.row, + "w": slot.cols, + "h": slot.rows + })); + return overlaps(taken, col, row, 1, 1); } function cellAt(layout, x, y, cols, rows) { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 9c8bae348..2d1eea4cf 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -21,7 +21,7 @@ Singleton { id: root readonly property var log: Log.scoped("SettingsData") - readonly property int settingsConfigVersion: 35 + readonly property int settingsConfigVersion: 36 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 14de78aa9..9c2f1351f 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -446,19 +446,22 @@ var LOCAL_SPEC = { id: "settings", enabled: true, w: 1, - h: 1 + h: 1, + small: true }, { id: "lock", enabled: true, w: 1, - h: 1 + h: 1, + small: true }, { id: "power", enabled: true, w: 1, - h: 1 + h: 1, + small: true }, { id: "brightnessSlider", @@ -531,6 +534,13 @@ var LOCAL_SPEC = { enabled: true, w: 4, h: 1 + }, + { + id: "runningApps", + enabled: true, + w: 7, + h: 1, + small: true } ] }, diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index 3ac0a19f1..65f443280 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -17,7 +17,7 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"]; var CC_HEADER_IDS = ["userCard", "quickActions", "header"]; var CC_ACTION_TILE_IDS = ["settings", "lock", "power"]; -var CC_USER_KEYS = ["w", "h", "col", "row", "hostname", "compositor", "uptime", "badge"]; +var CC_USER_KEYS = ["w", "h", "col", "row", "hostname", "compositor", "uptime", "badge", "background"]; var REMOVED_KEYS_V21 = ["showBattery", "showCapsLockIndicator", "showClipboard", "showClock", "showControlCenterButton", "showCpuUsage", "showFocusedWindow", "showLauncherButton", "showMemUsage", "showMusic", "showNotificationButton", "showPrivacyButton", "showSystemTray", "showWeather", "showWorkspaceSwitcher", "hideBrightnessSlider", "updaterHideWidget", "workspaceScrolling", "appLauncherViewMode", "spotlightModalViewMode", "audioDeviceScrollVolumeEnabled", "desktopClockX", "desktopClockY", "desktopClockWidth", "desktopClockHeight", "desktopClockDisplayPreferences", "systemMonitorX", "systemMonitorY", "systemMonitorWidth", "systemMonitorHeight", "systemMonitorDisplayPreferences", "systemMonitorVariants"]; @@ -707,6 +707,20 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 35; } + if (currentVersion < 36 && targetVersion >= 36) { + // The footer's running-apps chip became a grid tile; layouts saved before then never mention it. + const widgets = settings.controlCenterWidgets; + if (Array.isArray(widgets) && !widgets.some(widget => widget?.id === "runningApps")) + settings.controlCenterWidgets = widgets.concat([{ + id: "runningApps", + enabled: true, + w: Math.max(1, (settings.controlCenterColumns ?? 8) - 1), + h: 1, + small: true + }]); + settings.configVersion = 36; + } + return settings; } @@ -721,7 +735,8 @@ function migrateControlCenterHeader(widgets, fixedHeader, columns) { id: id, enabled: true, w: 1, - h: 1 + h: 1, + small: true })); const identity = header.length === 0 ? {} : header.find(widget => widget.id === "userCard" || (widget.id === "header" && widget.showUser !== false)); if (!identity || rest.some(widget => widget?.id === "user")) diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index fe6cd0904..9fd187abe 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -56,8 +56,10 @@ Singleton { readonly property real tileTextGap: Theme.spacingM readonly property real footerHeight: iconBoxSize readonly property real footerGap: Theme.spacingM - readonly property real footerAppIconSize: Theme.iconSize * iconScale - readonly property int footerMaxAppIcons: 3 + readonly property real runningAppsIconSize: Theme.iconSize * iconScale + readonly property int runningAppsMaxIcons: 3 + // Small tile body height as a fraction of a row; the resize corner snaps to the nearer of this and a full row. + readonly property real smallRowFraction: 0.5 readonly property real shapeButtonSize: Theme.iconSize readonly property real detailDialogInset: Theme.spacingL @@ -120,6 +122,10 @@ Singleton { readonly property color tileInactiveSubtitle: Theme.surfaceVariantText readonly property color tileInactiveIcon: Theme.primary + function actionIconColor(id) { + return id === "power" ? Theme.error : tileInactiveIcon; + } + readonly property bool animationsEnabled: !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None function preferredDetailHeight(section, pluginHeight) { diff --git a/quickshell/Modules/ControlCenter/Components/CcFooter.qml b/quickshell/Modules/ControlCenter/Components/CcFooter.qml index 9795b2159..2a96cd321 100644 --- a/quickshell/Modules/ControlCenter/Components/CcFooter.qml +++ b/quickshell/Modules/ControlCenter/Components/CcFooter.qml @@ -1,10 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell import qs.Common import qs.Modules.ControlCenter -import qs.Modules.ControlCenter.Widgets import qs.Widgets Item { @@ -14,28 +12,20 @@ Item { LayoutMirroring.childrenInherit: true property bool editMode: false - property var toplevels: [] + property bool showEdit: true property string pendingAction: "" - signal runningAppsRequested signal addWidgetRequested signal resetRequested signal clearRequested signal editToggled signal cancelRequested - readonly property var appIds: { - const ids = []; - for (const toplevel of toplevels) { - const id = toplevel?.appId ?? ""; - if (!ids.includes(id)) - ids.push(id); - } - return ids; - } readonly property real leadingWidth: Math.max(0, width - trailing.width - CcMetrics.footerGap) + readonly property bool occupied: editMode || showEdit - implicitHeight: CcMetrics.footerHeight + implicitHeight: occupied ? CcMetrics.footerHeight : 0 + visible: occupied function cancelConfirmation() { pendingAction = ""; @@ -52,86 +42,6 @@ Item { onEditModeChanged: cancelConfirmation() - StyledButton { - id: appsChip - - readonly property real iconBox: height - Theme.spacingS * 2 - - anchors.left: parent.left - height: parent.height - width: Math.min(Theme.spacingS + icons.width + Theme.spacingS + countLabel.implicitWidth + Theme.spacingXS + chevron.width + Theme.spacingM, root.leadingWidth) - radius: Theme.fullRadius(width, height) - color: CcMetrics.tileInactiveColor - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium - visible: !root.editMode && root.toplevels.length > 0 - Accessible.name: countLabel.text - onClicked: root.runningAppsRequested() - - Row { - id: icons - - anchors.left: parent.left - anchors.leftMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - Repeater { - model: ScriptModel { - values: root.appIds.slice(0, CcMetrics.footerMaxAppIcons) - } - - Rectangle { - required property string modelData - - width: appsChip.iconBox - height: width - radius: Theme.fullRadius(width, height) - color: CcMetrics.iconBoxInactiveColor - - CcAppIcon { - anchors.centerIn: parent - appId: parent.modelData - iconSize: CcMetrics.footerAppIconSize - } - } - } - } - - StyledText { - id: countLabel - anchors.left: icons.right - anchors.leftMargin: Theme.spacingS - anchors.right: chevron.left - anchors.rightMargin: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - text: root.toplevels.length === 1 ? I18n.tr("%1 window", "singular, %1 is 1, open windows in the control center footer").arg(1) : I18n.tr("%1 windows", "plural, %1 is a count of open windows in the control center footer").arg(root.toplevels.length) - font.pixelSize: Theme.fontSizeMedium - font.weight: Theme.fontWeightMedium - color: CcMetrics.tileInactiveContent - elide: Text.ElideRight - } - - DankIcon { - id: chevron - anchors.right: parent.right - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - name: I18n.isRtl ? "chevron_left" : "chevron_right" - size: Theme.iconSizeSmall - color: Theme.onSurfaceVariant - } - - FocusRing { - visible: parent.visualFocus - } - - StateLayer { - control: parent - stateColor: CcMetrics.tileInactiveContent - } - } - Row { id: editActions @@ -230,7 +140,7 @@ Item { border.width: Theme.layerOutlineWidth border.color: Theme.outlineMedium tooltipText: I18n.tr("Edit") - visible: !root.editMode + visible: !root.editMode && root.showEdit onClicked: root.editToggled() } } diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 0a2f6104b..4c2dc127b 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -24,8 +24,23 @@ DankEditableGrid { signal lockRequested signal powerRequested signal settingsRequested + signal accountsRequested + signal editRequested signal closeRequested property bool tapToClose: false + property var runningToplevels: [] + property bool dockEdit: false + readonly property var editDock: { + if (!dockEdit) + return null; + const layout = slotLayout; + const col = layout.columns - 1; + const row = layout.rows - 1; + if (row < 0 || GridUtils.cellOccupied(layout, col, row)) + return null; + const rect = GridUtils.slotRect(layout, col, row, 1, 1); + return Qt.point(rect.x, rect.y); + } readonly property real gridHeight: layoutHeight readonly property real cellWidth: (width + CcMetrics.gridGap) / columns @@ -36,7 +51,7 @@ DankEditableGrid { 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) - placeholderRadius: draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight) + placeholderRadius: draggingSlot?.small ? Theme.fullRadius(draggingSlot.width, draggingSlot.height) : (draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight)) onLayoutCommitted: items => model.setLayout(withHidden(items)) @@ -68,6 +83,21 @@ DankEditableGrid { return widgets; } + DankActionButton { + x: (root.editDock?.x ?? 0) + root.contentPadding + (root.slotLayout.colW - width) / 2 + y: (root.editDock?.y ?? 0) + root.contentPadding + (root.slotLayout.rowUnit - height) / 2 + visible: root.editDock !== null + buttonSize: CcMetrics.footerHeight + iconName: "edit" + iconSize: CcMetrics.iconBoxIconSize + iconColor: CcMetrics.tileInactiveContent + backgroundColor: CcMetrics.tileInactiveColor + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + tooltipText: I18n.tr("Edit") + onClicked: root.editRequested() + } + Repeater { id: tileRepeater diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index 5f20e15c0..9a987f83f 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -13,10 +13,26 @@ DankEditableGridSlot { readonly property real cols: slot?.cols ?? 1 readonly property real rows: slot?.rows ?? 1 readonly property bool compact: cols <= 2 && rows === 1 + readonly property bool small: WidgetUtils.isSmall(grid.layoutItems[index] ?? widgetData, rows) readonly property var tileItem: tileLoader.item + // Small tiles resize from a virtual half cell so growing them passes through full size first. + property real biasW: 0 + property real biasH: 0 + readonly property real smallSpanLimit: (1 + CcMetrics.smallRowFraction) / 2 passthrough: tileItem?.passthrough ?? null + function reanchor(small) { + const shift = ((small ? CcMetrics.smallRowFraction : 1) - smallSpanLimit) * grid.cellWidth; + biasH += shift; + if (cols === 1) + biasW += shift; + } + + function spanOf(requested, bias) { + return (requested + bias + CcMetrics.gridGap) / grid.cellWidth; + } + onPressAndHold: { if (!editChrome.hasOptions) return; @@ -25,8 +41,13 @@ DankEditableGridSlot { onResizeRequested: (requestedWidth, requestedHeight) => { 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 canShrink = WidgetUtils.canShrink(widgetData.id); + if (canShrink && root.small !== (spanOf(requestedHeight, biasH) < smallSpanLimit)) + reanchor(!root.small); + const spanW = spanOf(requestedWidth, biasW); + const spanH = spanOf(requestedHeight, biasH); + let width = GridUtils.dimension(Math.round(spanW / step) * step, sizeSpec.minW, sizeSpec.maxW, sizeSpec.w, step); + let height = GridUtils.dimension(Math.round(spanH / step) * step, sizeSpec.minH, sizeSpec.maxH, sizeSpec.h, step); const current = WidgetUtils.clampSize(widgetData, grid.columns, grid.maximumRows); if (WidgetUtils.isSliderWidget(widgetData.id) && width < 2 && height < 2) { if (width !== current.w && sizeSpec.maxH > 1) @@ -39,6 +60,9 @@ DankEditableGridSlot { changes.w = width; if (height !== current.h) changes.h = height; + const small = canShrink && height === 1 && spanH < smallSpanLimit; + if (small !== (widgetData.small === true)) + changes.small = small; grid.previewSize(index, changes); } @@ -104,6 +128,13 @@ DankEditableGridSlot { when: root.tileItem !== null } + Binding { + target: root.tileItem + property: "small" + value: root.small + when: root.tileItem !== null && "small" in root.tileItem + } + Connections { target: root.tileItem ignoreUnknownSignals: true @@ -131,9 +162,13 @@ DankEditableGridSlot { passthrough: root.passthrough dragging: root.dragging resizing: root.resizing - cornerRadius: root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height) - sizeText: root.cols + "×" + root.rows - onResizeStarted: (px, py) => root.beginResize(px, py) + cornerRadius: root.small ? Theme.fullRadius(root.width, root.height) : (root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height)) + sizeText: (root.small && root.cols === 1 ? CcMetrics.smallRowFraction : root.cols) + "×" + (root.small ? CcMetrics.smallRowFraction : root.rows) + onResizeStarted: (px, py) => { + root.biasH = root.small ? (CcMetrics.smallRowFraction - 1) * root.grid.cellWidth : 0; + root.biasW = root.small && root.cols === 1 ? root.biasH : 0; + root.beginResize(px, py); + } onResizeMoved: (px, py) => root.resizeTo(px, py) onResizeEnded: root.finishResize() onResizeCanceled: root.cancelResize() diff --git a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml index 3ee453ce9..a3c0858fd 100644 --- a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml +++ b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml @@ -15,6 +15,8 @@ Item { id: root property int widgetIndex: -1 + property var model: null + property int columns: CcMetrics.gridColumns property var transientSurfaceTracker: null property Item _anchor: null @@ -29,6 +31,8 @@ Item { readonly property bool isDisk: widgetId === "diskUsage" readonly property bool isIdleInhibitor: widgetId === "idleInhibitor" readonly property bool isUser: widgetId === "user" + readonly property bool isRunningApps: widgetId === "runningApps" + readonly property var cardActions: WidgetUtils.cardActions(widgetData) visible: widgetIndex >= 0 || contextMenu.renderActive @@ -86,53 +90,96 @@ Item { } customContent: Component { - CcGroup { - CcListRow { - visible: root.isPlugin - iconName: "settings" - title: I18n.tr("Plugin settings") - clickable: true - onClicked: { - PopoutService.openSettingsWithTab(SettingsTabs.pluginPrefix + root.widgetId.replace("plugin_", "")); - root.close(); - } - } + Column { + width: parent?.width ?? 0 - Repeater { - model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : [] + CcGroup { + width: parent.width + + CcListRow { + visible: root.isPlugin + iconName: "settings" + title: I18n.tr("Plugin settings") + clickable: true + onClicked: { + PopoutService.openSettingsWithTab(SettingsTabs.pluginPrefix + root.widgetId.replace("plugin_", "")); + root.close(); + } + } CcToggleRow { - required property var modelData + visible: root.isUser + text: I18n.tr("Background") + checked: root.widgetData?.background === true + onToggled: checked => root.persistOption("background", checked) + } - text: modelData.text - checked: DashRegistry.optionValue(modelData, root.widgetData?.[modelData.key]) - onToggled: checked => root.persistOption(modelData.key, checked) + Repeater { + model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : [] + + CcToggleRow { + required property var modelData + + text: modelData.text + checked: DashRegistry.optionValue(modelData, root.widgetData?.[modelData.key]) + onToggled: checked => root.persistOption(modelData.key, checked) + } + } + + CcToggleRow { + visible: root.isRunningApps + text: I18n.tr("Show window count", "toggle in control center running apps widget options") + checked: root.widgetData?.showCount !== false + onToggled: checked => root.persistOption("showCount", checked) + } + + CcToggleRow { + visible: root.isDisk + text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off") + checked: root.widgetData?.showMountPath !== false + onToggled: checked => root.persistOption("showMountPath", checked) + } + + CcListRow { + visible: root.isIdleInhibitor + iconName: "timer" + title: I18n.tr("Duration") + body: DankDropdown { + readonly property var presets: IdleInhibitPresets.presetOptions + + compactMode: true + dropdownWidth: parent.width + transientSurfaceTracker: contextMenu.transientSurfaceTracker + currentValue: presets.find(p => p.minutes === (root.widgetData?.durationMinutes ?? 0))?.label ?? "" + options: presets.map(p => p.label) + onValueChanged: value => { + const preset = presets.find(p => p.label === value); + if (preset) + root.persistOption("durationMinutes", preset.minutes); + } + } } } - CcToggleRow { - visible: root.isDisk - text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off") - checked: root.widgetData?.showMountPath !== false - onToggled: checked => root.persistOption("showMountPath", checked) + CcSectionLabel { + width: parent.width + visible: root.isUser + text: I18n.tr("Actions") } - CcListRow { - visible: root.isIdleInhibitor - iconName: "timer" - title: I18n.tr("Duration") - body: DankDropdown { - readonly property var presets: IdleInhibitPresets.presetOptions + CcGroup { + width: parent.width + visible: root.isUser - compactMode: true - dropdownWidth: parent.width - transientSurfaceTracker: contextMenu.transientSurfaceTracker - currentValue: presets.find(p => p.minutes === (root.widgetData?.durationMinutes ?? 0))?.label ?? "" - options: presets.map(p => p.label) - onValueChanged: value => { - const preset = presets.find(p => p.label === value); - if (preset) - root.persistOption("durationMinutes", preset.minutes); + Repeater { + model: root.isUser ? WidgetUtils.BUTTON_IDS : [] + + CcToggleRow { + required property string modelData + + text: root.model?.getWidgetForId(modelData)?.text ?? modelData + checked: root.cardActions.includes(modelData) + onToggled: checked => root.widgetIndex = WidgetUtils.setCardAction(root.widgetIndex, modelData, checked, root.columns) } } } diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 032280cb0..f7cf3e924 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -8,6 +8,7 @@ import qs.Modules.ControlCenter.Models import qs.Modules.ControlCenter.Details import qs.Widgets import "./utils/sections.js" as Sections +import "./utils/widgets.js" as WidgetUtils FocusScope { id: root @@ -19,7 +20,7 @@ FocusScope { readonly property bool pageOpen: (host.expandedSection ?? "") !== "" readonly property real gridHeight: widgetGrid.gridHeight - readonly property real chromeHeight: Theme.spacingS + footer.height + readonly property real chromeHeight: footer.occupied ? Theme.spacingS + footer.height : 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 @@ -30,7 +31,8 @@ FocusScope { const current = CompositorService.filterCurrentWorkspace(all, host.triggerScreen?.name) || []; return current.concat(all.filter(toplevel => !current.includes(toplevel))); } - readonly property string placedWidgetIds: (SettingsData.controlCenterWidgets || []).map(w => w.id).sort().join(",") + readonly property string placedWidgetIds: WidgetUtils.placedIds(SettingsData.controlCenterWidgets).sort().join(",") + readonly property bool editPlaced: placedWidgetIds.split(",").includes("edit") readonly property real targetImplicitHeight: { const total = CcMetrics.sheetPadding * 2 + gridHeight + chromeHeight; if (detailPage.shownSection === "") @@ -45,7 +47,8 @@ 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 + // Always reserve the footer: the row cap stays the same in edit mode and cannot loop through the docked edit button. + readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - Theme.spacingS - CcMetrics.footerHeight readonly property vector4d chromeRoom: host.chromeRoom ?? Qt.vector4d(Infinity, Infinity, Infinity, Infinity) readonly property DankPanelResizer panelResizer: DankPanelResizer { popout: root.host @@ -287,7 +290,7 @@ FocusScope { anchors.right: parent.right anchors.top: parent.top anchors.bottom: footer.top - anchors.bottomMargin: Theme.spacingS + anchors.bottomMargin: footer.occupied ? Theme.spacingS : 0 clip: contentHeight > height contentWidth: width contentHeight: Math.max(height, mainColumn.implicitHeight + CcMetrics.sheetPadding) @@ -320,12 +323,16 @@ FocusScope { live: root.host.shouldBeVisible && detailPage.shownSection === "" screenName: root.host.triggerScreen?.name || "" tapToClose: root.host.headerTogglesClose ?? false + runningToplevels: root.runningToplevels + dockEdit: !root.editPlaced && !root.host.editMode onExpandClicked: widgetData => root.openWidgetPage(widgetData) onRemoveWidget: index => widgetModel.removeWidget(index) onConfigRequested: (index, widgetData, anchor) => root.openConfigOverlay(index, widgetData, anchor) onColorPickerRequested: root.host.openColorPicker() onCloseRequested: root.host.close() onSettingsRequested: root.host.openSettings() + onAccountsRequested: root.host.openAccounts() + onEditRequested: root.host.editMode = true onLockRequested: { root.host.close(); root.host.lockRequested(); @@ -353,9 +360,8 @@ FocusScope { anchors.bottom: parent.bottom anchors.bottomMargin: CcMetrics.sheetPadding editMode: root.host.editMode - toplevels: root.runningToplevels + showEdit: !root.editPlaced && widgetGrid.editDock === null opacity: body.opacity - onRunningAppsRequested: root.navigateTo("runningApps") onAddWidgetRequested: root.openWidgetSheet() onResetRequested: widgetModel.resetToDefault() onClearRequested: widgetModel.clearAll() @@ -459,6 +465,8 @@ FocusScope { active: false sourceComponent: WidgetConfigOverlay { + model: widgetModel + columns: root.gridColumns transientSurfaceTracker: root.host.transientSurfaceTracker onVisibleChanged: { if (visible) diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index 61345cc2a..39f7322de 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -36,6 +36,11 @@ DankPopout { close(); } + function openAccounts() { + requestedWindow = "accounts"; + close(); + } + function openColorPicker() { if (!colorPickerModal) return; @@ -65,6 +70,9 @@ DankPopout { case "settings": PopoutService.focusOrToggleSettings(); break; + case "accounts": + PopoutService.openSettingsWithTab("user_accounts"); + break; case "colorPicker": root.colorPickerModal?.show(); break; diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index a18a405f4..ddf9bf406 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -110,6 +110,9 @@ QtObject { readonly property Component actionTile: Component { ActionTile {} } + readonly property Component runningAppsTile: Component { + RunningAppsTile {} + } readonly property Component pluginTile: Component { PluginTile {} } @@ -168,7 +171,10 @@ QtObject { case "lock": case "power": case "settings": + case "edit": return actionTile; + case "runningApps": + return runningAppsTile; default: return null; } @@ -223,6 +229,24 @@ QtObject { "category": "system", "enabled": true }, + { + "id": "edit", + "text": I18n.tr("Edit"), + "description": "", + "icon": "edit", + "type": "action", + "category": "system", + "enabled": true + }, + { + "id": "runningApps", + "text": I18n.tr("Running apps"), + "description": "", + "icon": "apps", + "type": "action", + "category": "system", + "enabled": true + }, { "id": "nightMode", "text": I18n.tr("Night mode"), diff --git a/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml index c9f43dd6f..057e4eb31 100644 --- a/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml @@ -1,6 +1,6 @@ import QtQuick -import qs.Common import qs.Modules.ControlCenter +import "../utils/widgets.js" as WidgetUtils CcTile { id: root @@ -10,19 +10,7 @@ CcTile { toggle: false iconName: widgetDef?.icon ?? "" title: widgetDef?.text ?? "" - restIconColor: action === "power" ? Theme.error : CcMetrics.tileInactiveIcon + restIconColor: CcMetrics.actionIconColor(action) - onClicked: { - switch (action) { - case "lock": - host?.lockRequested(); - return; - case "power": - host?.powerRequested(); - return; - case "settings": - host?.settingsRequested(); - return; - } - } + onClicked: WidgetUtils.triggerButton(host, action) } diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml index 2024065ed..1fc9801c0 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml @@ -20,17 +20,24 @@ Item { property real columns: 4 property real rows: 1 property bool compact: columns <= 2 && rows === 1 + property bool small: false + readonly property real bodyInset: small ? Math.max(0, (Math.min(width, height) - CcMetrics.iconBoxSize) / 2) : 0 + readonly property real bodyWidth: width - bodyInset * 2 + readonly property real bodyHeight: height - bodyInset * 2 property bool toggle: !opensPage property Component expandedContent: null + // Replaces the icon and labels inside the body; the chrome, focus and activation stay. + property Component bodyContent: null property real expandedMinimumHeight: Theme.listItemHeight readonly property Item expandedItem: expandedLoader.item readonly property bool expanded: expandedContent !== null && width >= CcMetrics.expandedTileMinWidth && height >= headerHeight + expandedMinimumHeight + tilePadding * 2 + Theme.spacingM readonly property real tilePadding: tall && !narrow ? Theme.spacingM : Theme.spacingS - readonly property real baseIconExtent: Math.min(Math.max(Theme.minimumTouchTargetSize, CcMetrics.iconBoxSize), height - tilePadding * 2, width - tilePadding * 2) + readonly property real baseIconExtent: Math.min(Math.max(Theme.minimumTouchTargetSize, CcMetrics.iconBoxSize), bodyHeight - tilePadding * 2, bodyWidth - tilePadding * 2) readonly property real iconExtent: stacked ? Math.max(0, Math.min(baseIconExtent, height - tilePadding * 2 - Theme.spacingS - titleLabel.implicitHeight)) : baseIconExtent readonly property real headerHeight: Math.max(baseIconExtent, Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingS) readonly property bool stacked: tall && !expanded && width <= height readonly property bool narrow: width < CcMetrics.expandedTileMinWidth + readonly property bool denseText: narrow || small readonly property real labelHeight: titleLabel.implicitHeight + (showSubtitle ? Theme.spacingXXS + subtitleLabel.implicitHeight : 0) readonly property bool showSubtitle: subtitle !== "" && (!stacked || height - tilePadding * 2 >= iconExtent + Theme.spacingS + titleLabel.implicitHeight + Theme.spacingXXS + subtitleLabel.implicitHeight) property bool showExpand: false @@ -50,10 +57,10 @@ Item { readonly property bool showsActive: active && interactive readonly property real restRadius: { if (showsActive) - return Math.min(CcMetrics.tileActiveRadius, width / 2, height / 2); - return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, height); + return Math.min(CcMetrics.tileActiveRadius, bodyWidth / 2, bodyHeight / 2); + return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(bodyWidth, bodyHeight); } - readonly property bool acceptsInput: interactive && enabled + property bool acceptsInput: interactive && enabled readonly property bool bodyActive: showsActive && !hasIconBox readonly property color bodyColor: { if (!enabled) @@ -83,7 +90,7 @@ Item { return showsActive ? CcMetrics.tileActiveColor : CcMetrics.iconBoxInactiveColor; } - property real bodyRadius: bodyLayer.pressed ? Math.min(Theme.cornerRadiusM, width / 2, height / 2) : restRadius + property real bodyRadius: bodyLayer.pressed ? Math.min(Theme.cornerRadiusM, bodyWidth / 2, bodyHeight / 2) : restRadius property real iconBoxRadius: { if (boxLayer.pressed) return Math.min(Theme.cornerRadiusS, CcMetrics.iconBoxSize / 2); @@ -151,6 +158,7 @@ Item { id: body anchors.fill: parent + anchors.margins: root.bodyInset radius: root.bodyRadius color: root.bodyColor border.width: Theme.layerOutlineWidth @@ -197,11 +205,11 @@ Item { id: compactIcon anchors.centerIn: parent name: root.iconName - size: CcMetrics.tileIconSize + size: root.small ? CcMetrics.iconBoxIconSize : CcMetrics.tileIconSize color: root.iconColor filled: root.showsActive rotation: root.iconRotation - visible: root.compact + visible: root.compact && root.bodyContent === null DankBlink { target: compactIcon @@ -209,11 +217,17 @@ Item { } } + Loader { + anchors.fill: parent + active: root.bodyContent !== null + sourceComponent: root.bodyContent + } + Item { id: content anchors.fill: parent anchors.margins: root.tilePadding - visible: !root.compact + visible: !root.compact && root.bodyContent === null Rectangle { id: iconBox @@ -311,7 +325,7 @@ Item { width: parent.width text: root.title color: root.contentColor - font.pixelSize: root.narrow ? Theme.fontSizeMedium : Theme.fontSizeLarge + font.pixelSize: root.denseText ? Theme.fontSizeMedium : Theme.fontSizeLarge font.weight: Theme.fontWeightMedium elide: Text.ElideRight wrapMode: root.stacked ? Text.Wrap : Text.NoWrap @@ -326,7 +340,7 @@ Item { width: parent.width text: root.subtitle color: root.subtitleColor - font.pixelSize: root.narrow ? Theme.fontSizeSmall : Theme.fontSizeMedium + font.pixelSize: root.denseText ? Theme.fontSizeSmall : Theme.fontSizeMedium elide: Text.ElideRight wrapMode: Text.NoWrap horizontalAlignment: root.stacked && root.narrow ? Text.AlignHCenter : Text.AlignLeft diff --git a/quickshell/Modules/ControlCenter/Widgets/RunningAppsTile.qml b/quickshell/Modules/ControlCenter/Widgets/RunningAppsTile.qml new file mode 100644 index 000000000..5c76bca2e --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/RunningAppsTile.qml @@ -0,0 +1,121 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Modules.ControlCenter +import qs.Widgets + +CcTile { + id: root + + readonly property var toplevels: host?.runningToplevels ?? [] + readonly property var appIds: { + const ids = []; + for (const toplevel of toplevels) { + const id = toplevel?.appId ?? ""; + if (!ids.includes(id)) + ids.push(id); + } + return ids; + } + readonly property bool showCount: widgetData?.showCount !== false + readonly property string countText: toplevels.length === 1 ? I18n.tr("%1 window", "singular, %1 is 1, open windows in the control center footer").arg(1) : I18n.tr("%1 windows", "plural, %1 is a count of open windows in the control center footer").arg(toplevels.length) + + opensPage: true + title: widgetDef?.text ?? "" + subtitle: countText + acceptsInput: interactive && toplevels.length > 0 + onClicked: expandClicked() + + bodyContent: Component { + Item { + id: body + + readonly property real iconBox: Math.max(0, Math.min(CcMetrics.iconBoxSize, height - Theme.spacingS * 2)) + readonly property int iconCount: Math.max(1, Math.min(root.appIds.length, CcMetrics.runningAppsMaxIcons, Math.floor((width - Theme.spacingS * 2 + Theme.spacingXS) / (iconBox + Theme.spacingXS)))) + readonly property real chevronRoom: chevron.width + Theme.spacingXS + readonly property real textRoom: width - Theme.spacingS * 2 - icons.width - Theme.spacingM + // Drop the wording, then the chevron, then the count itself as the pill narrows. + readonly property string label: { + if (!root.showCount || root.toplevels.length === 0) + return ""; + if (fullMetrics.advanceWidth + chevronRoom <= textRoom) + return root.countText; + return shortMetrics.advanceWidth <= textRoom ? String(root.toplevels.length) : ""; + } + readonly property bool showChevron: label !== "" && (label === root.countText || shortMetrics.advanceWidth + chevronRoom <= textRoom) + + StyledTextMetrics { + id: fullMetrics + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + text: root.countText + } + + StyledTextMetrics { + id: shortMetrics + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + text: String(root.toplevels.length) + } + + Row { + id: icons + + anchors.left: parent.left + anchors.leftMargin: body.label === "" ? (parent.width - width) / 2 : Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Repeater { + model: ScriptModel { + values: root.appIds.length > 0 ? root.appIds.slice(0, body.iconCount) : [""] + } + + Rectangle { + required property string modelData + + width: body.iconBox + height: width + radius: Theme.fullRadius(width, height) + color: CcMetrics.iconBoxInactiveColor + + CcAppIcon { + anchors.centerIn: parent + appId: parent.modelData + iconSize: Math.min(CcMetrics.runningAppsIconSize, parent.width) + } + } + } + } + + StyledText { + objectName: "runningAppsLabel" + anchors.left: icons.right + anchors.leftMargin: Theme.spacingS + anchors.right: body.showChevron ? chevron.left : parent.right + anchors.rightMargin: body.showChevron ? Theme.spacingXS : Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: body.label + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: root.contentColor + elide: Text.ElideRight + visible: body.label !== "" + } + + DankIcon { + id: chevron + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: I18n.isRtl ? "chevron_left" : "chevron_right" + size: Theme.iconSizeSmall + color: Theme.onSurfaceVariant + visible: body.showChevron + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Widgets/UserTile.qml b/quickshell/Modules/ControlCenter/Widgets/UserTile.qml index 6266c41e3..15740eb7c 100644 --- a/quickshell/Modules/ControlCenter/Widgets/UserTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/UserTile.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Effects +import Quickshell import qs.Common import qs.Modules.ControlCenter import qs.Modules.DankDash @@ -21,13 +22,35 @@ Item { signal optionChanged(string key, var value) - readonly property bool wide: width >= height * 2 - readonly property real avatarSide: Math.min(width, height) + readonly property var savedOrder: WidgetUtils.cardOrder(widgetData) + property var previewOrder: null + property string draggedAction: "" + property point dragPosition: Qt.point(0, 0) + property point grabOffset: Qt.point(0, 0) + readonly property var order: previewOrder ?? savedOrder + readonly property int split: order.indexOf(WidgetUtils.CARD_IDENTITY) + readonly property int leadingColumns: WidgetUtils.actionColumns(rows, split) + readonly property int trailingColumns: WidgetUtils.actionColumns(rows, order.length - split - 1) + // Grid pitch keeps card buttons in line with small tiles outside the card. + readonly property real pitch: host?.cellWidth ?? CcMetrics.columnWidth + CcMetrics.gridGap + readonly property real buttonInset: Math.max(0, (pitch - CcMetrics.gridGap - CcMetrics.iconBoxSize) / 2) + readonly property real identityStart: leadingColumns * pitch + readonly property real identityWidth: Math.max(0, width - (leadingColumns + trailingColumns) * pitch) + readonly property bool wide: identityWidth >= height * 2 + readonly property bool background: widgetData?.background === true + readonly property bool tall: height >= CcMetrics.gridRowUnit * 2 + readonly property real inset: background ? (tall ? Theme.spacingM : Theme.spacingS) : 0 + readonly property real avatarSide: Math.min(identityWidth, height) - inset * 2 readonly property string shape: widgetData?.shape ?? WidgetUtils.USER_SHAPES[0] readonly property bool editMode: host?.editMode ?? false readonly property bool tapToClose: interactive && (host?.tapToClose ?? false) - readonly property Item passthrough: shapeButton - readonly property real bodyRadius: Theme.fullRadius(avatarSide, avatarSide) + readonly property Item passthrough: passthroughArea + // Only a bare avatar gets the circle outline; any button beside it turns the card into a pill. + readonly property real bodyRadius: { + if (!background && savedOrder.length === 1) + return Theme.fullRadius(avatarSide, avatarSide); + return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, height); + } readonly property real fallbackGlyphRatio: 0.5 readonly property string avatarSource: PortalService.profileImageUrl readonly property bool hasImage: picture.status === Image.Ready @@ -39,12 +62,99 @@ Item { Accessible.role: Accessible.StaticText Accessible.name: UserInfoService.fullName || UserInfoService.username - UserIdentity { + function actionSlot(id) { + const index = order.indexOf(id); + const leading = index < split; + const count = leading ? split : order.length - split - 1; + const position = leading ? index : index - split - 1; + const perLine = WidgetUtils.actionColumns(rows, count); + const lines = Math.ceil(count / perLine); + const size = CcMetrics.iconBoxSize; + const start = leading ? 0 : width + CcMetrics.gridGap - trailingColumns * pitch; + const x = start + (position % perLine) * pitch + buttonInset; + return Qt.point(I18n.isRtl ? width - x - size : x, (height - lines * pitch + pitch - size) / 2 + Math.floor(position / perLine) * pitch); + } + + // Splitting at the identity's centre stops a swap across it from flickering back. + function reorderTarget(point) { + const identityX = I18n.isRtl ? width - identityStart - identityWidth : identityStart; + if (point.x >= identityX && point.x < identityX + identityWidth) { + const leading = (point.x < identityX + identityWidth / 2) !== I18n.isRtl; + return (order.indexOf(draggedAction) < split) === leading ? "" : WidgetUtils.CARD_IDENTITY; + } + const half = pitch / 2; + const center = CcMetrics.iconBoxSize / 2; + return order.find(id => { + if (id === draggedAction || id === WidgetUtils.CARD_IDENTITY) + return false; + const slot = actionSlot(id); + return Math.abs(point.x - slot.x - center) < half && Math.abs(point.y - slot.y - center) < half; + }) ?? ""; + } + + function beginReorder(id, scenePoint) { + const point = mapFromItem(null, scenePoint.x, scenePoint.y); + const slot = actionSlot(id); + grabOffset = Qt.point(point.x - slot.x, point.y - slot.y); + dragPosition = slot; + previewOrder = savedOrder.slice(); + draggedAction = id; + } + + function moveReorder(scenePoint) { + if (draggedAction === "") + return; + const point = mapFromItem(null, scenePoint.x, scenePoint.y); + dragPosition = Qt.point(point.x - grabOffset.x, point.y - grabOffset.y); + const target = reorderTarget(point); + if (target !== "") + previewOrder = WidgetUtils.moveInOrder(previewOrder, draggedAction, target); + } + + function finishReorder() { + if (draggedAction === "") + return; + if (JSON.stringify(previewOrder) !== JSON.stringify(savedOrder)) + optionChanged("actions", previewOrder); + draggedAction = ""; + previewOrder = null; + } + + function passesThrough(point) { + const targets = [shapeButton]; + for (let i = 0; i < actionRepeater.count; i++) + targets.push(actionRepeater.itemAt(i)); + return targets.some(item => item?.visible && item.contains(mapToItem(item, point.x, point.y))); + } + + onEditModeChanged: finishReorder() + + Rectangle { anchors.fill: parent + radius: root.bodyRadius + color: CcMetrics.tileInactiveColor + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + visible: root.background + } + + Item { + id: identityArea + + anchors.left: parent.left + anchors.leftMargin: root.identityStart + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.identityWidth + } + + UserIdentity { + anchors.fill: identityArea + anchors.margins: root.inset visible: root.wide live: root.live && root.wide options: DashRegistry.resolvedOptions("user", root.widgetData) - avatarSize: root.height + avatarSize: height stacked: root.rows >= 2 contentColor: Theme.onSurface mutedColor: Theme.onSurfaceVariant @@ -53,7 +163,7 @@ Item { Item { id: avatar - anchors.centerIn: parent + anchors.centerIn: identityArea width: root.avatarSide height: root.avatarSide visible: !root.wide @@ -111,6 +221,7 @@ Item { } StyledButton { + objectName: "userCloseButton" anchors.fill: parent visible: root.tapToClose radius: root.bodyRadius @@ -127,6 +238,102 @@ Item { } } + Repeater { + id: actionRepeater + + model: ScriptModel { + values: WidgetUtils.BUTTON_IDS.filter(id => root.savedOrder.includes(id)) + } + + Item { + id: action + + required property string modelData + readonly property var definition: root.host?.model?.getWidgetForId(modelData) ?? null + readonly property point slot: root.actionSlot(modelData) + readonly property bool held: root.draggedAction === modelData + + x: held ? root.dragPosition.x : slot.x + y: held ? root.dragPosition.y : slot.y + z: held ? 1 : 0 + width: CcMetrics.iconBoxSize + height: width + + Behavior on x { + enabled: !action.held && CcMetrics.animationsEnabled + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + + Behavior on y { + enabled: !action.held && CcMetrics.animationsEnabled + NumberAnimation { + duration: Theme.expressiveDurations.expressiveFastSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + + DankActionButton { + anchors.fill: parent + buttonSize: CcMetrics.iconBoxSize + iconName: action.definition?.icon ?? "" + iconSize: CcMetrics.iconBoxIconSize + iconColor: CcMetrics.actionIconColor(action.modelData) + backgroundColor: root.background ? "transparent" : CcMetrics.tileInactiveColor + border.width: root.background ? 0 : Theme.layerOutlineWidth + border.color: Theme.outlineMedium + tooltipText: action.definition?.text ?? "" + onClicked: { + if (root.interactive) + WidgetUtils.triggerButton(root.host, action.modelData); + } + } + + DragHandler { + target: null + enabled: root.editMode + cursorShape: Qt.ClosedHandCursor + onActiveChanged: { + if (active) { + root.beginReorder(action.modelData, centroid.scenePressPosition); + return; + } + root.finishReorder(); + } + onCentroidChanged: { + if (active) + root.moveReorder(centroid.scenePosition); + } + } + } + } + + StyledButton { + objectName: "userAvatarButton" + x: identityArea.x + (!root.wide ? (identityArea.width - width) / 2 : I18n.isRtl ? identityArea.width - root.inset - width : root.inset) + y: (root.height - height) / 2 + width: root.avatarSide + height: root.avatarSide + radius: Theme.fullRadius(width, height) + visible: root.interactive + Accessible.name: I18n.tr("Users & accounts", "settings sidebar category") + onClicked: root.host?.accountsRequested() + + FocusRing { + visible: parent.visualFocus + } + + StateLayer { + control: parent + cornerRadius: parent.radius + tooltipText: parent.Accessible.name + } + } + DankActionButton { id: shapeButton @@ -141,4 +348,16 @@ Item { visible: root.editMode && avatar.visible onClicked: root.optionChanged("shape", WidgetUtils.nextUserShape(root.shape)) } + + // Edit-mode grid input skips these points so the shape and card buttons stay clickable. + Item { + id: passthroughArea + + anchors.fill: parent + containmentMask: QtObject { + function contains(point: point): bool { + return root.passesThrough(point); + } + } + } } diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 549fa2823..8528dc38e 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -3,14 +3,81 @@ .import qs.Modules.ControlCenter as ControlCenter .import "../../../Common/GridLayout.js" as GridLayout -var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user"]; +var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user", "runningApps"]; var ACTION_IDS = ["settings", "lock", "power"]; +var BUTTON_IDS = ACTION_IDS.concat(["edit"]); +var SMALL_BY_DEFAULT = BUTTON_IDS.concat(["runningApps"]); +var CARD_IDENTITY = "user"; var USER_SHAPES = ["circle", "cookie4", "cookie7", "cookie12", "clover4", "clover8", "sunny", "pentagon", "arch", "slanted", "gem", "square"]; function isSliderWidget(id) { return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider"; } +function isButton(id) { + return BUTTON_IDS.includes(id); +} + +// A "user" entry marks where the identity sits among the card's buttons. +function cardOrder(widget) { + const saved = Array.isArray(widget?.actions) ? widget.actions : []; + const order = saved.filter((id, i) => (id === CARD_IDENTITY || BUTTON_IDS.includes(id)) && saved.indexOf(id) === i); + return order.includes(CARD_IDENTITY) ? order : [CARD_IDENTITY].concat(order); +} + +function cardActions(widget) { + return cardOrder(widget).filter(id => id !== CARD_IDENTITY); +} + +function actionColumns(rows, count) { + return count > 0 ? Math.ceil(count / Math.max(1, Math.min(count, Math.floor(Number(rows) || 1)))) : 0; +} + +function cardColumns(order, rows) { + const split = order.indexOf(CARD_IDENTITY); + return actionColumns(rows, split) + actionColumns(rows, order.length - split - 1); +} + +function moveInOrder(order, id, targetId) { + const from = order.indexOf(id); + const to = order.indexOf(targetId); + if (from < 0 || to < 0 || from === to) + return order; + const moved = order.slice(); + moved.splice(from, 1); + moved.splice(to, 0, id); + return moved; +} + +function placedIds(widgets) { + return (widgets || []).reduce((ids, widget) => ids.concat([widget?.id], cardActions(widget)), []); +} + +function triggerButton(host, id) { + switch (id) { + case "lock": + host?.lockRequested(); + return; + case "power": + host?.powerRequested(); + return; + case "settings": + host?.settingsRequested(); + return; + case "edit": + host?.editRequested(); + return; + } +} + +function canShrink(id) { + return id !== "user" && !isSliderWidget(id); +} + +function isSmall(widget, rows) { + return widget?.small === true && rows === 1 && canShrink(widget.id); +} + function isShown(widget) { switch (widget?.id) { case "battery": @@ -60,9 +127,13 @@ function sizeSpec(widget, columns, rows = Infinity) { "maxH": rows, "step": ControlCenter.CcMetrics.gridStep }; - if (widget?.id === "user") + if (widget?.id === "user") { spec.w = (Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns) - ACTION_IDS.length; - if (ACTION_IDS.includes(widget?.id)) + spec.minW = Math.min(columns, 1 + cardColumns(cardOrder(widget), widget.h)); + } + if (widget?.id === "runningApps") + spec.w = Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns; + if (isButton(widget?.id)) spec.w = 1; return spec; } @@ -89,6 +160,8 @@ function clampSize(widget, columns, rows = Infinity) { function addWidget(widgetId, columns) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); const widget = defaultWidget(widgetId, columns); + if (SMALL_BY_DEFAULT.includes(widgetId)) + widget.small = true; if (widgetId === "diskUsage") { widget.instanceId = generateUniqueId(); @@ -127,6 +200,50 @@ function setOption(index, key, value) { Common.SettingsData.set("controlCenterWidgets", widgets); } +// A button lives in the card or the grid, never both; returns the card's index since tiles moving in or out shift it. +function setCardAction(index, id, enabled, columns) { + const widgets = Common.SettingsData.controlCenterWidgets.slice(); + const card = widgets[index]; + if (!card) + return index; + const before = cardOrder(card); + const order = before.filter(action => action !== id).concat(enabled ? [id] : []); + const width = clampSize(card, columns).w; + const w = Math.max(1, Math.min(columns, width + cardColumns(order, card.h) - cardColumns(before, card.h))); + const updated = Object.assign({}, card, { + "actions": order, + "w": w + }); + widgets[index] = updated; + if (enabled) + return commitCard(widgets.filter(widget => widget?.id !== id), updated); + if (!before.includes(id) || placedIds(widgets).includes(id)) + return commitCard(widgets, updated); + + const tile = Object.assign(defaultWidget(id, columns), { + "small": true + }); + const leading = before.indexOf(id) < before.indexOf(CARD_IDENTITY); + const freed = width - w; + if (isUnplaced(card)) { + widgets.splice(leading ? index : index + 1, 0, tile); + } else if (freed > 0) { + tile.col = leading ? card.col : card.col + w; + tile.row = card.row; + if (leading) + updated.col = card.col + freed; + widgets.splice(index + 1, 0, tile); + } else { + widgets.push(tile); + } + return commitCard(widgets, updated); +} + +function commitCard(widgets, card) { + Common.SettingsData.set("controlCenterWidgets", widgets); + return widgets.indexOf(card); +} + function setLayout(widgets) { Common.SettingsData.set("controlCenterWidgets", widgets); } diff --git a/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml b/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml index c83321ded..b0e4e7691 100644 --- a/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml +++ b/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml @@ -88,6 +88,10 @@ FocusScope { root.windowRequested("settings"); } + function openAccounts() { + root.windowRequested("accounts"); + } + function openColorPicker() { if (!PopoutService.colorPickerModal) return; diff --git a/quickshell/Modules/DankIsland/DankIslandSurface.qml b/quickshell/Modules/DankIsland/DankIslandSurface.qml index 7b8fc9d69..d7ba437c2 100644 --- a/quickshell/Modules/DankIsland/DankIslandSurface.qml +++ b/quickshell/Modules/DankIsland/DankIslandSurface.qml @@ -163,6 +163,9 @@ Item { case "settings": PopoutService.focusOrToggleSettings(); break; + case "accounts": + PopoutService.openSettingsWithTab("user_accounts"); + break; case "colorPicker": PopoutService.showColorPicker(); break; diff --git a/quickshell/Widgets/DankEditableGridSlot.qml b/quickshell/Widgets/DankEditableGridSlot.qml index 14e1c2133..811cd66d8 100644 --- a/quickshell/Widgets/DankEditableGridSlot.qml +++ b/quickshell/Widgets/DankEditableGridSlot.qml @@ -87,8 +87,7 @@ Item { const target = root.passthrough; if (!target?.visible) return true; - const local = root.mapToItem(target, point.x, point.y); - return local.x < 0 || local.y < 0 || local.x >= target.width || local.y >= target.height; + return !target.contains(root.mapToItem(target, point.x, point.y)); } } diff --git a/quickshell/Widgets/DankGridEditChrome.qml b/quickshell/Widgets/DankGridEditChrome.qml index 2a7299b5a..0e2722e75 100644 --- a/quickshell/Widgets/DankGridEditChrome.qml +++ b/quickshell/Widgets/DankGridEditChrome.qml @@ -231,8 +231,7 @@ Item { const target = root.passthrough; if (!target?.visible) return true; - const local = band.mapToItem(target, point.x, point.y); - return local.x < 0 || local.y < 0 || local.x >= target.width || local.y >= target.height; + return !target.contains(band.mapToItem(target, point.x, point.y)); } } } diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml index 5fae4c562..b3f085bfc 100644 --- a/quickshell/tests/qml/control-center-sizes.qml +++ b/quickshell/tests/qml/control-center-sizes.qml @@ -29,6 +29,20 @@ ShellRoot { WidgetModel { id: registry } + QtObject { + id: userHost + property bool editMode: false + property bool tapToClose: true + property var model: registry + property int closes: 0 + property int accounts: 0 + function closeRequested() { + closes++; + } + function accountsRequested() { + accounts++; + } + } IslandController { id: island dashboardAvailableWidth: CcMetrics.sheetWidthFor(20) + controlCenterSheetInset + PopoutMetrics.editOverflow * 2 @@ -256,9 +270,241 @@ ShellRoot { 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"); + SettingsData.controlCenterWidgets = [ + { + id: "lock", + w: 1, + h: 1 + } + ]; + const buttonSlot = grid.children.find(child => typeof child.beginResize === "function"); + const buttonChrome = buttonSlot.children.find(child => typeof child.resizeStarted === "function"); + const resizeButton = delta => { + buttonChrome.resizeStarted(0, 0, 1); + buttonSlot.resizeTo(delta, delta); + buttonSlot.finishResize(); + return SettingsData.controlCenterWidgets[0]; + }; + let button = resizeButton(-grid.cellWidth / 2); + check(button.small === true && button.w === 1 && button.h === 1 && buttonSlot.tileItem.small, "shrinking a 1x1 below its cell keeps the cell and makes the button small " + JSON.stringify(button)); + button = resizeButton(0); + check(button.small === true, "grabbing a small button without moving keeps it small"); + button = resizeButton(grid.cellWidth * 0.3); + check(button.small === false && button.w === 1 && button.h === 1, "growing a small button passes through a full 1x1 " + JSON.stringify(button)); + button = resizeButton(-grid.cellWidth / 2); + button = resizeButton(grid.cellWidth); + check(button.small === false && button.w === 2 && button.h === 2, "past full size a small button grows a cell per cell of travel like a full one " + JSON.stringify(button)); + SettingsData.controlCenterWidgets = [ + { + id: "darkMode", + w: 4, + h: 1 + } + ]; + const wideSlot = grid.children.find(child => typeof child.beginResize === "function"); + const wideChrome = wideSlot.children.find(child => typeof child.resizeStarted === "function"); + const resizeWide = delta => { + wideChrome.resizeStarted(0, 0, 1); + wideSlot.resizeTo(0, delta); + wideSlot.finishResize(); + return SettingsData.controlCenterWidgets[0]; + }; + let wide = resizeWide(-grid.cellWidth / 2); + check(wide.small === true && wide.w === 4 && wide.h === 1 && wideSlot.tileItem.small, "wide tiles take the half-height step too " + JSON.stringify(wide)); + wide = resizeWide(grid.cellWidth * 0.3); + check(wide.small === false && wide.w === 4 && wide.h === 1, "a half-height tile grows back to a full row " + JSON.stringify(wide)); + SettingsData.controlCenterWidgets = [ + { + id: "user", + w: 5, + h: 1 + }, + { + id: "lock", + w: 1, + h: 1, + small: true + }, + { + id: "edit", + w: 1, + h: 1, + small: true + } + ]; + WidgetUtils.setCardAction(0, "lock", true, 8); + WidgetUtils.setCardAction(0, "edit", true, 8); + let card = SettingsData.controlCenterWidgets[0]; + check(SettingsData.controlCenterWidgets.length === 1 && JSON.stringify(card.actions) === JSON.stringify(["user", "lock", "edit"]) && card.w === 7, "card actions take their tiles out of the grid and widen the card by their columns " + JSON.stringify(SettingsData.controlCenterWidgets)); + check(WidgetUtils.sizeSpec(Object.assign({}, card, { + h: 2 + }), 8).minW === 2 && WidgetUtils.sizeSpec(card, 8).minW === 3, "card buttons wrap into rows before they claim columns"); + check(WidgetUtils.sizeSpec(Object.assign({}, card, { + h: 2, + actions: ["lock", "user", "edit"] + }), 8).minW === 3, "buttons on both sides of the identity claim a column each"); + const cardTile = grid.children.find(child => typeof child.beginResize === "function").tileItem; + const sceneOf = point => cardTile.mapToItem(null, point.x, point.y); + const edit = cardTile.actionSlot("edit"); + cardTile.beginReorder("edit", sceneOf(Qt.point(edit.x + 1, edit.y + 1))); + cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.75, cardTile.height / 2))); + check(JSON.stringify(cardTile.order) === JSON.stringify(["user", "lock", "edit"]), "a button stays put on its own side of the identity"); + cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.25, cardTile.height / 2))); + cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.4, cardTile.height / 2))); + cardTile.finishReorder(); + card = SettingsData.controlCenterWidgets[0]; + check(JSON.stringify(card.actions) === JSON.stringify(["edit", "user", "lock"]), "dragging a button past the identity's centre saves it on the other side " + JSON.stringify(card)); + check(Math.abs(cardTile.actionSlot("edit").x - cardTile.buttonInset) < 0.5 && cardTile.identityStart === cardTile.pitch, "the identity makes room for leading buttons"); + const lock = cardTile.actionSlot("lock"); + const lockCenter = Qt.point(lock.x + CcMetrics.iconBoxSize / 2, lock.y + CcMetrics.iconBoxSize / 2); + const dropPoint = Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.25, cardTile.height / 2); + mousePress(cardTile, lockCenter.x, lockCenter.y); + for (let i = 1; i <= 8; i++) + mouseMove(cardTile, lockCenter.x + (dropPoint.x - lockCenter.x) * i / 8, lockCenter.y + (dropPoint.y - lockCenter.y) * i / 8); + check(grid.draggingSourceIndex === -1 && cardTile.draggedAction === "lock", "dragging a card button reorders it instead of moving the card"); + mouseRelease(cardTile, dropPoint.x, dropPoint.y); + check(JSON.stringify(SettingsData.controlCenterWidgets[0].actions) === JSON.stringify(["edit", "lock", "user"]), "a mouse drop saves the new order " + JSON.stringify(SettingsData.controlCenterWidgets[0])); + const corner = Qt.point(cardTile.width - 2, cardTile.height - 2); + mousePress(cardTile, corner.x, corner.y); + mouseMove(cardTile, corner.x, corner.y + grid.cellWidth / 2); + mouseMove(cardTile, corner.x, corner.y + grid.cellWidth); + check(grid.sizePreview?.index === 0 && grid.draggingSourceIndex === -1, "the card's resize corner still works around its buttons"); + mouseRelease(cardTile, corner.x, corner.y + grid.cellWidth); + SettingsData.controlCenterWidgets = [ + { + id: "user", + w: 7, + h: 1, + col: 0, + row: 0, + actions: ["settings", "user", "lock"] + }, + { + id: "wifi", + w: 4, + h: 1, + col: 0, + row: 1 + } + ]; + let cardIndex = WidgetUtils.setCardAction(0, "settings", false, 8); + cardIndex = WidgetUtils.setCardAction(cardIndex, "lock", false, 8); + const returned = SettingsData.controlCenterWidgets; + const at = id => grid.slotLayout.slots[SettingsData.controlCenterWidgets.findIndex(widget => widget.id === id)]; + check(returned[cardIndex].id === "user" && returned[cardIndex].w === 5 && at("user").col === 1 && at("settings").col === 0 && at("lock").col === 6 && at("settings").row === 0 && at("lock").row === 0 && at("wifi").row === 1 && returned.every(widget => widget.id === "user" || widget.id === "wifi" || widget.small), "buttons taken off a placed card return as small tiles where they sat " + JSON.stringify(returned)); + SettingsData.controlCenterWidgets = [ + { + id: "wifi", + w: 2, + h: 1 + }, + { + id: "user", + w: 6, + h: 1, + actions: ["power", "user"] + } + ]; + cardIndex = WidgetUtils.setCardAction(1, "power", false, 8); + check(cardIndex === 2 && SettingsData.controlCenterWidgets[1].id === "power" && at("power").col === 2 && at("user").col === 3 && at("user").row === 0, "a button taken off an unplaced card packs next to it and the card index follows " + JSON.stringify(SettingsData.controlCenterWidgets)); + check(WidgetUtils.setCardAction(cardIndex, "power", true, 8) === 1, "adding a tile before the card to it reports the card's new index"); + grid.dockEdit = true; + SettingsData.controlCenterWidgets = [ + { + id: "wifi", + w: 8, + h: 1 + }, + { + id: "runningApps", + w: 7, + h: 1, + small: true + } + ]; + const dock = grid.editDock; + check(dock !== null && Math.abs(dock.x - grid.cellWidth * 7) < 0.5 && Math.abs(dock.y - grid.cellWidth) < 0.5, "the edit button joins a last row with a free trailing cell " + JSON.stringify(dock)); + SettingsData.controlCenterWidgets = [ + { + id: "wifi", + w: 4, + h: 1 + }, + { + id: "runningApps", + w: 8, + h: 1 + } + ]; + check(grid.editDock === null, "a full last row keeps the edit button in the footer"); + grid.dockEdit = false; grid.destroy(); wait(0); + const userTile = registry.componentForWidget({ + id: "user" + }).createObject(scene); + size(userTile, 6, 2); + userTile.widgetData = { + id: "user", + actions: ["user", "lock", "power"], + background: true + }; + const cardRadius = userTile.bodyRadius; + userTile.widgetData = { + id: "user", + actions: ["user", "lock", "power"] + }; + check(userTile.bodyRadius === cardRadius && cardRadius < userTile.height / 2, "a tall card with buttons keeps its card outline without a background"); + userTile.host = userHost; + const avatarButton = named(userTile, "userAvatarButton"); + const closeButton = named(userTile, "userCloseButton"); + const shownAvatar = item => { + if (item !== userTile && item.visible && (item.imageSource !== undefined || item.shape !== undefined)) + return item; + for (const child of item.children || []) { + const found = shownAvatar(child); + if (found) + return found; + } + return null; + }; + for (const layout of [[5, 1, {}], [6, 2, { + background: true, + actions: ["settings", "user", "lock"] + }], [1, 1, {}], [3, 3, { + background: true + }]]) { + size(userTile, layout[0], layout[1]); + userTile.widgetData = Object.assign({ + id: "user" + }, layout[2]); + settle(); + const shown = shownAvatar(userTile); + const at = shown.mapToItem(userTile, 0, 0); + const hit = avatarButton.mapToItem(userTile, 0, 0); + check(Math.abs(at.x - hit.x) < 1 && Math.abs(at.y - hit.y) < 1 && Math.abs(shown.width - avatarButton.width) < 1, "the avatar's click area covers the picture at " + layout[0] + "x" + layout[1] + " " + JSON.stringify([at.x, at.y, shown.width, hit.x, hit.y, avatarButton.width])); + } + size(userTile, 5, 1); + userTile.widgetData = { + id: "user" + }; + settle(); + const avatarCenter = avatarButton.mapToItem(userTile, avatarButton.width / 2, avatarButton.height / 2); + mouseMove(userTile, avatarCenter.x, avatarCenter.y); + check(avatarButton.hovered && !closeButton.hovered, "hovering the avatar lights only the avatar"); + mouseClick(userTile, avatarCenter.x, avatarCenter.y); + check(userHost.accounts === 1 && userHost.closes === 0, "clicking the avatar opens accounts without closing through the card"); + mouseClick(userTile, userTile.width - Theme.spacingL, userTile.height / 2); + check(userHost.accounts === 1 && userHost.closes === 1, "clicking the rest of the card only closes"); + userTile.destroy(); + wait(0); const tile = tileComponent.createObject(scene); + size(tile, 4, 1); + tile.small = true; + settle(); + const smallBox = named(tile, "tileIconBox"); + check(smallBox.height <= tile.bodyHeight - tile.tilePadding * 2 && named(tile, "tileLabels").height <= tile.bodyHeight, "a half-height tile keeps its icon and labels inside its body"); + tile.small = false; size(tile, 4, 3); settle(); check(tile.expanded && tile.expandedItem !== null, "large tile exposes inline actions"); diff --git a/quickshell/tests/settings-cc-grid.test.mjs b/quickshell/tests/settings-cc-grid.test.mjs index 0e972ad7b..cca135d2a 100644 --- a/quickshell/tests/settings-cc-grid.test.mjs +++ b/quickshell/tests/settings-cc-grid.test.mjs @@ -30,3 +30,10 @@ test("header entries become a user row and lock, power and settings tiles", () = const released = store.migrateToVersion({ configVersion: 18, controlCenterWidgets: [{ id: "wifi", width: 50 }] }, 35); assert.deepEqual(JSON.parse(JSON.stringify(released.controlCenterWidgets.map(w => [w.id, w.w, w.h]))), [["user", 5, 1], ["settings", 1, 1], ["lock", 1, 1], ["power", 1, 1], ["wifi", 4, 1]]); }); + +test("layouts saved before the running apps tile get one appended", () => { + const migrated = store.migrateToVersion({ configVersion: 35, controlCenterColumns: 6, controlCenterWidgets: [{ id: "wifi", w: 4, h: 1 }] }, 36); + assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)), [{ id: "wifi", w: 4, h: 1 }, { id: "runningApps", enabled: true, w: 5, h: 1, small: true }]); + const kept = store.migrateToVersion({ configVersion: 35, controlCenterWidgets: [{ id: "runningApps", w: 2, h: 1, col: 0, row: 3 }] }, 36); + assert.deepEqual(JSON.parse(JSON.stringify(kept.controlCenterWidgets)), [{ id: "runningApps", w: 2, h: 1, col: 0, row: 3 }]); +});