1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 05:25:41 -05:00

plugins: support control center plugins

This commit is contained in:
bbedward
2025-10-05 21:09:29 -04:00
parent 2b14ef76c9
commit c092cd2921
11 changed files with 593 additions and 34 deletions

View File

@@ -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 {

View File

@@ -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()
}
}
}
}
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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"]
}

View File

@@ -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
}
}
}
}

View File

@@ -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"]
}

View File

@@ -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)
}
```