A QML plugin for interacting with the niri Wayland compositor via its IPC protocol.
Why?
I really like the niri compositor/WM, but there are no good integrations for it for building UI widgets, status bars, etc. There are several options mentioned in the awesome-niri list, but none of them are great IMO.
Quickshell and Qt Quick stand out above the rest, but it currently only supports Hyprland. There is interest in adding support for niri, but the feature is blocked by the author's desire for compositors to implement a set of generic Wayland protocols, which is in progress for niri. This is understandable, as it would avoid projects like Quickshell having to add support for custom IPC protocols of each compositor, but in the meantime, niri users are left without a good solution. If, and when, Quickshell officially supports niri via these generic protocols, there will likely be little need for qml-niri to exist.
The DankMaterialShell project uses Quickshell and integrates with niri, but it is too complex and fancy for my personal needs. Extracting their NiriService could've been an option, but I'd rather keep my QML configuration simple, with the IPC implementation at a lower level.
- 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 v25.08)
The author is an experienced programmer, but not with C++ or Qt. Most of this project was written with the assistance of LLM tools such as Claude Sonnet 4.5. 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.
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, copy the plugin to your QML import path:
# Find your QML import path
qtpaths6 --qt-query QT_INSTALL_QML
# Copy the plugin (adjust path as needed)
sudo cp -r build/Niri /usr/lib64/qt6/qml/Alternatively, you can set the QML_IMPORT_PATH environment variable to include the build directory when running your QML applications.
Import the plugin and create a Niri instance:
import QtQuick
import Niri 0.1
Item {
Niri {
id: niri
Component.onCompleted: connect()
onConnected: console.log("Connected to niri")
onErrorOccurred: function(error) {
console.error("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 workspace information via the workspaces model:
ListView {
model: niri.workspaces
delegate: Rectangle {
Text {
text: "Workspace " + model.index +
(model.isFocused ? " (focused)" : "")
}
MouseArea {
anchors.fill: parent
onClicked: niri.focusWorkspaceById(model.id)
}
}
}Available workspace properties:
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
Access window information via the windows model:
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)
}
}
}
}
}Available window properties:
id: Unique window identifiertitle: Window titleappId: Application identifierpid: Process ID (-1 if unavailable)workspaceId: Current workspace IDisFocused: Currently focused windowisFloating: Floating window stateisUrgent: Window urgency flagiconPath: Absolute path to application icon (empty if not found)
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.
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
}Workspace control:
niri.focusWorkspace(0) // By index
niri.focusWorkspaceById(12345) // By ID
niri.focusWorkspaceByName("code") // By nameWindow control:
niri.focusWindow(windowId)
niri.closeWindow(windowId)
niri.closeWindowOrFocused() // Close focused windowThe plugin was 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 windowsPull requests to improve the testing situation, add unit tests, etc., are very welcome!
Properties:
workspaces: WorkspaceModel - List of all workspaceswindows: WindowModel - List of all windowsfocusedWindow: Window - Currently focused window (null if none)
Methods:
connect(): bool - Connect to niri IPC socketisConnected(): bool - Check connection statusfocusWorkspace(index)- Focus workspace by indexfocusWorkspaceById(id)- Focus workspace by IDfocusWorkspaceByName(name)- Focus workspace by namefocusWindow(id)- Focus specific windowcloseWindow(id)- Close specific windowcloseWindowOrFocused()- Close focused window
Signals:
connected()- Emitted on successful connectiondisconnected()- Emitted on disconnectionerrorOccurred(error)- Emitted on errorrawEventReceived(event)- Emitted for all IPC eventsfocusedWindowChanged()- Emitted when focused window changes or its properties update
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 0.1
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)
}
}
Row {
spacing: 10
anchors {
left: parent.left
leftMargin: 5
verticalCenter: parent.verticalCenter
}
Row {
spacing: 2
Repeater {
model: niri.workspaces
Rectangle {
visible: index < 11
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.
-
module "Niri" is not installed: EnsureQML_IMPORT_PATHincludes the directory containing theNiridirectory (not theNiridirectory itself), or that you copied to plugin to an existing QML import path (e.g./usr/lib64/qt6/qml/).Also, confirm that you're using Qt 6, and not older versions. You can do this with
qml --version. If the Qt 6 binary is not on your$PATH(e.g. on Void Linux it is at/usr/lib/qt6/bin/qml), you can 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.
