A QML plugin for interacting with the niri Wayland compositor via its IPC protocol.
- Real-time window and workspace monitoring and switching
- Tracking of focus, urgency, layout changes, etc.
- Application icon lookup via XDG desktop entries
- Event-driven updates for all compositor changes
- Native QML integration with Qt 6
- Qt 6 (Core, GUI, and QML modules)
- CMake 3.16 or newer
- C++17 compatible compiler
- A recent version of niri (tested with v26.04)
The author is an experienced programmer, but not with C++ or Qt. Most of this project was written with the assistance of Large Language Models. That said, nothing was "vibe-coded", and all code was carefully reviewed and tested.
If you do run into any issues, or have improvement suggestions, creating a GitHub issue would be appreciated.
Add this flake to your inputs in flake.nix:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
quickshell = {
url = "git+https://git.outfoxxed.me/outfoxxed/quickshell";
inputs.nixpkgs.follows = "nixpkgs";
};
qml-niri = {
url = "github:imiric/qml-niri/main";
inputs.nixpkgs.follows = "nixpkgs";
inputs.quickshell.follows = "quickshell";
};
};
# ...
}The plugin itself is available under qml-niri.packages.<system>.default. For those wishing to use it with Quickshell, the flake also provides a build of Quickshell with the plugin under qml-niri.packages.<system>.quickshell.
Install just and run:
git clone https://github.com/imiric/qml-niri.git
cd qml-niri
just buildThe just build command will create a build directory and compile the plugin. The built plugin will be located in build/Niri/.
After building, install the plugin with:
sudo just installThis installs into your Qt QML import path under the /usr prefix (e.g. /usr/lib64/qt6/qml/Niri), which should work for most distributions.
If your distribution uses a different prefix, pass it explicitly:
sudo just install /usr/localYou can check your QML import path with:
# Note: use the Qt6 qtpaths, which may not be on your PATH by default
/usr/lib/qt6/bin/qtpaths --qt-query QT_INSTALL_QMLAlternatively, during development or to avoid a system-wide install, set the QML_IMPORT_PATH environment variable to include the build directory when running your QML application:
QML_IMPORT_PATH="$PWD/build" <your-qml-command>For packaging, configure the prefix and stage into DESTDIR:
cmake -B build -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build
DESTDIR="$pkgdir" cmake --install buildThe QML install directory can be overridden with -DQML_INSTALL_DIR=....
This section walks through common tasks with runnable examples. For an exhaustive list of every property, method, signal, and model role, see the API Reference.
Import the plugin and create a Niri instance:
import QtQuick
import Niri
Item {
Niri {
id: niri
Component.onCompleted: connect()
onConnected: console.log("Connected to niri")
onErrorOccurred: function(error) {
console.error("Connection error:", error)
}
}
}Note
This requires the NIRI_SOCKET environment variable to be set with the path to a
valid Unix socket.
See the niri IPC documentation for details.
Access window information via the windows model. Each delegate exposes the window's
properties as model roles (see WindowModel roles):
ListView {
model: niri.windows
delegate: Rectangle {
color: model.isFocused ? "lightblue" : "white"
Text {
text: model.title + " (" + model.appId + ")"
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouseEvent) {
if (mouseEvent.button === Qt.LeftButton) {
niri.focusWindow(model.id)
} else {
niri.closeWindow(model.id)
}
}
}
}
}Focusing and closing:
niri.focusWindow(windowId)
niri.closeWindow(windowId)
niri.closeWindowOrFocused() // Close focused windowAccess workspace information via the workspaces model. Each delegate exposes the workspace's properties as model roles (see WorkspaceModel roles):
ListView {
model: niri.workspaces
delegate: Rectangle {
Text {
text: "Workspace " + model.index +
(model.isFocused ? " (focused)" : "")
}
MouseArea {
anchors.fill: parent
onClicked: niri.focusWorkspaceById(model.id)
}
}
}Focusing:
niri.focusWorkspace(0) // By index
niri.focusWorkspaceById(12345) // By ID
niri.focusWorkspaceByName("code") // By nameYou can limit the number of workspaces exposed by the model using the maxCount property. Only the first maxCount workspaces (after sorting) will be available:
Component.onCompleted: {
niri.workspaces.maxCount = 11
}This is useful for widget layouts that should only show a fixed number of workspaces.
The WorkspaceModel provides two helper methods for looking up workspaces by their
visible row or ID:
// Get a map of all role values for the workspace at visible row 0
const ws = niri.workspaces.get(0)
console.log(ws.name, ws.isFocused)
// Find the visible row index for a workspace ID (-1 if hidden or not found)
const row = niri.workspaces.indexOfId(12345)Both account for maxCount: rows hidden by the limit are treated as out of range.
Access the currently focused window and all of its properties:
Text {
text: niri.focusedWindow?.title ?? "No focused window"
}
Text {
text: "App: " + (niri.focusedWindow?.appId ?? "none")
}
Text {
text: "PID: " + (niri.focusedWindow?.pid ?? -1)
}Count of total windows and workspaces:
Text {
text: "Total windows: " + niri.windows.count
}
Text {
text: "Total workspaces: " + niri.workspaces.count
}All action methods return a result object describing the outcome:
const result = niri.focusWorkspace(1)
if (!result.ok) {
console.error("Failed to focus workspace:", result.error)
}The result object has the shape:
ok: bool-trueon success,falseon failureerror: string- Error message (only present whenokisfalse)
Failures include "not connected", IPC write/read errors, and action rejections from niri itself. Callers that don't care about the result can simply ignore the return value.
Note that per-action failures are not reported via the errorOccurred signal. That signal is reserved for connection-level problems such as socket disconnects or event stream subscription failures.
For actions not covered by the typed wrappers, sendRawAction lets you send an arbitrary niri Action as a JSON-shaped object:
const result = niri.sendRawAction({
"FocusWorkspace": { "reference": { "Index": 2 } }
})
if (!result.ok) {
console.error(result.error)
}Prefer the typed wrappers when available; sendRawAction performs no schema validation and will only report failures returned by niri itself.
Application icons are automatically looked up using XDG desktop entries, and can be rendered like so:
ListView {
model: niri.windows
delegate: Rectangle {
RowLayout {
spacing: 5
Image {
source: model.iconPath ? "file://" + model.iconPath : ""
sourceSize.width: 24
sourceSize.height: 24
visible: model.iconPath !== ""
smooth: true
}
// Fallback for missing icons
Rectangle {
width: 24
height: 24
color: "#CCC"
visible: model.iconPath === ""
radius: 4
}
Text {
text: model.title
}
}
}
}If an icon is not found (e.g. for AppImage, Flatpak, Snap apps), you can manually place an SVG or PNG file in a general XDG path, such as ~/.local/share/icons/hicolor/scalable/apps. Ensure that it's named after the application ID that niri reports (check with niri msg pick-window). Although a lowercase string, or having the name anywhere in the file name should work as well.
For example, for app ID "LibreWolf", the file ~/.local/share/icons/hicolor/scalable/apps/librewolf.svg would be resolved.
The implementation attempts to handle several path and naming variations, but it might not work in all scenarios, so a manual override is preferred over handling all scenarios correctly.
The plugin will output informational and error messages by default. To enable verbose logging for troubleshooting, set the QT_LOGGING_RULES environment variable. E.g.:
export QT_LOGGING_RULES="niri.debug=true"This will also show debug and warning messages for icon lookup, IPC communication, and event handling.
This project started because I wanted to integrate niri with Quickshell. So here is an example of a simple bar that showcases a niri workspaces switcher and the currently focused window title:
Show
import Quickshell
import QtQuick
import Niri
ShellRoot {
PanelWindow {
anchors {
top: true
left: true
right: true
}
implicitHeight: 30
color: "#1C1F22"
Niri {
id: niri
Component.onCompleted: connect()
onConnected: console.log("Connected to niri")
onErrorOccurred: function(error) {
console.error("Niri error:", error)
}
}
// Limit to first 10 workspaces
Component.onCompleted: niri.workspaces.maxCount = 10
Row {
spacing: 10
anchors {
left: parent.left
leftMargin: 5
verticalCenter: parent.verticalCenter
}
Row {
spacing: 2
Repeater {
model: niri.workspaces
Rectangle {
width: 30
height: 20
color: model.isFocused ? "#106DAA" :
model.isActive ? "#377B86" : "#222225"
border.color: model.isUrgent ? "red" : "#16181A"
border.width: 2
radius: 3
Text {
anchors.centerIn: parent
text: model.name || model.index
font.family: "Barlow Medium"
color: model.isFocused || model.isActive ? "white" : "#89919A"
font.pixelSize: 14
}
MouseArea {
anchors.fill: parent
onClicked: niri.focusWorkspaceById(model.id)
cursorShape: Qt.PointingHandCursor
}
}
}
}
Text {
text: niri.focusedWindow?.title ?? ""
font.family: "Barlow Medium"
font.pixelSize: 16
color: "#89919A"
}
}
}
}Save this as a .qml file somewhere on your filesystem, and run quickshell --path /path/to/file.qml to see it in action.
Assuming you have the Barlow font installed, it should look something like this:
For more elaborate examples, see my quickshell-niri project.
The plugin is mostly tested manually, using a few integration tests. You can run them with:
# Test event stream
just test events
# Test workspace model
just test workspaces
# Test window model
just test windows
# Test arbitrary actions (sendRawAction)
just test actionThese test files are also useful examples of common patterns (e.g. sorting and filtering with SortFilterProxyModel).
Pull requests to improve the testing situation, add unit tests, CI, etc., are very welcome!
The plugin exposes four QML types: Niri (the main entry point), WorkspaceModel and WindowModel (list models for the workspaces and windows properties), and Window (an individual window object). Only Niri is directly instantiable; the others are obtained via Niri's properties.
The main object. Connect to niri and issue actions through it.
Properties:
workspaces: WorkspaceModel - List of all workspaceswindows: WindowModel - List of all windowsfocusedWindow: Window - Currently focused window (nullif none)
Methods:
connect(): bool - Connect to the niri IPC socket. Returnstrueon success.isConnected(): bool - Check connection statusfocusWorkspace(index): object - Focus workspace by indexfocusWorkspaceById(id): object - Focus workspace by IDfocusWorkspaceByName(name): object - Focus workspace by namefocusWindow(id): object - Focus specific windowcloseWindow(id): object - Close specific windowcloseWindowOrFocused(id = 0): object - Close the given window, or the focused window ifidis0(the default)toggleOverview(): object - Show or hide the workspace overviewsendRawAction(action): object - Send an arbitrary niri Action
All action methods (everything except connect() and isConnected()) return a result object of the form { ok: bool, error?: string }. See Action results and error handling.
Signals:
connected()- Emitted on successful connectiondisconnected()- Emitted on disconnectionerrorOccurred(error)- Emitted on connection-level failures only (socket disconnect, event stream subscription failure)rawEventReceived(event)- Emitted for all IPC events, with the raw event objectfocusedWindowChanged()- Emitted when the focused window changes (including on model resets, focus loss when the focused window closes, and enforcement of the single-focus invariant)
A QAbstractListModel holding all workspaces, sorted by output name and then by index within each output. Accessed via niri.workspaces. Use it directly as a model for ListView/Repeater, where each delegate sees the roles below.
Properties:
count: int - Number of visible workspaces (capped bymaxCount)maxCount: int - Maximum number of workspaces to expose (default: unlimited; shows all workspaces). Only the firstmaxCountworkspaces, after sorting, are visible.
Methods:
get(row): object - Returns a map of role-name → value for the workspace at the given visible row, or an empty map ifrowis out of range (row < 0orrow >= count)indexOfId(id): int - Returns the visible row index for the workspace with the given ID, or-1if no such workspace exists or it is hidden bymaxCount
Signals:
countChanged()- Emitted when the number of visible workspaces changesmaxCountChanged()- Emitted whenmaxCountchanges
Available to delegates when using WorkspaceModel as a view model:
id: Unique workspace identifierindex: Workspace position on its outputname: Optional workspace nameoutput: Output device nameisActive: Currently active on its outputisFocused: Currently focused workspaceisUrgent: Has windows requesting attentionactiveWindowId: ID of the active window (0if none)
A QAbstractListModel holding all windows, sorted by window ID. Accessed via niri.windows. Use it directly as a model for ListView/Repeater, where each delegate sees the roles below.
Properties:
count: int - Number of windowsfocusedWindow: Window - Currently focused window (nullif none)
Signals:
countChanged()- Emitted when the number of windows changesfocusedWindowChanged()- Emitted when the focused window changes
Available to delegates when using WindowModel as a view model. These mirror the Window properties:
id: Unique window identifiertitle: Window titleappId: Application identifierpid: Process ID (-1if unavailable)workspaceId: Current workspace IDisFocused: Currently focused windowisFloating: Floating window stateisUrgent: Window urgency flagcolumnIndex: Tiled window column index in niri's scrolling layout (1-based,0if unavailable)tileIndex: Tiled window index within its column (1-based,0if unavailable)tileWidth: Tile width in logical pixels (includes niri decorations like borders)tileHeight: Tile height in logical pixels (includes niri decorations like borders)windowWidth: Window visual geometry width in logical pixels (without niri decorations)windowHeight: Window visual geometry height in logical pixels (without niri decorations)tilePosX: Tile X position in current workspace view (NaNif unavailable)tilePosY: Tile Y position in current workspace view (NaNif unavailable)windowOffsetX: Window visual geometry X offset inside its tilewindowOffsetY: Window visual geometry Y offset inside its tileiconPath: Absolute path to application icon (empty if not found)
An individual window object, owned by WindowModel. Not instantiable from QML; obtained via niri.focusedWindow or niri.windows.focusedWindow. Its properties match the WindowModel roles.
Properties:
id: quint64 - Unique window identifier (constant)title: string - Window titleappId: string - Application identifier (constant)pid: int - Process ID,-1if unavailable (constant)workspaceId: quint64 - Current workspace IDisFocused: bool - Currently focused windowisFloating: bool - Floating window stateisUrgent: bool - Window urgency flagcolumnIndex: int - Tiled window column index (1-based,0if unavailable)tileIndex: int - Tiled window index within its column (1-based,0if unavailable)tileWidth: real - Tile width in logical pixels (includes niri decorations)tileHeight: real - Tile height in logical pixels (includes niri decorations)windowWidth: int - Window visual geometry width in logical pixels (without decorations)windowHeight: int - Window visual geometry height in logical pixels (without decorations)tilePosX: real - Tile X position in current workspace view (NaNif unavailable)tilePosY: real - Tile Y position in current workspace view (NaNif unavailable)windowOffsetX: real - Window visual geometry X offset inside its tilewindowOffsetY: real - Window visual geometry Y offset inside its tileiconPath: string - Absolute path to application icon, empty if not found (constant)
Signals:
titleChanged()- Emitted when the title changesworkspaceIdChanged()- Emitted when the window moves to a different workspaceisFocusedChanged()- Emitted when focus state changesisFloatingChanged()- Emitted when floating state changesisUrgentChanged()- Emitted when urgency changeslayoutChanged()- Emitted when any layout-related property (column/tile indices, sizes, positions, offsets) changes
Properties marked (constant) never change for the lifetime of the window object and have no corresponding signal.
-
module "Niri" is not installed: This means the QML engine can't find the plugin. Check the following:-
If you installed system-wide, confirm the files landed in your Qt QML import path (e.g.
/usr/lib64/qt6/qml/Niri/). Ifjust installused the wrong prefix, reinstall with the correct one (see Installation). -
If you're running from the build directory instead, ensure
QML_IMPORT_PATHpoints to the directory containing theNiridirectory (i.e.build), not theNiridirectory itself. -
Confirm you're using Qt 6, not an older version, with
qml --version. If the Qt 6 binary isn't on your$PATH(e.g. on Void Linux it's at/usr/lib/qt6/bin/qml), symlink it asqml6somewhere on your$PATH.
-
-
Connection failed: Ensure niri is actually running. 😄 Otherwise, verify that the
NIRI_SOCKETenvironment variable is set and points to a valid socket. It should be something like/run/user/<name>/niri.wayland-1.1856.sock. Note that this is affected by the value ofXDG_RUNTIME_DIR.
