diff --git a/dank-qml-common b/dank-qml-common index 7092ee89b..4bd517101 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 7092ee89ba7bab4afe74a215f31b999ae1546b9f +Subproject commit 4bd5171013a256ebf66b7720564a58411172843c diff --git a/flake.lock b/flake.lock index a9159cfd2..538be21e4 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "dank-qml-common": { "flake": false, "locked": { - "lastModified": 1790691843, - "narHash": "sha256-ZoqLNYx8vgjFV18mI3l8vvkG9SufqZD2qr7q/j+aCns=", + "lastModified": 1790707225, + "narHash": "sha256-EIM8uHjiGEhXQnwWkpCLPsM3khAVLOq8amaWVLlvkLg=", "owner": "AvengeMedia", "repo": "dank-qml-common", - "rev": "7092ee89ba7bab4afe74a215f31b999ae1546b9f", + "rev": "4bd5171013a256ebf66b7720564a58411172843c", "type": "github" }, "original": { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 43af2b4a4..c64e00635 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: 34 + readonly property int settingsConfigVersion: 35 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 2d1d33980..25e2349c5 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -434,25 +434,37 @@ var LOCAL_SPEC = { controlCenterWidgets: { def: [ { - id: "userCard", + id: "user", enabled: true, - w: 6.5, - h: 1.5 + w: 5, + h: 1 }, { - id: "quickActions", + id: "settings", enabled: true, - w: 1.5, - h: 1.5 + w: 1, + h: 1 }, { - id: "volumeSlider", + id: "lock", + enabled: true, + w: 1, + h: 1 + }, + { + id: "power", + enabled: true, + w: 1, + h: 1 + }, + { + id: "brightnessSlider", enabled: true, w: 4, h: 1 }, { - id: "brightnessSlider", + id: "volumeSlider", enabled: true, w: 4, h: 1 @@ -481,14 +493,38 @@ var LOCAL_SPEC = { w: 4, h: 1 }, + { + id: "darkMode", + enabled: true, + w: 2, + h: 1 + }, { id: "nightMode", enabled: true, + w: 2, + h: 1 + }, + { + id: "doNotDisturb", + enabled: true, + w: 2, + h: 1 + }, + { + id: "idleInhibitor", + enabled: true, + w: 2, + h: 1 + }, + { + id: "battery", + enabled: true, w: 4, h: 1 }, { - id: "darkMode", + id: "diskUsage", enabled: true, w: 4, h: 1 diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index ce654163e..3ac0a19f1 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -15,8 +15,9 @@ 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_LEGACY_HEADER_IDS = ["userCard", "quickActions"]; -var CC_HEADER_OPTION_KEYS = ["hostname", "compositor", "uptime", "badge", "actions", "background", "powerAccent"]; +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 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"]; @@ -700,79 +701,42 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 31; } - if (currentVersion < 33 && targetVersion >= 33) { + if (currentVersion < 35 && targetVersion >= 35) { if (Array.isArray(settings.controlCenterWidgets)) - settings.controlCenterWidgets = migrateControlCenterHeader(settings.controlCenterWidgets); - settings.configVersion = 33; - } - - if (currentVersion < 34 && targetVersion >= 34) { - if (Array.isArray(settings.controlCenterWidgets)) - settings.controlCenterWidgets = splitControlCenterHeader(settings.controlCenterWidgets, settings.controlCenterColumns ?? SpecModule.SPEC.controlCenterColumns.def); - settings.configVersion = 34; + settings.controlCenterWidgets = migrateControlCenterHeader(settings.controlCenterWidgets, currentVersion < 33, settings.controlCenterColumns ?? 8); + settings.configVersion = 35; } return settings; } -function migrateControlCenterHeader(widgets) { - const rest = widgets.filter(widget => !CC_LEGACY_HEADER_IDS.includes(widget?.id)); - if (rest.some(widget => widget?.id === "header")) - return rest; - const header = { - id: "header", +function migrateControlCenterHeader(widgets, fixedHeader, columns) { + const header = widgets.filter(widget => CC_HEADER_IDS.includes(widget?.id)); + if (!fixedHeader && header.length === 0) + return widgets; + const rest = widgets.filter(widget => !CC_HEADER_IDS.includes(widget?.id)); + const actions = header.find(widget => Array.isArray(widget?.actions))?.actions ?? []; + const wanted = id => !rest.some(widget => widget?.id === id) && actions.find(action => action?.id === id)?.enabled !== false; + const tiles = CC_ACTION_TILE_IDS.filter(wanted).map(id => ({ + id: id, + enabled: true, + w: 1, + h: 1 + })); + 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")) + return tiles.concat(rest); + const user = { + id: "user", enabled: true, - w: 8, + w: Math.max(1, columns - tiles.length), h: 1 }; - for (const legacy of widgets) { - if (!CC_LEGACY_HEADER_IDS.includes(legacy?.id)) - continue; - for (const key of CC_HEADER_OPTION_KEYS) { - if (key in legacy) - header[key] = legacy[key]; - } + for (const key of CC_USER_KEYS) { + if (key in identity) + user[key] = identity[key]; } - return [header].concat(rest); -} - -function splitControlCenterHeader(widgets, columns) { - const userDefaults = SpecModule.SPEC.controlCenterWidgets.def.find(widget => widget.id === "userCard"); - const actionDefaults = SpecModule.SPEC.controlCenterWidgets.def.find(widget => widget.id === "quickActions"); - const result = []; - for (const widget of widgets) { - if (widget?.id !== "header") { - result.push(widget); - continue; - } - const actions = Object.assign({}, actionDefaults); - const user = Object.assign({}, userDefaults, { - w: Math.max(1, Math.min(Number(widget.w) || columns, columns) - actions.w), - h: Math.max(userDefaults.h, Number(widget.h) || userDefaults.h) - }); - for (const key of ["hostname", "compositor", "uptime", "badge", "background"]) { - if (key in widget) - user[key] = widget[key]; - } - for (const key of ["actions", "powerAccent"]) { - if (key in widget) - actions[key] = widget[key]; - } - if (Number.isFinite(widget.row)) { - user.row = widget.row; - actions.row = widget.row; - } - if (Number.isFinite(widget.col)) { - user.col = widget.col; - actions.col = widget.col + (widget.showUser === false ? 0 : user.w); - } - if (widget.showUser !== false) - result.push(user); - result.push(actions); - } - if (!result.some(widget => widget?.id === "quickActions")) - result.unshift(Object.assign({}, actionDefaults)); - return result; + return [user].concat(tiles, rest); } function migrateBarWidgetGlobals(settings) { diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 5a7440cc7..9868be072 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -246,7 +246,7 @@ Item { osdStartupTimer.start(); if (SettingsData.controlCenterWidgets.some(widget => widget.id === "diskUsage" && widget.enabled !== false)) DgopService.initializeDiskMounts(); - if (SettingsData.controlCenterWidgets.some(widget => widget.id === "userCard" && widget.enabled !== false && widget.uptime !== false)) + if (SettingsData.controlCenterWidgets.some(widget => widget.id === "user" && widget.enabled !== false && widget.uptime !== false)) DgopService.dgopAvailable; // These are dummy references just to trigger the singletons onCompleted to trigger diff --git a/quickshell/Modules/ControlCenter/CcMetrics.qml b/quickshell/Modules/ControlCenter/CcMetrics.qml index eab740756..f7a213a3a 100644 --- a/quickshell/Modules/ControlCenter/CcMetrics.qml +++ b/quickshell/Modules/ControlCenter/CcMetrics.qml @@ -14,7 +14,6 @@ Singleton { readonly property real gridStep: 0.5 property int columnPreview: 0 readonly property int gridColumns: columnPreview > 0 ? columnPreview : clampColumns(SettingsData.controlCenterColumns) - readonly property real sheetWidth: sheetWidthFor(gridColumns) function clampColumns(value) { const columns = Math.round(Number(value)); @@ -45,9 +44,7 @@ Singleton { readonly property real sliderRowHeight: Theme.minimumTouchTargetSize readonly property real stripTrackHeight: 32 readonly property real stripHandleHeight: stripTrackHeight + Theme.sliderHandleGap * 2 - readonly property real stripIconSize: Theme.iconSize readonly property real gridGap: Theme.spacingS - readonly property real tilePaddingH: Theme.spacingL readonly property real iconScale: SettingsData.controlCenterIconScale readonly property real tileIconSize: Theme.iconSizeLarge * iconScale readonly property real iconBoxSize: Theme.minimumTouchTargetSize * iconScale @@ -57,19 +54,11 @@ Singleton { readonly property real iconBoxIconSize: Theme.iconSize * iconScale readonly property real tallMeterThickness: 28 readonly property real tileTextGap: Theme.spacingM - readonly property real actionSize: Theme.minimumTouchTargetSize - readonly property real actionIconSize: Theme.iconSizeMedium - readonly property real actionGap: Theme.spacingXS - readonly property real headerHeight: actionSize * 2 + actionGap - - function actionSpan(count) { - return Math.ceil((actionSize * count + actionGap * Math.max(0, count - 1) + gridGap) / (columnWidth + gridGap) / gridStep) * gridStep; - } - - function actionCapacity(span) { - const size = span * (columnWidth + gridGap) - gridGap; - return Math.max(1, Math.floor((size + actionGap) / (actionSize + actionGap))); - } + 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 shapeButtonSize: Theme.iconSize readonly property real detailDialogInset: Theme.spacingL readonly property real detailDialogPadding: Theme.spacingS @@ -98,6 +87,9 @@ Singleton { readonly property real dialogWidth: 320 readonly property real libraryPanelWidth: 400 readonly property real libraryPanelHeight: 400 + readonly property real widgetSheetMinHeight: libraryPanelHeight + readonly property real previewMinWidth: columnWidth * 1.5 + readonly property real previewTileHeight: Theme.minimumTouchTargetSize readonly property real configMenuWidth: 260 readonly property real vpnPopoutListHeight: 200 readonly property real headerDropdownWidth: 120 @@ -144,6 +136,7 @@ Singleton { case "audioOutput": case "audioInput": case "diskUsage": + case "runningApps": return detailHeightList; default: return detailHeightDefault; diff --git a/quickshell/Modules/ControlCenter/Components/CcDetailPage.qml b/quickshell/Modules/ControlCenter/Components/CcDetailPage.qml index ba476b6a8..c45603a9b 100644 --- a/quickshell/Modules/ControlCenter/Components/CcDetailPage.qml +++ b/quickshell/Modules/ControlCenter/Components/CcDetailPage.qml @@ -22,10 +22,12 @@ FocusScope { property real minimumContentHeight: 0 property vector4d cornerRadii: Qt.vector4d(Theme.windowRadius, Theme.windowRadius, Theme.windowRadius, Theme.windowRadius) property real coverage: 0 + property var runningToplevels: [] signal dismissed signal backRequested signal collapseRequested + signal closeRequested signal codecSelectorRequested(var device) signal portSelectorRequested(var node) @@ -119,6 +121,8 @@ FocusScope { return diskUsageComponent; case "brightnessSlider": return brightnessComponent; + case "runningApps": + return runningAppsComponent; } if (sectionId.startsWith("builtin_") || sectionId.startsWith("plugin_")) return pluginComponent; @@ -338,6 +342,10 @@ FocusScope { function onDismissRequested() { root.backRequested(); } + + function onCloseRequested() { + root.closeRequested(); + } } Component { @@ -399,6 +407,13 @@ FocusScope { } } + Component { + id: runningAppsComponent + RunningAppsDetail { + toplevels: root.runningToplevels + } + } + Component { id: pluginComponent PluginDetailShell { diff --git a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml index 4a44cd6f1..dcc4b54b8 100644 --- a/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml +++ b/quickshell/Modules/ControlCenter/Components/CcEditChrome.qml @@ -12,7 +12,6 @@ DankGridEditChrome { signal configRequested(var anchor) 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/CcFooter.qml b/quickshell/Modules/ControlCenter/Components/CcFooter.qml new file mode 100644 index 000000000..d2cfe8c33 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Components/CcFooter.qml @@ -0,0 +1,213 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Modules.ControlCenter +import qs.Modules.ControlCenter.Widgets +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property bool editMode: false + property var toplevels: [] + property bool resetArmed: false + + signal runningAppsRequested + signal addWidgetRequested + signal resetRequested + 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) + + implicitHeight: CcMetrics.footerHeight + + function cancelConfirmation() { + resetArmed = false; + } + + 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: Theme.surfaceContainerHighest + + 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 + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + visible: root.editMode + + DankButton { + id: addButton + buttonHeight: CcMetrics.footerHeight + maximumWidth: root.leadingWidth + iconName: "add" + text: I18n.tr("Add widget") + backgroundColor: Theme.secondaryContainer + textColor: Theme.onSecondaryContainer + onClicked: { + root.cancelConfirmation(); + root.addWidgetRequested(); + } + } + + DankButton { + buttonHeight: CcMetrics.footerHeight + maximumWidth: Math.max(0, root.leadingWidth - addButton.width - editActions.spacing) + readonly property string label: I18n.tr("Defaults", "noun, control center edit button restoring the default layout") + readonly property string confirmLabel: I18n.tr("Confirm") + + iconName: "settings_backup_restore" + text: root.resetArmed ? confirmLabel : label + reserveText: root.resetArmed ? label : confirmLabel + backgroundColor: root.resetArmed ? Theme.primary : Theme.surfaceContainerHighest + textColor: root.resetArmed ? Theme.onPrimary : Theme.onSurface + onClicked: { + if (!root.resetArmed) { + root.resetArmed = true; + return; + } + root.resetArmed = false; + root.resetRequested(); + } + } + } + + Row { + id: trailing + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankActionButton { + buttonSize: CcMetrics.footerHeight + iconName: "close" + iconSize: CcMetrics.iconBoxIconSize + iconColor: CcMetrics.tileInactiveContent + backgroundColor: CcMetrics.tileInactiveColor + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + tooltipText: I18n.tr("Discard") + visible: root.editMode + onClicked: root.cancelRequested() + } + + DankActionButton { + buttonSize: CcMetrics.footerHeight + iconName: "check" + iconSize: CcMetrics.iconBoxIconSize + iconColor: Theme.onPrimary + backgroundColor: Theme.primary + tooltipText: I18n.tr("Save") + visible: root.editMode + onClicked: root.editToggled() + } + + DankActionButton { + 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") + visible: !root.editMode + onClicked: root.editToggled() + } + } +} diff --git a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml index 485b846ea..d445eff00 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileGrid.qml @@ -24,24 +24,54 @@ DankEditableGrid { signal lockRequested signal powerRequested signal settingsRequested - signal editRequested - signal headerTapped + signal closeRequested 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))) + readonly property var savedWidgets: SettingsData.controlCenterWidgets || [] + readonly property var shownIndices: savedWidgets.reduce((indices, widget, i) => WidgetUtils.isShown(widget) ? indices.concat([i]) : indices, []) + + sourceItems: shownIndices.map(i => Object.assign({}, savedWidgets[i], sizeWithHiddenTwin(i))) slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep, true) placeholderRadius: draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight) - onLayoutCommitted: items => model.setLayout(items) + onLayoutCommitted: items => model.setLayout(withHidden(items)) + + function sizeWithHiddenTwin(index) { + const widget = savedWidgets[index]; + const size = WidgetUtils.clampSize(widget, Infinity); + if (!WidgetUtils.isUnplaced(widget)) + return size; + for (const neighbor of [index + 1, index - 1]) { + const twin = savedWidgets[neighbor]; + if (!twin || shownIndices.includes(neighbor) || !WidgetUtils.isUnplaced(twin)) + continue; + const twinSize = WidgetUtils.clampSize(twin, Infinity); + if (twinSize.w !== size.w || twinSize.h !== size.h) + continue; + size.w += twinSize.w; + return size; + } + return size; + } + + function savedIndex(index) { + return shownIndices[index] ?? -1; + } + + function withHidden(items) { + const widgets = savedWidgets.slice(); + shownIndices.forEach((saved, i) => widgets[saved] = items[i]); + return widgets; + } function displayedItems() { - return GridUtils.placedItems(sourceItems.map(widget => Object.assign({}, widget, { + return withHidden(GridUtils.placedItems(sourceItems.map(widget => Object.assign({}, widget, { "w": WidgetUtils.clampSize(widget, columns).w - })), slotLayout.slots); + })), slotLayout.slots)); } Repeater { diff --git a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml index 653cae27b..5f20e15c0 100644 --- a/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml +++ b/quickshell/Modules/ControlCenter/Components/CcTileSlot.qml @@ -20,7 +20,7 @@ DankEditableGridSlot { onPressAndHold: { if (!editChrome.hasOptions) return; - root.grid.configRequested(root.index, root.widgetData, editChrome); + root.grid.configRequested(root.grid.savedIndex(root.index), root.widgetData, editChrome); } onResizeRequested: (requestedWidth, requestedHeight) => { @@ -34,17 +34,6 @@ DankEditableGridSlot { else width = Math.min(2, sizeSpec.maxW); } - if (widgetData.id === "quickActions") { - const count = WidgetUtils.enabledQuickActions(widgetData).length; - if (height !== current.h) - width = Math.max(width, CcMetrics.actionSpan(Math.ceil(count / CcMetrics.actionCapacity(height)))); - const size = WidgetUtils.clampSize(Object.assign({}, widgetData, { - w: width, - h: height - }), grid.columns, grid.maximumRows); - width = size.w; - height = size.h; - } const changes = {}; if (width !== current.w) changes.w = width; @@ -124,6 +113,10 @@ DankEditableGridSlot { return; root.grid.expandClicked(root.widgetData); } + + function onOptionChanged(key, value) { + root.grid.model?.setOption(root.grid.savedIndex(root.index), key, value); + } } CcEditChrome { @@ -135,7 +128,7 @@ DankEditableGridSlot { visible: root.grid.editMode enabled: root.interactionEnabled widgetData: root.widgetData - resizeEdgeWidth: root.widgetData.id === "quickActions" ? Theme.spacingL : -1 + passthrough: root.passthrough dragging: root.dragging resizing: root.resizing cornerRadius: root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height) @@ -144,7 +137,7 @@ DankEditableGridSlot { onResizeMoved: (px, py) => root.resizeTo(px, py) onResizeEnded: root.finishResize() onResizeCanceled: root.cancelResize() - onRemoveRequested: root.grid.removeWidget(root.index) - onConfigRequested: anchor => root.grid.configRequested(root.index, root.widgetData, anchor) + onRemoveRequested: root.grid.removeWidget(root.grid.savedIndex(root.index)) + onConfigRequested: anchor => root.grid.configRequested(root.grid.savedIndex(root.index), root.widgetData, anchor) } } diff --git a/quickshell/Modules/ControlCenter/Components/CcWidgetSheet.qml b/quickshell/Modules/ControlCenter/Components/CcWidgetSheet.qml new file mode 100644 index 000000000..841a17784 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Components/CcWidgetSheet.qml @@ -0,0 +1,192 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Common +import qs.Modules.ControlCenter +import qs.Widgets +import "../utils/widgets.js" as WidgetUtils + +DankBottomSheet { + id: root + + property var widgets: [] + readonly property string query: searchField.text.trim() + readonly property var matches: WidgetUtils.filterWidgets(widgets, query) + readonly property var categories: [ + { + "id": "user", + "title": I18n.tr("User") + }, + { + "id": "network", + "title": I18n.tr("Network") + }, + { + "id": "audio", + "title": I18n.tr("Audio") + }, + { + "id": "display", + "title": I18n.tr("Display") + }, + { + "id": "system", + "title": I18n.tr("System") + }, + { + "id": "plugins", + "title": I18n.tr("Plugins") + } + ] + readonly property var shownCategories: categories.filter(category => widgets.some(widget => (widget.category ?? "plugins") === category.id)) + + signal chosen(string widgetId) + signal clearRequested + + title: I18n.tr("Widgets") + initialFocusItem: searchField + + onOpenedChanged: { + if (opened) + searchField.text = ""; + } + + headerActions: [ + DankActionButton { + buttonSize: Theme.buttonHeightS + iconName: "clear_all" + iconColor: Theme.error + tooltipText: I18n.tr("Clear All") + onClicked: root.clearRequested() + } + ] + + component PreviewGrid: Grid { + id: grid + + property var entries: [] + + signal chosen(string widgetId) + + columns: Math.max(1, Math.floor((width + spacing) / (CcMetrics.previewMinWidth + spacing))) + spacing: Theme.spacingS + + Repeater { + model: grid.entries + + StyledButton { + id: preview + + required property var modelData + readonly property bool isUser: modelData.id === "user" + readonly property bool available: modelData.enabled !== false + + width: (grid.width - grid.spacing * (grid.columns - 1)) / grid.columns + height: CcMetrics.previewTileHeight + Theme.spacingXS + label.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadiusL + Accessible.name: modelData.text + Accessible.description: modelData.warning ?? modelData.description ?? "" + onClicked: { + if (!available) + return; + grid.chosen(modelData.id); + } + + Rectangle { + id: miniTile + anchors.horizontalCenter: parent.horizontalCenter + y: Theme.spacingS + width: preview.isUser ? height : parent.width - Theme.spacingS * 2 + height: CcMetrics.previewTileHeight + radius: Theme.fullRadius(width, height) + color: preview.available ? CcMetrics.tileInactiveColor : Theme.onSurface_12 + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + + DankIcon { + anchors.centerIn: parent + name: preview.modelData.icon + size: CcMetrics.iconBoxIconSize + color: preview.available ? CcMetrics.tileInactiveIcon : Theme.onSurface_38 + } + } + + StyledText { + id: label + anchors.top: miniTile.bottom + anchors.topMargin: Theme.spacingXS + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - Theme.spacingXS * 2 + text: preview.modelData.text + font.pixelSize: Theme.fontSizeSmall + color: preview.available ? Theme.surfaceText : Theme.onSurface_38 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + FocusRing { + visible: preview.visualFocus + } + + StateLayer { + control: preview + tooltipText: preview.modelData.warning ?? "" + } + } + } + } + + DankSearchField { + id: searchField + width: parent.width + height: Theme.fieldHeightLarge + placeholderText: I18n.tr("Search widgets...") + onAccepted: { + const first = root.matches.find(widget => widget.enabled !== false); + if (first) + root.chosen(first.id); + } + } + + Repeater { + model: root.query === "" ? root.shownCategories : [] + + DankCollapsibleSection { + id: section + + required property var modelData + required property int index + + width: parent?.width ?? 0 + title: modelData.title + expanded: index === 0 + + PreviewGrid { + Layout.fillWidth: true + entries: root.widgets.filter(widget => (widget.category ?? "plugins") === section.modelData.id) + onChosen: widgetId => root.chosen(widgetId) + } + } + } + + PreviewGrid { + width: parent.width + visible: root.query !== "" + entries: root.query !== "" ? root.matches : [] + onChosen: widgetId => root.chosen(widgetId) + } + + StyledText { + width: parent.width + visible: root.matches.length === 0 + topPadding: Theme.spacingL + bottomPadding: Theme.spacingL + text: I18n.tr("No widgets available") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + } +} diff --git a/quickshell/Modules/ControlCenter/Components/EditControls.qml b/quickshell/Modules/ControlCenter/Components/EditControls.qml deleted file mode 100644 index dd211935f..000000000 --- a/quickshell/Modules/ControlCenter/Components/EditControls.qml +++ /dev/null @@ -1,165 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Common -import qs.Modules.ControlCenter -import qs.Modules.ControlCenter.Widgets -import qs.Widgets - -Row { - id: root - - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - - property var availableWidgets: [] - property var popupScreen: null - property real popoutX: 0 - property real popoutY: 0 - property real popoutWidth: 0 - property real popoutHeight: 0 - - signal addWidget(string widgetId) - signal resetToDefault - signal clearAll - signal finishRequested - signal cancelRequested - - readonly property real buttonWidth: (width - spacing * 4 - Theme.buttonHeightS * 2) / 3 - - height: Theme.buttonHeightS - spacing: Theme.spacingS - - function openWidgetLibrary() { - libraryLoader.active = true; - const window = libraryLoader.item; - if (!window) - return; - if (popupScreen) - window.screen = popupScreen; - window.visible = true; - } - - function closeWidgetLibrary() { - if (!libraryLoader.item) - return; - libraryLoader.item.visible = false; - } - - onAddWidget: closeWidgetLibrary() - onVisibleChanged: { - if (visible) - return; - closeWidgetLibrary(); - libraryLoader.active = false; - } - - LazyLoader { - id: libraryLoader - - active: false - - PanelWindow { - id: addWidgetWindow - - screen: root.popupScreen - visible: false - color: "transparent" - - WlrLayershell.namespace: "dms:control-center-widget-library" - WlrLayershell.layer: WlrLayershell.Overlay - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None) - - anchors { - top: true - left: true - right: true - bottom: true - } - - WindowBlur { - targetWindow: addWidgetWindow - surfaceColor: widgetLibraryPanel.color - blurX: widgetLibraryPanel.x - blurY: widgetLibraryPanel.y - blurWidth: addWidgetWindow.visible ? widgetLibraryPanel.width : 0 - blurHeight: addWidgetWindow.visible ? widgetLibraryPanel.height : 0 - blurRadius: Theme.windowRadius - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onClicked: root.closeWidgetLibrary() - } - - CcWidgetLibrary { - id: widgetLibraryPanel - - width: Math.min(implicitWidth, addWidgetWindow.width - Theme.spacingL * 2) - height: Math.min(implicitHeight, addWidgetWindow.height - Theme.spacingL * 2) - x: Math.round(Math.max(Theme.spacingL, Math.min(addWidgetWindow.width - width - Theme.spacingL, root.popoutWidth > 0 ? root.popoutX + (root.popoutWidth - width) / 2 : (addWidgetWindow.width - width) / 2))) - y: Math.round(Math.max(Theme.spacingL, Math.min(addWidgetWindow.height - height - Theme.spacingL, root.popoutHeight > 0 ? root.popoutY + (root.popoutHeight - height) / 2 : (addWidgetWindow.height - height) / 2))) - widgets: root.availableWidgets - onChosen: widgetId => root.addWidget(widgetId) - onDismissed: root.closeWidgetLibrary() - } - - onVisibleChanged: { - if (visible) - widgetLibraryPanel.reset(); - } - } - } - - DankButton { - width: root.buttonWidth - buttonHeight: Theme.buttonHeightS - iconName: "add" - text: I18n.tr("Add widget") - backgroundColor: Theme.secondaryContainer - textColor: Theme.onSecondaryContainer - onClicked: root.openWidgetLibrary() - } - - DankButton { - width: root.buttonWidth - buttonHeight: Theme.buttonHeightS - iconName: "settings_backup_restore" - text: I18n.tr("Defaults", "noun, control center edit button restoring the default layout") - backgroundColor: Theme.chipSurface - textColor: Theme.surfaceText - onClicked: root.resetToDefault() - } - - DankButton { - width: root.buttonWidth - buttonHeight: Theme.buttonHeightS - iconName: "clear_all" - text: I18n.tr("Reset") - backgroundColor: Theme.errorHover - 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/WidgetConfigOverlay.qml b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml index 46b0fde6c..3ee453ce9 100644 --- a/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml +++ b/quickshell/Modules/ControlCenter/Components/WidgetConfigOverlay.qml @@ -7,7 +7,6 @@ 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 @@ -29,9 +28,7 @@ Item { readonly property bool isPlugin: widgetId.startsWith("plugin_") readonly property bool isDisk: widgetId === "diskUsage" readonly property bool isIdleInhibitor: widgetId === "idleInhibitor" - readonly property bool isUser: widgetId === "userCard" - readonly property bool isQuickActions: widgetId === "quickActions" - readonly property var quickActions: isQuickActions ? WidgetUtils.quickActions(widgetData) : [] + readonly property bool isUser: widgetId === "user" visible: widgetIndex >= 0 || contextMenu.renderActive @@ -67,20 +64,8 @@ Item { contextMenu.hide(); } - 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) - return; - widgets[widgetIndex] = Object.assign({}, widgets[widgetIndex], { - [key]: value - }); - SettingsData.set("controlCenterWidgets", widgets); + WidgetUtils.setOption(widgetIndex, key, value); } DankContextMenu { @@ -125,58 +110,6 @@ Item { } } - 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) - } - } - } - - CcToggleRow { - visible: root.isUser || root.isQuickActions - text: I18n.tr("Background") - checked: root.isUser ? root.widgetData?.background !== false : root.widgetData?.background === true - onToggled: checked => root.persistOption("background", checked) - } - - CcToggleRow { - visible: root.isQuickActions && root.widgetData?.background === true - text: I18n.tr("Button backgrounds") - checked: root.widgetData?.buttonBackgrounds === true - onToggled: checked => root.persistOption("buttonBackgrounds", 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 0667b70ba..cf04ba2db 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterContent.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import qs.Common +import qs.Services import qs.Modules.ControlCenter.Components import qs.Modules.ControlCenter.Models import qs.Modules.ControlCenter.Details @@ -18,10 +19,22 @@ FocusScope { readonly property bool pageOpen: (host.expandedSection ?? "") !== "" readonly property real gridHeight: widgetGrid.gridHeight + readonly property real chromeHeight: Theme.spacingS + footer.height + readonly property bool widgetSheetOpen: widgetSheetLoader.item?.active ?? false + readonly property real coveredAmount: Math.max(detailPage.opacity, widgetSheetLoader.item?.progress ?? 0) + property bool widgetSheetRequested: false + property int toplevelRevision: 0 + readonly property var runningToplevels: { + toplevelRevision; + const all = CompositorService.sortedToplevels ?? []; + 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 real targetImplicitHeight: { - let total = CcMetrics.sheetPadding * 2 + gridHeight; - if (host.editMode) - total += Theme.spacingS + editControls.height; + const total = CcMetrics.sheetPadding * 2 + gridHeight + chromeHeight; + if (widgetSheetOpen) + return Math.max(total, CcMetrics.widgetSheetMinHeight); if (detailPage.shownSection === "") return total; return Math.max(total, detailPage.topInset + detailPage.minimumHeight + CcMetrics.detailDialogInset); @@ -34,7 +47,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 - Theme.spacingS - editControls.height + readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - chromeHeight readonly property real editGutter: host.editGutter ?? 0 readonly property DankPanelResizer panelResizer: DankPanelResizer { popout: root.host @@ -141,7 +154,32 @@ FocusScope { SettingsData.set("controlCenterColumns", snapshot.columns); } + function openWidgetSheet() { + const sheet = widgetSheetLoader.item; + if (!sheet) { + widgetSheetRequested = true; + return; + } + sheet.opened = true; + } + + function closeWidgetSheet() { + if (!widgetSheetLoader.item) + return; + widgetSheetLoader.item.opened = false; + } + Keys.onEscapePressed: event => { + if (footer.resetArmed) { + footer.cancelConfirmation(); + event.accepted = true; + return; + } + if (widgetSheetLoader.item?.opened) { + closeWidgetSheet(); + event.accepted = true; + return; + } if (configOverlayLoader.item?.visible) { configOverlayLoader.item.close(); event.accepted = true; @@ -180,8 +218,10 @@ FocusScope { } else { panelResizer.cancel(); editSnapshot = null; + widgetSheetRequested = false; } - forceActiveFocus(); + if (!activeFocus) + forceActiveFocus(); } DankGridEditChrome { @@ -191,7 +231,7 @@ FocusScope { anchors.margins: -(contentInset + Theme.spacingS) z: 1 visible: root.host.editMode - enabled: detailPage.shownSection === "" + enabled: detailPage.shownSection === "" && !root.widgetSheetOpen edgeResize: root.panelResizing || root.panelResizer.sideMovable(-1, root.gridColumns) cornerResize: root.panelResizing || root.panelResizer.sideMovable(1, root.gridColumns) horizontalResize: true @@ -257,7 +297,7 @@ FocusScope { width: parent.width height: root.gridHeight - opacity: CcMetrics.hideCoveredContent ? 1 - detailPage.opacity : 1 + opacity: CcMetrics.hideCoveredContent ? 1 - root.coveredAmount : 1 CcTileGrid { id: widgetGrid @@ -275,8 +315,7 @@ FocusScope { 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 + onCloseRequested: root.host.close() onSettingsRequested: root.host.openSettings() onLockRequested: { root.host.close(); @@ -295,27 +334,17 @@ FocusScope { } } - EditControls { - id: editControls + CcFooter { + id: footer width: parent.width - visible: root.host.editMode - popupScreen: root.host.screen - popoutX: root.host.alignedX - popoutY: root.host.alignedY - popoutWidth: root.host.alignedWidth - popoutHeight: root.host.alignedHeight - availableWidgets: { - if (!root.host.editMode) - return []; - const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id); - const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); - return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); - } - onAddWidget: widgetId => widgetModel.addWidget(widgetId) - onResetToDefault: () => widgetModel.resetToDefault() - onClearAll: () => widgetModel.clearAll() - onFinishRequested: root.host.editMode = false + editMode: root.host.editMode + toplevels: root.runningToplevels + opacity: body.opacity + onRunningAppsRequested: root.navigateTo("runningApps") + onAddWidgetRequested: root.openWidgetSheet() + onResetRequested: widgetModel.resetToDefault() + onEditToggled: root.host.editMode = !root.host.editMode onCancelRequested: root.cancelEdit() } } @@ -337,8 +366,10 @@ FocusScope { transientSurfaceTracker: root.host.transientSurfaceTracker onCodecSelectorRequested: device => root.showCodecSelector(device) onPortSelectorRequested: node => root.showPortSelector(node) + runningToplevels: root.runningToplevels onBackRequested: root.goBack() onCollapseRequested: root.host.collapseAll() + onCloseRequested: root.host.close() onDismissed: { const target = root.detailReturnFocus; root.detailReturnFocus = null; @@ -374,6 +405,47 @@ FocusScope { } } + Connections { + target: CompositorService + + function onToplevelsChanged() { + root.toplevelRevision++; + } + } + + Loader { + id: widgetSheetLoader + + anchors.fill: parent + z: CcMetrics.overlayZ + active: root.host.editMode + asynchronous: true + onLoaded: { + if (!root.widgetSheetRequested) + return; + root.widgetSheetRequested = false; + item.opened = true; + } + sourceComponent: CcWidgetSheet { + scrimRadii: root.surfaceCornerRadii + surfaceColor: CcMetrics.dialogColor + widgets: { + const existingIds = root.placedWidgetIds.split(","); + const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); + return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); + } + onDismissRequested: opened = false + onChosen: widgetId => { + widgetModel.addWidget(widgetId); + opened = false; + } + onClearRequested: { + widgetModel.clearAll(); + opened = false; + } + } + } + Loader { id: configOverlayLoader diff --git a/quickshell/Modules/ControlCenter/Details/RunningAppsDetail.qml b/quickshell/Modules/ControlCenter/Details/RunningAppsDetail.qml new file mode 100644 index 000000000..c54922b39 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Details/RunningAppsDetail.qml @@ -0,0 +1,81 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Modules.ControlCenter +import qs.Modules.ControlCenter.Widgets +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property string title: I18n.tr("Running apps") + property var toplevels: [] + readonly property int count: toplevels.length + + signal closeRequested + signal dismissRequested + + onCountChanged: { + if (count === 0) + dismissRequested(); + } + + DankFlickable { + anchors.fill: parent + contentHeight: column.height + clip: true + + Column { + id: column + width: parent.width + spacing: CcMetrics.detailContentGap + + CcGroup { + Repeater { + model: ScriptModel { + values: root.toplevels + objectProp: CompositorService.toplevelKey + } + + CcListRow { + id: row + + required property var modelData + readonly property string appId: modelData?.appId ?? "" + readonly property string appName: Paths.getAppName(appId, DesktopEntries.heuristicLookup(appId)) + readonly property var placement: CompositorService.toplevelPlacement(modelData) + readonly property string workspaceLabel: placement.workspace ? I18n.tr("Workspace %1", "control center running app row, %1 is a workspace number or name").arg(placement.workspace) : "" + readonly property string outputLabel: Quickshell.screens.length > 1 ? (SettingsData.getScreenDisplayName(Quickshell.screens.find(screen => screen.name === placement.output)) || placement.output) : "" + + title: modelData?.title || appName + subtitle: [modelData?.title ? appName : "", workspaceLabel, outputLabel].filter(text => text !== "").join(" ยท ") + clickable: true + onClicked: { + modelData?.activate(); + root.closeRequested(); + } + + leading: CcAppIcon { + appId: row.appId + iconSize: Theme.iconSizeLarge + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + iconColor: Theme.onSurfaceVariant + Accessible.name: I18n.tr("Close") + onClicked: row.modelData?.close() + } + } + } + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index ee44be3ac..a18a405f4 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -104,11 +104,11 @@ QtObject { readonly property Component colorPickerTile: Component { ColorPickerTile {} } - readonly property Component userCard: Component { - UserCard {} + readonly property Component userTile: Component { + UserTile {} } - readonly property Component quickActions: Component { - QuickActions {} + readonly property Component actionTile: Component { + ActionTile {} } readonly property Component pluginTile: Component { PluginTile {} @@ -163,10 +163,12 @@ QtObject { return diskUsageTile; case "colorPicker": return colorPickerTile; - case "userCard": - return userCard; - case "quickActions": - return quickActions; + case "user": + return userTile; + case "lock": + case "power": + case "settings": + return actionTile; default: return null; } @@ -186,11 +188,39 @@ QtObject { readonly property var baseWidgetDefinitions: [ { - "id": "userCard", + "id": "user", "text": I18n.tr("User"), - "description": I18n.tr("Show hostname"), + "description": I18n.tr("Profile"), "icon": "person", "type": "info", + "category": "user", + "enabled": true + }, + { + "id": "settings", + "text": I18n.tr("Settings"), + "description": "", + "icon": "settings", + "type": "action", + "category": "system", + "enabled": true + }, + { + "id": "lock", + "text": I18n.tr("Lock"), + "description": I18n.tr("Lock screen"), + "icon": "lock", + "type": "action", + "category": "system", + "enabled": true + }, + { + "id": "power", + "text": I18n.tr("Power"), + "description": I18n.tr("Power menu"), + "icon": "power_settings_new", + "type": "action", + "category": "system", "enabled": true }, { @@ -199,6 +229,7 @@ QtObject { "description": I18n.tr("Blue light filter"), "icon": "nightlight", "type": "toggle", + "category": "display", "enabled": NightModeService.automationAvailable, "warning": !NightModeService.automationAvailable ? I18n.tr("Requires night mode support") : undefined }, @@ -208,6 +239,7 @@ QtObject { "description": I18n.tr("System theme toggle"), "icon": "contrast", "type": "toggle", + "category": "display", "enabled": true }, { @@ -216,6 +248,7 @@ QtObject { "description": I18n.tr("Block notifications"), "icon": "do_not_disturb_on", "type": "toggle", + "category": "system", "enabled": true }, { @@ -224,6 +257,7 @@ QtObject { "description": I18n.tr("Prevent screen timeout"), "icon": "motion_sensor_active", "type": "toggle", + "category": "system", "enabled": true }, { @@ -232,6 +266,7 @@ QtObject { "description": I18n.tr("Wi-Fi and Ethernet connection"), "icon": "wifi", "type": "connection", + "category": "network", "enabled": NetworkService.wifiAvailable, "warning": !NetworkService.wifiAvailable ? I18n.tr("Wi-Fi not available") : undefined }, @@ -241,6 +276,7 @@ QtObject { "description": I18n.tr("Device connections"), "icon": "bluetooth", "type": "connection", + "category": "network", "enabled": BluetoothService.available, "warning": !BluetoothService.available ? I18n.tr("Bluetooth not available") : undefined }, @@ -250,6 +286,7 @@ QtObject { "description": I18n.tr("Speaker settings"), "icon": "volume_up", "type": "connection", + "category": "audio", "enabled": true }, { @@ -258,6 +295,7 @@ QtObject { "description": I18n.tr("Microphone settings"), "icon": "mic", "type": "connection", + "category": "audio", "enabled": true }, { @@ -266,6 +304,7 @@ QtObject { "description": I18n.tr("Audio volume control"), "icon": "volume_up", "type": "slider", + "category": "audio", "enabled": true }, { @@ -274,6 +313,7 @@ QtObject { "description": I18n.tr("Display brightness control"), "icon": "brightness_6", "type": "slider", + "category": "display", "enabled": BrightnessService.brightnessAvailable, "warning": !BrightnessService.brightnessAvailable ? I18n.tr("Brightness control not available") : undefined, "allowMultiple": true @@ -284,15 +324,18 @@ QtObject { "description": I18n.tr("Microphone volume control"), "icon": "mic", "type": "slider", + "category": "audio", "enabled": true }, { "id": "battery", - "text": I18n.tr("Battery"), + "text": BatteryService.batteryAvailable || !PowerProfileWatcher.available ? I18n.tr("Battery") : I18n.tr("Power profile"), "description": I18n.tr("Battery and power management"), "icon": "battery_std", "type": "action", - "enabled": true + "category": "system", + "enabled": BatteryService.batteryAvailable || PowerProfileWatcher.available, + "warning": !BatteryService.batteryAvailable && !PowerProfileWatcher.available ? I18n.tr("No battery") : undefined }, { "id": "diskUsage", @@ -300,6 +343,7 @@ QtObject { "description": I18n.tr("Filesystem usage monitoring"), "icon": "storage", "type": "action", + "category": "system", "enabled": DgopService.dgopAvailable, "warning": !DgopService.dgopAvailable ? I18n.tr("Requires 'dgop' tool") : undefined, "allowMultiple": true @@ -310,6 +354,7 @@ QtObject { "description": I18n.tr("Choose colors from palette"), "icon": "palette", "type": "action", + "category": "display", "enabled": true }, { @@ -318,6 +363,7 @@ QtObject { "description": I18n.tr("VPN Connections"), "icon": "vpn_key", "type": "builtin_plugin", + "category": "network", "enabled": DMSNetworkService.available, "warning": !DMSNetworkService.available ? I18n.tr("VPN not available") : undefined, "isBuiltinPlugin": true @@ -328,6 +374,7 @@ QtObject { "description": I18n.tr("Print Server Management"), "icon": "Print", "type": "builtin_plugin", + "category": "system", "enabled": CupsService.cupsAvailable, "warning": !CupsService.cupsAvailable ? I18n.tr("CUPS not available") : undefined, "isBuiltinPlugin": true @@ -338,6 +385,7 @@ QtObject { "description": I18n.tr("Tailscale Network", "Tailscale control center widget description"), "icon": "device_hub", "type": "builtin_plugin", + "category": "network", "enabled": TailscaleService.available, "warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined, "isBuiltinPlugin": true @@ -348,6 +396,7 @@ QtObject { "description": I18n.tr("Switch between display configurations"), "icon": "monitor", "type": "builtin_plugin", + "category": "display", "enabled": true, "isBuiltinPlugin": true } @@ -394,6 +443,7 @@ QtObject { "description": plugin.description || "", "icon": plugin.icon || "extension", "type": "plugin", + "category": "plugins", "enabled": true, "isPlugin": true }); @@ -419,11 +469,15 @@ QtObject { WidgetUtils.setLayout(widgets); } + function setOption(index, key, value) { + WidgetUtils.setOption(index, key, value); + } + function resetToDefault() { - WidgetUtils.resetToDefault(columns); + WidgetUtils.resetToDefault(); } function clearAll() { - WidgetUtils.clearAll(columns); + WidgetUtils.clearAll(); } } diff --git a/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml new file mode 100644 index 000000000..c9f43dd6f --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/ActionTile.qml @@ -0,0 +1,28 @@ +import QtQuick +import qs.Common +import qs.Modules.ControlCenter + +CcTile { + id: root + + readonly property string action: widgetData?.id ?? "" + + toggle: false + iconName: widgetDef?.icon ?? "" + title: widgetDef?.text ?? "" + restIconColor: action === "power" ? Theme.error : CcMetrics.tileInactiveIcon + + onClicked: { + switch (action) { + case "lock": + host?.lockRequested(); + return; + case "power": + host?.powerRequested(); + return; + case "settings": + host?.settingsRequested(); + return; + } + } +} diff --git a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml index 0255b85ef..b449af1e5 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml @@ -8,10 +8,17 @@ CcTile { id: root readonly property bool available: BatteryService.batteryAvailable + readonly property bool profileMode: !available && PowerProfileWatcher.available - iconName: BatteryService.getBatteryIcon() - title: available ? I18n.tr("Battery") : I18n.tr("No battery") + iconName: profileMode ? Theme.getPowerProfileIcon(PowerProfileWatcher.currentProfile) : BatteryService.getBatteryIcon() + title: { + if (profileMode) + return I18n.tr("Power profile"); + return available ? I18n.tr("Battery") : I18n.tr("No battery"); + } subtitle: { + if (profileMode) + return Theme.getPowerProfileLabel(PowerProfileWatcher.currentProfile); if (!available) return I18n.tr("Not available"); if (BatteryService.isCharging) @@ -21,7 +28,8 @@ CcTile { return `${BatteryService.batteryLevel}%`; } active: available && (BatteryService.isCharging || BatteryService.isPluggedIn) - opensPage: true + opensPage: !profileMode + showExpand: profileMode tallContent: Component { Item { BatteryMeter { @@ -35,7 +43,13 @@ CcTile { } } - onClicked: expandClicked() + onClicked: { + if (!profileMode) { + expandClicked(); + return; + } + PowerProfileWatcher.cycleProfile(); + } expandedContent: Component { CcTileActions { actions: PowerProfileWatcher.availableProfiles.map(profile => ({ diff --git a/quickshell/Modules/ControlCenter/Widgets/CcAppIcon.qml b/quickshell/Modules/ControlCenter/Widgets/CcAppIcon.qml new file mode 100644 index 000000000..cb15c7761 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/CcAppIcon.qml @@ -0,0 +1,33 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.Common +import qs.Widgets + +Item { + id: root + + property string appId: "" + property real iconSize: Theme.iconSize + + implicitWidth: iconSize + implicitHeight: iconSize + + IconImage { + id: image + anchors.fill: parent + source: root.appId ? Paths.getAppIcon(root.appId, DesktopEntries.heuristicLookup(root.appId)) : "" + smooth: true + mipmap: true + asynchronous: true + visible: status === Image.Ready + } + + DankIcon { + anchors.centerIn: parent + name: "apps" + size: root.iconSize + color: Theme.onSurfaceVariant + visible: image.status === Image.Error || image.source.toString() === "" + } +} diff --git a/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml index fe73b6516..9a7797f32 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcSliderRow.qml @@ -70,7 +70,6 @@ Item { iconName: root.iconName iconSize: CcMetrics.iconBoxIconSize iconColor: CcMetrics.tileInactiveContent - visible: root.tall enabled: root.sliderEnabled && root.interactive tooltipText: root.iconTooltip Accessible.name: root.iconLabel @@ -110,6 +109,7 @@ Item { id: trackArea objectName: "sliderTrackArea" anchors.left: parent.left + anchors.leftMargin: root.tall ? 0 : action.width + Theme.spacingS anchors.right: parent.right y: root.vertical ? (root.showNumber ? labels.height + Theme.spacingS : 0) : root.tall ? action.height + Theme.spacingM : 0 height: Math.max(0, (root.vertical ? action.y - Theme.spacingS : parent.height) - y) @@ -131,14 +131,8 @@ Item { return "l"; return "m"; } - insetIcon: root.tall ? "" : root.iconName - insetIconPosition: "end" - insetIconClickable: true - insetIconTooltip: root.iconTooltip - insetIconLabel: root.iconLabel Accessible.name: root.sliderLabel showValue: !root.vertical - onInsetIconClicked: root.iconClicked() onSliderValueChanged: newValue => root.sliderValueChanged(newValue) Binding on trackHeight { @@ -152,12 +146,6 @@ Item { value: CcMetrics.stripHandleHeight restoreMode: Binding.RestoreBinding } - - Binding on insetIconSize { - when: !root.tall - value: CcMetrics.stripIconSize - restoreMode: Binding.RestoreBinding - } } } } diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml index 30ea28bb8..2bb0f2725 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml @@ -35,6 +35,7 @@ Item { readonly property bool showSubtitle: subtitle !== "" && (!stacked || height - tilePadding * 2 >= iconExtent + Theme.spacingS + titleLabel.implicitHeight + Theme.spacingXXS + subtitleLabel.implicitHeight) property bool showExpand: false property bool opensPage: false + property color restIconColor: CcMetrics.tileInactiveIcon property Component tallContent: null property bool interactive: true property bool iconBlinking: false @@ -73,7 +74,7 @@ Item { return Theme.onSurface_38; if (hasIconBox) return active ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveContent; - return bodyActive ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveIcon; + return bodyActive ? CcMetrics.tileActiveContent : root.restIconColor; } readonly property color iconBoxColor: { if (!enabled) diff --git a/quickshell/Modules/ControlCenter/Widgets/CcWidgetLibrary.qml b/quickshell/Modules/ControlCenter/Widgets/CcWidgetLibrary.qml index a9571d312..64d4e0f25 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcWidgetLibrary.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcWidgetLibrary.qml @@ -2,17 +2,13 @@ import QtQuick import qs.Common import qs.Modules.ControlCenter import qs.Widgets +import "../utils/widgets.js" as WidgetUtils Rectangle { id: root property var widgets: [] - readonly property var matches: { - const query = searchField.text.trim().toLowerCase(); - if (!query) - return widgets; - return widgets.filter(widget => [widget.text, widget.description, widget.id].some(value => (value || "").toLowerCase().includes(query))); - } + readonly property var matches: WidgetUtils.filterWidgets(widgets, searchField.text) signal chosen(string widgetId) signal dismissed diff --git a/quickshell/Modules/ControlCenter/Widgets/QuickActions.qml b/quickshell/Modules/ControlCenter/Widgets/QuickActions.qml deleted file mode 100644 index f03041395..000000000 --- a/quickshell/Modules/ControlCenter/Widgets/QuickActions.qml +++ /dev/null @@ -1,91 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import qs.Common -import qs.Modules.ControlCenter -import qs.Widgets -import "../utils/widgets.js" as WidgetUtils - -Item { - id: root - - property var widgetData: ({}) - property var widgetDef: null - property var host: null - property bool live: true - property bool interactive: true - property real columns: 1.5 - property real rows: 1.5 - property bool compact: false - - readonly property Item passthrough: actionGrid - readonly property bool editMode: host?.editMode ?? false - readonly property var actions: WidgetUtils.enabledQuickActions(widgetData) - readonly property bool background: widgetData?.background === true - readonly property bool buttonBackgrounds: widgetData?.buttonBackgrounds === true - readonly property real bodyRadius: background ? Theme.cornerRadiusXL : Theme.cornerRadiusL - readonly property int actionColumns: Math.max(1, Math.min(actions.length, Math.floor((width + CcMetrics.actionGap) / (CcMetrics.actionSize + CcMetrics.actionGap)))) - - width: parent?.width ?? 0 - height: CcMetrics.headerHeight - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - - 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; - } - } - - Rectangle { - anchors.fill: parent - radius: root.bodyRadius - color: Theme.foregroundColor(Theme.cardSurface) - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium - visible: root.background - } - - Grid { - id: actionGrid - - anchors.centerIn: parent - columns: root.actionColumns - spacing: CcMetrics.actionGap - - Repeater { - model: root.actions - - DankActionButton { - required property var modelData - readonly property string actionId: modelData.id - readonly property bool destructive: root.widgetData?.powerAccent === true && actionId === "power" - readonly property bool editing: actionId === "edit" && root.editMode - - buttonSize: CcMetrics.actionSize - iconSize: CcMetrics.actionIconSize - circular: root.background - iconName: WidgetUtils.quickActionIcon(actionId) - iconColor: editing ? Theme.onPrimary : destructive ? Theme.error : Theme.onSurface - backgroundColor: editing ? Theme.primary : root.background ? (root.buttonBackgrounds ? Theme.foregroundColor(Theme.chipSurface) : "transparent") : Theme.foregroundColor(Theme.cardSurface) - tooltipText: I18n.tr(WidgetUtils.quickActionLabel(actionId)) - checkable: actionId === "edit" - checked: editing - Accessible.checkable: checkable - Accessible.checked: checked - onClicked: root.trigger(actionId) - } - } - } -} diff --git a/quickshell/Modules/ControlCenter/Widgets/UserCard.qml b/quickshell/Modules/ControlCenter/Widgets/UserCard.qml deleted file mode 100644 index fd6f778b5..000000000 --- a/quickshell/Modules/ControlCenter/Widgets/UserCard.qml +++ /dev/null @@ -1,72 +0,0 @@ -import QtQuick -import qs.Common -import qs.Modules.ControlCenter -import qs.Modules.DankDash -import qs.Widgets - -Item { - id: root - - property var widgetData: ({}) - property var widgetDef: null - property var host: null - property bool live: true - property bool interactive: true - property real columns: 4 - property real rows: 1.5 - property bool compact: false - - readonly property bool tapToClose: interactive && (host?.tapToClose ?? false) - readonly property bool background: widgetData?.background !== false - readonly property real bodyRadius: Theme.cornerRadiusXL - - width: parent?.width ?? 0 - height: CcMetrics.headerHeight - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - - Rectangle { - anchors.fill: parent - radius: root.bodyRadius - color: Theme.foregroundColor(Theme.cardSurface) - border.width: Theme.layerOutlineWidth - border.color: Theme.outlineMedium - visible: root.background - } - - UserIdentity { - id: identity - - anchors.fill: parent - anchors.leftMargin: root.background ? Theme.spacingL : 0 - anchors.rightMargin: anchors.leftMargin - anchors.topMargin: Theme.spacingS - anchors.bottomMargin: Theme.spacingS - live: root.live - options: DashRegistry.resolvedOptions("user", root.widgetData) - avatarSize: narrow ? height : Math.min(height - Theme.spacingM, width / 3) - narrow: width < Theme.minimumTouchTargetSize + Theme.spacingM + Theme.buttonHeightM - stacked: root.height >= CcMetrics.headerHeight - contentColor: Theme.onSurface - mutedColor: Theme.onSurfaceVariant - } - - StyledButton { - anchors.fill: parent - visible: root.tapToClose - radius: root.bodyRadius - Accessible.name: I18n.tr("Close") - onClicked: root.host?.headerTapped() - - FocusRing { - anchors.fill: parent - radius: parent.radius - visible: parent.visualFocus - } - - StateLayer { - control: parent - tooltipText: I18n.tr("Close") - } - } -} diff --git a/quickshell/Modules/ControlCenter/Widgets/UserTile.qml b/quickshell/Modules/ControlCenter/Widgets/UserTile.qml new file mode 100644 index 000000000..53c017386 --- /dev/null +++ b/quickshell/Modules/ControlCenter/Widgets/UserTile.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtQuick.Effects +import qs.Common +import qs.Modules.ControlCenter +import qs.Modules.DankDash +import qs.Services +import qs.Widgets +import "../utils/widgets.js" as WidgetUtils + +Item { + id: root + + property var widgetData: ({}) + property var widgetDef: null + property var host: null + property bool live: true + property bool interactive: true + property real columns: 2 + property real rows: 2 + property bool compact: false + + signal optionChanged(string key, var value) + + readonly property bool wide: width >= height * 2 + readonly property real avatarSide: Math.min(width, height) + 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 real fallbackGlyphRatio: 0.5 + readonly property string avatarSource: { + const image = PortalService.profileImage; + if (image === "") + return ""; + return image.startsWith("/") ? "file://" + image : image; + } + readonly property bool hasImage: picture.status === Image.Ready + + width: parent?.width ?? 0 + height: parent?.height ?? 0 + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + Accessible.role: Accessible.StaticText + Accessible.name: UserInfoService.fullName || UserInfoService.username + + UserIdentity { + anchors.fill: parent + visible: root.wide + live: root.live && root.wide + options: DashRegistry.resolvedOptions("user", root.widgetData) + avatarSize: root.height + stacked: root.rows >= 2 + contentColor: Theme.onSurface + mutedColor: Theme.onSurfaceVariant + } + + Item { + id: avatar + + anchors.centerIn: parent + width: root.avatarSide + height: root.avatarSide + visible: !root.wide + + DankMaterialShape { + anchors.fill: parent + shape: root.shape + color: Theme.primaryContainer + visible: !root.hasImage + } + + DankIcon { + anchors.centerIn: parent + name: "person" + size: Math.round(root.avatarSide * root.fallbackGlyphRatio) + color: Theme.onPrimaryContainer + filled: true + visible: !root.hasImage + } + + Item { + id: avatarMask + + anchors.fill: parent + layer.enabled: root.hasImage + visible: false + + DankMaterialShape { + anchors.fill: parent + shape: root.shape + } + } + + Image { + id: picture + + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + retainWhileLoading: true + visible: root.hasImage + sourceSize.width: Math.max(1, Math.ceil(width * Screen.devicePixelRatio)) + sourceSize.height: Math.max(1, Math.ceil(height * Screen.devicePixelRatio)) + source: avatar.visible ? root.avatarSource : "" + layer.enabled: root.hasImage + layer.effect: MultiEffect { + maskEnabled: true + maskSource: avatarMask + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + } + + StyledButton { + anchors.fill: parent + visible: root.tapToClose + radius: root.bodyRadius + Accessible.name: I18n.tr("Close") + onClicked: root.host?.closeRequested() + + FocusRing { + visible: parent.visualFocus + } + + StateLayer { + control: parent + cornerRadius: root.bodyRadius + } + } + + DankActionButton { + id: shapeButton + + anchors.bottom: avatar.bottom + anchors.left: avatar.left + buttonSize: CcMetrics.shapeButtonSize + iconSize: Theme.iconSizeSmall + iconName: "shuffle" + iconColor: Theme.onSurface + backgroundColor: Theme.surfaceContainerHighest + Accessible.name: I18n.tr("Shuffle") + visible: root.editMode && avatar.visible + onClicked: root.optionChanged("shape", WidgetUtils.nextUserShape(root.shape)) + } +} diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js index ec9b3b52d..549fa2823 100644 --- a/quickshell/Modules/ControlCenter/utils/widgets.js +++ b/quickshell/Modules/ControlCenter/utils/widgets.js @@ -1,71 +1,44 @@ .import qs.Common as Common +.import qs.Services as Services .import qs.Modules.ControlCenter as ControlCenter .import "../../../Common/GridLayout.js" as GridLayout -var PINNED_IDS = ["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); +var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user"]; +var ACTION_IDS = ["settings", "lock", "power"]; +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 isPinned(id) { - return PINNED_IDS.includes(id); +function isShown(widget) { + switch (widget?.id) { + case "battery": + return Services.BatteryService.batteryAvailable || Services.PowerProfileWatcher.available; + case "diskUsage": + return Services.DgopService.dgopAvailable; + default: + return true; + } +} + +function isUnplaced(widget) { + return !Number.isFinite(widget?.col) || !Number.isFinite(widget?.row); } function hasOptions(id) { return OPTION_IDS.includes(id) || String(id ?? "").startsWith("plugin_"); } -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; +function filterWidgets(widgets, query) { + const needle = query.trim().toLowerCase(); + if (!needle) + return widgets; + return widgets.filter(widget => [widget.text, widget.description, widget.id].some(value => (value || "").toLowerCase().includes(needle))); } -function enabledQuickActions(widgetData) { - return quickActions(widgetData).filter(action => action.enabled); -} - -function quickActionIcon(id) { - return QUICK_ACTIONS.find(action => action.id === id)?.icon ?? ""; -} - -function quickActionLabel(id) { - return QUICK_ACTIONS.find(action => action.id === id)?.label ?? ""; +function nextUserShape(shape) { + return USER_SHAPES[(USER_SHAPES.indexOf(shape) + 1) % USER_SHAPES.length]; } function defaultWidget(id, columns) { @@ -77,10 +50,6 @@ function defaultWidget(id, columns) { }, columns)); } -function pinnedWidgets(columns) { - return PINNED_IDS.map(id => defaultWidget(id, columns)); -} - function sizeSpec(widget, columns, rows = Infinity) { const spec = { "w": 4, @@ -91,18 +60,11 @@ function sizeSpec(widget, columns, rows = Infinity) { "maxH": rows, "step": ControlCenter.CcMetrics.gridStep }; - switch (widget?.id) { - case "userCard": - spec.w = Math.max(1, (Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns) - ControlCenter.CcMetrics.actionSpan(2)); - spec.h = ControlCenter.CcMetrics.actionSpan(2); - return spec; - case "quickActions": - spec.w = ControlCenter.CcMetrics.actionSpan(2); - spec.h = ControlCenter.CcMetrics.actionSpan(2); - return spec; - default: - return spec; - } + if (widget?.id === "user") + spec.w = (Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns) - ACTION_IDS.length; + if (ACTION_IDS.includes(widget?.id)) + spec.w = 1; + return spec; } function clampSize(widget, columns, rows = Infinity) { @@ -111,18 +73,6 @@ function clampSize(widget, columns, rows = Infinity) { "w": GridLayout.dimension(widget.w, spec.minW, spec.maxW, spec.w, spec.step), "h": GridLayout.dimension(widget.h, spec.minH, spec.maxH, spec.h, spec.step) }; - if (widget.id === "quickActions") { - const count = enabledQuickActions(widget).length; - const capacity = ControlCenter.CcMetrics.actionCapacity(size.w); - const neededRows = ControlCenter.CcMetrics.actionSpan(Math.ceil(count / capacity)); - if (neededRows <= rows) { - size.h = Math.max(size.h, neededRows); - return size; - } - size.h = rows; - size.w = Math.min(columns, Math.max(size.w, ControlCenter.CcMetrics.actionSpan(Math.ceil(count / ControlCenter.CcMetrics.actionCapacity(rows))))); - return size; - } if (!isSliderWidget(widget.id) || size.w >= 2 || size.h >= 2) return size; if (rows > 1) @@ -161,21 +111,30 @@ function generateUniqueId() { function removeWidget(index) { const widgets = Common.SettingsData.controlCenterWidgets.slice(); - if (index < 0 || index >= widgets.length || isPinned(widgets[index]?.id)) + if (index < 0 || index >= widgets.length) return; widgets.splice(index, 1); Common.SettingsData.set("controlCenterWidgets", widgets); } +function setOption(index, key, value) { + const widgets = Common.SettingsData.controlCenterWidgets.slice(); + if (index < 0 || index >= widgets.length) + return; + widgets[index] = Object.assign({}, widgets[index], { + [key]: value + }); + Common.SettingsData.set("controlCenterWidgets", widgets); +} + function setLayout(widgets) { Common.SettingsData.set("controlCenterWidgets", widgets); } -function resetToDefault(columns) { - const ids = ["userCard", "quickActions", "volumeSlider", "brightnessSlider", "wifi", "bluetooth", "audioOutput", "audioInput", "nightMode", "darkMode"]; - Common.SettingsData.set("controlCenterWidgets", ids.map(id => defaultWidget(id, columns))); +function resetToDefault() { + Common.SettingsData.resetToDefault(["controlCenterWidgets", "controlCenterColumns"]); } -function clearAll(columns) { - Common.SettingsData.set("controlCenterWidgets", pinnedWidgets(columns)); +function clearAll() { + Common.SettingsData.set("controlCenterWidgets", []); } diff --git a/quickshell/Modules/DankBar/Widgets/RunningApps.qml b/quickshell/Modules/DankBar/Widgets/RunningApps.qml index 997140ba1..f8ac8d94e 100644 --- a/quickshell/Modules/DankBar/Widgets/RunningApps.qml +++ b/quickshell/Modules/DankBar/Widgets/RunningApps.qml @@ -74,20 +74,7 @@ BasePill { readonly property bool _currentWorkspace: SettingsData.widgetOption("runningApps", widgetData, "runningAppsCurrentWorkspace") readonly property bool _currentMonitor: SettingsData.widgetOption("runningApps", widgetData, "runningAppsCurrentMonitor") readonly property bool _groupByApp: SettingsData.widgetOption("runningApps", widgetData, "runningAppsGroupByApp") - readonly property string windowModelKey: { - if (_groupByApp) - return "appId"; - switch (CompositorService.compositor) { - case "aqueous": - return AqueousService.available ? "aqueousKey" : "address"; - case "niri": - return "niriWindowId"; - case "mango": - return "mangoWindowId"; - default: - return "address"; - } - } + readonly property string windowModelKey: _groupByApp ? "appId" : CompositorService.toplevelKey readonly property var sortedToplevels: { _toplevelsUpdateTrigger; diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 2145e9c83..a23ea789e 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -790,6 +790,57 @@ Singleton { return toplevels; } + readonly property string toplevelKey: { + switch (compositor) { + case "aqueous": + return AqueousService.available ? "aqueousKey" : "address"; + case "niri": + return "niriWindowId"; + case "mango": + return "mangoWindowId"; + default: + return "address"; + } + } + + function toplevelPlacement(toplevel) { + if (!toplevel) + return { + "workspace": "", + "output": "" + }; + if (isAqueous && AqueousService.available) { + const workspace = AqueousService.workspaces.find(w => w.id === toplevel.aqueousWorkspaceId); + return { + "workspace": String(workspace?.name || workspace?.number || ""), + "output": AqueousService.outputs.find(o => o.id === toplevel.aqueousOutputId)?.name ?? "" + }; + } + if (useNiriSorting) { + const workspace = NiriService.allWorkspaces.find(w => w.id === toplevel.niriWorkspaceId); + return { + "workspace": String(workspace?.name || workspace?.idx || ""), + "output": workspace?.output ?? "" + }; + } + if (useMangoSorting) + return { + "workspace": (toplevel.mangoTags || []).join(", "), + "output": toplevel.mangoMonitor ?? "" + }; + if (isHyprland) { + const workspace = WindowModel.hyprlandToplevelFor(Array.from(Hyprland.toplevels?.values || []), toplevel)?.workspace; + return { + "workspace": workspace?.name ?? "", + "output": workspace?.monitor?.name ?? "" + }; + } + return { + "workspace": "", + "output": toplevel.screens?.[0]?.name ?? "" + }; + } + function fullscreenToplevelOnScreen(screenOrName) { const screenName = _screenName(screenOrName); if (isAqueous && AqueousService.available) diff --git a/quickshell/tests/qml/control-center-sizes.qml b/quickshell/tests/qml/control-center-sizes.qml index 0ac9cbd83..5fae4c562 100644 --- a/quickshell/tests/qml/control-center-sizes.qml +++ b/quickshell/tests/qml/control-center-sizes.qml @@ -185,7 +185,7 @@ ShellRoot { h: 12 }, { - id: "diskUsage", + id: "colorPicker", w: 2, h: 2 } @@ -295,17 +295,6 @@ ShellRoot { verticalSlider.destroy(); wait(0); - const stripSlider = sliderComponent.createObject(scene); - let iconClicks = 0; - stripSlider.iconClicked.connect(() => iconClicks++); - size(stripSlider, 4, 1); - settle(); - check(stripSlider.slider.mapToItem(stripSlider, 0, 0).x === 0 && stripSlider.slider.width === stripSlider.width, "strip slider track spans the row"); - mouseClick(stripSlider.slider, stripSlider.slider.width - Theme.spacingXS - CcMetrics.stripIconSize / 2, stripSlider.slider.height / 2); - check(iconClicks === 1 && stripSlider.slider.value === 50, "strip slider inset icon takes the click without moving the value"); - stripSlider.destroy(); - wait(0); - SessionData.recentColors = Array.from({ length: 18 }, (_, i) => Qt.rgba(i / 18, 0.5, 0.5, 1).toString()); diff --git a/quickshell/tests/settings-cc-grid.test.mjs b/quickshell/tests/settings-cc-grid.test.mjs index 835a90a31..0e972ad7b 100644 --- a/quickshell/tests/settings-cc-grid.test.mjs +++ b/quickshell/tests/settings-cc-grid.test.mjs @@ -26,30 +26,7 @@ test("sparse settings migrate without inventing grid keys", () => { assert.equal(sparse.controlCenterWidgets, undefined); }); -test("split header tiles collapse into one pinned header ahead of the layout", () => { - const migrated = store.migrateToVersion({ configVersion: 32, controlCenterWidgets: [{ id: "userCard", w: 4, h: 1, hostname: false }, { id: "wifi", w: 4, h: 1, col: 0, row: 0 }, { id: "quickActions", w: 4, h: 1, powerAccent: true, actions: [{ id: "power", enabled: true }] }] }, 33); - assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)), [{ id: "header", enabled: true, w: 8, h: 1, hostname: false, actions: [{ id: "power", enabled: true }], powerAccent: true }, { id: "wifi", w: 4, h: 1, col: 0, row: 0 }]); - assert.equal(migrated.configVersion, 33); - assert.deepEqual(JSON.parse(JSON.stringify(store.migrateToVersion({ configVersion: 31, controlCenterWidgets: [{ id: "wifi", w: 4, h: 1 }] }, 33).controlCenterWidgets.map(w => w.id))), ["header", "wifi"]); - assert.equal(store.migrateToVersion({ configVersion: 31 }, 33).controlCenterWidgets, undefined); -}); - -test("header splits into independently placed user and action widgets with their options", () => { - const wifi = { id: "wifi", w: 4, h: 1, col: 0, row: 0 }; - const actions = [{ id: "settings", enabled: false }, { id: "edit", enabled: true }]; - const migrated = store.migrateToVersion({ configVersion: 33, controlCenterColumns: 10, controlCenterWidgets: [wifi, { id: "header", w: 8, h: 2, col: 1, row: 3, hostname: false, compositor: false, uptime: false, badge: false, background: false, powerAccent: true, actions }] }, 34); - assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)), [wifi, - { id: "userCard", enabled: true, w: 6.5, h: 2, hostname: false, compositor: false, uptime: false, badge: false, background: false, col: 1, row: 3 }, - { id: "quickActions", enabled: true, w: 1.5, h: 1.5, actions, powerAccent: true, col: 7.5, row: 3 } - ]); - assert.equal(migrated.configVersion, 34); - assert.equal(store.migrateToVersion(migrated, 34), null); -}); - -test("header migration respects hidden identity and retains an edit entry point", () => { - const hidden = store.migrateToVersion({ configVersion: 33, controlCenterWidgets: [{ id: "header", showUser: false, col: 2, row: 1 }] }, 34); - assert.deepEqual(JSON.parse(JSON.stringify(hidden.controlCenterWidgets)), [{ id: "quickActions", enabled: true, w: 1.5, h: 1.5, col: 2, row: 1 }]); - const absent = store.migrateToVersion({ configVersion: 33, controlCenterWidgets: [] }, 34); - assert.equal(absent.controlCenterWidgets[0].id, "quickActions"); - assert.equal(store.migrateToVersion({ configVersion: 33 }, 34).controlCenterWidgets, undefined); +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]]); });