diff --git a/include/modules/niri/workspace.hpp b/include/modules/niri/workspace.hpp new file mode 100644 index 000000000..c2d03ec12 --- /dev/null +++ b/include/modules/niri/workspace.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace waybar::modules::niri { + +class Workspaces; + +class Workspace { + public: + Workspace(const Json::Value& workspace_data, Workspaces& manager); + ~Workspace() = default; + + Workspace(const Workspace&) = delete; + Workspace& operator=(const Workspace&) = delete; + + Gtk::Button& button() { return button_; } + uint64_t id() const { return id_; } + + void update(const Json::Value& workspace_data, const std::vector& all_windows); + + private: + void rebuildTaskbar(const std::vector& my_windows); + + Glib::RefPtr loadIcon(const std::string& app_id, int size); + + Workspaces& manager_; + uint64_t id_; + + // Layout: button_ + // └─ box_ (horizontal) + // ├─ label_ workspace label / icon + // └─ taskbar_box_ app icon buttons (shown only when taskbar enabled) + Gtk::Button button_; + Gtk::Box box_; + Gtk::Label label_; + Gtk::Box taskbar_box_; +}; + +} // namespace waybar::modules::niri \ No newline at end of file diff --git a/include/modules/niri/workspaces.hpp b/include/modules/niri/workspaces.hpp index 089864122..51ab0021a 100644 --- a/include/modules/niri/workspaces.hpp +++ b/include/modules/niri/workspaces.hpp @@ -1,30 +1,40 @@ #pragma once -#include +#include #include +#include +#include + #include "AModule.hpp" #include "bar.hpp" #include "modules/niri/backend.hpp" +#include "modules/niri/workspace.hpp" namespace waybar::modules::niri { class Workspaces : public AModule, public EventHandler { public: - Workspaces(const std::string&, const Bar&, const Json::Value&); + Workspaces(const std::string& id, const Bar& bar, const Json::Value& config); ~Workspaces() override; + void update() override; + const Json::Value& config() const { return config_; } + const Bar& bar() const { return bar_; } + + std::string getIcon(const std::string& value, const Json::Value& ws) const; + private: void onEvent(const Json::Value& ev) override; void doUpdate(); - Gtk::Button& addButton(const Json::Value& ws); - std::string getIcon(const std::string& value, const Json::Value& ws); + + void createWorkspace(const Json::Value& workspace_data); const Bar& bar_; Gtk::Box box_; - // Map from niri workspace id to button. - std::unordered_map buttons_; + + std::vector> workspaces_; }; -} // namespace waybar::modules::niri +} // namespace waybar::modules::niri \ No newline at end of file diff --git a/man/waybar-niri-workspaces.5.scd b/man/waybar-niri-workspaces.5.scd index 4be85eb3f..827fa69bc 100644 --- a/man/waybar-niri-workspaces.5.scd +++ b/man/waybar-niri-workspaces.5.scd @@ -49,6 +49,20 @@ Addressed by *niri/workspaces* typeof: bool ++ default: false ++ Enables this module to consume all left over space dynamically. + +*workspace-taskbar*: ++ + typeof: object ++ + Contains settings for the workspace taskbar, which displays app icons within each workspace. + + *enable*: ++ + typeof: bool ++ + default: false ++ + Enables the workspace taskbar mode. + + *icon-size*: ++ + typeof: int ++ + default: 18 ++ + Size of the icons in the workspace taskbar. # FORMAT REPLACEMENTS @@ -92,6 +106,16 @@ Additional to workspace name matching, the following *format-icons* can be set. } ``` +``` +"niri/workspaces": { + "format": "{icon}", + "workspace-taskbar": { + "enable": true, + "icon-size": 18 + } +} +``` + # Style - *#workspaces button* @@ -103,3 +127,5 @@ Additional to workspace name matching, the following *format-icons* can be set. the bar that it is displayed on. - *#workspaces button#niri-workspace-*: Workspaces named this, or index for unnamed workspaces. +- *#workspaces button.niri-workspace*: The main container for the workspace. +- *#workspaces button .niri-taskbar-btn*: The icon buttons within the taskbar. diff --git a/meson.build b/meson.build index 0c494eb22..3c8a57342 100644 --- a/meson.build +++ b/meson.build @@ -74,6 +74,7 @@ wayland_client = dependency('wayland-client') wayland_cursor = dependency('wayland-cursor') wayland_protos = dependency('wayland-protocols') gtkmm = dependency('gtkmm-3.0', version : ['>=3.22.0']) +giomm = dependency('giomm-2.4', version : ['>=2.4.0']) dbusmenu_gtk = dependency('dbusmenu-gtk3-0.4', required: get_option('dbusmenu-gtk')) giounix = dependency('gio-unix-2.0') jsoncpp = dependency('jsoncpp', version : ['>=1.9.2'], fallback : ['jsoncpp', 'jsoncpp_dep']) @@ -340,6 +341,7 @@ if get_option('niri') 'src/modules/niri/language.cpp', 'src/modules/niri/window.cpp', 'src/modules/niri/workspaces.cpp', + 'src/modules/niri/workspace.cpp', ) man_files += files( 'man/waybar-niri-language.5.scd', @@ -542,6 +544,7 @@ executable( jsoncpp, wayland_cursor, gtkmm, + giomm, dbusmenu_gtk, giounix, libinput, diff --git a/src/modules/niri/backend.cpp b/src/modules/niri/backend.cpp index de3f8a9a6..e745c078f 100644 --- a/src/modules/niri/backend.cpp +++ b/src/modules/niri/backend.cpp @@ -196,6 +196,18 @@ void IPC::parseIPC(const std::string& line) { for (auto& win : windows_) { win["is_focused"] = focused && win["id"].asUInt64() == id; } + } else if (const auto& payload = ev["WindowLayoutsChanged"]) { + const auto& changes = payload["changes"]; + for (const auto& change : changes) { + const auto id = change[0].asUInt64(); + auto it = std::find_if(windows_.begin(), windows_.end(), + [id](const auto& win) { return win["id"].asUInt64() == id; }); + if (it != windows_.end()) { + (*it)["layout"] = change[1]; + } else { + spdlog::warn("WindowLayoutsChanged: unknown window id {}", id); + } + } } } diff --git a/src/modules/niri/workspace.cpp b/src/modules/niri/workspace.cpp new file mode 100644 index 000000000..0b874f8c4 --- /dev/null +++ b/src/modules/niri/workspace.cpp @@ -0,0 +1,259 @@ +#include "modules/niri/workspace.hpp" + +#include +#include +#include +#include +#include +#include + +#include "modules/niri/backend.hpp" +#include "modules/niri/workspaces.hpp" + +namespace waybar::modules::niri { + +Workspace::Workspace(const Json::Value& workspace_data, Workspaces& manager) + : manager_(manager), + id_(workspace_data["id"].asUInt64()), + box_(Gtk::ORIENTATION_HORIZONTAL, 0), + taskbar_box_(Gtk::ORIENTATION_HORIZONTAL, 0) { + button_.add(box_); + box_.pack_start(label_, false, false, 0); + box_.pack_start(taskbar_box_, false, false, 0); + + button_.set_relief(Gtk::RELIEF_NONE); + button_.get_style_context()->add_class("niri-workspace"); + + if (!manager_.config()["disable-click"].asBool()) { + const auto ws_id = id_; + button_.signal_pressed().connect([ws_id] { + try { + Json::Value request(Json::objectValue); + auto& action = (request["Action"] = Json::Value(Json::objectValue)); + auto& focusWorkspace = (action["FocusWorkspace"] = Json::Value(Json::objectValue)); + auto& reference = (focusWorkspace["reference"] = Json::Value(Json::objectValue)); + reference["Id"] = ws_id; + IPC::send(request); + } catch (const std::exception& e) { + spdlog::error("Niri: error focusing workspace: {}", e.what()); + } + }); + } + + button_.show_all(); +} + +void Workspace::update(const Json::Value& data, const std::vector& all_windows) { + // ── CSS classes ────────────────────────────────────────────────────────── + auto style = button_.get_style_context(); + + auto setClass = [&](const char* cls, bool on) { + if (on) + style->add_class(cls); + else + style->remove_class(cls); + }; + + setClass("focused", data["is_focused"].asBool()); + setClass("active", data["is_active"].asBool()); + setClass("urgent", data["is_urgent"].asBool()); + setClass("empty", data["active_window_id"].isNull()); + setClass("current_output", + data["output"] && data["output"].asString() == manager_.bar().output->name); + + // ── Workspace label ─────────────────────────────────────────────────────── + std::string name; + if (data["name"]) { + name = data["name"].asString(); + } else { + name = std::to_string(data["idx"].asUInt()); + } + + button_.set_name("niri-workspace-" + name); + + const auto& cfg = manager_.config(); + + if (cfg["format"].isString()) { + auto format = cfg["format"].asString(); + name = fmt::format(fmt::runtime(format), fmt::arg("icon", manager_.getIcon(name, data)), + fmt::arg("value", name), fmt::arg("name", data["name"].asString()), + fmt::arg("index", data["idx"].asUInt()), + fmt::arg("output", data["output"].asString())); + } + + if (!cfg["disable-markup"].asBool()) { + label_.set_markup(name); + } else { + label_.set_text(name); + } + + // ── Visibility ─────────────────────────────────────────────────────────── + const bool alloutputs = cfg["all-outputs"].asBool(); + if (cfg["current-only"].asBool()) { + const auto* prop = alloutputs ? "is_focused" : "is_active"; + data[prop].asBool() ? button_.show() : button_.hide(); + } else if (cfg["hide-empty"].asBool()) { + (data["active_window_id"].isNull() && !data["is_focused"].asBool()) ? button_.hide() + : button_.show(); + } else { + button_.show(); + } + + // ── Taskbar ─────────────────────────────────────────────────────────────── + const auto& taskbar_cfg = cfg["workspace-taskbar"]; + if (taskbar_cfg.isObject() && taskbar_cfg["enable"].asBool()) { + std::vector my_windows; + for (const auto& win : all_windows) { + if (win["workspace_id"].asUInt64() == id_) { + my_windows.push_back(win); + } + } + + std::sort(my_windows.begin(), my_windows.end(), [](const Json::Value& a, const Json::Value& b) { + const auto& la = a["layout"]; + const auto& lb = b["layout"]; + const bool ha = la.isObject() && la["pos_in_scrolling_layout"].isArray(); + const bool hb = lb.isObject() && lb["pos_in_scrolling_layout"].isArray(); + if (!ha && !hb) return false; + if (!ha) return false; + if (!hb) return true; + const int col_a = la["pos_in_scrolling_layout"][0].asInt(); + const int col_b = lb["pos_in_scrolling_layout"][0].asInt(); + if (col_a != col_b) return col_a < col_b; + return la["pos_in_scrolling_layout"][1].asInt() < lb["pos_in_scrolling_layout"][1].asInt(); + }); + + rebuildTaskbar(my_windows); + taskbar_box_.show(); + label_.hide(); + } else { + for (auto* child : taskbar_box_.get_children()) { + taskbar_box_.remove(*child); + } + taskbar_box_.hide(); + } +} + +// ── Taskbar rebuild ────────────────────────────────────────────────────────── + +void Workspace::rebuildTaskbar(const std::vector& my_windows) { + for (auto* child : taskbar_box_.get_children()) { + taskbar_box_.remove(*child); + } + + const auto& taskbar_cfg = manager_.config()["workspace-taskbar"]; + const int icon_size = taskbar_cfg["icon-size"].isInt() ? taskbar_cfg["icon-size"].asInt() : 16; + + for (const auto& win : my_windows) { + const auto win_id = win["id"].asUInt64(); + const std::string app_id = win["app_id"].isString() ? win["app_id"].asString() : ""; + const std::string title = win["title"].isString() ? win["title"].asString() : app_id; + const bool is_focused = win["is_focused"].asBool(); + + auto* btn = Gtk::make_managed(); + btn->set_relief(Gtk::RELIEF_NONE); + btn->get_style_context()->add_class("niri-taskbar-btn"); + if (is_focused) btn->get_style_context()->add_class("focused"); + btn->set_tooltip_text(title); + + auto pixbuf = loadIcon(app_id, icon_size); + if (pixbuf) { + auto* img = Gtk::make_managed(pixbuf); + btn->add(*img); + } else { + std::string fallback = app_id.empty() ? title : app_id; + if (!fallback.empty()) { + fallback = fallback.substr(0, 3); + } else { + fallback = "?"; + } + auto* lbl = Gtk::make_managed(fallback); + btn->add(*lbl); + } + + // Left click → focus window. + btn->signal_clicked().connect([win_id] { + try { + Json::Value request(Json::objectValue); + auto& action = (request["Action"] = Json::Value(Json::objectValue)); + auto& focusWindow = (action["FocusWindow"] = Json::Value(Json::objectValue)); + focusWindow["id"] = win_id; + IPC::send(request); + } catch (const std::exception& e) { + spdlog::error("Niri: error focusing window {}: {}", win_id, e.what()); + } + }); + + // Middle click → close window. + btn->signal_button_release_event().connect([win_id](GdkEventButton* event) -> bool { + if (event->button == GDK_BUTTON_MIDDLE) { + try { + Json::Value request(Json::objectValue); + auto& action = (request["Action"] = Json::Value(Json::objectValue)); + auto& closeWindow = (action["CloseWindow"] = Json::Value(Json::objectValue)); + closeWindow["id"] = win_id; + IPC::send(request); + } catch (const std::exception& e) { + spdlog::error("Niri: error closing window {}: {}", win_id, e.what()); + } + return true; + } + return false; + }); + + taskbar_box_.pack_start(*btn, false, false, 0); + btn->show_all(); + } +} + +// ── Icon loading ───────────────────────────────────────────────────────────── + +Glib::RefPtr Workspace::loadIcon(const std::string& app_id, int size) { + if (app_id.empty()) return {}; + auto app_info = Gio::DesktopAppInfo::create(app_id + ".desktop"); + + if (app_info) { + auto icon = app_info->get_icon(); + if (icon) { + auto theme = Gtk::IconTheme::get_default(); + auto icon_info = theme->lookup_icon(icon, size, Gtk::ICON_LOOKUP_FORCE_SIZE); + + if (icon_info) { + try { + + return icon_info.load_icon(); + } catch (...) { + + } + } + } + } + + auto theme = Gtk::IconTheme::get_default(); + + auto tryLoad = [&](const std::string& name) -> Glib::RefPtr { + if (!theme->has_icon(name)) return {}; + try { + return theme->load_icon(name, size, Gtk::ICON_LOOKUP_FORCE_SIZE); + } catch (...) { + return {}; + } + }; + + if (auto pb = tryLoad(app_id)) return pb; + + std::string lower = app_id; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + if (auto pb = tryLoad(lower)) return pb; + + auto dot = app_id.rfind('.'); + if (dot != std::string::npos) { + std::string last = app_id.substr(dot + 1); + std::transform(last.begin(), last.end(), last.begin(), ::tolower); + if (auto pb = tryLoad(last)) return pb; + } + + return {}; +} + +} // namespace waybar::modules::niri \ No newline at end of file diff --git a/src/modules/niri/workspaces.cpp b/src/modules/niri/workspaces.cpp index 97d15215a..e3743e32c 100644 --- a/src/modules/niri/workspaces.cpp +++ b/src/modules/niri/workspaces.cpp @@ -1,9 +1,9 @@ #include "modules/niri/workspaces.hpp" -#include -#include #include +#include + namespace waybar::modules::niri { Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& config) @@ -22,117 +22,85 @@ Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& gIPC->registerForIPC("WorkspaceActiveWindowChanged", this); gIPC->registerForIPC("WorkspaceUrgencyChanged", this); + gIPC->registerForIPC("WindowsChanged", this); + gIPC->registerForIPC("WindowOpenedOrChanged", this); + gIPC->registerForIPC("WindowLayoutsChanged", this); + gIPC->registerForIPC("WindowFocusChanged", this); + gIPC->registerForIPC("WindowClosed", this); + gIPC->registerForIPC("WindowFocusChanged", this); + dp.emit(); } -Workspaces::~Workspaces() { gIPC->unregisterForIPC(this); } +Workspaces::~Workspaces() { + gIPC->unregisterForIPC(this); +} -void Workspaces::onEvent(const Json::Value& ev) { dp.emit(); } +void Workspaces::onEvent(const Json::Value& /*ev*/) { dp.emit(); } void Workspaces::doUpdate() { auto ipcLock = gIPC->lockData(); - const auto alloutputs = config_["all-outputs"].asBool(); - std::vector my_workspaces; - const auto& workspaces = gIPC->workspaces(); - std::copy_if(workspaces.cbegin(), workspaces.cend(), std::back_inserter(my_workspaces), - [&](const auto& ws) { - if (alloutputs) return true; - return ws["output"].asString() == bar_.output->name; - }); - - // Remove buttons for removed workspaces. - for (auto it = buttons_.begin(); it != buttons_.end();) { - auto ws = std::find_if(my_workspaces.begin(), my_workspaces.end(), - [it](const auto& ws) { return ws["id"].asUInt64() == it->first; }); - if (ws == my_workspaces.end()) { - it = buttons_.erase(it); - } else { - ++it; + const bool alloutputs = config_["all-outputs"].asBool(); + const auto& all_workspaces = gIPC->workspaces(); + const auto& all_windows = gIPC->windows(); + + std::vector my_workspaces; + my_workspaces.reserve(all_workspaces.size()); + for (const auto& ws : all_workspaces) { + if (alloutputs || ws["output"].asString() == bar_.output->name) { + my_workspaces.push_back(&ws); } } - // Add buttons for new workspaces, update existing ones. - for (const auto& ws : my_workspaces) { - auto bit = buttons_.find(ws["id"].asUInt64()); - auto& button = bit == buttons_.end() ? addButton(ws) : bit->second; - auto style_context = button.get_style_context(); - - if (ws["is_focused"].asBool()) - style_context->add_class("focused"); - else - style_context->remove_class("focused"); - - if (ws["is_active"].asBool()) - style_context->add_class("active"); - else - style_context->remove_class("active"); - - if (ws["is_urgent"].asBool()) - style_context->add_class("urgent"); - else - style_context->remove_class("urgent"); - - if (ws["output"]) { - if (ws["output"].asString() == bar_.output->name) - style_context->add_class("current_output"); - else - style_context->remove_class("current_output"); - } else { - style_context->remove_class("current_output"); + workspaces_.erase( + std::remove_if(workspaces_.begin(), workspaces_.end(), + [&](const std::unique_ptr& w) { + bool gone = + std::none_of(my_workspaces.begin(), my_workspaces.end(), + [&](const Json::Value* ws) { + return ws->operator[]("id").asUInt64() == w->id(); + }); + if (gone) box_.remove(w->button()); + return gone; + }), + workspaces_.end()); + + for (const auto* ws_ptr : my_workspaces) { + const auto& ws = *ws_ptr; + const auto ws_id = ws.isMember("id") ? ws["id"].asUInt64() : 0; + + auto it = std::find_if(workspaces_.begin(), workspaces_.end(), + [ws_id](const std::unique_ptr& w) { + return w->id() == ws_id; + }); + + if (it == workspaces_.end()) { + createWorkspace(ws); + it = workspaces_.end() - 1; } - if (ws["active_window_id"].isNull()) - style_context->add_class("empty"); - else - style_context->remove_class("empty"); + std::vector windows_vec(all_windows.begin(), all_windows.end()); + (*it)->update(ws, windows_vec); + } - std::string name; - if (ws["name"]) { - name = ws["name"].asString(); - } else { - name = std::to_string(ws["idx"].asUInt()); - } - button.set_name("niri-workspace-" + name); - - if (config_["format"].isString()) { - auto format = config_["format"].asString(); - name = fmt::format(fmt::runtime(format), fmt::arg("icon", getIcon(name, ws)), - fmt::arg("value", name), fmt::arg("name", ws["name"].asString()), - fmt::arg("index", ws["idx"].asUInt()), - fmt::arg("output", ws["output"].asString())); - } - if (!config_["disable-markup"].asBool()) { - static_cast(button.get_children()[0])->set_markup(name); - } else { - button.set_label(name); - } + for (auto pos_it = my_workspaces.cbegin(); pos_it != my_workspaces.cend(); ++pos_it) { + const auto& ws = **pos_it; + const auto ws_id = ws.isMember("id") ? ws["id"].asUInt64() : 0; - if (config_["current-only"].asBool()) { - const auto* property = alloutputs ? "is_focused" : "is_active"; - if (ws[property].asBool()) - button.show(); - else - button.hide(); - } else if (config_["hide-empty"].asBool()) { - if (ws["active_window_id"].isNull() && !ws["is_focused"].asBool()) - button.hide(); - else - button.show(); + int pos = static_cast(pos_it - my_workspaces.cbegin()); + if (alloutputs) { } else { - button.show(); + pos = static_cast(ws["idx"].asUInt()) - 1; } - } - - // Refresh the button order. - for (auto it = my_workspaces.cbegin(); it != my_workspaces.cend(); ++it) { - const auto& ws = *it; - auto pos = ws["idx"].asUInt() - 1; - if (alloutputs) pos = it - my_workspaces.cbegin(); - - auto& button = buttons_[ws["id"].asUInt64()]; - box_.reorder_child(button, pos); + auto it = std::find_if(workspaces_.begin(), workspaces_.end(), + [ws_id](const std::unique_ptr& w) { + return w->id() == ws_id; + }); + if (it != workspaces_.end()) { + box_.reorder_child((*it)->button(), pos); + } } } @@ -141,48 +109,20 @@ void Workspaces::update() { AModule::update(); } -Gtk::Button& Workspaces::addButton(const Json::Value& ws) { - std::string name; - if (ws["name"]) { - name = ws["name"].asString(); - } else { - name = std::to_string(ws["idx"].asUInt()); - } - auto pair = buttons_.emplace(ws["id"].asUInt64(), name); - auto&& button = pair.first->second; - box_.pack_start(button, false, false, 0); - button.set_relief(Gtk::RELIEF_NONE); - if (!config_["disable-click"].asBool()) { - const auto id = ws["id"].asUInt64(); - button.signal_pressed().connect([=] { - try { - // {"Action":{"FocusWorkspace":{"reference":{"Id":1}}}} - Json::Value request(Json::objectValue); - auto& action = (request["Action"] = Json::Value(Json::objectValue)); - auto& focusWorkspace = (action["FocusWorkspace"] = Json::Value(Json::objectValue)); - auto& reference = (focusWorkspace["reference"] = Json::Value(Json::objectValue)); - reference["Id"] = id; - - IPC::send(request); - } catch (const std::exception& e) { - spdlog::error("Error switching workspace: {}", e.what()); - } - }); - } - return button; +void Workspaces::createWorkspace(const Json::Value& workspace_data) { + auto ws = std::make_unique(workspace_data, *this); + box_.pack_start(ws->button(), false, false, 0); + workspaces_.push_back(std::move(ws)); } -std::string Workspaces::getIcon(const std::string& value, const Json::Value& ws) { +std::string Workspaces::getIcon(const std::string& value, const Json::Value& ws) const { const auto& icons = config_["format-icons"]; if (!icons) return value; if (ws["is_urgent"].asBool() && icons["urgent"]) return icons["urgent"].asString(); - if (ws["is_active"].asBool() && icons["active"]) return icons["active"].asString(); - if (ws["is_focused"].asBool() && icons["focused"]) return icons["focused"].asString(); - if (ws["active_window_id"].isNull() && icons["empty"]) return icons["empty"].asString(); if (ws["name"]) { @@ -198,4 +138,4 @@ std::string Workspaces::getIcon(const std::string& value, const Json::Value& ws) return value; } -} // namespace waybar::modules::niri +} // namespace waybar::modules::niri \ No newline at end of file