Skip to main content
Version: 2.0

Development

Dux Shell

Development

Build and ship plugins for Dux Shell. This guide covers the actual patterns and components you'll use, with real working examples from the plugin library.

Development Environment​

For IDE support (autocomplete, type checking, etc.), clone the DMS repo and develop plugins there:

mkdir -p ~/repos && cd ~/repos
git clone --recurse-submodules https://github.com/duxlabs-dev/dux-shell.git
cd dux-shell/quickshell

# Generate QML language server config
touch .qmlls.ini
qs -p . # Press Ctrl+C after it starts

# Create your plugins here
mkdir -p dms-plugins/MyPlugin

Shared widgets come from the dank-qml-common submodule under quickshell/DCommon. The submodule must be present for the QML language server to resolve them.

DMS 2.0 widget migration

Import shared widgets from qs.DCommon.Widgets and use their new names, such as DButton instead of DankButton. The old wrappers are deprecated and log their replacements. Shell-specific widgets still use qs.Widgets. See the migration examples.

VSCode Setup​

  1. Install the QML Extension
  2. Configure qmlls path via Ctrl+Shift+P → "Preferences: Open User Settings (JSON)":
{
"qt-qml.doNotAskForQmllsDownload": true,
"qt-qml.qmlls.customExePath": "/usr/lib/qt6/bin/qmlls"
}
  1. Open VSCode in the ~/repos/dux-shell/quickshell directory

Live Development​

Symlink your plugin to the DMS plugins directory for live testing:

ln -sf ~/repos/dux-shell/quickshell/dms-plugins/MyPlugin \
~/.config/dms/plugins/MyPlugin

Reload your plugin at runtime without restarting DMS:

dms ipc call plugins reload myPlugin

List all plugins and their status:

dms ipc call plugins list
tip

Use Run on Save to auto-reload your plugin during development. The reload command uses the plugin id from your plugin.json.

Quick Start​

1. Create Plugin Directory​

mkdir -p ~/.config/dms/plugins/MyPlugin
cd ~/.config/dms/plugins/MyPlugin

2. Create Manifest​

Save this as plugin.json:

{
"id": "myPlugin",
"name": "My Plugin",
"description": "What this plugin does",
"version": "1.0.0",
"author": "Your Name",
"icon": "widgets",
"type": "widget",
"component": "./MyWidget.qml",
"settings": "./MySettings.qml",
"permissions": ["settings_read", "settings_write"]
}

3. Create Widget Component​

Save this as MyWidget.qml:

import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Plugins

PluginComponent {
id: root

property string displayText: pluginData.displayText || "Hello"

horizontalBarPill: Component {
Row {
spacing: Theme.spacingS

DankIcon {
name: "widgets"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}

StyledText {
text: root.displayText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
}

verticalBarPill: Component {
Column {
spacing: Theme.spacingXS

DankIcon {
name: "widgets"
size: Theme.iconSize
color: Theme.primary
anchors.horizontalCenter: parent.horizontalCenter
}

StyledText {
text: root.displayText
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}

4. Create Settings Component​

Save this as MySettings.qml:

import QtQuick
import qs.Common
import qs.Modules.Plugins
import qs.Widgets

PluginSettings {
id: root
pluginId: "myPlugin"

StyledText {
width: parent.width
text: "My Plugin Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}

StyledText {
width: parent.width
text: "Configure your plugin here"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}

StringSetting {
settingKey: "displayText"
label: "Display Text"
description: "Text shown in the bar"
placeholder: "Enter text"
defaultValue: "Hello"
}
}

5. Load It​

  1. Open DMS Settings → Plugins
  2. Click "Scan for Plugins"
  3. Toggle your plugin on
  4. Add to DankBar widget list
  5. Restart shell: dms restart

You now have a working plugin.

Widget Plugins​

Widget plugins show up in DankBar, in docks, or in the Control Center. They use PluginComponent as the base.

DankBar Widget​

Here's a real color display widget:

import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Plugins

PluginComponent {
id: root

property color customColor: pluginData.customColor || Theme.primary

horizontalBarPill: Component {
Row {
spacing: Theme.spacingS

Rectangle {
width: 20
height: 20
radius: 4
color: root.customColor
border.color: Theme.outlineStrong
border.width: 1
anchors.verticalCenter: parent.verticalCenter
}

StyledText {
text: root.customColor.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
}

verticalBarPill: Component {
Column {
spacing: Theme.spacingXS

Rectangle {
width: 20
height: 20
radius: 4
color: root.customColor
border.color: Theme.outlineStrong
border.width: 1
anchors.horizontalCenter: parent.horizontalCenter
}

StyledText {
text: root.customColor.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}

The widget pulls customColor from pluginData, which automatically syncs with your settings. No manual loading needed.

Widget with Popout​

Add a popout menu that opens when you click the widget.

To add a layer namespace to your plugin, just add layerNamespacePlugin: "<namespace for your plugin>" like below. Make sure to only type what you want the namespace to be and to not add a prefix (like dms: or dms:plugins: for example) since the shell will add dms:plugins: as a prefix automatically. For example, the namespace of the plugin below will be dms:plugins:emoji-launcher.

While you don't have to add a layer namespace to you widget (it will fallback to dms:plugins:plugin), it's prefered to do so.

warning

As of right now, layer namespace only work with popout widget plugins.

PluginComponent {
id: root

layerNamespacePlugin: "emoji-launcher"

property var displayedEmojis: ["😊", "😢", "❤️"]

horizontalBarPill: Component {
Row {
spacing: Theme.spacingXS
Repeater {
model: root.displayedEmojis
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeLarge
}
}
}
}

verticalBarPill: Component {
Column {
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"
showCloseButton: true

property var allEmojis: [
"😀", "😃", "😄", "😁", "😆", "🤣",
"❤️", "🧡", "💛", "💚", "💙", "💜"
]

Item {
width: parent.width
implicitHeight: root.popoutHeight - popoutColumn.headerHeight -
popoutColumn.detailsHeight - Theme.spacingXL

DankGridView {
anchors.fill: parent
cellWidth: 50
cellHeight: 50
model: popoutColumn.allEmojis

delegate: StyledRect {
width: 45
height: 45
radius: Theme.cornerRadius
color: emojiMouse.containsMouse ?
Theme.surfaceContainerHighest :
Theme.surfaceContainerHigh

StyledText {
anchors.centerIn: parent
text: modelData
font.pixelSize: Theme.fontSizeXLarge
}

MouseArea {
id: emojiMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor

onClicked: {
Quickshell.execDetached(["sh", "-c",
"echo -n '" + modelData + "' | wl-copy"])
ToastService.showInfo("Copied " + modelData)
popoutColumn.closePopout()
}
}
}
}
}
}
}

popoutWidth: 400
popoutHeight: 500
}

The PopoutComponent helper gives you consistent header/footer and a closePopout() function.

Control Center Widget​

Tiles use square cells. The default size is 4×1; 2×2 is square. Users resize tiles and the panel within the available screen space.

This audio tile uses demo data. Small tiles use the standard icon and status layout. Larger tiles add a volume slider and as many device rows as fit. The icon toggles mute; the label opens the full device list. Icon-only tiles open the list directly.

import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.ControlCenter.Widgets

PluginComponent {
id: root

PluginGlobalVar { id: muteState; varName: "muted"; defaultValue: false }
PluginGlobalVar { id: volumeState; varName: "volume"; defaultValue: 70 }
PluginGlobalVar { id: deviceState; varName: "device"; defaultValue: 0 }
readonly property var devices: [
{ name: I18n.trFor("responsiveAudio", "Speakers"), icon: "speaker" },
{ name: I18n.trFor("responsiveAudio", "Headphones"), icon: "headphones" },
{ name: I18n.trFor("responsiveAudio", "Monitor"), icon: "monitor" }
]
readonly property var deviceActions: devices.map((device, index) => ({
text: device.name,
icon: device.icon,
active: index === deviceState.value,
trigger: () => deviceState.set(index)
}))

ccWidgetIcon: muteState.value ? "volume_off" : devices[deviceState.value].icon
ccWidgetPrimaryText: devices[deviceState.value].name
ccWidgetSecondaryText: muteState.value ? I18n.trFor("responsiveAudio", "Muted") : I18n.trFor("responsiveAudio", "Volume: %1%").arg(volumeState.value)
ccWidgetIsActive: !muteState.value
onCcWidgetToggled: muteState.set(!muteState.value)

ccExpandedMinimumHeight: Theme.sliderHandleHeightM + Theme.spacingXS + Theme.spacingS + Theme.listItemHeight
ccExpandedContent: Component {
CcTileContent {
id: content

DankSlider {
id: volumeControl
width: parent.width
size: "m"
minimum: 0
maximum: 100
unit: "%"
enabled: !muteState.value
Accessible.name: I18n.trFor("responsiveAudio", "Volume")
onSliderValueChanged: newValue => volumeState.set(newValue)

Binding on value {
value: volumeState.value
when: !volumeControl.isDragging
}
}

CcTileActions {
anchors.top: volumeControl.bottom
anchors.topMargin: Theme.spacingS
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
tile: content.tile
actions: root.deviceActions
}
}
}

ccDetailHeight: devices.length * (Theme.listItemHeight + Theme.groupedListGap) - Theme.groupedListGap
ccDetailContent: Component {
CcTileActions {
actions: root.deviceActions
}
}
}

Replace the demo devices and handlers with your backend. Use your manifest's plugin id in I18n.trFor(). The tile and detail page have separate plugin instances, so PluginGlobalVar keeps their state in sync. It also preserves the selection and volume when inline content is recreated.

CcTileActions fits whole rows to its available height. Each action takes text, icon, and trigger; active marks the selected row; enabled: false disables a row. The same actions work in the inline list and detail page.

ccExpandedMinimumHeight reserves enough room below the header for the slider and one row. The host loads ccExpandedContent when it fits. Set ccWidgetIsToggle: false for action tiles.

CcTileContent provides tile, columns, rows, live, contentColor, and subtitleColor. Lay out controls using its width and height; pass tile to nested CcTileActions as shown above. Inline content is disabled during editing and destroyed when the Control Center closes or the tile becomes too small. Gate ongoing work on live.

Widgets in a Dock​

Docks run the same widget plugins as the bar. Users add them from the dock's edit mode or Settings → Dock → Apps & widgets. Your PluginComponent needs no extra code, but keep a few things in mind:

  • Define both horizontalBarPill and verticalBarPill. Docks sit on any edge.
  • Size icons with root.iconSize and text with root.textSize. Both follow the host's thickness, scale and maximize options, so the pill matches the bar or dock it sits in.
  • Gate timers and animations on surfaceLive. It goes false while the bar or dock is hidden.
  • Don't assume a single instance. The same widget can sit on several bars and docks, and widgetInstanceId tells them apart.

surfaceContext describes where the widget is running:

PropertyDescription
kind"bar" or "dock"
idThe bar or dock config id
screen, isVertical, thicknessHost display, orientation and thickness
liveThe host is revealed and the monitor is on
editModeThe host is in widget edit mode
inlineExpansionThe dock opens widgets inline (see below)

Attached Panels​

Set attachedContent to give a widget a panel that opens inside the dock:

import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins

PluginComponent {
id: root
horizontalBarPill: Component {
DankIcon {
name: "sticky_note_2"
size: root.iconSize
}
}
verticalBarPill: horizontalBarPill
attachedContent: Component {
Column {
spacing: Theme.spacingM
padding: Theme.spacingM
StyledText {
width: parent.width - Theme.spacingM * 2
text: I18n.trFor("attachedPanelExample", "Notes")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
}
DankTextField {
width: parent.width - Theme.spacingM * 2
placeholderText: I18n.trFor("attachedPanelExample", "Type here…")
Accessible.name: I18n.trFor("attachedPanelExample", "Notes")
}
DankButton {
text: I18n.trFor("attachedPanelExample", "Close")
onClicked: root.surfaceContext.dismissExpansion()
}
}
}
popoutWidth: Theme.listItemHeight * 6
popoutHeight: Theme.listItemHeight * 3
popoutContent: Component {
PopoutComponent {
headerText: I18n.trFor("attachedPanelExample", "Notes")
detailsText: I18n.trFor("attachedPanelExample", "This widget opens an attached panel in the dock.")
showCloseButton: true
}
}
}

The dock decides whether the panel is used, not the plugin. With the dock's Open widgets setting on Inline, a click grows the dock and shows the panel next to the apps. On Popout (the default) the widget opens its normal popoutContent at the matching bar position. A widget with no popout still expands inline. Bars always use popoutContent, and a pillClickAction wins over both.

The panel spans the dock's length and grows up to 320 px away from the edge, at most half the screen. You don't pick its size, so lay it out for a short dock too. Only one panel is open per dock. Escape or surfaceContext.dismissExpansion() closes it and hands focus back to the widget, and attachedActive is true while your widget owns it.

Requires DMS 2.0

Docks that host widgets, surfaceContext and attachedContent are new in 2.0. Declare "requires_dms": ">=2.0.0" if your widget depends on them.

Daemon Plugins​

Daemon plugins run in the background without UI. They monitor events, automate tasks, or provide services.

Here's a daemon that runs a script whenever the wallpaper changes:

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Plugins

PluginComponent {
id: root

property string scriptPath: pluginData.scriptPath || ""

Connections {
target: SessionData
function onWallpaperPathChanged() {
if (scriptPath && scriptPath !== "") {
var process = scriptProcessComponent.createObject(root, {
wallpaperPath: SessionData.wallpaperPath
})
process.running = true
}
}
}

Component {
id: scriptProcessComponent

Process {
property string wallpaperPath: ""
command: [scriptPath, wallpaperPath]

stdout: SplitParser {
onRead: line => console.log("Script:", line)
}

stderr: SplitParser {
onRead: line => {
if (line.trim()) {
ToastService.showError("Script error", line)
}
}
}

onExited: (exitCode) => {
if (exitCode !== 0) {
ToastService.showError("Script failed", "Exit code: " + exitCode)
}
destroy()
}
}
}

Component.onCompleted: {
console.info("Wallpaper watcher daemon started")
}
}

Daemon manifest uses "type": "daemon":

{
"id": "wallpaperWatcher",
"type": "daemon",
"component": "./WallpaperWatcher.qml"
}

Desktop Plugins​

Desktop plugins render directly on the desktop background layer using Wayland's wlr-layer-shell protocol. Users can freely position and resize them. The same component can be placed on the lock screen (see Lock Screen).

Basic Desktop Widget​

import QtQuick
import Quickshell
import qs.Common
import qs.Modules.Plugins

DesktopPluginComponent {
id: root

// Size constraints
minWidth: 150
minHeight: 100

// Access saved settings via pluginData
property string displayText: pluginData.displayText ?? "Hello"
property real bgOpacity: (pluginData.backgroundOpacity ?? 80) / 100

Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, root.bgOpacity)

Text {
anchors.centerIn: parent
text: root.displayText
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
}
}
}

Desktop manifest uses "type": "desktop":

{
"id": "myDesktopWidget",
"name": "My Desktop Widget",
"description": "A custom desktop widget",
"version": "1.0.0",
"author": "Your Name",
"type": "desktop",
"capabilities": ["desktop-widget"],
"component": "./MyWidget.qml",
"icon": "widgets",
"settings": "./MySettings.qml",
"requires_dms": ">=1.2.0",
"permissions": ["settings_read", "settings_write"]
}

DesktopPluginComponent Properties​

Auto-injected (don't declare these):

PropertyTypeDescription
pluginServicevarReference to PluginService for data persistence
pluginIdstringYour plugin's unique identifier
widgetWidthrealCurrent widget width
widgetHeightrealCurrent widget height
pluginDatavarObject containing all saved plugin settings

Optional (define on your component):

PropertyTypeDefaultDescription
minWidthreal100Minimum allowed width
minHeightreal100Minimum allowed height
defaultWidthreal200Initial width for new widgets
defaultHeightreal200Initial height for new widgets
forceSquareboolfalseConstrain to square aspect ratio
acceptsKeyboardFocusboolfalseOpt in to receive keyboard focus when not interacting (see below)
lockScreenboolfalseSet by the host when the instance renders on the lock screen (see below)

Helper functions:

// Read a specific setting with default value
function getData(key, defaultValue)

// Write a setting (triggers pluginDataChanged signal)
function setData(key, value)

User Interaction​

Desktop widgets support:

ActionTriggerDescription
MoveRight-click + drag anywhereRepositions the widget
ResizeRight-click + drag bottom-right cornerResizes within min/max bounds

Keyboard Focus​

By default, desktop widgets do not receive keyboard focus — keystrokes go to the focused application underneath. Opt in by setting acceptsKeyboardFocus: true on your component:

DesktopPluginComponent {
id: root

acceptsKeyboardFocus: true

TextInput {
anchors.fill: parent
focus: true
// Now receives keystrokes
}
}

When enabled, the widget's layer requests OnDemand keyboard focus whenever the user is not actively dragging or resizing it. Use this for widgets with text inputs, search fields, or keyboard shortcuts. Leave it off (the default) for purely visual widgets so the wallpaper layer doesn't steal focus from real applications.

warning

Only enable this if your widget actually needs keyboard input. Always-focused desktop widgets can interfere with global shortcuts and the focused application.

Lock Screen​

The lock screen is built from widgets. The stock layout is six built-in instances (clock, date, password, notifications, status row, power button) that users can move, configure, disable or remove, and every desktop widget can be added next to them. Users manage them from Settings → Lock screen → Widgets, or from the inline editor (dms ipc call lock demo, or the "Edit widgets" row on that page), which shows the lock screen with a FAB bar to add widgets or reset to the stock layout and lets them drag, resize and remove widgets in place. Lock screen instances have their own list and their own saved geometry, so one plugin can be on the desktop and the lock screen at the same time with different settings. The password widget cannot be removed: it owns keyboard focus and IME input. After dms-greeter sync, the greeter renders the same stock widgets with the same styles and positions; plugin widgets and the notifications widget are lock-screen only, and the ring password style falls back to the pill there because the user picker lives inside the field.

Each stock widget has its own options page: the clock offers horizontal, vertical (two-tone stacked digits), expressive (Google Sans Flex, sized by the widget box) and analog styles with size, weight and color; the password block offers pill, expressive (shape-morphing lock), ring (swaylock-style indicator around the avatar), minimal and dots styles; the power button offers Material shapes; the status row can sit on a backdrop. Plugin widgets get the same reset, geometry and display options as on the desktop.

The host sets lockScreen: true on the component before it loads. Use it to adapt:

DesktopPluginComponent {
id: root

// hide controls that make no sense while the session is locked
readonly property bool interactive: !lockScreen
}

Rules that apply on the lock screen:

  • The widget is an item inside the session lock surface, not a layer surface. Nothing that opens a window, popout, or external application works there, and acceptsKeyboardFocus is ignored: the host returns keyboard focus to the password field whenever a widget takes it, so IME and typing always reach the lock.
  • Right-click move and resize are off. Geometry is edited in the inline editor only.
  • The lock screen runs while the user is away. Keep timers coarse (SystemClock with Minutes where possible) and stop animations when lockScreen is true.
  • Positions default to screen fractions (the "Sync position across displays" option), so one layout fits every output.

Responsive Layout​

Adapt to widget dimensions:

GridLayout {
columns: {
if (root.widgetWidth < 200) return 1
if (root.widgetWidth < 400) return 2
return 3
}
}

Dynamic Size Constraints​

DesktopPluginComponent {
id: root

property bool showAllTiles: pluginData.showAllTiles ?? true

minWidth: showAllTiles ? 200 : 100
minHeight: {
if (tileCount === 0) return 60
if (tileCount === 1) return 80
return 120 + (tileCount - 2) * 40
}
}

Time-Based Updates​

Use SystemClock for efficient time updates:

import Quickshell

DesktopPluginComponent {
id: root

SystemClock {
id: clock
precision: SystemClock.Seconds // or Minutes

onDateChanged: updateDisplay()
}

function updateDisplay() {
// Update widget content
}
}

Canvas/Graph Performance​

For graphing widgets:

Canvas {
id: graph
renderStrategy: Canvas.Cooperative

property var history: []

onHistoryChanged: requestPaint()

onPaint: {
var ctx = getContext("2d")
ctx.reset()
// Draw graph...
}
}

Complete Example: Desktop Clock​

A clock widget with analog and digital modes, demonstrating dynamic component loading and responsive sizing.

// DesktopClock.qml
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

// Hour markers
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
}
}

// Hour hand
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
}

// Minute hand
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
}

// Second hand
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
}

// Center dot
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(Qt.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(Qt.locale(), "ddd, MMM d") ?? ""
font.pixelSize: digitalRoot.dateFontSize
color: Theme.surfaceText
}
}
}
}
// DesktopClockSettings.qml
import QtQuick
import qs.Common
import qs.Modules.Plugins

PluginSettings {
id: root
pluginId: "exampleDesktopClock"

SelectionSetting {
settingKey: "clockStyle"
label: I18n.tr("Clock Style")
options: [
{ label: I18n.tr("Analog"), value: "analog" },
{ label: I18n.tr("Digital"), value: "digital" }
]
defaultValue: "analog"
}

ToggleSetting {
settingKey: "showSeconds"
label: I18n.tr("Show Seconds")
defaultValue: true
}

ToggleSetting {
settingKey: "showDate"
label: I18n.tr("Show Date")
defaultValue: true
}

SliderSetting {
settingKey: "backgroundOpacity"
label: I18n.tr("Background Opacity")
defaultValue: 50
minimum: 0
maximum: 100
unit: "%"
}
}

Desktop Plugin Best Practices​

  1. Use Theme singleton - Never hardcode colors, spacing, or font sizes
  2. Set appropriate minWidth/minHeight - Prevent unusable widget sizes
  3. Handle null data - Use ?? operator for all pluginData access
  4. Optimize Canvas redraws - Use renderStrategy: Canvas.Cooperative
  5. Responsive layouts - Adapt to widget dimensions dynamically
  6. Transparency - Provide opacity controls so wallpaper shows through

Launcher Plugins​

Launcher plugins integrate with DankLauncher to provide searchable results. They use QtObject as their root (not Item or PluginComponent) since they don't render any UI directly.

Basic Structure​

import QtQuick
import Quickshell
import qs.Common
import qs.Services

QtObject {
id: root

property var pluginService: null
property string pluginId: "myPlugin"
property string trigger: ""

signal itemsChanged

// Required: Return search results
function getItems(query) {
return [{
name: "Item Name",
icon: "material:icon_name",
comment: "Description shown below name",
action: "custom:data",
categories: ["My Plugin"],
imageUrl: "https://...", // Optional: tile image
animated: false, // Optional: for GIFs
attribution: "/path/to/attribution.svg" // Optional
}];
}

// Required: Handle item selection
function executeItem(item) {
if (!item?.action) return;
const data = item.action.substring(7); // Remove "custom:" prefix
// Do something with data
}
}

Plugin Manifest​

{
"id": "myPlugin",
"name": "My Plugin",
"description": "Plugin description",
"version": "1.0.0",
"author": "Your Name",
"icon": "extension",
"type": "launcher",
"trigger": "!mp",
"viewMode": "list",
"viewModeEnforced": false,
"component": "./MyPlugin.qml",
"settings": "./MyPluginSettings.qml",
"permissions": ["settings_read", "settings_write"]
}
FieldDescription
triggerPrefix that activates the plugin (e.g., gif, :s, !calc)
viewModeDefault view: "list", "grid", or "tile"
viewModeEnforcedIf true, user cannot change the view mode

Item Properties​

PropertyTypeDescription
namestringDisplay name (required)
iconstring"material:icon_name" or "unicode:🚀"
commentstringSecondary text/description
actionstringAction identifier (e.g., "copy:text", "custom:data")
categoriesarrayCategory strings for filtering
keywordsarrayAdditional search terms
imageUrlstringURL for tile/grid image
animatedboolSet true for GIFs to enable animation
attributionstringPath to attribution image (e.g., for API branding)

Context Menus​

Add right-click/Tab actions by implementing getContextMenuActions(item):

function getContextMenuActions(item) {
if (!item) return [];

return [
{
icon: "content_copy",
text: I18n.tr("Copy URL"),
action: () => {
Quickshell.execDetached(["dms", "cl", "copy", item.url]);
ToastService.showInfo(I18n.tr("Copied to clipboard"));
}
},
{
icon: "open_in_new",
text: I18n.tr("Open in Browser"),
action: () => Qt.openUrlExternally(item.url)
}
];
}

Each action object:

FieldTypeDescription
iconstringMaterial icon name
textstringDisplay label
actionfunctionCallback executed when selected
closeLauncherboolIf true, closes the launcher after action executes (default: false)

Use closeLauncher: true for actions that need focus to return to the previous application, such as paste operations:

{
icon: "content_paste",
text: I18n.tr("Paste"),
closeLauncher: true,
action: () => {
Quickshell.execDetached(["dms", "cl", "paste"]);
}
}

Categories​

Allow users to filter results by category. Categories appear as a dropdown when the plugin is active.

property string currentCategory: ""

function getCategories() {
return [
{ id: "", name: I18n.tr("All"), searchTerm: "" },
{ id: "happy", name: "Happy", searchTerm: "happy" },
{ id: "sad", name: "Sad", searchTerm: "sad" }
];
}

function setCategory(categoryId) {
if (currentCategory === categoryId) return;
currentCategory = categoryId;
searchDebounce.restart();
}
FieldTypeDescription
idstringUnique identifier (empty string for "All")
namestringDisplay name in dropdown
searchTermstringOptional: search term to use for this category

For dynamic categories fetched from an API, emit itemsChanged or call pluginService.requestLauncherUpdate(pluginId) when categories are ready.

Paste Support (Shift+Enter)​

Launcher plugins can support Shift+Enter to paste content directly. Two optional functions control this:

getPasteText(item) - Returns text to paste:

function getPasteText(item) {
if (!item?.action?.startsWith("copy:")) return null;
return item.action.substring(5); // "copy:hello" -> "hello"
}

getPasteArgs(item) - Returns a command array for custom clipboard operations:

function getPasteArgs(item) {
const text = getPasteText(item);
if (!text) return null;
// Download content to clipboard before pasting
return ["dms", "cl", "copy", "--download", text];
}

Resolution order:

  1. If getPasteArgs exists, use the returned command array
  2. Else if getPasteText exists, use ["dms", "cl", "copy", text]
  3. Else paste is not supported for the item

Example - Image paste with URL fallback:

property bool pasteUrlOnly: pluginData.pasteUrlOnly ?? false

function getPasteText(item) {
return item.imageUrl || null;
}

function getPasteArgs(item) {
const url = getPasteText(item);
if (!url) return null;
if (pasteUrlOnly)
return ["dms", "cl", "copy", url];
return ["dms", "cl", "copy", "--download", url];
}

Requesting Updates​

Notify the launcher to refresh results after async data loads:

property Connections serviceConn: Connections {
target: MyService

function onResultsReady() {
if (pluginService)
pluginService.requestLauncherUpdate(pluginId);
}
}

Persisting Settings​

Component.onCompleted: {
if (!pluginService) return;
trigger = pluginService.loadPluginData(pluginId, "trigger", "!default");
}

function saveSetting(key, value) {
if (pluginService)
pluginService.savePluginData(pluginId, key, value);
}

Example: Calculator Plugin​

import QtQuick
import Quickshell
import qs.Services
import "calculator.js" as Calculator

QtObject {
id: root

property var pluginService: null
property string trigger: ""

signal itemsChanged

Component.onCompleted: {
if (pluginService)
trigger = pluginService.loadPluginData("calculator", "trigger", "=");
}

function getItems(query) {
if (!query || query.trim().length === 0)
return [];

const trimmedQuery = query.trim();
if (!Calculator.isMathExpression(trimmedQuery))
return [];

const result = Calculator.evaluate(trimmedQuery);
if (!result.success)
return [];

return [{
name: result.result.toString(),
icon: "material:equal",
comment: trimmedQuery + " = " + result.result,
action: "copy:" + result.result,
categories: ["Calculator"]
}];
}

function executeItem(item) {
if (!item?.action) return;

const actionParts = item.action.split(":");
const actionType = actionParts[0];
const actionData = actionParts.slice(1).join(":");

if (actionType === "copy") {
Quickshell.execDetached(["sh", "-c", "echo -n '" + actionData + "' | wl-copy"]);
ToastService.showInfo("Calculator", "Copied to clipboard: " + actionData);
}
}
}

Launcher Settings​

Launcher plugins use PluginSettings just like widget plugins:

import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins

PluginSettings {
id: root
pluginId: "calculator"

StyledText {
width: parent.width
text: "Calculator Plugin"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}

ToggleSetting {
id: noTriggerToggle
settingKey: "noTrigger"
label: "Always Active"
description: value ? "Type expressions directly" : "Use trigger prefix"
defaultValue: false
onValueChanged: {
if (value)
root.saveValue("trigger", "");
else
root.saveValue("trigger", triggerSetting.value || "=");
}
}

StringSetting {
id: triggerSetting
visible: !noTriggerToggle.value
settingKey: "trigger"
label: "Trigger"
description: "Prefix to activate calculator (e.g., =, calc)"
placeholder: "="
defaultValue: "="
}
}

Dash Plugins​

The dash is the popout you get from the bar clock or dms ipc call dash open. It has a row of tabs, and its Overview tab is a grid of cards. A plugin can add a tab, a card, or both.

The built-in tabs and cards (Media, Weather, Clock, Calendar, CPU and the rest) go through the same registry as plugins. A plugin tab or card gets everything a built-in one gets: users reorder and hide tabs, drag cards around and resize them, and your options show up in the same options sheet.

Requires DMS 2.0

Dash plugins are new in 2.0. Declare "requires_dms": ">=2.0.0".

How Users See Them​

  • Only enabled plugins show up. A disabled plugin has no tab, no card and no rows under Settings → Dashboard. Its saved card placement is kept, so re-enabling it puts the card back.
  • A plugin tab appears in the tab bar as soon as the plugin loads. Users hide or reorder it under Settings → Dashboard → Tabs.
  • Cards are never placed on their own. Users add them from the dash: three-dot menu → Edit → Add widget.
  • With both surfaces, clicking the card opens the tab. If the user hid the tab from the bar, it opens as a detail page with a back button.
  • Tab ids are plugin_<pluginId>, so dms ipc call dash open plugin_myPlugin opens yours.

Picking a Manifest Form​

You wantManifest
A tab"type": "dash" + "component": "./Tab.qml"
A card"type": "dashCard" + "component": "./Card.qml"
A tab and a card"type": "composite" + "components": { "dash": ..., "dashCard": ... }
Dash surfaces plus a bar widget, daemon, ..."type": "composite" with every surface in components

Dash Tab​

A tab is a DashTabComponent, an Item sized to the dash content area. Here's a complete stopwatch tab. It's the DashTabExample plugin in the DMS repo.

{
"id": "dashTabExample",
"name": "Stopwatch Example",
"description": "A standalone dash tab: a stopwatch with laps that keeps time while the dash is closed",
"version": "1.0.0",
"author": "Dux Shell",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./StopwatchTab.qml",
"icon": "timer",
"dash": {
"title": "Stopwatch",
"options": [
{
"key": "hundredths",
"text": "Show hundredths",
"type": "toggle",
"def": true
},
{
"key": "laps",
"text": "Laps shown",
"type": "number",
"def": 5,
"min": 0,
"max": 10
}
]
},
"requires_dms": ">=2.0.0"
}
// StopwatchTab.qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
import qs.Modules.DankDash.Overview

DashTabComponent {
id: root

property bool running: false
property real startedAt: 0
property real banked: 0
property var laps: []
property real now: 0

readonly property real elapsed: banked + (running ? Math.max(0, now - startedAt) : 0)
readonly property bool showHundredths: options.hundredths !== false
readonly property var visibleLaps: laps.slice(0, options.laps ?? 5)

focusTarget: startButton
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
menuActions: [
{
label: I18n.trFor("dashTabExample", "Reset"),
iconName: "restart_alt",
enabled: root.elapsed > 0,
action: () => root.reset()
}
]

onPluginIdChanged: restore()
onPluginServiceChanged: restore()

function restore() {
if (!pluginService || !pluginId)
return;
const saved = pluginService.loadPluginState(pluginId, "stopwatch", null);
if (!saved)
return;
running = saved.running === true;
startedAt = Number(saved.startedAt) || 0;
banked = Number(saved.banked) || 0;
laps = Array.isArray(saved.laps) ? saved.laps : [];
now = Date.now();
}

function save() {
pluginService?.savePluginState(pluginId, "stopwatch", {
running: running,
startedAt: startedAt,
banked: banked,
laps: laps
});
}

function toggle() {
now = Date.now();
if (running) {
banked += now - startedAt;
running = false;
} else {
startedAt = now;
running = true;
}
save();
}

function lap() {
if (!running)
return;
now = Date.now();
laps = [elapsed].concat(laps).slice(0, 99);
save();
}

function reset() {
running = false;
startedAt = 0;
banked = 0;
laps = [];
save();
}

function pad2(value) {
return String(value).padStart(2, "0");
}

function format(ms) {
const centis = Math.floor(ms / 10);
const seconds = Math.floor(centis / 100);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const clock = (hours > 0 ? hours + ":" : "") + pad2(minutes % 60) + ":" + pad2(seconds % 60);
return showHundredths ? clock + "." + pad2(centis % 100) : clock;
}

function restoreFocus() {
startButton.forceActiveFocus(Qt.OtherFocusReason);
}

function handleKeyEvent(event) {
if (event.modifiers & (Qt.ControlModifier | Qt.AltModifier))
return false;
switch (event.key) {
case Qt.Key_Space:
toggle();
return true;
case Qt.Key_L:
lap();
return true;
case Qt.Key_R:
reset();
return true;
}
return false;
}

Timer {
interval: root.showHundredths ? 40 : 200
repeat: true
triggeredOnStart: true
running: root.live && root.running
onTriggered: root.now = Date.now()
}

Column {
id: content
anchors.centerIn: parent
width: Math.min(parent.width, DashMetrics.gridRowUnit * 5)
spacing: Theme.spacingL

NumericText {
anchors.horizontalCenter: parent.horizontalCenter
isMonospace: false
text: root.format(root.elapsed)
font.pixelSize: Theme.fontSizeDisplayLarge
font.weight: Font.Medium
color: root.running ? Theme.primary : Theme.surfaceText
}

Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingS

DankButton {
text: root.running ? I18n.trFor("dashTabExample", "Lap") : I18n.trFor("dashTabExample", "Reset")
reserveText: I18n.trFor("dashTabExample", "Reset")
iconName: root.running ? "flag" : "restart_alt"
enabled: root.running || root.elapsed > 0
backgroundColor: Theme.secondaryContainer
textColor: Theme.onSecondaryContainer
onClicked: root.running ? root.lap() : root.reset()
}

DankButton {
id: startButton
text: root.running ? I18n.trFor("dashTabExample", "Pause") : root.elapsed > 0 ? I18n.trFor("dashTabExample", "Resume") : I18n.trFor("dashTabExample", "Start")
reserveText: I18n.trFor("dashTabExample", "Resume")
iconName: root.running ? "pause" : "play_arrow"
onClicked: root.toggle()
}
}

Card {
id: lapCard
width: parent.width
height: lapList.implicitHeight + pad * 2
visible: root.visibleLaps.length > 0

Column {
id: lapList
width: parent.width

Repeater {
model: root.visibleLaps

Item {
id: lapRow

required property int index
required property real modelData

width: lapList.width
height: Theme.buttonHeightXS

StyledText {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: I18n.trFor("dashTabExample", "Lap %1").arg(root.laps.length - lapRow.index)
font.pixelSize: Theme.fontSizeMedium
color: lapCard.mutedColor
}

NumericText {
anchors.right: total.left
anchors.rightMargin: Theme.spacingXL
anchors.verticalCenter: parent.verticalCenter
isMonospace: false
text: root.format(lapRow.modelData - (root.laps[lapRow.index + 1] ?? 0))
font.pixelSize: Theme.fontSizeMedium
color: lapCard.contentColor
}

NumericText {
id: total
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
isMonospace: false
text: root.format(lapRow.modelData)
font.pixelSize: Theme.fontSizeMedium
color: lapCard.mutedColor
}
}
}
}
}
}
}

What's going on:

  • The display Timer only runs while live is true (tab current and dash visible) and the stopwatch is running. Time comes from stored timestamps, so the stopwatch keeps counting while the dash is closed.
  • Runtime data goes through the state API, not settings. No permissions needed.
  • focusTarget is where Down lands when the user enters the tab. restoreFocus() runs when the dash opens on the tab; focusing with Qt.OtherFocusReason avoids drawing a focus ring.
  • handleKeyEvent gets keys before the dash does. Return true to consume one.
  • menuActions adds entries to the tab's three-dot menu.
  • implicitHeight grows the dash when many laps are listed. It never goes below DashMetrics.tabMinHeight.
  • options.hundredths and options.laps come from the manifest's dash.options. See Dash Options.

Dash Card​

A card is a DashCardComponent, a tonal surface in the overview grid. This one shows how much of the day, week, month or year has passed. It's the DashCardExample plugin.

{
"id": "dashCardExample",
"name": "Progress Example",
"description": "A standalone overview card showing how much of the day, week, month or year has passed",
"version": "1.0.0",
"author": "Dux Shell",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./ProgressCard.qml",
"icon": "hourglass_bottom",
"dash": {
"card": {
"title": "Progress",
"w": 2,
"h": 1,
"minW": 1,
"minH": 1,
"maxW": 4,
"maxH": 2
},
"options": [
{
"key": "period",
"text": "Period",
"type": "choice",
"def": "day",
"choices": [
{ "value": "day", "text": "Day" },
{ "value": "week", "text": "Week" },
{ "value": "month", "text": "Month" },
{ "value": "year", "text": "Year" }
]
},
{
"key": "remaining",
"text": "Show time left",
"type": "toggle",
"def": true
},
{
"key": "tone",
"text": "Tone",
"type": "choice",
"def": "",
"choices": [
{ "value": "", "text": "Surface" },
{ "value": "primary", "text": "Primary" },
{ "value": "secondary", "text": "Secondary" },
{ "value": "tertiary", "text": "Tertiary" }
]
}
]
},
"requires_dms": ">=2.0.0"
}
// ProgressCard.qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash

DashCardComponent {
id: root

readonly property string period: options.period ?? "day"
readonly property var span: spanFor(clock.date, period)
readonly property real progress: Math.max(0, Math.min(1, (clock.date - span.start) / (span.end - span.start)))
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
readonly property string periodText: {
switch (period) {
case "week":
return I18n.trFor("dashCardExample", "This week");
case "month":
return I18n.trFor("dashCardExample", "This month");
case "year":
return I18n.trFor("dashCardExample", "This year");
}
return I18n.trFor("dashCardExample", "Today");
}
readonly property string remainingText: {
const minutes = Math.ceil((span.end - clock.date) / 60000);
if (minutes < 60)
return I18n.trFor("dashCardExample", "%1 min left").arg(minutes);
const hours = Math.floor(minutes / 60);
if (hours < 48)
return I18n.trFor("dashCardExample", "%1 h left").arg(hours);
return I18n.trFor("dashCardExample", "%1 days left").arg(Math.floor(hours / 24));
}

tone: options.tone ?? ""

function weekStart() {
const setting = SettingsData.firstDayOfWeek;
return (setting >= 0 && setting < 7 ? setting : Qt.locale().firstDayOfWeek) % 7;
}

function spanFor(now, period) {
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
switch (period) {
case "week":
{
const first = day - (now.getDay() - weekStart() + 7) % 7;
return {
start: new Date(year, month, first),
end: new Date(year, month, first + 7)
};
}
case "month":
return {
start: new Date(year, month, 1),
end: new Date(year, month + 1, 1)
};
case "year":
return {
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1)
};
}
return {
start: new Date(year, month, day),
end: new Date(year, month, day + 1)
};
}

SystemClock {
id: clock
enabled: root.live
precision: SystemClock.Minutes
}

StyledText {
id: label
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
text: root.periodText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: root.mutedColor
elide: Text.ElideRight
}

Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter

NumericText {
isMonospace: false
text: Math.floor(root.progress * 100) + "%"
font.pixelSize: root.tall ? Theme.fontSizeDisplay : root.wide ? Theme.fontSizeXXLarge : Theme.fontSizeXLarge
font.weight: Font.Bold
color: root.accentColor
}

StyledText {
width: parent.width
visible: root.options.remaining !== false && (root.wide || root.tall)
text: root.remainingText
font.pixelSize: Theme.fontSizeSmall
color: root.mutedColor
elide: Text.ElideRight
}
}

Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Theme.spacingS
radius: height / 2
color: root.chipColor

Rectangle {
width: Math.max(height, parent.width * root.progress)
height: parent.height
radius: height / 2
color: root.accentColor
}
}
}

What's going on:

  • The grid is 6 columns by default (users pick 3 to 8) with 96 px rows. card.w/card.h set the default size, minW..maxH the range users can resize within. Widths are clamped to the column count.
  • The card has to look right at every size in its range. wide and tall compare the card's pixel size to DashMetrics.gridRowUnit.
  • tone tints the surface. Use the card colors (accentColor, contentColor, mutedColor, chipColor) instead of raw theme colors so every tone works.
  • SystemClock is disabled while the overview is hidden.
  • The card has no tab and isn't clickable, so keyboard navigation skips it.

Tab and Card Together​

A plugin with both surfaces, and maybe a bar widget too, uses components. DashCounterExample shares one counter between a tab, a card and a bar or dock widget:

{
"id": "dashCounterExample",
"name": "Counter Example",
"description": "One counter shared by a dash tab, an overview card and a bar or dock widget",
"version": "1.0.0",
"author": "Dux Shell",
"type": "composite",
"capabilities": ["dash-tab", "dash-card", "dankbar-widget"],
"icon": "counter_1",
"components": {
"dash": "./CounterTab.qml",
"dashCard": "./CounterCard.qml",
"widget": "./CounterWidget.qml"
},
"dash": {
"title": "Counter",
"card": {
"w": 2,
"h": 1,
"minW": 1,
"minH": 1,
"maxW": 3,
"maxH": 2
}
},
"requires_dms": ">=2.0.0",
"permissions": ["settings_read", "settings_write"]
}

The card is clickable, so a click opens the tab:

// CounterCard.qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins

DashCardComponent {
id: root

readonly property int count: pluginData.count ?? 0

clickable: true

function handleKeyEvent(event) {
switch (event.key) {
case Qt.Key_Left:
setData("count", Math.max(0, count - 1));
return true;
case Qt.Key_Right:
setData("count", count + 1);
return true;
}
return false;
}

Row {
anchors.centerIn: parent
spacing: Theme.spacingM

DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "counter_1"
size: Theme.iconSizeLarge
color: root.accentColor
}

StyledText {
anchors.verticalCenter: parent.verticalCenter
text: root.count
font.pixelSize: Theme.fontSizeXXLarge
font.weight: Font.Medium
color: root.contentColor
}
}
}

The bar widget opens the dash on the counter tab. It works the same in a dock:

// CounterWidget.qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins

PluginComponent {
id: root

property var popoutService: null
readonly property int count: pluginData.count ?? 0

pillClickAction: (x, y, width, section, screen) => popoutService?.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)
pillRightClickAction: () => pluginService?.savePluginData(pluginId, "count", count + 1)

horizontalBarPill: Component {
Row {
spacing: Theme.spacingXS

DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "counter_1"
size: root.iconSize
color: Theme.widgetIconColor
}

StyledText {
anchors.verticalCenter: parent.verticalCenter
text: root.count
font.pixelSize: root.textSize
color: Theme.widgetTextColor
}
}
}

verticalBarPill: Component {
Column {
spacing: Theme.spacingXXS

DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "counter_1"
size: root.iconSize
color: Theme.widgetIconColor
}

StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: root.count
font.pixelSize: root.textSize
color: Theme.widgetTextColor
}
}
}
}

All three surfaces read pluginData.count and write with setData / savePluginData. pluginData reloads on pluginDataChanged, so they stay in sync.

Dash Options​

The optional dash block in plugin.json labels your surfaces and declares user options:

FieldDescription
icon, titleTab icon and label. Fall back to the top-level icon and name
card.title, card.iconCard name and icon in the Add widget menu. Fall back to the tab label and icon
card.w, card.hDefault card size in grid cells
card.minW, card.minH, card.maxW, card.maxHResize range. Widths clamp to the column count, heights to the screen
optionsUser options for the tab and card

Each option has a key, a text label, a type and a default def:

"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false, "description": "Hide the secondary line" },
{ "key": "style", "text": "Style", "type": "choice", "def": "bars", "choices": [{ "value": "bars", "text": "Bars" }, { "value": "dots", "text": "Dots" }] },
{ "key": "limit", "text": "Items", "type": "number", "def": 5, "min": 1, "max": 20, "step": 1 }
]
  • toggle takes an optional description. number takes min, max, step and unit.
  • text, description and choice texts go through your plugin's translations.
  • The rows show up in the options sheet (Options in the tab's three-dot menu, or the tune button on a card in edit mode) and under Settings → Dashboard.
  • Read values as options.<key> in both the tab and the card. Bindings update when the user changes a value.
  • widgets is reserved for the widget grid below.

Set "cardOnly": true on an option that only the overview card reads. It stays on the card's sheet and in Settings, and the tab's sheet leaves it out.

dash.options only reach the dash surfaces. If your bar widget or daemon needs the same value, use a settings component and pluginData instead.

DashTabComponent Reference​

Auto-injected (don't set these):

PropertyTypeDescription
pluginIdstringYour plugin id
pluginServicevarPluginService
popoutServicevarPopoutService
entryIdstringplugin_<pluginId>
dashHostvarThe dash popout: dashVisible, requestTab(id), editMode
editModeboolThe dash is in edit mode
liveboolThis tab is current and the dash is visible

Declare targetScreen, active or rowBudget (rows of 96 px that fit on screen) on your tab and the host binds them too.

Provided by the base:

MemberDescription
optionsResolved dash.options values
pluginDataYour plugin settings, reloaded on pluginDataChanged
getData(key, default) / setData(key, value)Read and write plugin settings

Optional (define on your tab):

MemberDescription
implicitHeightThe dash grows to fit it. Minimum DashMetrics.tabMinHeight
focusTargetItem focused when Down enters the tab
restoreFocus()Called when the dash opens on the tab or returns focus to it
handleKeyEvent(event)Return true to consume a key. Ctrl+Tab and Ctrl+Shift+Tab stay with the dash
blocksTabNavigationTrue while an editor needs native Tab traversal
menuActions[{ label, iconName, action, visible?, enabled? }] for the three-dot menu
signal tabRequested(string id)Switch the dash to another tab, like "overview" or "plugin_other"
signal navFocusRequestedHand focus back to the dash navigation

For tiles inside a tab, use Card from qs.Modules.DankDash.Overview. It has the same surface colors, radius and tones as the overview cards.

Widgets Inside a Tab​

Give your tab a DashWidgetGrid and assign it to widgetGrid to get the dash Edit, Add widget, Reset and Clear All actions for free:

import qs.Modules.DankDash
import qs.Modules.DankDash.Overview

DashTabComponent {
id: tab
widgetGrid: grid
implicitHeight: grid.implicitHeight
focusTarget: grid.focusTarget

DashWidgetGrid {
id: grid
width: parent.width
entryId: tab.entryId
live: tab.live
editMode: tab.editMode
definitions: [
{ id: "counter", text: I18n.trFor("myPlugin", "Counter"), icon: "counter_1", component: counter, w: 2, h: 2, minW: 1, minH: 1, maxW: 4, maxH: 3 },
{ id: "reset", text: I18n.trFor("myPlugin", "Reset"), icon: "restart_alt", component: reset, w: 2, h: 1, enabled: false }
]
}

Component {
id: counter
Card {}
}

Component {
id: reset
Card {}
}
}
  • The grid is 4 columns (2 when it's narrower than Theme.smallBreakpoint), separate from the overview column count.
  • Each definition needs a stable id, a translated text, an icon, a component and a default w/h. minW/minH/maxW/maxH are optional.
  • enabled: false leaves a widget in the Add menu instead of placing it.
  • options gives a widget its own options, see Widget Options.
  • Widgets get widgetId, widgetOptions and a bound live if they declare them. The grid unloads widget content when live is false.
  • The layout is saved in dashOptions[entryId].widgets. Reset brings back your defaults.

CounterTab.qml in DashCounterExample is a complete tab built this way.

Widget Options​

A definition can carry options, a list of option specs in the same shape as Dash Options. Build them with the DashRegistry helpers:

definitions: [
{
id: "counter", text: I18n.trFor("myPlugin", "Counter"), icon: "counter_1", component: counter, w: 2, h: 2,
options: [
DashRegistry.toneOption("primary"),
DashRegistry.toggle("compact", I18n.trFor("myPlugin", "Compact"), false),
DashRegistry.choice("style", I18n.trFor("myPlugin", "Style"), "bars", [{ value: "bars", text: "Bars" }, { value: "dots", text: "Dots" }]),
DashRegistry.number("limit", I18n.trFor("myPlugin", "Items"), 5, 1, 20)
]
}
]
  • In edit mode a widget with options shows an options button that opens them in a sheet.
  • Every placed widget stores its own values. The widget reads them from widgetOptions by key, for example widgetOptions.tone.
  • DashRegistry.toneOption(def) is the shared Tone choice: "" for the surface, "primary", "secondary" or "tertiary".
  • id, w, h, col and row are reserved keys.

A Card takes the tone directly with tone: widgetOptions.tone. Anything else can follow a tone with DankTone from qs.Widgets:

Rectangle {
property var widgetOptions: ({})

color: toneColors.surfaceColor

DankTone {
id: toneColors
tone: widgetOptions.tone ?? ""
}

StyledText {
text: "42"
color: toneColors.contentColor
}
}

DankTone exposes surfaceColor, containerColor, contentColor, accentColor, onAccentColor, mutedColor, chipColor and tinted, the same values a Card has. The built-in weather widgets are painted this way.

DashCardComponent Reference​

DashCardComponent extends the overview Card.

Auto-injected:

PropertyTypeDescription
pluginIdstringYour plugin id
pluginServicevarPluginService
popoutServicevarPopoutService
entryIdstringplugin_<pluginId>
liveboolThe overview is visible
interactiveboolFalse while the user edits the grid

Provided by the base:

MemberDescription
padContent padding. Children sit inside it
titleOptional label drawn at the top
tone"", "primary", "secondary" or "tertiary"
containerColor, contentColor, accentColor, onAccentColor, mutedColor, chipColorColors that follow tone
clickable, signal clickedState layer and click. Clicking opens your tab if you have one
opensTabSet false to keep clicks to yourself
options, pluginData, getData, setDataSame as the tab
focusTargetDefaults to the card when clickable, else null (skipped by keyboard navigation)
handleKeyEvent(event), blocksTabNavigationSame as the tab

Keyboard​

The dash offers every key to the current tab's handleKeyEvent first. What it does with the rest:

  • Down or Right from the header enters the tab content at focusTarget.
  • Tab and Shift+Tab switch dash tabs unless the tab sets blocksTabNavigation. Ctrl+Tab and Ctrl+Shift+Tab always switch.
  • On the overview, Alt+Arrows or Alt+H/J/K/L move between cards and Alt+Tab / Alt+Shift+Tab cycle them. Non-clickable cards without a focusTarget are skipped.
  • Escape leaves edit mode, then a detail page, then closes the dash. Consume it in handleKeyEvent to close your own overlay first.

Opening the Dash From Other Surfaces​

popoutService.toggleDankDash(tabId, x, y, width, section, screen) opens the dash on a tab. From a bar or dock widget, use the positioned pillClickAction form so the dash opens next to the pill:

PluginComponent {
property var popoutService: null
pillClickAction: (x, y, width, section, screen) => popoutService?.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)
}

Dash Plugin Best Practices​

  1. Gate on live - The dash content stays loaded after the popout closes. Timers, animations, SystemClock and service refs should all stop when live is false
  2. Lay out for every size - Test your card at minWxminH and maxWxmaxH
  3. Use the card colors - Raw Theme.primary breaks tinted cards
  4. Keep state out of settings - Use the state API for runtime data, setData for things users configure
  5. Use Card for tiles in a tab - It matches the overview look and follows the user's surface settings

Lyrics Providers​

A plugin can add a lyrics source. The provider is a program you ship in the plugin, written in anything. DMS runs it with the track's title and artist and it prints the lyrics. It sits next to the built-in providers with the same cache, rate limit and priority order.

Requires DMS 2.0

Lyrics providers are new in 2.0. Declare "requires_dms": ">=2.0.0".

How Users See Them​

  • After the plugin is enabled under Settings → Plugins, the provider shows up switched off at the bottom of Settings → Sound & media → Media player → Lyrics providers.
  • It only runs once the user switches it on there. Dragging it up or down sets its priority against the other providers.
  • Your name is the row title and the credit under the lyrics. url makes the credit a link.
  • Provider ids are plugin_<pluginId>.

Manifest​

{
"id": "exampleLyrics",
"name": "Example Lyrics",
"description": "Lyrics from lyrics.example.com",
"version": "1.0.0",
"author": "Your Name",
"type": "lyrics",
"requires_dms": ">=2.0.0",
"lyrics": {
"command": "./fetch.py",
"name": "Example",
"url": "https://lyrics.example.com",
"wordSync": false
}
}

A plugin with only a lyrics block needs no QML. The block also works next to component or components, so a widget or daemon plugin can bring its own provider.

Key
commandRequired. The program to run, relative to the plugin directory. Commit it with the executable bit and a shebang line.
nameThe row title and credit. Defaults to the plugin's name.
urlLink for the credit.
wordSyncSet it when your source usually has word timing. DMS then waits up to 2 seconds for you when the best answer so far only has line timing.

The Command​

DMS runs the command once per lookup. The track comes in on stdin. album and duration (seconds) are left out when unknown:

{ "title": "Get Lucky", "artist": "Daft Punk", "album": "Random Access Memories", "duration": 369 }

Print the answer on stdout:

{ "format": "lrc", "lyrics": "[00:31.40]Like the legend of the phoenix\n[00:35.93]All ends with beginnings" }
OutputMeans
"format": "lrc"LRC: line-synced, enhanced with <mm:ss.xx> word stamps, or plain text with no stamps.
"format": "ttml"TTML, including singers (ttm:agent) and backing vocals (ttm:role="x-bg").
"format": "lyricsfile"Lyricsfile 1.0 YAML.
{"instrumental": true}The track is an instrumental.
Nothing, exit 0No lyrics for this track.
Non-zero exitAn error.
  • Answers are cached per track. A miss is asked again after 14 days, and so are line-synced lyrics in case word timing showed up.
  • Errors are not cached, and the other providers still answer.
  • The command gets 8 seconds. After that, or once another provider wins, it is killed along with anything it started.
  • Output over 512 KiB is rejected. stderr is dropped.
  • It runs with the environment and working directory of the dms process. Find your own files relative to the script.
  • Each provider gets a burst of 10 lookups, then one every 2 seconds.

Example​

#!/usr/bin/env python3
import json
import sys
import urllib.error
import urllib.parse
import urllib.request

track = json.load(sys.stdin)
query = urllib.parse.urlencode({"artist": track["artist"], "title": track["title"]})
try:
with urllib.request.urlopen(f"https://lyrics.example.com/search?{query}", timeout=5) as response:
found = json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
sys.exit(0)
raise

json.dump({"format": "lrc", "lyrics": found["lrc"]}, sys.stdout)

A 404 prints nothing, which is a miss. Any other failure raises, exits non-zero and counts as an error.

Testing a Provider​

Run it by hand:

echo '{"title":"Get Lucky","artist":"Daft Punk"}' | ./fetch.py

DMS caches every answer, misses included. Clear the cache after changing your command, then skip to another track and back, since the shell keeps the current track's lyrics until the track changes:

rm -rf ~/.cache/dms/lyrics

Lyrics Provider Best Practices​

  1. Match strictly - Check the title, artist and duration of what you found. A wrong song beats every provider below yours, a miss lets them answer
  2. Exit 0 when nothing matches - A non-zero exit is an error, and errors are asked again on every lookup
  3. Answer in time - Keep your own request timeouts well under 8 seconds

Composite Plugins​

A single plugin can provide multiple surfaces at once — for example a background daemon, a DankBar widget, and a desktop widget. Each surface has a different lifecycle (the daemon is instantiated once; bar and desktop widgets are instantiated per bar/placement, per screen), so each one is its own QML file.

Manifest​

Replace the single type + component with a components map, and set type to composite:

{
"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"
}

Provide any subset of the surface keys below. A daemon plus a bar widget is just as valid as all of them.

Surfaces​

KeyRoot componentLifecycle
widgetPluginComponent (bar pills + optional Control Center widget)per bar, per screen
desktopDesktopPluginComponentper placement, per screen
daemonPluginComponent (or any Item exposing pluginService / pluginId)one persistent instance
launcherQtObject with getItems / executeItem (requires trigger)one instance
dashDashTabComponentwhile the tab is current (DMS 2.0+)
dashCardDashCardComponentwith the overview grid (DMS 2.0+)

Each surface is written exactly as it would be for a standalone plugin of that type — see the Widget, Daemon, Desktop, and Launcher sections above. They're simply bundled into one plugin directory and listed under components.

Sharing state between surfaces​

Each surface is a separate object, so they can't read each other's properties directly. Share runtime state through:

  • Global variables — PluginService.getGlobalVar(pluginId, name, default) / setGlobalVar(...). Reactive, in-process, and namespaced per plugin. Use PluginGlobalVar inside widgets for a reactive binding.
  • The daemon instance — register IpcHandlers there and have the other surfaces read the resulting state via global variables.
  • Settings — savePluginData / loadPluginData. All surfaces of a plugin share one settings namespace, so a single settings component configures every surface.

Enabling​

Composite plugins respect the enable toggle in Settings → Plugins; they are not auto-loaded. (A pure desktop plugin still auto-loads for backwards compatibility, but a composite that happens to include a desktop surface does not, because it may also carry a daemon.) Enable it once, then add the bar widget and/or place the desktop widget as usual.

Requires DMS 1.5.0

The components map is only understood by DMS 1.5.0 and later — declare "requires_dms": ">=1.5.0". The legacy single type + component form continues to work unchanged on every version.

Dependency Checks​

A plugin can optionally gate activation behind a dependency check, so it refuses to load (and tells the user why) when something it needs is missing. The check runs before the plugin loads — both when the user enables it and when it auto-loads at startup.

Point the manifest's startupCheck field at a small component, and list the tools you depend on in dependencies:

{
"id": "myPlugin",
"name": "My Plugin",
"type": "widget",
"component": "./Widget.qml",
"startupCheck": "./StartupCheck.qml",
"dependencies": ["boregard"]
}

The check component must be non-visual — use a QtObject so nothing is created in the graphics scene. It exposes a check function. Call done(null) to allow activation, or done(error) to block it:

// StartupCheck.qml
import QtQuick
import qs.Common

QtObject {
function check(done) {
Proc.runCommand("myPlugin.depCheck", ["which", "boregard"], (stdout, exitCode) => {
if (exitCode === 0) {
done(null)
return
}
done({
title: I18n.tr("boregard is required"),
details: I18n.tr("The 'boregard' tool is not installed or not on your PATH.\n\nInstall it from https://duxlabs.dev, then re-enable this plugin.")
})
})
}
}

The error passed to done can be:

  • a string — shown as the toast title, or
  • an object { title, details } — where details is long-form text rendered as an expandable section of the toast. Use this for multi-line install instructions. Any http(s) URL in details becomes a clickable link that opens in the browser.

A synchronous variant is supported for in-process checks — declare check() with no argument and return the result directly:

QtObject {
function check() {
return SomeService.ready ? null : I18n.tr("Service unavailable")
}
}
note

Async vs sync is chosen by arity: if you declare check(done) (one argument) it is treated as async and you must call done(...); if you declare check() (no arguments) its return value is used directly. Don't mix the two — a one-argument check that returns instead of calling done will hang activation.

When the check fails the enable toggle reverts and the error is shown as a toast. Plugins without a startupCheck are unaffected. The last error per plugin is available at PluginService.pluginLoadErrors[pluginId]. See ExampleStartupCheck in the PLUGINS/ directory for a complete plugin.

Plugin Settings​

Use PluginSettings as the base and drop in setting components. They handle all the loading and saving automatically.

import QtQuick
import qs.Common
import qs.Modules.Plugins
import qs.Widgets

PluginSettings {
id: root
pluginId: "colorDemo"

StyledText {
width: parent.width
text: "Color Demo Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}

StyledText {
width: parent.width
text: "Pick colors for your widget"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}

ColorSetting {
settingKey: "customColor"
label: "Widget Color"
description: "Color shown in the bar"
defaultValue: Theme.primary
}

SliderSetting {
settingKey: "updateInterval"
label: "Update Speed"
description: "How often to refresh"
defaultValue: 60
minimum: 10
maximum: 300
unit: "sec"
}

ToggleSetting {
settingKey: "showInBar"
label: "Show in Bar"
description: "Display widget in DankBar"
defaultValue: true
}

StringSetting {
settingKey: "apiKey"
label: "API Key"
description: "Your service API key"
placeholder: "Enter key"
defaultValue: ""
}

SelectionSetting {
settingKey: "theme"
label: "Theme"
description: "Widget appearance"
options: [
{label: "Light", value: "light"},
{label: "Dark", value: "dark"},
{label: "Auto", value: "auto"}
]
defaultValue: "dark"
}
}

The setting components available:

  • ColorSetting - Opens color picker modal
  • SliderSetting - Numeric slider
  • ToggleSetting - Boolean switch
  • StringSetting - Text input
  • SelectionSetting - Dropdown menu

Access settings in your widget via pluginData:

property color customColor: pluginData.customColor || Theme.primary
property int updateInterval: pluginData.updateInterval || 60
property bool showInBar: pluginData.showInBar !== undefined ? pluginData.showInBar : true
property string apiKey: pluginData.apiKey || ""
property string theme: pluginData.theme || "dark"

Plugin State​

Settings are great for user preferences, but sometimes you need to persist runtime data — command history, recent items, counters, cached results. That's what the state API is for.

State is stored per-plugin at ~/.local/state/dms/plugins/{pluginId}_state.json, completely separate from the shared plugin_settings.json. No permissions required.

State vs Settings​

Use Settings (savePluginData)Use State (savePluginState)
User-configured preferencesRuntime/transient data
Trigger character, themes, togglesCommand history, recent items
Stored in shared plugin_settings.jsonStored in {pluginId}_state.json
Shown/edited in settings UIAccumulated during usage

API​

// Save a value
pluginService.savePluginState(pluginId, key, value)

// Load a value (synchronous, returns defaultValue if key doesn't exist)
pluginService.loadPluginState(pluginId, key, defaultValue)

// Remove a key
pluginService.removePluginStateKey(pluginId, key)

// Wipe all state for a plugin
pluginService.clearPluginState(pluginId)

// Get the filesystem path to the state file
pluginService.getPluginStatePath(pluginId)

A pluginStateChanged(pluginId) signal fires whenever any state key changes.

Usage​

Load state alongside settings on startup, then save as things change:

Component.onCompleted: {
// Settings: user preferences (plugin_settings.json)
trigger = pluginService.loadPluginData("myPlugin", "trigger", "!");

// State: runtime data (myPlugin_state.json)
history = pluginService.loadPluginState("myPlugin", "history", []);
}

function addToHistory(entry) {
history.unshift(entry);
if (history.length > 50)
history = history.slice(0, 50);
pluginService.savePluginState("myPlugin", "history", history);
}

State in Settings Components​

PluginSettings provides convenience wrappers so you don't have to touch pluginService directly:

PluginSettings {
id: settings
pluginId: "myPlugin"

// Settings helpers (plugin_settings.json)
// settings.saveValue(key, value)
// settings.loadValue(key, defaultValue)

// State helpers (myPlugin_state.json)
// settings.saveState(key, value)
// settings.loadState(key, defaultValue)
// settings.clearState()
}

How It Works Under the Hood​

  • Reads are synchronous — loadPluginState() returns the value immediately (backed by FileView with blockLoading)
  • Writes are debounced — rapid savePluginState() calls batch into a single disk write (150ms)
  • Writes are atomic — uses FileView with atomicWrites to prevent corruption
  • Cleanup — FileView instances are destroyed when a plugin is unloaded
  • No permissions needed — state operates on its own file, independent of settings_write

See PLUGINS/QuickNotesExample/ in the DMS repo for a complete launcher plugin using the state API.

Common Patterns​

Auto-injected Properties​

PluginComponent automatically provides these properties - don't declare them yourself:

  • pluginData - Reactive settings object
  • pluginService - Service for manual data operations
  • pluginId - Your plugin's ID
  • axis - Bar axis info
  • section - "left", "center", or "right"
  • parentScreen - Screen reference
  • widgetThickness - Widget height/width
  • barThickness - Bar height/width
  • iconSize - Recommended icon size for the current bar context
  • variants - Variant instances

Use iconSize for consistent icon sizing that adapts to bar orientation and user preferences:

DankIcon {
name: "settings"
size: root.iconSize
}

Saving Data Manually​

Most of the time pluginData handles everything, but if you need to save manually:

if (pluginService) {
pluginService.savePluginData(pluginId, "key", value)
}

Showing Notifications​

ToastService.showInfo("Title", "Message")
ToastService.showError("Title", "Error message")

Copying to Clipboard​

Quickshell.execDetached(["sh", "-c", "echo -n 'text' | wl-copy"])

Timers​

PluginComponent {
Timer {
interval: 1000
running: true
repeat: true
onTriggered: {
// Do something every second
}
}
}

Running Commands​

Proc.runCommand (from qs.Common) runs an external command with debouncing and a timeout, and delivers stdout plus the exit code to a callback:

Proc.runCommand(id, command, callback, debounceMs, timeoutMs, owner)
  • id — a fixed string (use "pluginId.purpose") coalesces rapid calls into one run and reuses one debounce timer. Pass null for a one-shot with no coalescing.
  • command — argv array, e.g. ["sh", "-c", "cat /proc/loadavg"].
  • callback(stdout, exitCode) — invoked after the process exits.
  • debounceMs — wait before launching; another call within the window replaces the pending command. Default 50.
  • timeoutMs — kill the process after this long (exit code 124). Default 10000, Proc.noTimeout disables.
  • owner — pass your component root. If the owner is destroyed while a run is pending, the command is not launched and the callback is never invoked — without it, a pending command still fires after your instance is gone (a reload or monitor disconnect mid-poll).
PluginComponent {
id: root

Timer {
interval: 2000
running: true
repeat: true
onTriggered: Proc.runCommand("myPlugin.load", ["cat", "/proc/loadavg"], (stdout, exitCode) => {
if (exitCode !== 0)
return
root.load = stdout.split(" ")[0]
}, 100, undefined, root)
}
}

Proc.release(id) drops a fixed id's pending work and its timer explicitly — call it from Component.onDestruction if you don't pass owner. Both owner and release require DMS 1.6+; on older versions guard your callback with if (!root) return instead.

Lyrics​

Requires DMS 2.0

Declare "requires_dms": ">=2.0.0" to use lyrics.

Lyrics come from a LyricsController in qs.Services. It looks up the track through the DMS core and keeps the current line and word in sync with playback. It does nothing while enabled is false.

LyricsService.controller is the shared controller for the active MPRIS player. The media tab, the island and plugins all read the same one, so there is one lookup and one clock no matter how many views are open. It only runs while something holds a LyricsSubscription:

import QtQuick
import qs.Services
import qs.Widgets
import qs.Modules.Plugins

DesktopPluginComponent {
id: root

readonly property var lyrics: LyricsService.controller

LyricsSubscription {
active: root.visible
}

StyledText {
anchors.centerIn: parent
text: root.lyrics.lines[root.lyrics.activeIndex]?.x ?? ""
}
}

For the full scrolling view with word highlights, use LyricsOverlay from qs.Modules.DankDash.Media. It subscribes by itself while visible:

LyricsOverlay {
anchors.fill: parent
}

Players that are not MPRIS​

Give your player its own LyricsController and pass it to the overlay:

LyricsController {
id: radioLyrics
enabled: available && overlay.visible
track: radio.title ? {
title: radio.title,
artist: radio.artist,
length: radio.duration
} : null
player: radio
playing: radio.playing
}

LyricsOverlay {
id: overlay
anchors.fill: parent
lyrics: radioLyrics
}

Inputs:

  • track: { title, artist, album, length } with length in seconds, or null when nothing is loaded. Add key to tell tracks from different players apart.
  • player: any object with a position in seconds. Update it when the user seeks. Between updates the controller counts forward on its own using rate.
  • playing, stopped and rate. Set stopped when position means nothing, so the controller holds the last line.
  • url: the track's file:// url for local files. Other urls are ignored.
  • embeddedText: plain lyrics to show when the lookup finds nothing.
  • settling: true while your metadata is still changing. The lookup waits for it.
  • enabled: only true while the lyrics are on screen. Check available too, since older cores have no lyrics support.

Outputs:

  • state: idle, loading, ready, instrumental, none or error. pending is true while a lookup is in flight.
  • synced: true when lines has timestamps. Otherwise read plainLines.
  • lines: groups of { t, e, x, parts }, times in seconds. activeIndex is the current group.
  • wordTime and wordEnd: the current word's timing, when the provider has word timing.
  • attributionName, attributionUrl: credit for the provider.

Lookups only use the lyrics providers the user enabled in Settings. With none enabled, lookups never hit the network. To add a provider, see Lyrics Providers.

Plugin Manifest Reference​

Required Fields​

{
"id": "pluginId",
"name": "Plugin Name",
"description": "What it does",
"version": "1.0.0",
"author": "Your Name",
"type": "widget",
"component": "./Widget.qml"
}

Optional Fields​

{
"icon": "material_icon",
"settings": "./Settings.qml",
"startupCheck": "./StartupCheck.qml",
"trigger": "#",
"permissions": ["settings_read", "settings_write"],
"requires_dms": ">=0.1.18",
"dependencies": ["tool1", "tool2"],
"components": {
"daemon": "./Daemon.qml",
"widget": "./Widget.qml",
"desktop": "./Desktop.qml",
"launcher": "./Launcher.qml",
"dash": "./DashTab.qml",
"dashCard": "./DashCard.qml"
},
"dash": {
"title": "My Tab",
"card": { "w": 2, "h": 1 },
"options": []
},
"lyrics": {
"command": "./fetch.py",
"name": "My Source",
"url": "https://example.com",
"wordSync": false
}
}

components replaces component for composite plugins that expose more than one surface. Provide one or the other. dash labels the dash surfaces and declares their options, see Dash Options. lyrics adds a lyrics provider. A plugin with only lyrics needs no component.

Plugin Types​

  • "widget" - DankBar or Control Center widget
  • "daemon" - Background service
  • "launcher" - Spotlight extension
  • "desktop" - Desktop layer widget
  • "dash" - Dash tab (requires DMS ≥ 2.0.0)
  • "dashCard" - Dash overview card (requires DMS ≥ 2.0.0)
  • "lyrics" - Lyrics provider with no QML (requires DMS ≥ 2.0.0)
  • "composite" - Multiple surfaces in one plugin (use with components; requires DMS ≥ 1.5.0)

Permissions​

  • "settings_read" - Read plugin settings
  • "settings_write" - Write plugin settings
  • "process" - Execute system commands
  • "network" - Network access

Translations​

Plugins ship their own translations. Drop a translations/ directory next to plugin.json, wrap your strings in I18n.trFor(), done — nothing needs to change in the DMS repo, and users get your translations just by installing the plugin.

MyPlugin/
├── plugin.json
├── MyWidget.qml
└── translations/
├── es.json
├── fr.json
└── zh_CN.json

DMS loads the file matching the user's locale when it discovers the plugin, and re-reads it whenever the locale changes. No restart, no registration call.

Marking Strings​

Wrap every user-facing string in I18n.trFor(), with your plugin id as the first argument:

import qs.Common

StyledText {
text: I18n.trFor("myPlugin", "Cycle Speed")
}

Placeholders work the same as anywhere else in DMS:

ToastService.showInfo(I18n.trFor("myPlugin", "Copied %1 to clipboard").arg(item))

Lookup order is: your plugin's translation file → the global DMS catalog → the English term itself. Strings DMS already translates ("Settings", "Close", etc.) resolve from the global catalog for free, and anything untranslated renders as your English text instead of breaking.

Two rules:

  • The plugin id must be a literal string that exactly matches id in your plugin.json. The string extraction tooling for central translation reads call sites — a variable there means your strings never get extracted.
  • I18n.trFor doesn't exist on DMS versions without plugin translation support, and calling a missing function in QML is a TypeError, not a silent no-op. Bump requires_dms when you adopt it.

Plain I18n.tr() still works inside plugins, but it only checks the global catalog — it will never see your translations/ files.

Translation Files​

One JSON file per locale. Top-level keys are context buckets; unless you have a reason to do otherwise, use the English term as its own bucket:

{
"Cycle Speed": {
"Cycle Speed": "Velocidad de ciclo"
},
"Copied %1 to clipboard": {
"Copied %1 to clipboard": "%1 copiado al portapapeles"
}
}

There is no en.json — the strings in your QML are the English source. Translate what you want, skip what you don't; missing terms fall through the lookup order above.

File Naming and Locale Resolution​

Use the same file names DMS ships in its own catalog: ar, de, eo, es, fa, fr, he, hu, it, ja, ko, nl, pl, pt (Brazilian), ru, sv, tr, uk, vi, zh_CN, zh_TW.

You're not limited to that list — resolution is driven by the user's locale, not by what DMS core translates. Ship a cs.json and Czech users get it even though DMS itself has no Czech catalog yet.

For a user on zh_CN, DMS tries zh_CN.json, then zh-CN.json, then zh.json, and uses the first one that exists. No match means your English strings render as-is.

Testing Translations​

  1. Reload after editing a file: dms ipc call plugins reload myPlugin
  2. Switch languages under Settings → Locale — plugin strings retranslate live along with the rest of the shell
  3. Broken JSON logs a bad plugin translations warning in shell output and falls back to English

ExampleEmojiPlugin in the DMS repo's PLUGINS/ directory is a complete working example — trFor calls in the widget and settings, plus es.json and fr.json.

Central Translation via POEditor​

DMS itself is translated by community translators through a POEditor project covering 21 languages. Plugins in the registry can apply to have their strings translated there too:

  1. Make sure every user-facing string goes through I18n.trFor("<your plugin id>", ...) with the id as a literal
  2. Open a PR to the registry setting "i18n": true in your plugin's JSON, with a short note on what the plugin does and roughly how many strings it has
  3. On approval your English strings are uploaded to POEditor, tagged with your plugin id
  4. As translators complete languages, PRs land in your repo adding translations/<locale>.json files — merge them and the translations ship with your next plugin update

Keep your English strings stable once they're in POEditor. Renaming a term is a delete-plus-add: the old term's translations are discarded.

Testing​

  1. Enable plugin: Settings → Plugins → Scan → Toggle on
  2. Add to bar: Settings → DankBar → Add widget. For a dock, use the dock's edit mode. Dash tabs show up on their own; add dash cards from the dash (three-dot menu → Edit → Add widget), and open a tab directly with dms ipc call dash open plugin_myPlugin
  3. Check console: Look for errors in shell output
  4. Hot-reload your plugin: dms ipc call plugins reload myPlugin
  5. Check settings files: ~/.config/DankMaterialShell/settings.json or ~/.config/DankMaterialShell/config.d/*.json

Hot Reloading​

During development you can reload plugins without restarting the shell:

# Reload your plugin after making changes
dms ipc call plugins reload myPlugin

# Check if it's running
dms ipc call plugins status myPlugin

# List all plugins and their state
dms ipc call plugins list

This is way faster than dms restart when you're iterating on your code.

Publishing​

  1. Create GitHub repo
  2. Include plugin.json, README, screenshots
  3. Tag releases: git tag v1.0.0 && git push --tags
  4. Submit to registry: dms-plugin-registry

Examples​

Check the PLUGINS/ directory in the DMS repo for example plugins:

  • ColorDemoPlugin - Color picker integration
  • ExampleEmojiPlugin - Popout with grid view, plugin-local translations
  • ControlCenterExample - Control Center toggle
  • LauncherExample - Spotlight extension
  • WallpaperWatcherDaemon - Background event watcher
  • ExampleCompositePlugin - Daemon + bar widget + desktop widget in one plugin
  • ExampleStartupCheck - Blocks activation when a system dependency is missing
  • DashTabExample - Standalone dash tab: a stopwatch with laps
  • DashCardExample - Standalone dash card: day, week, month or year progress
  • DashCounterExample - Dash tab, dash card and a bar or dock widget sharing one counter
  • AttachedPanelExample - Widget with an attached dock panel

First-party plugins (good references for real-world patterns):

Desktop widgets:

  • DesktopClockWidget - Digital/analog clock with date display and style options
  • SystemMonitorWidget - CPU, memory, network, disk, GPU tiles with real-time graphs

Clone them and experiment.