Skip to content
📖 10 min🎯Difficulty:
Prerequisites:PanelWindowListView
You'll build:A workspace indicator for your panel

The Problem ​

Virtual desktops (workspaces) let users organize windows into groups. An indicator shows which workspace is active, which have windows, and allows switching with a click or scroll.

The Naive Approach ​

Read from wmctrl:

qml
Timer {
  interval: 500; running: true; repeat: true
  onTriggered: {
    var out = Qt.process(["wmctrl", "-d"]).stdout
    // parse workspace list
  }
}

wmctrl works on X11 but not Wayland. On Wayland, you must query the compositor directly.

💡Mental Model

Think of workspace indicators like elevator floor buttons. Each button represents a floor (workspace). Lit buttons show floors with activity (windows open). The button for the current floor is highlighted. Pressing any button sends the elevator there.

The Idea ​

Query your compositor for workspace information. The approach depends on your compositor: Hyprland uses hyprctl (JSON), Sway uses swaymsg -t get_workspaces (JSON), KWin uses D-Bus. Parse the JSON and display workspaces as numbered buttons.

Let's Build It ​

qml
// WorkspaceService.qml
pragma Singleton
import Quickshell

QtObject {
  id: ws

  property var workspaces: []
  property int activeWorkspace: -1

  function switchTo(number) {
    Qt.process(["hyprctl", "dispatch", "workspace", String(number)])
  }

  function refresh() {
    Qt.process(["hyprctl", "workspaces", "-j"])
      .onStdout.connect(function(data) {
        try {
          var parsed = JSON.parse(data)
          workspaces = parsed.sort(function(a, b) { return a.id - b.id })
          var active = parsed.filter(function(w) { return w.active })
          if (active.length > 0) activeWorkspace = active[0].id
        } catch (e) {}
      })
  }

  Timer {
    interval: 1000; running: true; repeat: true
    onTriggered: ws.refresh()
  }
}

Workspace indicator widget:

qml
// WorkspaceIndicator.qml
Item {
  property int maxWorkspaces: 10

  Row {
    spacing: 4
    Repeater {
      model: maxWorkspaces

      delegate: Rectangle {
        required property int index
        readonly property int wsNumber: index + 1
        readonly property var wsData: findWorkspace(wsNumber)

        function findWorkspace(num) {
          for (var i = 0; i < WorkspaceService.workspaces.length; i++) {
            if (WorkspaceService.workspaces[i].id === num)
              return WorkspaceService.workspaces[i]
          }
          return null
        }

        width: 28; height: 22; radius: 4
        color: wsNumber === WorkspaceService.activeWorkspace ? "#89b4fa" :
               wsData && wsData.windows > 0 ? "#585b70" : "#313244"

        Text {
          text: wsNumber
          anchors.centerIn: parent
          color: "white"
          font.pixelSize: 12
          font.bold: wsNumber === WorkspaceService.activeWorkspace
        }

        MouseArea {
          anchors.fill: parent
          cursorShape: Qt.PointingHandCursor
          onClicked: WorkspaceService.switchTo(wsNumber)
        }

        // Scroll to switch workspaces
        onWheel: function(wheel) {
          if (wheel.angleDelta.y > 0)
            WorkspaceService.switchTo(WorkspaceService.activeWorkspace - 1)
          else
            WorkspaceService.switchTo(WorkspaceService.activeWorkspace + 1)
        }
      }
    }
  }
}
🛠️Let's Build It

The workspace indicator queries hyprctl workspaces -j for JSON data. Each workspace is rendered as a numbered button. The active workspace is brightly colored, workspaces with windows are dimly lit, and empty workspaces are dark. Click switches, scroll cycles.

Compositor-Specific Commands ​

CompositorCommandOutput
Hyprlandhyprctl workspaces -jJSON array with id, name, windows, active
Swayswaymsg -t get_workspacesJSON array with num, name, focused
Riverriverctl get-outputsCustom protocol; use status command
KWinD-Bus org.kde.KWin.VirtualDesktopManagerD-Bus object list

Let's Improve It ​

Show window icons/titles on hover:

qml
ToolTip {
  visible: ma.containsMouse
  text: getWindowsOnWorkspace(wsNumber).join("\n")
}
⚠️Common Mistake

Using fixed workspace counts. Not every user uses 10 workspaces. Some use 4, some use dynamic workspaces. Support configuration: config.maxWorkspaces or detect from compositor output.

Not handling compositor-specific formats. The JSON output format differs between Hyprland, Sway, and others. Abstract compositor interaction into a compositor-agnostic service interface.

Forgetting scroll wrapping. When scrolling past the last workspace, some users expect wrapping to the first. Others expect no-op. Make it configurable.

Under the Hood ​

Workspace management on Wayland uses compositor-specific protocols. Hyprland uses its custom IPC (Unix socket at /tmp/hypr/*/.socket.sock). Sway uses swaymsg over the Wayland protocol itself (wlr-foreign-toplevel-management). The communications method differs, but all expose: workspace ID, name, active state, and window count.

Exercises ​

⭐Exercise
Add workspace rename support. Double-click a workspace number to enter a custom name (e.g., "WWW", "Terminal"). Pass the name via `hyprctl dispatch renameworkspace`.
⭐⭐Exercise
Implement drag-and-drop window movement between workspaces. When a workspace indicator receives a dragged window (from a window switcher), move that window to the target workspace.
⭐⭐⭐Exercise
Build a workspace overview (like macOS Mission Control). A popup shows all workspaces as thumbnails. Each thumbnail contains miniature representations of open windows. Click a thumbnail to switch to that workspace.
✅What You've Learned
  • Query compositor via hyprctl/swaymsg for workspace JSON data
  • Show numbered buttons with active/highlighted/inactive state
  • Support click to switch and scroll to cycle
  • Abstract compositor-specific commands for portability

Built with VitePress. Licensed under MIT.