fix: backward-compat + bug fixes and man-page build fix for 0.16.0

Post-0.15.0 review of the 0.15.0..HEAD range surfaced regressions and
bugs. This restores backward compatibility for existing configs/CSS,
fixes confirmed defects, and repairs the scdoc man-page build break on
master. Pango-markup tooltips are intentional and were kept.

Backward-compat restorations:
- AModule: honor legacy numeric Gdk::CursorType cursor values (int overload)
- memory: correct GiB divisor (was ~2.3% low); round bare {} placeholders
- wireplumber: scale max-volume into the linear domain so the cap works again
- idle_inhibitor: gate right/middle-click deactivate & scroll on dynamic-timeouts;
  accept both dynamic-timeout(s); widen timeout to double (no fractional truncation)
- custom: keep #custom-<name>.<class> CSS selectors working (classes on box_)
- image: don't wordexp-split a single path; fall back to the literal path
- niri/window: restore hide-when-empty (new show-empty opt-in); escape tooltip
- wlr/taskbar: plain-text tooltip when markup is disabled

Bug fixes:
- tray: fix use-after-free in onAdd; guard the watcher retry timeout
- hyprland: clamp max-windows iterator (OOB); drop duplicate language tooltip block
- niri/window: supply {col}/{max_col} args in the empty branch (fmt::format_error)
- mpris: escape {dynamic}/{player} tooltip; fix dangling player; albumArtist source
- mango: fix use-after-free race (dispatch under callback_mutex_)
- mpd: contain throwing checkErrors in noexcept idle paths (no std::terminate/UAF)
- keyboard_state: always render every lock label, with guarded defaults
- bluetooth: bound GATT ReadValue timeout, opt-in + services-resolved gating,
  preserve authoritative Battery1 percentage
- wireplumber: fix WpDevice reference leak / NULL handling
- battery, clock, dwl, wayfire, graph, custom_graph, transform, river: assorted
  crash/logic fixes

Man page / build:
- niri-workspaces: fix scdoc "indented by an amount greater than 1"
  (workspace-taskbar sub-options were mis-indented; breaks man-page build)
- document new show-empty (niri/window); correct network {txBitrate}/{rxBitrate}

Not compiled locally (no gtkmm on this host); C++ build relies on CI.
Man pages validated with scdoc 1.11.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex
2026-07-04 02:14:13 +02:00
co-authored by Claude Opus 4.8
parent 482cfab64e
commit f72f84e011
35 changed files with 377 additions and 192 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ class AGraph : public AModule {
void addValue(const int n);
const std::chrono::seconds interval_;
const std::chrono::milliseconds interval_;
bool onDraw(const Cairo::RefPtr<Cairo::Context>& cr);
+2
View File
@@ -86,6 +86,8 @@ class AModule : public IModule {
Gtk::EventBox event_box_;
virtual void setCursor(std::string const& c);
// Backward-compat overload for legacy numeric Gdk::CursorType configs (pre-0.16)
virtual void setCursor(Gdk::CursorType const& c);
virtual bool handleToggle(GdkEventButton* const& ev);
virtual bool handleMouseEnter(GdkEventCrossing* const& ev);
+3 -3
View File
@@ -38,9 +38,9 @@ class IdleInhibitor : public ALabel {
struct zwp_idle_inhibitor_v1* idle_inhibitor_;
int pid_;
bool dynamicTimeout;
short timeout;
short timeout_step;
bool dynamicTimeout = false;
double timeout;
double timeout_step;
bool wait_for_activity_;
};
+1 -1
View File
@@ -76,7 +76,7 @@ screensaver, also known as "presentation mode".
typeof: double ++
The number of minutes the inhibition should last.
*timeout_step*: ++
*timeout-step*: ++
typeof: double ++
default: 10 ++
The number of minutes to add or subtract when scrolling (when dynamic timeouts are enabled).
+2 -2
View File
@@ -196,9 +196,9 @@ Addressed by *network*
*{bandwidthTotalBytes}*: Instant total speed in bytes/seconds.
*{tx_bitrate}*: Link transmit bitrate (e.g., 866.7 Mb/s).
*{txBitrate}*: Link transmit bitrate (e.g., 866.7 Mb/s).
*{rx_bitrate}*: Link receive bitrate (e.g., 866.7 Mb/s).
*{rxBitrate}*: Link receive bitrate (e.g., 866.7 Mb/s).
*{linkSpeed}*: Ethernet link speed.
+5
View File
@@ -40,6 +40,11 @@ Addressed by *niri/window*
default: false ++
Enables this module to consume all left over space dynamically.
*show-empty*: ++
typeof: bool ++
default: false ++
When no window is focused (empty workspace or all windows closed), keep the module visible and render *format* with empty {title}/{app_id} and {col}/{max_col} set to -1. When false (the default), the module is hidden while no window is focused.
# FORMAT REPLACEMENTS
See the output of "niri msg windows" for examples
+5
View File
@@ -67,6 +67,11 @@ see the cursor-shape-v1 protocol for all possible cursor types:
https://wayland.app/protocols/cursor-shape-v1#wp_cursor_shape_device_v1:enum:shape.
Depending on the compositor and cursor theme used, cursors not listed in the protocol may also work.
Prior to 0.16.0 the `cursor` option accepted a numeric _Gdk::CursorType_ value
(e.g. `"cursor": 8`). Numeric values are deprecated but still honored for
backward compatibility; a cursor-shape-v1 name string (e.g. `"grab"`) is now
preferred.
Example of disabling the cursor on a custom module:
```
+8 -4
View File
@@ -3,6 +3,7 @@
#include <cairomm/context.h>
#include <fmt/format.h>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <iostream>
@@ -17,10 +18,13 @@ AGraph::AGraph(const Json::Value& config, const std::string& name, const std::st
: AModule(config, name, id,
config["format-alt"].isString() || config["menu"].isString() || enable_click,
enable_scroll),
interval_(config_["interval"] == "once"
? std::chrono::seconds::max()
: std::chrono::seconds(
config_["interval"].isUInt() ? config_["interval"].asUInt() : interval)) {
interval_(
config_["interval"] == "once"
? std::chrono::milliseconds::max()
: std::chrono::milliseconds(
config_["interval"].isNumeric()
? std::max(1L, static_cast<long>(config_["interval"].asDouble() * 1000))
: 1000L * static_cast<long>(interval))) {
graph_.signal_draw().connect(sigc::mem_fun(*this, &AGraph::onDraw));
graph_.set_name(name);
if (!id.empty()) {
+5
View File
@@ -90,6 +90,7 @@ auto AIconLabel::update() -> void {
label_.set_markup(cleanLabel);
if (iconLabel.front() == '/') {
try {
int scaled_icon_size = app_icon_size_ * image_.get_scale_factor();
auto pixbuf = Gdk::Pixbuf::create_from_file(iconLabel, scaled_icon_size, scaled_icon_size);
@@ -97,6 +98,10 @@ auto AIconLabel::update() -> void {
image_.get_window());
image_.set(surface);
image_.set_visible(true);
} catch (const Glib::Exception& e) {
spdlog::warn("Failed to load embedded icon {}: {}", iconLabel, std::string(e.what()));
image_.set_visible(false);
}
} else {
image_.set_from_icon_name(iconLabel, Gtk::ICON_SIZE_INVALID);
image_.set_visible(true);
+25
View File
@@ -85,6 +85,13 @@ AModule::AModule(const Json::Value& config, const std::string& name, const std::
}
} else if (config_["cursor"].isString()) {
setCursor(config_["cursor"].asString());
} else if (config_["cursor"].isInt() || config_["cursor"].isUInt()) {
// Backward-compat: legacy numeric Gdk::CursorType values (pre-0.16)
setCursor(Gdk::CursorType(config_["cursor"].asInt()));
spdlog::warn(
"Numeric 'cursor' values are deprecated; use a cursor-shape-v1 name string instead "
"(module {})",
name_);
} else {
spdlog::warn("unknown cursor option configured on module {}", name_);
}
@@ -140,6 +147,24 @@ void AModule::setCursor(std::string const& c) {
}
}
// Backward-compat overload: honor legacy numeric Gdk::CursorType configs (pre-0.16)
void AModule::setCursor(Gdk::CursorType const& c) {
auto gdk_window = event_box_.get_window();
if (gdk_window) {
auto cursor = Gdk::Cursor::create(gdk_window->get_display(), c);
gdk_window->set_cursor(cursor);
} else {
// window may not be accessible yet, in this case,
// schedule another call for setting the cursor in 1 sec
cursor_timeout_conn_ = Glib::signal_timeout().connect_seconds(
[this, c]() {
setCursor(c);
return false;
},
1);
}
}
bool AModule::handleMouseEnter(GdkEventCrossing* const& e) {
if (auto* module = event_box_.get_child(); module != nullptr) {
module->set_state_flags(Gtk::StateFlags::STATE_FLAG_PRELIGHT);
+1 -1
View File
@@ -609,7 +609,7 @@ waybar::modules::Battery::getInfos() {
if (status == "Discharging" && time_to_empty_now_exists) {
if (time_to_empty_now != 0) time_remaining = (float)time_to_empty_now / 3600.0f;
} else if (status == "Discharging" && total_power_exists && total_energy_exists) {
if (smooth_power_ != 0) time_remaining = (float)total_energy / smooth_power_;
if (total_power != 0) time_remaining = (float)total_energy / smooth_power_;
} else if (status == "Charging" && time_to_full_now_exists) {
if (time_to_full_now_exists && (time_to_full_now != 0))
time_remaining = -(float)time_to_full_now / 3600.0f;
+33 -4
View File
@@ -87,14 +87,29 @@ auto isChildPath(const std::string& child, const std::string& parent) -> bool {
return child.starts_with(parent);
}
// Returns true only if some configured format/tooltip string actually references the peripheral
// battery placeholder. The GATT battery scan issues over-the-air BLE reads, so it must stay
// opt-in: users who don't display {device_battery_percentage_peripheral} pay zero cost.
auto configUsesPeripheralBattery(const Json::Value& config) -> bool {
for (const auto& key : config.getMemberNames()) {
if (config[key].isString() &&
config[key].asString().find("device_battery_percentage_peripheral") != std::string::npos) {
return true;
}
}
return false;
}
auto readBatteryCharacteristicValue(GDBusProxy* proxy_char) -> std::optional<unsigned char> {
GVariantBuilder builder;
g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}"));
GError* error = nullptr;
// Use a small finite timeout (2s) instead of the default (-1 == 25s) so a sleeping or
// dropped BLE peripheral cannot stall the Waybar main thread for ~25s on a synchronous read.
GVariant* gvar =
g_dbus_proxy_call_sync(proxy_char, "ReadValue", g_variant_new("(a{sv})", &builder),
G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error);
G_DBUS_CALL_FLAGS_NONE, 2000, nullptr, &error);
if (error != nullptr) {
g_error_free(error);
return std::nullopt;
@@ -538,7 +553,9 @@ auto waybar::modules::Bluetooth::processBatteryServiceCharacteristics(
if (hasUserDescriptionDescriptor(objects, char_path, user_description_uuid)) {
peripheral_battery = battery_value.value();
} else {
} else if (!central_battery.has_value()) {
// Only fill the central sink once so multiple non-described 0x2a19 characteristics don't
// clobber each other (order-dependent last-writer-wins) or an already-set Battery1 value.
central_battery = battery_value.value();
}
}
@@ -564,8 +581,20 @@ auto waybar::modules::Bluetooth::getDeviceProperties(GDBusObject* object, Device
g_object_unref(proxy_device);
device_info.battery_percentage = getDeviceBatteryPercentage(object);
getDeviceGattBatteryLevels(object, device_info.battery_percentage,
device_info.battery_percentage_peripheral);
// Only perform the (synchronous, over-the-air) GATT battery scan when the peripheral battery
// placeholder is actually used and the device's services are resolved. This keeps the scan off
// the frequent property-changed hot path (RSSI/TxPower updates leave ServicesResolved false or
// unchanged) and opt-in for split-keyboard style peripherals.
if (device_info.services_resolved && configUsesPeripheralBattery(config_)) {
// Read the GATT central level into a separate local; only fall back to it when the
// authoritative org.bluez.Battery1 percentage is absent, so the GATT scan can never overwrite
// the Battery1 value used by {device_battery_percentage}.
std::optional<unsigned char> gatt_central;
getDeviceGattBatteryLevels(object, gatt_central, device_info.battery_percentage_peripheral);
if (!device_info.battery_percentage.has_value()) {
device_info.battery_percentage = gatt_central;
}
}
return true;
}
+6 -2
View File
@@ -568,8 +568,12 @@ void waybar::modules::Clock::tz_down() {
tzCurrIdx_ = (tzCurrIdx_ == 0) ? tzSize - 1 : tzCurrIdx_ - 1;
}
void waybar::modules::Clock::action_exec(const std::string& action) {
auto cmd = action.substr(strlen("exec "));
pid_children_.push_back(util::command::forkExec(cmd));
const auto pos = action.find(" ");
if (pos == std::string::npos || action.find_first_not_of(" ", pos) == std::string::npos) {
spdlog::error("Clock: exec action requires a command argument");
return;
}
pid_children_.push_back(util::command::forkExec(action.substr(pos + 1)));
}
#ifdef HAVE_LANGINFO_1STDAY
+26 -2
View File
@@ -223,6 +223,17 @@ auto waybar::modules::Custom::update() -> void {
for (auto const& c : class_) {
style->add_class(c);
}
// Mirror the dynamic script classes onto box_, which now carries the
// #custom-<name> widget name (see AIconLabel), so #custom-<name>.<class>
// CSS selectors keep resolving as they did in 0.15.0.
auto box_style = box_.get_style_context();
for (auto const& c : box_style->list_classes()) {
if (c == id_ || c == MODULE_CLASS) continue;
box_style->remove_class(c);
}
for (auto const& c : class_) {
box_style->add_class(c);
}
style->add_class("flat");
style->add_class("text-button");
style->add_class(MODULE_CLASS);
@@ -230,14 +241,20 @@ auto waybar::modules::Custom::update() -> void {
image_style->add_class("image-button");
event_box_.show();
if (!image_path_.empty()) {
auto pixbuf = Gdk::Pixbuf::create_from_file(image_path_, app_icon_size_, app_icon_size_);
try {
auto pixbuf =
Gdk::Pixbuf::create_from_file(image_path_, app_icon_size_, app_icon_size_);
image_.set(pixbuf);
} catch (const Glib::Error& e) {
spdlog::warn("custom {}: failed to load image-path '{}': {}", name_, image_path_,
std::string(e.what()));
image_.clear();
}
} else if (!image_name_.empty()) {
image_.set_from_icon_name(image_name_, Gtk::ICON_SIZE_INVALID);
image_.set_pixel_size(app_icon_size_);
}
image_.set_visible(!image_name_.empty() || !image_path_.empty());
label_.set_visible(!str.empty());
}
} catch (const fmt::format_error& e) {
@@ -251,6 +268,13 @@ auto waybar::modules::Custom::update() -> void {
}
// Call parent update
AIconLabel::update();
// Show a configured image-path/image-name image after the base update() so
// AIconLabel::update()'s icon gate cannot re-hide it. Leave the embedded-icon
// and "icon" cases to the base class (they have no image-path/image-name).
if (!image_name_.empty() || !image_path_.empty()) {
image_.set_visible(true);
}
}
void waybar::modules::Custom::parseOutputRaw() {
+21
View File
@@ -2,6 +2,11 @@
#include <spdlog/spdlog.h>
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <string>
#include "util/scope_guard.hpp"
waybar::modules::CustomGraph::CustomGraph(const std::string& name, const std::string& id,
@@ -228,6 +233,22 @@ void waybar::modules::CustomGraph::parseOutputRaw() {
tooltip_ = validated_line;
}
class_.clear();
// Derive the graph value from the first line's leading numeric value,
// since the raw (i3blocks) format has no dedicated percentage field.
std::string value = validated_line;
if (!value.empty() && value.back() == '%') {
value.pop_back();
}
try {
double parsed = std::stod(value);
if (std::isnan(parsed)) {
percentage_ = 0;
} else {
percentage_ = static_cast<int>(std::clamp(std::lround(parsed), 0L, 100L));
}
} catch (const std::exception&) {
percentage_ = 0;
}
} else if (i == 1) {
if (config_["escape"].isBool() && config_["escape"].asBool()) {
tooltip_ = Glib::Markup::escape_text(validated_line);
+1 -3
View File
@@ -138,9 +138,7 @@ void Window::handle_frame() {
box_.set_visible(true);
} else {
box_.get_style_context()->remove_class("active");
if (hide_inactive_) {
box_.set_visible(false);
}
box_.set_visible(!hide_inactive_);
}
}
}
+13 -26
View File
@@ -53,14 +53,23 @@ auto Language::update() -> void {
std::string tooltipContent = std::string{};
bool tooltip_enabled = tooltipEnabled();
if (tooltip_enabled) {
if (config_.isMember("tooltip-format")) {
auto tooltip_format = config_["tooltip-format"].asString();
// Default to "{long}" when no tooltip-format is provided, matching the man page
auto tooltip_format =
config_.isMember("tooltip-format") ? config_["tooltip-format"].asString() : "{long}";
if (config_.isMember("tooltip-format-" + layout_.short_description + "-" + layout_.variant)) {
const auto propName = "tooltip-format-" + layout_.short_description + "-" + layout_.variant;
tooltipContent = fmt::format(fmt::runtime(tooltip_format), config_[propName].asString());
tooltipContent = trim(fmt::format(fmt::runtime(tooltip_format), config_[propName].asString(),
fmt::arg("long", layout_.full_name),
fmt::arg("short", layout_.short_name),
fmt::arg("shortDescription", layout_.short_description),
fmt::arg("variant", layout_.variant)));
} else if (config_.isMember("tooltip-format-" + layout_.short_description)) {
const auto propName = "tooltip-format-" + layout_.short_description;
tooltipContent = fmt::format(fmt::runtime(tooltip_format), config_[propName].asString());
tooltipContent = trim(fmt::format(fmt::runtime(tooltip_format), config_[propName].asString(),
fmt::arg("long", layout_.full_name),
fmt::arg("short", layout_.short_name),
fmt::arg("shortDescription", layout_.short_description),
fmt::arg("variant", layout_.variant)));
} else {
tooltipContent =
trim(fmt::format(fmt::runtime(tooltip_format), fmt::arg("long", layout_.full_name),
@@ -68,10 +77,6 @@ auto Language::update() -> void {
fmt::arg("shortDescription", layout_.short_description),
fmt::arg("variant", layout_.variant)));
}
} else {
// if no tooltip format is provided, use the same text as the module
tooltipContent = layoutName;
}
spdlog::debug("hyprland language formatted tooltip content {}", tooltipContent);
}
@@ -85,24 +90,6 @@ auto Language::update() -> void {
label_.hide();
}
// Tooltip support
if (tooltipEnabled()) {
std::string tooltipFormat;
if (config_["tooltip-format"].isString()) {
tooltipFormat = config_["tooltip-format"].asString();
} else {
tooltipFormat = "{long}";
}
auto tooltipText =
trim(fmt::format(fmt::runtime(tooltipFormat), fmt::arg("long", layout_.full_name),
fmt::arg("short", layout_.short_name),
fmt::arg("shortDescription", layout_.short_description),
fmt::arg("variant", layout_.variant)));
label_.set_tooltip_text(tooltipText);
} else {
label_.set_tooltip_text("");
}
ALabel::update();
}
+2 -1
View File
@@ -438,7 +438,8 @@ void Workspace::update(const std::string& workspace_icon, const std::string& wor
auto windowSeparator = m_workspaceManager.getWindowSeparator();
auto groupThreshold = m_workspaceManager.windowRewriteGroupThreshold();
auto end_it = m_workspaceManager.maxWindows() == 0
auto end_it = (m_workspaceManager.maxWindows() <= 0 ||
static_cast<size_t>(m_workspaceManager.maxWindows()) >= m_windowMap.size())
? m_windowMap.end()
: m_windowMap.begin() + m_workspaceManager.maxWindows();
+18 -5
View File
@@ -44,7 +44,14 @@ waybar::modules::IdleInhibitor::IdleInhibitor(const std::string& id, const Bar&
event_box_.signal_button_press_event().connect(
sigc::mem_fun(*this, &IdleInhibitor::handleToggle));
// Only connect our own scroll handler when the user hasn't configured on-scroll-*
// commands. When on-scroll-* is set, AModule already connects a scroll handler that
// (via virtual dispatch) reaches IdleInhibitor::handleScroll; a second connection here
// would fire handleScroll twice per scroll event.
if (!(config_["on-scroll-up"].isString() || config_["on-scroll-down"].isString() ||
config_["on-scroll-left"].isString() || config_["on-scroll-right"].isString())) {
event_box_.signal_scroll_event().connect(sigc::mem_fun(*this, &IdleInhibitor::handleScroll));
}
// Add this to the modules list
waybar::modules::IdleInhibitor::modules.push_back(this);
@@ -157,8 +164,11 @@ void waybar::modules::IdleInhibitor::toggleStatus(int force_status) {
}
bool waybar::modules::IdleInhibitor::handleToggle(GdkEventButton* const& e) {
// Accept both the documented "dynamic-timeouts" (plural) and the legacy
// "dynamic-timeout" (singular) key spellings.
const bool dynamic = config_["dynamic-timeouts"].asBool() || config_["dynamic-timeout"].asBool();
if (e->button == 1) {
if (config_["dynamic-timeout"].asBool()) {
if (dynamic) {
toggleStatus(1);
} else {
toggleStatus();
@@ -171,7 +181,7 @@ bool waybar::modules::IdleInhibitor::handleToggle(GdkEventButton* const& e) {
}
}
}
if (e->button == 3) {
if (e->button == 3 && dynamic) {
toggleStatus(0);
// Make all other idle inhibitor modules update
@@ -181,7 +191,7 @@ bool waybar::modules::IdleInhibitor::handleToggle(GdkEventButton* const& e) {
}
}
}
if (e->button == 2) {
if (e->button == 2 && dynamic) {
toggleStatus(0);
timeout = config_["timeout"].asDouble();
}
@@ -190,8 +200,11 @@ bool waybar::modules::IdleInhibitor::handleToggle(GdkEventButton* const& e) {
}
bool waybar::modules::IdleInhibitor::handleScroll(GdkEventScroll* e) {
if (!config_["dynamic-timeout"].asBool()) {
return true;
// Accept both the documented "dynamic-timeouts" (plural) and the legacy
// "dynamic-timeout" (singular) key spellings.
if (!(config_["dynamic-timeouts"].asBool() || config_["dynamic-timeout"].asBool())) {
// Delegate to the base handler so any configured on-scroll-* command still runs.
return ALabel::handleScroll(e);
}
auto dir = AModule::getScrollDir(e);
if (dir == SCROLL_DIR::NONE) {
+20 -5
View File
@@ -126,7 +126,12 @@ void MultipleImageStrategy::setupAndDraw() {
bool has_onclick = !data.on_click.empty();
Glib::RefPtr<Gdk::Pixbuf> pixbuf;
try {
pixbuf = Gdk::Pixbuf::create_from_file(path, size_, size_);
} catch (const Glib::Error& e) {
spdlog::error("failed to load image '{}': {}", path, std::string(e.what()));
pixbuf.reset(); // fall through to the .empty branch
}
if (has_onclick) {
auto btn = images_data_[i].btn;
@@ -234,22 +239,32 @@ SingleImageStrategy::SingleImageStrategy(const std::string& id, const Json::Valu
void SingleImageStrategy::update() {
if (config_["path"].isString()) {
auto result = Config::tryExpandPath(config_["path"].asString(), "");
path_ = result.empty() ? "" : result.front();
auto p = config_["path"].asString();
auto result = Config::tryExpandPath(p, "");
// Only use the expanded path when it resolves to exactly one existing match;
// otherwise keep the literal path so paths with spaces/metacharacters still work.
path_ = (result.size() == 1) ? result.front() : p;
} else if (config_["exec"].isString()) {
output_ = util::command::exec(config_["exec"].asString(), "");
parseOutputRaw();
// expand path if "~" or "$HOME" is present in original path
auto result = Config::tryExpandPath(path_, "");
path_ = result.empty() ? "" : result.front();
path_ = (result.size() == 1) ? result.front() : path_;
}
if (Glib::file_test(path_, Glib::FILE_TEST_EXISTS)) {
Glib::RefPtr<Gdk::Pixbuf> pixbuf;
if (Glib::file_test(path_, Glib::FILE_TEST_EXISTS)) {
int scaled_icon_size = size_ * image_.get_scale_factor();
try {
pixbuf = Gdk::Pixbuf::create_from_file(path_, scaled_icon_size, scaled_icon_size);
} catch (const Glib::Exception& e) {
// Existing but corrupt/non-image file: degrade to the empty state instead of crashing.
spdlog::warn("Failed to load image {}: {}", path_, std::string(e.what()));
pixbuf.reset();
}
}
if (pixbuf) {
auto surface = Gdk::Cairo::create_surface_from_pixbuf(pixbuf, image_.get_scale_factor(),
image_.get_window());
image_.set(surface);
+8 -17
View File
@@ -86,7 +86,6 @@ auto isCommonFormatIcons(const Json::Value& config) -> bool {
auto keyStateToIcons(const Json::Value& config)
-> std::unordered_map<std::string, std::vector<std::string>> {
std::unordered_map<std::string, std::vector<std::string>> key_icon_states;
std::vector<std::string> default_icons = {"unlocked", "locked"};
if (isCommonFormatIcons(config)) {
std::vector<std::string> icons = {
@@ -100,26 +99,18 @@ auto keyStateToIcons(const Json::Value& config)
return key_icon_states;
}
bool found_any = false;
const auto& format_icons = config["format-icons"];
for (const auto& key : std::vector<std::string>{"numlock", "capslock", "scrolllock"}) {
std::string map_key = key.substr(0, key.length() - 4);
map_key[0] = std::toupper(map_key[0]);
if (config["format-icons"].isObject() && config["format-icons"][key].isObject()) {
std::string unlocked = config["format-icons"][key]["unlocked"].isString()
? config["format-icons"][key]["unlocked"].asString()
: "unlocked";
std::string locked = config["format-icons"][key]["locked"].isString()
? config["format-icons"][key]["locked"].asString()
: "locked";
std::string unlocked = "unlocked";
std::string locked = "locked";
if (format_icons.isObject() && format_icons[key].isObject()) {
const auto& obj = format_icons[key];
if (obj["unlocked"].isString()) unlocked = obj["unlocked"].asString();
if (obj["locked"].isString()) locked = obj["locked"].asString();
}
key_icon_states[map_key] = {unlocked, locked};
found_any = true;
}
}
if (!found_any) {
key_icon_states["Num"] = default_icons;
key_icon_states["Caps"] = default_icons;
key_icon_states["Scroll"] = default_icons;
}
return key_icon_states;
+2 -7
View File
@@ -204,19 +204,14 @@ void IPC::parseIPC(const std::string& line) {
}
}
std::vector<EventHandler*> handlers_to_notify;
{
std::lock_guard<std::mutex> lock(callback_mutex_);
for (auto& [ev, handler] : callbacks_) {
if (ev == "monitor") {
handlers_to_notify.push_back(handler);
}
}
}
for (auto* handler : handlers_to_notify) {
handler->onEvent(root);
}
}
}
return;
}
+17 -14
View File
@@ -1,14 +1,14 @@
#include <cmath>
#include "modules/memory.hpp"
namespace {
const std::unordered_map<std::string, float> kUnits = {{"kB", 1.000},
{"kiB", 1.024},
{"MB", 1.000 * 1000.0},
{"MiB", 1.024 * 1024.0},
{"GB", 1.000 * 1000.0 * 1000.0},
{"GiB", 1.024 * 1024.0 * 1024.0},
{"TB", 1.000 * 1000.0 * 1000.0 * 1000.0},
{"TiB", 1.024 * 1024.0 * 1024.0 * 1024.0}};
// /proc/meminfo reports values in kiB (1024 bytes) despite the 'kB' label.
// These divisors convert a kiB count to the requested unit.
const std::unordered_map<std::string, float> kUnits = {
{"B", 1.0 / 1024.0}, {"kB", 1000.0 / 1024.0}, {"kiB", 1.0},
{"MB", 1000000.0 / 1024.0}, {"MiB", 1024.0}, {"GB", 1000000000.0 / 1024.0},
{"GiB", 1024.0 * 1024.0}, {"TB", 1e12 / 1024.0}, {"TiB", 1024.0 * 1024.0 * 1024.0}};
}
waybar::modules::Memory::Memory(const std::string& id, const Json::Value& config)
@@ -57,12 +57,15 @@ auto waybar::modules::Memory::update() -> void {
}
float divisor = kUnits.at(unit_);
float total_ram = memtotal / divisor;
float total_swap = swaptotal / divisor;
float used_ram = (memtotal - memfree) / divisor;
float used_swap = (swaptotal - swapfree) / divisor;
float available_ram = memfree / divisor;
float available_swap = swapfree / divisor;
// Pre-round to 2 decimals so bare {total}/{used}/{avail} placeholders print
// cleanly, matching the 0.15.0 behavior (an explicit spec like {used:.1f}
// still overrides this).
float total_ram = 0.01f * std::round((memtotal / divisor) * 100.0f);
float total_swap = 0.01f * std::round((swaptotal / divisor) * 100.0f);
float used_ram = 0.01f * std::round(((memtotal - memfree) / divisor) * 100.0f);
float used_swap = 0.01f * std::round(((swaptotal - swapfree) / divisor) * 100.0f);
float available_ram = 0.01f * std::round((memfree / divisor) * 100.0f);
float available_swap = 0.01f * std::round((swapfree / divisor) * 100.0f);
auto format = format_;
auto state = getState(used_ram_percentage);
+1 -1
View File
@@ -28,7 +28,7 @@ waybar::modules::MPD::MPD(const std::string& id, const Json::Value& config)
connection_(nullptr, &mpd_connection_free),
status_(nullptr, &mpd_status_free),
song_(nullptr, &mpd_song_free),
ellipsis_(config_["ellipsis"].isString() ? config_["ellipsis"].asString() : "\u2026") {
ellipsis_(config_["ellipsis"].isString() ? config_["ellipsis"].asString() : "") {
if (!config_["port"].isNull() && !config_["port"].isUInt()) {
spdlog::warn("{}: `port` configuration should be an unsigned int", module_name_);
}
+13 -1
View File
@@ -58,6 +58,7 @@ void Idle::entry() noexcept {
auto conn = ctx_->connection().get();
assert(conn != nullptr);
try {
if (!mpd_send_idle_mask(
conn, static_cast<mpd_idle>(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) {
ctx_->checkErrors(conn);
@@ -69,6 +70,10 @@ void Idle::entry() noexcept {
Glib::signal_io().connect(idle_slot, mpd_connection_get_fd(conn),
Glib::IO_IN | Glib::IO_PRI | Glib::IO_ERR | Glib::IO_HUP);
}
} catch (std::exception const& e) {
spdlog::warn("mpd: Idle: error: {}", e.what());
ctx_->setState(std::make_unique<Disconnected>(ctx_));
}
}
void Idle::exit() noexcept {
@@ -114,8 +119,10 @@ bool Idle::on_io(Glib::IOCondition const&) {
void Playing::entry() noexcept {
timer();
idle();
spdlog::debug("mpd: Playing: enabled {}ms periodic timer.", ctx_->playing_interval());
// NB: idle() may transition to Disconnected (destroying *this) on error, so it
// must be the last statement here — do not access members after this call.
idle();
}
void Playing::exit() noexcept {
@@ -143,6 +150,7 @@ void Playing::idle() noexcept {
auto conn = ctx_->connection().get();
assert(conn != nullptr);
try {
if (!mpd_send_idle_mask(
conn, static_cast<mpd_idle>(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) {
ctx_->checkErrors(conn);
@@ -154,6 +162,10 @@ void Playing::idle() noexcept {
Glib::signal_io().connect(idle_slot, mpd_connection_get_fd(conn),
Glib::IO_IN | Glib::IO_PRI | Glib::IO_ERR | Glib::IO_HUP);
}
} catch (std::exception const& e) {
spdlog::warn("mpd: Playing: error: {}", e.what());
ctx_->setState(std::make_unique<Disconnected>(ctx_));
}
}
bool Playing::on_timer() {
+6 -3
View File
@@ -385,6 +385,7 @@ auto Mpris::onPlayerNameAppeared(PlayerctlPlayerManager* manager, PlayerctlPlaye
if (mpris->player != nullptr) {
g_signal_handlers_disconnect_by_data(mpris->player, mpris);
if (mpris->last_active_player_ == mpris->player) mpris->last_active_player_ = nullptr;
g_clear_object(&mpris->player);
}
mpris->player = playerctl_player_new_from_name(player_name, nullptr);
@@ -544,11 +545,12 @@ auto Mpris::getPlayerInfo() -> std::optional<PlayerInfo> {
if (error) goto errorexit;
if (auto* album_artist_ =
playerctl_player_print_metadata_prop(player, "xesam:albumArtist", &error)) {
playerctl_player_print_metadata_prop(last_active_player_, "xesam:albumArtist", &error)) {
spdlog::debug("mpris[{}]: albumArtist = {}", info.name, album_artist_);
info.album_artist = album_artist_;
g_free(album_artist_);
}
if (error) goto errorexit;
if (auto* album_ = playerctl_player_get_album(last_active_player_, &error)) {
spdlog::debug("mpris[{}]: album = {}", info.name, album_);
@@ -745,7 +747,8 @@ auto Mpris::update() -> void {
if (tooltipEnabled()) {
try {
auto tooltip_text = fmt::format(
fmt::runtime(tooltipstr), fmt::arg("player", info.name),
fmt::runtime(tooltipstr),
fmt::arg("player", std::string(Glib::Markup::escape_text(info.name))),
fmt::arg("status", info.status_string),
fmt::arg("artist",
std::string(Glib::Markup::escape_text(getArtistStr(info, tooltip_len_limits_)))),
@@ -754,7 +757,7 @@ auto Mpris::update() -> void {
fmt::arg("album",
std::string(Glib::Markup::escape_text(getAlbumStr(info, tooltip_len_limits_)))),
fmt::arg("length", tooltipLength), fmt::arg("position", tooltipPosition),
fmt::arg("dynamic", getDynamicStr(info, tooltip_len_limits_, false)),
fmt::arg("dynamic", getDynamicStr(info, tooltip_len_limits_, true)),
fmt::arg("player_icon", getIconFromJson(config_["player-icons"], info.name)),
fmt::arg("status_icon", getIconFromJson(config_["status-icons"], info.status_string)));
+7 -3
View File
@@ -78,7 +78,7 @@ void Window::doUpdate() {
updateAppIconName(appId, "");
if (tooltipEnabled()) label_.set_tooltip_markup(title);
if (tooltipEnabled()) label_.set_tooltip_markup(sanitizedTitle);
const auto id = window["id"].asUInt64();
const auto workspaceId = window["workspace_id"].asUInt64();
@@ -93,11 +93,15 @@ void Window::doUpdate() {
oldAppId_ = appId;
}
} else {
if (config_["show-empty"].asBool()) {
label_.show();
label_.set_markup(waybar::util::rewriteString(
fmt::format(fmt::runtime(format_), fmt::arg("title", ""),
fmt::arg("app_id", "")),
fmt::format(fmt::runtime(format_), fmt::arg("title", ""), fmt::arg("app_id", ""),
fmt::arg("col", -1), fmt::arg("max_col", -1)),
config_["rewrite"]));
} else {
label_.hide();
}
updateAppIconName("", "");
setClass("solo", false);
+9 -3
View File
@@ -129,13 +129,15 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con
}
if (!control_) {
// Keep going: without river_control_v1 the tags are still displayed read-only
// (0.15.0 behavior); only the click-to-select/toggle wiring is disabled below.
spdlog::error("river_control_v1 not advertised");
return;
}
if (!seat_) {
// Keep going: wl_seat is only required for the focused-output ("output") class
// and for issuing control commands. Read-only tag display still works without it.
spdlog::error("wl_seat not advertised");
return;
}
// Store the output this module belongs to; the river_output_status and
@@ -169,7 +171,7 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con
button.set_relief(Gtk::RELIEF_NONE);
box_.pack_start(button, false, false, 0);
if (!config_["disable-click"].asBool()) {
if (control_ && seat_ && !config_["disable-click"].asBool()) {
if (set_tags.isArray() && !set_tags.empty())
button.signal_clicked().connect(sigc::bind(
sigc::mem_fun(*this, &Tags::handle_primary_clicked), set_tags[tag].asUInt()));
@@ -213,14 +215,17 @@ void Tags::handle_show() {
output_status_ = zriver_status_manager_v1_get_river_output_status(status_manager_, output_);
zriver_output_status_v1_add_listener(output_status_, &output_status_listener_impl, this);
if (seat_) {
seat_status_ = zriver_status_manager_v1_get_river_seat_status(status_manager_, seat_);
zriver_seat_status_v1_add_listener(seat_status_, &seat_status_listener_impl, this);
}
zriver_status_manager_v1_destroy(status_manager_);
status_manager_ = nullptr;
}
void Tags::handle_primary_clicked(uint32_t tag) {
if (!control_ || !seat_) return;
// Send river command to select tag on left mouse click
zriver_command_callback_v1* callback;
zriver_control_v1_add_argument(control_, "set-focused-tags");
@@ -230,6 +235,7 @@ void Tags::handle_primary_clicked(uint32_t tag) {
}
bool Tags::handle_button_press(GdkEventButton* event_button, uint32_t tag) {
if (!control_ || !seat_) return true;
if (event_button->type == GDK_BUTTON_PRESS && event_button->button == 3) {
// Send river command to toggle tag on right mouse click
zriver_command_callback_v1* callback;
+13 -1
View File
@@ -143,12 +143,24 @@ void Host::proxyReady(GObject* src, GAsyncResult* res, gpointer data) {
// Store the timeout connection so it is disconnected in ~Host, avoiding a
// use-after-free if the Host is destroyed before the retry fires.
host->retry_connection_ = Glib::signal_timeout().connect(
[host]() {
[host]() -> bool {
if (host->watcher_ != nullptr) {
return false;
}
try {
auto conn = Gio::DBus::Connection::get_sync(Gio::DBus::BusType::BUS_TYPE_SESSION);
host->nameAppeared(conn, "org.kde.StatusNotifierWatcher", "");
} catch (const Glib::Error& e) {
spdlog::error("Host: retry get_sync failed: {}", static_cast<std::string>(e.what()));
if (host->retry_count_ < MAX_RETRIES) {
host->retry_count_ += 1;
return true; // re-arm this timer; never let the exception escape
}
spdlog::warn("Host: giving up on watcher proxy creation after {} retries",
host->retry_count_);
} catch (const std::exception& e) {
spdlog::error("Host: retry failed: {}", e.what());
}
return false;
},
RETRY_DELAY_MS);
+5 -2
View File
@@ -70,11 +70,14 @@ void Tray::onAdd(std::unique_ptr<Item>& item) {
}
items_.push_back(item.get());
item->event_box.signal_show().connect([this] { dp.emit(); });
item->event_box.signal_hide().connect([this] { dp.emit(); });
// After this point `item` may be erased/invalidated by the ignore-list check;
// do not touch it again below.
spdlog::debug("Tray::onAdd deferred check - checking ignore list");
host_.checkIgnoreList(ignore_list_, std::bind(&Tray::onRemove, this, std::placeholders::_1));
item->event_box.signal_show().connect([this] { dp.emit(); });
item->event_box.signal_hide().connect([this] { dp.emit(); });
dp.emit();
}
+4
View File
@@ -168,6 +168,7 @@ auto IPC::start() -> void {
send("window-rules/get-focused-output", {});
std::thread([self = shared_from_this()] {
try {
auto sock = connect();
{
@@ -188,6 +189,9 @@ auto IPC::start() -> void {
spdlog::debug("Wayfire IPC: received event \"{}\"", ev);
self->root_event_handler(ev, json);
}
} catch (const std::exception& e) {
spdlog::error("Wayfire IPC event thread stopped: {}", e.what());
}
}).detach();
}
+16 -5
View File
@@ -118,18 +118,26 @@ void waybar::modules::Wireplumber::updateNodeName(waybar::modules::Wireplumber*
// find form-factor
const auto* devid = wp_properties_get(properties, "device.id");
if (devid != nullptr) {
spdlog::debug("[{}]: '{}' device.id is {}", self->name_, self->type_, devid);
auto* dev = static_cast<WpDevice*>(wp_object_manager_lookup(
self->om_, WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=s", devid, nullptr));
g_autoptr(WpDevice) dev = static_cast<WpDevice*>(
wp_object_manager_lookup(self->om_, WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_G_PROPERTY,
"bound-id", "=s", devid, nullptr));
if (const auto* ff =
wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(dev), "device.form-factor")) {
const gchar* ff = dev != nullptr ? wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(dev),
"device.form-factor")
: nullptr;
if (ff != nullptr) {
self->form_factor_ = ff;
spdlog::debug("[{}]: Updating node form factor to: {}", self->name_, self->form_factor_);
} else {
self->form_factor_ = "";
}
} else {
self->form_factor_ = "";
}
}
void waybar::modules::Wireplumber::updateSourceName(waybar::modules::Wireplumber* self,
@@ -614,7 +622,10 @@ bool waybar::modules::Wireplumber::handleScroll(GdkEventScroll* e) {
step = config_["scroll-step"].asDouble();
}
if (config_["max-volume"].isDouble()) {
maxVolume = config_["max-volume"].asDouble();
// {volume} is displayed as cubic-percent (pow(volume_, 3) * 100), while volume_/newVol are
// linear gains. Map the documented cubic-percent ceiling into the linear domain the clamp
// operates in, restoring the 0.15.0 cap semantics (e.g. 130 -> cbrt(1.3) linear -> 130%).
maxVolume = cbrt(config_["max-volume"].asDouble() / 100.0);
}
double vol = volume_;
+1 -1
View File
@@ -627,7 +627,7 @@ void Task::update() {
if (markup)
button.set_tooltip_markup(txt);
else
button.set_tooltip_markup(txt);
button.set_tooltip_text(txt);
}
}
+6 -3
View File
@@ -3,6 +3,8 @@
#include <iomanip>
#include <regex>
#include <sstream>
#include <stdexcept>
#include <system_error>
namespace fs = std::filesystem;
struct TransformResult {
@@ -12,11 +14,12 @@ struct TransformResult {
TransformResult transform_8bit_to_hex(const std::string& file_path) {
std::ifstream f(file_path, std::ios::in | std::ios::binary);
const auto size = fs::file_size(file_path);
std::string result(size, '\0');
if (!f.is_open() || !f.good()) {
std::error_code ec;
const auto size = fs::file_size(file_path, ec);
if (ec || !f.is_open() || !f.good()) {
throw std::runtime_error("Cannot open file: " + file_path);
}
std::string result(size, '\0');
if (size == 0) {
return {.css = result, .was_transformed = false};