- QML 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| BarWidget.qml | ||
| Main.qml | ||
| manifest.json | ||
| Panel.qml | ||
| README.md | ||
Noctalia Taskbar Plugin (KWin)
Plasma-style tasklist widget for the kwin-custom session (plain KWin, no
plasmashell). Pinned apps + running windows merged into one capsule per app,
minimize/restore/activate/launch, multi-window popup. Built and verified
working 2026-07-03.
This is Noctalia-specific, not a portable quickshell config.
BarWidget.qml imports Noctalia's internal component library (qs.Commons,
qs.Services.UI, qs.Widgets — Style, Color, TooltipService,
BarService, NText, NPopupContextMenu, ThemeIcons), which only
resolves inside noctalia-shell's own QML import path. It will not run via
plain qs -p shell.qml the way a self-contained project (like the old
Hyprland ~/Documents/quickshell-tasklist/) would. Redeploying this means
copying it back into a Noctalia plugin directory, not running it standalone.
Why it exists
KWin 6.7.2 (this system's build) advertises no window-management
Wayland protocol — confirmed by checking wayland-info against the running
compositor, with and without plasmashell running as a control:
- no
zwlr_foreign_toplevel_manager_v1(what Quickshell's ownQuickshell.Waylandbindings expect) - no
org_kde_plasma_window_management - no
ext_workspace_v1
Plasma's own Task Manager doesn't use any of these either — it talks to KWin through a private, first-party channel. Third-party tools (this plugin included) have to go through D-Bus instead.
The D-Bus mechanism (all empirically verified via busctl, not guessed)
- List windows:
org.kde.KWin/WindowsRunner(org.kde.krunner1.Match("")) — KWin's built-in KRunner window-search plugin. Returns every open window with title/icon/a match ID encoding the window's UUID. Gotcha: returns every window listed twice for an empty-string query (two internal match rules both fire) — dedupe by UUID before using the result. - Per-window detail:
org.kde.KWin/KWin(org.kde.KWin.getWindowInfo(uuid)) — richer than the Match result:resourceClass(reliable appId, better than Match's icon-name field),minimized,desktops(array, for per-workspace filtering),skipTaskbar,caption. - Current desktop:
org.kde.KWin/VirtualDesktopManager(.currentproperty) — bare UUID, no braces (contrast withinternalIdbelow, which does have braces). - Activate/focus/restore:
org.kde.krunner1.Run(matchId, "")on the same/WindowsRunnerobject. Single atomic D-Bus call, no race potential, reliable. - Minimize: no D-Bus method exposes this directly. Drive it via
org.kde.KWin/Scripting(org.kde.kwin.Scripting):loadScript(path) → start() → unloadScript(path), where the loaded script is a tiny generated.jsfile with full access toworkspace.windowList()and eachWindow's settable.minimizedboolean. Gotcha:internalIdon the KWin-script-sideWindowobject stringifies with braces ("{uuid}") — mixing this up with the bare-UUID format fromgetWindowInfo/VirtualDesktopManagersilently no-ops the match.
Real bugs found during implementation (all fixed, all instructive if redoing this)
-
pragma Singletondoesn't auto-register inside a plugin directory. The service file (Main.qml) was originallyTaskbarService.qmlusingpragma Singleton+Singleton { ... }— the correct pattern for noctalia-shell's own core services (qs.Services.*, backed by a real qmldir/module system), but plugin directories don't get that treatment. No instance ever got created, so its pollingTimernever ran — silent failure, no error, just an invisible (1px-wide, empty-Repeater) widget. Confirmed by checking for spawnedbusctlchild processes under the shell's PID: zero, even though the plugin "loaded" per the journal.Fix: use Noctalia's actual documented plugin mechanism —
manifest.json'sentryPoints.main(seearch-updater's plugin for the working precedent).PluginService.qml(/etc/xdg/quickshell/noctalia-shell/Services/Noctalia/PluginService.qml) instantiatesMain.qmlonce, globally, and wires it into every other entry point viapluginApi.mainInstance. Consume it inBarWidget.qmlaspluginApi.mainInstance, not a bare global name. -
FileView.writeAdapter()requires a child adapter object. Original minimize implementation wrote the generated KWin script viaFileView { setText(...); writeAdapter(); }with no adapter — every single call logged"Cannot call writeAdapter without an adapter"and silently did nothing; the script file was never created, soloadScriptalways pointed at a nonexistent path. This is why minimize "needed two clicks" — the second click's action was actually a different code path (activate(), which restores an already-real minimized window) succeeding, misread as the minimize retry working.Fix: don't use
FileViewfor this at all — shell out viaProcess { command: ["sh", "-c", "cat > '<path>' << 'EOF'\n<script>\nEOF\n"] }, the same pattern the pre-existingwindow-minimizeplugin already used reliably for its own shell commands. -
KWin's
Match("")returns every window twice. Not a timing race — confirmed deterministic via rawbusctloutput (34 matches, 17 unique). Dedupe by UUID when building the window list, or every app with a single real window renders as two capsules / opens the multi-window popup incorrectly. -
Overlapping poll cycles corrupt shared state. The window list is built by one
Matchcall fanning out into N sequentialgetWindowInfocalls (resolveNext()recursion). With ~18 windows open, one full resolve cycle can take longer than the 1s poll interval, so a newTimertick can start a second cycle while the first is still writing into the same_pendingUuids/_resolvedproperties. Fixed with a simpleresolvingboolean guard — skiprefresh()if a cycle is already in flight. -
Action feedback lag. Minimize/restore's real backend action is fast (sub-second), but the capsule only re-renders on the next full poll cycle (up to ~1-2s with many windows) unless you optimistically patch the local
windowsarray the instant an action is triggered.applyOptimisticMinimized()does this — mutate-and-reassignwindowsimmediately inminimize()/restore()/activate(), before the async D-Bus chain even completes, so the UI feels responsive regardless of poll timing.
Known non-bugs (accepted limitations, not fixed)
- Polling, not push. No "window list changed" D-Bus signal was found
on
/WindowsRunneror elsewhere — 1s poll interval is a real responsiveness ceiling, not a bug. - Bar auto-hide + popup interaction. With the bar's
displayMode: auto_hidesetting on, moving the mouse from the capsule to the multi-window popup can cross outside the bar's hover zone and trigger auto-hide mid-interaction. Pre-existing bar behavior, not something this plugin controls; switching the bar toalways_visiblesidesteps it. - Single-window "focused" detection is a simplification. The capsule can't distinguish "this window is focused" from "this window merely isn't minimized" when an app has exactly one window (no focus tracking in the window model) — clicking a not-minimized single-window capsule always minimizes it, even if it wasn't actually the focused window.
Redeploying
cp Main.qml BarWidget.qml manifest.json ~/.config/noctalia/plugins/taskbar/
Then register it (if not already) — add to ~/.config/noctalia/plugins.json's
states object:
"taskbar": { "enabled": true, "sourceUrl": "" }
And to ~/.config/noctalia/settings.json's bar.widgets.<section> array
(built for center, but any section works):
{ "id": "plugin:taskbar" }
Restart the shell to pick it up:
systemctl --user restart 'app-noctalia\[email protected]'