From c50c18e4cdcdeb8e41dd7a0c7a59c065617853b3 Mon Sep 17 00:00:00 2001 From: Lukas Essmann Date: Tue, 11 Aug 2026 08:10:50 -0700 Subject: [PATCH 1/2] fix(hyprland): order workspaces by id. fix #3871 Named workspaces get negative ids from Hyprland, and the bar's comparator treated a negative id as "named": it pushed those workspaces behind every numbered one and sorted them alphabetically. Hyprland orders workspaces by ascending id, so with `workspace = name:0` / `name:Grave` rules the bar read 8, 0, Grave while `workspace m+1` walked Grave, 0, 8, and `m~1` landed on the last pill in the bar instead of the first. Sort by id and drop the special case. Labels in id mode now fall back to the workspace name instead of its position in the bar, since a named workspace has no numeric id to show; a negative id is an allocation artifact that changes between restarts, so it is not worth rendering. --- meson.build | 1 + .../hyprland/hyprland_workspace_backend.cpp | 12 +- src/shell/bar/widgets/workspaces_widget.cpp | 3 + tests/hyprland_workspace_backend_test.cpp | 157 ++++++++++++++++++ 4 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 tests/hyprland_workspace_backend_test.cpp diff --git a/meson.build b/meson.build index 3ab5dce65a..4285d22c94 100644 --- a/meson.build +++ b/meson.build @@ -1138,6 +1138,7 @@ if build_tests 'fcitx_status', 'google_client_calendar_list', 'hook_manager', + 'hyprland_workspace_backend', 'i18n_language_tag', 'i18n_supported_languages', 'ical_parser', diff --git a/src/compositors/hyprland/hyprland_workspace_backend.cpp b/src/compositors/hyprland/hyprland_workspace_backend.cpp index ffe4d57946..cbc9e96669 100644 --- a/src/compositors/hyprland/hyprland_workspace_backend.cpp +++ b/src/compositors/hyprland/hyprland_workspace_backend.cpp @@ -892,16 +892,10 @@ bool HyprlandWorkspaceBackend::isSpecial(const WorkspaceState& state) { return state.id < 0 && (state.name == "special" || state.name.starts_with("special:")); } +// Hyprland traverses workspaces by ascending id (`workspace m+1`, `workspace m~1`), and named +// workspaces get negative ids, so they belong before the numbered ones. bool HyprlandWorkspaceBackend::workspaceOrderLess(const WorkspaceState* a, const WorkspaceState* b) { - const bool aNamed = a->id < 0; - const bool bNamed = b->id < 0; - if (aNamed != bNamed) { - return !aNamed; // numbered workspaces before named ones - } - if (!aNamed) { - return a->id < b->id; // numbered: ascending id - } - return a->name < b->name; // named: alphabetical + return a->id < b->id; } Workspace HyprlandWorkspaceBackend::toWorkspace(const WorkspaceState& state) { diff --git a/src/shell/bar/widgets/workspaces_widget.cpp b/src/shell/bar/widgets/workspaces_widget.cpp index ff05403307..e8fe954087 100644 --- a/src/shell/bar/widgets/workspaces_widget.cpp +++ b/src/shell/bar/widgets/workspaces_widget.cpp @@ -1385,6 +1385,9 @@ std::string WorkspacesWidget::workspaceLabel(const Workspace& workspace, std::si label = std::to_string(workspace.index); } else if (const auto numericId = numericWorkspaceId(workspace); numericId.has_value()) { label = std::to_string(*numericId); + } else if (!workspace.name.empty()) { + // Named workspaces have no numeric id, so the name beats labeling them by bar position. + label = workspace.name; } else { label = std::to_string(displayIndex + 1); } diff --git a/tests/hyprland_workspace_backend_test.cpp b/tests/hyprland_workspace_backend_test.cpp new file mode 100644 index 0000000000..e406287397 --- /dev/null +++ b/tests/hyprland_workspace_backend_test.cpp @@ -0,0 +1,157 @@ +#include "compositors/hyprland/hyprland_runtime.h" +#include "compositors/hyprland/hyprland_workspace_backend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + // Named workspaces (`workspace = name:0, ...`) get negative ids, in an order unrelated to + // their names. The special workspace must stay out of the listing entirely. + constexpr std::string_view kWorkspacesJson = R"([ + {"id": -1338, "name": "Grave", "monitor": "WAYLAND-1"}, + {"id": -1337, "name": "0", "monitor": "WAYLAND-1"}, + {"id": 8, "name": "8", "monitor": "WAYLAND-1"}, + {"id": -99, "name": "special:special", "monitor": "WAYLAND-1"} + ])"; + + constexpr std::string_view kMonitorsJson = R"([ + {"name": "WAYLAND-1", "activeWorkspace": {"id": 8, "name": "8"}} + ])"; + + std::string replyFor(std::string_view command) { + if (command.find("j/workspaces") != std::string_view::npos) { + return std::string(kWorkspacesJson); + } + if (command.find("j/monitors") != std::string_view::npos) { + return std::string(kMonitorsJson); + } + if (command.find("j/status") != std::string_view::npos) { + return R"({"configProvider": "lua"})"; + } + return "[]"; + } + + // Stands in for hyprctl's request socket: read the command until the peer half-closes, + // answer with JSON, close. + void serve(int listener, const std::atomic_bool& stop) { + while (!stop.load()) { + const int client = ::accept(listener, nullptr, nullptr); + if (client < 0) { + if (stop.load()) { + return; + } + continue; + } + std::string request; + char buffer[1024]; + while (true) { + const ssize_t bytes = ::recv(client, buffer, sizeof(buffer), 0); + if (bytes <= 0) { + break; + } + request.append(buffer, buffer + bytes); + } + const std::string reply = replyFor(request); + (void)::send(client, reply.data(), reply.size(), MSG_NOSIGNAL); + ::close(client); + } + } + + std::string describe(const std::vector& workspaces) { + std::string out; + for (const auto& workspace : workspaces) { + if (!out.empty()) { + out += ", "; + } + out += workspace.name + "(" + workspace.id + ")"; + } + return out; + } + + bool orderedById(const char* what, const std::vector& workspaces) { + static const std::vector expected{"-1338", "-1337", "8"}; + + std::vector actual; + actual.reserve(workspaces.size()); + for (const auto& workspace : workspaces) { + actual.push_back(workspace.id); + } + if (actual == expected) { + return true; + } + std::cerr << "FAIL: " << what << " must be ordered by ascending hyprland id, got " << describe(workspaces) << '\n'; + return false; + } + +} // namespace + +int main() { + const std::string runtimeDir = "/tmp/noctalia-hypr-workspace-test-" + std::to_string(::getpid()); + const std::string signature = "test"; + const std::string socketDir = runtimeDir + "/hypr/" + signature; + + std::error_code ec; + std::filesystem::create_directories(socketDir, ec); + if (ec) { + std::cerr << "FAIL: cannot create " << socketDir << ": " << ec.message() << '\n'; + return 1; + } + const std::string socketPath = socketDir + "/.socket.sock"; + + const int listener = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (listener < 0) { + std::cerr << "FAIL: socket() failed\n"; + return 1; + } + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (socketPath.size() >= sizeof(address.sun_path)) { + std::cerr << "FAIL: socket path too long\n"; + return 1; + } + std::memcpy(address.sun_path, socketPath.c_str(), socketPath.size() + 1); + if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0) { + std::cerr << "FAIL: bind() failed\n"; + return 1; + } + if (::listen(listener, 8) != 0) { + std::cerr << "FAIL: listen() failed\n"; + return 1; + } + + std::atomic_bool stop{false}; + std::thread server([&]() { serve(listener, stop); }); + + // The runtime resolves its socket paths from the environment on construction. + ::setenv("XDG_RUNTIME_DIR", runtimeDir.c_str(), 1); + ::setenv("HYPRLAND_INSTANCE_SIGNATURE", signature.c_str(), 1); + + bool ok = true; + { + compositors::hyprland::HyprlandRuntime runtime; + HyprlandWorkspaceBackend backend([](wl_output*) { return std::string("WAYLAND-1"); }, runtime); + backend.syncFromCompositor(); + + ok = orderedById("all()", backend.all()) && ok; + ok = orderedById("forOutput()", backend.forOutput(reinterpret_cast(0x1))) && ok; + } + + stop.store(true); + ::shutdown(listener, SHUT_RDWR); + ::close(listener); + server.join(); + std::filesystem::remove_all(runtimeDir, ec); + + return ok ? 0 : 1; +} From d16a46e03dfe04464f83bfea47707810c709730d Mon Sep 17 00:00:00 2001 From: Lemmy Date: Sat, 15 Aug 2026 00:32:58 -0400 Subject: [PATCH 2/2] fix(workspaces): bound named ID fallback labels --- assets/translations/en.json | 2 +- src/shell/bar/widgets/workspaces_widget.cpp | 11 ++++++----- .../bar/widgets/workspaces_widget_definition.cpp | 12 +----------- tests/hyprland_workspace_backend_test.cpp | 6 +++--- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 55a0d83a35..34e0508e73 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3592,7 +3592,7 @@ "max-label-chars": { "description": "Maximum number of characters to show for workspace names", "label": "Max Label Characters", - "workspaces-description": "Maximum characters for workspace name labels" + "workspaces-description": "Maximum characters for non-numeric workspace labels" }, "max-length": { "description": "Maximum widget length in pixels", diff --git a/src/shell/bar/widgets/workspaces_widget.cpp b/src/shell/bar/widgets/workspaces_widget.cpp index e8fe954087..40d46f7e2f 100644 --- a/src/shell/bar/widgets/workspaces_widget.cpp +++ b/src/shell/bar/widgets/workspaces_widget.cpp @@ -1393,11 +1393,12 @@ std::string WorkspacesWidget::workspaceLabel(const Workspace& workspace, std::si } } else { label = !workspace.name.empty() ? workspace.name : workspace.id; - // Only truncate non-numeric labels (words like "VESKTOP" → "VE"). - // Numeric labels (workspace IDs like "10", "11") stay as-is. - if (!isNumericLabel(label) && m_maxLabelChars > 0) { - label = StringUtils::truncateUtf8CodePoints(label, m_maxLabelChars); - } + } + + // Only truncate non-numeric labels (words like "VESKTOP" → "VE"). + // Numeric labels (workspace IDs like "10", "11") stay as-is. + if (!isNumericLabel(label) && m_maxLabelChars > 0) { + label = StringUtils::truncateUtf8CodePoints(label, m_maxLabelChars); } return label; diff --git a/src/shell/bar/widgets/workspaces_widget_definition.cpp b/src/shell/bar/widgets/workspaces_widget_definition.cpp index f2d5cd8ef4..741f71b536 100644 --- a/src/shell/bar/widgets/workspaces_widget_definition.cpp +++ b/src/shell/bar/widgets/workspaces_widget_definition.cpp @@ -18,16 +18,6 @@ namespace { return visibility; } - // Only name labels are truncated; ID labels are left alone. - settings::WidgetSettingVisibility nameLabelsShown() { - settings::WidgetSettingVisibility visibility; - visibility.all = { - {"show_labels", {"true"}}, - {"label_source", {"name"}}, - }; - return visibility; - } - settings::WidgetSettingVisibility regularStyleOnly() { settings::WidgetSettingVisibility visibility; visibility.all = {{"style", {"regular"}}}; @@ -100,7 +90,7 @@ const noctalia::bar::WidgetDefinition& workspacesWidg settings::WidgetSettingPresentation{ .descriptionKey = "settings.widgets.settings.max-label-chars.workspaces-description", .group = "workspaces.list", - .visibleWhen = nameLabelsShown(), + .visibleWhen = labelsShown(), }, }), field<&Options::style>({ diff --git a/tests/hyprland_workspace_backend_test.cpp b/tests/hyprland_workspace_backend_test.cpp index e406287397..f83b5edad9 100644 --- a/tests/hyprland_workspace_backend_test.cpp +++ b/tests/hyprland_workspace_backend_test.cpp @@ -29,13 +29,13 @@ namespace { ])"; std::string replyFor(std::string_view command) { - if (command.find("j/workspaces") != std::string_view::npos) { + if (command.contains("j/workspaces")) { return std::string(kWorkspacesJson); } - if (command.find("j/monitors") != std::string_view::npos) { + if (command.contains("j/monitors")) { return std::string(kMonitorsJson); } - if (command.find("j/status") != std::string_view::npos) { + if (command.contains("j/status")) { return R"({"configProvider": "lua"})"; } return "[]";