diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/CompositeBarWidget.qml b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeBarWidget.qml new file mode 100644 index 00000000..1add9f6b --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeBarWidget.qml @@ -0,0 +1,137 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Plugins + +PluginComponent { + id: root + + layerNamespacePlugin: "composite-example" + + property var enabledEmojis: pluginData.emojis || ["๐Ÿ˜Š", "๐Ÿ˜ข", "โค๏ธ"] + property int cycleInterval: pluginData.cycleInterval || 3000 + property int maxBarEmojis: pluginData.maxBarEmojis || 3 + + property int currentIndex: 0 + property var displayedEmojis: [] + + Timer { + interval: root.cycleInterval + running: true + repeat: true + onTriggered: { + if (root.enabledEmojis.length > 0) { + root.currentIndex = (root.currentIndex + 1) % root.enabledEmojis.length; + root.updateDisplayedEmojis(); + } + } + } + + function updateDisplayedEmojis() { + const maxToShow = Math.min(root.maxBarEmojis, root.enabledEmojis.length); + let emojis = []; + for (let i = 0; i < maxToShow; i++) { + const idx = (root.currentIndex + i) % root.enabledEmojis.length; + emojis.push(root.enabledEmojis[idx]); + } + root.displayedEmojis = emojis; + } + + Component.onCompleted: { + updateDisplayedEmojis(); + } + + onEnabledEmojisChanged: updateDisplayedEmojis() + onMaxBarEmojisChanged: updateDisplayedEmojis() + + horizontalBarPill: Component { + Row { + id: emojiRow + spacing: Theme.spacingXS + + Repeater { + model: root.displayedEmojis + StyledText { + text: modelData + font.pixelSize: Theme.fontSizeLarge + } + } + } + } + + verticalBarPill: Component { + Column { + id: emojiColumn + spacing: Theme.spacingXS + + Repeater { + model: root.displayedEmojis + StyledText { + text: modelData + font.pixelSize: Theme.fontSizeMedium + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + + popoutContent: Component { + PopoutComponent { + id: popoutColumn + + headerText: "Emoji Picker" + detailsText: "Click an emoji to copy it to clipboard" + showCloseButton: true + + property var allEmojis: ["๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ™ƒ", "๐Ÿ˜‰", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿค”", "๐Ÿค", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ถ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ™„", "๐Ÿ˜ฌ", "๐Ÿคฅ", "๐Ÿ˜Œ", "๐Ÿ˜”", "๐Ÿ˜ช", "๐Ÿคค", "๐Ÿ˜ด", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿคข", "๐Ÿคฎ", "โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘Š", "โœŠ", "๐Ÿค›", "๐Ÿคœ", "๐Ÿคž", "โœŒ๏ธ", "๐ŸคŸ", "๐Ÿค˜"] + + Item { + width: parent.width + implicitHeight: root.popoutHeight - popoutColumn.headerHeight - popoutColumn.detailsHeight - Theme.spacingXL + + DankGridView { + id: emojiGrid + anchors.horizontalCenter: parent.horizontalCenter + width: Math.floor(parent.width / 50) * 50 + height: parent.height + clip: true + cellWidth: 50 + cellHeight: 50 + model: popoutColumn.allEmojis + + delegate: StyledRect { + width: 45 + height: 45 + radius: Theme.cornerRadius + color: emojiMouseArea.containsMouse ? Theme.surfaceContainerHighest : Theme.surfaceContainerHigh + border.width: 0 + + StyledText { + anchors.centerIn: parent + text: modelData + font.pixelSize: Theme.fontSizeXLarge + } + + MouseArea { + id: emojiMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Quickshell.execDetached(["dms", "cl", "copy", modelData]); + ToastService.showInfo("Copied " + modelData + " to clipboard"); + popoutColumn.closePopout(); + } + } + } + } + } + } + } + + popoutWidth: 400 + popoutHeight: 500 +} diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDaemon.qml b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDaemon.qml new file mode 100644 index 00000000..33d00470 --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDaemon.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell.Io +import qs.Common +import qs.Services +import qs.Modules.Plugins + +PluginComponent { + id: root + + property string scriptPath: pluginData.scriptPath || "" + property var popoutService: null + + Connections { + target: SessionData + function onWallpaperPathChanged() { + if (scriptPath) { + var scriptProcess = scriptProcessComponent.createObject(root, { + wallpaperPath: SessionData.wallpaperPath + }); + scriptProcess.running = true; + } + } + } + + Component { + id: scriptProcessComponent + + Process { + property string wallpaperPath: "" + + command: [scriptPath, wallpaperPath] + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim()) { + console.log("CompositeDaemon script output:", text.trim()); + } + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) { + ToastService.showError("Wallpaper Change Script Error", text.trim()); + } + } + } + + onExited: exitCode => { + if (exitCode !== 0) { + ToastService.showError("Wallpaper Change Script Error", "Script exited with code: " + exitCode); + } + destroy(); + } + } + } + + IpcHandler { + target: "compositeExample" + + function runHook(): string { + if (!root.scriptPath) + return "no script configured"; + var scriptProcess = scriptProcessComponent.createObject(root, { + wallpaperPath: SessionData.wallpaperPath + }); + scriptProcess.running = true; + return "ran hook"; + } + } + + Component.onCompleted: { + console.info("CompositeDaemon: Started monitoring wallpaper changes"); + } + + Component.onDestruction: { + console.info("CompositeDaemon: Stopped monitoring wallpaper changes"); + } +} diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDesktopWidget.qml b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDesktopWidget.qml new file mode 100644 index 00000000..55ac34c1 --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeDesktopWidget.qml @@ -0,0 +1,172 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Modules.Plugins + +DesktopPluginComponent { + id: root + + minWidth: 120 + minHeight: 120 + + property bool showSeconds: pluginData.showSeconds ?? true + property bool showDate: pluginData.showDate ?? true + property string clockStyle: pluginData.clockStyle ?? "analog" + property real backgroundOpacity: (pluginData.backgroundOpacity ?? 50) / 100 + + SystemClock { + id: systemClock + precision: root.showSeconds ? SystemClock.Seconds : SystemClock.Minutes + } + + Rectangle { + id: background + anchors.fill: parent + radius: Theme.cornerRadius + color: Theme.surfaceContainer + opacity: root.backgroundOpacity + } + + Loader { + anchors.fill: parent + anchors.margins: Theme.spacingM + sourceComponent: root.clockStyle === "digital" ? digitalClock : analogClock + } + + Component { + id: analogClock + + Item { + id: analogClockRoot + + property real clockSize: Math.min(width, height) - (root.showDate ? 30 : 0) + + Item { + id: clockFace + width: analogClockRoot.clockSize + height: analogClockRoot.clockSize + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + + Repeater { + model: 12 + + Rectangle { + required property int index + property real markAngle: index * 30 + property real markRadius: clockFace.width / 2 - 8 + + x: clockFace.width / 2 + markRadius * Math.sin(markAngle * Math.PI / 180) - width / 2 + y: clockFace.height / 2 - markRadius * Math.cos(markAngle * Math.PI / 180) - height / 2 + width: index % 3 === 0 ? 8 : 4 + height: width + radius: width / 2 + color: index % 3 === 0 ? Theme.primary : Theme.outlineVariant + } + } + + Rectangle { + id: hourHand + property int hours: systemClock.date?.getHours() % 12 ?? 0 + property int minutes: systemClock.date?.getMinutes() ?? 0 + + x: clockFace.width / 2 - width / 2 + y: clockFace.height / 2 - height + 4 + width: 6 + height: clockFace.height * 0.25 + radius: 3 + color: Theme.primary + antialiasing: true + transformOrigin: Item.Bottom + rotation: (hours + minutes / 60) * 30 + } + + Rectangle { + id: minuteHand + property int minutes: systemClock.date?.getMinutes() ?? 0 + property int seconds: systemClock.date?.getSeconds() ?? 0 + + x: clockFace.width / 2 - width / 2 + y: clockFace.height / 2 - height + 4 + width: 4 + height: clockFace.height * 0.35 + radius: 2 + color: Theme.onSurface + antialiasing: true + transformOrigin: Item.Bottom + rotation: (minutes + seconds / 60) * 6 + } + + Rectangle { + id: secondHand + visible: root.showSeconds + property int seconds: systemClock.date?.getSeconds() ?? 0 + + x: clockFace.width / 2 - width / 2 + y: clockFace.height / 2 - height + 4 + width: 2 + height: clockFace.height * 0.4 + radius: 1 + color: Theme.error + antialiasing: true + transformOrigin: Item.Bottom + rotation: seconds * 6 + } + + Rectangle { + anchors.centerIn: parent + width: 10 + height: 10 + radius: 5 + color: Theme.primary + } + } + + Text { + visible: root.showDate + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingXS + text: systemClock.date?.toLocaleDateString(I18n.locale(), "ddd, MMM d") ?? "" + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + } + } + + Component { + id: digitalClock + + Item { + id: digitalRoot + + property real timeFontSize: Math.min(width * 0.16, height * (root.showDate ? 0.4 : 0.5)) + property real dateFontSize: Math.max(Theme.fontSizeSmall, timeFontSize * 0.35) + + Text { + id: timeText + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: root.showDate ? -digitalRoot.dateFontSize * 0.8 : 0 + text: systemClock.date?.toLocaleTimeString(Qt.locale(), root.showSeconds ? "hh:mm:ss" : "hh:mm") ?? "" + font.pixelSize: digitalRoot.timeFontSize + font.weight: Font.Bold + font.family: "monospace" + color: Theme.primary + } + + Text { + id: dateText + visible: root.showDate + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: timeText.bottom + anchors.topMargin: Theme.spacingXS + text: systemClock.date?.toLocaleDateString(I18n.locale(), "ddd, MMM d") ?? "" + font.pixelSize: digitalRoot.dateFontSize + color: Theme.surfaceText + } + } + } +} diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/CompositeSettings.qml b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeSettings.qml new file mode 100644 index 00000000..7eefa7f3 --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/CompositeSettings.qml @@ -0,0 +1,145 @@ +import QtQuick +import qs.Common +import qs.Widgets +import qs.Modules.Plugins + +PluginSettings { + id: root + pluginId: "exampleComposite" + + StyledText { + width: parent.width + text: "Bar Widget โ€” Emoji Cycler" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.surfaceText + } + + SelectionSetting { + settingKey: "emojiSet" + label: "Emoji Set" + description: "Choose which collection of emojis to cycle through" + options: [ + { + label: "Happy & Sad", + value: "happySad" + }, + { + label: "Hearts", + value: "hearts" + }, + { + label: "Hand Gestures", + value: "hands" + }, + { + label: "All Mixed", + value: "mixed" + } + ] + defaultValue: "happySad" + + onValueChanged: { + const sets = { + "happySad": ["๐Ÿ˜Š", "๐Ÿ˜ข", "๐Ÿ˜‚", "๐Ÿ˜ญ", "๐Ÿ˜", "๐Ÿ˜ก"], + "hearts": ["โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค"], + "hands": ["๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘Š", "โœŒ๏ธ", "๐Ÿค˜", "๐Ÿ‘Œ", "โœ‹", "๐Ÿคš"], + "mixed": ["๐Ÿ˜Š", "โค๏ธ", "๐Ÿ‘", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐ŸŒŸ", "๐Ÿ’ฏ"] + }; + root.saveValue("emojis", sets[value] || sets["happySad"]); + } + + Component.onCompleted: { + const currentSet = value || defaultValue; + const sets = { + "happySad": ["๐Ÿ˜Š", "๐Ÿ˜ข", "๐Ÿ˜‚", "๐Ÿ˜ญ", "๐Ÿ˜", "๐Ÿ˜ก"], + "hearts": ["โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค"], + "hands": ["๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘Š", "โœŒ๏ธ", "๐Ÿค˜", "๐Ÿ‘Œ", "โœ‹", "๐Ÿคš"], + "mixed": ["๐Ÿ˜Š", "โค๏ธ", "๐Ÿ‘", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐ŸŒŸ", "๐Ÿ’ฏ"] + }; + root.saveValue("emojis", sets[currentSet] || sets["happySad"]); + } + } + + SliderSetting { + settingKey: "cycleInterval" + label: "Cycle Speed" + description: "How quickly emojis rotate" + defaultValue: 3000 + minimum: 500 + maximum: 10000 + unit: "ms" + leftIcon: "schedule" + } + + SliderSetting { + settingKey: "maxBarEmojis" + label: "Max Bar Emojis" + description: "Maximum number of emojis to display in the bar at once" + defaultValue: 3 + minimum: 1 + maximum: 8 + rightIcon: "emoji_emotions" + } + + StyledText { + width: parent.width + text: "Desktop Widget โ€” Clock" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.surfaceText + } + + SelectionSetting { + settingKey: "clockStyle" + label: "Clock Style" + options: [ + { + label: "Analog", + value: "analog" + }, + { + label: "Digital", + value: "digital" + } + ] + defaultValue: "analog" + } + + ToggleSetting { + settingKey: "showSeconds" + label: "Show Seconds" + defaultValue: true + } + + ToggleSetting { + settingKey: "showDate" + label: "Show Date" + defaultValue: true + } + + SliderSetting { + settingKey: "backgroundOpacity" + label: "Background Opacity" + defaultValue: 50 + minimum: 0 + maximum: 100 + unit: "%" + } + + StyledText { + width: parent.width + text: "Daemon โ€” Wallpaper Hook" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + color: Theme.surfaceText + } + + StringSetting { + settingKey: "scriptPath" + label: "Script Path" + description: "Script executed when the wallpaper changes. The new wallpaper path is passed as the first argument." + placeholder: "/path/to/your/script.sh" + defaultValue: "" + } +} diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/README.md b/quickshell/PLUGINS/ExampleCompositePlugin/README.md new file mode 100644 index 00000000..fc34906a --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/README.md @@ -0,0 +1,53 @@ +# Composite Example + +A single plugin that provides **all three surfaces at once** by combining three of +the standalone example plugins: + +| Surface | Source example | File | +|---------|----------------|------| +| `daemon` | WallpaperWatcherDaemon | `CompositeDaemon.qml` | +| `widget` | Emoji Cycler (bar widget + popout) | `CompositeBarWidget.qml` | +| `desktop` | Desktop Clock | `CompositeDesktopWidget.qml` | + +It demonstrates the `components` manifest map, where each surface points at its own +QML file: + +```json +"type": "composite", +"components": { + "daemon": "./CompositeDaemon.qml", + "widget": "./CompositeBarWidget.qml", + "desktop": "./CompositeDesktopWidget.qml" +} +``` + +All surfaces share one settings UI (`CompositeSettings.qml`) and one plugin-settings +namespace (`exampleComposite`), so `pluginData` is the same for every surface. + +## Surfaces + +- **Daemon** โ€” watches `SessionData.wallpaperPath` and runs a user-configured script + on change. Also registers an `IpcHandler` (`target: "compositeExample"`) exposing a + `runHook` call, so you can trigger the hook over IPC. +- **Bar widget** โ€” cycles emojis in the bar; click the pill for an emoji picker popout + that copies to the clipboard. +- **Desktop widget** โ€” an analog/digital clock you can drag and resize on the desktop. + +## Usage + +1. Copy this directory into `$CONFIGPATH/DankMaterialShell/plugins/`. +2. Settings โ†’ Plugins โ†’ **Scan for Plugins**, then enable **Composite Example**. + (Composite plugins respect the enable toggle โ€” unlike a pure `desktop` plugin they + do not auto-load, because they also carry a daemon.) +3. Add the bar widget via Settings โ†’ Appearance โ†’ DankBar Layout. +4. Place the desktop clock via Settings โ†’ Desktop Widgets. + +## Notes + +- The daemon surface is instantiated once and lives for as long as the plugin is + enabled. The bar and desktop surfaces are instantiated per bar/placement per screen. +- Cross-surface runtime state (not needed here) is best shared via + `PluginService.getGlobalVar` / `setGlobalVar` or the daemon instance, since each + surface is a separate object. +- `requires_dms` is `>=1.5.0` because the `components` multi-surface manifest is only + understood by DMS 1.5.0 and later. diff --git a/quickshell/PLUGINS/ExampleCompositePlugin/plugin.json b/quickshell/PLUGINS/ExampleCompositePlugin/plugin.json new file mode 100644 index 00000000..cbba695b --- /dev/null +++ b/quickshell/PLUGINS/ExampleCompositePlugin/plugin.json @@ -0,0 +1,21 @@ +{ + "id": "exampleComposite", + "name": "Composite Example", + "description": "One plugin providing all three surfaces at once: a wallpaper-watcher daemon, an emoji bar widget with popout, and a desktop clock", + "version": "1.0.0", + "author": "DankMaterialShell", + "type": "composite", + "capabilities": ["daemon", "dankbar-widget", "desktop-widget", "clipboard"], + "icon": "extension", + "components": { + "daemon": "./CompositeDaemon.qml", + "widget": "./CompositeBarWidget.qml", + "desktop": "./CompositeDesktopWidget.qml" + }, + "settings": "./CompositeSettings.qml", + "requires_dms": ">=1.5.0", + "permissions": [ + "settings_read", + "settings_write" + ] +} diff --git a/quickshell/PLUGINS/README.md b/quickshell/PLUGINS/README.md index f7bed6ac..8beecbf1 100644 --- a/quickshell/PLUGINS/README.md +++ b/quickshell/PLUGINS/README.md @@ -1635,6 +1635,79 @@ See `PLUGINS/ExampleDesktopClock/` for a complete working example demonstrating: - Responsive sizing - Edit mode handling +## Composite Plugins + +A single plugin can provide **multiple surfaces at once** โ€” for example a background +daemon (for IPC / monitoring), a bar widget, and a desktop widget. Because each surface +has a different lifecycle (the daemon is instantiated once; bar and desktop widgets are +instantiated per bar/placement per screen), each surface is its own QML file. + +### Plugin Type Configuration + +Instead of a single `type` + `component`, declare a `components` map. Set `type` to +`composite` (any value works; `composite` is conventional): + +```json +{ + "id": "myComposite", + "name": "My Composite Plugin", + "description": "A daemon plus a bar widget plus a desktop widget", + "version": "1.0.0", + "author": "Your Name", + "type": "composite", + "capabilities": ["daemon", "dankbar-widget", "desktop-widget"], + "components": { + "daemon": "./MyDaemon.qml", + "widget": "./MyBarWidget.qml", + "desktop": "./MyDesktopWidget.qml", + "launcher": "./MyLauncher.qml" + }, + "trigger": "#", + "settings": "./MySettings.qml", + "requires_dms": ">=1.5.0", + "permissions": ["settings_read", "settings_write"] +} +``` + +### Surfaces + +Provide any subset of these keys in `components`: + +| Surface | Component contract | Notes | +|---------|--------------------|-------| +| `widget` | `PluginComponent` (bar pills + optional Control Center widget) | see [Widget Component](#widget-component) | +| `desktop` | `DesktopPluginComponent` (or an `Item` following the desktop contract) | see [Desktop Plugins](#desktop-plugins) | +| `daemon` | any `Item` exposing `pluginService` / `pluginId` | instantiated once; ideal for IPC handlers and background monitoring | +| `launcher` | launcher contract (`getItems` / `executeItem`) | requires `trigger` (or empty-trigger mode); see [Launcher Plugins](#launcher-plugins) | + +Each surface is loaded independently into its own registry, so the same plugin can show +up in the bar **and** on the desktop **and** run a daemon simultaneously. + +### Shared State + +Each surface is a separate object, so share runtime state through: + +- `PluginService.getGlobalVar(pluginId, name, default)` / `setGlobalVar(...)` โ€” reactive, + in-process, namespaced per plugin (see [Plugin Global Variables](#plugin-global-variables)). +- The daemon instance โ€” register `IpcHandler`s or expose data other surfaces read via + global vars. +- `savePluginData` / `loadPluginData` for persisted settings (all surfaces of a plugin + share one settings namespace, so one `settings` component configures them all). + +### Settings, Enabling, and Backwards Compatibility + +- Declare a single top-level `settings` component; it configures every surface. +- Composite plugins respect the **enable toggle** in Settings โ†’ Plugins (they are not + auto-loaded). A pure `desktop` plugin still auto-loads for backwards compatibility. +- The legacy single `type` + `component` form is unchanged and fully supported โ€” it is + treated internally as a one-entry `components` map. + +### Example Plugin + +See `PLUGINS/ExampleCompositePlugin/` for a working composite that combines the +WallpaperWatcher daemon, the Emoji Cycler bar widget, and the Desktop Clock into one +plugin. + ## Resources - **Plugin Schema**: `plugin-schema.json` - JSON Schema for validation @@ -1644,6 +1717,7 @@ See `PLUGINS/ExampleDesktopClock/` for a complete working example demonstrating: - [LauncherExample](./LauncherExample/) - [Calculator](https://github.com/rochacbruno/DankCalculator) - [Desktop Clock](./ExampleDesktopClock/) + - [Composite Example](./ExampleCompositePlugin/) - **PluginService**: `Services/PluginService.qml` - **Settings UI**: `Modules/Settings/PluginsTab.qml` - **DankBar Integration**: `Modules/DankBar/DankBar.qml` diff --git a/quickshell/PLUGINS/plugin-schema.json b/quickshell/PLUGINS/plugin-schema.json index 2f7e1720..fc55e832 100644 --- a/quickshell/PLUGINS/plugin-schema.json +++ b/quickshell/PLUGINS/plugin-schema.json @@ -11,8 +11,7 @@ "version", "author", "type", - "capabilities", - "component" + "capabilities" ], "properties": { "id": { @@ -42,8 +41,8 @@ }, "type": { "type": "string", - "description": "Plugin type", - "enum": ["widget", "daemon", "launcher", "desktop"] + "description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.", + "enum": ["widget", "daemon", "launcher", "desktop", "composite"] }, "capabilities": { "type": "array", @@ -55,9 +54,37 @@ }, "component": { "type": "string", - "description": "Relative path to main QML component file", + "description": "Relative path to main QML component file. Required unless 'components' is provided.", "pattern": "^\\./.*\\.qml$" }, + "components": { + "type": "object", + "description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.", + "properties": { + "widget": { + "type": "string", + "description": "Bar/Control Center widget component (PluginComponent)", + "pattern": "^\\./.*\\.qml$" + }, + "desktop": { + "type": "string", + "description": "Desktop widget component", + "pattern": "^\\./.*\\.qml$" + }, + "daemon": { + "type": "string", + "description": "Background daemon component (instantiated once)", + "pattern": "^\\./.*\\.qml$" + }, + "launcher": { + "type": "string", + "description": "Launcher provider component (requires 'trigger')", + "pattern": "^\\./.*\\.qml$" + } + }, + "additionalProperties": false, + "minProperties": 1 + }, "trigger": { "type": "string", "description": "Trigger string for launcher activation (required for launcher type)" @@ -109,6 +136,29 @@ "then": { "required": ["trigger"] } + }, + { + "if": { + "required": ["components"], + "properties": { + "components": { + "required": ["launcher"] + } + } + }, + "then": { + "required": ["trigger"] + } + }, + { + "anyOf": [ + { + "required": ["component"] + }, + { + "required": ["components"] + } + ] } ], "additionalProperties": true diff --git a/quickshell/Services/DesktopWidgetRegistry.qml b/quickshell/Services/DesktopWidgetRegistry.qml index 0e2ea6ff..43f5e8e2 100644 --- a/quickshell/Services/DesktopWidgetRegistry.qml +++ b/quickshell/Services/DesktopWidgetRegistry.qml @@ -21,8 +21,7 @@ Singleton { Connections { target: PluginService function onPluginLoaded(pluginId) { - const plugin = PluginService.availablePlugins[pluginId]; - if (plugin?.type === "desktop") + if (PluginService.pluginDesktopComponents[pluginId] !== undefined) syncPluginWidgets(); } function onPluginUnloaded(pluginId) { diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index 7bc476ec..e921bb04 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -1,7 +1,6 @@ pragma Singleton pragma ComponentBehavior: Bound -import QtCore import QtQuick import Qt.labs.folderlistmodel import Quickshell @@ -202,8 +201,51 @@ Singleton { } } + readonly property var pluginSurfaceKeys: ["widget", "desktop", "daemon", "launcher"] + + function _stripDotSlash(p) { + return p.startsWith("./") ? p.slice(2) : p; + } + + function _deriveLegacySurface(type, capabilities) { + if (type === "daemon") + return "daemon"; + if (type === "launcher" || (capabilities && capabilities.includes("launcher"))) + return "launcher"; + if (type === "desktop") + return "desktop"; + return "widget"; + } + + function _resolveComponentPaths(manifest, dir) { + const paths = {}; + if (manifest.components && typeof manifest.components === "object") { + for (const surface in manifest.components) { + if (!pluginSurfaceKeys.includes(surface)) { + log.warn("unknown plugin surface", surface, "in", dir); + continue; + } + const rel = manifest.components[surface]; + if (!rel) + continue; + paths[surface] = dir + "/" + _stripDotSlash(rel); + } + return paths; + } + if (manifest.component) { + const surface = _deriveLegacySurface(manifest.type, manifest.capabilities); + paths[surface] = dir + "/" + _stripDotSlash(manifest.component); + } + return paths; + } + + function pluginHasSurface(pluginId, surface) { + const plugin = availablePlugins[pluginId]; + return !!(plugin && plugin.surfaces && plugin.surfaces.includes(surface)); + } + function _onManifestParsed(absPath, manifest, sourceTag, mtimeEpochMs) { - if (!manifest || !manifest.id || !manifest.name || !manifest.component) { + if (!manifest || !manifest.id || !manifest.name || (!manifest.component && !manifest.components)) { log.error("invalid manifest fields:", absPath); knownManifests[absPath] = { mtime: mtimeEpochMs, @@ -214,13 +256,22 @@ Singleton { } const dir = absPath.substring(0, absPath.lastIndexOf('/')); - let comp = manifest.component; - if (comp.startsWith("./")) - comp = comp.slice(2); let settings = manifest.settings; if (settings && settings.startsWith("./")) settings = settings.slice(2); + const componentPaths = _resolveComponentPaths(manifest, dir); + const surfaces = Object.keys(componentPaths); + if (surfaces.length === 0) { + log.error("no valid component surfaces in manifest:", absPath); + knownManifests[absPath] = { + mtime: mtimeEpochMs, + source: sourceTag, + bad: true + }; + return; + } + const info = {}; for (const k in manifest) info[k] = manifest[k]; @@ -236,10 +287,12 @@ Singleton { info.manifestPath = absPath; info.pluginDirectory = dir; - info.componentPath = dir + "/" + comp; + info.componentPaths = componentPaths; + info.surfaces = surfaces; + info.componentPath = componentPaths.widget || componentPaths[surfaces[0]]; info.settingsPath = settings ? (dir + "/" + settings) : null; info.loaded = isPluginLoaded(manifest.id); - info.type = manifest.type || "widget"; + info.type = manifest.type || (manifest.components ? "composite" : "widget"); info.source = sourceTag; info.requires_dms = manifest.requires_dms || null; @@ -260,7 +313,8 @@ Singleton { }; _updateAvailablePluginsList(); pluginListUpdated(); - const enabled = info.type === "desktop" || SettingsData.getPluginSetting(manifest.id, "enabled", false); + const isPureDesktop = surfaces.length === 1 && surfaces[0] === "desktop"; + const enabled = isPureDesktop || SettingsData.getPluginSetting(manifest.id, "enabled", false); if (enabled && !info.loaded) loadPlugin(manifest.id); } else { @@ -296,59 +350,70 @@ Singleton { return true; } - const isDaemon = plugin.type === "daemon"; - const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher")); - const isDesktop = plugin.type === "desktop"; + const componentPaths = plugin.componentPaths || {}; + const surfaces = Object.keys(componentPaths); + if (surfaces.length === 0) { + log.error("Plugin has no component surfaces:", pluginId); + pluginLoadFailed(pluginId, "No component surfaces"); + return false; + } - const prevInstance = pluginInstances[pluginId]; + const newWidgets = Object.assign({}, pluginWidgetComponents); + const newDesktop = Object.assign({}, pluginDesktopComponents); + const newDaemons = Object.assign({}, pluginDaemonComponents); + const newLaunchers = Object.assign({}, pluginLauncherComponents); + const newInstances = Object.assign({}, pluginInstances); + + const prevInstance = newInstances[pluginId]; if (prevInstance) { prevInstance.destroy(); - const newInstances = Object.assign({}, pluginInstances); delete newInstances[pluginId]; - pluginInstances = newInstances; } try { - let url = "file://" + plugin.componentPath; - if (bustCache) - url += "?t=" + Date.now(); - const comp = Qt.createComponent(url, Component.PreferSynchronous); - if (comp.status === Component.Error) { - log.error("component error", pluginId, comp.errorString()); - pluginLoadFailed(pluginId, comp.errorString()); - return false; - } - - if (isDaemon) { - const newDaemons = Object.assign({}, pluginDaemonComponents); - newDaemons[pluginId] = comp; - pluginDaemonComponents = newDaemons; - } else if (isLauncher) { - const instance = comp.createObject(root, { - "pluginService": root - }); - if (!instance) { - log.error("failed to instantiate plugin:", pluginId, comp.errorString()); + for (const surface of surfaces) { + let url = "file://" + componentPaths[surface]; + if (bustCache) + url += "?t=" + Date.now(); + const comp = Qt.createComponent(url, Component.PreferSynchronous); + if (comp.status === Component.Error) { + log.error("component error", pluginId, surface, comp.errorString()); pluginLoadFailed(pluginId, comp.errorString()); return false; } - const newInstances = Object.assign({}, pluginInstances); - newInstances[pluginId] = instance; - pluginInstances = newInstances; - const newLaunchers = Object.assign({}, pluginLauncherComponents); - newLaunchers[pluginId] = comp; - pluginLauncherComponents = newLaunchers; - } else if (isDesktop) { - const newDesktop = Object.assign({}, pluginDesktopComponents); - newDesktop[pluginId] = comp; - pluginDesktopComponents = newDesktop; - } else { - const newComponents = Object.assign({}, pluginWidgetComponents); - newComponents[pluginId] = comp; - pluginWidgetComponents = newComponents; + switch (surface) { + case "daemon": + newDaemons[pluginId] = comp; + break; + case "desktop": + newDesktop[pluginId] = comp; + break; + case "launcher": { + const instance = comp.createObject(root, { + "pluginService": root + }); + if (!instance) { + log.error("failed to instantiate launcher surface:", pluginId, comp.errorString()); + pluginLoadFailed(pluginId, comp.errorString()); + return false; + } + newInstances[pluginId] = instance; + newLaunchers[pluginId] = comp; + break; + } + default: + newWidgets[pluginId] = comp; + break; + } } + pluginWidgetComponents = newWidgets; + pluginDesktopComponents = newDesktop; + pluginDaemonComponents = newDaemons; + pluginLauncherComponents = newLaunchers; + pluginInstances = newInstances; + plugin.loaded = true; const newLoaded = Object.assign({}, loadedPlugins); newLoaded[pluginId] = plugin; @@ -371,10 +436,6 @@ Singleton { } try { - const isDaemon = plugin.type === "daemon"; - const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher")); - const isDesktop = plugin.type === "desktop"; - const instance = pluginInstances[pluginId]; if (instance) { instance.destroy(); @@ -383,19 +444,22 @@ Singleton { pluginInstances = newInstances; } - if (isDaemon && pluginDaemonComponents[pluginId]) { + if (pluginDaemonComponents[pluginId]) { const newDaemons = Object.assign({}, pluginDaemonComponents); delete newDaemons[pluginId]; pluginDaemonComponents = newDaemons; - } else if (isLauncher && pluginLauncherComponents[pluginId]) { + } + if (pluginLauncherComponents[pluginId]) { const newLaunchers = Object.assign({}, pluginLauncherComponents); delete newLaunchers[pluginId]; pluginLauncherComponents = newLaunchers; - } else if (isDesktop && pluginDesktopComponents[pluginId]) { + } + if (pluginDesktopComponents[pluginId]) { const newDesktop = Object.assign({}, pluginDesktopComponents); delete newDesktop[pluginId]; pluginDesktopComponents = newDesktop; - } else if (pluginWidgetComponents[pluginId]) { + } + if (pluginWidgetComponents[pluginId]) { const newComponents = Object.assign({}, pluginWidgetComponents); delete newComponents[pluginId]; pluginWidgetComponents = newComponents; @@ -452,7 +516,8 @@ Singleton { const result = []; for (const pluginId in availablePlugins) { const plugin = availablePlugins[pluginId]; - if (plugin.type !== "widget") { + const hasWidgetSurface = plugin.surfaces ? plugin.surfaces.includes("widget") : (plugin.type === "widget"); + if (!hasWidgetSurface) { continue; } const variants = getPluginVariants(pluginId);