Skip to content
📖 12 min🎯Difficulty:
Prerequisites:PopupWindowListViewKeyboard shortcuts
You'll build:A visual window switcher with thumbnail previews

The Problem ​

Alt+Tab is the most-used shortcut in any desktop environment. A good window switcher shows all open windows with icons, titles, and live thumbnails. Users navigate with keyboard and select with Enter.

The Naive Approach ​

List windows from xdotool:

qml
Timer {
  interval: 500; running: true; repeat: true
  onTriggered: {
    var out = Qt.process(["xdotool", "search", "--onlyvisible", "--name", "."]).stdout
    // parse and display window list
  }
}

xdotool is X11-only and doesn't provide thumbnails. On Wayland, you need compositor-specific tools or the wlr-foreign-toplevel-management protocol.

💡Mental Model

Think of the window switcher like a Rolodex. Each card represents a window with its name, icon, and a photograph (thumbnail). You flip through cards with the keyboard and stop on the one you want. The Rolodex only appears when you need it — triggered by Alt+Tab.

The Idea ​

Query open windows via compositor IPC, display them in a PopupWindow with keyboard navigation. Highlight the selected window. On enter, focus it.

Let's Build It ​

qml
// WindowService.qml
pragma Singleton
import Quickshell
import QtQml.Models

QtObject {
  id: ws

  property ListModel windows: ListModel {}

  function refresh() {
    Qt.process(["hyprctl", "clients", "-j"])
      .onStdout.connect(function(data) {
        try {
          var parsed = JSON.parse(data).filter(function(c) {
            return !c.hidden && c.mapped
          })
          windows.clear()
          parsed.forEach(function(w) {
            windows.append({
              address: w.address,
              title: w.title || "Untitled",
              klass: w.class,
              workspace: w.workspace.id,
              x: w.at[0], y: w.at[1],
              w: w.size[0], h: w.size[1]
            })
          })
        } catch (e) {}
      })
  }

  function focusWindow(address) {
    Qt.process(["hyprctl", "dispatch", "focuswindow", "address:" + address])
  }

  function closeWindow(address) {
    Qt.process(["hyprctl", "dispatch", "closewindow", "address:" + address])
  }
}

Window switcher popup:

qml
// WindowSwitcher.qml
PopupWindow {
  id: root
  width: 500; height: 400
  visible: false

  property int selectedIndex: 0

  function open() {
    WindowService.refresh()
    selectedIndex = 0
    visible = true
    listView.forceActiveFocus()
  }

  Rectangle {
    anchors.fill: parent; radius: 10; color: "#1e1e2e"

    Column {
      anchors { fill: parent; margins: 12 }; spacing: 8

      Text {
        text: "Switch Window"
        font.pixelSize: 14; font.bold: true; color: "#cdd6f4"
      }

      ListView {
        id: listView
        width: parent.width; height: parent.height - 40
        model: WindowService.windows
        spacing: 4; clip: true

        delegate: Rectangle {
          required property var modelData
          required property int index

          width: parent.width; height: 52; radius: 6
          color: index === root.selectedIndex ? "#45475a" : "#313244"

          Row {
            anchors { fill: parent; leftMargin: 12; rightMargin: 12 }; spacing: 12
            anchors.verticalCenter: parent.verticalCenter

            Rectangle {
              width: 40; height: 40; radius: 4; color: "#585b70"
              Text { text: modelData.klass.charAt(0).toUpperCase(); anchors.centerIn: parent; color: "white"; font.pixelSize: 18 }
            }

            Column { anchors.verticalCenter: parent.verticalCenter; spacing: 2
              Text { text: modelData.title; font.pixelSize: 13; color: "#cdd6f4"; elide: Text.ElideRight; width: 360 }
              Text { text: modelData.klass; font.pixelSize: 11; color: "#6c7086" }
            }
          }

          MouseArea {
            anchors.fill: parent
            onClicked: {
              root.selectedIndex = index
              root.selectCurrent()
            }
          }
        }

        highlightMoveDuration: 100
        ScrollBar.vertical: ScrollBar { active: true }

        Keys.onPressed: function(event) {
          switch (event.key) {
            case Qt.Key_Tab:
            case Qt.Key_Down:
              selectedIndex = Math.min(selectedIndex + 1, WindowService.windows.count - 1)
              listView.currentIndex = selectedIndex
              break
            case Qt.Key_Up:
              selectedIndex = Math.max(selectedIndex - 1, 0)
              listView.currentIndex = selectedIndex
              break
            case Qt.Key_Return:
            case Qt.Key_Space:
              root.selectCurrent()
              break
            case Qt.Key_Escape:
              root.visible = false
              break
          }
        }
      }
    }
  }

  function selectCurrent() {
    var w = WindowService.windows.get(selectedIndex)
    if (w) {
      WindowService.focusWindow(w.address)
    }
    visible = false
  }
}
🛠️Let's Build It

The window service fetches all open windows via hyprctl clients -j. The switcher popup renders them as a list with app icon (first letter of class), title, and app class. Arrow keys navigate, Enter selects, Escape dismisses. Alt+Tab cycles forward, Alt+Shift+Tab cycles backward.

Let's Improve It ​

Add live window thumbnails via hyprctl:

qml
Image {
  source: "file:///tmp/hypr/" + modelData.address + ".png"
  fillMode: Image.PreserveAspectFit
  asynchronous: true
}

(Requires enabling thumbnails in Hyprland config.)

⚠️Common Mistake

Showing windows from other workspaces. Some users want "all windows" in the switcher, others want "current workspace only". Make this configurable: config.switcherScope: "all" | "current".

Not updating the window list. If a window opens or closes while the switcher is open, the list should update. Call WindowService.refresh() on a short timer while the popup is visible.

Keyboard focus getting stuck. Ensure the popup grabs keyboard focus when opened and releases it when closed. Missing this prevents Alt+Tab from working after the switcher is dismissed.

Under the Hood ​

The wlr-foreign-toplevel-management protocol (supported by Sway, Hyprland, River, etc.) provides a compositor-agnostic way to enumerate windows and receive open/close/state-change events. Using this protocol eliminates the need for compositor-specific commands like hyprctl. Quickshell may offer a wrapper for this protocol in future versions.

Exercises ​

⭐Exercise
Add window search. When the switcher opens, the user can start typing and the list filters to windows whose title or class matches. Highlight the best match.
⭐⭐Exercise
Support mouse selection — clicking a window item immediately focuses it and closes the switcher. Add a "Close" button (X icon) on hover that calls `closeWindow`.
⭐⭐⭐Exercise
Implement a "recently used ordering" (Alt+Tab MRU). Track window focus events in `WindowService` and sort the switcher list by last-focused time. The last-focused window should be pre-selected when the switcher opens.
✅What You've Learned
  • Use compositor IPC (hyprctl) or wlr-foreign-toplevel-management for window enumeration
  • Provide keyboard navigation: arrows, Enter, Escape
  • Support filtering by current workspace vs all workspaces
  • Update the window list while the switcher is open

Built with VitePress. Licensed under MIT.