diff --git a/Modules/ControlCenter/Components/DetailHost.qml b/Modules/ControlCenter/Components/DetailHost.qml index fe1de92a..959db0ec 100644 --- a/Modules/ControlCenter/Components/DetailHost.qml +++ b/Modules/ControlCenter/Components/DetailHost.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Common +import qs.Services import qs.Modules.ControlCenter.Details Item { @@ -9,31 +10,76 @@ Item { property var expandedWidgetData: null property var bluetoothCodecSelector: null + property var pluginDetailInstance: null + Loader { + id: pluginDetailLoader width: parent.width height: 250 y: Theme.spacingS - active: parent.height > 0 - property string sectionKey: root.expandedSection - sourceComponent: { - switch (root.expandedSection) { - case "network": - case "wifi": return networkDetailComponent - case "bluetooth": return bluetoothDetailComponent - case "audioOutput": return audioOutputDetailComponent - case "audioInput": return audioInputDetailComponent - case "battery": return batteryDetailComponent - default: - if (root.expandedSection.startsWith("diskUsage_")) { - return diskUsageDetailComponent - } - return null + active: false + sourceComponent: null + } + + Loader { + id: coreDetailLoader + width: parent.width + height: 250 + y: Theme.spacingS + active: false + sourceComponent: null + } + + onExpandedSectionChanged: { + if (pluginDetailInstance) { + pluginDetailInstance.destroy() + pluginDetailInstance = null + } + pluginDetailLoader.active = false + coreDetailLoader.active = false + + if (!root.expandedSection) { + return + } + + if (root.expandedSection.startsWith("plugin_")) { + const pluginId = root.expandedSection.replace("plugin_", "") + const pluginComponent = PluginService.pluginWidgetComponents[pluginId] + if (!pluginComponent) { + return } + + pluginDetailInstance = pluginComponent.createObject(null) + if (!pluginDetailInstance || !pluginDetailInstance.ccDetailContent) { + if (pluginDetailInstance) { + pluginDetailInstance.destroy() + pluginDetailInstance = null + } + return + } + + pluginDetailLoader.sourceComponent = pluginDetailInstance.ccDetailContent + pluginDetailLoader.active = parent.height > 0 + return } - onSectionKeyChanged: { - active = false - active = true + + if (root.expandedSection.startsWith("diskUsage_")) { + coreDetailLoader.sourceComponent = diskUsageDetailComponent + coreDetailLoader.active = parent.height > 0 + return } + + switch (root.expandedSection) { + case "network": + case "wifi": coreDetailLoader.sourceComponent = networkDetailComponent; break + case "bluetooth": coreDetailLoader.sourceComponent = bluetoothDetailComponent; break + case "audioOutput": coreDetailLoader.sourceComponent = audioOutputDetailComponent; break + case "audioInput": coreDetailLoader.sourceComponent = audioInputDetailComponent; break + case "battery": coreDetailLoader.sourceComponent = batteryDetailComponent; break + default: return + } + + coreDetailLoader.active = parent.height > 0 } Component { diff --git a/Modules/ControlCenter/Components/DragDropGrid.qml b/Modules/ControlCenter/Components/DragDropGrid.qml index 952e8860..14e6b9be 100644 --- a/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/Modules/ControlCenter/Components/DragDropGrid.qml @@ -121,7 +121,9 @@ Column { widgetComponent: { const id = modelData.id || "" - if (id === "wifi" || id === "bluetooth" || id === "audioOutput" || id === "audioInput") { + if (id.startsWith("plugin_")) { + return pluginWidgetComponent + } else if (id === "wifi" || id === "bluetooth" || id === "audioOutput" || id === "audioInput") { return compoundPillComponent } else if (id === "volumeSlider") { return audioSliderComponent @@ -699,4 +701,137 @@ enabled: !root.editMode colorPickerModal: root.colorPickerModal } } + + Component { + id: pluginWidgetComponent + Loader { + property var widgetData: parent.widgetData || {} + property int widgetIndex: parent.widgetIndex || 0 + property int widgetWidth: widgetData.width || 50 + width: parent.width + height: 60 + + property var pluginInstance: null + property string pluginId: widgetData.id?.replace("plugin_", "") || "" + + sourceComponent: { + if (!pluginInstance) return null + + const hasDetail = pluginInstance.ccDetailContent !== null + + if (widgetWidth <= 25) { + return pluginSmallToggleComponent + } else if (hasDetail) { + return pluginCompoundPillComponent + } else { + return pluginToggleComponent + } + } + + Component.onCompleted: { + Qt.callLater(() => { + const pluginComponent = PluginService.pluginWidgetComponents[pluginId] + if (pluginComponent) { + const instance = pluginComponent.createObject(null, { + pluginId: pluginId, + pluginService: PluginService, + visible: false, + width: 0, + height: 0 + }) + if (instance) { + pluginInstance = instance + } + } + }) + } + + Connections { + target: PluginService + function onPluginDataChanged(changedPluginId) { + if (changedPluginId === pluginId && pluginInstance) { + pluginInstance.loadPluginData() + } + } + } + + Component.onDestruction: { + if (pluginInstance) { + pluginInstance.destroy() + } + } + } + } + + Component { + id: pluginCompoundPillComponent + CompoundPill { + property var widgetData: parent.widgetData || {} + property int widgetIndex: parent.widgetIndex || 0 + property var pluginInstance: parent.pluginInstance + + iconName: pluginInstance?.ccWidgetIcon || "extension" + primaryText: pluginInstance?.ccWidgetPrimaryText || "Plugin" + secondaryText: pluginInstance?.ccWidgetSecondaryText || "" + isActive: pluginInstance?.ccWidgetIsActive || false + + onToggled: { + if (root.editMode) return + if (pluginInstance) { + pluginInstance.ccWidgetToggled() + } + } + + onExpandClicked: { + if (root.editMode) return + root.expandClicked(widgetData, widgetIndex) + } + } + } + + Component { + id: pluginToggleComponent + ToggleButton { + property var widgetData: parent.widgetData || {} + property int widgetIndex: parent.widgetIndex || 0 + property var pluginInstance: parent.pluginInstance + property var widgetDef: root.model?.getWidgetForId(widgetData.id || "") + + iconName: pluginInstance?.ccWidgetIcon || widgetDef?.icon || "extension" + text: pluginInstance?.ccWidgetPrimaryText || widgetDef?.text || "Plugin" + secondaryText: pluginInstance?.ccWidgetSecondaryText || "" + isActive: pluginInstance?.ccWidgetIsActive || false + enabled: !root.editMode + + onClicked: { + if (root.editMode) return + if (pluginInstance) { + pluginInstance.ccWidgetToggled() + } + } + } + } + + Component { + id: pluginSmallToggleComponent + SmallToggleButton { + property var widgetData: parent.widgetData || {} + property int widgetIndex: parent.widgetIndex || 0 + property var pluginInstance: parent.pluginInstance + property var widgetDef: root.model?.getWidgetForId(widgetData.id || "") + + iconName: pluginInstance?.ccWidgetIcon || widgetDef?.icon || "extension" + isActive: pluginInstance?.ccWidgetIsActive || false + enabled: !root.editMode + + onClicked: { + if (root.editMode) return + if (pluginInstance && pluginInstance.ccDetailContent) { + root.expandClicked(widgetData, widgetIndex) + } else if (pluginInstance) { + pluginInstance.ccWidgetToggled() + } + } + } + } } diff --git a/Modules/ControlCenter/ControlCenterPopout.qml b/Modules/ControlCenter/ControlCenterPopout.qml index ab8c0ea0..84b4d2f0 100644 --- a/Modules/ControlCenter/ControlCenterPopout.qml +++ b/Modules/ControlCenter/ControlCenterPopout.qml @@ -167,8 +167,10 @@ DankPopout { visible: editMode popoutContent: controlContent availableWidgets: { + if (!editMode) return [] const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id) - return widgetModel.baseWidgetDefinitions.filter(w => w.allowMultiple || !existingIds.includes(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() diff --git a/Modules/ControlCenter/Models/WidgetModel.qml b/Modules/ControlCenter/Models/WidgetModel.qml index d4daf1ba..0d3d84e2 100644 --- a/Modules/ControlCenter/Models/WidgetModel.qml +++ b/Modules/ControlCenter/Models/WidgetModel.qml @@ -6,7 +6,7 @@ import "../utils/widgets.js" as WidgetUtils QtObject { id: root - readonly property var baseWidgetDefinitions: [ + readonly property var coreWidgetDefinitions: [ { "id": "nightMode", "text": "Night Mode", @@ -127,6 +127,51 @@ QtObject { } ] + function getPluginWidgets() { + const plugins = [] + const loadedPlugins = PluginService.getLoadedPlugins() + + for (let i = 0; i < loadedPlugins.length; i++) { + const plugin = loadedPlugins[i] + + if (plugin.type === "daemon") { + continue + } + + const pluginComponent = PluginService.pluginWidgetComponents[plugin.id] + if (!pluginComponent) { + continue + } + + const tempInstance = pluginComponent.createObject(null) + if (!tempInstance) { + continue + } + + const hasCCWidget = tempInstance.ccWidgetIcon && tempInstance.ccWidgetIcon.length > 0 + tempInstance.destroy() + + if (!hasCCWidget) { + continue + } + + plugins.push({ + "id": "plugin_" + plugin.id, + "pluginId": plugin.id, + "text": plugin.name || "Plugin", + "description": plugin.description || "", + "icon": plugin.icon || "extension", + "type": "plugin", + "enabled": true, + "isPlugin": true + }) + } + + return plugins + } + + readonly property var baseWidgetDefinitions: coreWidgetDefinitions + function getWidgetForId(widgetId) { return WidgetUtils.getWidgetForId(baseWidgetDefinitions, widgetId) } diff --git a/Modules/Plugins/PluginComponent.qml b/Modules/Plugins/PluginComponent.qml index 5d051c06..fc68128d 100644 --- a/Modules/Plugins/PluginComponent.qml +++ b/Modules/Plugins/PluginComponent.qml @@ -21,6 +21,18 @@ Item { property real popoutHeight: 400 property var pillClickAction: null + property Component controlCenterWidget: null + property string ccWidgetIcon: "" + property string ccWidgetPrimaryText: "" + property string ccWidgetSecondaryText: "" + property bool ccWidgetIsActive: false + property bool ccWidgetIsToggle: true + property Component ccDetailContent: null + property real ccDetailHeight: 250 + + signal ccWidgetToggled() + signal ccWidgetExpanded() + property var pluginData: ({}) readonly property bool isVertical: axis?.isVertical ?? false diff --git a/Modules/Plugins/PluginControlCenterWrapper.qml b/Modules/Plugins/PluginControlCenterWrapper.qml new file mode 100644 index 00000000..5d408263 --- /dev/null +++ b/Modules/Plugins/PluginControlCenterWrapper.qml @@ -0,0 +1,51 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property string pluginId: "" + property var pluginInstance: null + property bool isCompoundPill: false + property bool isSmallToggle: false + + readonly property bool hasDetail: pluginInstance?.ccDetailContent !== null + readonly property string iconName: pluginInstance?.ccWidgetIcon || "extension" + readonly property string primaryText: pluginInstance?.ccWidgetPrimaryText || "Plugin" + readonly property string secondaryText: pluginInstance?.ccWidgetSecondaryText || "" + readonly property bool isActive: pluginInstance?.ccWidgetIsActive || false + readonly property Component detailContent: pluginInstance?.ccDetailContent || null + readonly property real detailHeight: pluginInstance?.ccDetailHeight || 250 + + signal toggled() + signal expanded() + + Component.onCompleted: { + if (pluginInstance) { + pluginInstance.ccWidgetToggled.connect(handleToggled) + pluginInstance.ccWidgetExpanded.connect(handleExpanded) + } + } + + function handleToggled() { + toggled() + } + + function handleExpanded() { + expanded() + } + + function invokeToggle() { + if (pluginInstance) { + pluginInstance.ccWidgetToggled() + } + } + + function invokeExpand() { + if (pluginInstance) { + pluginInstance.ccWidgetExpanded() + } + } +} diff --git a/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml b/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml new file mode 100644 index 00000000..7d619005 --- /dev/null +++ b/PLUGINS/ControlCenterDetailExample/DetailExampleWidget.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins + +PluginComponent { + id: root + + property bool isEnabled: pluginData.isEnabled || false + property var options: pluginData.options || ["Option A", "Option B", "Option C"] + + ccWidgetIcon: isEnabled ? "settings" : "settings" + ccWidgetPrimaryText: "Detail Example" + ccWidgetSecondaryText: { + if (isEnabled) { + const selected = pluginData.selectedOption || "Option A" + return selected + } + return "Disabled" + } + ccWidgetIsActive: isEnabled + + onCcWidgetToggled: { + isEnabled = !isEnabled + if (pluginService) { + pluginService.savePluginData("controlCenterDetailExample", "isEnabled", isEnabled) + } + } + + ccDetailContent: Component { + Rectangle { + id: detailRoot + implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.width: 0 + visible: true + + property var options: ["Option A", "Option B", "Option C"] + property string currentSelection: SettingsData.getPluginSetting("controlCenterDetailExample", "selectedOption", "Option A") + + Column { + id: detailColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + StyledText { + text: "Detail Example Settings" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + } + + StyledText { + text: "Select an option below:" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + Repeater { + model: detailRoot.options + + Rectangle { + width: parent.width + height: 40 + radius: Theme.cornerRadius + color: optionMouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent" + border.color: detailRoot.currentSelection === modelData ? Theme.primary : "transparent" + border.width: detailRoot.currentSelection === modelData ? 2 : 0 + + MouseArea { + id: optionMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + z: 100 + onClicked: { + SettingsData.setPluginSetting("controlCenterDetailExample", "selectedOption", modelData) + detailRoot.currentSelection = modelData + PluginService.pluginDataChanged("controlCenterDetailExample") + ToastService.showInfo("Option Selected", modelData) + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + enabled: false + + DankIcon { + name: detailRoot.currentSelection === modelData ? "radio_button_checked" : "radio_button_unchecked" + color: detailRoot.currentSelection === modelData ? Theme.primary : Theme.surfaceVariantText + size: Theme.iconSize + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: modelData + color: detailRoot.currentSelection === modelData ? Theme.primary : Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + + horizontalBarPill: Component { + Row { + spacing: Theme.spacingXS + + DankIcon { + name: root.isEnabled ? "settings" : "settings_off" + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.iconSize - 4 + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: { + const selected = root.pluginData.selectedOption || "Option A" + return selected.substring(0, 1) + } + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeMedium + anchors.verticalCenter: parent.verticalCenter + } + } + } +} diff --git a/PLUGINS/ControlCenterDetailExample/plugin.json b/PLUGINS/ControlCenterDetailExample/plugin.json new file mode 100644 index 00000000..cbba7ee8 --- /dev/null +++ b/PLUGINS/ControlCenterDetailExample/plugin.json @@ -0,0 +1,11 @@ +{ + "id": "controlCenterDetailExample", + "name": "CC Detail Example", + "description": "Example plugin with Control Center detail dropdown", + "version": "1.0.0", + "author": "DankMaterialShell", + "icon": "settings", + "type": "widget", + "component": "./DetailExampleWidget.qml", + "permissions": ["settings_read", "settings_write"] +} diff --git a/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml b/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml new file mode 100644 index 00000000..450d538c --- /dev/null +++ b/PLUGINS/ControlCenterExample/ControlCenterExampleWidget.qml @@ -0,0 +1,68 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins + +PluginComponent { + id: root + + property bool isEnabled: pluginData.isEnabled || false + property int clickCount: pluginData.clickCount || 0 + + ccWidgetIcon: isEnabled ? "toggle_on" : "toggle_off" + ccWidgetPrimaryText: "Example Toggle" + ccWidgetSecondaryText: isEnabled ? `Active • ${clickCount} clicks` : "Inactive" + ccWidgetIsActive: isEnabled + + onCcWidgetToggled: { + isEnabled = !isEnabled + clickCount += 1 + if (pluginService) { + pluginService.savePluginData("controlCenterExample", "isEnabled", isEnabled) + pluginService.savePluginData("controlCenterExample", "clickCount", clickCount) + } + ToastService.showInfo("Example Toggle", isEnabled ? "Activated!" : "Deactivated!") + } + + horizontalBarPill: Component { + Row { + spacing: Theme.spacingXS + + DankIcon { + name: root.isEnabled ? "toggle_on" : "toggle_off" + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.iconSize - 4 + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: `${root.clickCount}` + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeMedium + anchors.verticalCenter: parent.verticalCenter + } + } + } + + verticalBarPill: Component { + Column { + spacing: Theme.spacingXS + + DankIcon { + name: root.isEnabled ? "toggle_on" : "toggle_off" + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.iconSize - 4 + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: `${root.clickCount}` + color: root.isEnabled ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + anchors.horizontalCenter: parent.horizontalCenter + } + } + } +} diff --git a/PLUGINS/ControlCenterExample/plugin.json b/PLUGINS/ControlCenterExample/plugin.json new file mode 100644 index 00000000..b7430607 --- /dev/null +++ b/PLUGINS/ControlCenterExample/plugin.json @@ -0,0 +1,11 @@ +{ + "id": "controlCenterExample", + "name": "CC Toggle Example", + "description": "Example plugin with Control Center toggle widget", + "version": "1.0.0", + "author": "DankMaterialShell", + "icon": "toggle_on", + "type": "widget", + "component": "./ControlCenterExampleWidget.qml", + "permissions": ["settings_read", "settings_write"] +} diff --git a/PLUGINS/README.md b/PLUGINS/README.md index 1d8cb94b..c340f798 100644 --- a/PLUGINS/README.md +++ b/PLUGINS/README.md @@ -1,10 +1,10 @@ # Plugin System -The DMS shell includes an experimental plugin system that allows extending functionality through self-contained, dynamically-loaded QML components. +Create widgets for DankBar and Control Center using dynamically-loaded QML components. ## Overview -The plugin system enables developers to create custom widgets that can be displayed in the DankBar alongside built-in widgets. Plugins are discovered, loaded, and managed through the **PluginService**, providing a clean separation between core shell functionality and user extensions. +Plugins let you add custom widgets to DankBar and Control Center. They're discovered from `~/.config/DankMaterialShell/plugins/` and managed via PluginService. ## Architecture @@ -161,25 +161,65 @@ PluginComponent { - `popoutHeight`: Popout window height - `pillClickAction`: Custom click handler function (overrides popout) -**Custom Click Actions:** +### Control Center Integration -Override the default popout behavior with `pillClickAction`: +Add your plugin to Control Center by defining CC properties: ```qml PluginComponent { - horizontalBarPill: Component { - StyledText { text: "Click Me" } + ccWidgetIcon: "toggle_on" + ccWidgetPrimaryText: "My Feature" + ccWidgetSecondaryText: isEnabled ? "Active" : "Inactive" + ccWidgetIsActive: isEnabled + + onCcWidgetToggled: { + isEnabled = !isEnabled + if (pluginService) { + pluginService.savePluginData("myPlugin", "isEnabled", isEnabled) + } } - // Simple 0-parameter function - pillClickAction: () => { - Process.exec("bash", ["-c", "notify-send 'Clicked!'"]) + ccDetailContent: Component { + Rectangle { + implicitHeight: 200 + color: Theme.surfaceContainerHigh + radius: Theme.cornerRadius + // Your detail UI here + } } - // Or with position parameters for popouts: (x, y, width, section, screen) - pillClickAction: (x, y, width, section, screen) => { - popoutService?.toggleControlCenter(x, y, width, section, screen) - } + horizontalBarPill: Component { /* ... */ } +} +``` + +**CC Properties:** +- `ccWidgetIcon`: Material icon name +- `ccWidgetPrimaryText`: Main label +- `ccWidgetSecondaryText`: Subtitle/status +- `ccWidgetIsActive`: Active state styling +- `ccDetailContent`: Optional dropdown panel (use for CompoundPill) + +**Signals:** +- `ccWidgetToggled()`: Fired when icon clicked +- `ccWidgetExpanded()`: Fired when expand area clicked (CompoundPill only) + +**Widget Sizing:** +- 25% width → SmallToggleButton (icon only) +- 50% width → ToggleButton (no detail) or CompoundPill (with detail) +- Users can resize in edit mode + +**Custom Click Actions:** + +Override default popout with `pillClickAction`: + +```qml +pillClickAction: () => { + Process.exec("bash", ["-c", "notify-send 'Clicked!'"]) +} + +// Or with position params: (x, y, width, section, screen) +pillClickAction: (x, y, width, section, screen) => { + popoutService?.toggleControlCenter(x, y, width, section, screen) } ```