1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-25 05:52:50 -05:00

Compare commits

..

4 Commits

Author SHA1 Message Date
bbedward
0ca12d275c Abstract away plugin dev a little more 2025-10-01 17:47:39 -04:00
bbedward
df9e834309 Some consistent styling for plugins 2025-10-01 14:04:17 -04:00
bbedward
ab1c0bb129 Merge branch 'master' of github.com:bbedward/DankMaterialShell into wip/plugins 2025-10-01 13:38:49 -04:00
Bruno Cesar Rocha
53983933dc feat: Plugin System (#276)
* feat: Plugin System

* fix: merge conflicts
2025-10-01 11:28:10 -04:00
26 changed files with 2921 additions and 58 deletions

167
CLAUDE.md
View File

@@ -63,6 +63,9 @@ quickshell -p shell.qml
# Or use the shorthand
qs -p .
# Run with verbose output for debugging
qs -v -p shell.qml
# Code formatting and linting
qmlfmt -t 4 -i 4 -b 250 -w /path/to/file.qml # Format a QML file (requires qmlfmt, do not use qmlformat)
qmllint **/*.qml # Lint all QML files for syntax errors
@@ -89,6 +92,7 @@ shell.qml # Main entry point (minimal orchestration)
│ ├── DisplayService.qml
│ ├── NotificationService.qml
│ ├── WeatherService.qml
│ ├── PluginService.qml
│ └── [14 more services]
├── Modules/ # UI components (93 files)
│ ├── TopBar/ # Panel components (13 files)
@@ -104,15 +108,21 @@ shell.qml # Main entry point (minimal orchestration)
│ ├── SettingsModal.qml
│ ├── ClipboardHistoryModal.qml
│ ├── ProcessListModal.qml
│ ├── PluginSettingsModal.qml
│ └── [7 more modals]
── Widgets/ # Reusable UI controls (19 files)
├── DankIcon.qml
├── DankSlider.qml
├── DankToggle.qml
├── DankTabBar.qml
├── DankGridView.qml
├── DankListView.qml
└── [13 more widgets]
── Widgets/ # Reusable UI controls (19 files)
├── DankIcon.qml
├── DankSlider.qml
├── DankToggle.qml
├── DankTabBar.qml
├── DankGridView.qml
├── DankListView.qml
└── [13 more widgets]
└── plugins/ # External plugins directory ($CONFIGPATH/DankMaterialShell/plugins/)
└── PluginName/ # Example Plugin structure
├── plugin.json # Plugin manifest
├── PluginNameWidget.qml # Widget component
└── PluginNameSettings.qml # Settings UI
```
### Component Organization
@@ -163,6 +173,12 @@ shell.qml # Main entry point (minimal orchestration)
- **DankLocationSearch**: Location picker with search
- **SystemLogo**: Animated system branding component
7. **Plugins/** - External plugin system (`$CONFIGPATH/DankMaterialShell/plugins/`)
- **PluginService**: Discovers, loads, and manages plugin lifecycle
- **Dynamic Loading**: Plugins loaded at runtime from external directory
- **DankBar Integration**: Plugin widgets rendered alongside built-in widgets
- **Settings System**: Per-plugin settings with persistence
### Key Architectural Patterns
1. **Singleton Services Pattern**:
@@ -408,10 +424,10 @@ When modifying the shell:
Singleton {
id: root
property bool featureAvailable: false
property type currentValue: defaultValue
function performAction(param) {
// Implementation
}
@@ -422,7 +438,7 @@ When modifying the shell:
```qml
// In module files
property alias serviceValue: NewService.currentValue
SomeControl {
visible: NewService.featureAvailable
enabled: NewService.featureAvailable
@@ -430,6 +446,134 @@ When modifying the shell:
}
```
### Creating Plugins
Plugins are external, dynamically-loaded components that extend DankBar functionality. Plugins are stored in `~/.config/DankMaterialShell/plugins/` and have their settings isolated from core DMS settings.
1. **Create plugin directory**:
```bash
mkdir -p ~/.config/DankMaterialShell/plugins/YourPlugin
```
2. **Create manifest** (`plugin.json`):
```json
{
"id": "yourPlugin",
"name": "Your Plugin",
"description": "Widget description",
"version": "1.0.0",
"author": "Your Name",
"icon": "extension",
"component": "./YourWidget.qml",
"settings": "./YourSettings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
3. **Create widget component** (`YourWidget.qml`):
```qml
import QtQuick
import qs.Services
Rectangle {
id: root
property bool compactMode: false
property string section: "center"
property real widgetHeight: 30
property var pluginService: null
width: content.implicitWidth + 16
height: widgetHeight
radius: 8
color: "#20FFFFFF"
Component.onCompleted: {
if (pluginService) {
var data = pluginService.loadPluginData("yourPlugin", "key", defaultValue)
}
}
}
```
4. **Create settings component** (`YourSettings.qml`):
```qml
import QtQuick
import QtQuick.Controls
FocusScope {
id: root
property var pluginService: null
implicitHeight: settingsColumn.implicitHeight
height: implicitHeight
Column {
id: settingsColumn
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: "Your Plugin Settings"
font.pixelSize: 18
font.weight: Font.Bold
}
// Your settings UI here
}
function saveSettings(key, value) {
if (pluginService) {
pluginService.savePluginData("yourPlugin", key, value)
}
}
function loadSettings(key, defaultValue) {
if (pluginService) {
return pluginService.loadPluginData("yourPlugin", key, defaultValue)
}
return defaultValue
}
}
```
5. **Enable plugin**:
- Open Settings → Plugins
- Click "Scan for Plugins"
- Toggle plugin to enable
- Add plugin ID to DankBar widget list
**Plugin Directory Structure:**
```
~/.config/DankMaterialShell/
├── settings.json # Core DMS settings + plugin settings
│ └── pluginSettings: {
│ └── yourPlugin: {
│ ├── enabled: true,
│ └── customData: {...}
│ }
│ }
└── plugins/ # Plugin files directory
└── YourPlugin/ # Plugin directory (matches manifest ID)
├── plugin.json # Plugin manifest
├── YourWidget.qml # Widget component
└── YourSettings.qml # Settings UI (optional)
```
**Key Plugin APIs:**
- `pluginService.loadPluginData(pluginId, key, default)` - Load persistent data
- `pluginService.savePluginData(pluginId, key, value)` - Save persistent data
- `PluginService.enablePlugin(pluginId)` - Load plugin
- `PluginService.disablePlugin(pluginId)` - Unload plugin
**Important Notes:**
- Plugin settings are automatically injected by the PluginService via `item.pluginService = PluginService`
- Settings are stored in the main settings.json but namespaced under `pluginSettings.{pluginId}`
- Plugin directories must match the plugin ID in the manifest
- Use the injected `pluginService` property in both widget and settings components
### Debugging Common Issues
1. **Import errors**: Check import paths
@@ -454,6 +598,7 @@ When modifying the shell:
- **Function Discovery**: Use grep/search tools to find existing utility functions before implementing new ones
- **Modern QML Patterns**: Leverage new widgets like DankTextField, DankDropdown, CachingImage
- **Structured Organization**: Follow the established Services/Modules/Widgets/Modals separation
- **Plugin System**: For user extensions, create plugins instead of modifying core modules - see docs/PLUGINS.md
### Common Widget Patterns

View File

@@ -175,6 +175,8 @@ Singleton {
property bool _loading: false
property var pluginSettings: ({})
function getEffectiveTimeFormat() {
if (use24HourClock) {
return Locale.ShortFormat
@@ -361,6 +363,7 @@ Singleton {
widgetBackgroundColor = settings.widgetBackgroundColor !== undefined ? settings.widgetBackgroundColor : "sch"
surfaceBase = settings.surfaceBase !== undefined ? settings.surfaceBase : "s"
screenPreferences = settings.screenPreferences !== undefined ? settings.screenPreferences : ({})
pluginSettings = settings.pluginSettings !== undefined ? settings.pluginSettings : ({})
animationSpeed = settings.animationSpeed !== undefined ? settings.animationSpeed : SettingsData.AnimationSpeed.Short
applyStoredTheme()
detectAvailableIconThemes()
@@ -481,6 +484,7 @@ Singleton {
"notificationTimeoutCritical": notificationTimeoutCritical,
"notificationPopupPosition": notificationPopupPosition,
"screenPreferences": screenPreferences,
"pluginSettings": pluginSettings,
"animationSpeed": animationSpeed
}, null, 2))
}
@@ -1266,6 +1270,33 @@ Singleton {
return Quickshell.screens.filter(screen => prefs.includes(screen.name))
}
// Plugin settings functions
function getPluginSetting(pluginId, key, defaultValue) {
if (!pluginSettings[pluginId]) {
return defaultValue
}
return pluginSettings[pluginId][key] !== undefined ? pluginSettings[pluginId][key] : defaultValue
}
function setPluginSetting(pluginId, key, value) {
if (!pluginSettings[pluginId]) {
pluginSettings[pluginId] = {}
}
pluginSettings[pluginId][key] = value
saveSettings()
}
function removePluginSettings(pluginId) {
if (pluginSettings[pluginId]) {
delete pluginSettings[pluginId]
saveSettings()
}
}
function getPluginSettingsForPlugin(pluginId) {
return pluginSettings[pluginId] || {}
}
function setAnimationSpeed(speed) {
animationSpeed = speed
saveSettings()

View File

@@ -22,16 +22,19 @@ import qs.Modules.ProcessList
import qs.Modules.Settings
import qs.Modules.DankBar
import qs.Modules.DankBar.Popouts
import qs.Modules.Plugins
import qs.Services
Item {
Component.onCompleted: {
PortalService.init()
// Initialize DisplayService night mode functionality
DisplayService.nightModeEnabled
// Initialize WallpaperCyclingService
WallpaperCyclingService.cyclingActive
PortalService.init()
// Initialize DisplayService night mode functionality
DisplayService.nightModeEnabled
// Initialize WallpaperCyclingService
WallpaperCyclingService.cyclingActive
// Initialize PluginService by accessing its properties
PluginService.pluginDirectory
}
WallpaperBackground {}

View File

@@ -154,13 +154,26 @@ Item {
}
Loader {
id: aboutLoader
id: pluginsLoader
anchors.fill: parent
active: root.currentIndex === 10
visible: active
asynchronous: true
sourceComponent: PluginsTab {
}
}
Loader {
id: aboutLoader
anchors.fill: parent
active: root.currentIndex === 11
visible: active
asynchronous: true
sourceComponent: AboutTab {
}

View File

@@ -34,7 +34,7 @@ DankModal {
objectName: "settingsModal"
width: 800
height: 750
height: 800
visible: false
onBackgroundClicked: () => {
return hide();

View File

@@ -38,6 +38,9 @@ Rectangle {
}, {
"text": "Power",
"icon": "power_settings_new"
}, {
"text": "Plugins",
"icon": "extension"
}, {
"text": "About",
"icon": "info"

View File

@@ -9,6 +9,10 @@ Item {
property var components: null
property bool noBackground: false
required property var axis
property string section: "center"
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
readonly property bool isVertical: axis?.isVertical ?? false
readonly property real spacing: noBackground ? 2 : Theme.spacingXS
@@ -266,7 +270,8 @@ Item {
}
function getWidgetComponent(widgetId) {
const componentMap = {
// Build dynamic component map including plugins
let baseMap = {
"launcherButton": "launcherButtonComponent",
"workspaceSwitcher": "workspaceSwitcherComponent",
"focusedWindow": "focusedWindowComponent",
@@ -296,8 +301,15 @@ Item {
"systemUpdate": "systemUpdateComponent"
}
const componentKey = componentMap[widgetId]
return componentKey ? root.components[componentKey] : null
// For built-in components, get from components property
const componentKey = baseMap[widgetId]
if (componentKey && root.components[componentKey]) {
return root.components[componentKey]
}
// For plugin components, get from PluginService
let pluginMap = PluginService.getWidgetComponents()
return pluginMap[widgetId] || null
}
height: parent.height
@@ -337,6 +349,7 @@ Item {
id: centerRepeater
model: root.widgetsModel
Loader {
property string widgetId: model.widgetId
property var widgetData: model
@@ -362,8 +375,36 @@ Item {
item.axis = root.axis
}
if (root.axis && "isVertical" in item) {
item.isVertical = root.axis.isVertical
try {
item.isVertical = root.axis.isVertical
} catch (e) {
}
}
// Inject properties for plugin widgets
if ("section" in item) {
item.section = root.section
}
if ("parentScreen" in item) {
item.parentScreen = root.parentScreen
}
if ("widgetThickness" in item) {
item.widgetThickness = root.widgetThickness
}
if ("barThickness" in item) {
item.barThickness = root.barThickness
}
// Inject PluginService for plugin widgets
if (item.pluginService !== undefined) {
console.log("CenterSection: Injecting PluginService into plugin widget:", model.widgetId)
item.pluginService = PluginService
if (item.loadTimezones) {
console.log("CenterSection: Calling loadTimezones for widget:", model.widgetId)
item.loadTimezones()
}
}
layoutTimer.restart()
}
@@ -379,4 +420,27 @@ Item {
layoutTimer.restart()
}
}
// Listen for plugin changes and refresh components
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId === pluginId) {
item.sourceComponent = root.getWidgetComponent(pluginId)
}
}
}
function onPluginUnloaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId === pluginId) {
item.sourceComponent = root.getWidgetComponent(pluginId)
}
}
}
}
}

View File

@@ -95,6 +95,24 @@ Item {
Qt.callLater(() => Qt.callLater(forceWidgetRefresh))
}
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
console.log("DankBar: Plugin loaded:", pluginId)
// Force componentMap to update by triggering property change
if (topBarContent) {
topBarContent.updateComponentMap()
}
}
function onPluginUnloaded(pluginId) {
console.log("DankBar: Plugin unloaded:", pluginId)
// Force componentMap to update by triggering property change
if (topBarContent) {
topBarContent.updateComponentMap()
}
}
}
function forceWidgetRefresh() {
}
@@ -366,7 +384,13 @@ Item {
anchors.bottomMargin: !barWindow.isVertical ? SettingsData.dankBarInnerPadding / 2 : Math.max(Theme.spacingXS, SettingsData.dankBarInnerPadding * 0.8)
clip: true
readonly property int availableWidth: width
property int componentMapRevision: 0
function updateComponentMap() {
componentMapRevision++
}
readonly property int availableWidth: width
readonly property int launcherButtonWidth: 40
readonly property int workspaceSwitcherWidth: 120
readonly property int focusedAppMaxWidth: 456
@@ -421,35 +445,44 @@ Item {
return widgetVisibility[widgetId] ?? true
}
readonly property var componentMap: ({
"launcherButton": launcherButtonComponent,
"workspaceSwitcher": workspaceSwitcherComponent,
"focusedWindow": focusedWindowComponent,
"runningApps": runningAppsComponent,
"clock": clockComponent,
"music": mediaComponent,
"weather": weatherComponent,
"systemTray": systemTrayComponent,
"privacyIndicator": privacyIndicatorComponent,
"clipboard": clipboardComponent,
"cpuUsage": cpuUsageComponent,
"memUsage": memUsageComponent,
"diskUsage": diskUsageComponent,
"cpuTemp": cpuTempComponent,
"gpuTemp": gpuTempComponent,
"notificationButton": notificationButtonComponent,
"battery": batteryComponent,
"controlCenterButton": controlCenterButtonComponent,
"idleInhibitor": idleInhibitorComponent,
"spacer": spacerComponent,
"separator": separatorComponent,
"network_speed_monitor": networkComponent,
"keyboard_layout_name": keyboardLayoutNameComponent,
"vpn": vpnComponent,
"notepadButton": notepadButtonComponent,
"colorPicker": colorPickerComponent,
"systemUpdate": systemUpdateComponent
})
readonly property var componentMap: {
// This property depends on componentMapRevision to ensure it updates when plugins change
componentMapRevision;
let baseMap = {
"launcherButton": launcherButtonComponent,
"workspaceSwitcher": workspaceSwitcherComponent,
"focusedWindow": focusedWindowComponent,
"runningApps": runningAppsComponent,
"clock": clockComponent,
"music": mediaComponent,
"weather": weatherComponent,
"systemTray": systemTrayComponent,
"privacyIndicator": privacyIndicatorComponent,
"clipboard": clipboardComponent,
"cpuUsage": cpuUsageComponent,
"memUsage": memUsageComponent,
"diskUsage": diskUsageComponent,
"cpuTemp": cpuTempComponent,
"gpuTemp": gpuTempComponent,
"notificationButton": notificationButtonComponent,
"battery": batteryComponent,
"controlCenterButton": controlCenterButtonComponent,
"idleInhibitor": idleInhibitorComponent,
"spacer": spacerComponent,
"separator": separatorComponent,
"network_speed_monitor": networkComponent,
"keyboard_layout_name": keyboardLayoutNameComponent,
"vpn": vpnComponent,
"notepadButton": notepadButtonComponent,
"colorPicker": colorPickerComponent,
"systemUpdate": systemUpdateComponent
}
// Merge with plugin widgets
let pluginMap = PluginService.getWidgetComponents()
return Object.assign(baseMap, pluginMap)
}
function getWidgetComponent(widgetId) {
return componentMap[widgetId] || null
@@ -504,6 +537,9 @@ Item {
widgetsModel: SettingsData.dankBarLeftWidgetsModel
components: topBarContent.allComponents
noBackground: SettingsData.dankBarNoBackground
parentScreen: barWindow.screen
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
}
RightSection {
@@ -516,6 +552,9 @@ Item {
widgetsModel: SettingsData.dankBarRightWidgetsModel
components: topBarContent.allComponents
noBackground: SettingsData.dankBarNoBackground
parentScreen: barWindow.screen
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
}
CenterSection {
@@ -528,6 +567,9 @@ Item {
widgetsModel: SettingsData.dankBarCenterWidgetsModel
components: topBarContent.allComponents
noBackground: SettingsData.dankBarNoBackground
parentScreen: barWindow.screen
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
}
}
@@ -547,6 +589,9 @@ Item {
widgetsModel: SettingsData.dankBarLeftWidgetsModel
components: topBarContent.allComponents
noBackground: SettingsData.dankBarNoBackground
parentScreen: barWindow.screen
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
}
CenterSection {
@@ -560,6 +605,9 @@ Item {
widgetsModel: SettingsData.dankBarCenterWidgetsModel
components: topBarContent.allComponents
noBackground: SettingsData.dankBarNoBackground
parentScreen: barWindow.screen
widgetThickness: barWindow.widgetThickness
barThickness: barWindow.effectiveBarThickness
}
RightSection {
@@ -1016,6 +1064,7 @@ Item {
}
}
}
}
}
}

View File

@@ -8,6 +8,9 @@ Item {
property var components: null
property bool noBackground: false
required property var axis
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
readonly property bool isVertical: axis?.isVertical ?? false
@@ -38,6 +41,10 @@ Item {
components: root.components
isInColumn: false
axis: root.axis
section: "left"
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
}
}
}
@@ -63,6 +70,10 @@ Item {
components: root.components
isInColumn: true
axis: root.axis
section: "left"
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
}
}
}

View File

@@ -8,6 +8,9 @@ Item {
property var components: null
property bool noBackground: false
required property var axis
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
readonly property bool isVertical: axis?.isVertical ?? false
@@ -40,6 +43,10 @@ Item {
components: root.components
isInColumn: false
axis: root.axis
section: "right"
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
}
}
}
@@ -65,6 +72,10 @@ Item {
components: root.components
isInColumn: true
axis: root.axis
section: "right"
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
}
}
}

View File

@@ -11,6 +11,10 @@ Loader {
property var components: null
property bool isInColumn: false
property var axis: null
property string section: "center"
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
asynchronous: false
@@ -21,22 +25,72 @@ Loader {
signal contentItemReady(var item)
Binding {
target: root.item
when: root.item && "parentScreen" in root.item
property: "parentScreen"
value: root.parentScreen
restoreMode: Binding.RestoreNone
}
Binding {
target: root.item
when: root.item && "section" in root.item
property: "section"
value: root.section
restoreMode: Binding.RestoreNone
}
Binding {
target: root.item
when: root.item && "widgetThickness" in root.item
property: "widgetThickness"
value: root.widgetThickness
restoreMode: Binding.RestoreNone
}
Binding {
target: root.item
when: root.item && "barThickness" in root.item
property: "barThickness"
value: root.barThickness
restoreMode: Binding.RestoreNone
}
Binding {
target: root.item
when: root.item && "axis" in root.item
property: "axis"
value: root.axis
restoreMode: Binding.RestoreNone
}
onLoaded: {
if (item) {
contentItemReady(item)
if (widgetId === "spacer") {
item.spacerSize = Qt.binding(() => spacerSize)
}
if (axis && "axis" in item) {
item.axis = axis
}
if (axis && "isVertical" in item) {
item.isVertical = axis.isVertical
try {
item.isVertical = axis.isVertical
} catch (e) {
}
}
if (item.pluginService !== undefined) {
console.log("WidgetHost: Injecting PluginService into plugin widget:", widgetId)
item.pluginService = PluginService
if (item.loadTimezones) {
console.log("WidgetHost: Calling loadTimezones for widget:", widgetId)
item.loadTimezones()
}
}
}
}
function getWidgetComponent(widgetId, components) {
// Build component map for built-in widgets
const componentMap = {
"launcherButton": components.launcherButtonComponent,
"workspaceSwitcher": components.workspaceSwitcherComponent,
@@ -67,7 +121,14 @@ Loader {
"systemUpdate": components.systemUpdateComponent
}
return componentMap[widgetId] || null
// Check for built-in component first
if (componentMap[widgetId]) {
return componentMap[widgetId]
}
// Check for plugin component
let pluginMap = PluginService.getWidgetComponents()
return pluginMap[widgetId] || null
}
function getWidgetVisible(widgetId, dgopAvailable) {

View File

@@ -0,0 +1,53 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
property var axis: null
property string section: "center"
property var popoutTarget: null
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
property alias content: contentLoader.sourceComponent
readonly property real horizontalPadding: SettingsData.dankBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetThickness / 30))
signal clicked()
width: contentLoader.item ? (contentLoader.item.implicitWidth + horizontalPadding * 2) : 0
height: widgetThickness
radius: SettingsData.dankBarNoBackground ? 0 : Theme.cornerRadius
color: {
if (SettingsData.dankBarNoBackground) {
return "transparent"
}
const baseColor = mouseArea.containsMouse ? Theme.widgetBaseHoverColor : Theme.widgetBaseBackgroundColor
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency)
}
Loader {
id: contentLoader
anchors.centerIn: parent
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: {
if (popoutTarget && popoutTarget.setTriggerPosition) {
const globalPos = mapToGlobal(0, 0)
const currentScreen = parentScreen || Screen
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barThickness, width)
popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen)
}
root.clicked()
}
}
}

View File

@@ -0,0 +1,53 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
property var axis: null
property string section: "center"
property var popoutTarget: null
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
property alias content: contentLoader.sourceComponent
readonly property real horizontalPadding: SettingsData.dankBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetThickness / 30))
signal clicked()
width: widgetThickness
height: contentLoader.item ? (contentLoader.item.implicitHeight + horizontalPadding * 2) : 0
radius: SettingsData.dankBarNoBackground ? 0 : Theme.cornerRadius
color: {
if (SettingsData.dankBarNoBackground) {
return "transparent"
}
const baseColor = mouseArea.containsMouse ? Theme.widgetBaseHoverColor : Theme.widgetBaseBackgroundColor
return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency)
}
Loader {
id: contentLoader
anchors.centerIn: parent
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: {
if (popoutTarget && popoutTarget.setTriggerPosition) {
const globalPos = mapToGlobal(0, 0)
const currentScreen = parentScreen || Screen
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barThickness, height)
popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen)
}
root.clicked()
}
}
}

View File

@@ -0,0 +1,131 @@
import QtQuick
import qs.Common
import qs.Widgets
Column {
id: root
required property string settingKey
required property string label
property string description: ""
property var items: []
property Component delegate: null
width: parent.width
spacing: Theme.spacingM
Component.onCompleted: {
const settings = findSettings()
if (settings) {
items = settings.loadValue(settingKey, [])
}
}
onItemsChanged: {
const settings = findSettings()
if (settings) {
settings.saveValue(settingKey, items)
}
}
function findSettings() {
let item = parent
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined) {
return item
}
item = item.parent
}
return null
}
function addItem(item) {
items = items.concat([item])
}
function removeItem(index) {
const newItems = items.slice()
newItems.splice(index, 1)
items = newItems
}
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
visible: root.description !== ""
}
Column {
width: parent.width
spacing: Theme.spacingS
Repeater {
model: root.items
delegate: root.delegate ? root.delegate : defaultDelegate
}
StyledText {
text: "No items added yet"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: root.items.length === 0
}
}
Component {
id: defaultDelegate
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: Theme.surfaceText
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.removeItem(index)
}
}
}
}
}
}

View File

@@ -0,0 +1,230 @@
import QtQuick
import qs.Common
import qs.Widgets
Column {
id: root
required property string settingKey
required property string label
property string description: ""
property var fields: []
property var items: []
width: parent.width
spacing: Theme.spacingM
Component.onCompleted: {
const settings = findSettings()
if (settings) {
items = settings.loadValue(settingKey, [])
}
}
onItemsChanged: {
const settings = findSettings()
if (settings) {
settings.saveValue(settingKey, items)
}
}
function findSettings() {
let item = parent
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined) {
return item
}
item = item.parent
}
return null
}
function addItem(item) {
items = items.concat([item])
}
function removeItem(index) {
const newItems = items.slice()
newItems.splice(index, 1)
items = newItems
}
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
visible: root.description !== ""
}
Flow {
width: parent.width
spacing: Theme.spacingS
Repeater {
model: root.fields
StyledText {
text: modelData.label
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
width: modelData.width || 200
}
}
}
Flow {
id: inputRow
width: parent.width
spacing: Theme.spacingS
property var inputFields: []
Repeater {
id: inputRepeater
model: root.fields
DankTextField {
width: modelData.width || 200
placeholderText: modelData.placeholder || ""
Component.onCompleted: {
inputRow.inputFields.push(this)
}
Keys.onReturnPressed: {
addButton.clicked()
}
}
}
DankButton {
id: addButton
width: 50
height: 36
text: "Add"
onClicked: {
let newItem = {}
let hasValue = false
for (let i = 0; i < root.fields.length; i++) {
const field = root.fields[i]
const input = inputRow.inputFields[i]
const value = input.text.trim()
if (value !== "") {
hasValue = true
}
if (field.required && value === "") {
return
}
newItem[field.id] = value || (field.default || "")
}
if (hasValue) {
root.addItem(newItem)
for (let i = 0; i < inputRow.inputFields.length; i++) {
inputRow.inputFields[i].text = ""
}
if (inputRow.inputFields.length > 0) {
inputRow.inputFields[0].forceActiveFocus()
}
}
}
}
}
StyledText {
text: "Current Items"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
visible: root.items.length > 0
}
Column {
width: parent.width
spacing: Theme.spacingS
Repeater {
model: root.items
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
Repeater {
model: root.fields
StyledText {
text: {
const value = root.items[index][modelData.id]
return value || ""
}
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
width: modelData.width || 200
elide: Text.ElideRight
}
}
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.removeItem(index)
}
}
}
}
}
StyledText {
text: "No items added yet"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: root.items.length === 0
}
}
}

View File

@@ -0,0 +1,69 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property var axis: null
property string section: "center"
property var parentScreen: null
property real widgetThickness: 30
property real barThickness: 48
property Component horizontalBarPill: null
property Component verticalBarPill: null
property Component popoutContent: null
property real popoutWidth: 400
property real popoutHeight: 400
readonly property bool isVertical: axis?.isVertical ?? false
readonly property bool hasHorizontalPill: horizontalBarPill !== null
readonly property bool hasVerticalPill: verticalBarPill !== null
readonly property bool hasPopout: popoutContent !== null
width: isVertical ? (hasVerticalPill ? verticalPill.width : 0) : (hasHorizontalPill ? horizontalPill.width : 0)
height: isVertical ? (hasVerticalPill ? verticalPill.height : 0) : (hasHorizontalPill ? horizontalPill.height : 0)
BaseHorizontalPill {
id: horizontalPill
visible: !isVertical && hasHorizontalPill
axis: root.axis
section: root.section
popoutTarget: hasPopout ? pluginPopout : null
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
content: root.horizontalBarPill
onClicked: {
if (hasPopout) {
pluginPopout.toggle()
}
}
}
BaseVerticalPill {
id: verticalPill
visible: isVertical && hasVerticalPill
axis: root.axis
section: root.section
popoutTarget: hasPopout ? pluginPopout : null
parentScreen: root.parentScreen
widgetThickness: root.widgetThickness
barThickness: root.barThickness
content: root.verticalBarPill
onClicked: {
if (hasPopout) {
pluginPopout.toggle()
}
}
}
PluginPopout {
id: pluginPopout
contentWidth: root.popoutWidth
contentHeight: root.popoutHeight
pluginContent: root.popoutContent
}
}

View File

@@ -0,0 +1,116 @@
import QtQuick
import qs.Common
import qs.Widgets
DankPopout {
id: root
property var triggerScreen: null
property Component pluginContent: null
property real contentWidth: 400
property real contentHeight: 400
function setTriggerPosition(x, y, width, section, screen) {
triggerX = x
triggerY = y
triggerWidth = width
triggerSection = section
triggerScreen = screen
}
popupWidth: contentWidth
popupHeight: popoutContent.item ? popoutContent.item.implicitHeight : contentHeight
screen: triggerScreen
shouldBeVisible: false
visible: shouldBeVisible
content: Component {
Rectangle {
id: popoutContainer
implicitHeight: popoutColumn.implicitHeight + Theme.spacingL * 2
color: Theme.popupBackground()
radius: Theme.cornerRadius
border.width: 0
antialiasing: true
smooth: true
focus: true
Component.onCompleted: {
if (root.shouldBeVisible) {
forceActiveFocus()
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) {
root.close()
event.accepted = true
}
}
Connections {
target: root
function onShouldBeVisibleChanged() {
if (root.shouldBeVisible) {
Qt.callLater(() => {
popoutContainer.forceActiveFocus()
})
}
}
}
Column {
id: popoutColumn
width: parent.width - Theme.spacingL * 2
anchors.left: parent.left
anchors.top: parent.top
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
Row {
width: parent.width
height: 32
visible: closeButton.visible
Item {
width: parent.width - 32
height: 32
}
Rectangle {
id: closeButton
width: 32
height: 32
radius: 16
color: closeArea.containsMouse ? Theme.errorHover : "transparent"
visible: true
DankIcon {
anchors.centerIn: parent
name: "close"
size: Theme.iconSize - 4
color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: {
root.close()
}
}
}
}
Loader {
id: popoutContent
width: parent.width
sourceComponent: root.pluginContent
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
import QtQuick
import qs.Common
import qs.Services
Item {
id: root
required property string pluginId
property var pluginService: null
default property alias content: settingsColumn.children
implicitHeight: settingsColumn.implicitHeight
height: implicitHeight
function saveValue(key, value) {
if (pluginService && pluginService.savePluginData) {
pluginService.savePluginData(pluginId, key, value)
}
}
function loadValue(key, defaultValue) {
if (pluginService && pluginService.loadPluginData) {
return pluginService.loadPluginData(pluginId, key, defaultValue)
}
return defaultValue
}
Column {
id: settingsColumn
width: parent.width
spacing: Theme.spacingM
}
}

View File

@@ -0,0 +1,113 @@
import QtQuick
import qs.Common
import qs.Widgets
Column {
id: root
required property string settingKey
required property string label
property string description: ""
required property var options
property string defaultValue: ""
property string value: defaultValue
width: parent.width
spacing: Theme.spacingS
readonly property var optionLabels: {
const labels = []
for (let i = 0; i < options.length; i++) {
labels.push(options[i].label || options[i])
}
return labels
}
readonly property var valueToLabel: {
const map = {}
for (let i = 0; i < options.length; i++) {
const opt = options[i]
if (typeof opt === 'object') {
map[opt.value] = opt.label
} else {
map[opt] = opt
}
}
return map
}
readonly property var labelToValue: {
const map = {}
for (let i = 0; i < options.length; i++) {
const opt = options[i]
if (typeof opt === 'object') {
map[opt.label] = opt.value
} else {
map[opt] = opt
}
}
return map
}
Component.onCompleted: {
const settings = findSettings()
if (settings) {
value = settings.loadValue(settingKey, defaultValue)
}
}
onValueChanged: {
const settings = findSettings()
if (settings) {
settings.saveValue(settingKey, value)
}
}
function findSettings() {
let item = parent
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined) {
return item
}
item = item.parent
}
return null
}
Row {
width: parent.width
spacing: Theme.spacingM
Column {
width: parent.width * 0.4
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
visible: root.description !== ""
}
}
DankDropdown {
width: parent.width * 0.6 - Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
currentValue: root.valueToLabel[root.value] || root.value
options: root.optionLabels
onValueChanged: newValue => {
root.value = root.labelToValue[newValue] || newValue
}
}
}
}

View File

@@ -0,0 +1,65 @@
import QtQuick
import qs.Common
import qs.Widgets
Column {
id: root
required property string settingKey
required property string label
property string description: ""
property string placeholder: ""
property string defaultValue: ""
property string value: defaultValue
width: parent.width
spacing: Theme.spacingS
Component.onCompleted: {
const settings = findSettings()
if (settings) {
value = settings.loadValue(settingKey, defaultValue)
textField.text = value
}
}
function findSettings() {
let item = parent
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined) {
return item
}
item = item.parent
}
return null
}
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
visible: root.description !== ""
}
DankTextField {
id: textField
width: parent.width
placeholderText: root.placeholder
onEditingFinished: {
root.value = text
const settings = findSettings()
if (settings) {
settings.saveValue(settingKey, text)
}
}
}
}

View File

@@ -0,0 +1,72 @@
import QtQuick
import qs.Common
import qs.Widgets
Row {
id: root
required property string settingKey
required property string label
property string description: ""
property bool defaultValue: false
property bool value: defaultValue
width: parent.width
spacing: Theme.spacingM
Component.onCompleted: {
const settings = findSettings()
if (settings) {
value = settings.loadValue(settingKey, defaultValue)
}
}
onValueChanged: {
const settings = findSettings()
if (settings) {
settings.saveValue(settingKey, value)
}
}
function findSettings() {
let item = parent
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined) {
return item
}
item = item.parent
}
return null
}
Column {
width: parent.width - toggle.width - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
visible: root.description !== ""
}
}
DankToggle {
id: toggle
anchors.verticalCenter: parent.verticalCenter
checked: root.value
onToggled: isChecked => {
root.value = isChecked
}
}
}

View File

@@ -8,7 +8,8 @@ import qs.Widgets
Item {
id: dankBarTab
property var baseWidgetDefinitions: [{
property var baseWidgetDefinitions: {
var coreWidgets = [{
"id": "launcherButton",
"text": "App Launcher",
"description": "Quick access to application launcher",
@@ -177,6 +178,22 @@ Item {
"icon": "update",
"enabled": SystemUpdateService.distributionSupported
}]
// Add plugin widgets dynamically
var loadedPlugins = PluginService.getLoadedPlugins()
for (var i = 0; i < loadedPlugins.length; i++) {
var plugin = loadedPlugins[i]
coreWidgets.push({
"id": plugin.id,
"text": plugin.name,
"description": plugin.description || "Plugin widget",
"icon": plugin.icon || "extension",
"enabled": true
})
}
return coreWidgets
}
property var defaultLeftWidgets: [{
"id": "launcherButton",
"enabled": true

View File

@@ -0,0 +1,452 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: pluginsTab
property string expandedPluginId: ""
Component.onCompleted: {
console.log("PluginsTab: Component completed")
console.log("PluginsTab: PluginService available:", typeof PluginService !== "undefined")
if (typeof PluginService !== "undefined") {
console.log("PluginsTab: Available plugins:", Object.keys(PluginService.availablePlugins).length)
console.log("PluginsTab: Plugin directory:", PluginService.pluginDirectory)
}
}
DankFlickable {
anchors.fill: parent
anchors.topMargin: Theme.spacingL
clip: true
contentHeight: mainColumn.height
contentWidth: width
Column {
id: mainColumn
width: parent.width
spacing: Theme.spacingXL
StyledRect {
width: parent.width
height: headerColumn.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
Column {
id: headerColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "extension"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: "Plugin Management"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: "Manage and configure plugins for extending DMS functionality"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
DankButton {
text: "Scan for Plugins"
iconName: "refresh"
onClicked: {
PluginService.scanPlugins()
ToastService.showInfo("Scanning for plugins...")
}
}
DankButton {
text: "Create Plugin Directory"
iconName: "create_new_folder"
onClicked: {
PluginService.createPluginDirectory()
ToastService.showInfo("Created plugin directory: " + PluginService.pluginDirectory)
}
}
}
}
}
StyledRect {
width: parent.width
height: directoryColumn.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
Column {
id: directoryColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
StyledText {
text: "Plugin Directory"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: PluginService.pluginDirectory
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
font.family: "monospace"
}
StyledText {
text: "Place plugin directories here. Each plugin should have a plugin.json manifest file."
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
width: parent.width
}
}
}
StyledRect {
width: parent.width
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
Column {
id: availableColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
StyledText {
text: "Available Plugins"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
}
Column {
width: parent.width
spacing: Theme.spacingM
Repeater {
id: pluginRepeater
model: PluginService.getAvailablePlugins()
StyledRect {
id: pluginDelegate
width: parent.width
height: pluginItemColumn.implicitHeight + Theme.spacingM * 2 + settingsContainer.height
radius: Theme.cornerRadius
property var pluginData: modelData
property string pluginId: pluginData ? pluginData.id : ""
property string pluginName: pluginData ? (pluginData.name || pluginData.id) : ""
property string pluginVersion: pluginData ? (pluginData.version || "1.0.0") : ""
property string pluginAuthor: pluginData ? (pluginData.author || "Unknown") : ""
property string pluginDescription: pluginData ? (pluginData.description || "") : ""
property string pluginIcon: pluginData ? (pluginData.icon || "extension") : "extension"
property string pluginSettingsPath: pluginData ? (pluginData.settingsPath || "") : ""
property var pluginPermissions: pluginData ? (pluginData.permissions || []) : []
property bool hasSettings: pluginData && pluginData.settings !== undefined && pluginData.settings !== ""
property bool isExpanded: pluginsTab.expandedPluginId === pluginId
onIsExpandedChanged: {
console.log("Plugin", pluginId, "isExpanded changed to:", isExpanded)
}
color: pluginMouseArea.containsMouse ? Theme.surfacePressed : (isExpanded ? Theme.surfaceContainerHighest : Theme.surfaceContainerHigh)
border.width: 0
MouseArea {
id: pluginMouseArea
anchors.fill: parent
anchors.bottomMargin: pluginDelegate.isExpanded ? settingsContainer.height : 0
hoverEnabled: true
cursorShape: pluginDelegate.hasSettings ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
console.log("Plugin clicked:", pluginDelegate.pluginId, "hasSettings:", pluginDelegate.hasSettings, "isLoaded:", PluginService.isPluginLoaded(pluginDelegate.pluginId))
if (pluginDelegate.hasSettings) {
if (pluginsTab.expandedPluginId === pluginDelegate.pluginId) {
console.log("Collapsing plugin:", pluginDelegate.pluginId)
pluginsTab.expandedPluginId = ""
} else {
console.log("Expanding plugin:", pluginDelegate.pluginId)
pluginsTab.expandedPluginId = pluginDelegate.pluginId
}
}
}
}
Column {
id: pluginItemColumn
width: parent.width
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: pluginDelegate.pluginIcon
size: Theme.iconSize
color: PluginService.isPluginLoaded(pluginDelegate.pluginId) ? Theme.primary : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM - pluginToggle.width - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
Row {
spacing: Theme.spacingXS
width: parent.width
StyledText {
text: pluginDelegate.pluginName
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: pluginDelegate.hasSettings ? (pluginDelegate.isExpanded ? "expand_less" : "expand_more") : ""
size: 16
color: pluginDelegate.hasSettings ? Theme.primary : "transparent"
anchors.verticalCenter: parent.verticalCenter
visible: pluginDelegate.hasSettings
}
}
StyledText {
text: "v" + pluginDelegate.pluginVersion + " by " + pluginDelegate.pluginAuthor
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
}
}
DankToggle {
id: pluginToggle
anchors.verticalCenter: parent.verticalCenter
checked: PluginService.isPluginLoaded(pluginDelegate.pluginId)
onToggled: (isChecked) => {
if (isChecked) {
if (PluginService.enablePlugin(pluginDelegate.pluginId)) {
ToastService.showInfo("Plugin enabled: " + pluginDelegate.pluginName)
} else {
ToastService.showError("Failed to enable plugin: " + pluginDelegate.pluginName)
checked = false
}
} else {
if (PluginService.disablePlugin(pluginDelegate.pluginId)) {
ToastService.showInfo("Plugin disabled: " + pluginDelegate.pluginName)
if (pluginsTab.expandedPluginId === pluginDelegate.pluginId) {
pluginsTab.expandedPluginId = ""
}
} else {
ToastService.showError("Failed to disable plugin: " + pluginDelegate.pluginName)
checked = true
}
}
}
}
}
StyledText {
width: parent.width
text: pluginDelegate.pluginDescription
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
visible: pluginDelegate.pluginDescription !== ""
}
Flow {
width: parent.width
spacing: Theme.spacingXS
visible: pluginDelegate.pluginPermissions && Array.isArray(pluginDelegate.pluginPermissions) && pluginDelegate.pluginPermissions.length > 0
Repeater {
model: pluginDelegate.pluginPermissions
Rectangle {
height: 20
width: permissionText.implicitWidth + Theme.spacingXS * 2
radius: 10
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 1
StyledText {
id: permissionText
anchors.centerIn: parent
text: modelData
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.primary
}
}
}
}
}
// Settings container
Item {
id: settingsContainer
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: pluginDelegate.isExpanded && pluginDelegate.hasSettings ? (settingsLoader.item ? settingsLoader.item.implicitHeight + Theme.spacingL * 2 : 0) : 0
clip: true
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainerHighest
radius: Theme.cornerRadius
anchors.topMargin: Theme.spacingXS
border.width: 0
}
Loader {
id: settingsLoader
anchors.fill: parent
anchors.margins: Theme.spacingL
active: pluginDelegate.isExpanded && pluginDelegate.hasSettings && PluginService.isPluginLoaded(pluginDelegate.pluginId)
asynchronous: false
onActiveChanged: {
console.log("Settings loader active changed to:", active, "for plugin:", pluginDelegate.pluginId,
"isExpanded:", pluginDelegate.isExpanded, "hasSettings:", pluginDelegate.hasSettings,
"isLoaded:", PluginService.isPluginLoaded(pluginDelegate.pluginId))
}
source: {
if (active && pluginDelegate.pluginSettingsPath) {
console.log("Loading plugin settings from:", pluginDelegate.pluginSettingsPath)
var path = pluginDelegate.pluginSettingsPath
if (!path.startsWith("file://")) {
path = "file://" + path
}
return path
}
return ""
}
onStatusChanged: {
console.log("Settings loader status changed:", status, "for plugin:", pluginDelegate.pluginId)
if (status === Loader.Error) {
console.error("Failed to load plugin settings:", pluginDelegate.pluginSettingsPath)
} else if (status === Loader.Ready) {
console.log("Settings successfully loaded for plugin:", pluginDelegate.pluginId)
}
}
onLoaded: {
if (item) {
console.log("Plugin settings loaded for:", pluginDelegate.pluginId)
if (typeof PluginService !== "undefined") {
console.log("Making PluginService available to plugin settings")
console.log("PluginService functions available:",
"savePluginData" in PluginService,
"loadPluginData" in PluginService)
item.pluginService = PluginService
console.log("PluginService assignment completed, item.pluginService:", item.pluginService !== null)
} else {
console.error("PluginService not available in PluginsTab context")
}
if (item.loadTimezones) {
console.log("Calling loadTimezones for WorldClock plugin")
item.loadTimezones()
}
if (item.initializeSettings) {
item.initializeSettings()
}
}
}
}
StyledText {
anchors.centerIn: parent
text: !PluginService.isPluginLoaded(pluginDelegate.pluginId) ?
"Enable plugin to access settings" :
(settingsLoader.status === Loader.Error ?
"Failed to load settings" :
"No configurable settings")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: pluginDelegate.isExpanded && (!settingsLoader.active || settingsLoader.status === Loader.Error)
}
}
}
}
StyledText {
width: parent.width
text: "No plugins found.\nPlace plugins in " + PluginService.pluginDirectory
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
visible: pluginRepeater.model.length === 0
}
}
}
}
}
}
Connections {
target: PluginService
function onPluginLoaded() {
pluginRepeater.model = PluginService.getAvailablePlugins()
}
function onPluginUnloaded() {
pluginRepeater.model = PluginService.getAvailablePlugins()
if (pluginsTab.expandedPluginId !== "" && !PluginService.isPluginLoaded(pluginsTab.expandedPluginId)) {
pluginsTab.expandedPluginId = ""
}
}
}
}

318
Services/PluginService.qml Normal file
View File

@@ -0,0 +1,318 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
property var availablePlugins: ({})
property var loadedPlugins: ({})
property var pluginWidgetComponents: ({})
property string pluginDirectory: {
var configDir = StandardPaths.writableLocation(StandardPaths.ConfigLocation)
var configDirStr = configDir.toString()
if (configDirStr.startsWith("file://")) {
configDirStr = configDirStr.substring(7)
}
return configDirStr + "/DankMaterialShell/plugins"
}
property string systemPluginDirectory: "/etc/xdg/quickshell/dms-plugins"
property var pluginDirectories: [pluginDirectory, systemPluginDirectory]
signal pluginLoaded(string pluginId)
signal pluginUnloaded(string pluginId)
signal pluginLoadFailed(string pluginId, string error)
Component.onCompleted: {
Qt.callLater(initializePlugins)
}
function initializePlugins() {
scanPlugins()
}
property int currentScanIndex: 0
property var scanResults: []
property var lsProcess: Process {
id: dirScanner
stdout: StdioCollector {
onStreamFinished: {
var output = text.trim()
var currentDir = pluginDirectories[currentScanIndex]
if (output) {
var directories = output.split('\n')
for (var i = 0; i < directories.length; i++) {
var dir = directories[i].trim()
if (dir) {
var manifestPath = currentDir + "/" + dir + "/plugin.json"
console.log("PluginService: Found plugin directory:", dir, "checking manifest at:", manifestPath)
loadPluginManifest(manifestPath)
}
}
} else {
console.log("PluginService: No directories found in:", currentDir)
}
}
}
onExited: function(exitCode) {
if (exitCode !== 0) {
console.log("PluginService: Directory scan failed for:", pluginDirectories[currentScanIndex], "exit code:", exitCode)
}
currentScanIndex++
if (currentScanIndex < pluginDirectories.length) {
scanNextDirectory()
} else {
currentScanIndex = 0
}
}
}
function scanPlugins() {
currentScanIndex = 0
scanNextDirectory()
}
function scanNextDirectory() {
var dir = pluginDirectories[currentScanIndex]
console.log("PluginService: Scanning directory:", dir)
lsProcess.command = ["find", "-L", dir, "-maxdepth", "1", "-type", "d", "-not", "-path", dir, "-exec", "basename", "{}", ";"]
lsProcess.running = true
}
property var manifestReaders: ({})
function loadPluginManifest(manifestPath) {
console.log("PluginService: Loading manifest:", manifestPath)
// Create a unique key for this manifest reader
var readerId = "reader_" + Date.now() + "_" + Math.random()
var catProcess = Qt.createComponent("data:text/plain,import Quickshell.Io; Process { stdout: StdioCollector { } }")
if (catProcess.status === Component.Ready) {
var process = catProcess.createObject(root)
process.command = ["cat", manifestPath]
process.stdout.streamFinished.connect(function() {
try {
console.log("PluginService: DEBUGGING parsing manifest, text length:", process.stdout.text.length)
var manifest = JSON.parse(process.stdout.text.trim())
console.log("PluginService: Successfully parsed manifest for plugin:", manifest.id)
processManifest(manifest, manifestPath)
} catch (e) {
console.error("PluginService: Failed to parse manifest", manifestPath, ":", e.message)
}
process.destroy()
delete manifestReaders[readerId]
})
process.exited.connect(function(exitCode) {
if (exitCode !== 0) {
console.error("PluginService: Failed to read manifest file:", manifestPath, "exit code:", exitCode)
process.destroy()
delete manifestReaders[readerId]
}
})
manifestReaders[readerId] = process
process.running = true
} else {
console.error("PluginService: Failed to create manifest reader process")
}
}
function processManifest(manifest, manifestPath) {
registerPlugin(manifest, manifestPath)
// Auto-load plugin if it's enabled in settings (default to enabled)
var enabled = SettingsData.getPluginSetting(manifest.id, "enabled", true)
if (enabled) {
loadPlugin(manifest.id)
}
}
function registerPlugin(manifest, manifestPath) {
console.log("PluginService: registerPlugin called with", manifest.id)
if (!manifest.id || !manifest.name || !manifest.component) {
console.error("PluginService: Invalid manifest, missing required fields:", manifestPath)
return
}
var pluginDir = manifestPath.substring(0, manifestPath.lastIndexOf('/'))
// Clean up relative paths by removing './' prefix
var componentFile = manifest.component
if (componentFile.startsWith('./')) {
componentFile = componentFile.substring(2)
}
var settingsFile = manifest.settings
if (settingsFile && settingsFile.startsWith('./')) {
settingsFile = settingsFile.substring(2)
}
var pluginInfo = {}
for (var key in manifest) {
pluginInfo[key] = manifest[key]
}
pluginInfo.manifestPath = manifestPath
pluginInfo.pluginDirectory = pluginDir
pluginInfo.componentPath = pluginDir + '/' + componentFile
pluginInfo.settingsPath = settingsFile ? pluginDir + '/' + settingsFile : null
pluginInfo.loaded = false
availablePlugins[manifest.id] = pluginInfo
console.log("PluginService: Registered plugin:", manifest.id, "-", manifest.name)
console.log("PluginService: Component path:", pluginInfo.componentPath)
}
function loadPlugin(pluginId) {
console.log("PluginService: loadPlugin called for", pluginId)
var plugin = availablePlugins[pluginId]
if (!plugin) {
console.error("PluginService: Plugin not found:", pluginId)
pluginLoadFailed(pluginId, "Plugin not found")
return false
}
if (plugin.loaded) {
console.log("PluginService: Plugin already loaded:", pluginId)
return true
}
try {
// Create the widget component
var componentUrl = "file://" + plugin.componentPath
console.log("PluginService: Loading component from:", componentUrl)
var component = Qt.createComponent(componentUrl)
if (component.status === Component.Error) {
console.error("PluginService: Failed to create component for plugin:", pluginId, "Error:", component.errorString())
pluginLoadFailed(pluginId, component.errorString())
return false
}
pluginWidgetComponents[pluginId] = component
plugin.loaded = true
loadedPlugins[pluginId] = plugin
console.log("PluginService: Successfully loaded plugin:", pluginId)
pluginLoaded(pluginId)
return true
} catch (error) {
console.error("PluginService: Error loading plugin:", pluginId, "Error:", error.message)
pluginLoadFailed(pluginId, error.message)
return false
}
}
function unloadPlugin(pluginId) {
var plugin = loadedPlugins[pluginId]
if (!plugin) {
console.warn("PluginService: Plugin not loaded:", pluginId)
return false
}
try {
// Remove from component map
delete pluginWidgetComponents[pluginId]
// Mark as unloaded
plugin.loaded = false
delete loadedPlugins[pluginId]
console.log("PluginService: Successfully unloaded plugin:", pluginId)
pluginUnloaded(pluginId)
return true
} catch (error) {
console.error("PluginService: Error unloading plugin:", pluginId, "Error:", error.message)
return false
}
}
function getWidgetComponents() {
return pluginWidgetComponents
}
function getAvailablePlugins() {
var result = []
for (var key in availablePlugins) {
result.push(availablePlugins[key])
}
return result
}
function getLoadedPlugins() {
var result = []
for (var key in loadedPlugins) {
result.push(loadedPlugins[key])
}
return result
}
function isPluginLoaded(pluginId) {
return loadedPlugins[pluginId] !== undefined
}
function enablePlugin(pluginId) {
console.log("PluginService: Enabling plugin:", pluginId)
SettingsData.setPluginSetting(pluginId, "enabled", true)
return loadPlugin(pluginId)
}
function disablePlugin(pluginId) {
console.log("PluginService: Disabling plugin:", pluginId)
SettingsData.setPluginSetting(pluginId, "enabled", false)
return unloadPlugin(pluginId)
}
function reloadPlugin(pluginId) {
if (isPluginLoaded(pluginId)) {
unloadPlugin(pluginId)
}
return loadPlugin(pluginId)
}
function savePluginData(pluginId, key, value) {
console.log("PluginService: Saving plugin data:", pluginId, key, JSON.stringify(value))
SettingsData.setPluginSetting(pluginId, key, value)
console.log("PluginService: Data saved successfully")
return true
}
function loadPluginData(pluginId, key, defaultValue) {
console.log("PluginService: Loading plugin data:", pluginId, key)
var value = SettingsData.getPluginSetting(pluginId, key, defaultValue)
console.log("PluginService: Loaded key:", key, "value:", JSON.stringify(value))
return value
}
function createPluginDirectory() {
console.log("PluginService: Creating plugin directory:", pluginDirectory)
var mkdirProcess = Qt.createComponent("data:text/plain,import Quickshell.Io; Process { }")
if (mkdirProcess.status === Component.Ready) {
var process = mkdirProcess.createObject(root)
process.command = ["mkdir", "-p", pluginDirectory]
process.exited.connect(function(exitCode) {
if (exitCode === 0) {
console.log("PluginService: Successfully created plugin directory")
} else {
console.error("PluginService: Failed to create plugin directory, exit code:", exitCode)
}
process.destroy()
})
process.running = true
return true
} else {
console.error("PluginService: Failed to create mkdir process")
return false
}
}
}

75
Widgets/DankButton.qml Normal file
View File

@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string text: ""
property string iconName: ""
property int iconSize: Theme.iconSizeSmall
property bool enabled: true
property bool hovered: mouseArea.containsMouse
property bool pressed: mouseArea.pressed
property color backgroundColor: Theme.primary
property color textColor: Theme.primaryText
property int buttonHeight: 40
property int horizontalPadding: Theme.spacingL
signal clicked()
width: Math.max(contentRow.implicitWidth + horizontalPadding * 2, 64)
height: buttonHeight
radius: Theme.cornerRadius
color: backgroundColor
opacity: enabled ? 1 : 0.4
Rectangle {
id: stateLayer
anchors.fill: parent
radius: parent.radius
color: {
if (pressed) return Theme.primaryPressed
if (hovered) return Theme.primaryHover
return "transparent"
}
Behavior on color {
ColorAnimation {
duration: Theme.shorterDuration
easing.type: Theme.standardEasing
}
}
}
Row {
id: contentRow
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: root.iconName
size: root.iconSize
color: root.textColor
visible: root.iconName !== ""
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.text
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: root.textColor
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: root.enabled
onClicked: root.clicked()
}
}

675
docs/PLUGINS.md Normal file
View File

@@ -0,0 +1,675 @@
# Plugin System
The DMS shell includes an experimental plugin system that allows extending functionality through self-contained, 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.
## Architecture
### Core Components
1. **PluginService** (`Services/PluginService.qml`)
- Singleton service managing plugin lifecycle
- Discovers plugins from `$CONFIGPATH/DankMaterialShell/plugins/`
- Handles loading, unloading, and state management
- Provides data persistence for plugin settings
2. **PluginsTab** (`Modules/Settings/PluginsTab.qml`)
- UI for managing available plugins
- Access plugin settings
3. **PluginsTab Settings** (`Modules/Settings/PluginsTab.qml`)
- Accordion-style plugin configuration interface
- Dynamically loads plugin settings components inline
- Provides consistent settings interface with proper focus handling
4. **DankBar Integration** (`Modules/DankBar/DankBar.qml`)
- Renders plugin widgets in the bar
- Merges plugin components with built-in widgets
- Supports left, center, and right sections
## Plugin Structure
Each plugin must be a directory in `$CONFIGPATH/DankMaterialShell/plugins/` containing:
```
$CONFIGPATH/DankMaterialShell/plugins/YourPlugin/
├── plugin.json # Required: Plugin manifest
├── YourWidget.qml # Required: Widget component
├── YourSettings.qml # Optional: Settings UI
├── qmldir # Optional: QML module definition
└── *.js # Optional: JavaScript utilities
```
### Plugin Manifest (plugin.json)
The manifest file defines plugin metadata and configuration:
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"icon": "material_icon_name",
"component": "./YourWidget.qml",
"settings": "./YourSettings.qml",
"dependencies": {
"libraryName": {
"url": "https://cdn.example.com/library.js",
"optional": true
}
},
"settings_schema": {
"settingKey": {
"type": "string|number|boolean|array|object",
"default": "defaultValue"
}
},
"permissions": [
"settings_read",
"settings_write"
]
}
```
**Required Fields:**
- `id`: Unique plugin identifier (camelCase, no spaces)
- `name`: Human-readable plugin name
- `component`: Relative path to widget QML file
**Optional Fields:**
- `description`: Short description of plugin functionality
- `version`: Semantic version string
- `author`: Plugin creator name
- `icon`: Material Design icon name
- `settings`: Path to settings component
- `dependencies`: External JS libraries
- `settings_schema`: Configuration schema
- `permissions`: Required capabilities
### Widget Component
The main widget component uses the **PluginComponent** wrapper which provides automatic property injection and bar integration:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
// Define horizontal bar pill (optional)
horizontalBarPill: Component {
StyledRect {
width: content.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: content
anchors.centerIn: parent
text: "Hello World"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
// Define vertical bar pill (optional)
verticalBarPill: Component {
// Same as horizontal but optimized for vertical layout
}
// Define popout content (optional)
popoutContent: Component {
Column {
width: parent.width
spacing: Theme.spacingM
padding: Theme.spacingM
StyledText {
text: "Popout Content"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
}
}
}
// Popout dimensions (required if popoutContent is set)
popoutWidth: 400
popoutHeight: 300
}
```
**PluginComponent Properties (automatically injected):**
- `axis`: Bar axis information (horizontal/vertical)
- `section`: Bar section ("left", "center", "right")
- `parentScreen`: Screen reference for multi-monitor support
- `widgetThickness`: Recommended widget size perpendicular to bar
- `barThickness`: Bar thickness parallel to edge
**Component Options:**
- `horizontalBarPill`: Component shown in horizontal bars
- `verticalBarPill`: Component shown in vertical bars
- `popoutContent`: Optional popout window content
- `popoutWidth`: Popout window width
- `popoutHeight`: Popout window height
The PluginComponent automatically handles:
- Bar orientation detection
- Click handlers for popouts
- Proper positioning and anchoring
- Theme integration
### Settings Component
Optional settings UI loaded inline in the PluginsTab accordion interface. Use the simplified settings API with auto-storage components:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
id: root
pluginId: "yourPlugin"
StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your API key for accessing the service"
placeholder: "Enter API key..."
}
ToggleSetting {
settingKey: "notifications"
label: "Enable Notifications"
description: "Show desktop notifications for updates"
defaultValue: true
}
SelectionSetting {
settingKey: "updateInterval"
label: "Update Interval"
description: "How often to refresh data"
options: [
{label: "1 minute", value: "60"},
{label: "5 minutes", value: "300"},
{label: "15 minutes", value: "900"}
]
defaultValue: "300"
}
ListSetting {
id: itemList
settingKey: "items"
label: "Saved Items"
description: "List of configured items"
delegate: Component {
StyledRect {
width: parent.width
height: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.surfaceText
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
width: 60
height: 28
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
radius: Theme.cornerRadius
StyledText {
anchors.centerIn: parent
text: "Remove"
color: Theme.errorText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: removeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: itemList.removeItem(index)
}
}
}
}
}
}
```
**Available Setting Components:**
All settings automatically save on change and load on component creation. No manual `pluginService.savePluginData()` calls needed!
1. **PluginSettings** - Root wrapper for all plugin settings
- `pluginId`: Your plugin ID (required)
- Auto-handles storage and provides saveValue/loadValue to children
- Place all other setting components inside this wrapper
2. **StringSetting** - Text input field
- `settingKey`: Storage key (required)
- `label`: Display label (required)
- `description`: Help text (optional)
- `placeholder`: Input placeholder (optional)
- `defaultValue`: Default value (optional)
- Layout: Vertical stack (label, description, input field)
3. **ToggleSetting** - Boolean toggle switch
- `settingKey`: Storage key (required)
- `label`: Display label (required)
- `description`: Help text (optional)
- `defaultValue`: Default boolean (optional)
- Layout: Horizontal (label/description left, toggle right)
4. **SelectionSetting** - Dropdown menu
- `settingKey`: Storage key (required)
- `label`: Display label (required)
- `description`: Help text (optional)
- `options`: Array of `{label, value}` objects or simple strings (required)
- `defaultValue`: Default value (optional)
- Layout: Horizontal (label/description left, dropdown right)
- Stores the `value` field, displays the `label` field
5. **ListSetting** - Manage list of items (manual add/remove)
- `settingKey`: Storage key (required)
- `label`: Display label (required)
- `description`: Help text (optional)
- `delegate`: Custom item delegate Component (optional)
- `addItem(item)`: Add item to list
- `removeItem(index)`: Remove item from list
- Use when you need custom UI for adding items
6. **ListSettingWithInput** - Complete list management with built-in form
- `settingKey`: Storage key (required)
- `label`: Display label (required)
- `description`: Help text (optional)
- `fields`: Array of field definitions (required)
- `id`: Field ID in saved object (required)
- `label`: Column header text (required)
- `placeholder`: Input placeholder (optional)
- `width`: Column width in pixels (optional, default 200)
- `required`: Must have value to add (optional, default false)
- `default`: Default value if empty (optional)
- Automatically generates:
- Column headers from field labels
- Input fields with placeholders
- Add button with validation
- List display showing all field values
- Remove buttons for each item
- Best for collecting structured data (servers, locations, etc.)
**Complete Settings Example:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myPlugin"
// Section header (optional)
StyledText {
width: parent.width
text: "General Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
// Text input
StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your service API key"
placeholder: "sk-..."
defaultValue: ""
}
// Toggle switches
ToggleSetting {
settingKey: "enabled"
label: "Enable Feature"
description: "Turn this feature on or off"
defaultValue: true
}
// Dropdown selection
SelectionSetting {
settingKey: "theme"
label: "Theme"
description: "Choose your preferred theme"
options: [
{label: "Dark", value: "dark"},
{label: "Light", value: "light"},
{label: "Auto", value: "auto"}
]
defaultValue: "dark"
}
// Structured list with multi-field input
ListSettingWithInput {
settingKey: "locations"
label: "Locations"
description: "Track multiple locations"
fields: [
{id: "name", label: "Name", placeholder: "Home", width: 150, required: true},
{id: "timezone", label: "Timezone", placeholder: "America/New_York", width: 200, required: true}
]
}
}
```
**Key Benefits:**
- Zero boilerplate - just define your settings
- Automatic persistence to `settings.json`
- Clean, consistent UI across all plugins
- No manual `pluginService` calls needed
- Proper layout and spacing handled automatically
## PluginService API
### Properties
```qml
PluginService.pluginDirectory: string
// Path to plugins directory ($CONFIGPATH/DankMaterialShell/plugins)
PluginService.availablePlugins: object
// Map of all discovered plugins {pluginId: pluginInfo}
PluginService.loadedPlugins: object
// Map of currently loaded plugins {pluginId: pluginInfo}
PluginService.pluginWidgetComponents: object
// Map of loaded widget components {pluginId: Component}
```
### Functions
```qml
// Plugin Management
PluginService.loadPlugin(pluginId: string): bool
PluginService.unloadPlugin(pluginId: string): bool
PluginService.reloadPlugin(pluginId: string): bool
PluginService.enablePlugin(pluginId: string): bool
PluginService.disablePlugin(pluginId: string): bool
// Plugin Discovery
PluginService.scanPlugins(): void
PluginService.getAvailablePlugins(): array
PluginService.getLoadedPlugins(): array
PluginService.isPluginLoaded(pluginId: string): bool
PluginService.getWidgetComponents(): object
// Data Persistence
PluginService.savePluginData(pluginId: string, key: string, value: any): bool
PluginService.loadPluginData(pluginId: string, key: string, defaultValue: any): any
```
### Signals
```qml
PluginService.pluginLoaded(pluginId: string)
PluginService.pluginUnloaded(pluginId: string)
PluginService.pluginLoadFailed(pluginId: string, error: string)
```
## Creating a Plugin
### Step 1: Create Plugin Directory
```bash
mkdir -p $CONFIGPATH/DankMaterialShell/plugins/MyPlugin
cd $CONFIGPATH/DankMaterialShell/plugins/MyPlugin
```
### Step 2: Create Manifest
Create `plugin.json`:
```json
{
"id": "myPlugin",
"name": "My Plugin",
"description": "A sample plugin",
"version": "1.0.0",
"author": "Your Name",
"icon": "extension",
"component": "./MyWidget.qml",
"settings": "./MySettings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
### Step 3: Create Widget Component
Create `MyWidget.qml`:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
horizontalBarPill: Component {
StyledRect {
width: textItem.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: textItem
anchors.centerIn: parent
text: "Hello World"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
}
}
}
verticalBarPill: Component {
StyledRect {
width: parent.widgetThickness
height: textItem.implicitWidth + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
StyledText {
id: textItem
anchors.centerIn: parent
text: "Hello"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
rotation: 90
}
}
}
}
```
**Note:** Use `PluginComponent` wrapper for automatic property injection and bar integration. Define separate components for horizontal and vertical orientations.
### Step 4: Create Settings Component (Optional)
Create `MySettings.qml`:
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
pluginId: "myPlugin"
StyledText {
width: parent.width
text: "Configure your plugin settings"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
StringSetting {
settingKey: "text"
label: "Display Text"
description: "Text shown in the bar widget"
placeholder: "Hello World"
defaultValue: "Hello World"
}
ToggleSetting {
settingKey: "showIcon"
label: "Show Icon"
description: "Display an icon next to the text"
defaultValue: true
}
}
```
### Step 5: Enable Plugin
1. Run the shell: `qs -p $CONFIGPATH/quickshell/dms/shell.qml`
2. Open Settings (Ctrl+,)
3. Navigate to Plugins tab
4. Click "Scan for Plugins"
5. Enable your plugin with the toggle switch
6. Add the plugin to your DankBar configuration
## Adding Plugin to DankBar
After enabling a plugin, add it to the bar:
1. Open Settings → Appearance → DankBar Layout
2. Add a new widget entry with your plugin ID
3. Choose section (left, center, right)
4. Save and reload
Or edit `$CONFIGPATH/quickshell/dms/config.json`:
```json
{
"dankBarLeftWidgets": [
{"widgetId": "myPlugin", "enabled": true}
]
}
```
## Best Practices
1. **Use Existing Widgets**: Leverage `qs.Widgets` components (DankIcon, DankToggle, etc.) for consistency
2. **Follow Theme**: Use `Theme` singleton for colors, spacing, and fonts
3. **Data Persistence**: Use PluginService data APIs instead of manual file operations
4. **Error Handling**: Gracefully handle missing dependencies and invalid data
5. **Performance**: Keep widgets lightweight, avoid expensive operations
6. **Responsive Design**: Adapt to `compactMode` and different screen sizes
7. **Clean Code**: Follow QML code conventions from CLAUDE.md
8. **Documentation**: Include README.md explaining plugin usage
9. **Versioning**: Use semantic versioning for updates
10. **Dependencies**: Document external library requirements
## Debugging
### Console Logging
View plugin logs:
```bash
qs -v -p $CONFIGPATH/quickshell/dms/shell.qml
```
Look for lines prefixed with:
- `PluginService:` - Service operations
- `PluginsTab:` - UI interactions
- `PluginsTab:` - Settings loading and accordion interface
### Common Issues
1. **Plugin Not Detected**
- Check plugin.json syntax (use `jq` or JSON validator)
- Verify directory is in `$CONFIGPATH/DankMaterialShell/plugins/`
- Click "Scan for Plugins" in Settings
2. **Widget Not Displaying**
- Ensure plugin is enabled in Settings
- Add plugin ID to DankBar widget list
- Check widget width/height properties
3. **Settings Not Loading**
- Verify `settings` path in plugin.json
- Check settings component for errors
- Ensure plugin is enabled and loaded
- Review PluginsTab console output for injection issues
4. **Data Not Persisting**
- Confirm pluginService.savePluginData() calls (with injection)
- Check `$CONFIGPATH/DankMaterialShell/settings.json` for pluginSettings data
- Verify plugin has settings permissions
- Ensure PluginService was properly injected into settings component
## Security Considerations
Plugins run with full QML runtime access. Only install plugins from trusted sources.
**Permissions System:**
- `settings_read`: Read plugin configuration
- `settings_write`: Write plugin configuration
- `process`: Execute system commands
- `network`: Network access
Future versions may enforce permission restrictions.
## API Stability
The plugin API is currently **experimental**. Breaking changes may occur in minor version updates. Pin to specific DMS versions for production use.
**Roadmap:**
- Plugin marketplace/repository
- Sandboxed plugin execution
- Enhanced permission system
- Plugin update notifications
- Inter-plugin communication
## Resources
- **Example Plugin**: https://github.com/rochacbruno/WorldClock
- **PluginService**: `Services/PluginService.qml`
- **Settings UI**: `Modules/Settings/PluginsTab.qml`
- **DankBar Integration**: `Modules/DankBar/DankBar.qml`
- **Theme Reference**: `Common/Theme.qml`
- **Widget Library**: `Widgets/`
## Contributing
Share your plugins with the community:
1. Create a public repository with your plugin
2. Include comprehensive README.md
4. Add example screenshots
5. Document dependencies and permissions
For plugin system improvements, submit issues or PRs to the main DMS repository.