No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-29 06:23:17 +02:00
BarWidget.qml Initial commit: Quickshell KDE taskbar plugin 2026-07-29 06:23:17 +02:00
Main.qml Initial commit: Quickshell KDE taskbar plugin 2026-07-29 06:23:17 +02:00
manifest.json Initial commit: Quickshell KDE taskbar plugin 2026-07-29 06:23:17 +02:00
Panel.qml Initial commit: Quickshell KDE taskbar plugin 2026-07-29 06:23:17 +02:00
README.md Initial commit: Quickshell KDE taskbar plugin 2026-07-29 06:23:17 +02:00

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.WidgetsStyle, 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 own Quickshell.Wayland bindings 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 (.current property) — bare UUID, no braces (contrast with internalId below, which does have braces).
  • Activate/focus/restore: org.kde.krunner1.Run(matchId, "") on the same /WindowsRunner object. 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 .js file with full access to workspace.windowList() and each Window's settable .minimized boolean. Gotcha: internalId on the KWin-script-side Window object stringifies with braces ("{uuid}") — mixing this up with the bare-UUID format from getWindowInfo/VirtualDesktopManager silently no-ops the match.

Real bugs found during implementation (all fixed, all instructive if redoing this)

  1. pragma Singleton doesn't auto-register inside a plugin directory. The service file (Main.qml) was originally TaskbarService.qml using pragma 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 polling Timer never ran — silent failure, no error, just an invisible (1px-wide, empty-Repeater) widget. Confirmed by checking for spawned busctl child 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's entryPoints.main (see arch-updater's plugin for the working precedent). PluginService.qml (/etc/xdg/quickshell/noctalia-shell/Services/Noctalia/PluginService.qml) instantiates Main.qml once, globally, and wires it into every other entry point via pluginApi.mainInstance. Consume it in BarWidget.qml as pluginApi.mainInstance, not a bare global name.

  2. FileView.writeAdapter() requires a child adapter object. Original minimize implementation wrote the generated KWin script via FileView { 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, so loadScript always 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 FileView for this at all — shell out via Process { command: ["sh", "-c", "cat > '<path>' << 'EOF'\n<script>\nEOF\n"] }, the same pattern the pre-existing window-minimize plugin already used reliably for its own shell commands.

  3. KWin's Match("") returns every window twice. Not a timing race — confirmed deterministic via raw busctl output (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.

  4. Overlapping poll cycles corrupt shared state. The window list is built by one Match call fanning out into N sequential getWindowInfo calls (resolveNext() recursion). With ~18 windows open, one full resolve cycle can take longer than the 1s poll interval, so a new Timer tick can start a second cycle while the first is still writing into the same _pendingUuids/_resolved properties. Fixed with a simple resolving boolean guard — skip refresh() if a cycle is already in flight.

  5. 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 windows array the instant an action is triggered. applyOptimisticMinimized() does this — mutate-and-reassign windows immediately in minimize()/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 /WindowsRunner or elsewhere — 1s poll interval is a real responsiveness ceiling, not a bug.
  • Bar auto-hide + popup interaction. With the bar's displayMode: auto_hide setting 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 to always_visible sidesteps 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]'