Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 3 additions & 9 deletions src/compositors/hyprland/hyprland_workspace_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 9 additions & 5 deletions src/shell/bar/widgets/workspaces_widget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1385,16 +1385,20 @@ 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);
}
} 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;
Expand Down
12 changes: 1 addition & 11 deletions src/shell/bar/widgets/workspaces_widget_definition.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}};
Expand Down Expand Up @@ -100,7 +90,7 @@ const noctalia::bar::WidgetDefinition<WorkspacesWidget::Options>& workspacesWidg
settings::WidgetSettingPresentation{
.descriptionKey = "settings.widgets.settings.max-label-chars.workspaces-description",
.group = "workspaces.list",
.visibleWhen = nameLabelsShown(),
.visibleWhen = labelsShown(),
},
}),
field<&Options::style>({
Expand Down
157 changes: 157 additions & 0 deletions tests/hyprland_workspace_backend_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#include "compositors/hyprland/hyprland_runtime.h"
#include "compositors/hyprland/hyprland_workspace_backend.h"

#include <atomic>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <sys/un.h>
#include <thread>
#include <unistd.h>
#include <vector>

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.contains("j/workspaces")) {
return std::string(kWorkspacesJson);
}
if (command.contains("j/monitors")) {
return std::string(kMonitorsJson);
}
if (command.contains("j/status")) {
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<Workspace>& 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<Workspace>& workspaces) {
static const std::vector<std::string> expected{"-1338", "-1337", "8"};

std::vector<std::string> 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<const sockaddr*>(&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<wl_output*>(0x1))) && ok;
}

stop.store(true);
::shutdown(listener, SHUT_RDWR);
::close(listener);
server.join();
std::filesystem::remove_all(runtimeDir, ec);

return ok ? 0 : 1;
}
Loading