Skip to content

fix(niri): filter out duplicates in workspace list#2528

Open
gpayer wants to merge 1 commit intonoctalia-dev:mainfrom
gpayer:bugfix/niri-service-workspaces
Open

fix(niri): filter out duplicates in workspace list#2528
gpayer wants to merge 1 commit intonoctalia-dev:mainfrom
gpayer:bugfix/niri-service-workspaces

Conversation

@gpayer
Copy link
Copy Markdown

@gpayer gpayer commented Apr 22, 2026

Pull Request

This fixes the workspace multiplication bug when running noctalia in niri.

Motivation

The bug was highly annoying and looking at the code seemed easy to fix.

Type of Change

Mark the relevant option with an "x".

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring

Related Issue

Testing

Describe how you tested your changes and mark the relevant items.

  • Tested on niri
  • Tested on Hyprland
  • Tested on sway
  • Tested with different bar positions and density settings
  • Tested at different interface scaling values
  • Tested with multiple monitors (if applicable)

Screenshots / Videos

For screenshots showing the error, see #2463. This does not happen anymore after the fix.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my code
  • No new warnings or errors
  • Documentation or comments updated (if relevant)

@alfarelcynthesis
Copy link
Copy Markdown

alfarelcynthesis commented Apr 22, 2026

Testing this and it seems to have fixed the issue for me, thanks!

A note, not sure if this is how it originally behaved, but empty workspaces on monitors disappear when you unfocus the monitor.

@gpayer
Copy link
Copy Markdown
Author

gpayer commented Apr 22, 2026

A note, not sure if this is how it originally behaved, but empty workspaces on monitors disappear when you unfocus the monitor.

That was always the case. You only see the "next" empty workspace. But unnamed empty workspaces are closed when you unfocus them. Only named empty workspaces remain, I just tested this. So works as before I'd say.

@Mic92
Copy link
Copy Markdown

Mic92 commented Apr 22, 2026

FWIW the root cause sits one layer down in noctalia-qs, not in the QML.

NiriIpc::updateWorkspacesFromArray() re-sorts the workspace model after applying an update by calling ObjectModel::diffUpdate(sortedList). diffUpdate was written for "add/remove" diffs, not permutations — when an element is already in the list but at a different index it inserts a second copy at the target index without removing the old one:

// model.hpp
for (auto* object: newValues) {
    if (this->mValuesList.length() == oi || this->mValuesList.at(oi) != object) {
        this->insertObject(object, oi);   // old position never removed
    }
    oi++;
}

Anything that makes the post-update sort a non-trivial permutation trips it. For me that's docking the laptop (all workspaces migrate eDP-1 → DP-12, so the sort key flips), for #2463 it's moving a window between workspaces. Traced with QT_LOGGING_RULES='quickshell.niri.ipc*=true':

before:  [36@DP-12/1, 1@eDP-1/1, 2@eDP-1/2, 3@eDP-1/3, 35@eDP-1/4]
event:   WorkspacesChanged [1@DP-12/1, 2@DP-12/2, 3@DP-12/3, 36@DP-12/4]
sorted:  [1, 2, 3, 36]          # was [36, 1, 2, 3] in the model
after:   [1, 2, 3, 36, 1, 2, 3] # diffUpdate inserted 1/2/3 at the front

No New workspace: log lines, so these are the same NiriWorkspace* appearing multiple times in the model — the dedup loop never reclaims them because their id is always in the incoming list, so the model grows monotonically across events.

backport patch for noctalia-qs 0.0.10
--- a/src/wayland/niri/ipc/connection.cpp
+++ b/src/wayland/niri/ipc/connection.cpp
@@ -25,8 +25,34 @@
 namespace qs::niri::ipc {
 
 namespace {
+
 QS_LOGGING_CATEGORY(logNiriIpc, "quickshell.niri.ipc", QtWarningMsg);
 QS_LOGGING_CATEGORY(logNiriIpcEvents, "quickshell.niri.ipc.events", QtWarningMsg);
+
+// diffUpdate inserts without removing the prior row; reorder after sort avoids duplicate model rows.
+void reorderNiriWorkspaceModel(ObjectModel<NiriWorkspace>& model, const QList<NiriWorkspace*>& sorted) {
+	QList<NiriWorkspace*> order;
+	order.reserve(sorted.size());
+	for (auto* w: sorted) {
+		if (!order.contains(w)) order.append(w);
+	}
+
+	auto& list = model.valueList();
+	for (qsizetype i = 0; i < list.length();) {
+		auto* o = list.at(i);
+		if (!order.contains(o) || list.indexOf(o) != i) model.removeAt(i);
+		else i++;
+	}
+
+	for (qsizetype t = 0; t < order.length(); ++t) {
+		auto* w = order.at(t);
+		auto c = list.indexOf(w);
+		if (c < 0 || c == t) continue;
+		model.removeAt(c);
+		model.insertObject(w, t);
+	}
+}
+
 } // namespace
 
 void NiriIpcEvent::set(const QString& type, const QJsonObject& data) {
@@ -46,12 +72,6 @@ NiriIpc::NiriIpc() {
 		return;
 	}
 
-	this->workspaceRefreshTimer.setInterval(50);
-	this->workspaceRefreshTimer.setSingleShot(true);
-	QObject::connect(&this->workspaceRefreshTimer, &QTimer::timeout, this, [this]() {
-		this->refreshWorkspaces();
-	});
-
 	// clang-format off
 	QObject::connect(&this->eventSocket, &QLocalSocket::errorOccurred, this, &NiriIpc::eventSocketError);
 	QObject::connect(&this->eventSocket, &QLocalSocket::stateChanged, this, &NiriIpc::eventSocketStateChanged);
@@ -170,8 +190,8 @@ void NiriIpc::handleWorkspaceActivated(const QJsonObject& data) {
 
 	auto* activated = this->findWorkspaceById(workspaceId);
 	if (!activated) {
-		// Unknown workspace, do a full refresh
-		this->workspaceRefreshTimer.start();
+		// Unknown workspace — same idea as Hyprland falling back to j/workspaces
+		this->refreshWorkspaces();
 		return;
 	}
 
@@ -195,8 +215,13 @@ void NiriIpc::handleWorkspaceActivated(const QJsonObject& data) {
 	Qt::endPropertyUpdateGroup();
 
 	emit this->workspacesUpdated();
-	// Also refresh to catch any other state changes
-	this->workspaceRefreshTimer.start();
+
+	// Event-stream ordering can leave is_focused stale on toplevels (e.g. empty workspace:
+	// no WindowFocusChanged before the shell reads state). Request::Windows is authoritative;
+	// see https://docs.rs/niri-ipc/latest/niri_ipc/
+	if (isFocused) {
+		this->refreshWindows();
+	}
 }
 
 void NiriIpc::handleWorkspaceActiveWindowChanged(const QJsonObject& data) {
@@ -252,7 +277,6 @@ void NiriIpc::handleWindowOpenedOrChanged(const QJsonObject& data) {
 	}
 
 	emit this->windowsUpdated();
-	this->workspaceRefreshTimer.start();
 }
 
 void NiriIpc::handleWindowClosed(const QJsonObject& data) {
@@ -275,7 +299,6 @@ void NiriIpc::handleWindowClosed(const QJsonObject& data) {
 
 	qCDebug(logNiriIpc) << "Window closed with id" << windowId;
 	emit this->windowsUpdated();
-	this->workspaceRefreshTimer.start();
 }
 
 void NiriIpc::handleWindowsChanged(const QJsonObject& data) {
@@ -598,7 +621,7 @@ void NiriIpc::updateWorkspacesFromArray(const QJsonArray& workspacesArray) {
 		}
 		return a->bindableIdx().value() < b->bindableIdx().value();
 	});
-	this->mWorkspaces.diffUpdate(sortedList);
+	reorderNiriWorkspaceModel(this->mWorkspaces, sortedList);
 
 	emit this->workspacesUpdated();
 }
--- a/src/wayland/niri/ipc/connection.hpp
+++ b/src/wayland/niri/ipc/connection.hpp
@@ -8,7 +8,6 @@
 #include <qobject.h>
 #include <qproperty.h>
 #include <qqmlintegration.h>
-#include <qtimer.h>
 #include <qtmetamacros.h>
 #include <qtypes.h>
 
@@ -145,8 +144,6 @@ private:
 	bool requestingWorkspaces = false;
 	bool requestingWindows = false;
 
-	QTimer workspaceRefreshTimer;
-
 	ObjectModel<NiriOutput> mOutputs {this};
 	ObjectModel<NiriWorkspace> mWorkspaces {this};
 	ObjectModel<NiriWindow> mWindows {this};

@gpayer
Copy link
Copy Markdown
Author

gpayer commented Apr 22, 2026

Interesting. I am using the official noctalia flake to get the package:

noctalia = {
      url = "github:noctalia-dev/noctalia-shell/v4.7.6";
      inputs.nixpkgs.follows = "nixpkgs-unstable";
    };

I'll dig into this tomorrow, maybe removing the follows line is relevant.

@Mic92
Copy link
Copy Markdown

Mic92 commented Apr 26, 2026

Here is my attempt: noctalia-dev/noctalia-qs#35

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace widget failure in niri

3 participants