From a33c7e02503fbcfdc014dc189e042ddb81d26a50 Mon Sep 17 00:00:00 2001 From: Divya Jain Date: Thu, 14 May 2026 19:15:05 +0530 Subject: [PATCH] feat: add Display Profiles builtin control center widget (#2410) * feat: add Display Profiles builtin control center widget Adds a new built-in widget that lets users switch between their configured display profiles directly from the Control Center, without opening Settings. - Widget shows the active profile name and cycles profiles on pill click - Detail panel shows all profiles as a button group for direct selection - Integrates with existing DisplayConfigState/SettingsData singletons - Follows the same loader/instance pattern as VPN, CUPS, and Tailscale widgets * fix: refine display profiles widget behavior --- .../BuiltinPlugins/DisplayProfilesWidget.qml | 249 ++++++++++++++++++ .../ControlCenter/Components/DetailHost.qml | 6 + .../ControlCenter/Components/DragDropGrid.qml | 6 + .../ControlCenter/Models/WidgetModel.qml | 38 +++ 4 files changed, 299 insertions(+) create mode 100644 quickshell/Modules/ControlCenter/BuiltinPlugins/DisplayProfilesWidget.qml diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/DisplayProfilesWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/DisplayProfilesWidget.qml new file mode 100644 index 00000000..72837ef5 --- /dev/null +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/DisplayProfilesWidget.qml @@ -0,0 +1,249 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins +import qs.Modules.Settings.DisplayConfig + +PluginComponent { + id: root + + readonly property var allProfiles: DisplayConfigState.validatedProfiles || ({}) + readonly property var profiles: { + const result = []; + for (const id in allProfiles) { + if (allProfiles[id].name) + result.push({ id: id, name: allProfiles[id].name }); + } + return result; + } + readonly property bool autoMode: SettingsData.displayProfileAutoSelect + readonly property string activeProfileId: SettingsData.getActiveDisplayProfile(CompositorService.compositor) + readonly property var activeProfile: allProfiles[activeProfileId] || null + readonly property string activeProfileName: activeProfile?.name ?? "" + readonly property string displayProfileLabel: { + if (autoMode) + return I18n.tr("Auto"); + if (activeProfileName.length > 0) + return activeProfileName; + if (profiles.length === 0) + return I18n.tr("No profiles"); + return I18n.tr("None active"); + } + + ccWidgetIcon: "monitor" + ccWidgetPrimaryText: I18n.tr("Display") + ccWidgetSecondaryText: displayProfileLabel + ccWidgetIsActive: autoMode || activeProfileId.length > 0 + + onCcWidgetToggled: cycleNext() + + function setAutoMode(enabled) { + SettingsData.displayProfileAutoSelect = enabled; + if (!enabled) + SettingsData.setActiveDisplayProfile(CompositorService.compositor, ""); + SettingsData.saveSettings(); + if (enabled) + DisplayConfigState.applyAutoConfig(); + } + + function cycleNext() { + if (autoMode || profiles.length < 2) + return; + const idx = profiles.findIndex(p => p.id === activeProfileId); + const next = profiles[(idx + 1) % profiles.length]; + DisplayConfigState.activateProfile(next.id); + } + + ccDetailContent: Component { + Rectangle { + id: detailRoot + implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.nestedSurface + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + + Column { + id: detailColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + Item { + width: parent.width + height: 32 + + StyledText { + text: I18n.tr("Display Profiles") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + Rectangle { + id: autoButton + width: autoLabel.implicitWidth + Theme.spacingL * 2 + height: 28 + radius: 14 + color: root.autoMode ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : (autoMouseArea.containsMouse ? Theme.surfaceLight : "transparent") + border.color: root.autoMode ? Theme.primary : Theme.outlineMedium + border.width: root.autoMode ? 1 : Theme.layerOutlineWidth + + StyledText { + id: autoLabel + anchors.centerIn: parent + text: I18n.tr("Auto") + color: root.autoMode ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: autoMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setAutoMode(!root.autoMode) + } + } + + DankActionButton { + id: settingsButton + anchors.verticalCenter: parent.verticalCenter + iconName: "settings" + buttonSize: 28 + iconSize: 16 + iconColor: Theme.surfaceVariantText + onClicked: { + PopoutService.closeControlCenter(); + PopoutService.openSettingsWithTab("displays"); + } + } + } + } + + StyledText { + visible: root.autoMode + width: parent.width + text: I18n.tr("Auto mode is on. Manual profile selection is disabled.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + StyledText { + visible: root.profiles.length === 0 + width: parent.width + text: I18n.tr("No display profiles found. Create them in Settings > Displays.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Column { + visible: root.profiles.length > 0 + width: parent.width + spacing: Theme.spacingXS + opacity: root.autoMode ? 0.55 : 1.0 + + Repeater { + model: root.profiles + + delegate: Rectangle { + required property var modelData + + readonly property bool isActive: modelData.id === root.activeProfileId && !root.autoMode + + width: detailColumn.width + height: 44 + radius: Theme.cornerRadius + color: { + if (isActive) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12); + if (profileMouseArea.containsMouse) + return Theme.surfaceLight; + return Theme.floatingSurface; + } + border.color: isActive ? Theme.primary : Theme.outlineMedium + border.width: isActive ? 1 : Theme.layerOutlineWidth + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: isActive ? Font.Medium : Font.Normal + } + + StyledText { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + visible: isActive + text: I18n.tr("Active") + color: Theme.primary + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: profileMouseArea + anchors.fill: parent + hoverEnabled: true + enabled: !root.autoMode + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: DisplayConfigState.activateProfile(modelData.id) + } + } + } + } + } + } + } + + horizontalBarPill: Component { + Row { + spacing: Theme.spacingXS + DankIcon { + name: "monitor" + color: Theme.primary + size: root.iconSize + anchors.verticalCenter: parent.verticalCenter + } + StyledText { + text: root.displayProfileLabel + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + anchors.verticalCenter: parent.verticalCenter + } + } + } + + verticalBarPill: Component { + Column { + spacing: 2 + DankIcon { + name: "monitor" + color: Theme.primary + size: root.iconSize + anchors.horizontalCenter: parent.horizontalCenter + } + StyledText { + text: root.displayProfileLabel + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + anchors.horizontalCenter: parent.horizontalCenter + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml index d9990448..f2983598 100644 --- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml +++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml @@ -135,6 +135,12 @@ Item { } builtinInstance = widgetModel.tailscaleBuiltinInstance; } + if (builtinId === "builtin_display_profiles") { + if (widgetModel?.displayProfilesLoader) { + widgetModel.displayProfilesLoader.active = true; + } + builtinInstance = widgetModel.displayProfilesBuiltinInstance; + } if (!builtinInstance || !builtinInstance.ccDetailContent) { return; diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index 0a92919e..8000f679 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -924,6 +924,12 @@ Column { } builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance); } + if (id === "builtin_display_profiles") { + if (root.model?.displayProfilesLoader) { + root.model.displayProfilesLoader.active = true; + } + builtinInstance = Qt.binding(() => root.model?.displayProfilesBuiltinInstance); + } } sourceComponent: { diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml index f854cb7a..273f0450 100644 --- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml +++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml @@ -11,6 +11,7 @@ QtObject { property var vpnBuiltinInstance: null property var cupsBuiltinInstance: null property var tailscaleBuiltinInstance: null + property var displayProfilesBuiltinInstance: null property var vpnLoader: Loader { active: false @@ -93,6 +94,34 @@ QtObject { } } + property var displayProfilesLoader: Loader { + active: false + sourceComponent: Component { + DisplayProfilesWidget {} + } + + onItemChanged: { + root.displayProfilesBuiltinInstance = item; + } + + onActiveChanged: { + if (!active) + root.displayProfilesBuiltinInstance = null; + } + + Connections { + target: SettingsData + function onControlCenterWidgetsChanged() { + const widgets = SettingsData.controlCenterWidgets || []; + const hasWidget = widgets.some(w => w.id === "builtin_display_profiles"); + if (!hasWidget && displayProfilesLoader.active) { + root.log.debug("No Display Profiles widget in control center, deactivating loader"); + displayProfilesLoader.active = false; + } + } + } + } + readonly property var coreWidgetDefinitions: [ { "id": "nightMode", @@ -242,6 +271,15 @@ QtObject { "enabled": TailscaleService.available, "warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined, "isBuiltinPlugin": true + }, + { + "id": "builtin_display_profiles", + "text": I18n.tr("Display Profiles"), + "description": I18n.tr("Switch between display configurations"), + "icon": "monitor", + "type": "builtin_plugin", + "enabled": true, + "isBuiltinPlugin": true } ]