From 64b4d0d235526e97cf1c2ee875e35d405eb418f5 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sun, 27 Sep 2026 20:55:54 -0400 Subject: [PATCH] feat(control center): configurable user card & power control action tiles - Quick actions are reorderable with button color roles and optional power accents - Updated dank-qml-common --- dank-qml-common | 2 +- quickshell/Common/GridLayout.js | 31 +++- quickshell/Common/SettingsData.qml | 3 +- quickshell/Common/Theme.qml | 3 + quickshell/Common/settings/SettingsSpec.js | 15 ++ quickshell/Common/settings/SettingsStore.js | 15 ++ quickshell/Modals/Settings/ProfileSection.qml | 2 + .../Modules/ControlCenter/CcMetrics.qml | 13 +- .../ControlCenter/Components/CcEditChrome.qml | 4 +- .../ControlCenter/Components/CcTileGrid.qml | 8 +- .../ControlCenter/Components/CcTileSlot.qml | 2 +- .../ControlCenter/Components/EditControls.qml | 22 ++- .../ControlCenter/Components/HeaderPane.qml | 149 ---------------- .../Components/WidgetConfigOverlay.qml | 168 ++++++++++++++++++ .../ControlCenter/ControlCenterContent.qml | 54 +++--- .../ControlCenter/Models/WidgetModel.qml | 12 +- .../ControlCenter/Widgets/CcCardTile.qml | 36 ++++ .../Widgets/QuickActionsTile.qml | 89 ++++++++++ .../ControlCenter/Widgets/UserCardTile.qml | 37 ++++ .../Modules/ControlCenter/utils/widgets.js | 113 ++++++++++-- quickshell/Modules/DankDash/DashMetrics.qml | 4 - quickshell/Modules/DankDash/DashRegistry.qml | 3 +- .../DankDash/Overview/UserInfoCard.qml | 155 ++-------------- quickshell/Modules/Greetd/GreeterContent.qml | 2 + .../Modules/Greetd/GreeterUserPicker.qml | 2 + quickshell/Modules/Lock/LockScreenContent.qml | 2 + .../BarWidgetOptions/BatteryOptions.qml | 8 +- .../Modules/Settings/UserAccountsTab.qml | 43 +++++ quickshell/Widgets/DankGridEditChrome.qml | 9 +- quickshell/Widgets/DankPopoutHost.qml | 5 +- quickshell/Widgets/UserIdentity.qml | 156 ++++++++++++++++ quickshell/tests/dash-grid.test.mjs | 8 + quickshell/tests/qml/control-center-sizes.qml | 10 +- quickshell/tests/settings-cc-grid.test.mjs | 7 + .../translations/settings_search_index.json | 18 ++ 35 files changed, 837 insertions(+), 373 deletions(-) delete mode 100644 quickshell/Modules/ControlCenter/Components/HeaderPane.qml create mode 100644 quickshell/Modules/ControlCenter/Widgets/CcCardTile.qml create mode 100644 quickshell/Modules/ControlCenter/Widgets/QuickActionsTile.qml create mode 100644 quickshell/Modules/ControlCenter/Widgets/UserCardTile.qml create mode 100644 quickshell/Widgets/UserIdentity.qml diff --git a/dank-qml-common b/dank-qml-common index 9594c1e3b..660b04470 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 9594c1e3bdc561da82b74fd188811f66b64d1e12 +Subproject commit 660b04470bb7937b970c862024bcadd7000e6a89 diff --git a/quickshell/Common/GridLayout.js b/quickshell/Common/GridLayout.js index 46b09bad9..e8767febe 100644 --- a/quickshell/Common/GridLayout.js +++ b/quickshell/Common/GridLayout.js @@ -1,4 +1,4 @@ -function packCells(cards, order, columns, isAvailable, step = 1) { +function packCells(cards, order, columns, isAvailable, step = 1, gravity = false) { const steps = Math.round(columns / step); const cells = []; const taken = []; @@ -18,12 +18,21 @@ function packCells(cards, order, columns, isAvailable, step = 1) { const h = Math.max(1, Math.round((card.h || 1) / step)); const cell = positioned(card) ? settle(taken, Math.max(0, Math.min(steps - w, Math.round(card.col / step))), Math.max(0, Math.round(card.row / step)), w, h) : firstFit(taken, steps, w, h); taken.push(cell); + cells[sourceIndex] = cell; + } + + if (gravity) + compact(taken); + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + if (!cell) + continue; rows = Math.max(rows, cell.y + cell.h); - cells[sourceIndex] = { + cells[i] = { "col": cell.x * step, "row": cell.y * step, - "cols": w * step, - "rows": h * step + "cols": cell.w * step, + "rows": cell.h * step }; } @@ -33,6 +42,16 @@ function packCells(cards, order, columns, isAvailable, step = 1) { }; } +// Saved positions only settle downward, so a shrink or removal above leaves a hole; gravity pulls everything up into it. +function compact(taken) { + const ordered = taken.slice().sort((a, b) => a.y - b.y || a.x - b.x); + for (const cell of ordered) { + const others = taken.filter(other => other !== cell); + while (cell.y > 0 && !overlaps(others, cell.x, cell.y - 1, cell.w, cell.h)) + cell.y--; + } +} + function positioned(card) { return Number.isFinite(card.col) && Number.isFinite(card.row); } @@ -73,8 +92,8 @@ function firstFit(taken, steps, w, h) { }; } -function packCards(cards, order, columns, width, gap, rowUnit, mirror, isAvailable, step = 1) { - const packed = packCells(cards, order, columns, isAvailable, step); +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) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index fcb741adc..a44c5a2fa 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: 31 + readonly property int settingsConfigVersion: 32 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" @@ -452,6 +452,7 @@ Singleton { property bool dankLauncherV2IncludeFoldersInAll: Spec.SPEC.dankLauncherV2IncludeFoldersInAll.def property bool launcherUseOverlayLayer: Spec.SPEC.launcherUseOverlayLayer.def property string launcherStyle: Spec.SPEC.launcherStyle.def + property string avatarRing: Spec.SPEC.avatarRing.def property bool spotlightBarShowModeChips: Spec.SPEC.spotlightBarShowModeChips.def property bool keybindsFloatingWindow: Spec.SPEC.keybindsFloatingWindow.def onKeybindsFloatingWindowChanged: saveSettings() diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 9d1b50ad1..b91ef7b81 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -578,6 +578,9 @@ Singleton { } } + readonly property real avatarRingWidth: SettingsData.avatarRing === "none" ? 0 : outlineWidth + readonly property color avatarRingColor: SettingsData.avatarRing === "outline" ? surfaceVariant : roleColor(SettingsData.avatarRing) + function roleColor(mode) { switch (mode) { case "primary": diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 731629bf3..2d9c71fd4 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -430,6 +430,18 @@ var LOCAL_SPEC = { }, controlCenterWidgets: { def: [ + { + id: "userCard", + enabled: true, + w: 4, + h: 1 + }, + { + id: "quickActions", + enabled: true, + w: 4, + h: 1 + }, { id: "volumeSlider", enabled: true, @@ -635,6 +647,9 @@ var LOCAL_SPEC = { launcherStyle: { def: "full" }, + avatarRing: { + def: "outline" + }, spotlightBarShowModeChips: { def: false }, diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index 0534382b3..775ddeb55 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -15,6 +15,7 @@ var SESSION_BACKED_PLUGIN_IDS = ["dankNotepadModule"]; var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClockTransparency", "desktopClockColorMode", "desktopClockCustomColor", "desktopClockShowDate", "desktopClockShowAnalogNumbers", "desktopClockShowAnalogSeconds", "desktopClockX", "desktopClockY", "desktopClockWidth", "desktopClockHeight", "desktopClockDisplayPreferences", "systemMonitorEnabled", "systemMonitorShowHeader", "systemMonitorTransparency", "systemMonitorColorMode", "systemMonitorCustomColor", "systemMonitorShowCpu", "systemMonitorShowCpuGraph", "systemMonitorShowCpuTemp", "systemMonitorShowGpuTemp", "systemMonitorGpuPciId", "systemMonitorShowMemory", "systemMonitorShowMemoryGraph", "systemMonitorShowNetwork", "systemMonitorShowNetworkGraph", "systemMonitorShowDisk", "systemMonitorShowTopProcesses", "systemMonitorTopProcessCount", "systemMonitorTopProcessSortBy", "systemMonitorGraphInterval", "systemMonitorLayoutMode", "systemMonitorX", "systemMonitorY", "systemMonitorWidth", "systemMonitorHeight", "systemMonitorDisplayPreferences", "systemMonitorVariants", "desktopWidgetPositions"]; var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"]; +var CC_PINNED_WIDGETS = ["userCard", "quickActions"]; 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"]; @@ -698,6 +699,20 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 31; } + if (currentVersion < 32 && targetVersion >= 32) { + if (Array.isArray(settings.controlCenterWidgets)) { + const ids = settings.controlCenterWidgets.map(widget => widget?.id); + const pinned = CC_PINNED_WIDGETS.filter(id => !ids.includes(id)).map(id => ({ + id: id, + enabled: true, + w: 4, + h: 1 + })); + settings.controlCenterWidgets = pinned.concat(settings.controlCenterWidgets); + } + settings.configVersion = 32; + } + return settings; } diff --git a/quickshell/Modals/Settings/ProfileSection.qml b/quickshell/Modals/Settings/ProfileSection.qml index 651c61328..ae950b420 100644 --- a/quickshell/Modals/Settings/ProfileSection.qml +++ b/quickshell/Modals/Settings/ProfileSection.qml @@ -21,6 +21,8 @@ SettingsNavRow { DankCircularImage { width: Theme.minimumTouchTargetSize + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor height: width anchors.left: parent.left anchors.leftMargin: (SettingsMetrics.navIconSize - width) / 2 diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index 37c94cc67..78505190e 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -54,11 +54,13 @@ Singleton { readonly property real iconBoxIconSize: Theme.iconSize * iconScale readonly property real tallMeterThickness: 28 readonly property real tileTextGap: Theme.spacingM - readonly property real headerActionSize: Theme.iconButtonSize * iconScale - readonly property real headerActionIconSize: Theme.iconSize * iconScale - - readonly property real headerAvatarSize: 56 - readonly property real headerHeight: tileHeight + readonly property real actionSize: Theme.iconButtonSize * iconScale + readonly property real actionIconSize: Theme.iconSize * iconScale + readonly property real actionGap: Theme.spacingS + // Buttons never shrink, so the actions tile cannot go narrower than its pills plus padding. + function quickActionsMinColumns(count) { + return Math.ceil((actionSize * count + actionGap * Math.max(0, count - 1) + Theme.spacingS * 2 + gridGap) / (columnWidth + gridGap) / gridStep) * gridStep; + } readonly property real detailDialogInset: Theme.spacingL readonly property real detailDialogPadding: Theme.spacingS @@ -105,6 +107,7 @@ Singleton { readonly property int overlayZ: 10000 readonly property real popupEnterScale: 0.92 readonly property color dialogColor: Theme.foregroundColor(Theme.hostSurface) + readonly property int backdropBlurRadius: 32 readonly property bool hideCoveredContent: BlurService.enabled && Theme.connectedSurfaceBlurEnabled && dialogColor.a < 1 readonly property color tileActiveColor: Theme.ccTileActiveBg diff --git a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml index 69b674e16..4a44cd6f1 100644 --- a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml +++ b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Common import qs.Widgets +import "../utils/widgets.js" as WidgetUtils import qs.Modules.ControlCenter DankGridEditChrome { @@ -10,7 +11,8 @@ DankGridEditChrome { signal configRequested(var anchor) - hasOptions: widgetData.id === "diskUsage" || widgetData.id === "brightnessSlider" || widgetData.id === "idleInhibitor" || String(widgetData.id ?? "").startsWith("plugin_") + hasOptions: WidgetUtils.hasOptions(widgetData.id) + removable: !WidgetUtils.isPinned(widgetData.id) buttonSize: Theme.iconSize hitOverflow: CcMetrics.gridGap / 2 iconSize: PopoutMetrics.chromeIconSize diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 8581f70f6..485b846ea 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -21,13 +21,19 @@ DankEditableGrid { signal removeWidget(int index) signal configRequested(int index, var widgetData, var anchor) signal colorPickerRequested + signal lockRequested + signal powerRequested + signal settingsRequested + signal editRequested + signal headerTapped + property bool tapToClose: false readonly property real gridHeight: layoutHeight readonly property real cellWidth: (width + CcMetrics.gridGap) / columns readonly property CcTileSlot draggingSlot: tileRepeater.itemAt(draggingSourceIndex) as CcTileSlot sourceItems: (SettingsData.controlCenterWidgets || []).map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, Infinity))) - slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep) + 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) onLayoutCommitted: items => model.setLayout(items) diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index f96df3fd3..285ec0ccc 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -9,7 +9,7 @@ DankEditableGridSlot { id: root readonly property var widgetData: JSON.parse(json) - readonly property var sizeSpec: WidgetUtils.sizeSpec(widgetData.id || "", grid.columns, grid.maximumRows) + readonly property var sizeSpec: WidgetUtils.sizeSpec(widgetData, grid.columns, grid.maximumRows) readonly property real cols: slot?.cols ?? 1 readonly property real rows: slot?.rows ?? 1 readonly property bool compact: cols <= 2 && rows === 1 diff --git a/quickshell/Modules/ControlCenter/Components/EditControls.qml b/quickshell/Modules/ControlCenter/Components/EditControls.qml index 0ce4476a9..dd211935f 100644 --- a/quickshell/Modules/ControlCenter/Components/EditControls.qml +++ b/quickshell/Modules/ControlCenter/Components/EditControls.qml @@ -24,8 +24,10 @@ Row { signal addWidget(string widgetId) signal resetToDefault signal clearAll + signal finishRequested + signal cancelRequested - readonly property real buttonWidth: (width - spacing * 2) / 3 + readonly property real buttonWidth: (width - spacing * 4 - Theme.buttonHeightS * 2) / 3 height: Theme.buttonHeightS spacing: Theme.spacingS @@ -142,4 +144,22 @@ Row { textColor: Theme.error onClicked: root.clearAll() } + + DankActionButton { + buttonSize: Theme.buttonHeightS + iconName: "close" + iconColor: Theme.onSecondaryContainer + backgroundColor: Theme.secondaryContainer + tooltipText: I18n.tr("Cancel") + onClicked: root.cancelRequested() + } + + DankActionButton { + buttonSize: Theme.buttonHeightS + iconName: "check" + iconColor: Theme.primaryText + backgroundColor: Theme.primary + tooltipText: I18n.tr("Finish") + onClicked: root.finishRequested() + } } diff --git a/quickshell/Modules/ControlCenter/Components/HeaderPane.qml b/quickshell/Modules/ControlCenter/Components/HeaderPane.qml deleted file mode 100644 index 96f7be463..000000000 --- a/quickshell/Modules/ControlCenter/Components/HeaderPane.qml +++ /dev/null @@ -1,149 +0,0 @@ -import QtQuick -import qs.Common -import qs.Modules.ControlCenter -import qs.Services -import qs.Widgets - -Item { - id: root - - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - - property bool editMode: false - property bool tapToClose: false - property bool live: true - - signal powerButtonClicked - signal lockRequested - signal editModeToggled - signal editCancelled - signal settingsButtonClicked - signal headerTapped - - Ref { - service: DgopService - modules: "system" - active: root.live && root.visible && (root.Window.window?.visible ?? false) - } - - implicitHeight: CcMetrics.headerHeight - height: implicitHeight - - MouseArea { - anchors.fill: parent - enabled: root.tapToClose - acceptedButtons: Qt.LeftButton - onClicked: root.headerTapped() - } - - Item { - anchors.left: parent.left - anchors.right: actionButtonsRow.left - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Theme.spacingS - height: CcMetrics.headerAvatarSize - - DankCircularImage { - id: avatar - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: CcMetrics.headerAvatarSize - height: CcMetrics.headerAvatarSize - imageSource: { - if (PortalService.profileImage === "") - return ""; - if (PortalService.profileImage.startsWith("/")) - return "file://" + PortalService.profileImage; - return PortalService.profileImage; - } - fallbackIcon: "person" - } - - Item { - anchors.left: avatar.right - anchors.leftMargin: Theme.spacingM - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - implicitHeight: userLabel.implicitHeight + Theme.spacingXXS + uptimeLabel.implicitHeight - - StyledText { - id: userLabel - width: parent.width - text: UserInfoService.fullName || UserInfoService.username || I18n.tr("User") - font.pixelSize: Theme.fontSizeLarge - font.weight: Theme.fontWeightMedium - color: Theme.surfaceText - elide: Text.ElideRight - horizontalAlignment: Text.AlignLeft - } - - StyledText { - id: uptimeLabel - y: userLabel.implicitHeight + Theme.spacingXXS - width: parent.width - text: DgopService.uptime ? I18n.tr("up", "uptime prefix, e.g. 'up 4h 2m'") + " " + DgopService.uptime.slice(3) : I18n.tr("Unknown") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - elide: Text.ElideRight - horizontalAlignment: Text.AlignLeft - } - } - } - - Row { - id: actionButtonsRow - height: CcMetrics.headerActionSize - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankActionButton { - buttonSize: CcMetrics.headerActionSize - iconSize: CcMetrics.headerActionIconSize - iconName: "lock" - iconColor: Theme.surfaceText - Accessible.name: I18n.tr("Lock") - onClicked: root.lockRequested() - } - - DankActionButton { - buttonSize: CcMetrics.headerActionSize - iconSize: CcMetrics.headerActionIconSize - iconName: "power_settings_new" - iconColor: Theme.surfaceText - Accessible.name: I18n.tr("Power") - onClicked: root.powerButtonClicked() - } - - DankActionButton { - buttonSize: CcMetrics.headerActionSize - iconSize: CcMetrics.headerActionIconSize - iconName: "settings" - iconColor: Theme.surfaceText - Accessible.name: I18n.tr("Settings") - onClicked: root.settingsButtonClicked() - } - - DankActionButton { - buttonSize: CcMetrics.headerActionSize - iconSize: CcMetrics.headerActionIconSize - iconName: "close" - iconColor: Theme.surfaceText - visible: root.editMode - Accessible.name: I18n.tr("Cancel") - onClicked: root.editCancelled() - } - - DankActionButton { - buttonSize: CcMetrics.headerActionSize - iconSize: CcMetrics.headerActionIconSize - iconName: root.editMode ? "done" : "edit" - iconColor: root.editMode ? Theme.onSecondaryContainer : Theme.surfaceText - backgroundColor: root.editMode ? Theme.secondaryContainer : "transparent" - Accessible.name: root.editMode ? I18n.tr("Finish") : I18n.tr("Edit") - onClicked: root.editModeToggled() - } - } -} diff --git a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml index eea27b43c..b314e02f3 100644 --- a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml +++ b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml @@ -1,14 +1,22 @@ +pragma ComponentBehavior: Bound + import QtQuick +import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Modules.ControlCenter import qs.Modules.ControlCenter.Widgets import qs.Modules.DankBar.Widgets +import qs.Modules.DankDash +import qs.Modules.Settings.Widgets +import "../utils/widgets.js" as WidgetUtils import qs.Services import qs.Widgets Item { id: root + property Item backdrop: null property int widgetIndex: -1 property real anchorX: 0 property real anchorY: 0 @@ -25,6 +33,9 @@ Item { readonly property bool isPlugin: widgetId.startsWith("plugin_") readonly property bool isDisk: widgetId === "diskUsage" readonly property bool isIdleInhibitor: widgetId === "idleInhibitor" + readonly property bool isUserCard: widgetId === "userCard" + readonly property bool isQuickActions: widgetId === "quickActions" + readonly property var quickActions: isQuickActions ? WidgetUtils.quickActions(widgetData) : [] visible: widgetIndex >= 0 z: CcMetrics.overlayZ @@ -43,6 +54,12 @@ Item { widgetIndex = -1; } + function toggleAction(id, enabled) { + persistOption("actions", quickActions.map(action => action.id === id ? Object.assign({}, action, { + "enabled": enabled + }) : action)); + } + function persistOption(key, value) { const widgets = (SettingsData.controlCenterWidgets || []).slice(); if (widgetIndex < 0 || widgetIndex >= widgets.length) @@ -105,6 +122,48 @@ Item { } } + // The popout is not a real subsurface, so fake compositor blur the same way the detail dialog does, but from the sheet content it covers. + Loader { + anchors.fill: parent + z: -1 + active: root.visible && root.backdrop !== null && CcMetrics.hideCoveredContent + + sourceComponent: ClippingRectangle { + id: clipArea + + // Children land in an internal content item, so `parent` never reaches this pad. + readonly property real pad: CcMetrics.backdropBlurRadius + + radius: panel.radius + color: "transparent" + + ShaderEffectSource { + id: backdropSource + + x: -clipArea.pad + y: -clipArea.pad + width: clipArea.width + clipArea.pad * 2 + height: clipArea.height + clipArea.pad * 2 + visible: false + sourceItem: root.backdrop + // mapFromItem is not reactive, so the capture is rebuilt from the panel's own geometry. + sourceRect: { + const origin = root.backdrop.mapFromItem(root, panel.x - clipArea.pad, panel.y - clipArea.pad); + return Qt.rect(origin.x, origin.y, width, height); + } + } + + MultiEffect { + anchors.fill: backdropSource + source: backdropSource + blurEnabled: true + blur: 1 + blurMax: CcMetrics.backdropBlurRadius + autoPaddingEnabled: false + } + } + } + MouseArea { anchors.fill: parent acceptedButtons: Qt.AllButtons @@ -129,6 +188,115 @@ Item { } } + Repeater { + model: root.isUserCard ? 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) + } + } + + SettingsReorderList { + id: actionList + + visible: root.isQuickActions + model: root.quickActions + onReordered: indices => root.persistOption("actions", indices.map(i => root.quickActions[i])) + + delegate: SettingsReorderRow { + required property var modelData + readonly property bool locked: modelData.id === "edit" + + reorderList: actionList + paddingH: CcMetrics.rowPaddingH + paddingV: CcMetrics.rowPaddingV + rowColor: dragging ? Theme.blend(CcMetrics.rowColor, Theme.onSurface, Theme.stateLayerDrag) : CcMetrics.rowColor + iconName: WidgetUtils.quickActionIcon(modelData.id) + title: I18n.tr(WidgetUtils.quickActionLabel(modelData.id)) + clickable: !locked + onClicked: root.toggleAction(modelData.id, !modelData.enabled) + + DankToggle { + hideText: true + text: parent.title + activeFocusOnTab: false + checked: modelData.enabled + enabled: !locked + onToggled: value => root.toggleAction(modelData.id, value) + } + } + } + + CcListRow { + id: colorRow + + readonly property var roles: [{ + "value": "default", + "label": I18n.tr("Default"), + "color": Theme.secondaryContainer + }, { + "value": "primary", + "label": I18n.tr("Primary"), + "color": Theme.primary + }, { + "value": "primaryContainer", + "label": I18n.tr("Primary Container"), + "color": Theme.primaryContainer + }, { + "value": "secondary", + "label": I18n.tr("Secondary"), + "color": Theme.secondary + }, { + "value": "surfaceVariant", + "label": I18n.tr("Surface Variant"), + "color": Theme.surfaceVariant + }, { + "value": "surfaceText", + "label": I18n.tr("Text Color"), + "color": Theme.surfaceText + }] + + visible: root.isQuickActions + iconName: "palette" + title: I18n.tr("Button color") + body: DankDropdown { + compactMode: true + dropdownWidth: parent.width + currentValue: colorRow.roles.find(role => role.value === WidgetUtils.quickActionRole(root.widgetData))?.label ?? "" + options: colorRow.roles.map(role => role.label) + // QV4 has no Object.fromEntries. + optionColorMap: { + const map = {}; + for (const role of colorRow.roles) + map[role.label] = role.color; + return map; + } + onValueChanged: value => { + const role = colorRow.roles.find(role => role.label === value); + if (role) + root.persistOption("buttonColor", role.value); + } + } + } + + CcToggleRow { + visible: root.isQuickActions + text: I18n.tr("Background") + checked: root.widgetData?.background === true + onToggled: checked => root.persistOption("background", checked) + } + + CcToggleRow { + visible: root.isQuickActions + text: I18n.tr("Highlight power", "toggle that gives the control center power button the error color") + checked: root.widgetData?.powerAccent === true + onToggled: checked => root.persistOption("powerAccent", 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") diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml index 3ff96c812..37567b98c 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -19,7 +19,7 @@ FocusScope { readonly property bool pageOpen: (host.expandedSection ?? "") !== "" readonly property real gridHeight: widgetGrid.gridHeight readonly property real targetImplicitHeight: { - let total = CcMetrics.sheetPadding * 2 + headerPane.height + Theme.spacingS + gridHeight; + let total = CcMetrics.sheetPadding * 2 + gridHeight; if (host.editMode) total += Theme.spacingS + editControls.height; if (detailPage.shownSection === "") @@ -34,7 +34,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 - CcMetrics.headerHeight - Theme.spacingS * 2 - editControls.height + readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - Theme.spacingS - editControls.height readonly property real editGutter: host.editGutter ?? 0 readonly property DankPanelResizer panelResizer: DankPanelResizer { popout: root.host @@ -252,33 +252,6 @@ FocusScope { y: CcMetrics.sheetPadding spacing: Theme.spacingS - HeaderPane { - id: headerPane - - width: parent.width - editMode: root.host.editMode - live: root.host.shouldBeVisible - tapToClose: root.host.headerTogglesClose ?? false - onHeaderTapped: root.host.close() - onEditModeToggled: root.host.editMode = !root.host.editMode - onEditCancelled: root.cancelEdit() - onPowerButtonClicked: { - const loader = root.host.powerMenuModalLoader; - if (!loader) - return; - loader.active = true; - if (!loader.item) - return; - const bounds = Qt.rect(root.host.alignedX, root.host.alignedY, root.host.popupWidth, root.host.popupHeight); - loader.item.openFromControlCenter(bounds, root.host.screen); - } - onLockRequested: { - root.host.close(); - root.host.lockRequested(); - } - onSettingsButtonClicked: root.host.openSettings() - } - Item { id: body @@ -297,10 +270,28 @@ FocusScope { model: widgetModel live: root.host.shouldBeVisible && detailPage.shownSection === "" screenName: root.host.triggerScreen?.name || "" + tapToClose: root.host.headerTogglesClose ?? false onExpandClicked: widgetData => root.openWidgetPage(widgetData) onRemoveWidget: index => widgetModel.removeWidget(index) onConfigRequested: (index, widgetData, anchor) => root.openConfigOverlay(index, widgetData, anchor) onColorPickerRequested: root.host.openColorPicker() + onHeaderTapped: root.host.close() + onEditRequested: root.host.editMode = !root.host.editMode + onSettingsRequested: root.host.openSettings() + onLockRequested: { + root.host.close(); + root.host.lockRequested(); + } + onPowerRequested: { + const loader = root.host.powerMenuModalLoader; + if (!loader) + return; + loader.active = true; + if (!loader.item) + return; + const bounds = Qt.rect(root.host.alignedX, root.host.alignedY, root.host.popupWidth, root.host.popupHeight); + loader.item.openFromControlCenter(bounds, root.host.screen); + } } } @@ -324,6 +315,8 @@ FocusScope { onAddWidget: widgetId => widgetModel.addWidget(widgetId) onResetToDefault: () => widgetModel.resetToDefault() onClearAll: () => widgetModel.clearAll() + onFinishRequested: root.host.editMode = false + onCancelRequested: root.cancelEdit() } } } @@ -334,7 +327,7 @@ FocusScope { z: 1 anchors.fill: parent section: root.host.expandedSection ?? "" - topInset: CcMetrics.sheetPadding + headerPane.height + Theme.spacingS + topInset: CcMetrics.sheetPadding minimumContentHeight: Math.max(0, root.gridHeight - CcMetrics.pageHeaderHeight) cornerRadii: root.surfaceCornerRadii coverage: Math.max(codecSelectorLoader.item?.presence ?? 0, portSelectorLoader.item?.presence ?? 0) @@ -387,6 +380,7 @@ FocusScope { z: CcMetrics.overlayZ active: false sourceComponent: WidgetConfigOverlay { + backdrop: contentFlickable onVisibleChanged: { if (visible) return; diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index 44c3c4845..6ddda492e 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -104,6 +104,12 @@ QtObject { readonly property Component colorPickerTile: Component { ColorPickerTile {} } + readonly property Component userCardTile: Component { + UserCardTile {} + } + readonly property Component quickActionsTile: Component { + QuickActionsTile {} + } readonly property Component pluginTile: Component { PluginTile {} } @@ -157,6 +163,10 @@ QtObject { return diskUsageTile; case "colorPicker": return colorPickerTile; + case "userCard": + return userCardTile; + case "quickActions": + return quickActionsTile; default: return null; } @@ -406,6 +416,6 @@ QtObject { } function clearAll() { - WidgetUtils.clearAll(); + WidgetUtils.clearAll(columns); } } diff --git a/quickshell/Modules/ControlCenter/Widgets/CcCardTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcCardTile.qml new file mode 100644 index 000000000..60f19e98f --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/CcCardTile.qml @@ -0,0 +1,36 @@ +import QtQuick +import qs.Common +import qs.Modules.ControlCenter + +Rectangle { + 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: columns <= 2 && rows === 1 + readonly property bool tall: height >= CcMetrics.gridRowUnit * 2 + readonly property bool narrow: width < CcMetrics.expandedTileMinWidth + readonly property real pad: tall && !narrow ? Theme.spacingM : Theme.spacingS + readonly property real bodyRadius: tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, height) + + width: parent?.width ?? 0 + height: CcMetrics.tileHeight + radius: bodyRadius + color: CcMetrics.tileInactiveColor + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + + Behavior on radius { + enabled: CcMetrics.animationsEnabled && !SettingsData.reduceMotion + NumberAnimation { + duration: Theme.expressiveDurations.expressiveEffects + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } +} diff --git a/quickshell/Modules/ControlCenter/Widgets/QuickActionsTile.qml b/quickshell/Modules/ControlCenter/Widgets/QuickActionsTile.qml new file mode 100644 index 000000000..8199f39b5 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/QuickActionsTile.qml @@ -0,0 +1,89 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets +import qs.Modules.ControlCenter +import "../utils/widgets.js" as WidgetUtils + +CcCardTile { + id: root + + readonly property bool editMode: host?.editMode ?? false + readonly property var actions: WidgetUtils.quickActions(widgetData).filter(action => action.enabled) + readonly property string role: WidgetUtils.quickActionRole(widgetData) + readonly property bool filled: widgetData?.background === true + readonly property bool powerAccent: widgetData?.powerAccent === true + readonly property color roleBg: role === "default" ? Theme.secondaryContainer : Theme.roleColor(role) + readonly property color roleFg: { + switch (role) { + case "primary": + return Theme.primaryText; + case "primaryContainer": + return Theme.onPrimaryContainer; + case "secondary": + case "surfaceVariant": + return Theme.surfaceText; + case "surfaceText": + return Theme.surface; + } + return Theme.onSecondaryContainer; + } + // Without a pill the icon carries the role itself, so container roles fall back to their accent. + readonly property color roleIcon: { + switch (role) { + case "primary": + case "primaryContainer": + return Theme.primary; + case "secondary": + return Theme.secondary; + } + return Theme.surfaceText; + } + // The edit toggle lights up in primary, so a primary role needs a different active colour. + readonly property color activeBg: role === "primary" ? Theme.primaryContainer : Theme.primary + readonly property color activeFg: role === "primary" ? Theme.onPrimaryContainer : Theme.primaryText + + function trigger(id) { + switch (id) { + case "lock": + host?.lockRequested(); + return; + case "power": + host?.powerRequested(); + return; + case "settings": + host?.settingsRequested(); + return; + case "edit": + host?.editRequested(); + return; + } + } + + Row { + anchors.centerIn: parent + spacing: CcMetrics.actionGap + + Repeater { + model: root.actions + + DankActionButton { + required property var modelData + readonly property string actionId: modelData.id + readonly property bool destructive: root.powerAccent && actionId === "power" + readonly property bool active: actionId === "edit" && root.editMode + readonly property color bg: active ? root.activeBg : destructive ? Theme.errorContainer : root.roleBg + readonly property color fg: active ? root.activeFg : destructive ? Theme.onErrorContainer : root.roleFg + + buttonSize: CcMetrics.actionSize + iconSize: CcMetrics.actionIconSize + iconName: WidgetUtils.quickActionIcon(actionId) + iconColor: root.filled || active ? fg : destructive ? Theme.error : root.roleIcon + backgroundColor: root.filled || active ? bg : "transparent" + Accessible.name: I18n.tr(WidgetUtils.quickActionLabel(actionId)) + onClicked: root.trigger(actionId) + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Widgets/UserCardTile.qml b/quickshell/Modules/ControlCenter/Widgets/UserCardTile.qml new file mode 100644 index 000000000..13040a1af --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/UserCardTile.qml @@ -0,0 +1,37 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.ControlCenter +import qs.Modules.DankDash + +CcCardTile { + id: root + + // Keys and defaults come from the dashboard user card so both surfaces expose the same toggles. + readonly property var options: DashRegistry.resolvedOptions("user", widgetData) + readonly property bool tapToClose: interactive && (host?.tapToClose ?? false) + + Accessible.role: tapToClose ? Accessible.Button : Accessible.Pane + Accessible.name: UserInfoService.username + + UserIdentity { + anchors.fill: parent + anchors.margins: root.pad + anchors.leftMargin: root.narrow ? root.pad : root.pad + Theme.spacingXS + live: root.live + options: root.options + narrow: root.width < CcMetrics.columnWidth * 2 + tall: root.tall + avatarSize: root.tall ? DashMetrics.avatarSizeHero : DashMetrics.avatarSize + contentColor: CcMetrics.tileInactiveContent + mutedColor: CcMetrics.tileInactiveSubtitle + } + + MouseArea { + anchors.fill: parent + enabled: root.tapToClose + acceptedButtons: Qt.LeftButton + onClicked: root.host?.headerTapped() + } +} diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index 54ca3ea70..e4927d6e3 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -2,12 +2,87 @@ .import qs.Modules.ControlCenter as ControlCenter .import "../../../Common/GridLayout.js" as GridLayout +var PINNED_IDS = ["userCard", "quickActions"]; +var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "userCard", "quickActions"]; +var QUICK_ACTIONS = [{ + "id": "lock", + "icon": "lock", + "label": "Lock" + }, { + "id": "power", + "icon": "power_settings_new", + "label": "Power" + }, { + "id": "settings", + "icon": "settings", + "label": "Settings" + }, { + "id": "edit", + "icon": "edit", + "label": "Edit" + }]; +var QUICK_ACTION_IDS = QUICK_ACTIONS.map(action => action.id); + function isSliderWidget(id) { return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider"; } -function sizeSpec(id, columns, rows = Infinity) { - return { +function isPinned(id) { + return PINNED_IDS.includes(id); +} + +function hasOptions(id) { + return OPTION_IDS.includes(id) || String(id ?? "").startsWith("plugin_"); +} + +// Saved order wins, unknown ids drop, missing ones append so new actions show up without a migration. +function quickActions(widgetData) { + const saved = Array.isArray(widgetData?.actions) ? widgetData.actions : []; + const out = saved.filter(action => QUICK_ACTION_IDS.includes(action?.id)).map(action => ({ + "id": action.id, + "enabled": action.id === "edit" || action.enabled !== false + })); + for (const id of QUICK_ACTION_IDS) { + if (!out.some(action => action.id === id)) + out.push({ + "id": id, + "enabled": true + }); + } + return out; +} + +var QUICK_ACTION_ROLES = ["primary", "primaryContainer", "secondary", "surfaceVariant", "surfaceText"]; + +function quickActionRole(widgetData) { + return QUICK_ACTION_ROLES.includes(widgetData?.buttonColor) ? widgetData.buttonColor : "default"; +} + +function quickActionIcon(id) { + return QUICK_ACTIONS.find(action => action.id === id)?.icon ?? ""; +} + +// Untranslated catalog term; callers wrap it in I18n.tr. +function quickActionLabel(id) { + return QUICK_ACTIONS.find(action => action.id === id)?.label ?? ""; +} + +function defaultWidget(id, columns) { + return Object.assign({ + "id": id, + "enabled": true + }, clampSize({ + "id": id + }, columns)); +} + +function pinnedWidgets(columns) { + return PINNED_IDS.map(id => defaultWidget(id, columns)); +} + +function sizeSpec(widget, columns, rows = Infinity) { + const id = widget?.id || ""; + const spec = { "w": 4, "h": 1, "minW": 1, @@ -16,10 +91,22 @@ function sizeSpec(id, columns, rows = Infinity) { "maxH": rows, "step": ControlCenter.CcMetrics.gridStep }; + switch (id) { + case "userCard": + spec.w = Number.isFinite(columns) ? Math.max(1, columns - 4) : 4; + spec.maxH = Math.min(2, rows); + break; + case "quickActions": + spec.minW = Math.min(ControlCenter.CcMetrics.quickActionsMinColumns(quickActions(widget).filter(action => action.enabled).length), columns); + spec.w = Math.max(spec.minW, 4); + spec.maxH = Math.min(2, rows); + break; + } + return spec; } function clampSize(widget, columns, rows = Infinity) { - const spec = sizeSpec(widget.id || "", columns, rows); + const spec = sizeSpec(widget, columns, rows); const size = { "w": GridLayout.dimension(widget.w, spec.minW, spec.maxW, spec.w, spec.step), "h": GridLayout.dimension(widget.h, spec.minH, spec.maxH, spec.h, spec.step) @@ -39,12 +126,7 @@ function clampSize(widget, columns, rows = Infinity) { function addWidget(widgetId, columns) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); - const widget = Object.assign({ - "id": widgetId, - "enabled": true - }, clampSize({ - "id": widgetId - }, columns)); + const widget = defaultWidget(widgetId, columns); if (widgetId === "diskUsage") { widget.instanceId = generateUniqueId(); @@ -67,7 +149,7 @@ function generateUniqueId() { function removeWidget(index) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); - if (index < 0 || index >= widgets.length) + if (index < 0 || index >= widgets.length || isPinned(widgets[index]?.id)) return; widgets.splice(index, 1); Common.SettingsData.set("controlCenterWidgets", widgets); @@ -79,14 +161,9 @@ function setLayout(widgets) { function resetToDefault(columns) { const ids = ["volumeSlider", "brightnessSlider", "wifi", "bluetooth", "audioOutput", "audioInput", "nightMode", "darkMode"]; - Common.SettingsData.set("controlCenterWidgets", ids.map(id => Object.assign({ - "id": id, - "enabled": true - }, clampSize({ - "id": id - }, columns)))); + Common.SettingsData.set("controlCenterWidgets", pinnedWidgets(columns).concat(ids.map(id => defaultWidget(id, columns)))); } -function clearAll() { - Common.SettingsData.set("controlCenterWidgets", []); +function clearAll(columns) { + Common.SettingsData.set("controlCenterWidgets", pinnedWidgets(columns)); } diff --git a/quickshell/Modules/DankDash/DashMetrics.qml b/quickshell/Modules/DankDash/DashMetrics.qml index de4d48680..a71e46995 100644 --- a/quickshell/Modules/DankDash/DashMetrics.qml +++ b/quickshell/Modules/DankDash/DashMetrics.qml @@ -140,10 +140,6 @@ Singleton { readonly property real avatarSize: Theme.buttonHeightM readonly property real avatarSizeHero: 72 - readonly property real userBadgeSize: 26 - readonly property real userBadgeOverhang: 0.2 - readonly property real userBadgeIconSize: Theme.iconSizeSmall - readonly property real userChipHeight: 28 readonly property int historyLength: 60 readonly property real tileTrendRatio: 0.45 diff --git a/quickshell/Modules/DankDash/DashRegistry.qml b/quickshell/Modules/DankDash/DashRegistry.qml index 8162b914f..7cc10f984 100644 --- a/quickshell/Modules/DankDash/DashRegistry.qml +++ b/quickshell/Modules/DankDash/DashRegistry.qml @@ -488,9 +488,8 @@ Singleton { return optionSpecs(id).some(spec => spec.key in stored); } - function resolvedOptions(id) { + function resolvedOptions(id, stored = storedOptions(id)) { const out = {}; - const stored = storedOptions(id); for (const spec of optionSpecs(id)) out[spec.key] = optionValue(spec, stored[spec.key]); return out; diff --git a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml index 57989dcaf..2b9488d7b 100644 --- a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml +++ b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml @@ -1,158 +1,27 @@ import QtQuick import qs.Common -import qs.Services import qs.Widgets import qs.Modules.DankDash Card { id: root - property bool live: Window.window?.visible ?? false - property bool dgopRefHeld: false - readonly property bool narrow: width < DashMetrics.gridRowUnit * 2 readonly property bool tall: height >= DashMetrics.gridRowUnit * 2 - readonly property bool showHostname: options.hostname !== false && UserInfoService.hostname !== "" - readonly property bool showCompositor: options.compositor !== false && compositorName !== "" - readonly property bool showUptime: options.uptime !== false - readonly property bool showBadge: options.badge !== false - readonly property real avatarSize: Math.min(tall ? DashMetrics.avatarSizeHero : DashMetrics.avatarSize, height - pad * 2, width - pad * 2) - readonly property color badgeColor: tinted ? contentColor : Theme.primaryContainer - readonly property color badgeContentColor: tinted ? containerColor : Theme.onPrimaryContainer - readonly property string compositorName: CompositorService.displayName - readonly property string uptimeText: { - const prefix = I18n.tr("up", "uptime prefix, e.g. 'up 4h 2m'"); - return DgopService.shortUptime ? prefix + DgopService.shortUptime.slice(2) : prefix; - } - readonly property string avatarSource: { - if (PortalService.profileImage === "") - return ""; - if (PortalService.profileImage.startsWith("/")) - return "file://" + PortalService.profileImage; - return PortalService.profileImage; - } entryId: "user" - function syncDgopRef(wanted) { - if (wanted === dgopRefHeld) - return; - dgopRefHeld = wanted; - if (wanted) { - DgopService.addRef("system"); - return; - } - DgopService.removeRef("system"); - } - - onLiveChanged: syncDgopRef(live && showUptime) - onShowUptimeChanged: syncDgopRef(live && showUptime) - Component.onCompleted: syncDgopRef(live && showUptime) - Component.onDestruction: syncDgopRef(false) - - Item { - id: avatarBox - - x: root.narrow ? (parent.width - width) / 2 : 0 - anchors.verticalCenter: parent.verticalCenter - width: root.avatarSize - height: root.avatarSize - - DankCircularImage { - anchors.fill: parent - imageSource: root.avatarSource - fallbackIcon: "material:person" - } - - DankMaterialShape { - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.rightMargin: -DashMetrics.userBadgeSize * DashMetrics.userBadgeOverhang - anchors.bottomMargin: -DashMetrics.userBadgeSize * DashMetrics.userBadgeOverhang - width: DashMetrics.userBadgeSize - height: DashMetrics.userBadgeSize - shape: "gem" - color: root.badgeColor - visible: root.showBadge - - SystemLogo { - anchors.centerIn: parent - width: DashMetrics.userBadgeIconSize - height: DashMetrics.userBadgeIconSize - colorOverride: root.badgeContentColor - } - } - } - - Column { - anchors.left: avatarBox.right - anchors.leftMargin: Theme.spacingM - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - visible: !root.narrow - - StyledText { - width: parent.width - text: root.showHostname ? UserInfoService.username + " @ " + UserInfoService.hostname : UserInfoService.username - font.pixelSize: root.tall ? Theme.fontSizeXLarge : Theme.fontSizeLarge - font.weight: Theme.fontWeightMedium - color: root.contentColor - elide: Text.ElideRight - } - - Row { - id: detailRow - - width: parent.width - height: DashMetrics.userChipHeight - spacing: Theme.spacingS - - Row { - id: compositorDetails - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - visible: root.showCompositor - - DankIcon { - anchors.verticalCenter: parent.verticalCenter - name: "select_window" - size: Theme.iconSizeSmall - color: root.mutedColor - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - text: root.compositorName - font.pixelSize: Theme.fontSizeSmall - color: root.mutedColor - } - } - - Row { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - (compositorDetails.visible ? compositorDetails.width + parent.spacing : 0) - spacing: Theme.spacingXS - visible: root.showUptime && DgopService.shortUptime !== "" - - DankIcon { - id: uptimeIcon - anchors.verticalCenter: parent.verticalCenter - name: "schedule" - size: Theme.iconSizeSmall - color: root.mutedColor - } - - StyledText { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - uptimeIcon.width - parent.spacing - text: root.uptimeText - font.pixelSize: Theme.fontSizeSmall - color: root.mutedColor - wrapMode: Text.NoWrap - elide: Text.ElideRight - } - } - } + UserIdentity { + anchors.fill: parent + live: root.Window.window?.visible ?? false + options: root.options + narrow: root.narrow + tall: root.tall + avatarSize: root.tall ? DashMetrics.avatarSizeHero : DashMetrics.avatarSize + contentColor: root.contentColor + mutedColor: root.mutedColor + badgeColor: root.tinted ? root.contentColor : Theme.primaryContainer + badgeContentColor: root.tinted ? root.containerColor : Theme.onPrimaryContainer + ringColor: root.tinted ? root.containerColor : Theme.avatarRingColor } } diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index 0d0eb7337..26a27c7c8 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -833,6 +833,8 @@ Item { DankCircularImage { anchors.fill: parent + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor imageSource: { const displayUser = GreeterState.username || root.pickerThemeUsername; if (displayUser) { diff --git a/quickshell/Modules/Greetd/GreeterUserPicker.qml b/quickshell/Modules/Greetd/GreeterUserPicker.qml index b2b166681..17bc5db86 100644 --- a/quickshell/Modules/Greetd/GreeterUserPicker.qml +++ b/quickshell/Modules/Greetd/GreeterUserPicker.qml @@ -134,6 +134,8 @@ Item { DankCircularImage { anchors.fill: parent + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor imageSource: root.profileImageSource(userRow.modelData.username) fallbackIcon: "person" } diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 091263b98..44c6e4544 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -600,6 +600,8 @@ Item { DankCircularImage { Layout.preferredWidth: LockMetrics.avatarSize + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor Layout.preferredHeight: LockMetrics.fieldHeight imageSource: { if (PortalService.profileImage === "") diff --git a/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml b/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml index 0075809a9..dbd926a3d 100644 --- a/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml +++ b/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml @@ -15,7 +15,13 @@ Column { } readonly property var barStore: ({ "isDefault": keys => keys.every(key => (root.barConfig?.[key] ?? SettingsData.barConfigDefault(key)) === SettingsData.barConfigDefault(key)), - "resetToDefault": keys => SettingsData.updateBarConfig(root.page.barId, Object.fromEntries(keys.map(key => [key, SettingsData.barConfigDefault(key)]))) + "resetToDefault": keys => { + // QV4 has no Object.fromEntries. + const patch = {}; + for (const key of keys) + patch[key] = SettingsData.barConfigDefault(key); + SettingsData.updateBarConfig(root.page.barId, patch); + } }) width: parent?.width ?? 0 diff --git a/quickshell/Modules/Settings/UserAccountsTab.qml b/quickshell/Modules/Settings/UserAccountsTab.qml index 462ee9f5a..50ba450d9 100644 --- a/quickshell/Modules/Settings/UserAccountsTab.qml +++ b/quickshell/Modules/Settings/UserAccountsTab.qml @@ -24,6 +24,8 @@ Column { leading: DankCircularImage { width: SettingsMetrics.avatarSize + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor height: width imageSource: PortalService.profileImage fallbackIcon: "person" @@ -42,5 +44,46 @@ Column { onClicked: PortalService.setProfileImage("") } } + + SettingsDropdownRow { + id: ringRow + + readonly property var rings: [{ + "value": "none", + "label": I18n.tr("None") + }, { + "value": "outline", + "label": I18n.tr("Outline") + }, { + "value": "primary", + "label": I18n.tr("Primary") + }, { + "value": "secondary", + "label": I18n.tr("Secondary") + }, { + "value": "tertiary", + "label": I18n.tr("Tertiary") + }] + + settingKey: "avatarRing" + tags: ["user", "account", "profile", "avatar", "ring", "border", "color"] + text: I18n.tr("Avatar ring") + options: rings.map(ring => ring.label) + optionColorMap: { + const map = {}; + for (const ring of rings) { + if (ring.value === "none") + continue; + map[ring.label] = ring.value === "outline" ? Theme.surfaceVariant : Theme.roleColor(ring.value); + } + return map; + } + currentValue: rings.find(ring => ring.value === SettingsData.avatarRing)?.label ?? "" + onValueChanged: value => { + const ring = rings.find(ring => ring.label === value); + if (ring) + SettingsData.set("avatarRing", ring.value); + } + } } } diff --git a/quickshell/Widgets/DankGridEditChrome.qml b/quickshell/Widgets/DankGridEditChrome.qml index f328cd8e3..36a9ef21b 100644 --- a/quickshell/Widgets/DankGridEditChrome.qml +++ b/quickshell/Widgets/DankGridEditChrome.qml @@ -24,6 +24,7 @@ Item { readonly property real touchTargetSize: Math.max(Theme.minimumTouchTargetSize, buttonSize) readonly property real contentInset: touchTargetSize / 2 readonly property bool showOptionsButton: hasOptions && width - contentInset * 2 >= touchTargetSize * (horizontalResize ? 3 : 2) + readonly property int chromeButtons: (removable ? 1 : 0) + (showOptionsButton ? 1 : 0) readonly property rect hitBounds: Qt.rect(contentInset - hitOverflow, contentInset - hitOverflow, width - (contentInset - hitOverflow) * 2, height - (contentInset - hitOverflow) * 2) signal removeRequested @@ -56,13 +57,13 @@ Item { 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.showOptionsButton ? root.touchTargetSize : 0) + width: root.buttonSize + root.touchTargetSize * Math.max(0, root.chromeButtons - 1) height: root.buttonSize radius: Theme.fullRadius(width, height) color: Theme.chipSurface border.color: Theme.primary border.width: Theme.outlineWidth - visible: root.removable + visible: root.chromeButtons > 0 } DankActionButton { @@ -100,7 +101,9 @@ Item { DankActionButton { id: optionsButton - x: I18n.isRtl ? root.width - root.contentInset - root.touchTargetSize - width : root.contentInset + root.touchTargetSize + readonly property real slotOffset: root.removable ? root.touchTargetSize : 0 + + x: I18n.isRtl ? root.width - root.contentInset - slotOffset - width : root.contentInset + slotOffset y: 0 width: root.touchTargetSize height: root.touchTargetSize diff --git a/quickshell/Widgets/DankPopoutHost.qml b/quickshell/Widgets/DankPopoutHost.qml index 07012ccf2..9d27c2de1 100644 --- a/quickshell/Widgets/DankPopoutHost.qml +++ b/quickshell/Widgets/DankPopoutHost.qml @@ -1834,12 +1834,13 @@ Item { visible: !root._surfaceSwitching readonly property bool shouldClip: Theme.isDirectionalEffect || root.usesConnectedSurfaceChrome readonly property real clipOversize: 1000 + // inputMargin is only non-zero while a grid is being edited; let its chrome cross the connected edge instead of clipping it. readonly property real connectedClipAllowance: { if (!root.usesConnectedSurfaceChrome) return 0; if (root.frameOwnsConnectedChrome) - return 0; - return -Theme.connectedCornerRadius; + return root.inputMargin; + return -Theme.connectedCornerRadius + root.inputMargin; } clip: shouldClip diff --git a/quickshell/Widgets/UserIdentity.qml b/quickshell/Widgets/UserIdentity.qml new file mode 100644 index 000000000..1b063696f --- /dev/null +++ b/quickshell/Widgets/UserIdentity.qml @@ -0,0 +1,156 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property bool live: Window.window?.visible ?? false + property var options: ({}) + property real avatarSize: Theme.buttonHeightM + property bool narrow: false + property bool tall: false + property color contentColor: Theme.surfaceText + property color mutedColor: Theme.surfaceVariantText + property color badgeColor: Theme.primaryContainer + property color badgeContentColor: Theme.onPrimaryContainer + property color ringColor: Theme.avatarRingColor + + readonly property real avatarDiameter: Math.min(avatarSize, width, height) + readonly property real badgeSize: Math.round(Math.max(Theme.iconSizeSmall + Theme.spacingXS, avatarDiameter / 3)) + readonly property real badgeOverhang: 0.15 + readonly property real badgeIconRatio: 0.6 + readonly property bool showHostname: options.hostname !== false && UserInfoService.hostname !== "" + readonly property bool showCompositor: options.compositor !== false && compositorName !== "" + readonly property bool showUptime: options.uptime !== false + readonly property bool showBadge: options.badge !== false + readonly property string compositorName: CompositorService.displayName + readonly property string uptimeText: { + const prefix = I18n.tr("up", "uptime prefix, e.g. 'up 4h 2m'"); + return DgopService.shortUptime ? prefix + DgopService.shortUptime.slice(2) : prefix; + } + readonly property string avatarSource: { + if (PortalService.profileImage === "") + return ""; + if (PortalService.profileImage.startsWith("/")) + return "file://" + PortalService.profileImage; + return PortalService.profileImage; + } + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + Ref { + service: DgopService + modules: ["system"] + active: root.live && root.showUptime + } + + Item { + id: avatarBox + + x: root.narrow ? (parent.width - width) / 2 : 0 + anchors.verticalCenter: parent.verticalCenter + width: root.avatarDiameter + height: root.avatarDiameter + + DankCircularImage { + anchors.fill: parent + imageSource: root.avatarSource + fallbackIcon: "material:person" + ringWidth: Theme.avatarRingWidth + ringColor: root.ringColor + } + + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: -root.badgeSize * root.badgeOverhang + anchors.bottomMargin: -root.badgeSize * root.badgeOverhang + width: root.badgeSize + height: root.badgeSize + radius: Theme.fullRadius(width, height) + color: root.badgeColor + border.width: Theme.avatarRingWidth + border.color: root.ringColor + visible: root.showBadge + + SystemLogo { + anchors.centerIn: parent + width: Math.round(parent.width * root.badgeIconRatio) + height: width + colorOverride: String(root.badgeContentColor) + } + } + } + + Column { + anchors.left: avatarBox.right + anchors.leftMargin: Theme.spacingM + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !root.narrow + + StyledText { + width: parent.width + text: root.showHostname ? UserInfoService.username + " @ " + UserInfoService.hostname : UserInfoService.username + font.pixelSize: root.tall ? Theme.fontSizeXLarge : Theme.fontSizeLarge + font.weight: Theme.fontWeightMedium + color: root.contentColor + elide: Text.ElideRight + } + + // Tall cards have room for a second line, so uptime wraps there and elides everywhere else. + Flow { + width: parent.width + spacing: Theme.spacingS + + Row { + id: compositorDetails + spacing: Theme.spacingXS + visible: root.showCompositor + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "select_window" + size: Theme.iconSizeSmall + color: root.mutedColor + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.compositorName + font.pixelSize: Theme.fontSizeSmall + color: root.mutedColor + } + } + + Row { + readonly property real remaining: parent.width - (compositorDetails.visible ? compositorDetails.width + parent.spacing : 0) + + width: root.tall ? Math.min(implicitWidth, parent.width) : remaining + spacing: Theme.spacingXS + visible: root.showUptime && DgopService.shortUptime !== "" + + DankIcon { + id: uptimeIcon + anchors.verticalCenter: parent.verticalCenter + name: "schedule" + size: Theme.iconSizeSmall + color: root.mutedColor + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + width: root.tall ? implicitWidth : Math.max(0, parent.width - uptimeIcon.width - parent.spacing) + text: root.uptimeText + font.pixelSize: Theme.fontSizeSmall + color: root.mutedColor + wrapMode: Text.NoWrap + elide: Text.ElideRight + } + } + } + } +} diff --git a/quickshell/tests/dash-grid.test.mjs b/quickshell/tests/dash-grid.test.mjs index 1d132d7ec..cd032d0a4 100644 --- a/quickshell/tests/dash-grid.test.mjs +++ b/quickshell/tests/dash-grid.test.mjs @@ -59,6 +59,14 @@ test("cards without a cell take the first free one and cells clamp to the column assert.deepEqual(plain(grid.packCells([{ id: "a", w: 1, h: 1, col: 0.5, row: 1.5 }], [0], 4, null, 0.5).cells[0]), { col: 0.5, row: 1.5, cols: 1, rows: 1 }); }); +test("gravity pulls positioned cards up into holes without jumping over blockers", () => { + const board = [{ id: "a", w: 2, h: 1, col: 0, row: 0 }, { id: "b", w: 2, h: 1, col: 2, row: 0 }, { id: "c", w: 2, h: 1, col: 0, row: 2 }, { id: "d", w: 2, h: 1, col: 2, row: 3 }]; + assert.deepEqual(plain(grid.packCells(board, [0, 1, 2, 3], 6).cells.map(c => [c.col, c.row])), [[0, 0], [2, 0], [0, 2], [2, 3]]); + assert.deepEqual(plain(grid.packCells(board, [0, 1, 2, 3], 6, null, 1, true).cells.map(c => [c.col, c.row])), [[0, 0], [2, 0], [0, 1], [2, 1]]); + const blocked = [{ id: "a", w: 2, h: 1, col: 0, row: 1 }, { id: "b", w: 2, h: 1, col: 0, row: 3 }]; + assert.deepEqual(plain(grid.packCells(blocked, [1, 0], 6, null, 1, true).cells.map(c => [c.col, c.row])), [[0, 0], [0, 1]]); +}); + test("a dragged tile snaps to the cell under its corner", () => { const layout = grid.packCards(cards, order, 6, 600, 0, 100, false); assert.deepEqual(plain(grid.cellAt(layout, 240, 160, 2, 1)), { col: 2, row: 2 }); diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml index 0f0e923e4..3c61af31b 100644 --- a/quickshell/tests/qml/control-center-sizes.qml +++ b/quickshell/tests/qml/control-center-sizes.qml @@ -201,10 +201,14 @@ ShellRoot { check(Math.abs(grid.slotLayout.slots[1].w - grid.slotLayout.slots[1].h) < 0.01, "host inset preserves square geometry"); grid.availableHeight = CcMetrics.gridRowUnit * 5 + CcMetrics.gridGap * 4; check(grid.slotLayout.slots[0].rows <= 5, "tile height fits the available screen"); - grid.beginDrag(0); - grid.updateDragTarget(0, grid.cellWidth); + grid.beginDrag(1); + grid.updateDragTarget(0, 0); grid.endDrag(); - check(SettingsData.controlCenterWidgets[0].w === 10 && SettingsData.controlCenterWidgets[0].h === 12 && SettingsData.controlCenterWidgets[0].row === 1 && SettingsData.controlCenterWidgets[1].row === grid.maximumRows + 1, "moving on a smaller screen preserves saved spans and pushes the collided tile " + JSON.stringify(SettingsData.controlCenterWidgets)); + check(SettingsData.controlCenterWidgets[0].w === 10 && SettingsData.controlCenterWidgets[0].h === 12 && SettingsData.controlCenterWidgets[0].row === 2 && SettingsData.controlCenterWidgets[1].row === 0, "moving on a smaller screen preserves saved spans and pushes the collided tile " + JSON.stringify(SettingsData.controlCenterWidgets)); + grid.beginDrag(0); + grid.updateDragTarget(0, grid.cellWidth * 4); + grid.endDrag(); + check(SettingsData.controlCenterWidgets[0].row === 2, "gravity closes the gap a drop below free space would leave " + JSON.stringify(SettingsData.controlCenterWidgets)); grid.previewSize(0, { w: 6 }); diff --git a/quickshell/tests/settings-cc-grid.test.mjs b/quickshell/tests/settings-cc-grid.test.mjs index 8d0d9be8e..0d8850014 100644 --- a/quickshell/tests/settings-cc-grid.test.mjs +++ b/quickshell/tests/settings-cc-grid.test.mjs @@ -25,3 +25,10 @@ test("sparse settings migrate without inventing grid keys", () => { assert.equal(sparse.controlCenterColumns, undefined); assert.equal(sparse.controlCenterWidgets, undefined); }); + +test("pinned header tiles are prepended once to a saved layout", () => { + const migrated = store.migrateToVersion({ configVersion: 31, controlCenterWidgets: [{ id: "wifi", w: 4, h: 1, col: 0, row: 0 }, { id: "quickActions", w: 4, h: 1 }] }, 32); + assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)).map(w => w.id), ["userCard", "wifi", "quickActions"]); + assert.equal(migrated.configVersion, 32); + assert.equal(store.migrateToVersion({ configVersion: 31 }, 32).controlCenterWidgets, undefined); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index cb42f47f2..d56ab9927 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -16188,6 +16188,24 @@ ], "parentLabel": "Dock" }, + { + "section": "avatarRing", + "label": "Avatar ring", + "tabIndex": 60, + "category": "Users & accounts", + "keywords": [ + "account", + "accounts", + "avatar", + "border", + "color", + "profile", + "ring", + "user", + "users" + ], + "page": "user_accounts" + }, { "section": "userProfile", "label": "Profile",