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.
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
- Install the QML Extension
- 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"
}
- Open VSCode in the
~/repos/dux-shell/quickshelldirectory
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
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
- Open DMS Settings → Plugins
- Click "Scan for Plugins"
- Toggle your plugin on
- Add to DankBar widget list
- 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.
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
horizontalBarPillandverticalBarPill. Docks sit on any edge. - Size icons with
root.iconSizeand text withroot.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
widgetInstanceIdtells them apart.
surfaceContext describes where the widget is running:
| Property | Description |
|---|---|
kind | "bar" or "dock" |
id | The bar or dock config id |
screen, isVertical, thickness | Host display, orientation and thickness |
live | The host is revealed and the monitor is on |
editMode | The host is in widget edit mode |
inlineExpansion | The 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.
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):
| Property | Type | Description |
|---|---|---|
pluginService | var | Reference to PluginService for data persistence |
pluginId | string | Your plugin's unique identifier |
widgetWidth | real | Current widget width |
widgetHeight | real | Current widget height |
pluginData | var | Object containing all saved plugin settings |
Optional (define on your component):
| Property | Type | Default | Description |
|---|---|---|---|
minWidth | real | 100 | Minimum allowed width |
minHeight | real | 100 | Minimum allowed height |
defaultWidth | real | 200 | Initial width for new widgets |
defaultHeight | real | 200 | Initial height for new widgets |
forceSquare | bool | false | Constrain to square aspect ratio |
acceptsKeyboardFocus | bool | false | Opt in to receive keyboard focus when not interacting (see below) |
lockScreen | bool | false | Set 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:
| Action | Trigger | Description |
|---|---|---|
| Move | Right-click + drag anywhere | Repositions the widget |
| Resize | Right-click + drag bottom-right corner | Resizes 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.
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
acceptsKeyboardFocusis 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 (
SystemClockwithMinuteswhere possible) and stop animations whenlockScreenis 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
- Use Theme singleton - Never hardcode colors, spacing, or font sizes
- Set appropriate minWidth/minHeight - Prevent unusable widget sizes
- Handle null data - Use
??operator for allpluginDataaccess - Optimize Canvas redraws - Use
renderStrategy: Canvas.Cooperative - Responsive layouts - Adapt to widget dimensions dynamically
- 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"]
}
| Field | Description |
|---|---|
trigger | Prefix that activates the plugin (e.g., gif, :s, !calc) |
viewMode | Default view: "list", "grid", or "tile" |
viewModeEnforced | If true, user cannot change the view mode |
Item Properties
| Property | Type | Description |
|---|---|---|
name | string | Display name (required) |
icon | string | "material:icon_name" or "unicode:🚀" |
comment | string | Secondary text/description |
action | string | Action identifier (e.g., "copy:text", "custom:data") |
categories | array | Category strings for filtering |
keywords | array | Additional search terms |
imageUrl | string | URL for tile/grid image |
animated | bool | Set true for GIFs to enable animation |
attribution | string | Path 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:
| Field | Type | Description |
|---|---|---|
icon | string | Material icon name |
text | string | Display label |
action | function | Callback executed when selected |
closeLauncher | bool | If 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();
}
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (empty string for "All") |
name | string | Display name in dropdown |
searchTerm | string | Optional: 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:
- If
getPasteArgsexists, use the returned command array - Else if
getPasteTextexists, use["dms", "cl", "copy", text] - 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.
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>, sodms ipc call dash open plugin_myPluginopens yours.
Picking a Manifest Form
| You want | Manifest |
|---|---|
| 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
Timeronly runs whileliveis 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.
focusTargetis where Down lands when the user enters the tab.restoreFocus()runs when the dash opens on the tab; focusing withQt.OtherFocusReasonavoids drawing a focus ring.handleKeyEventgets keys before the dash does. Returntrueto consume one.menuActionsadds entries to the tab's three-dot menu.implicitHeightgrows the dash when many laps are listed. It never goes belowDashMetrics.tabMinHeight.options.hundredthsandoptions.lapscome from the manifest'sdash.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.hset the default size,minW..maxHthe range users can resize within. Widths are clamped to the column count. - The card has to look right at every size in its range.
wideandtallcompare the card's pixel size toDashMetrics.gridRowUnit. tonetints the surface. Use the card colors (accentColor,contentColor,mutedColor,chipColor) instead of raw theme colors so every tone works.SystemClockis 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:
| Field | Description |
|---|---|
icon, title | Tab icon and label. Fall back to the top-level icon and name |
card.title, card.icon | Card name and icon in the Add widget menu. Fall back to the tab label and icon |
card.w, card.h | Default card size in grid cells |
card.minW, card.minH, card.maxW, card.maxH | Resize range. Widths clamp to the column count, heights to the screen |
options | User 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 }
]
toggletakes an optionaldescription.numbertakesmin,max,stepandunit.text,descriptionand 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. widgetsis 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):
| Property | Type | Description |
|---|---|---|
pluginId | string | Your plugin id |
pluginService | var | PluginService |
popoutService | var | PopoutService |
entryId | string | plugin_<pluginId> |
dashHost | var | The dash popout: dashVisible, requestTab(id), editMode |
editMode | bool | The dash is in edit mode |
live | bool | This 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:
| Member | Description |
|---|---|
options | Resolved dash.options values |
pluginData | Your plugin settings, reloaded on pluginDataChanged |
getData(key, default) / setData(key, value) | Read and write plugin settings |
Optional (define on your tab):
| Member | Description |
|---|---|
implicitHeight | The dash grows to fit it. Minimum DashMetrics.tabMinHeight |
focusTarget | Item 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 |
blocksTabNavigation | True 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 navFocusRequested | Hand 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 translatedtext, anicon, acomponentand a defaultw/h.minW/minH/maxW/maxHare optional. enabled: falseleaves a widget in the Add menu instead of placing it.optionsgives a widget its own options, see Widget Options.- Widgets get
widgetId,widgetOptionsand a boundliveif they declare them. The grid unloads widget content whenliveis 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
widgetOptionsby key, for examplewidgetOptions.tone. DashRegistry.toneOption(def)is the shared Tone choice:""for the surface,"primary","secondary"or"tertiary".id,w,h,colandroware 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:
| Property | Type | Description |
|---|---|---|
pluginId | string | Your plugin id |
pluginService | var | PluginService |
popoutService | var | PopoutService |
entryId | string | plugin_<pluginId> |
live | bool | The overview is visible |
interactive | bool | False while the user edits the grid |
Provided by the base:
| Member | Description |
|---|---|
pad | Content padding. Children sit inside it |
title | Optional label drawn at the top |
tone | "", "primary", "secondary" or "tertiary" |
containerColor, contentColor, accentColor, onAccentColor, mutedColor, chipColor | Colors that follow tone |
clickable, signal clicked | State layer and click. Clicking opens your tab if you have one |
opensTab | Set false to keep clicks to yourself |
options, pluginData, getData, setData | Same as the tab |
focusTarget | Defaults to the card when clickable, else null (skipped by keyboard navigation) |
handleKeyEvent(event), blocksTabNavigation | Same 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
focusTargetare skipped. - Escape leaves edit mode, then a detail page, then closes the dash. Consume it in
handleKeyEventto 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
- Gate on
live- The dash content stays loaded after the popout closes. Timers, animations,SystemClockand service refs should all stop whenliveis false - Lay out for every size - Test your card at
minWxminHandmaxWxmaxH - Use the card colors - Raw
Theme.primarybreaks tinted cards - Keep state out of settings - Use the state API for runtime data,
setDatafor things users configure - Use
Cardfor 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.
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
nameis the row title and the credit under the lyrics.urlmakes 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 | |
|---|---|
command | Required. The program to run, relative to the plugin directory. Commit it with the executable bit and a shebang line. |
name | The row title and credit. Defaults to the plugin's name. |
url | Link for the credit. |
wordSync | Set 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" }
| Output | Means |
|---|---|
"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 0 | No lyrics for this track. |
| Non-zero exit | An 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
dmsprocess. 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
- 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
- Exit 0 when nothing matches - A non-zero exit is an error, and errors are asked again on every lookup
- 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
| Key | Root component | Lifecycle |
|---|---|---|
widget | PluginComponent (bar pills + optional Control Center widget) | per bar, per screen |
desktop | DesktopPluginComponent | per placement, per screen |
daemon | PluginComponent (or any Item exposing pluginService / pluginId) | one persistent instance |
launcher | QtObject with getItems / executeItem (requires trigger) | one instance |
dash | DashTabComponent | while the tab is current (DMS 2.0+) |
dashCard | DashCardComponent | with 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. UsePluginGlobalVarinside 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 singlesettingscomponent 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.
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 }— wheredetailsis long-form text rendered as an expandable section of the toast. Use this for multi-line install instructions. Anyhttp(s)URL indetailsbecomes 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")
}
}
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 modalSliderSetting- Numeric sliderToggleSetting- Boolean switchStringSetting- Text inputSelectionSetting- 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 preferences | Runtime/transient data |
| Trigger character, themes, toggles | Command history, recent items |
Stored in shared plugin_settings.json | Stored in {pluginId}_state.json |
| Shown/edited in settings UI | Accumulated 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 byFileViewwithblockLoading) - Writes are debounced — rapid
savePluginState()calls batch into a single disk write (150ms) - Writes are atomic — uses
FileViewwithatomicWritesto prevent corruption - Cleanup —
FileViewinstances 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 objectpluginService- Service for manual data operationspluginId- Your plugin's IDaxis- Bar axis infosection- "left", "center", or "right"parentScreen- Screen referencewidgetThickness- Widget height/widthbarThickness- Bar height/widthiconSize- Recommended icon size for the current bar contextvariants- 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. Passnullfor 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.noTimeoutdisables.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
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 }withlengthin seconds, ornullwhen nothing is loaded. Addkeyto tell tracks from different players apart.player: any object with apositionin seconds. Update it when the user seeks. Between updates the controller counts forward on its own usingrate.playing,stoppedandrate. Setstoppedwhenpositionmeans nothing, so the controller holds the last line.url: the track'sfile://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. Checkavailabletoo, since older cores have no lyrics support.
Outputs:
state:idle,loading,ready,instrumental,noneorerror.pendingis true while a lookup is in flight.synced: true whenlineshas timestamps. Otherwise readplainLines.lines: groups of{ t, e, x, parts }, times in seconds.activeIndexis the current group.wordTimeandwordEnd: 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 withcomponents; 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
idin yourplugin.json. The string extraction tooling for central translation reads call sites — a variable there means your strings never get extracted. I18n.trFordoesn't exist on DMS versions without plugin translation support, and calling a missing function in QML is a TypeError, not a silent no-op. Bumprequires_dmswhen 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
- Reload after editing a file:
dms ipc call plugins reload myPlugin - Switch languages under Settings → Locale — plugin strings retranslate live along with the rest of the shell
- Broken JSON logs a
bad plugin translationswarning 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:
- Make sure every user-facing string goes through
I18n.trFor("<your plugin id>", ...)with the id as a literal - Open a PR to the registry setting
"i18n": truein your plugin's JSON, with a short note on what the plugin does and roughly how many strings it has - On approval your English strings are uploaded to POEditor, tagged with your plugin id
- As translators complete languages, PRs land in your repo adding
translations/<locale>.jsonfiles — 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
- Enable plugin: Settings → Plugins → Scan → Toggle on
- 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 - Check console: Look for errors in shell output
- Hot-reload your plugin:
dms ipc call plugins reload myPlugin - Check settings files:
~/.config/DankMaterialShell/settings.jsonor~/.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
- Create GitHub repo
- Include
plugin.json, README, screenshots - Tag releases:
git tag v1.0.0 && git push --tags - 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):
- DankGifSearch - Launcher with tile view and API integration
- DankStickerSearch - Launcher with dynamic categories
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.