diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5c57d93a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,187 @@ +# Contributing to Waybar + +Thanks for helping improve Waybar! This guide covers the essentials. + +## Building for development + +```bash +meson setup build +ninja -C build +./build/waybar # run your build directly +``` + +Enable all optional modules while developing: + +```bash +meson setup build -Dexperimental=true +``` + +## Code style + +Waybar follows [Google's C++ style guide](https://google.github.io/styleguide/cppguide.html). +Format your changes before committing: + +```bash +clang-format -i +``` + +CI runs `clang-format` and a full build on Linux and FreeBSD — please make sure +both pass. + +## Documentation + +Module documentation lives in [`man/`](man) as scdoc man pages, **not** in the +wiki. Editing a man page and merging to `master` regenerates the matching wiki +page automatically (see [`.github/wiki`](.github/wiki)). When you add a module, +add its man page and a line in [`.github/wiki/mapping.json`](.github/wiki/mapping.json). + +## Pull requests + +- Branch from `master` and keep each PR focused on one change. +- Describe what changed and why; link any related issues. +- Add or update the man page for every user-facing option you introduce. +- Build and test against the module(s) you touched. + +Have fun :) + +--- + +# Coding Conventions + +## 1. Language & Build +- **Standard**: C++20. +- **Build system**: Meson (`meson.build`). Project version is defined there. +- **Compiler flags**: Added via `add_project_arguments()` in Meson. Feature flags use `HAVE_*` / `WANT_*` prefixes (e.g. `-DHAVE_NIRI`, `-DHAVE_HYPRLAND`, `-DHAVE_LIBUDEV`). + +## 2. Formatting +- **Tool**: `.clang-format` is checked in. **Never bypass it.** +- **Style**: Google base style. +- **Indent**: 2 spaces. No tabs. +- **Column limit**: 100. +- **Braces**: K&R (opening brace on the same line). +- **Declaration alignment**: Disabled (`AlignConsecutiveDeclarations: false`). +- **Pointer/reference alignment**: Left (`const Json::Value& config`, `int* ptr`, not `int *ptr`). + +## 3. Naming + +### Files +- Match the primary exported class exactly: `AAppIconLabel.hpp`, `workspaces.cpp`, `backlight_backend.hpp`. +- Corresponding header and source should live in predictable paths: + - `include/.hpp` + - `src/.cpp` + +### Types +- **Classes / Structs**: `PascalCase`. + - Abstract base classes are prefixed with `A` (e.g., `AModule`, `ALabel`, `AIconLabel`, `AAppIconLabel`). +- **Enums / Enum classes**: `PascalCase` name. + - Enumerators: `UPPER_SNAKE_CASE` (e.g., `SCROLL_DIR::NONE`, `KillSignalAction::RELOAD`, `ChangeType::Increase`). +- **Concepts / Type aliases**: `PascalCase`. + +### Variables +- **Member variables**: `snake_case_` with a **trailing underscore**. + - Examples: `config_`, `bar_`, `label_`, `app_icon_size_`, `distance_scrolled_y_`, `on_updated_cb_`. +- **Function parameters & locals**: `snake_case` (no trailing underscore). + - Examples: `workspace_data`, `should_refresh`, `app_identifier`, `preferred_device`. +- **Static / constexpr constants**: `UPPER_SNAKE_CASE` or descriptive `kPascalCase`. + - Examples: `MODULE_CLASS`, `EPOLL_MAX_EVENTS`, `kExecFailureExitCode`. + +### Functions & Methods +- **Free functions**: `snake_case`. + - Examples: `sanitize_string()`, `rewrite_string()`, `get_total_memory()`, `best_device()`. +- **Class methods**: `lowerCamelCase`. + - Examples: `update()`, `tooltipEnabled()`, `handleScroll()`, `getScrollDir()`, `resolveFormat()`, `setBrightness()`. +- **Virtual overrides**: Mark with `override` (and `final` where applicable). Header signatures often use a trailing return type: + ```cpp + auto update() -> void override; + auto refresh(int should_refresh) -> void; + ``` + +### Namespaces +- All lowercase, nested by module path: + ```cpp + namespace waybar { } + namespace waybar::modules::niri { } + namespace waybar::util { } + ``` +- Close every namespace with a comment: + ```cpp + } // namespace waybar::modules::niri + ``` + +## 4. Includes & Headers +- Use `#pragma once` in all project headers. +- Include order in `.cpp` files: + 1. Corresponding header first. + 2. Blank line. + 3. External library headers (``, ``, ``, ``). + 4. Standard library headers (``, ``, ``). + 5. Blank line. + 6. Other project headers (`"util/..."`, `"modules/..."`). +- Do not use `using namespace` in headers. In `.cpp` files it is acceptable for narrow scopes (e.g., `using namespace std::literals::chrono_literals;`). +- Headers that expose standard-library types in their public interface (e.g. `std::chrono::milliseconds` as a return type or `std::vector` as a member) must `#include` the corresponding standard header directly. Do not rely on transitive includes from other headers. + +## 5. Class & Module Design + +### Base Class Patterns +- All UI modules ultimately derive from `AModule` (and often `ALabel` or `AIconLabel`). +- Accept configuration in constructors: + ```cpp + MyModule(const Json::Value& config, const std::string& name, const std::string& id, ...); + ``` + +### Signals & Threading +- Use `Glib::Dispatcher` (via `waybar::SafeSignal`) to marshal work to the GTK main thread. +- Use `sigc::signal` for normal GTK++ signals. +- If a scope must not be interrupted by `pthread_cancel`, guard it with `waybar::util::CancellationGuard`. + +### State / IPC +- Modules that talk to a compositor often implement a small `EventHandler` interface (`onEvent(...)`) and delegate to a singleton backend (e.g., `gIPC`). + +### RAII +- Prefer `std::unique_ptr` with custom deleters over raw `new/delete` for C-API resources (see `ScopedFd`, `UdevDeleter`, `UdevDeviceDeleter`, `ScopeGuard`). + +## 6. JSON Configuration +- Every module receives `const Json::Value& config` (usually as the first constructor argument). +- Always validate node type before reading: + ```cpp + if (config_["sort-by-id"].isBool()) { ... } + if (config.isMember("window-rewrite-default") && config["window-rewrite-default"].isString()) { ... } + ``` +- Use `waybar::util::JsonParser` if you need to pre-process JSON with non-standard escape sequences. + +## 7. String & UI Formatting +- Use `fmt::format` / `fmt::join` for all string composition. +- Use `fmt::dynamic_format_arg_store` when building arguments dynamically. +- Custom `fmt::formatter` specializations are allowed for domain types (e.g., `Glib::ustring`, project enums). +- Sanitize arbitrary text before inserting into Pango markup with `waybar::util::sanitize_string`. +- Use `waybar::util::rewriteString` for user-configurable regex rewrites. +- Truncate UTF-8 safely with `waybar::util::utf8_truncate` / `utf8_width`. + +## 8. Error Handling & Logging +- Use `spdlog` for all logging: + - `spdlog::error("Context: {}", e.what());` + - `spdlog::warn("Deprecated key '{}', prefer '{}'", old, replacement);` + - `spdlog::debug("State changed to {}", value);` +- Throw `std::runtime_error` (or similar) for fatal initialization failures that should bubble up to `main()`. + +## 9. GTK / Glib Patterns +- Prefer gtkmm-3.0 types (`Gtk::Button`, `Gtk::Label`, `Gdk::Pixbuf`, `Glib::RefPtr`, `Glib::ustring`) over raw C GTK APIs. +- Access the default icon theme through thread-safe wrappers if off the main thread (`DefaultGtkIconThemeWrapper`). +- Tooltips and labels should respect the module `tooltip` toggle (see `tooltipEnabled()` in `AModule`). + +## 10. Platform Portability +- Isolate platform-specific code in dedicated files (e.g., `linux.cpp`, `bsd.cpp`). +- Use preprocessor guards for platform differences (`#if defined(__FreeBSD__)`, `#if defined(HAVE_LIBNL)`). +- Keep the common interface in a shared header or base class. + +## 11. Thread Safety & Cross-Thread Communication +- GTK is strictly single-threaded. Never emit raw `sigc::signal` from background threads. +- Use `waybar::SafeSignal` to marshal events from worker threads to the GTK main loop. +- When a module manages background threads, use `std::mutex`, `std::recursive_mutex`, or atomic variables to protect shared state, and ensure the destructor joins or synchronizes with those threads before destroying resources. + +## 12. Unsafe Patterns to Avoid +- Do not use `strcpy`, `strcat`, or `sprintf` into fixed-size buffers (e.g. `char buf[PATH_MAX]`). Prefer `std::string`, `std::vector`, or `std::array` with bounds-safe operations. +- When passing a `std::vector` buffer to a C API that expects a mutable `char*` string, always ensure the buffer is null-terminated and clamp the written length to `size() - 1`. Never use `std::copy` from an unbounded source into a fixed-size buffer. + +## 13. Singleton Lifetime +- Singletons or objects with process-wide lifetime must not store references (`&`) or pointers to objects with shorter lifetime (e.g., configuration trees, GTK widgets, or bar instances) unless they are explicitly notified of destruction. Prefer storing configuration by value (`Json::Value`, `std::string`, etc.) if the singleton outlives the config loader. diff --git a/Dockerfiles/archlinux b/Dockerfiles/archlinux index d4274a46..8743a7c4 100644 --- a/Dockerfiles/archlinux +++ b/Dockerfiles/archlinux @@ -3,5 +3,5 @@ FROM archlinux:base-devel RUN pacman -Syu --noconfirm && \ - pacman -S --noconfirm git meson base-devel libinput wayland wayland-protocols glib2-devel pixman libxkbcommon mesa gtkmm3 jsoncpp pugixml scdoc libpulse libdbusmenu-gtk3 libmpdclient gobject-introspection libxkbcommon playerctl iniparser fftw && \ + pacman -S --noconfirm git meson base-devel libinput wayland wayland-protocols glib2-devel pixman libxkbcommon mesa gtkmm3 jsoncpp pugixml scdoc libpulse libdbusmenu-gtk3 libmpdclient gobject-introspection libxkbcommon playerctl iniparser fftw clang && \ sed -Ei 's/#(en_(US|GB)\.UTF)/\1/' /etc/locale.gen && locale-gen diff --git a/Makefile b/Makefile index 12af8e0e..54e72ce0 100644 --- a/Makefile +++ b/Makefile @@ -27,5 +27,10 @@ test-detailed: meson test -C build --verbose --print-errorlogs --test-args='--reporter console -s' .PHONY: test-detailed +format: + git diff --name-only --diff-filter=ACMR | \ + grep -E '\.(c|h|cpp|hpp)$$' | \ + xargs -r clang-format -i + clean: rm -rf build diff --git a/README.md b/README.md index d2e38182..dae91322 100644 --- a/README.md +++ b/README.md @@ -4,163 +4,143 @@ This is my fork of [Waybar](https://github.com/Alexays/Waybar). It has a few changes with the biggest being that it works with my fork or river (the Wayland compositor). In fact, it will probably **not** work with vanilla river. -# Waybar [![Licence](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Paypal Donate](https://img.shields.io/badge/Donate-Paypal-2244dd.svg)](https://paypal.me/ARouillard)
![Waybar](https://raw.githubusercontent.com/alexays/waybar/master/preview-2.png) +# Waybar -> Highly customizable Wayland bar for Sway and Wlroots based compositors.
-> Available in [all major distributions](https://github.com/Alexays/Waybar/wiki/Installation)
-> *Waybar [examples](https://github.com/Alexays/Waybar/wiki/Examples)* +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Donate](https://img.shields.io/badge/Donate-Paypal-2244dd.svg)](https://paypal.me/ARouillard) +[![CI](https://github.com/Alexays/Waybar/actions/workflows/linux.yml/badge.svg)](https://github.com/Alexays/Waybar/actions/workflows/linux.yml) +[![Release](https://img.shields.io/github/v/release/Alexays/Waybar)](https://github.com/Alexays/Waybar/releases) -#### Current features -- Sway (Workspaces, Binding mode, Focused window name) -- River (Mapping mode, Tags, Focused window name) -- Hyprland (Window Icons, Workspaces, Focused window name) -- Niri (Workspaces, Focused window name, Language) -- DWL (Tags, Focused window name) [requires dwl ipc patch](https://codeberg.org/dwl/dwl-patches/src/branch/main/patches/ipc) -- Tray [#21](https://github.com/Alexays/Waybar/issues/21) -- Local time -- Battery -- UPower -- Power profiles daemon -- Network -- Bluetooth -- Pulseaudio -- Privacy Info -- Wireplumber -- Disk -- Memory -- Cpu load average -- Temperature -- MPD -- Custom scripts -- Custom image -- Multiple output configuration -- And many more customizations +![Waybar](https://raw.githubusercontent.com/alexays/waybar/master/preview-2.png) -#### Configuration and Styling +> Highly customizable Wayland bar for Sway and wlroots-based compositors.
+> Available in [all major distributions](https://github.com/Alexays/Waybar/wiki/Installation). -[See the wiki for more details](https://github.com/Alexays/Waybar/wiki). +**[Installation](#installation) · [Wiki](https://github.com/Alexays/Waybar/wiki) · [Configuration](https://github.com/Alexays/Waybar/wiki/Configuration) · [Styling](https://github.com/Alexays/Waybar/wiki/Styling) · [Examples](https://github.com/Alexays/Waybar/wiki/Examples) · [FAQ](https://github.com/Alexays/Waybar/wiki/FAQ)** -### Installation +## Features -Waybar is available from a number of Linux distributions: +### Compositor integration + +| Compositor | Workspaces / Tags | Window | Layout | Language | Mode | +| --- | :---: | :---: | :---: | :---: | :---: | +| [Sway](https://github.com/Alexays/Waybar/wiki/Module:-Sway) | ✅ | ✅ | | ✅ | ✅ | +| [River](https://github.com/Alexays/Waybar/wiki/Module:-River) | ✅ | ✅ | ✅ | | ✅ | +| [Hyprland](https://github.com/Alexays/Waybar/wiki/Module:-Hyprland) | ✅ | ✅ | | ✅ | ✅ | +| [Niri](https://github.com/Alexays/Waybar/wiki/Module:-Niri) | ✅ | ✅ | | ✅ | | +| [Mango](https://github.com/Alexays/Waybar/wiki/Module:-Mango) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [DWL](https://github.com/Alexays/Waybar/wiki/Module:-Dwl) | ✅ | ✅ | | | | +| [Wayfire](https://github.com/Alexays/Waybar/wiki/Module:-Wayfire) | ✅ | ✅ | | | | + +> DWL requires the [dwl IPC patch](https://codeberg.org/dwl/dwl-patches/src/branch/main/patches/ipc). + +### Modules + +- **Power & hardware** — [Battery](https://github.com/Alexays/Waybar/wiki/Module:-Battery), [UPower](https://github.com/Alexays/Waybar/wiki/Module:-UPower), [Power profiles daemon](https://github.com/Alexays/Waybar/wiki/Module:-PowerProfilesDaemon), [Backlight](https://github.com/Alexays/Waybar/wiki/Module:-Backlight), [CPU](https://github.com/Alexays/Waybar/wiki/Module:-CPU), [Memory](https://github.com/Alexays/Waybar/wiki/Module:-Memory), [Disk](https://github.com/Alexays/Waybar/wiki/Module:-Disk), [Temperature](https://github.com/Alexays/Waybar/wiki/Module:-Temperature) +- **Connectivity** — [Network](https://github.com/Alexays/Waybar/wiki/Module:-Network), [Bluetooth](https://github.com/Alexays/Waybar/wiki/Module:-Bluetooth), [GPS](https://github.com/Alexays/Waybar/wiki/Module:-GPS), [WWAN](https://github.com/Alexays/Waybar/wiki/Module:-WWAN) +- **Audio & media** — [PulseAudio](https://github.com/Alexays/Waybar/wiki/Module:-PulseAudio), [WirePlumber](https://github.com/Alexays/Waybar/wiki/Module:-WirePlumber), [JACK](https://github.com/Alexays/Waybar/wiki/Module:-JACK), [sndio](https://github.com/Alexays/Waybar/wiki/Module:-Sndio), [Cava](https://github.com/Alexays/Waybar/wiki/Module:-Cava), [MPD](https://github.com/Alexays/Waybar/wiki/Module:-MPD), [MPRIS](https://github.com/Alexays/Waybar/wiki/Module:-MPRIS) +- **Desktop** — [Clock & calendar](https://github.com/Alexays/Waybar/wiki/Module:-Clock), [System tray](https://github.com/Alexays/Waybar/wiki/Module:-Tray), [Idle inhibitor](https://github.com/Alexays/Waybar/wiki/Module:-Idle-Inhibitor), [Keyboard state](https://github.com/Alexays/Waybar/wiki/Module:-Keyboard-State), [Privacy](https://github.com/Alexays/Waybar/wiki/Module:-Privacy), [Gamemode](https://github.com/Alexays/Waybar/wiki/Module:-Gamemode), [Systemd failed units](https://github.com/Alexays/Waybar/wiki/Module:-Systemd-failed-units), [Image](https://github.com/Alexays/Waybar/wiki/Module:-Image), [Custom scripts](https://github.com/Alexays/Waybar/wiki/Module:-Custom) + +…and more. Every module is documented on the [wiki](https://github.com/Alexays/Waybar/wiki) (see the *Modules* sidebar). + +## Getting started + +```bash +git clone https://github.com/Alexays/Waybar +cd Waybar +meson setup build +ninja -C build +./build/waybar # run without installing +``` + +Waybar launches with a sensible [default config](resources/config.jsonc). To make +it yours, copy the default config and stylesheet into `~/.config/waybar/` and edit +them. The [Configuration](https://github.com/Alexays/Waybar/wiki/Configuration) +and [Styling](https://github.com/Alexays/Waybar/wiki/Styling) guides cover every +option, and [Examples](https://github.com/Alexays/Waybar/wiki/Examples) has +ready-to-use community setups. + +## Installation + +Waybar is packaged by most distributions: [![Packaging status](https://repology.org/badge/vertical-allrepos/waybar.svg?columns=3&header=Waybar%20Downstream%20Packaging)](https://repology.org/project/waybar/versions) -An Ubuntu PPA with more recent versions is available -[here](https://launchpad.net/~nschloe/+archive/ubuntu/waybar). +An Ubuntu PPA with more recent versions is available [here](https://launchpad.net/~nschloe/+archive/ubuntu/waybar). - -#### Building from source +### Building from source ```bash -$ git clone https://github.com/Alexays/Waybar -$ cd Waybar -$ meson setup build -$ ninja -C build -$ ./build/waybar -# If you want to install it -$ ninja -C build install -$ waybar +git clone https://github.com/Alexays/Waybar +cd Waybar +meson setup build +ninja -C build +ninja -C build install # optional ``` -**Dependencies** +
+Runtime dependencies ``` -gtkmm3 -jsoncpp -libsigc++ -fmt -wayland -chrono-date -spdlog -libgtk-3-dev [gtk-layer-shell] -gobject-introspection [gtk-layer-shell] -libgirepository1.0-dev [gtk-layer-shell] -libpulse [Pulseaudio module] -libnl [Network module] +gtkmm3 jsoncpp libsigc++ fmt wayland +chrono-date spdlog xkbregistry libgtk-3-dev upower + +libpulse [Pulseaudio module] +libnl [Network module] libappindicator-gtk3 [Tray module] -libdbusmenu-gtk3 [Tray module] -libmpdclient [MPD module] -libsndio [sndio module] -libevdev [KeyboardState module] -xkbregistry -upower [UPower battery module] +libdbusmenu-gtk3 [Tray module] +libmpdclient [MPD module] +libsndio [sndio module] +libevdev [KeyboardState module] ``` +
-**Build dependencies** +
+Build dependencies ``` -cmake -meson -scdoc -wayland-protocols +cmake meson scdoc wayland-protocols ``` +
-On Ubuntu, you can install all the relevant dependencies using this command (tested with 19.10 and 20.04): +
+Install dependencies — Ubuntu -``` +```bash sudo apt install \ - clang-tidy \ - gobject-introspection \ - libdbusmenu-gtk3-dev \ - libevdev-dev \ - libfmt-dev \ - libgirepository1.0-dev \ - libgtk-3-dev \ - libgtkmm-3.0-dev \ - libinput-dev \ - libjsoncpp-dev \ - libmpdclient-dev \ - libnl-3-dev \ - libnl-genl-3-dev \ - libpulse-dev \ - libsigc++-2.0-dev \ - libspdlog-dev \ - libwayland-dev \ - scdoc \ - upower \ + clang-tidy gobject-introspection libdbusmenu-gtk3-dev libevdev-dev \ + libfmt-dev libgirepository1.0-dev libgtk-3-dev libgtkmm-3.0-dev \ + libinput-dev libjsoncpp-dev libmpdclient-dev libnl-3-dev libnl-genl-3-dev \ + libpulse-dev libsigc++-2.0-dev libspdlog-dev libwayland-dev scdoc upower \ libxkbregistry-dev ``` +
-On Arch, you can use this command: +
+Install dependencies — Arch -``` +```bash pacman -S --asdeps \ - gtkmm3 \ - jsoncpp \ - libsigc++ \ - fmt \ - wayland \ - chrono-date \ - spdlog \ - gtk3 \ - gobject-introspection \ - libgirepository \ - libpulse \ - libnl \ - libappindicator-gtk3 \ - libdbusmenu-gtk3 \ - libmpdclient \ - sndio \ - libevdev \ - libxkbcommon \ - upower \ - meson \ - cmake \ - scdoc \ - wayland-protocols \ - glib2-devel + gtkmm3 jsoncpp libsigc++ fmt wayland chrono-date spdlog gtk3 \ + gobject-introspection libgirepository libpulse libnl libappindicator-gtk3 \ + libdbusmenu-gtk3 libmpdclient sndio libevdev libxkbcommon upower meson \ + cmake scdoc wayland-protocols glib2-devel ``` +
+## Contributing -Contributions welcome!
-Have fun :)
-The style guidelines are [Google's](https://google.github.io/styleguide/cppguide.html) +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). The style +guidelines are [Google's C++ style](https://google.github.io/styleguide/cppguide.html). + +> **Docs live in the man pages.** Module documentation is written in +> [`man/`](man) (scdoc) and auto-synced to the wiki. Edit the man page, not the +> wiki — see [`.github/wiki`](.github/wiki). > [!CAUTION] -> Distributions of Waybar are only released on the [official GitHub page](https://github.com/Alexays/Waybar).
-> Waybar does **not** have an official website. Do not trust any sites that claim to be official. +> Distributions of Waybar are only released on the [official GitHub page](https://github.com/Alexays/Waybar).
+> Waybar does **not** have an official website. Do not trust any site claiming to be official. ## License -Waybar is licensed under the MIT license. [See LICENSE for more information](https://github.com/Alexays/Waybar/blob/master/LICENSE). +Waybar is licensed under the MIT license. [See LICENSE for details](https://github.com/Alexays/Waybar/blob/master/LICENSE). diff --git a/include/AGraph.hpp b/include/AGraph.hpp new file mode 100644 index 00000000..e65cd450 --- /dev/null +++ b/include/AGraph.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "AModule.hpp" + +namespace waybar { + +enum class GraphType { LINE, BAR, GAUGE }; + +class AGraph : public AModule { + public: + AGraph(const Json::Value&, const std::string&, const std::string&, uint16_t interval = 0, + bool enable_click = false, bool enable_scroll = false); + virtual ~AGraph() = default; + auto update() -> void override; + + protected: + Gtk::DrawingArea graph_; + std::deque values_; + uint16_t datapoints_ = 20; + GraphType graph_type_ = GraphType::LINE; + + void addValue(const int n); + + const std::chrono::milliseconds interval_; + + bool onDraw(const Cairo::RefPtr& cr); + + std::map submenus_; + std::map menuActionsMap_; + static void handleGtkMenuEvent(GtkMenuItem* menuitem, gpointer data); + + private: + void drawFilledArea(const Cairo::RefPtr& cr, + const std::vector>& points, double height, + const Gdk::RGBA& bg_color); + + void drawLine(const Cairo::RefPtr& cr, + const std::vector>& points, const Gdk::RGBA& fg_color); + + void drawPath(const Cairo::RefPtr& cr, + const std::vector>& points); + + void drawBars(const Cairo::RefPtr& cr, double width, double height, + int current_value, const Gdk::RGBA& fg_color); + + void drawGauge(const Cairo::RefPtr& cr, double width, double height, + int current_value, const Gdk::RGBA& fg_color); +}; + +} // namespace waybar diff --git a/include/AIconLabel.hpp b/include/AIconLabel.hpp index f0fb8614..80aa431a 100644 --- a/include/AIconLabel.hpp +++ b/include/AIconLabel.hpp @@ -14,10 +14,14 @@ class AIconLabel : public ALabel { bool enable_click = false, bool enable_scroll = false); virtual ~AIconLabel() = default; auto update() -> void override; + static std::tuple extractIcon(const std::string& input); protected: Gtk::Image image_; Gtk::Box box_; + unsigned app_icon_size_{24}; + + bool label_contains_icon{false}; bool iconEnabled() const; }; diff --git a/include/ALabel.hpp b/include/ALabel.hpp index b3852877..948289ae 100644 --- a/include/ALabel.hpp +++ b/include/ALabel.hpp @@ -1,9 +1,16 @@ #pragma once +#include +#include #include #include +#include #include +#include +#include +#include + #include "AModule.hpp" namespace waybar { @@ -25,12 +32,68 @@ class ALabel : public AModule { bool alt_ = false; std::string default_format_; + bool setLabelMarkup(const Glib::ustring& markup); + bool setTooltipMarkup(const Glib::ustring& markup); + + // resolveTooltipFormat() / resolveFormat() are inherited from AModule. + + // Combined label + tooltip helper. Builds a single fmt argument store from + // `args`, renders `labelFormat` into the label and the resolved tooltip format + // into the tooltip, both through the dedup-aware setters. Honors the `tooltip` + // toggle. This replaces the label/tooltip formatting boilerplate that modules + // used to duplicate. `state` selects `tooltip-format-` when non-empty. + template + void updateLabelAndTooltipForState(const std::string& state, const std::string& labelFormat, + const std::string& tooltipDefault, Args&&... args) { + fmt::dynamic_format_arg_store store; + (store.push_back(std::forward(args)), ...); + setLabelMarkup(fmt::vformat(labelFormat, store)); + if (tooltipEnabled()) { + setTooltipMarkup(fmt::vformat(resolveTooltipFormat(tooltipDefault, state), store)); + } + } + + template + void updateLabelAndTooltip(const std::string& labelFormat, const std::string& tooltipDefault, + Args&&... args) { + updateLabelAndTooltipForState("", labelFormat, tooltipDefault, std::forward(args)...); + } + + // Overloads accepting a pre-built argument store, for modules that must + // assemble a dynamic set of format arguments (e.g. per-core CPU stats) that + // cannot be expressed through a fixed variadic call. + // A non-const reference is used so this overload is preferred over the + // variadic template above (which would otherwise bind the store as a single + // forwarded argument). + void updateLabelAndTooltipForState(const std::string& state, const std::string& labelFormat, + const std::string& tooltipDefault, + fmt::dynamic_format_arg_store& store) { + setLabelMarkup(fmt::vformat(labelFormat, store)); + if (tooltipEnabled()) { + setTooltipMarkup(fmt::vformat(resolveTooltipFormat(tooltipDefault, state), store)); + } + } + + void updateLabelAndTooltip(const std::string& labelFormat, const std::string& tooltipDefault, + fmt::dynamic_format_arg_store& store) { + updateLabelAndTooltipForState("", labelFormat, tooltipDefault, store); + } + bool handleToggle(GdkEventButton* const& e) override; + void copyToClipboard(const std::string&); virtual std::string getState(uint8_t value, bool lesser = false); std::map submenus_; std::map menuActionsMap_; static void handleGtkMenuEvent(GtkMenuItem* menuitem, gpointer data); + + private: + // Raw UTF-8 bytes, not Glib::ustring: ustring::operator== collates with + // g_utf8_collate(), which gives private-use codepoints (nerd-font icons) + // no collation weight, so two different icons compare equal. + std::optional last_label_markup_; + std::optional last_tooltip_markup_; + Glib::RefPtr active_tooltip_; }; } // namespace waybar diff --git a/include/AModule.hpp b/include/AModule.hpp index a338ffe3..3fea52f6 100644 --- a/include/AModule.hpp +++ b/include/AModule.hpp @@ -1,11 +1,16 @@ #pragma once +#include +#include #include #include #include #include #include +#include +#include + #include "IModule.hpp" namespace waybar { @@ -15,6 +20,7 @@ class AModule : public IModule { static constexpr const char* MODULE_CLASS = "module"; ~AModule() override; + sigc::signal signal_updated; auto update() -> void override; virtual auto refresh(int shouldRefresh) -> void {}; operator Gtk::Widget&() override; @@ -25,6 +31,10 @@ class AModule : public IModule { bool expandEnabled() const; + virtual void suspend() {}; + virtual void resume() {}; + bool shouldSuspend() const { return disable_on_sleep_; } + protected: // Don't need to make an object directly // Derived classes are able to use it @@ -36,11 +46,47 @@ class AModule : public IModule { SCROLL_DIR getScrollDir(GdkEventScroll* e); bool tooltipEnabled() const; + // --- Generic format/tooltip resolution (config-only, usable by any module, + // ALabel-derived or not). Prefers `-`, then ``, then default. + std::string resolveFormat(const std::string& defaultFormat, const std::string& state = "") const { + if (!state.empty() && config_["format-" + state].isString()) { + return config_["format-" + state].asString(); + } + if (config_["format"].isString()) { + return config_["format"].asString(); + } + return defaultFormat; + } + std::string resolveTooltipFormat(const std::string& defaultFormat, + const std::string& state = "") const { + if (!state.empty() && config_["tooltip-format-" + state].isString()) { + return config_["tooltip-format-" + state].asString(); + } + if (config_["tooltip-format"].isString()) { + return config_["tooltip-format"].asString(); + } + return defaultFormat; + } + + // Generic tooltip for any widget: honors the `tooltip` toggle and + // `tooltip-format`, formats with the given args and applies it. Lets modules + // that are not ALabel-derived (e.g. gamemode) reuse the shared logic. + template + void updateTooltip(Gtk::Widget& widget, const std::string& defaultFormat, Args&&... args) { + if (!tooltipEnabled()) { + return; + } + widget.set_tooltip_markup( + fmt::format(fmt::runtime(resolveTooltipFormat(defaultFormat)), std::forward(args)...)); + } + std::vector pid_children_; const std::string name_; const Json::Value& config_; 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); @@ -48,8 +94,17 @@ class AModule : public IModule { virtual bool handleMouseLeave(GdkEventCrossing* const& ev); virtual bool handleScroll(GdkEventScroll*); virtual bool handleRelease(GdkEventButton* const& ev); + + bool disable_on_sleep_{false}; GObject* menu_ = nullptr; + // Maps a configured event name (e.g. "on-click-middle") to a built-in module + // action name. Populated from the `actions` config section, and by modules + // that interpret on-click* config values as internal actions (e.g. + // wlr/taskbar). Entries here are dispatched through doAction() instead of + // being run as shell commands. + std::map eventActionMap_; + private: bool handleUserEvent(GdkEventButton* const& ev); const bool isTooltip; @@ -57,7 +112,7 @@ class AModule : public IModule { bool hasUserEvents_; gdouble distance_scrolled_y_; gdouble distance_scrolled_x_; - std::map eventActionMap_; + sigc::connection cursor_timeout_conn_; static const inline std::map, std::string> eventMap_{ {std::make_pair(1, GdkEventType::GDK_BUTTON_PRESS), "on-click"}, {std::make_pair(1, GdkEventType::GDK_BUTTON_RELEASE), "on-click-release"}, @@ -78,7 +133,8 @@ class AModule : public IModule { {std::make_pair(9, GdkEventType::GDK_BUTTON_PRESS), "on-click-forward"}, {std::make_pair(9, GdkEventType::GDK_BUTTON_RELEASE), "on-click-forward-release"}, {std::make_pair(9, GdkEventType::GDK_2BUTTON_PRESS), "on-double-click-forward"}, - {std::make_pair(9, GdkEventType::GDK_3BUTTON_PRESS), "on-triple-click-forward"}}; + {std::make_pair(9, GdkEventType::GDK_3BUTTON_PRESS), "on-triple-click-forward"}, + {std::make_pair(10, GdkEventType::GDK_BUTTON_PRESS), "on-click-copy"}}; }; } // namespace waybar diff --git a/include/bar.hpp b/include/bar.hpp index 54b332bf..2dc51379 100644 --- a/include/bar.hpp +++ b/include/bar.hpp @@ -24,6 +24,8 @@ struct waybar_output { Glib::RefPtr monitor; std::string name; std::string identifier; + int32_t width; + int32_t height; std::unique_ptr xdg_output = { nullptr, &zxdg_output_v1_destroy}; @@ -75,6 +77,8 @@ class Bar : public sigc::trackable { util::KillSignalAction getOnSigusr1Action(); util::KillSignalAction getOnSigusr2Action(); + void toggleSuspend(bool suspend); + struct waybar_output* output; Json::Value config; struct wl_surface* surface; @@ -99,6 +103,7 @@ class Bar : public sigc::trackable { void setMode(const bar_mode&); void setPassThrough(bool passthrough); void setPosition(Gtk::PositionType position); + void forceLayerCommit(); void onConfigure(GdkEventConfigure* ev); void configureGlobalOffset(int width, int height); void onOutputGeometryChanged(); @@ -126,6 +131,10 @@ class Bar : public sigc::trackable { waybar::util::KillSignalAction onSigusr1 = util::SIGNALACTION_DEFAULT_SIGUSR1; waybar::util::KillSignalAction onSigusr2 = util::SIGNALACTION_DEFAULT_SIGUSR2; + + /* Disconnected in ~Bar before the modules are destroyed (#5182). */ + sigc::connection map_conn_; + sigc::connection unmap_conn_; }; } // namespace waybar diff --git a/include/client.hpp b/include/client.hpp index e206dea2..67a93b86 100644 --- a/include/client.hpp +++ b/include/client.hpp @@ -14,6 +14,7 @@ struct zwp_idle_inhibitor_v1; struct zwp_idle_inhibit_manager_v1; +struct ext_idle_notifier_v1; namespace waybar { @@ -32,6 +33,7 @@ class Client { struct wl_registry* registry = nullptr; struct zxdg_output_manager_v1* xdg_output_manager = nullptr; struct zwp_idle_inhibit_manager_v1* idle_inhibit_manager = nullptr; + struct ext_idle_notifier_v1* idle_notifier = nullptr; std::vector> bars; Config config; std::string bar_id; @@ -48,7 +50,9 @@ class Client { static void handleGlobal(void* data, struct wl_registry* registry, uint32_t name, const char* interface, uint32_t version); static void handleGlobalRemove(void* data, struct wl_registry* registry, uint32_t name); + static void handleOutputLogicalSize(void*, struct zxdg_output_v1*, int32_t, int32_t); static void handleOutputDone(void*, struct zxdg_output_v1*); + void createBarsBatch(); static void handleOutputName(void*, struct zxdg_output_v1*, const char*); static void handleOutputDescription(void*, struct zxdg_output_v1*, const char*); void handleMonitorAdded(Glib::RefPtr monitor); @@ -65,6 +69,8 @@ class Client { std::map signal_toggle_state; sigc::connection monitor_added_connection_; sigc::connection monitor_removed_connection_; + std::list pending_outputs_; + bool bars_scheduled_ = false; }; } // namespace waybar diff --git a/include/config.hpp b/include/config.hpp index 1ef9ef5f..3b3b7243 100644 --- a/include/config.hpp +++ b/include/config.hpp @@ -29,7 +29,8 @@ class Config { Json::Value& getConfig() { return config_; } - std::vector getOutputConfigs(const std::string& name, const std::string& identifier); + std::vector getOutputConfigs(const std::string& name, const std::string& identifier, + int32_t width, int32_t height); private: void setupConfig(Json::Value& dst, const std::string& config_file, int depth); diff --git a/include/group.hpp b/include/group.hpp index dbc886a6..3e24f7e5 100644 --- a/include/group.hpp +++ b/include/group.hpp @@ -12,15 +12,17 @@ namespace waybar { class Group : public AModule { + sigc::connection reveal_timeout_; + public: Group(const std::string&, const std::string&, const Json::Value&, bool); - ~Group() override = default; + ~Group() override; auto update() -> void override; operator Gtk::Widget&() override; auto refresh(int sig) -> void override; virtual Gtk::Box& getBox(); - void addWidget(Gtk::Widget& widget); + void addWidget(AModule* module); protected: Gtk::Box box; @@ -30,6 +32,9 @@ class Group : public AModule { bool is_drawer = false; bool click_to_reveal = false; std::optional toggle_signal; + std::string always_visible_class; + bool empty_if_drawer_empty = false; + int reveal_delay = 0; std::string add_class_to_drawer_children; bool handleMouseEnter(GdkEventCrossing* const& ev) override; bool handleMouseLeave(GdkEventCrossing* const& ev) override; @@ -45,6 +50,10 @@ class Group : public AModule { hide_group(); } } + void manage_visibility(AModule* module); + void show_widget(Gtk::Widget& widget); + void hide_widget(Gtk::Widget& widget); + void hide_current_widget_if_inactive(); }; } // namespace waybar diff --git a/include/modules/battery.hpp b/include/modules/battery.hpp index 09c172b0..fb6d092f 100644 --- a/include/modules/battery.hpp +++ b/include/modules/battery.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -51,6 +52,11 @@ class Battery : public ALabel { bool warnFirstTime_{true}; bool weightedAverage_{true}; const Bar& bar_; + bool smoothPowerEnable_{false}; + double time_constant_s_{260.0}; + double smooth_power_{0.0}; // µW + std::chrono::steady_clock::time_point last_t_{std::chrono::steady_clock::now()}; + std::string old_status_raw_{""}; util::SleeperThread thread_; util::SleeperThread thread_battery_update_; diff --git a/include/modules/bluetooth.hpp b/include/modules/bluetooth.hpp index b89383a0..5e03b118 100644 --- a/include/modules/bluetooth.hpp +++ b/include/modules/bluetooth.hpp @@ -41,6 +41,7 @@ class Bluetooth : public ALabel { bool services_resolved; // NOTE: experimental feature in bluez std::optional battery_percentage; + std::optional battery_percentage_peripheral; }; public: @@ -59,6 +60,12 @@ class Bluetooth : public ALabel { gpointer) -> void; auto getDeviceBatteryPercentage(GDBusObject*) -> std::optional; + auto getDeviceGattBatteryLevels(GDBusObject*, std::optional&, + std::optional&) -> void; + static auto processBatteryServiceCharacteristics(GList*, const std::string&, const std::string&, + const std::string&, + std::optional&, + std::optional&) -> void; auto getDeviceProperties(GDBusObject*, DeviceInfo&) -> bool; auto getControllerProperties(GDBusObject*, ControllerInfo&) -> bool; diff --git a/include/modules/cava/cavaGLSL.hpp b/include/modules/cava/cavaGLSL.hpp index d2632aff..5372d054 100644 --- a/include/modules/cava/cavaGLSL.hpp +++ b/include/modules/cava/cavaGLSL.hpp @@ -1,36 +1,64 @@ #pragma once #include +#include +#include +#include +#include + +#include #include "AModule.hpp" #include "cava_backend.hpp" namespace waybar::modules::cava { -class CavaGLSL final : public AModule, public Gtk::GLArea { +class CavaGLSL final : public AModule { public: CavaGLSL(const std::string&, const Json::Value&); - ~CavaGLSL() = default; + ~CavaGLSL(); + auto doAction(const std::string& name) -> void override; private: + using Action = void (CavaGLSL::*)(); + + Gtk::GLArea gl_area_; std::shared_ptr backend_; - struct ::cava::config_params prm_; - int frame_counter{0}; + // Cached config params (deep-copied strings to avoid dangling char* on backend reload) + int sdl_width_{0}; + int sdl_height_{0}; + int bar_width_{0}; + int bar_spacing_{0}; + int gradient_count_{0}; + std::string vertex_shader_; + std::string fragment_shader_; + std::string bcolor_; + std::string color_; + std::array gradient_colors_; + int frame_counter_{0}; bool silence_{false}; bool hide_on_silence_{false}; + bool mapped_{false}; // Cava method - auto onUpdate(const ::cava::audio_raw& input) -> void; + void pauseResume(); + auto onUpdate(const CavaBackend::AudioRaw& input) -> void; auto onSilence() -> void; - // Member variable to store the shared pointer - std::shared_ptr<::cava::audio_raw> m_data_; - GLuint shaderProgram_; + auto onBackendConfigChanged() -> void; + void cacheConfigParams(const ::cava::config_params& src); + // Member variable to store audio data + CavaBackend::AudioRaw m_data_; + GLuint shaderProgram_{0}; // OpenGL variables - GLuint fbo_; - GLuint texture_; + GLuint fbo_{0}; + GLuint texture_{0}; + GLuint vbo_{0}; + GLuint ibo_{0}; + GLuint vao_{0}; GLint uniform_bars_; GLint uniform_previous_bars_; GLint uniform_bars_count_; GLint uniform_time_; + GLint uniform_input_texture_; // Methods void onRealize(); bool onRender(const Glib::RefPtr& context); @@ -39,5 +67,13 @@ class CavaGLSL final : public AModule, public Gtk::GLArea { void initSurface(); void initGLSL(); GLuint loadShader(const std::string& fileName, GLenum type); + void cleanupGL(); + + // ModuleActionMap + static const std::map actionMap_; + + sigc::connection audio_raw_update_conn_; + sigc::connection silence_conn_; + sigc::connection config_changed_conn_; }; } // namespace waybar::modules::cava diff --git a/include/modules/cava/cavaRaw.hpp b/include/modules/cava/cavaRaw.hpp index f4ceb603..76f88eff 100644 --- a/include/modules/cava/cavaRaw.hpp +++ b/include/modules/cava/cavaRaw.hpp @@ -1,30 +1,38 @@ #pragma once +#include +#include + +#include + #include "ALabel.hpp" #include "cava_backend.hpp" namespace waybar::modules::cava { -class Cava final : public ALabel, public sigc::trackable { +class CavaRaw final : public ALabel { public: - Cava(const std::string&, const Json::Value&); - ~Cava() = default; + CavaRaw(const std::string&, const Json::Value&); + ~CavaRaw(); auto doAction(const std::string& name) -> void override; private: + using Action = void (CavaRaw::*)(); + std::shared_ptr backend_; // Text to display - Glib::ustring label_text_{""}; + Glib::ustring label_text_; bool silence_{false}; bool hide_on_silence_{false}; - std::string format_silent_{""}; - int ascii_range_{0}; + std::string format_silent_; // Cava method - void pause_resume(); + void pauseResume(); auto onUpdate(const std::string& input) -> void; auto onSilence() -> void; // ModuleActionMap - static inline std::map - actionMap_{{"mode", &waybar::modules::cava::Cava::pause_resume}}; + static const std::map actionMap_; + + sigc::connection update_conn_; + sigc::connection silence_conn_; }; } // namespace waybar::modules::cava diff --git a/include/modules/cava/cava_backend.hpp b/include/modules/cava/cava_backend.hpp index 4022a2ff..bdbac4a1 100644 --- a/include/modules/cava/cava_backend.hpp +++ b/include/modules/cava/cava_backend.hpp @@ -1,8 +1,17 @@ #pragma once +#include +#include +#include +#include +#include +#include +#include + #include #include +#include "util/SafeSignal.hpp" #include "util/sleeper_thread.hpp" namespace cava { @@ -21,7 +30,6 @@ extern "C" { } // namespace cava namespace waybar::modules::cava { -using namespace std::literals::chrono_literals; class CavaBackend final { public: @@ -29,19 +37,38 @@ class CavaBackend final { virtual ~CavaBackend(); // Methods - int getAsciiRange(); + int getAsciiRange() const; void doPauseResume(); - void Update(); - const struct ::cava::config_params* getPrm(); - std::chrono::milliseconds getFrameTimeMilsec(); + void update(); + const ::cava::config_params& getPrm() const; + std::chrono::milliseconds getFrameTimeMilsec() const; + + struct AudioRaw { + std::vector bars_raw; + std::vector previous_bars_raw; + int number_of_bars = 0; + + AudioRaw() = default; + explicit AudioRaw(const ::cava::audio_raw& raw) { + number_of_bars = raw.number_of_bars; + if (raw.bars_raw != nullptr && number_of_bars > 0) { + bars_raw.assign(raw.bars_raw, raw.bars_raw + number_of_bars); + } + if (raw.previous_bars_raw != nullptr && number_of_bars > 0) { + previous_bars_raw.assign(raw.previous_bars_raw, raw.previous_bars_raw + number_of_bars); + } + } + }; // Signal accessor - using type_signal_update = sigc::signal; - type_signal_update signal_update(); - using type_signal_audio_raw_update = sigc::signal; - type_signal_audio_raw_update signal_audio_raw_update(); - using type_signal_silence = sigc::signal; - type_signal_silence signal_silence(); + using SignalUpdate = SafeSignal; + SignalUpdate& signalUpdate(); + using SignalAudioRawUpdate = SafeSignal; + SignalAudioRawUpdate& signalAudioRawUpdate(); + using SignalSilence = SafeSignal<>; + SignalSilence& signalSilence(); + using SignalConfigChanged = SafeSignal<>; + SignalConfigChanged& signalConfigChanged(); private: CavaBackend(const Json::Value& config); @@ -49,36 +76,78 @@ class CavaBackend final { util::SleeperThread out_thread_; // Cava API to read audio source - ::cava::ptr input_source_{NULL}; + ::cava::ptr input_source_{nullptr}; struct ::cava::error_s error_{}; // cava errors struct ::cava::config_params prm_{}; // cava parameters struct ::cava::audio_raw audio_raw_{}; // cava handled raw audio data(is based on audio_data) struct ::cava::audio_data audio_data_{}; // cava audio data - struct ::cava::cava_plan* plan_{NULL}; //{new cava_plan{}}; + struct ::cava::cava_plan* plan_{nullptr}; //{new cava_plan{}}; std::chrono::seconds fetch_input_delay_{4}; - // Delay to handle audio source - std::chrono::milliseconds frame_time_milsec_{1s}; - const Json::Value& config_; + struct AdaptiveDelay { + std::chrono::milliseconds delay; + std::chrono::seconds delta{0}; + + explicit AdaptiveDelay(std::chrono::milliseconds initial = std::chrono::seconds(1)) + : delay(initial) {} + + bool increase() { + if (delta == std::chrono::seconds{0}) { + delta += std::chrono::seconds{1}; + delay += delta; + return true; + } + return false; + } + + bool decrease() { + if (delta > std::chrono::seconds{0}) { + delay -= delta; + delta -= std::chrono::seconds{1}; + return true; + } + return false; + } + + std::chrono::milliseconds current() const { return delay; } + + void reset(std::chrono::milliseconds new_delay) { + delay = new_delay; + delta = std::chrono::seconds{0}; + } + }; + + AdaptiveDelay adaptive_delay_; + + Json::Value config_; int re_paint_{0}; bool silence_{false}; bool silence_prev_{false}; - std::chrono::seconds suspend_silence_delay_{0}; int sleep_counter_{0}; std::string output_{}; // Methods void invoke(); void execute(); - bool isSilence(); + bool isSilent(); void doUpdate(bool force = false); void loadConfig(); void freeBackend(); // Signal - type_signal_update m_signal_update_; - type_signal_audio_raw_update m_signal_audio_raw_; - type_signal_silence m_signal_silence_; + SignalUpdate m_signal_update_; + SignalAudioRawUpdate m_signal_audio_raw_; + SignalSilence m_signal_silence_; + SignalConfigChanged m_signal_config_changed_; + + std::atomic shutdown_{false}; + bool audio_raw_initialized_{false}; + mutable std::recursive_mutex state_mutex_; + + // Synchronization for joining read_thread_ during destruction + bool read_thread_exited_{false}; + mutable std::mutex read_thread_exit_mutex_; + std::condition_variable read_thread_exit_cv_; }; } // namespace waybar::modules::cava diff --git a/include/modules/cava/cava_frontend.hpp b/include/modules/cava/cava_frontend.hpp index 402a9bad..4eefb0fe 100644 --- a/include/modules/cava/cava_frontend.hpp +++ b/include/modules/cava/cava_frontend.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #ifdef HAVE_LIBCAVA #include "cavaRaw.hpp" #include "cava_backend.hpp" @@ -9,16 +11,16 @@ #endif namespace waybar::modules::cava { -AModule* getModule(const std::string& id, const Json::Value& config) { +inline std::unique_ptr getModule(const std::string& id, const Json::Value& config) { #ifdef HAVE_LIBCAVA const std::shared_ptr backend_{waybar::modules::cava::CavaBackend::inst(config)}; - switch (backend_->getPrm()->output) { + switch (backend_->getPrm().output) { #ifdef HAVE_LIBCAVAGLSL case ::cava::output_method::OUTPUT_SDL_GLSL: - return new waybar::modules::cava::CavaGLSL(id, config); + return std::make_unique(id, config); #endif default: - return new waybar::modules::cava::Cava(id, config); + return std::make_unique(id, config); } #else throw std::runtime_error("Unknown module"); diff --git a/include/modules/clock.hpp b/include/modules/clock.hpp index c3548063..aef3f52c 100644 --- a/include/modules/clock.hpp +++ b/include/modules/clock.hpp @@ -12,6 +12,7 @@ const std::string kOrdPlaceholder{"ordinal_date"}; enum class CldMode { MONTH, YEAR }; enum class WS { LEFT, RIGHT, HIDDEN }; +enum class WeekNumbering { LOCALE, ISO, MONDAY, SUNDAY }; class Clock final : public ALabel { public: @@ -51,6 +52,7 @@ class Clock final : public ALabel { date::day cldBaseDay_{0}; // calendar Cached day. Is used when today is changing(midnight) std::string cldText_{""}; // calendar text to print bool iso8601Calendar_{false}; // whether the calendar is in ISO8601 + WeekNumbering weekNumbering_{WeekNumbering::LOCALE}; // week number calculation method CldMode cldMode_{CldMode::MONTH}; auto get_calendar(const date::year_month_day& today, const date::year_month_day& ymd, const date::time_zone* tz) -> const std::string; @@ -80,6 +82,7 @@ class Clock final : public ALabel { void cldShift_reset(); void tz_up(); void tz_down(); + void action_exec(const std::string& action); // Module Action Map static inline std::map actionMap_{ {"mode", &waybar::modules::Clock::cldModeSwitch}, @@ -88,6 +91,9 @@ class Clock final : public ALabel { {"shift_reset", &waybar::modules::Clock::cldShift_reset}, {"tz_up", &waybar::modules::Clock::tz_up}, {"tz_down", &waybar::modules::Clock::tz_down}}; + static inline std::map + actionWithArgsMap_{{"exec", &waybar::modules::Clock::action_exec}}; }; } // namespace waybar::modules diff --git a/include/modules/cpu_graph.hpp b/include/modules/cpu_graph.hpp new file mode 100644 index 00000000..cf74a3fe --- /dev/null +++ b/include/modules/cpu_graph.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "AGraph.hpp" +#include "util/sleeper_thread.hpp" + +namespace waybar::modules { + +class CpuGraph : public AGraph { + public: + CpuGraph(const std::string&, const Json::Value&); + virtual ~CpuGraph() = default; + auto update() -> void override; + + private: + static constexpr const char* MODERATE_CLASS = "cpu-moderate"; + static constexpr const char* HIGH_CLASS = "cpu-high"; + static constexpr const char* INTENSIVE_CLASS = "cpu-intensive"; + + std::vector> prev_times_; + util::SleeperThread thread_; +}; + +} // namespace waybar::modules diff --git a/include/modules/custom.hpp b/include/modules/custom.hpp index a345a33b..dbdcb698 100644 --- a/include/modules/custom.hpp +++ b/include/modules/custom.hpp @@ -3,16 +3,18 @@ #include #include +#include #include -#include "ALabel.hpp" +#include "AIconLabel.hpp" #include "util/command.hpp" +#include "util/command_line_stream.hpp" #include "util/json.hpp" #include "util/sleeper_thread.hpp" namespace waybar::modules { -class Custom : public ALabel { +class Custom : public AIconLabel { public: Custom(const std::string&, const std::string&, const Json::Value&, const std::string&); virtual ~Custom(); @@ -22,6 +24,9 @@ class Custom : public ALabel { private: void delayWorker(); void continuousWorker(); + void startContinuousProcess(bool throw_on_failure); + void handleContinuousProcessExit(int exit_code); + void scheduleContinuousRestart(); void waitingWorker(); void parseOutputRaw(); void parseOutputJson(); @@ -36,13 +41,16 @@ class Custom : public ALabel { std::string alt_; std::string tooltip_; std::string last_tooltip_markup_; + std::string image_path_; + std::string image_name_; + unsigned app_icon_size_{24}; const bool tooltip_format_enabled_; std::vector class_; int percentage_; - FILE* fp_; - int pid_; util::command::res output_; util::JsonParser parser_; + std::unique_ptr continuous_stream_; + sigc::connection restart_connection_; util::SleeperThread thread_; }; diff --git a/include/modules/custom_graph.hpp b/include/modules/custom_graph.hpp new file mode 100644 index 00000000..a081f376 --- /dev/null +++ b/include/modules/custom_graph.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include + +#include "AGraph.hpp" +#include "util/command.hpp" +#include "util/json.hpp" +#include "util/sleeper_thread.hpp" + +namespace waybar::modules { + +class CustomGraph : public AGraph { + public: + CustomGraph(const std::string&, const std::string&, const Json::Value&, const std::string&); + virtual ~CustomGraph(); + auto update() -> void override; + void refresh(int /*signal*/) override; + + private: + void delayWorker(); + void continuousWorker(); + void waitingWorker(); + void parseOutputRaw(); + void parseOutputJson(); + void handleEvent(); + bool handleScroll(GdkEventScroll* e) override; + bool handleToggle(GdkEventButton* const& e) override; + + const std::string name_; + const std::string output_name_; + std::string text_; + std::string id_; + std::string alt_; + std::string tooltip_; + const bool tooltip_format_enabled_; + std::vector class_; + int percentage_; + FILE* fp_; + int pid_; + util::command::res output_; + util::JsonParser parser_; + + util::SleeperThread thread_; +}; + +} // namespace waybar::modules diff --git a/include/modules/disk.hpp b/include/modules/disk.hpp index b576e19f..1ebd1907 100644 --- a/include/modules/disk.hpp +++ b/include/modules/disk.hpp @@ -4,6 +4,7 @@ #include #include +#include #include "ALabel.hpp" #include "util/format.hpp" @@ -19,7 +20,9 @@ class Disk : public ALabel { private: util::SleeperThread thread_; - std::string path_; + std::string header_; + std::vector paths_; + std::string separator_; std::string unit_; float calc_specific_divisor(const std::string& divisor); diff --git a/include/modules/dwl/tags.hpp b/include/modules/dwl/tags.hpp index 856a35dd..4fee30fb 100644 --- a/include/modules/dwl/tags.hpp +++ b/include/modules/dwl/tags.hpp @@ -21,6 +21,8 @@ class Tags : public waybar::AModule { void handle_primary_clicked(uint32_t tag); bool handle_button_press(GdkEventButton* event_button, uint32_t tag); + void handle_active_output(zdwl_ipc_output_v2* zdwl_output_v2, uint32_t active); + struct zdwl_ipc_manager_v2* status_manager_; struct wl_seat* seat_; @@ -28,6 +30,7 @@ class Tags : public waybar::AModule { const waybar::Bar& bar_; Gtk::Box box_; std::vector buttons_; + bool hide_vacant_; struct zdwl_ipc_output_v2* output_status_; }; diff --git a/include/modules/dwl/window.hpp b/include/modules/dwl/window.hpp index 5efd0abb..e731e8e4 100644 --- a/include/modules/dwl/window.hpp +++ b/include/modules/dwl/window.hpp @@ -19,6 +19,7 @@ class Window : public AAppIconLabel, public sigc::trackable { void handle_layout(const uint32_t layout); void handle_title(const char* title); void handle_appid(const char* ppid); + void handle_active(const uint32_t active); void handle_layout_symbol(const char* layout_symbol); void handle_frame(); @@ -30,6 +31,9 @@ class Window : public AAppIconLabel, public sigc::trackable { std::string title_; std::string appid_; std::string layout_symbol_; + bool active_; + bool hide_inactive_; + bool hide_empty_; uint32_t layout_; struct zdwl_ipc_output_v2* output_status_; diff --git a/include/modules/hyprland/language.hpp b/include/modules/hyprland/language.hpp index ec59e5c3..3cb8e31d 100644 --- a/include/modules/hyprland/language.hpp +++ b/include/modules/hyprland/language.hpp @@ -37,6 +37,7 @@ class Language : public waybar::ALabel, public EventHandler { util::JsonParser parser_; Layout layout_; + std::string prev_short_name_; // applied CSS class; touched only in update() (#4665) IPC& m_ipc; }; diff --git a/include/modules/hyprland/submap.hpp b/include/modules/hyprland/submap.hpp index 0dcb5d5a..97a20c7c 100644 --- a/include/modules/hyprland/submap.hpp +++ b/include/modules/hyprland/submap.hpp @@ -26,9 +26,11 @@ class Submap : public waybar::ALabel, public EventHandler { const Bar& bar_; util::JsonParser parser_; std::string submap_; + std::string icon_; std::string prev_submap_; bool always_on_ = false; std::string default_submap_ = "Default"; + std::unordered_map icons_; IPC& m_ipc; }; diff --git a/include/modules/hyprland/workspace.hpp b/include/modules/hyprland/workspace.hpp index 1d80d331..b8cbc889 100644 --- a/include/modules/hyprland/workspace.hpp +++ b/include/modules/hyprland/workspace.hpp @@ -30,7 +30,8 @@ class Workspace { public: explicit Workspace(const Json::Value& workspace_data, Workspaces& workspace_manager, const Json::Value& clients_data = Json::Value::nullRef); - std::string& selectIcon(std::map& icons_map); + ~Workspace(); + std::string& selectString(std::map& string_map); Gtk::Button& button() { return m_button; }; int id() const { return m_id; }; @@ -45,12 +46,22 @@ class Workspace { bool isUrgent() const { return m_isUrgent; }; bool handleClicked(GdkEventButton* bt) const; + + bool handleEnter(GdkEventCrossing* event); + bool handleLeave(GdkEventCrossing* event); + + void startHoverCheck(); + void stopHoverCheck(); + bool syncHoverClass(); + bool pointerInsideButton(); + void setActive(bool value = true) { m_isActive = value; }; void setPersistentRule(bool value = true) { m_isPersistentRule = value; }; void setPersistentConfig(bool value = true) { m_isPersistentConfig = value; }; void setUrgent(bool value = true) { m_isUrgent = value; }; void setVisible(bool value = true) { m_isVisible = value; }; void setWindows(uint value) { m_windows = value; }; + void setId(int value) { m_id = value; }; void setName(std::string const& value) { m_name = value; }; void setOutput(std::string const& value) { m_output = value; }; bool containsWindow(WindowAddress const& addr) const { @@ -64,13 +75,14 @@ class Workspace { bool onWindowOpened(WindowCreationPayload const& create_window_payload); std::optional closeWindow(WindowAddress const& addr); - void update(const std::string& workspace_icon); + void update(const std::string& workspace_icon, const std::string& workspace_tooltip); private: Workspaces& m_workspaceManager; int m_id; std::string m_name; + std::string m_prevNameClass; std::string m_output; uint m_windows; bool m_isActive = false; @@ -80,6 +92,8 @@ class Workspace { bool m_isUrgent = false; bool m_isVisible = false; + sigc::connection m_hoverCheckConnection; + std::vector m_windowMap; Gtk::Button m_button; diff --git a/include/modules/hyprland/workspaces.hpp b/include/modules/hyprland/workspaces.hpp index 03548ccb..f71a6e42 100644 --- a/include/modules/hyprland/workspaces.hpp +++ b/include/modules/hyprland/workspaces.hpp @@ -39,9 +39,11 @@ class Workspaces : public AModule, public EventHandler { auto allOutputs() const -> bool { return m_allOutputs; } auto showSpecial() const -> bool { return m_showSpecial; } auto activeOnly() const -> bool { return m_activeOnly; } + auto hideActive() const -> bool { return m_hideActive; } auto specialVisibleOnly() const -> bool { return m_specialVisibleOnly; } auto persistentOnly() const -> bool { return m_persistentOnly; } auto moveToMonitor() const -> bool { return m_moveToMonitor; } + auto uniqueIcons() const -> bool { return m_uniqueIcons; } auto enableTaskbar() const -> bool { return m_enableTaskbar; } auto taskbarWithIcon() const -> bool { return m_taskbarWithIcon; } auto barScroll() const -> bool { return m_barScroll; } @@ -52,16 +54,20 @@ class Workspaces : public AModule, public EventHandler { auto taskbarFormatBefore() const -> std::string { return m_taskbarFormatBefore; } auto taskbarFormatAfter() const -> std::string { return m_taskbarFormatAfter; } auto taskbarIconSize() const -> int { return m_taskbarIconSize; } + auto taskbarMaxIcons() const -> int { return m_taskbarMaxIcons; } auto taskbarOrientation() const -> Gtk::Orientation { return m_taskbarOrientation; } auto taskbarReverseDirection() const -> bool { return m_taskbarReverseDirection; } auto onClickWindow() const -> std::string { return m_onClickWindow; } auto getIgnoredWindows() const -> std::vector { return m_ignoreWindows; } + auto maxWindows() const -> int { return m_maxWindows; } enum class ActiveWindowPosition { NONE, FIRST, LAST }; auto activeWindowPosition() const -> ActiveWindowPosition { return m_activeWindowPosition; } std::string getRewrite(const std::string& window_class, const std::string& window_title); std::string& getWindowSeparator() { return m_formatWindowSeparator; } + auto windowRewriteGroupThreshold() const -> int { return m_windowRewriteGroupThreshold; } + auto const& getWindowRewriteGroupFormat() const { return m_windowRewriteGroupFormat; } bool isWorkspaceIgnored(std::string const& workspace_name); bool windowRewriteConfigUsesTitle() const { return m_anyWindowRewriteRuleUsesTitle; } @@ -89,6 +95,7 @@ class Workspaces : public AModule, public EventHandler { auto populateIgnoreWorkspacesConfig(const Json::Value& config) -> void; auto populateFormatWindowSeparatorConfig(const Json::Value& config) -> void; auto populateWindowRewriteConfig(const Json::Value& config) -> void; + auto populateMaxWindowsConfig(const Json::Value& config) -> void; auto populateWorkspaceTaskbarConfig(const Json::Value& config) -> void; void registerIpc(); @@ -101,6 +108,7 @@ class Workspaces : public AModule, public EventHandler { Json::Value const& clientsData = Json::Value::nullRef); void onWorkspaceMoved(std::string const& payload); void onWorkspaceRenamed(std::string const& payload); + void onWorkspaceIdChanged(std::string const& payload); static std::optional parseWorkspaceId(std::string const& workspaceIdStr); // monitor events @@ -146,9 +154,11 @@ class Workspaces : public AModule, public EventHandler { bool m_allOutputs = false; bool m_showSpecial = false; bool m_activeOnly = false; + bool m_hideActive = false; bool m_specialVisibleOnly = false; bool m_persistentOnly = false; bool m_moveToMonitor = false; + bool m_uniqueIcons = false; bool m_barScroll = false; Json::Value m_persistentWorkspaceConfig; @@ -158,21 +168,25 @@ class Workspaces : public AModule, public EventHandler { std::map> m_orphanWindowMap; enum class SortMethod { ID, NAME, NUMBER, SPECIAL_CENTERED, DEFAULT }; - util::EnumParser m_enumParser; SortMethod m_sortBy = SortMethod::DEFAULT; - std::map m_sortMap = {{"ID", SortMethod::ID}, - {"NAME", SortMethod::NAME}, - {"NUMBER", SortMethod::NUMBER}, - {"SPECIAL-CENTERED", SortMethod::SPECIAL_CENTERED}, - {"DEFAULT", SortMethod::DEFAULT}}; + static inline const std::map m_sortMap = { + {"ID", SortMethod::ID}, + {"NAME", SortMethod::NAME}, + {"NUMBER", SortMethod::NUMBER}, + {"SPECIAL-CENTERED", SortMethod::SPECIAL_CENTERED}, + {"DEFAULT", SortMethod::DEFAULT}}; std::string m_formatBefore; std::string m_formatAfter; std::map m_iconsMap; + std::map m_tooltipMap; + bool m_withTooltip = false; util::RegexCollection m_windowRewriteRules; bool m_anyWindowRewriteRuleUsesTitle = false; std::string m_formatWindowSeparator; + int m_windowRewriteGroupThreshold = 0; + std::string m_windowRewriteGroupFormat = "{icon}×{count}"; bool m_withIcon; uint64_t m_monitorId; @@ -191,17 +205,18 @@ class Workspaces : public AModule, public EventHandler { std::string m_taskbarFormatBefore; std::string m_taskbarFormatAfter; int m_taskbarIconSize = 16; + int m_taskbarMaxIcons = 0; // 0 means unlimited Gtk::Orientation m_taskbarOrientation = Gtk::ORIENTATION_HORIZONTAL; bool m_taskbarReverseDirection = false; - util::EnumParser m_activeWindowEnumParser; ActiveWindowPosition m_activeWindowPosition = ActiveWindowPosition::NONE; - std::map m_activeWindowPositionMap = { + static inline std::map m_activeWindowPositionMap = { {"NONE", ActiveWindowPosition::NONE}, {"FIRST", ActiveWindowPosition::FIRST}, {"LAST", ActiveWindowPosition::LAST}, }; std::string m_onClickWindow; std::string m_currentActiveWindowAddress; + int m_maxWindows = 0; std::vector m_ignoreWorkspaces; std::vector m_ignoreWindows; @@ -211,6 +226,10 @@ class Workspaces : public AModule, public EventHandler { Gtk::Box m_box; sigc::connection m_scrollEventConnection_; IPC& m_ipc; + + // Coalesces bursts of Hyprland events into a single UI refresh. Armed and + // disconnected only on the GTK main thread (see Workspaces::update). + sigc::connection m_debounceTimer; }; } // namespace waybar::modules::hyprland diff --git a/include/modules/idle_inhibitor.hpp b/include/modules/idle_inhibitor.hpp index 22bd808f..8fef1985 100644 --- a/include/modules/idle_inhibitor.hpp +++ b/include/modules/idle_inhibitor.hpp @@ -6,25 +6,42 @@ #include "bar.hpp" #include "client.hpp" +struct ext_idle_notification_v1; + namespace waybar::modules { class IdleInhibitor : public ALabel { sigc::connection timeout_; + ext_idle_notification_v1* idle_notification_; + uint32_t idle_timeout_ms_; public: IdleInhibitor(const std::string&, const waybar::Bar&, const Json::Value&); virtual ~IdleInhibitor(); auto update() -> void override; + auto refresh(int) -> void override; static std::list modules; static bool status; + static long deactivationTime; private: bool handleToggle(GdkEventButton* const& e) override; - void toggleStatus(); + bool handleScroll(GdkEventScroll* e) override; + + void toggleStatus(int force_status = -1); + void setupIdleNotification(); + void teardownIdleNotification(); + static void handleIdled(void* data, ext_idle_notification_v1* notification); + static void handleResumed(void* data, ext_idle_notification_v1* notification); const Bar& bar_; struct zwp_idle_inhibitor_v1* idle_inhibitor_; int pid_; + + bool dynamicTimeout = false; + double timeout; + double timeout_step; + bool wait_for_activity_; }; } // namespace waybar::modules diff --git a/include/modules/image.hpp b/include/modules/image.hpp index 510dad94..5b4ac818 100644 --- a/include/modules/image.hpp +++ b/include/modules/image.hpp @@ -14,6 +14,69 @@ namespace waybar::modules { +namespace image { + +class IStrategy { + public: + virtual ~IStrategy() = default; + // Runs on the worker thread before update(). Use it for blocking work (e.g. + // spawning a user script) so the GTK main loop isn't stalled. Default no-op. + virtual void fetch() {} + virtual void update() = 0; +}; + +class SingleImageStrategy : public IStrategy { + public: + SingleImageStrategy(const std::string&, const Json::Value&, const std::string&, Gtk::EventBox&, + bool); + ~SingleImageStrategy() override = default; + void update() override; + + private: + void parseOutputRaw(); + + util::command::res output_; + Json::Value config_; + Gtk::Image image_; + std::string path_; + std::string tooltip_; + int size_; + Gtk::Box box_; + bool hasTooltip_; +}; + +class MultipleImageStrategy : public IStrategy { + public: + MultipleImageStrategy(const std::string&, const Json::Value&, const std::string&, Gtk::EventBox&); + ~MultipleImageStrategy() override = default; + void fetch() override; + void update() override; + + private: + struct ImageData { + std::string path; + std::string marker; + std::string tooltip; + std::string on_click; + std::shared_ptr img; + std::shared_ptr btn; + }; + + void setImagesData(const Json::Value&); + void setupAndDraw(); + void resetBoxAndMemory(); + void handleClick(const Glib::ustring& data); + + Json::Value config_; + int size_; + Gtk::Box box_; + std::vector images_data_; + // stdout captured by fetch() on the worker thread and consumed by update() + std::string exec_output_; +}; + +} // namespace image + class Image : public AModule { public: Image(const std::string&, const Json::Value&); @@ -24,16 +87,11 @@ class Image : public AModule { private: void delayWorker(); void handleEvent(); - void parseOutputRaw(); + static std::unique_ptr getStrategy(const std::string&, const Json::Value&, + const std::string&, Gtk::EventBox&, bool); - Gtk::Box box_; - Gtk::Image image_; - std::string path_; - std::string tooltip_; - int size_; std::chrono::milliseconds interval_; - util::command::res output_; - + std::unique_ptr strategy_; util::SleeperThread thread_; }; diff --git a/include/modules/keyboard_state.hpp b/include/modules/keyboard_state.hpp index db7f2550..c9f1afac 100644 --- a/include/modules/keyboard_state.hpp +++ b/include/modules/keyboard_state.hpp @@ -36,8 +36,7 @@ class KeyboardState : public AModule { std::string capslock_format_; std::string scrolllock_format_; const std::chrono::seconds interval_; - std::string icon_locked_; - std::string icon_unlocked_; + std::unordered_map> key_icon_states_; std::string devices_path_; struct libinput* libinput_; diff --git a/include/modules/mango/backend.hpp b/include/modules/mango/backend.hpp new file mode 100644 index 00000000..7e4313f3 --- /dev/null +++ b/include/modules/mango/backend.hpp @@ -0,0 +1,69 @@ +// include/modules/mango/backend.hpp +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "util/json.hpp" + +namespace waybar::modules::mango { + +class EventHandler { + public: + virtual void onEvent(const Json::Value& ev) = 0; + virtual ~EventHandler() = default; +}; + +class IPC { + public: + static IPC& getInstance(); + IPC(const IPC&) = delete; + IPC& operator=(const IPC&) = delete; + + void registerForIPC(const std::string& ev, EventHandler* handler); + void unregisterForIPC(EventHandler* handler); + + static Json::Value send(const Json::Value& request); + static void sendAsync(const Json::Value& request); + + std::unique_lock lockData() { return std::unique_lock(data_mutex_); } + + std::unordered_map getMonitors() const; + Json::Value getMonitor(const std::string& name); + Json::Value getActiveClientForMonitor(const std::string& name) const; + std::string getKeyboardLayout() const; + std::string getKeymode() const; + std::string getLayoutSymbolForMonitor(const std::string& name) const; + + private: + IPC(); + ~IPC(); + void startIPC(); + static int connectToSocket(); + void parseIPC(const std::string& line); + + void handleMonitorUpdate(const Json::Value& mon); + void updateFocusingClient(const Json::Value& client); + void updateKeyboardLayout(const std::string& layout); + + static Json::Value sendCommand(const std::string& cmd); + + std::atomic running_ = true; + int sockfd_ = -1; + std::thread ipc_thread_; + mutable std::mutex data_mutex_; + std::unordered_map monitors_; + std::unordered_map clients_; + uint64_t focusing_client_id_ = 0; + std::string keyboard_layout_; + std::string keymode_; + Json::Value active_client_; + std::mutex callback_mutex_; + std::list> callbacks_; +}; +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/mango/keymode.hpp b/include/modules/mango/keymode.hpp new file mode 100644 index 00000000..5e3ef685 --- /dev/null +++ b/include/modules/mango/keymode.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "ALabel.hpp" +#include "bar.hpp" +#include "modules/mango/backend.hpp" + +namespace waybar::modules::mango { + +class Keymode : public ALabel, public EventHandler { + public: + Keymode(const std::string&, const Bar&, const Json::Value&); + ~Keymode() override; + void update() override; + + private: + void onEvent(const Json::Value& ev) override; + void doUpdate(); + + std::mutex mutex_; + const Bar& bar_; + std::string last_keymode_; +}; + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/mango/language.hpp b/include/modules/mango/language.hpp new file mode 100644 index 00000000..3d91515b --- /dev/null +++ b/include/modules/mango/language.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include "ALabel.hpp" +#include "bar.hpp" +#include "modules/mango/backend.hpp" + +namespace waybar::modules::mango { + +class Language : public ALabel, public EventHandler { + public: + Language(const std::string&, const Bar&, const Json::Value&); + ~Language() override; + void update() override; + + private: + void updateFromIPC(); + void onEvent(const Json::Value& ev) override; + void doUpdate(); + + struct Layout { + std::string full_name; + std::string short_name; + std::string variant; + std::string short_description; + }; + + Layout getLayout(const std::string& fullName); + + std::mutex mutex_; + const Bar& bar_; + + std::vector layouts_; + unsigned current_idx_; + std::string last_short_name_; + + struct rxkb_context* rxkb_ctx_ = nullptr; +}; + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/mango/layout.hpp b/include/modules/mango/layout.hpp new file mode 100644 index 00000000..4a349876 --- /dev/null +++ b/include/modules/mango/layout.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "ALabel.hpp" +#include "bar.hpp" +#include "modules/mango/backend.hpp" + +namespace waybar::modules::mango { + +class Layout : public ALabel, public EventHandler { + public: + Layout(const std::string&, const Bar&, const Json::Value&); + ~Layout() override; + void update() override; + + private: + void onEvent(const Json::Value& ev) override; + void doUpdate(); + + std::mutex mutex_; + const Bar& bar_; + std::string last_symbol_; +}; + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/mango/window.hpp b/include/modules/mango/window.hpp new file mode 100644 index 00000000..987da34b --- /dev/null +++ b/include/modules/mango/window.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include + +#include "AAppIconLabel.hpp" +#include "bar.hpp" +#include "modules/mango/backend.hpp" + +namespace waybar::modules::mango { + +class Window : public AAppIconLabel, public EventHandler { + public: + Window(const std::string&, const Bar&, const Json::Value&); + ~Window() override; + void update() override; + + private: + void onEvent(const Json::Value& ev) override; + void doUpdate(); + void setClass(const std::string& className, bool enable); + + const Bar& bar_; + std::string oldAppId_; + std::mutex mutex_; +}; + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/mango/workspaces.hpp b/include/modules/mango/workspaces.hpp new file mode 100644 index 00000000..f5e52022 --- /dev/null +++ b/include/modules/mango/workspaces.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include + +#include "AModule.hpp" +#include "bar.hpp" +#include "modules/mango/backend.hpp" + +namespace waybar::modules::mango { + +class Workspaces : public AModule, public EventHandler { + public: + Workspaces(const std::string&, const Bar&, const Json::Value&); + ~Workspaces() override; + void update() override; + + private: + void onEvent(const Json::Value& ev) override; + void doUpdate(); + + Gtk::Button& addButton(uint64_t idx); + void updateButtonState(Gtk::Button& button, const Json::Value& tag, const Json::Value& monitor); + std::string getIcon(const std::string& value, const Json::Value& tag); + bool handleButtonClick(GdkEventButton* event, uint64_t idx, bool isOverview); + + const Bar& bar_; + Gtk::Box box_; + + std::unordered_map buttons_; + Gtk::Button* overview_button_ = nullptr; + + std::string on_click_left_; + std::string on_click_middle_; + std::string on_click_right_; +}; + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/include/modules/memory.hpp b/include/modules/memory.hpp index c73ece23..0e405187 100644 --- a/include/modules/memory.hpp +++ b/include/modules/memory.hpp @@ -19,8 +19,6 @@ class Memory : public ALabel { private: void parseMeminfo(); - static float calc_divisor(const std::string& divisor); - std::unordered_map meminfo_; util::SleeperThread thread_; diff --git a/include/modules/mpd/mpd.hpp b/include/modules/mpd/mpd.hpp index 9d81638e..7a777cf8 100644 --- a/include/modules/mpd/mpd.hpp +++ b/include/modules/mpd/mpd.hpp @@ -28,11 +28,14 @@ class MPD : public ALabel { unsigned timeout_; + unsigned playing_interval_; + detail::unique_connection connection_; detail::unique_status status_; mpd_state state_; detail::unique_song song_; + std::string ellipsis_; public: MPD(const std::string&, const Json::Value&); @@ -45,6 +48,10 @@ class MPD : public ALabel { void setLabel(); std::string getStateIcon() const; std::string getOptionIcon(const std::string& optionName, bool activated) const; + std::string getArtistStr(bool truncated) const; + std::string getAlbumArtistStr(bool truncated) const; + std::string getAlbumStr(bool truncated) const; + std::string getTitleStr(bool truncated) const; // GUI-side methods bool handlePlayPause(GdkEventButton* const&); @@ -54,11 +61,11 @@ class MPD : public ALabel { void tryConnect(); void checkErrors(mpd_connection* conn); void fetchState(); - void queryMPD(); inline bool stopped() const { return connection_ && state_ == MPD_STATE_STOP; } inline bool playing() const { return connection_ && state_ == MPD_STATE_PLAY; } inline bool paused() const { return connection_ && state_ == MPD_STATE_PAUSE; } + inline unsigned playing_interval() const { return playing_interval_; } }; #if !defined(MPD_NOINLINE) diff --git a/include/modules/mpd/state.hpp b/include/modules/mpd/state.hpp index 2c9071b4..1da1471d 100644 --- a/include/modules/mpd/state.hpp +++ b/include/modules/mpd/state.hpp @@ -82,6 +82,7 @@ class Idle : public State { class Playing : public State { Context* const ctx_; sigc::connection timer_connection_; + sigc::connection idle_connection_; public: Playing(Context* const ctx) : ctx_{ctx} {} @@ -98,7 +99,10 @@ class Playing : public State { Playing(Playing const&) = delete; Playing& operator=(Playing const&) = delete; + void timer() noexcept; + void idle() noexcept; bool on_timer(); + bool on_io(Glib::IOCondition const&); }; class Paused : public State { @@ -194,10 +198,10 @@ class Context { bool is_paused() const; bool is_stopped() const; constexpr std::size_t interval() const; + unsigned playing_interval() const; void tryConnect() const; void checkErrors(mpd_connection*) const; void do_update(); - void queryMPD() const; void fetchState() const; constexpr mpd_state state() const; void emit() const; diff --git a/include/modules/mpd/state.inl.hpp b/include/modules/mpd/state.inl.hpp index 895970e6..fc0964ef 100644 --- a/include/modules/mpd/state.inl.hpp +++ b/include/modules/mpd/state.inl.hpp @@ -1,13 +1,15 @@ #pragma once namespace detail { +using namespace std::literals::chrono_literals; inline bool Context::is_connected() const { return mpd_module_->connection_ != nullptr; } inline bool Context::is_playing() const { return mpd_module_->playing(); } inline bool Context::is_paused() const { return mpd_module_->paused(); } inline bool Context::is_stopped() const { return mpd_module_->stopped(); } -constexpr inline std::size_t Context::interval() const { return mpd_module_->interval_.count(); } +constexpr inline std::size_t Context::interval() const { return mpd_module_->interval_ / 1s; } +inline unsigned Context::playing_interval() const { return mpd_module_->playing_interval(); } inline void Context::tryConnect() const { mpd_module_->tryConnect(); } inline unique_connection& Context::connection() { return mpd_module_->connection_; } constexpr inline mpd_state Context::state() const { return mpd_module_->state_; } @@ -15,7 +17,6 @@ constexpr inline mpd_state Context::state() const { return mpd_module_->state_; inline void Context::do_update() { mpd_module_->setLabel(); } inline void Context::checkErrors(mpd_connection* conn) const { mpd_module_->checkErrors(conn); } -inline void Context::queryMPD() const { mpd_module_->queryMPD(); } inline void Context::fetchState() const { mpd_module_->fetchState(); } inline void Context::emit() const { mpd_module_->emit(); } diff --git a/include/modules/mpris/mpris.hpp b/include/modules/mpris/mpris.hpp index a33db4d2..c5ce936a 100644 --- a/include/modules/mpris/mpris.hpp +++ b/include/modules/mpris/mpris.hpp @@ -38,6 +38,7 @@ class Mpris : public ALabel { std::optional artist; std::optional album; + std::optional album_artist; std::optional title; std::optional length; // as HH:MM:SS std::optional position; // same format @@ -76,6 +77,8 @@ class Mpris : public ALabel { std::string player_; std::vector ignored_players_; + bool prefer_album_artist_; + PlayerctlPlayerManager* manager; PlayerctlPlayer* player; PlayerctlPlayer* last_active_player_ = nullptr; diff --git a/include/modules/network.hpp b/include/modules/network.hpp index 66fc6d04..a74ffda2 100644 --- a/include/modules/network.hpp +++ b/include/modules/network.hpp @@ -17,6 +17,8 @@ #include "util/rfkill.hpp" #endif +#define ETH_ALEN 6 + enum ip_addr_pref : uint8_t { IPV4, IPV6, IPV4_6 }; namespace waybar::modules { @@ -34,6 +36,7 @@ class Network : public ALabel { static int handleEvents(struct nl_msg*, void*); static int handleEventsDone(struct nl_msg*, void*); static int handleScan(struct nl_msg*, void*); + static int handleStationGet(struct nl_msg *msg, void *data); void askForStateDump(void); @@ -48,15 +51,18 @@ class Network : public ALabel { bool matchInterface(const std::string& ifname, const std::vector& altnames, std::string& matched) const; auto getInfo() -> void; + bool isWireless() const; const std::string getNetworkState() const; void clearIface(); std::optional> readBandwidthUsage(); + uint32_t readLinkSpeed() const; int ifid_{-1}; ip_addr_pref addr_pref_{ip_addr_pref::IPV4}; struct sockaddr_nl nladdr_{0}; struct nl_sock* sock_{nullptr}; struct nl_sock* ev_sock_{nullptr}; + struct nl_sock* station_sock_{nullptr}; int efd_{-1}; int ev_fd_{-1}; int nl80211_id_{-1}; @@ -90,6 +96,7 @@ class Network : public ALabel { uint8_t signal_strength_; std::string signal_strength_app_; uint32_t route_priority; + uint32_t link_speed_{0}; util::SleeperThread thread_; util::SleeperThread thread_timer_; @@ -97,6 +104,8 @@ class Network : public ALabel { util::Rfkill rfkill_{RFKILL_TYPE_WLAN}; #endif float frequency_{0}; + uint32_t tx_bitrate_{0}; + uint32_t rx_bitrate_{0}; }; } // namespace waybar::modules diff --git a/include/modules/niri/backend.hpp b/include/modules/niri/backend.hpp index 07be039a..94b6d66f 100644 --- a/include/modules/niri/backend.hpp +++ b/include/modules/niri/backend.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -18,6 +19,7 @@ class EventHandler { class IPC { public: IPC(); + ~IPC(); void registerForIPC(const std::string& ev, EventHandler* ev_handler); void unregisterForIPC(EventHandler* handler); @@ -32,7 +34,7 @@ class IPC { unsigned keyboardLayoutCurrent() const { return keyboardLayoutCurrent_; } private: - void startIPC(); + void startIPC(int initial_socketfd); static int connectToSocket(); void parseIPC(const std::string&); @@ -45,6 +47,8 @@ class IPC { util::JsonParser parser_; std::mutex callbackMutex_; std::list> callbacks_; + + std::atomic running_{true}; }; inline std::unique_ptr gIPC; diff --git a/include/modules/niri/workspace.hpp b/include/modules/niri/workspace.hpp new file mode 100644 index 00000000..e1bf7ba6 --- /dev/null +++ b/include/modules/niri/workspace.hpp @@ -0,0 +1,47 @@ +#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, + const std::string& windows_str, std::size_t total); + + 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 08986412..97030223 100644 --- a/include/modules/niri/workspaces.hpp +++ b/include/modules/niri/workspaces.hpp @@ -1,30 +1,62 @@ #pragma once -#include +#include #include +#include +#include +#include + #include "AModule.hpp" #include "bar.hpp" #include "modules/niri/backend.hpp" +#include "modules/niri/workspace.hpp" +#include "util/regex_collection.hpp" // Added for rewrite rules 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); + void sortWorkspaces(std::vector& workspaces) const; + bool isWorkspaceIgnored(const std::string& name); + bool handleScroll(GdkEventScroll* /*unused*/) override; + // Added for window rewrite + void populateWindowRewriteConfig(); + void populateFormatWindowSeparatorConfig(); + std::string getRewrite(const std::string& app_id, const std::string& title); + std::string getWindowsRepresentation(const Json::Value& ws); const Bar& bar_; Gtk::Box box_; - // Map from niri workspace id to button. - std::unordered_map buttons_; + + std::vector> workspaces_; + + // Vec of regex rules to ignore workspaces. + std::vector ignoreWorkspaces_; + + bool sort_by_id_ = false; + bool sort_by_name_ = false; + bool sort_by_coordinates_ = false; + + // Added for window rewrite + util::RegexCollection m_windowRewriteRules; + std::string m_windowRewriteDefault; + std::string m_formatWindowSeparator; + }; } // namespace waybar::modules::niri diff --git a/include/modules/power_profiles_daemon.hpp b/include/modules/power_profiles_daemon.hpp index b2436171..1aa087a4 100644 --- a/include/modules/power_profiles_daemon.hpp +++ b/include/modules/power_profiles_daemon.hpp @@ -9,9 +9,18 @@ namespace waybar::modules { struct Profile { std::string name; + // Legacy driver field, kept for backward compatibility with the + // `{driver}` format placeholder and with older power-profiles-daemon + // versions that only expose a single `Driver` DBus property. std::string driver; + std::string cpuDriver; + std::string platformDriver; - Profile(std::string n, std::string d) : name(std::move(n)), driver(std::move(d)) {} + Profile(std::string n, std::string d, std::string cd, std::string pd) + : name(std::move(n)), + driver(std::move(d)), + cpuDriver(std::move(cd)), + platformDriver(std::move(pd)) {} }; class PowerProfilesDaemon : public ALabel { diff --git a/include/modules/privacy/privacy.hpp b/include/modules/privacy/privacy.hpp index 68d2bf19..41145e50 100644 --- a/include/modules/privacy/privacy.hpp +++ b/include/modules/privacy/privacy.hpp @@ -1,40 +1,50 @@ #pragma once +#include #include #include "gtkmm/box.h" #include "modules/privacy/privacy_item.hpp" +#include "util/geoclue_backend.hpp" #include "util/pipewire/pipewire_backend.hpp" #include "util/pipewire/privacy_node_info.hpp" -using waybar::util::PipewireBackend::PrivacyNodeInfo; +using waybar::util::PipewireBackend::PrivacyNodeType; +using waybar::util::PipewireBackend::PWPrivacyNodeInfo; namespace waybar::modules::privacy { class Privacy : public AModule { public: Privacy(const std::string&, const Json::Value&, Gtk::Orientation, const std::string& pos); + ~Privacy() override; auto update() -> void override; - void onPrivacyNodesChanged(); - private: - std::list nodes_screenshare; // Screen is being shared - std::list nodes_audio_in; // Application is using the microphone - std::list nodes_audio_out; // Application is outputting audio + std::list nodes_screenshare; // Screen is being shared + std::list nodes_audio_in; // Application is using the microphone + std::list nodes_audio_out; // Application is outputting audio + std::atomic location_in_use; // GeoClue is being used std::mutex mutex_; sigc::connection visibility_conn; + sigc::connection geoclue_timeout_conn; // Config Gtk::Box box_; + std::vector modules_; uint iconSpacing = 4; uint iconSize = 20; uint transition_duration = 250; std::set> ignore; bool ignore_monitor = true; - std::shared_ptr backend = nullptr; + std::shared_ptr pw_backend = nullptr; + std::shared_ptr geoclue_backend = nullptr; + + void onPWPrivacyNodesChanged(); + bool locationTimeout(bool in_use); + void onGeoCluePrivacyNodesChanged(); }; } // namespace waybar::modules::privacy diff --git a/include/modules/privacy/privacy_item.hpp b/include/modules/privacy/privacy_item.hpp index 31e200e3..2a9cafcd 100644 --- a/include/modules/privacy/privacy_item.hpp +++ b/include/modules/privacy/privacy_item.hpp @@ -9,35 +9,36 @@ #include "gtkmm/revealer.h" #include "util/pipewire/privacy_node_info.hpp" -using waybar::util::PipewireBackend::PrivacyNodeInfo; using waybar::util::PipewireBackend::PrivacyNodeType; +using waybar::util::PipewireBackend::PWPrivacyNodeInfo; namespace waybar::modules::privacy { class PrivacyItem : public Gtk::Revealer { - public: + protected: PrivacyItem(const Json::Value& config_, enum PrivacyNodeType privacy_type_, - std::list* nodes, Gtk::Orientation orientation, - const std::string& pos, const uint icon_size, const uint transition_duration); + Gtk::Orientation orientation, const std::string& pos, const uint icon_size, + const uint transition_duration); + + public: + virtual void set_tooltip() = 0; enum PrivacyNodeType privacy_type; void set_in_use(bool in_use); - private: - std::list* nodes; - - sigc::connection signal_conn; - + uint tooltipIconSize = 24; Gtk::Box tooltip_window; + private: + sigc::connection signal_conn; + bool init = false; bool in_use = false; // Config std::string iconName = "image-missing-symbolic"; bool tooltip = true; - uint tooltipIconSize = 24; Gtk::Box box_; Gtk::Image icon_; @@ -45,4 +46,28 @@ class PrivacyItem : public Gtk::Revealer { void update_tooltip(); }; +class GeoCluePrivacyItem : public PrivacyItem { + public: + GeoCluePrivacyItem(const Json::Value& config_, Gtk::Orientation orientation, + const std::string& pos, const uint icon_size, const uint transition_duration) + : PrivacyItem(config_, util::PipewireBackend::PRIVACY_NODE_TYPE_LOCATION, orientation, pos, + icon_size, transition_duration) {} + + void set_tooltip() override; +}; + +class PWPrivacyItem : public PrivacyItem { + public: + PWPrivacyItem(const Json::Value& config_, enum PrivacyNodeType privacy_type_, + std::list* nodes_, Gtk::Orientation orientation, + const std::string& pos, const uint icon_size, const uint transition_duration) + : PrivacyItem(config_, privacy_type_, orientation, pos, icon_size, transition_duration), + nodes(nodes_) {} + + void set_tooltip() override; + + private: + std::list* nodes; +}; + } // namespace waybar::modules::privacy diff --git a/include/modules/pulseaudio.hpp b/include/modules/pulseaudio.hpp index eead664f..cfd300f7 100644 --- a/include/modules/pulseaudio.hpp +++ b/include/modules/pulseaudio.hpp @@ -22,6 +22,7 @@ class Pulseaudio : public ALabel { const std::vector getPulseIcon() const; std::shared_ptr backend = nullptr; + util::PulseaudioTarget target = util::PulseaudioTarget::Sink; }; } // namespace waybar::modules diff --git a/include/modules/pulseaudio_slider.hpp b/include/modules/pulseaudio_slider.hpp index 3ef44684..597b3cc8 100644 --- a/include/modules/pulseaudio_slider.hpp +++ b/include/modules/pulseaudio_slider.hpp @@ -6,11 +6,6 @@ #include "util/audio_backend.hpp" namespace waybar::modules { -enum class PulseaudioSliderTarget { - Sink, - Source, -}; - class PulseaudioSlider : public ASlider { public: PulseaudioSlider(const std::string&, const Json::Value&); @@ -21,7 +16,15 @@ class PulseaudioSlider : public ASlider { private: std::shared_ptr backend = nullptr; - PulseaudioSliderTarget target = PulseaudioSliderTarget::Sink; + util::PulseaudioTarget target = util::PulseaudioTarget::Sink; + + bool zero_on_mute = true; + bool unmute_on_volume_change = true; + // zero_on_mute and unmute_on_volume_change default to true + // in order to maintain previous behaviour when using a + // config in which these values are undefined + + bool previously_muted = false; }; -} // namespace waybar::modules \ No newline at end of file +} // namespace waybar::modules diff --git a/include/modules/river/tags.hpp b/include/modules/river/tags.hpp index b28aa5ec..faa59bcb 100644 --- a/include/modules/river/tags.hpp +++ b/include/modules/river/tags.hpp @@ -21,6 +21,8 @@ class Tags : public waybar::AModule { void handle_view_tags(struct wl_array* tags); void handle_urgent_tags(uint32_t tags); void handle_focused_view(const char *title, uint32_t tags); + void handle_focused_output(struct wl_output* output); + void handle_unfocused_output(struct wl_output* output); void handle_show(); void handle_primary_clicked(uint32_t tag); @@ -28,17 +30,17 @@ class Tags : public waybar::AModule { struct zriver_status_manager_v1* status_manager_; struct zriver_control_v1* control_; - struct zriver_seat_status_v1 *seat_status_; struct wl_seat* seat_; - // used to make sure the focused view tags are on the correct output - const wl_output* output_; - const wl_output* focused_output_; private: const waybar::Bar& bar_; + struct wl_output* focused_output_; // stores the focused output + struct wl_output* output_; // stores the output this module belongs to Gtk::Box box_; std::vector buttons_; struct zriver_output_status_v1* output_status_; + struct zriver_seat_status_v1* seat_status_; + bool hide_vacant_ = false; // parsed once; asBool() in a wl callback would throw (#4078) }; } /* namespace waybar::modules::river */ diff --git a/include/modules/sni/host.hpp b/include/modules/sni/host.hpp index d76ec74a..4dad1f94 100644 --- a/include/modules/sni/host.hpp +++ b/include/modules/sni/host.hpp @@ -14,16 +14,22 @@ namespace waybar::modules::SNI { class Host { public: - Host(const std::size_t id, const Json::Value&, const Bar&, + Host(std::size_t id, const Json::Value&, const Bar&, const std::vector&, const std::function&)>&, - const std::function&)>&, const std::function&); + const std::function&)>&, const std::function&, + const std::function&); ~Host(); + void checkIgnoreList(const std::vector& ignore_list, + const std::function&)>& on_remove); + + void reorderItems(); + private: - void busAcquired(const Glib::RefPtr&, Glib::ustring); - void nameAppeared(const Glib::RefPtr&, Glib::ustring, + void busAcquired(const Glib::RefPtr&, const Glib::ustring&); + void nameAppeared(const Glib::RefPtr&, const Glib::ustring&, const Glib::ustring&); - void nameVanished(const Glib::RefPtr&, Glib::ustring); + void nameVanished(const Glib::RefPtr&, const Glib::ustring&); static void proxyReady(GObject*, GAsyncResult*, gpointer); static void registerHost(GObject*, GAsyncResult*, gpointer); static void itemRegistered(SnWatcher*, const gchar*, gpointer); @@ -33,7 +39,7 @@ class Host { void removeItem(std::vector>::iterator); void clearItems(); - std::tuple getBusNameAndObjectPath(const std::string); + static std::tuple getBusNameAndObjectPath(const std::string&); void addRegisteredItem(const std::string& service); std::vector> items_; @@ -43,10 +49,19 @@ class Host { std::size_t watcher_id_; GCancellable* cancellable_ = nullptr; SnWatcher* watcher_ = nullptr; + sigc::connection retry_connection_; + unsigned retry_count_ = 0; const Json::Value& config_; const Bar& bar_; + const std::vector ignore_list_; const std::function&)> on_add_; const std::function&)> on_remove_; + // Re-applies the configured ordering to the already-added tray widgets. This + // must NOT re-run the add path (which would re-parent widgets and reconnect + // signals); it only reorders existing children. + const std::function on_reorder_; + + ItemOrderMap orders_; const std::function on_update_; }; diff --git a/include/modules/sni/icon_manager.hpp b/include/modules/sni/icon_manager.hpp index 614d42d9..9f603d16 100644 --- a/include/modules/sni/icon_manager.hpp +++ b/include/modules/sni/icon_manager.hpp @@ -5,6 +5,7 @@ #include #include +#include class IconManager { public: @@ -19,7 +20,10 @@ class IconManager { std::string app_name = key; const Json::Value& icon_value = icons_config[key]; - if (icon_value.isString()) { + if (icon_value.isBool() && !icon_value.asBool()) { + // false value means hide this app + hidden_apps_.insert(app_name); + } else if (icon_value.isString()) { std::string icon_path = icon_value.asString(); icons_map_[app_name] = icon_path; } @@ -37,7 +41,12 @@ class IconManager { return ""; } + bool isHidden(const std::string& app_name) const { + return hidden_apps_.find(app_name) != hidden_apps_.end(); + } + private: IconManager() = default; std::unordered_map icons_map_; + std::unordered_set hidden_apps_; }; diff --git a/include/modules/sni/item.hpp b/include/modules/sni/item.hpp index 74d54f4c..f2e8b3ce 100644 --- a/include/modules/sni/item.hpp +++ b/include/modules/sni/item.hpp @@ -24,11 +24,15 @@ struct ToolTip { Glib::ustring text; }; +class Host; + +using ItemOrderMap = std::unordered_map; + class Item : public sigc::trackable { public: Item(const std::string&, const std::string&, const Json::Value&, const Bar&, const std::function&, const std::function&, - const std::function&); + const std::function&, Host&, const ItemOrderMap&); ~Item(); bool isReady() const; @@ -46,6 +50,7 @@ class Item : public sigc::trackable { std::string title; std::string icon_name; Glib::RefPtr icon_pixmap; + bool has_custom_icon_ = false; Glib::RefPtr icon_theme; std::string overlay_icon_name; Glib::RefPtr overlay_icon_pixmap; @@ -63,6 +68,7 @@ class Item : public sigc::trackable { * while compliant SNI implementation would always reset the flag to desired value. */ bool item_is_menu = true; + int order_ = -1; // -1 means not set private: void onConfigure(GdkEventConfigure* ev); @@ -100,6 +106,8 @@ class Item : public sigc::trackable { gdouble distance_scrolled_y_ = 0; // visibility of items with Status == Passive bool show_passive_ = false; + // hidden via config + bool is_hidden_ = false; bool ready_ = false; Glib::ustring status_ = "active"; @@ -111,6 +119,9 @@ class Item : public sigc::trackable { Glib::RefPtr proxy_; Glib::RefPtr cancellable_; std::set update_pending_; + + Host& host_; + const ItemOrderMap& orders_; }; } // namespace waybar::modules::SNI diff --git a/include/modules/sni/tray.hpp b/include/modules/sni/tray.hpp index 3d90b3fd..c3ac4e34 100644 --- a/include/modules/sni/tray.hpp +++ b/include/modules/sni/tray.hpp @@ -1,6 +1,10 @@ #pragma once #include +#include + +#include +#include #include "AModule.hpp" #include "bar.hpp" @@ -13,19 +17,28 @@ namespace waybar::modules::SNI { class Tray : public AModule { public: Tray(const std::string&, const Bar&, const Json::Value&); - virtual ~Tray() = default; + ~Tray() override = default; auto update() -> void override; private: void onAdd(std::unique_ptr& item); void onRemove(std::unique_ptr& item); + // Reorders the already-added tray widgets by their configured order. Does not + // add or remove any widget. + void reorderBox(); + void checkIgnoreList(std::unique_ptr* item); + std::vector parseIgnoreList(const Json::Value& config); void queueUpdate(); static inline std::size_t nb_hosts_ = 0; - bool show_passive_ = false; Gtk::Box box_; SNI::Watcher::singleton watcher_; + std::vector ignore_list_; SNI::Host host_; + std::vector items_; + // signal_show/signal_hide connections owned per added item, so they can be + // disconnected on removal instead of leaking and accumulating. + std::unordered_map> item_connections_; }; } // namespace waybar::modules::SNI diff --git a/include/modules/sway/ipc/client.hpp b/include/modules/sway/ipc/client.hpp index f6eb7c40..f506b0bf 100644 --- a/include/modules/sway/ipc/client.hpp +++ b/include/modules/sway/ipc/client.hpp @@ -1,15 +1,13 @@ #pragma once #include -#include -#include -#include -#include -#include +#include +#include +#include #include -#include #include +#include #include "ipc.hpp" #include "util/SafeSignal.hpp" @@ -41,11 +39,20 @@ class Ipc { static inline const std::string ipc_magic_ = "i3-ipc"; static inline const size_t ipc_header_size_ = ipc_magic_.size() + 8; - const std::string getSocketPath() const; - int open(const std::string&) const; + static std::string getSocketPath(); + static int open(const std::string&); + struct ipc_response send(int fd, uint32_t type, const std::string& payload = ""); struct ipc_response recv(int fd); + // Re-establish the event socket and re-subscribe after sway drops us, backing + // off between attempts so we don't busy-loop while sway is unavailable. + void reconnectEvent(); + + std::string socketPath_; + std::vector subscribed_events_; + std::atomic running_{true}; + util::ScopedFd fd_; util::ScopedFd fd_event_; std::mutex mutex_; diff --git a/include/modules/sway/language.hpp b/include/modules/sway/language.hpp index 470b9879..6ababa4f 100644 --- a/include/modules/sway/language.hpp +++ b/include/modules/sway/language.hpp @@ -54,6 +54,9 @@ class Language : public ALabel, public sigc::trackable { const static std::string XKB_ACTIVE_LAYOUT_NAME_KEY; Layout layout_; + // CSS class currently applied to label_. Tracked so update() (main thread) can swap classes + // instead of set_current_layout() mutating the widget from the IPC worker thread (#3702). + std::string applied_class_; std::string tooltip_format_ = ""; std::map layouts_map_; bool hide_single_; diff --git a/include/modules/sway/workspaces.hpp b/include/modules/sway/workspaces.hpp index 14528841..8b902f03 100644 --- a/include/modules/sway/workspaces.hpp +++ b/include/modules/sway/workspaces.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -24,12 +25,15 @@ class Workspaces : public AModule, public sigc::trackable { private: static constexpr std::string_view workspace_switch_cmd_ = "workspace {} \"{}\""; + static constexpr std::string_view workspace_switch_number_cmd_ = "workspace {} number {}"; static constexpr std::string_view persistent_workspace_switch_cmd_ = R"(workspace {} "{}"; move workspace to output "{}"; workspace {} "{}")"; static int convertWorkspaceNameToNum(const std::string& name); static int windowRewritePriorityFunction(std::string const& window_rule); + auto populateIgnoreWorkspacesConfig(const Json::Value& config) -> void; + bool isWorkspaceIgnored(std::string const& name); void onCmd(const struct Ipc::ipc_response&); void onEvent(const struct Ipc::ipc_response&); bool filterButtons(); @@ -41,6 +45,7 @@ class Workspaces : public AModule, public sigc::trackable { std::string getCycleWorkspace(std::vector::iterator, bool prev) const; uint16_t getWorkspaceIndex(const std::string& name) const; static std::string trimWorkspaceName(const std::string&); + std::optional getCustomSortIndex(const std::string& name) const; bool handleScroll(GdkEventScroll* /*unused*/) override; const Bar& bar_; @@ -49,9 +54,11 @@ class Workspaces : public AModule, public sigc::trackable { std::vector workspaces_order_; Gtk::Box box_; std::string m_formatWindowSeparator; + std::vector m_ignoreWorkspaces; util::RegexCollection m_windowRewriteRules; util::JsonParser parser_; std::unordered_map buttons_; + std::unordered_map custom_sort_priorities_; std::mutex mutex_; Ipc ipc_; }; diff --git a/include/modules/temperature.hpp b/include/modules/temperature.hpp index 918281be..d65ef84d 100644 --- a/include/modules/temperature.hpp +++ b/include/modules/temperature.hpp @@ -14,6 +14,8 @@ class Temperature : public ALabel { Temperature(const std::string&, const Json::Value&); virtual ~Temperature() = default; auto update() -> void override; + void suspend() override; + void resume() override; private: float getTemperature(); diff --git a/include/modules/wireplumber.hpp b/include/modules/wireplumber.hpp index 0565704f..dc96b520 100644 --- a/include/modules/wireplumber.hpp +++ b/include/modules/wireplumber.hpp @@ -17,6 +17,11 @@ class Wireplumber : public ALabel { auto update() -> void override; private: + bool setupConnection(); + void teardownConnection(); + void scheduleReconnect(); + bool onReconnectTimeout(); + static void onCoreDisconnected(waybar::modules::Wireplumber* self); void asyncLoadRequiredApiModules(); void prepare(waybar::modules::Wireplumber* self); void activatePlugins(); @@ -24,17 +29,25 @@ class Wireplumber : public ALabel { static void updateNodeName(waybar::modules::Wireplumber* self, uint32_t id); static void updateSourceVolume(waybar::modules::Wireplumber* self, uint32_t id); static void updateSourceName(waybar::modules::Wireplumber* self, uint32_t id); // NEW - static void onPluginActivated(WpObject* p, GAsyncResult* res, waybar::modules::Wireplumber* self); - static void onDefaultNodesApiLoaded(WpObject* p, GAsyncResult* res, - waybar::modules::Wireplumber* self); - static void onMixerApiLoaded(WpObject* p, GAsyncResult* res, waybar::modules::Wireplumber* self); + static void onPluginActivated(WpObject* p, GAsyncResult* res, gpointer data); + static void onDefaultNodesApiLoaded(WpObject* p, GAsyncResult* res, gpointer data); + static void onMixerApiLoaded(WpObject* p, GAsyncResult* res, gpointer data); static void onObjectManagerInstalled(waybar::modules::Wireplumber* self); static void onMixerChanged(waybar::modules::Wireplumber* self, uint32_t id); static void onDefaultNodesApiChanged(waybar::modules::Wireplumber* self); bool handleScroll(GdkEventScroll* e) override; + std::vector getWPIcon(); static std::list modules; + // Returns true while `self` is still a live module. Async load/activation callbacks use this to + // avoid dereferencing a `self` that was destroyed before the callback fired (see #3974). + static bool isModuleAlive(waybar::modules::Wireplumber* self); + + uint32_t resolvePhysicalSink(uint32_t start_id); + uint32_t findPlaybackNodeId(const gchar* description); + uint32_t get_linked_sink_id(WpObjectManager* om, uint32_t from_node_id); + uint32_t get_linked_node_from_output_ports(WpObjectManager* om, uint32_t from_node_id); WpCore* wp_core_; GPtrArray* apis_; @@ -43,6 +56,10 @@ class Wireplumber : public ALabel { WpPlugin* def_nodes_api_; gchar* default_node_name_; uint32_t pending_plugins_; + // Bumped on every (re)connection. The async load/activate callbacks capture the generation they + // were scheduled under (via their user_data) and no-op if it no longer matches, so a completion + // from a connection that was already torn down cannot corrupt the new generation's state (#2882). + uint32_t connection_generation_{0}; bool muted_; double volume_; double min_step_; @@ -54,6 +71,12 @@ class Wireplumber : public ALabel { bool source_muted_; double source_volume_; gchar* default_source_name_; + bool only_physical_; + bool resolved_physical_; + std::string form_factor_; + // Timer used to retry connecting to PipeWire after it goes away; disconnected in the destructor + // so a pending attempt can't outlive the module. See #2882. + sigc::connection reconnect_timer_; }; } // namespace waybar::modules diff --git a/include/modules/wlr/taskbar.hpp b/include/modules/wlr/taskbar.hpp index 413eae85..74269320 100644 --- a/include/modules/wlr/taskbar.hpp +++ b/include/modules/wlr/taskbar.hpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include "AModule.hpp" #include "bar.hpp" #include "client.hpp" +#include "ext-workspace-v1-client-protocol.h" #include "giomm/desktopappinfo.h" #include "util/icon_loader.hpp" #include "util/json.hpp" @@ -68,6 +70,11 @@ class Task { Glib::RefPtr app_info_; bool button_visible_ = false; bool ignored_ = false; + bool squashed_ = false; + /* Whether the toplevel is on this bar's output, per the protocol's + * output_enter/output_leave events */ + bool on_bar_output_ = false; + bool size_allocate_connected_ = false; bool with_icon_ = false; bool with_name_ = false; @@ -80,6 +87,7 @@ class Task { std::string title_; std::string app_id_; uint32_t state_ = 0; + struct ext_workspace_handle_v1* workspace_ = nullptr; int32_t drag_start_x; int32_t drag_start_y; @@ -91,6 +99,9 @@ class Task { void set_minimize_hint(); void on_button_size_allocated(Gtk::Allocation& alloc); void hide_if_ignored(); + void hide_if_duplicate(); + void show_button(); + void hide_button(); public: /* Getter functions */ @@ -102,6 +113,9 @@ class Task { bool minimized() const { return state_ & MINIMIZED; } bool active() const { return state_ & ACTIVE; } bool fullscreen() const { return state_ & FULLSCREEN; } + bool visible() const { return button_visible_; } + struct ext_workspace_handle_v1* workspace() const { return workspace_; } + void set_workspace(struct ext_workspace_handle_v1* workspace) { workspace_ = workspace; } public: /* Callbacks for the wlr protocol */ @@ -142,6 +156,12 @@ using TaskPtr = std::unique_ptr; class Taskbar : public waybar::AModule { public: + struct WorkspaceState { + Taskbar* taskbar; + struct ext_workspace_handle_v1* handle; + uint32_t state = 0; + }; + Taskbar(const std::string&, const waybar::Bar&, const Json::Value&); ~Taskbar(); void update(); @@ -153,32 +173,56 @@ class Taskbar : public waybar::AModule { IconLoader icon_loader_; std::unordered_set ignore_list_; + std::unordered_set squash_list_; std::map app_ids_replace_map_; struct zwlr_foreign_toplevel_manager_v1* manager_; + struct ext_workspace_manager_v1* workspace_manager_; struct wl_seat* seat_; + std::vector workspace_groups_; + std::vector> workspaces_; + struct ext_workspace_handle_v1* current_workspace_ = nullptr; public: /* Callbacks for global registration */ void register_manager(struct wl_registry*, uint32_t name, uint32_t version); + void register_workspace_manager(struct wl_registry*, uint32_t name, uint32_t version); void register_seat(struct wl_registry*, uint32_t name, uint32_t version); /* Callbacks for the wlr protocol */ void handle_toplevel_create(struct zwlr_foreign_toplevel_handle_v1*); void handle_finished(); + void handle_workspace_group_create(struct ext_workspace_group_handle_v1*); + void handle_workspace_group_removed(struct ext_workspace_group_handle_v1*); + void handle_workspace_create(struct ext_workspace_handle_v1*); + void handle_workspace_done(); + void handle_workspace_finished(); + void handle_workspace_removed(struct ext_workspace_handle_v1*); public: void add_button(Gtk::Button&); void move_button(Gtk::Button&, int); void remove_button(Gtk::Button&); void remove_task(uint32_t); + void assign_current_workspace(Task&); + void update_bar_css_classes(); bool show_output(struct wl_output*) const; bool all_outputs() const; const IconLoader& icon_loader() const; const std::unordered_set& ignore_list() const; + const std::unordered_set& squash_list() const; const std::map& app_ids_replace_map() const; + std::size_t task_id_count(std::string_view id) const; + std::size_t task_title_count(std::string_view title) const; + + auto tasks() { + return tasks_ | std::views::transform([](auto& task) -> Task& { return *task; }); + } + + private: + void set_bar_css_class(const std::string&, bool); }; } /* namespace waybar::modules::wlr */ diff --git a/include/modules/wwan.hpp b/include/modules/wwan.hpp new file mode 100644 index 00000000..0aa7112c --- /dev/null +++ b/include/modules/wwan.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include + +#include "ALabel.hpp" +#include "util/format.hpp" +#include "util/sleeper_thread.hpp" + +namespace waybar::modules { + +class Wwan : public ALabel { + public: + Wwan(const std::string&, const Json::Value&); + virtual ~Wwan(); + auto update() -> void override; + + private: + void updateCurrentModem(); + + util::SleeperThread thread_; + std::string state_; + GDBusConnection* connection = nullptr; + MMManager* manager = nullptr; + MMModem* current_modem = nullptr; + + bool hideDisconnected = true; + + const std::string dbus_name = "org.freedesktop.ModemManager1"; + const std::string dbus_obj_path = "/org/freedesktop/ModemManager1/"; + const std::string dbus_modems_path = "/org/freedesktop/ModemManager1/Modems/"; +}; + +} // namespace waybar::modules diff --git a/include/util/SafeSignal.hpp b/include/util/SafeSignal.hpp index afb0893e..b1e3a91a 100644 --- a/include/util/SafeSignal.hpp +++ b/include/util/SafeSignal.hpp @@ -12,11 +12,6 @@ #include #include -#ifdef __OpenBSD__ -#define SIGRTMIN SIGUSR1 - 1 -#define SIGRTMAX SIGUSR1 + 1 -#endif - namespace waybar { /** diff --git a/include/util/audio_backend.hpp b/include/util/audio_backend.hpp index 3737ae26..2086e868 100644 --- a/include/util/audio_backend.hpp +++ b/include/util/audio_backend.hpp @@ -14,6 +14,11 @@ namespace waybar::util { +enum class PulseaudioTarget { + Sink, + Source, +}; + class AudioBackend { private: static void subscribeCb(pa_context*, pa_subscription_event_type_t, uint32_t, void*); @@ -22,12 +27,21 @@ class AudioBackend { static void sourceInfoCb(pa_context*, const pa_source_info* i, int, void* data); static void serverInfoCb(pa_context*, const pa_server_info*, void*); static void volumeModifyCb(pa_context*, int, void*); + static void sourceVolumeModifyCb(pa_context*, int, void*); void connectContext(); + // Non-throwing reconnect used from the PulseAudio callback thread. Throwing + // across the libpulse C callback boundary calls std::terminate, so this + // swallows any failure and reports it via the return value instead. + bool reconnectContext() noexcept; pa_threaded_mainloop* mainloop_; pa_mainloop_api* mainloop_api_; pa_context* context_; + // Guards against the FAILED -> connect -> FAILED recursion / busy loop when a + // reconnect attempt fails synchronously inside pa_context_connect(). + bool reconnecting_{false}; pa_cvolume pa_volume_; + pa_cvolume pa_source_volume_; // SINK uint32_t sink_idx_{0}; @@ -50,6 +64,7 @@ class AudioBackend { std::string default_source_name_; std::vector ignored_sinks_; + std::map sink_mapping_; std::function on_updated_cb_ = NOOP; @@ -67,10 +82,13 @@ class AudioBackend { AudioBackend(std::function on_updated_cb, private_constructor_tag tag); ~AudioBackend(); - void changeVolume(uint16_t volume, uint16_t min_volume = 0, uint16_t max_volume = 100); - void changeVolume(ChangeType change_type, double step = 1, uint16_t max_volume = 100); + void changeVolume(uint16_t volume, uint16_t min_volume = 0, uint16_t max_volume = 100, + PulseaudioTarget target = PulseaudioTarget::Sink); + void changeVolume(ChangeType change_type, double step = 1, uint16_t max_volume = 100, + PulseaudioTarget target = PulseaudioTarget::Sink); void setIgnoredSinks(const Json::Value& config); + void setSinkMapping(const Json::Value& config); std::string getSinkPortName() const { return port_name_; } std::string getFormFactor() const { return form_factor_; } @@ -92,7 +110,11 @@ class AudioBackend { void toggleSourceMute(); void toggleSourceMute(bool); + uint16_t getVolume(PulseaudioTarget) const; + bool getMuted(PulseaudioTarget) const; + void unmute(PulseaudioTarget); + bool isBluetooth(); }; -} // namespace waybar::util \ No newline at end of file +} // namespace waybar::util diff --git a/include/util/backlight_backend.hpp b/include/util/backlight_backend.hpp index ba3ccca7..8a81505c 100644 --- a/include/util/backlight_backend.hpp +++ b/include/util/backlight_backend.hpp @@ -27,9 +27,11 @@ namespace waybar::util { class BacklightDevice { public: BacklightDevice() = default; - BacklightDevice(std::string name, int actual, int max, bool powered); + BacklightDevice(std::string name, int actual, int max, bool powered, + std::string subsystem = "backlight"); std::string name() const; + std::string subsystem() const; int get_actual() const; void set_actual(int actual); int get_max() const; @@ -45,6 +47,7 @@ class BacklightDevice { int actual_ = 1; int max_ = 1; bool powered_ = true; + std::string subsystem_ = "backlight"; }; class BacklightBackend { @@ -70,7 +73,8 @@ class BacklightBackend { std::mutex udev_thread_mutex_; private: - void set_brightness_internal(const std::string& device_name, int brightness, int max_brightness); + void set_brightness_internal(const std::string& device_name, int brightness, int max_brightness, + const std::string& subsystem = "backlight"); std::function on_updated_cb_; std::chrono::milliseconds polling_interval_; diff --git a/include/util/command_line_stream.hpp b/include/util/command_line_stream.hpp new file mode 100644 index 00000000..05c82fc4 --- /dev/null +++ b/include/util/command_line_stream.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include +#include + +namespace waybar::util::command { + +class LineStream { + public: + using OutputCallback = std::function; + using ExitCallback = std::function; + + LineStream(std::string output_name, OutputCallback on_output, ExitCallback on_exit); + ~LineStream(); + + void start(const std::string& cmd); + void stop(); + bool running() const; + + private: + bool handleStdout(Glib::IOCondition condition); + void handleExit(Glib::Pid pid, int status); + void closeStdout(); + void drainStdout(bool flush_trailing_line); + static int statusToExitCode(int status); + + std::string output_name_; + OutputCallback on_output_; + ExitCallback on_exit_; + std::string buffer_; + Glib::Pid pid_; + int stdout_fd_; + sigc::connection stdout_connection_; + sigc::connection child_connection_; +}; + +} // namespace waybar::util::command diff --git a/include/util/css_reload_helper.hpp b/include/util/css_reload_helper.hpp index 032b2382..e198a5b0 100644 --- a/include/util/css_reload_helper.hpp +++ b/include/util/css_reload_helper.hpp @@ -14,12 +14,14 @@ struct pollfd; namespace waybar { class CssReloadHelper { public: - CssReloadHelper(std::string cssFile, std::function callback); + CssReloadHelper(std::string cssFile, std::function callback); virtual ~CssReloadHelper() = default; virtual void monitorChanges(); + virtual void changeCssFile(const std::string& newCssFile); + protected: std::vector parseImports(const std::string& cssFile); @@ -42,7 +44,7 @@ class CssReloadHelper { private: std::string m_cssFile; - std::function m_callback; + std::function m_callback; std::vector>> m_fileMonitors; }; diff --git a/include/util/enum.hpp b/include/util/enum.hpp index 681385fd..6780a9e1 100644 --- a/include/util/enum.hpp +++ b/include/util/enum.hpp @@ -1,19 +1,33 @@ #pragma once +#include +#include #include #include #include +#include "util/string.hpp" + namespace waybar::util { template -struct EnumParser { - public: - EnumParser(); - ~EnumParser(); - +//struct EnumParser { EnumType parseStringToEnum(const std::string& str, - const std::map& enumMap); -}; + const std::map& enumMap) { + std::string uppercaseStr = capitalize(str); + std::map capitalizedEnumMap; + std::transform( + enumMap.begin(), enumMap.end(), + std::inserter(capitalizedEnumMap, capitalizedEnumMap.end()), + [](const auto& pair) { + return std::make_pair(capitalize(pair.first), pair.second); + }); + + auto it = capitalizedEnumMap.find(uppercaseStr); + if (it != capitalizedEnumMap.end()) return it->second; + + throw std::invalid_argument("Invalid string representation for enum"); +// } +} } // namespace waybar::util diff --git a/include/util/format.hpp b/include/util/format.hpp index c8ed837a..7addb045 100644 --- a/include/util/format.hpp +++ b/include/util/format.hpp @@ -5,41 +5,69 @@ class pow_format { public: - pow_format(long long val, std::string&& unit, bool binary = false) - : val_(val), unit_(unit), binary_(binary) {}; + pow_format(long long val, std::string&& unit, bool binary = false, bool skip_decimal = false, + int min_pow_for_decimal = 0) + : val_(val), + unit_(unit), + binary_(binary), + skip_decimal_(skip_decimal), + min_pow_for_decimal_(min_pow_for_decimal) {}; long long val_; std::string unit_; bool binary_; + bool skip_decimal_; + int min_pow_for_decimal_; }; namespace fmt { template <> struct formatter { - char spec = 0; - int width = 0; + char spec = 0; // alignment: '>', '<', '=' (0 = none) + int width = 0; // width digits; enforced only when scale_spec != 0 + char scale_spec = 0; // forced scale: 0 = auto, else one of '#','k','M','G','T','P' + char unit_pref = 0; // unit tri-state: 0 = default, 'u' = hide, 'U' = show + char base_pref = 0; // base tri-state: 0 = call-site, 'b' = decimal, 'B' = binary + bool force_int = false; // 'i': force integer display template constexpr auto parse(ParseContext& ctx) -> decltype(ctx.begin()) { auto it = ctx.begin(), end = ctx.end(); if (it != end && *it == ':') ++it; - if (it && (*it == '>' || *it == '<' || *it == '=')) { + if (it != end && (*it == '>' || *it == '<' || *it == '=')) { spec = *it; ++it; } - if (it == end || *it == '}') return it; - if ('0' <= *it && *it <= '9') { - // We ignore it for now, but keep it for compatibility with - // existing configs where the format for pow_format'ed numbers was - // 'string' and specifications such as {:>9} were valid. - // The rationale for ignoring it is that the only reason to specify - // an alignment and a with is to get a fixed width bar, and ">" is - // sufficient in this implementation. + // Consume scale/flag modifiers and the width in any order, until '}' or end. + // The width digits (parsed but only enforced when a scale is forced — see + // format()) may appear anywhere among the modifiers, so both {:=#3} and the + // more natural {:=3#} are accepted. On an unrecognised char we stop and let + // fmt raise its usual error. + while (it != end && *it != '}') { + char c = *it; + if (c == '#' || c == 'k' || c == 'M' || c == 'G' || c == 'T' || c == 'P') { + scale_spec = c; + ++it; + } else if (c == 'u' || c == 'U') { + unit_pref = c; + ++it; + } else if (c == 'b' || c == 'B') { + base_pref = c; + ++it; + } else if (c == 'i') { + force_int = true; + ++it; + } else if ('0' <= c && c <= '9') { + // Width kept for compatibility with existing configs such as {:>9}; only + // enforced (fixed field + '#' overflow) when a scale is forced. #if FMT_VERSION < 80000 - width = parse_nonnegative_int(it, end, ctx); + width = parse_nonnegative_int(it, end, ctx); #else - width = detail::parse_nonnegative_int(it, end, -1); + width = detail::parse_nonnegative_int(it, end, -1); #endif + } else { + break; + } } return it; } @@ -47,44 +75,100 @@ struct formatter { template auto format(const pow_format& s, FormatContext& ctx) const -> decltype(ctx.out()) { const char* units[] = {"", "k", "M", "G", "T", "P", nullptr}; + const int max_pow = 5; // last valid index in units[] - auto base = s.binary_ ? 1024ull : 1000ll; + // Effective base: 'b'/'B' override the call-site binary_. + bool binary = base_pref == 'B' ? true : base_pref == 'b' ? false : s.binary_; + auto base = binary ? 1024ull : 1000ll; + auto div = 1ll; auto fraction = (double)s.val_; int pow; - for (pow = 0; units[pow + 1] != nullptr && fraction / base >= 1; ++pow) { - fraction /= base; + if (scale_spec != 0) { + // Forced scale: map the char to a fixed index into units[]. + switch (scale_spec) { + case 'k': + pow = 1; + break; + case 'M': + pow = 2; + break; + case 'G': + pow = 3; + break; + case 'T': + pow = 4; + break; + case 'P': + pow = 5; + break; + default: + pow = 0; + break; // '#' -> base scale + } + if (pow > max_pow) pow = max_pow; + for (int i = 0; i < pow; ++i) div *= base; + fraction /= div; + } else { + for (pow = 0; units[pow + 1] != nullptr && fraction / base >= 1; ++pow) { + fraction /= base; + div *= base; + } } - auto number_width = 5 // coeff in {:.1f} format - + s.binary_; // potential 4th digit before the decimal point - auto max_width = number_width + 1 // prefix from units array - + s.binary_ // for the 'i' in GiB. - + s.unit_.length(); + // Precision: 'i' forces 0; otherwise 1 (or 0 when skip_decimal_ divides + // evenly). min_pow_for_decimal_ keeps its default-branch-only effect. + int precision = force_int ? 0 : (s.skip_decimal_ && ((s.val_ % div) == 0)) ? 0 : 1; + if (!force_int && scale_spec == 0 && pow < s.min_pow_for_decimal_) precision = 0; + + // Unit visibility: default on for auto scale, off for a forced scale; 'u'/'U' + // override. The binary 'i' is part of the scale prefix, so the unit is just + // unit_. + bool hide_unit = unit_pref == 'u' || (unit_pref == 0 && scale_spec != 0); + + // Scale prefix (letter + binary 'i'), suppressed entirely when a scale is + // forced. + std::string prefix = + scale_spec != 0 ? "" : std::string(units[pow]) + ((binary && pow) ? "i" : ""); + std::string unit = hide_unit ? "" : s.unit_; + + auto number_width = 3 + precision // coeff in {:.{precision}f} format + + (precision != 0) // float dot + + binary; // potential digit before the decimal point + // In auto mode the prefix column is always reserved (letter + optional 'i'), + // matching the historical fixed max_width even at base scale (the '=' padding + // fills the gap). A forced scale drops the prefix column entirely. + auto prefix_col = scale_spec != 0 ? 0 : 1 + binary; + auto max_width = number_width + prefix_col + unit.length(); + + // The numeric coefficient string. When a scale is forced with a width and the + // number does not fit, it overflows to '#' (spreadsheet-style). + bool fixed_num = scale_spec != 0 && width > 0; + std::string number = fmt::format("{:.{}f}", fraction, precision); + if (fixed_num && (int)number.length() > width) number = std::string(width, '#'); + + // Base-scale compensation for the '=' column-align: only in auto mode, where + // the absent prefix (and binary 'i') would otherwise shift the unit column. + const char* padding = (scale_spec == 0 && pow == 0) ? (binary ? " " : " ") : ""; - const char* format; - std::string string; switch (spec) { - case '>': - return fmt::format_to(ctx.out(), "{:>{}}", fmt::format("{}", s), max_width); - case '<': - return fmt::format_to(ctx.out(), "{:<{}}", fmt::format("{}", s), max_width); case '=': - format = "{coefficient:<{number_width}.1f}{padding}{prefix}{unit}"; - break; + // Column-align: left-justify the coefficient within its column, then pad + // so the prefix/unit line up across values of different magnitude. + return fmt::format_to(ctx.out(), "{:<{}}{}{}{}", number, fixed_num ? width : number_width, + padding, prefix, unit); + case '>': + case '<': case 0: - default: - format = "{coefficient:.1f}{prefix}{unit}"; - break; + default: { + // Right-justify the numeric field to the fixed width when forced. + std::string body = + (fixed_num ? fmt::format("{:>{}}", number, width) : number) + prefix + unit; + if (spec == '>') return fmt::format_to(ctx.out(), "{:>{}}", body, max_width); + if (spec == '<') return fmt::format_to(ctx.out(), "{:<{}}", body, max_width); + return fmt::format_to(ctx.out(), "{}", body); + } } - return fmt::format_to( - ctx.out(), fmt::runtime(format), fmt::arg("coefficient", fraction), - fmt::arg("number_width", number_width), - fmt::arg("prefix", std::string() + units[pow] + ((s.binary_ && pow) ? "i" : "")), - fmt::arg("unit", s.unit_), - fmt::arg("padding", pow ? "" - : s.binary_ ? " " - : " ")); } }; diff --git a/include/util/geoclue_backend.hpp b/include/util/geoclue_backend.hpp new file mode 100644 index 00000000..4bf19091 --- /dev/null +++ b/include/util/geoclue_backend.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include "giomm/dbusproxy.h" + +namespace waybar::util::GeoClueBackend { + +class GeoClueBackend { + private: + guint watcherID_; + sigc::connection signal_conn; + Glib::RefPtr proxy; + bool connected; + + /* Hack to keep constructor inaccessible but still public. + * This is required to be able to use std::make_shared. + * It is important to keep this class only accessible via a reference-counted + * pointer because the destructor will manually free memory, and this could be + * a problem with C++20's copy and move semantics. + */ + struct PrivateConstructorTag {}; + + public: + sigc::signal in_use_changed_signal_event; + + std::atomic location_in_use; // GeoClue is being used + + static std::shared_ptr getInstance(); + + // DBus callbacks + void onAppear(const Glib::RefPtr&, const Glib::ustring&, + const Glib::ustring&); + void onVanished(const Glib::RefPtr&, const Glib::ustring&); + void propertyChanged(const Gio::DBus::Proxy::MapChangedProperties& changedProperties, + const std::vector& invalidatedProperties); + + GeoClueBackend(PrivateConstructorTag tag); + ~GeoClueBackend(); +}; +} // namespace waybar::util::GeoClueBackend diff --git a/include/util/hosts_check.hpp b/include/util/hosts_check.hpp new file mode 100644 index 00000000..4179f633 --- /dev/null +++ b/include/util/hosts_check.hpp @@ -0,0 +1,7 @@ +#pragma once + +#include + +namespace waybar::util { +bool valid_host(const Json::Value& config); +} // namespace waybar::util diff --git a/include/util/json.hpp b/include/util/json.hpp index 5f756f35..5f45ae92 100644 --- a/include/util/json.hpp +++ b/include/util/json.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #if (FMT_VERSION >= 90000) @@ -26,14 +27,19 @@ class JsonParser { Json::Value root; // replace all occurrences of "\x" with "\u00", because JSON doesn't allow "\x" escape sequences - std::string modifiedJsonStr = replaceHexadecimalEscape(jsonStr); + std::string modifiedJsonStr; + const std::string* json = &jsonStr; + if (jsonStr.find("\\x") != std::string::npos) { + modifiedJsonStr = replaceHexadecimalEscape(jsonStr); + json = &modifiedJsonStr; + } - std::istringstream jsonStream(modifiedJsonStr); std::string errs; // Use local CharReaderBuilder for thread safety - the IPC singleton's // parser can be called concurrently from multiple module threads Json::CharReaderBuilder readerBuilder; - if (!Json::parseFromStream(readerBuilder, jsonStream, &root, &errs)) { + auto reader = std::unique_ptr(readerBuilder.newCharReader()); + if (!reader->parse(json->data(), json->data() + json->size(), &root, &errs)) { throw std::runtime_error("Error parsing JSON: " + errs); } return root; diff --git a/include/util/kill_signal.hpp b/include/util/kill_signal.hpp index 26d156e9..221c554a 100644 --- a/include/util/kill_signal.hpp +++ b/include/util/kill_signal.hpp @@ -13,14 +13,14 @@ enum class KillSignalAction : std::uint8_t { HIDE, NOOP, }; -const std::map userKillSignalActions = { +inline const std::map userKillSignalActions = { {"TOGGLE", KillSignalAction::TOGGLE}, {"RELOAD", KillSignalAction::RELOAD}, {"SHOW", KillSignalAction::SHOW}, {"HIDE", KillSignalAction::HIDE}, {"NOOP", KillSignalAction::NOOP}}; -const KillSignalAction SIGNALACTION_DEFAULT_SIGUSR1 = KillSignalAction::TOGGLE; -const KillSignalAction SIGNALACTION_DEFAULT_SIGUSR2 = KillSignalAction::RELOAD; +inline const KillSignalAction SIGNALACTION_DEFAULT_SIGUSR1 = KillSignalAction::TOGGLE; +inline const KillSignalAction SIGNALACTION_DEFAULT_SIGUSR2 = KillSignalAction::RELOAD; }; // namespace waybar::util diff --git a/include/util/pipewire/pipewire_backend.hpp b/include/util/pipewire/pipewire_backend.hpp index 90fb2bb2..7ea0ffee 100644 --- a/include/util/pipewire/pipewire_backend.hpp +++ b/include/util/pipewire/pipewire_backend.hpp @@ -29,7 +29,7 @@ class PipewireBackend { public: sigc::signal privacy_nodes_changed_signal_event; - std::unordered_map privacy_nodes; + std::unordered_map privacy_nodes; std::mutex mutex_; static std::shared_ptr getInstance(); diff --git a/include/util/pipewire/privacy_node_info.hpp b/include/util/pipewire/privacy_node_info.hpp index 54acad42..cd45313b 100644 --- a/include/util/pipewire/privacy_node_info.hpp +++ b/include/util/pipewire/privacy_node_info.hpp @@ -12,10 +12,11 @@ enum PrivacyNodeType { PRIVACY_NODE_TYPE_NONE, PRIVACY_NODE_TYPE_VIDEO_INPUT, PRIVACY_NODE_TYPE_AUDIO_INPUT, - PRIVACY_NODE_TYPE_AUDIO_OUTPUT + PRIVACY_NODE_TYPE_AUDIO_OUTPUT, + PRIVACY_NODE_TYPE_LOCATION }; -class PrivacyNodeInfo { +class PWPrivacyNodeInfo { public: PrivacyNodeType type = PRIVACY_NODE_TYPE_NONE; uint32_t id; diff --git a/include/util/sleeper_thread.hpp b/include/util/sleeper_thread.hpp index 1f3a372d..9d20f252 100644 --- a/include/util/sleeper_thread.hpp +++ b/include/util/sleeper_thread.hpp @@ -79,6 +79,12 @@ class SleeperThread { auto sleep_for(std::chrono::system_clock::duration dur) { std::unique_lock lk(mutex_); CancellationGuard cancel_lock; + + condvar_.wait(lk, [this] { + return !is_paused_ || signal_.load(std::memory_order_relaxed) || + !do_run_.load(std::memory_order_relaxed); + }); + constexpr auto max_time_point = std::chrono::steady_clock::time_point::max(); auto wait_end = max_time_point; auto now = std::chrono::steady_clock::now(); @@ -95,6 +101,12 @@ class SleeperThread { time_point) { std::unique_lock lk(mutex_); CancellationGuard cancel_lock; + + condvar_.wait(lk, [this] { + return !is_paused_ || signal_.load(std::memory_order_relaxed) || + !do_run_.load(std::memory_order_relaxed); + }); + return condvar_.wait_until(lk, time_point, [this] { return signal_.load(std::memory_order_relaxed) || !do_run_.load(std::memory_order_relaxed); }); @@ -122,6 +134,17 @@ class SleeperThread { } } + void pause() { + std::lock_guard lock(mutex_); + is_paused_ = true; + } + + void resume() { + std::lock_guard lock(mutex_); + is_paused_ = false; + condvar_.notify_all(); + } + ~SleeperThread() { connection_.disconnect(); stop(); @@ -137,6 +160,7 @@ class SleeperThread { std::atomic do_run_ = true; std::atomic signal_ = false; sigc::connection connection_; + bool is_paused_{false}; }; } // namespace waybar::util diff --git a/include/util/utf8_string.hpp b/include/util/utf8_string.hpp new file mode 100644 index 00000000..07a04466 --- /dev/null +++ b/include/util/utf8_string.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace waybar::util { +size_t utf8_width(const std::string& str); +void utf8_truncate(std::string& s, const std::string& ellipsis, size_t max_len); +} // namespace waybar::util diff --git a/man/waybar-backlight-slider.5.scd b/man/waybar-backlight-slider.5.scd index c6f027a1..05e48b26 100644 --- a/man/waybar-backlight-slider.5.scd +++ b/man/waybar-backlight-slider.5.scd @@ -29,13 +29,30 @@ The brightness can be controlled by dragging the slider across the bar or clicki *device*: ++ typeof: string ++ - The name of the preferred device to control. If left empty, a device will be chosen automatically. + The name of the preferred device to control. If left empty, a device will be chosen automatically. ++ + Both screen backlights (the udev *backlight* subsystem) and keyboard backlights (LEDs in the udev *leds* subsystem, e.g. *white:kbd_backlight*) are supported; name such an LED here to control it. When left empty, a screen backlight is always preferred for automatic selection. + +*interval*: ++ + typeof: uint ++ + default: 1000 ++ + The interval in milliseconds at which the brightness is polled and the slider is updated. *expand*: ++ typeof: bool ++ default: false ++ Enables this module to consume all left over space dynamically. +*Note*: As well as the JSON configuration, the slider modules are special in +that they *require* styling to work. You *need* to set *min-width* and/or +*min-height* (depending on whether your slider is vertical or not) for it to +display correctly. That is a GTK detail, not an issue with Waybar. See the +*STYLE* section below. + +*Warning*: If *min* is set to *0* (default), the slider can set brightness to +*0*, which may completely disable the backlight on some devices, making the +screen fully black. Consider setting a small minimum value (e.g. *10*) or +configuring brightness keybinds as a fallback. + # EXAMPLES ``` diff --git a/man/waybar-backlight.5.scd b/man/waybar-backlight.5.scd index 20810051..e3414c6d 100644 --- a/man/waybar-backlight.5.scd +++ b/man/waybar-backlight.5.scd @@ -15,6 +15,11 @@ The *backlight* module displays the current backlight level. default: 2 ++ The interval in which information gets polled. +*device*: ++ + typeof: string ++ + The name of the preferred backlight device to display. If left empty, a device will be chosen automatically. ++ + Both screen backlights (the udev *backlight* subsystem) and keyboard backlights (LEDs in the udev *leds* subsystem, e.g. *white:kbd_backlight*) are supported; name such an LED here to control it. When left empty, a screen backlight is always preferred for automatic selection. + *format*: ++ typeof: string ++ default: {percent}% ++ @@ -74,7 +79,13 @@ The *backlight* module displays the current backlight level. *reverse-scrolling*: ++ typeof: bool ++ - Option to reverse the scroll direction. + default: false ++ + Option to reverse the scroll direction for devices other than a mouse (touchpad, trackpad, etc). + +*reverse-mouse-scrolling*: ++ + typeof: bool ++ + default: false ++ + Option to reverse the scroll direction for mice. *scroll-step*: ++ typeof: float ++ @@ -86,6 +97,15 @@ The *backlight* module displays the current backlight level. default: 0.0 ++ The minimum brightness of the backlight. +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*tooltip-format*: ++ + typeof: string ++ + Text to be displayed in the tooltip. + *menu*: ++ typeof: string ++ Action that popups the menu. @@ -104,6 +124,16 @@ The *backlight* module displays the current backlight level. default: false ++ Enables this module to consume all left over space dynamically. +# FORMAT REPLACEMENTS + +*{percent}*: The current brightness in percent. + +*{percent_exp}*: The current brightness in percent, adjusted with a power curve to better match perceived brightness. + +*{icon}*: The icon from *format-icons* chosen according to *{percent}*. + +*{icon_exp}*: The icon from *format-icons* chosen according to *{percent_exp}*. + # EXAMPLE: ``` diff --git a/man/waybar-battery.5.scd b/man/waybar-battery.5.scd index 42b9e046..0708634d 100644 --- a/man/waybar-battery.5.scd +++ b/man/waybar-battery.5.scd @@ -27,11 +27,26 @@ The *battery* module displays the current capacity and state (eg. charging) of y default: false ++ Option to use the battery design capacity instead of its current maximal capacity. +*full-at-plugged*: ++ + typeof: bool ++ + default: false ++ + When enabled, a battery that is *Full* while the adapter is online is reported with the *Plugged* status instead of *Full* (so you can style/format it separately). Disabled by default to preserve the existing *Full* behaviour. + *interval*: ++ typeof: integer ++ default: 60 ++ The interval in which the information gets polled. +*smooth-power*: ++ + typeof: bool ++ + default: false ++ + Enable exponential smoothing of the battery power reading used to estimate the *{time}* remaining, so the estimate fluctuates less between refreshes. + +*smooth-power-time-constant*: ++ + typeof: double ++ + default: 260.0 ++ + The time constant (in seconds) of the *smooth-power* exponential filter. Only used when *smooth-power* is enabled. Values below 1.0 are clamped to 1.0. + *states*: ++ typeof: object ++ A number of battery states which get activated on certain capacity levels. See *waybar-states(5)*. @@ -109,6 +124,11 @@ The *battery* module displays the current capacity and state (eg. charging) of y default: true ++ Option to disable tooltip on hover. +*tooltip-format*: ++ + typeof: string ++ + default: {timeTo} ++ + The tooltip format. See *CUSTOM FORMATS* for status/state-specific variants. + *bat-compatibility*: ++ typeof: bool ++ default: false ++ @@ -150,6 +170,12 @@ The *battery* module displays the current capacity and state (eg. charging) of y *{health}*: The percentage of the highest-capacity battery's original maximum charge it can still hold. +The following additional replacement is available in *tooltip-format* (and its +custom variants) only: + +*{timeTo}*: Either an estimate of time until full or empty, or "Full", "Plugged" +or "Empty" depending on the current battery status. + # TIME FORMAT The *battery* module allows you to define how time should be formatted via *format-time*. @@ -169,6 +195,11 @@ The *battery* module allows one to define custom formats based on up to two fact *format--*: You can also set a custom format depending on both values. +The tooltip format can be customized the same way. The best fitting from +*tooltip-format*, *tooltip-format-*, *tooltip-format-* and +*tooltip-format--* will be used (using the same logic as +*format-\**). + # STATES - Every entry (*state*) consists of a ** (typeof: *string*) and a ** (typeof: *integer*). @@ -214,6 +245,20 @@ Where: } ``` +Custom icon set depending on the status (*format-icons* as an object): + +``` +"battery": { + "bat": "BAT2", + "interval": 60, + "format": "{capacity}% {icon}", + "format-icons": { + "default": ["󰂎", "󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"], + "charging": ["󰢟", "󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"] + } +} +``` + # STYLE - *#battery* diff --git a/man/waybar-bluetooth.5.scd b/man/waybar-bluetooth.5.scd index fd7d5fb5..2fb6b90f 100644 --- a/man/waybar-bluetooth.5.scd +++ b/man/waybar-bluetooth.5.scd @@ -16,6 +16,10 @@ Addressed by *bluetooth* typeof: string ++ Use the controller with the defined alias. Otherwise, a random controller is used. Recommended to define when there is more than 1 controller available to the system. +*controller-alias*: ++ + typeof: string ++ + Synonym for *controller*. If both are set, *controller-alias* takes precedence. + *format-device-preference*: ++ typeof: array ++ A ranking of bluetooth devices, addressed by their alias. The order is from *first displayed* to *last displayed*. ++ @@ -178,6 +182,9 @@ At the time of writing, the experimental features of BlueZ need to be turned on, *{device_battery_percentage}*: Battery percentage of the displayed device if available. Use only in the config options defined below. +*{device_battery_percentage_peripheral}*: Battery percentage of the peripheral half of a split keyboard (e.g., ZMK keyboards with separate central and peripheral batteries). ++ +This is read from GATT Battery Service characteristics that have a User Description descriptor. Use only in the config options defined below. + ## CONFIGURATION *format-connected-battery*: ++ @@ -220,6 +227,17 @@ At the time of writing, the experimental features of BlueZ need to be turned on, } ``` +Split keyboard with separate central/peripheral batteries (e.g., ZMK): + +``` +"bluetooth": { + "format-device-preference": [ "Keyball44" ], + "format": "", + "format-connected-battery": " {device_battery_percentage}%|{device_battery_percentage_peripheral}%", + "tooltip-format-connected": "{device_alias}\\nCentral: {device_battery_percentage}%\\nPeripheral: {device_battery_percentage_peripheral}%" +} +``` + # STYLE - *#bluetooth* @@ -227,6 +245,7 @@ At the time of writing, the experimental features of BlueZ need to be turned on, - *#bluetooth.off* - *#bluetooth.on* - *#bluetooth.connected* +- *#bluetooth.no-controller* - *#bluetooth.discoverable* - *#bluetooth.discovering* - *#bluetooth.pairable* diff --git a/man/waybar-cava.5.scd b/man/waybar-cava.5.scd index 95336e24..14ab9ef3 100644 --- a/man/waybar-cava.5.scd +++ b/man/waybar-cava.5.scd @@ -6,10 +6,10 @@ waybar - cava module # DESCRIPTION -*cava* module for karlstav/cava project. See it on github: https://github.com/karlstav/cava. - -Module supports two different frontends starting from the 0.15.0 release. The frontend that -will be used is managed by the method parameter in the [output] section of the cava configuration file. +The *cava* module integrates the *karlstav/cava* audio visualizer into Waybar. +It supports two frontends: a text-based *raw* frontend and a GPU-based *GLSL* +frontend. The active frontend is selected by the *method* option in the +*[output]* section of the cava configuration file. # FILES @@ -27,177 +27,194 @@ libcava lives in: # CONFIGURATION [- *Option* -:- *Typeof* -:- *Default* -:- *Description* +:[ *Type* +:[ *Default* +:[ *Description* |[ *cava_config* :[ string :[ -:< Path where cava configuration file is placed to -|[ *method* \[output\] -:[ string -:[ -:< Manages which frontend Waybar cava module should use. Values: raw, sdl_glsl +:[ Path to the cava configuration file. When provided, cava settings are read from it first. |[ *framerate* :[ integer :[ 30 -:[ Frames per second. Is used as a replacement for *interval* +:[ Target frames per second. Replaces the generic *interval* option. |[ *autosens* :[ integer :[ 1 -:[ Will attempt to decrease sensitivity if the bars peak +:[ Automatically decrease sensitivity when the bars peak. |[ *sensitivity* :[ integer :[ 100 -:[ Manual sensitivity in %. If autosens is enabled, this will only be the initial value. 200 means double height. Accepts only non-negative values +:[ Manual sensitivity in %. If *autosens* is enabled, this is only the initial value. 200 means double height. Accepts only non-negative values. |[ *bars* :[ integer :[ 12 -:[ The number of bars +:[ The number of bars. |[ *lower_cutoff_freq* :[ long integer :[ 50 -:[ Lower cutoff frequencies for lowest bars the bandwidth of the visualizer +:[ Lower cutoff frequency for the visualizer bandwidth. |[ *higher_cutoff_freq* :[ long integer :[ 10000 -:[ Higher cutoff frequencies for highest bars the bandwidth of the visualizer +:[ Higher cutoff frequency for the visualizer bandwidth. |[ *sleep_timer* :[ integer :[ 5 -:[ Seconds with no input before cava main thread goes to sleep mode +:[ Seconds of silence before cava enters sleep mode. |[ *hide_on_silence* :[ bool :[ false -:[ Hides the widget if no input (after sleep_timer elapsed) +:[ Hide the widget when silence lasts longer than *sleep_timer*. |[ *format_silent* :[ string :[ -:[ Widget's text after sleep_timer elapsed (hide_on_silence has to be false) +:[ Text shown when the module is silent and *hide_on_silence* is false. **Raw frontend only.** +|[ *format-icons* +:[ array +:[ +:[ Array of characters used to render bar levels in the raw frontend. The number of items determines the dynamic range. |[ *method* \[input\] :[ string :[ pulse -:[ Audio capturing method. Possible methods are: pipewire, pulse, alsa, fifo, sndio or shmem +:[ Audio capture backend. Supported values: pipewire, pulse, alsa, fifo, sndio, shmem. |[ *source* :[ string :[ auto -:[ See cava configuration +:[ Audio source identifier. See the cava documentation for details. |[ *sample_rate* :[ long integer :[ 44100 -:[ See cava configuration +:[ See the cava documentation. |[ *sample_bits* :[ integer :[ 16 -:[ See cava configuration +:[ See the cava documentation. |[ *stereo* :[ bool :[ true -:[ Visual channels +:[ Enable stereo visualization. |[ *reverse* :[ bool :[ false -:[ Displays frequencies the other way around +:[ Reverse the bar order (highest frequencies on the left). |[ *bar_delimiter* :[ integer :[ 0 -:[ Each bar is separated by a delimiter. Use decimal value in ascii table(i.e. 59 = ";"). 0 means no delimiter +:[ Delimiter placed between bars in the raw output. Use a decimal ASCII value (e.g. 59 = ";"). 0 means no delimiter. +|[ *data_format* +:[ string +:[ ascii +:[ Raw data format. Can be 'binary' or 'ascii'. **Raw frontend only.** +|[ *raw_target* +:[ string +:[ +:[ Raw output target. A fifo will be created if target does not exist. **Raw frontend only.** |[ *monstercat* :[ bool :[ false -:[ Disables or enables the so-called "Monstercat smoothing" with or without "waves" +:[ Enable Monstercat smoothing. |[ *waves* :[ bool :[ false -:[ Disables or enables the so-called "Monstercat smoothing" with or without "waves" +:[ Enable the waves effect alongside Monstercat smoothing. |[ *noise_reduction* -:[ integer -:[ 77 -:[ Range between 0 - 100. The raw visualization is very noisy, this factor adjusts the integral and gravity filters to keep the signal smooth. 100 will be very slow and smooth, 0 will be fast but noisy +:[ double +:[ 0.77 +:[ Smoothing factor between 0.0 and 1.0. Higher values produce slower, smoother animation; lower values are more reactive but noisy. |[ *input_delay* :[ integer -:[ 2 -:[ Sets the delay before fetching audio source thread start working. On author's machine, Waybar starts much faster than pipewire audio server, and without a little delay cava module fails because pipewire is not ready -|[ *ascii_max_range* -:[ integer -:[ 7 -:[ It's impossible to set it directly. The value is dictated by the number of icons in the array *format-icons* -|[ *data_format* -:[ string -:[ asci -:[ Raw data format. Can be 'binary' or 'ascii' -|[ *raw_target* -:[ string -:[ /dev/stdout -:[ Raw output target. A fifo will be created if target does not exist -|[ *menu* -:[ string -:[ -:[ Action that popups the menu. -|[ *menu-file* -:[ string -:[ -:[ Location of the menu descriptor file. There need to be an element of type GtkMenu with id *menu* -|[ *menu-actions* -:[ array -:[ -:[ The actions corresponding to the buttons of the menu. -|[ *bar_spacing* -:[ integer -:[ -:[ Bars' space between bars in number of characters +:[ 4 +:[ Delay in seconds before starting audio capture. Increase this if Waybar starts before the audio server (e.g. PipeWire). |[ *bar_width* :[ integer :[ -:[ Bars' width between bars in number of characters +:[ Bar width in pixels. **Used by the GLSL frontend.** +|[ *bar_spacing* +:[ integer +:[ +:[ Space between bars in pixels. **Used by the GLSL frontend.** |[ *bar_height* :[ integer :[ -:[ Useless. bar_height is only used for output in "noritake" format -|[ *background* +:[ Ignored by Waybar. Used only by cava's "noritake" output format. +|[ *menu* :[ string :[ -:[ GLSL actual. Support hex code colors only. Must be within '' -|[ *foreground* +:[ Action that opens the menu. +|[ *menu-file* :[ string :[ -:[ GLSL actual. Support hex code colors only. Must be within '' -|[ *gradient* -:[ integer -:[ 0 -:[ GLSL actual. Gradient mode(0/1 - on/off) -|[ *gradient_count* -:[ integer -:[ 0 -:[ GLSL actual. The count of colors for the gradient -|[ *gradient_color_N* -:[ string +:[ Location of the menu descriptor file. There must be a GtkMenu element with id *menu*. +|[ *menu-actions* +:[ array :[ -:[ GLSL actual. N - the number of the gradient color between 1 and 8. Only hex defined colors are supported. Must be within '' +:[ Actions corresponding to the buttons of the menu. +|[ *method* \[output\] +:[ string +:[ raw +:[ Cava output method. Set to *raw* for the text frontend or *sdl_glsl* for the GPU frontend. **This is set inside the *[output]* section of the cava configuration file, not in Waybar's JSON.** |[ *sdl_width* :[ integer :[ -:[ GLSL actual. Manages the width of the waybar cava GLSL frontend module +:[ GLSL frontend width in pixels. **GLSL only.** |[ *sdl_height* :[ integer :[ -:[ GLSL actual. Manages the height of the waybar cava GLSL frontend module +:[ GLSL frontend height in pixels. **GLSL only.** +|[ *min-length* +:[ integer +:[ +:[ Requested width of the GLSL widget in pixels. If not set, falls back to *max-length*, then *sdl_width*. **GLSL only.** +|[ *max-length* +:[ integer +:[ +:[ Fallback width of the GLSL widget if *min-length* is not set. **GLSL only.** +|[ *vertex_shader* +:[ string +:[ +:[ Path to the vertex shader. **GLSL only; set in the *[output]* section of the cava configuration file.** +|[ *fragment_shader* +:[ string +:[ +:[ Path to the fragment shader. **GLSL only; set in the *[output]* section of the cava configuration file.** |[ *continuous_rendering* :[ integer :[ 0 -:[ GLSL actual. Keep rendering even if no audio. Recommended to set to 1 +:[ Continue rendering when silent. Set to 1 for smooth animation. **GLSL only; set in the *[output]* section of the cava configuration file.** +|[ *background* +:[ string +:[ +:[ Background color as a '#RRGGBB' hex string (must be quoted). **GLSL only; set in the *[color]* section of the cava configuration file.** +|[ *foreground* +:[ string +:[ +:[ Foreground color as a '#RRGGBB' hex string (must be quoted). **GLSL only; set in the *[color]* section of the cava configuration file.** +|[ *gradient* +:[ integer +:[ 0 +:[ Enable gradient mode (0 = off, 1 = on). **GLSL only.** Can also be set in the *[color]* section of the cava configuration file. +|[ *gradient_count* +:[ integer +:[ 0 +:[ Number of gradient colors (up to 8). **GLSL only.** Can also be set in the *[color]* section of the cava configuration file. +|[ *gradient_color_N* +:[ string +:[ +:[ Gradient color N (1–8) as a '#RRGGBB' hex string (must be quoted). **GLSL only; set in the *[color]* section of the cava configuration file.** -Configuration can be provided as: -- The only cava configuration file which is provided through *cava_config*. The rest configuration can be skipped -- Without cava configuration file. In such case cava should be configured through provided list of the configuration option -- Mix. When provided both And cava configuration file And configuration options. In such case, waybar applies configuration file first and then overrides particular options by the provided list of configuration options +Configuration can be provided in three ways: + +- **Cava config only**: set *cava_config* to a cava configuration file and omit all other options. +- **Waybar JSON only**: leave out *cava_config* and set every option in Waybar's module configuration. +- **Mixed**: provide a *cava_config* and also set specific options in Waybar's JSON. Waybar reads the file first, then overrides any values present in the JSON. # ACTIONS [- *String* -:- *Action* +:[ *Action* |[ *mode* -:< Switch main cava thread and fetch audio source thread from/to pause/resume +:[ Toggle pause/resume for the audio capture and output threads. # DEPENDENCIES @@ -207,26 +224,32 @@ Configuration can be provided as: # SOLVING ISSUES -. On start Waybar throws an exception "error while loading shared libraries: libcava.so: cannot open shared object file: No such file or directory". - It might happen when libcava for some reason hasn't been registered in the system. sudo ldconfig should help -. Waybar is starting but cava module doesn't react to the music - 1. In such cases at first need to make sure usual cava application is working as well - 2. If so, need to comment all configuration options. Uncomment cava_config and provide the path to the working cava config - 3. You might set too huge or too small input_delay. Try to setup to 4 seconds, restart waybar, and check again 4 seconds past. Usual even on weak machines it should be enough - 4. You might accidentally switch action mode to pause mode +. At startup Waybar fails with *"error while loading shared libraries: libcava.so: cannot open shared object file: No such file or directory"*. + This happens when libcava has not been registered in the system library cache. Run *sudo ldconfig* to refresh the cache. + This can also occur when Waybar is installed under */usr/local* but libcava is elsewhere. To fix it: + 1. Remove the local libcava installation: *sudo rm -rfv /usr/local/include/cava /usr/local/lib64/pkgconfig/cava.pc /usr/local/lib64/libcava.so* + 2. Reconfigure Waybar to use the system prefix: *meson configure build -Dprefix="/usr"* + 3. Rebuild Waybar: *ninja -C build* + 4. Install Waybar: *sudo meson install -C build* -# RISING ISSUES +. Waybar starts but the cava module does not react to audio. + 1. First, verify that standalone cava works correctly. + 2. If it does, comment out all Waybar cava options, uncomment *cava_config*, and point it to the working cava configuration file. + 3. The *input_delay* may be too large or too small. Try setting it to 4 seconds, restart Waybar, and check again after that delay. This is usually sufficient, even on slower machines. + 4. You may have accidentally toggled pause mode via an action. -For clear understanding: this module is a cava API's consumer. So for any bugs related to cava engine you should contact Cava upstream(https://github.com/karlstav/cava) ++ -with the one Exception. Cava upstream doesn't provide cava as a shared library. For that, this module author made a fork libcava(https://github.com/LukashonakV/cava). ++ -So the order is: -. cava upstream -. libcava upstream. -In case when cava releases new version and you're wanna get it, it should be raised an issue to libcava(https://github.com/LukashonakV/cava) with title ++ -\[Bump\]x.x.x where x.x.x is cava release version. +# REPORTING ISSUES + +This module is a consumer of the cava API. For bugs in the cava engine itself, please report them to [cava upstream](https://github.com/karlstav/cava) first. + +Upstream cava does not provide a shared library. The Waybar cava module uses [libcava](https://github.com/LukashonakV/cava), a fork maintained by the module author, to provide one. If the issue is specific to the shared library packaging, report it to libcava. + +When requesting a new upstream cava release to be packaged in libcava, open an issue at libcava with the title `[Bump] x.x.x`, where `x.x.x` is the desired cava version. # EXAMPLES +## Raw frontend + ``` "cava": { //"cava_config": "$XDG_CONFIG_HOME/cava/cava.conf", @@ -240,7 +263,6 @@ In case when cava releases new version and you're wanna get it, it should be rai "source": "auto", "stereo": true, "reverse": false, - "bar_delimiter": 0, "monstercat": false, "waves": false, "noise_reduction": 0.77, @@ -251,36 +273,6 @@ In case when cava releases new version and you're wanna get it, it should be rai } }, ``` -# STYLE - -- *#cava* -- *#cava.silent* Applied after no sound has been detected for sleep_timer seconds -- *#cava.updated* Applied when a new frame is shown -# FRONTENDS - -## RAW -The cava raw frontend uses ASCII characters to visualize incoming audio data. Each ASCII symbol position corresponds to the value of the audio power pulse. - -Under the hood: -``` -. Incoming audio power pulse list is : 12684 -. Configured array of ASCII codes is: ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" ]. See `format-icons` https://github.com/Alexays/Waybar/wiki/Module:-Cava#example -``` -As a result cava frontend will give ▁▂▆█▄ - -Examples: - -waybar config -``` -"cava": { - "cava_config": "$XDG_CONFIG_HOME/cava/waybar_raw.conf", - "input_delay": 2, - "format-icons" : ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" ], - "actions": { - "on-click-right": "mode" - } - }, -``` waybar_raw.conf ``` @@ -351,6 +343,7 @@ sleep_timer = 5 # README.md contains further information on how to setup CAVA for JACK. # + # The options 'sample_rate', 'sample_bits', 'channels' and 'autoconnect' can be configured for some input methods: # sample_rate: fifo, pipewire, sndio, oss # sample_bits: fifo, pipewire, sndio, oss @@ -452,7 +445,7 @@ bar_delimiter = 0 # Noise reduction, int 0 - 100. default 77 # the raw visualization is very noisy, this factor adjusts the integral and gravity filters to keep the signal smooth -# 100 will be very slow and smooth, 0 will be fast but noisy. +# 100 will be very slow and smooth, 0 will be fast and noisy. [eq] @@ -461,33 +454,32 @@ bar_delimiter = 0 # Remember to uncomment more than one key! More keys = more precision. # Look at readme.md on github for further explanations and examples. ``` -## GLSL -The Cava GLSL frontend delegates the visualization of incoming audio data to the GPU via OpenGL. -There are some mandatory dependencies that need to be satisfied in order for Cava GLSL to be built and function properly: +## GLSL frontend -. epoxy library must be installed on the system -. Vertex and fragment shaders from the original project must be used. They should be downloaded, and the file paths must be configured correctly in the Waybar Cava configuration: - 1. cava shaders [cava shaders](https://github.com/karlstav/cava/tree/master/output/shaders) - 2. libcava shaders [libcava shaders](https://github.com/LukashonakV/cava/tree/master/output/shaders) -. It is highly recommended to have a separate cava configuration for the Waybar Cava GLSL module and to use this as the cava_config in the Waybar configuration. -. It is common for cava configurations to be placed in the XDG_CONFIG_HOME directory, including shaders as well. Consider keeping them in the $XDG_CONFIG_HOME/cava/shaders folder. +The GLSL frontend requires: -Key configuration options: +. The *epoxy* library. +. Vertex and fragment shaders from the cava project. Download them and place under _$XDG_CONFIG_HOME/cava/shaders_, then reference them in the cava configuration: + 1. [cava shaders](https://github.com/karlstav/cava/tree/master/output/shaders) + 2. [libcava shaders](https://github.com/LukashonakV/cava/tree/master/output/shaders) +. A separate cava configuration file is highly recommended. -. bars. The more values the parameter has, the more interesting the visualization becomes. -. method in output section must be set to sdl_glsl -. sdl_width and sdl_height manage the size of the module. Adjust them according to your needs. -. Shaders for sdl_glsl, located in $HOME/.config/cava/shaders. Example: "vertex_shader" = "pass_through.vert" "fragment_shader" = "spectrogram.frag" -. Set continuous_rendering to 1 to enable smooth rendering; set it to 0 otherwise. It is recommended to keep it set to 1. -. background, foreground, and gradient_color_N (where N is a number between 1 and 8) must be defined using hex code +Key cava configuration options for GLSL: + +. *bars* — higher values produce more detailed visualization. +. *method* in *[output]* must be set to *sdl_glsl*. +. *sdl_width* and *sdl_height* control the module size. +. *vertex_shader* and *fragment_shader* point to the shader files under _$HOME/.config/cava/shaders_. +. *continuous_rendering* — set to 1 for smooth animation. +. *background*, *foreground*, and *gradient_color_N* must use hex codes inside single quotes. Example: waybar config ``` "cava": { - "cava_config": "$XDG_CONFIG_HOME/cava/waybar_cava#3.conf", + "cava_config": "$XDG_CONFIG_HOME/cava/waybar_cava.conf", "input_delay": 2, "actions": { "on-click-right": "mode" @@ -495,7 +487,7 @@ waybar config }, ``` -waybar_raw.conf +waybar_cava.conf ``` ## Configuration file for CAVA. # Remove the ; to change parameters. @@ -641,7 +633,7 @@ vertex_shader = pass_through.vert fragment_shader = bar_spectrum.frag ; for glsl output mode, keep rendering even if no audio -continuous_rendering = 1; +continuous_rendering = 1 # disable console blank (screen saver) in tty # (Not supported on FreeBSD) @@ -705,4 +697,21 @@ gradient_color_2 = '#45475A' # Look at readme.md on github for further explanations and examples. ``` -Different waybar_cava#N.conf see at [cava GLSL](https://github.com/Alexays/Waybar/wiki/Module:-Cava:-GLSL) +More GLSL examples are available on the [cava GLSL wiki page](https://github.com/Alexays/Waybar/wiki/Module:-Cava:-GLSL). + +# STYLE + +- *#cava* Raw frontend widget +- *#cava.silent* Applied after no sound has been detected for *sleep_timer* seconds +- *#cava.updated* Applied when a new frame is shown +- *#cavaGLSL* GLSL frontend widget (used instead of *#cava* when the cava *method* is *sdl_glsl*) +- *#cavaGLSL.silent* Applied after no sound has been detected for *sleep_timer* seconds +- *#cavaGLSL.updated* Applied when a new frame is shown + +# FRONTENDS + +## RAW +The raw frontend maps each bar's amplitude to a character from *format-icons*. The final widget text is the concatenation of all characters, optionally separated by *bar_delimiter*. See the EXAMPLES section above for a complete configuration. + +## GLSL +The GLSL frontend renders the visualization with OpenGL ES using user-provided shaders. It is selected by setting *method = sdl_glsl* in the cava configuration. See the EXAMPLES section above for a complete configuration. diff --git a/man/waybar-cffi.5.scd b/man/waybar-cffi.5.scd index 0203ef77..9ac4aa79 100644 --- a/man/waybar-cffi.5.scd +++ b/man/waybar-cffi.5.scd @@ -37,6 +37,19 @@ Waybar config to enable the module: ``` +# DEVELOPING CFFI MODULES + +CFFI modules require a handful of functions and constants to be defined with C +linkage. The way to achieve this depends on the programming language being used +(search for FFI / Foreign Function Interface for that language). + +The complete list of symbols to define can be found in the header file shipped with +Waybar at *resources/custom_modules/cffi_example/waybar_cffi_module.h*, and a full +example written in C is provided in *resources/custom_modules/cffi_example/*. + +Language bindings exist for several languages, including Rust (the *waybar-cffi* +crate) and Zig. + # STYLE The classes and IDs are managed by the cffi dynamic library. diff --git a/man/waybar-clock.5.scd b/man/waybar-clock.5.scd index 1b13dcb7..8537779c 100644 --- a/man/waybar-clock.5.scd +++ b/man/waybar-clock.5.scd @@ -6,7 +6,16 @@ waybar - clock module # DESCRIPTION -*clock* module displays current date and time +*clock* module displays current date and time. + +There are two implementations: + +- *clock*: Full-featured implementation, including the calendar and timezone + support described below. Enabled at build time when either C++20 concepts + (\_\_cpp\_concepts >= 201907, gcc >= 13) are available, or the HowardHinnant date + library is installed. +- *simpleclock*: Fallback that provides date and time display only. Used when the + above build conditions are not met. # FILES @@ -27,7 +36,8 @@ $XDG_CONFIG_HOME/waybar/config ++ |[ *format* :[ string :[ *{:%H:%M}* -:[ The format, how the date and time should be displayed. See format options below +:[ The format, how the date and time should be displayed. See format options + below. Split the braces to insert literal text, e.g. *{0:%H}text{0:%M}* |[ *timezone* :[ string :[ @@ -120,8 +130,9 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe :[ Calendar view mode. Possible values: year|month |[ *mode-mon-col* :[ integer -:[ 3 -:[ Relevant for *mode=year*. Count of months per row +:[ 1 +:[ Relevant for *mode=year*. Count of months per row. Must be a divisor of 12 + (one of 1, 2, 3, 4, 6, 12); an invalid value falls back to 3 |[ *weeks-pos* :[ string :[ @@ -138,6 +149,18 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe :[ When enabled, the calendar follows the ISO 8601 standard: weeks begin on Monday, and the first week of the year is numbered 1. The default week format is '{:%V}'. +|[ *weeks-numbering* +:[ string +:[ +:[ Override the week number calculation method, independent of *iso8601* and + locale settings. Possible values: *iso* (ISO 8601, {:%V}), *monday* + (Monday-based, {:%W}), *sunday* (Sunday-based, {:%U}). When not set, the + method is derived from *iso8601* or the locale. +|[ *first-day-of-week* +:[ integer +:[ +:[ The first day of the week, where 0 is Sunday and 6 is Saturday. + When not set, the first day of the week is determined by the locale settings. 3. Addressed by *clock: calendar: format* [- *Option* @@ -155,8 +178,9 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe |[ *weeks* :[ string :[ *{:%U}* -:[ Format is applied to week numbers. When weekday format is not provided then - is used default format: '{:%W}' when week starts with Monday, '{:%U}' otherwise +:[ Format is applied to week numbers. The *{}* placeholder is replaced with the + format determined by *weeks-numbering* (if set), otherwise by *iso8601* or the + locale: '{:%V}' for ISO 8601, '{:%W}' when week starts with Monday, '{:%U}' otherwise |[ *weekdays* :[ string :[ @@ -180,6 +204,10 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe :[ Switch to the next calendar month/year |[ *shift_down* :[ Switch to the previous calendar month/year +|[ *shift_reset* +:[ Reset the calendar shift back to the current month/year +|[ *exec * +:[ Execute the specified command # FORMAT REPLACEMENTS @@ -207,11 +235,12 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe "format-alt": "{:%A, %B %d, %Y (%R)} 󰃰 ", "tooltip-format": "{calendar}", "calendar": { - "mode" : "year", - "mode-mon-col" : 3, - "weeks-pos" : "right", - "on-scroll" : 1, - "on-click-right": "mode", + "mode" : "year", + "mode-mon-col" : 3, + "weeks-pos" : "right", + "first-day-of-week": 1, + "on-scroll" : 1, + "on-click-right" : "mode", "format": { "months": "{}", "days": "{}", @@ -276,6 +305,21 @@ View all valid format options in *strftime(3)* or have a look https://en.cpprefe - *#clock* +If displaying seconds causes other modules to shift side to side, the cause is +usually proportional-width digits. If your font supports it, add +*font-feature-settings: "tnum";* to the *#clock* style (or wherever you set the bar +font) to use fixed-width numbers. + +The following classes are used only inside the *{calendar}* tooltip. Their +foreground *color* is read and applied to the corresponding calendar cells in +the tooltip markup: + +- *.calendar-today*: The current day +- *.calendar-days*: The day numbers +- *.calendar-weeks*: The week numbers +- *.calendar-weekdays*: The weekday header (Su, Mo, ...) +- *.calendar-months*: The month header (January, February, ...) + # Troubleshooting If clock module is disabled at startup with locale::facet::\_S\_create\_c\_locale ++ @@ -284,6 +328,10 @@ name not valid error message try one of the following: - check if LC_TIME is set properly (glibc) - set locale to C in the config file (musl) +When using *clock* instead of *simpleclock*, the locale defaults to *C* regardless +of the locale settings. To override this, prepend *L* to the format string, e.g. +*{:%a %m %d}* becomes *{:L%a %m %d}*. + The locale option must be set for {calendar} to use the correct start-of-week, regardless of system locale. ## Calendar in Chinese. Alignment diff --git a/man/waybar-cpu-graph.5.scd b/man/waybar-cpu-graph.5.scd new file mode 100644 index 00000000..59d39cc1 --- /dev/null +++ b/man/waybar-cpu-graph.5.scd @@ -0,0 +1,85 @@ +waybar-cpu-graph(5) + +# NAME + +waybar - cpu graph module + +# DESCRIPTION + +The *cpu graph* module displays a line graph with the CPU utilization. + +# CONFIGURATION + +*interval*: ++ + typeof: integer or float ++ + default: 5 ++ + The interval in which the information gets polled. + +*graph_type*: ++ + typeof: string ++ + default: line ++ + The rendering style of the graph. One of 'line', 'bar', or 'gauge'. + +*width*: ++ + typeof: integer ++ + The length in pixels the module should display. + +*datapoints*: ++ + typeof: integer ++ + How many data points to show. + +*on-click*: ++ + typeof: string ++ + Command to execute when clicked on the module. + +*on-click-middle*: ++ + typeof: string ++ + Command to execute when middle-clicked on the module using mousewheel. + +*on-click-right*: ++ + typeof: string ++ + Command to execute when you right-click on the module. + +*on-update*: ++ + typeof: string ++ + Command to execute when the module is updated. + +*on-scroll-up*: ++ + typeof: string ++ + Command to execute when scrolling up on the module. + +*on-scroll-down*: ++ + typeof: string ++ + Command to execute when scrolling down on the module. + +*smooth-scrolling-threshold*: ++ + typeof: double ++ + Threshold to be used when scrolling. + +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables this module to consume all left over space dynamically. + +# EXAMPLES + +Basic configuration: + +``` +"cpu_graph": { + "interval": 2, + "width": 10 +} +``` + +# STYLE + +- *#cpu_graph* +- *.cpu-intensive* +- *.cpu-high* +- *.cpu-moderate* diff --git a/man/waybar-cpu.5.scd b/man/waybar-cpu.5.scd index 40682372..dda55daf 100644 --- a/man/waybar-cpu.5.scd +++ b/man/waybar-cpu.5.scd @@ -83,6 +83,18 @@ The *cpu* module displays the current CPU utilization. default: true ++ Option to disable tooltip on hover. +*tooltip-format*: ++ + typeof: string ++ + The format of the tooltip shown on hover. Supports the same replacements as *format*. + +*format-*: ++ + typeof: string ++ + The format to use when the given *state* (see *states*) is active. Supports the same replacements as *format*. + +*tooltip-format-*: ++ + typeof: string ++ + The tooltip format to use when the given *state* (see *states*) is active. Takes precedence over *tooltip-format*. + *expand*: ++ typeof: bool ++ default: false ++ @@ -92,6 +104,12 @@ The *cpu* module displays the current CPU utilization. *{load}*: Current CPU load. +*{load1}*: CPU load average over the last minute. + +*{load5}*: CPU load average over the last 5 minutes. + +*{load15}*: CPU load average over the last 15 minutes. + *{usage}*: Current overall CPU usage. *{usage*{n}*}*: Current CPU core n usage. Cores are numbered from zero, so first core will be {usage0} and 4th will be {usage3}. @@ -106,6 +124,8 @@ The *cpu* module displays the current CPU utilization. *{icon*{n}*}*: Icon for CPU core n usage. Use like {icon0}. +*{icons}*: All per-core icons concatenated. Equivalent to {icon0}{icon1}...{icon*N*} but adapts to the number of cores automatically. + # EXAMPLES Basic configuration: @@ -128,6 +148,16 @@ CPU usage per core rendered as icons: }, ``` +Automatically determine number of icons according to number of logical cores: + +``` +"cpu": { + "interval": 1, + "format": "{icons} {usage:>2}% ", + "format-icons": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"], +}, +``` + # STYLE - *#cpu* diff --git a/man/waybar-custom-graph.5.scd b/man/waybar-custom-graph.5.scd new file mode 100644 index 00000000..9a134fd6 --- /dev/null +++ b/man/waybar-custom-graph.5.scd @@ -0,0 +1,192 @@ +waybar-custom-graph(5) +# NAME + +waybar - custom graph module + +# DESCRIPTION + +The *custom-graph* module displays a graph with the percentage output of a script. + +# CONFIGURATION + +Addressed by *custom-graph/* + +*exec*: ++ + typeof: string ++ + The path to the script, which should be executed. + +*exec-if*: ++ + typeof: string ++ + The path to a script, which determines if the script in *exec* should be executed. ++ + *exec* will be executed if the exit code of *exec-if* equals 0. + +*exec-on-event*: ++ + typeof: bool ++ + default: true ++ + If an event command is set (e.g. *on-click* or *on-scroll-up*) then re-execute the script after executing the event command. + +*return-type*: ++ + typeof: string ++ + See *return-type* + +*interval*: ++ + typeof: integer or float ++ + The interval (in seconds) in which the information gets polled. ++ + Minimum value is 0.001 (1ms). Values smaller than 1ms will be set to 1ms. ++ + Use *once* if you want to execute the module only on startup. ++ + You can update it manually with a signal. If no *interval* or *signal* is defined, it is assumed that the out script loops itself. ++ + If a *signal* is defined then the script will run once on startup and will only update with a signal. + +*restart-interval*: ++ + typeof: integer ++ + The restart interval (in whole seconds). ++ + Can't be used with the *interval* option, so only with continuous scripts. ++ + Once the script exits, it'll be re-executed after the *restart-interval*. + +*signal*: ++ + typeof: integer ++ + The signal number used to update the module. ++ + The number is valid between 1 and N, where *SIGRTMIN+N* = *SIGRTMAX*. ++ + If no interval is defined then a signal will be the only way to update the module. + +*graph_type*: ++ + typeof: string ++ + default: line ++ + The style of graph to render. One of *line*, *bar* or *gauge*. + +*width*: ++ + typeof: integer ++ + default: 100 ++ + The width of the graph in pixels. + +*datapoints*: ++ + typeof: integer ++ + default: 20 ++ + The number of most recent values to retain and plot on the graph. + +*on-click*: ++ + typeof: string ++ + Command to execute when clicked on the module. + +*on-click-middle*: ++ + typeof: string ++ + Command to execute when middle-clicked on the module using mousewheel. + +*on-click-right*: ++ + typeof: string ++ + Command to execute when you right-click on the module. + +*on-update*: ++ + typeof: string ++ + Command to execute when the module is updated. + +*on-scroll-up*: ++ + typeof: string ++ + Command to execute when scrolling up on the module. + +*on-scroll-down*: ++ + typeof: string ++ + Command to execute when scrolling down on the module. + +*smooth-scrolling-threshold*: ++ + typeof: double ++ + Threshold to be used when scrolling. + +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*tooltip-format*: ++ + typeof: string ++ + The tooltip format. If specified, overrides any tooltip output from the script in *exec*. ++ + See *FORMAT REPLACEMENTS*. + +*escape*: ++ + typeof: bool ++ + default: false ++ + Option to enable escaping of script output. + +*menu*: ++ + typeof: string ++ + Action that popups the menu. + +*menu-file*: ++ + typeof: string ++ + Location of the menu descriptor file. There need to be an element of type + GtkMenu with id *menu* + +*menu-actions*: ++ + typeof: array ++ + The actions corresponding to the buttons of the menu. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables this module to consume all left over space dynamically. + +# RETURN-TYPE + +When *return-type* is set to *json*, Waybar expects the *exec*-script to output its data in JSON format. +This should look like this: + +``` +{"text": "$text", "tooltip": "$tooltip", "class": "$class", "percentage": $percentage } +``` + +The *class* parameter also accepts an array of strings. + +If nothing or an invalid option is specified, Waybar expects i3blocks style output. Values are *newline* separated. +This should look like this: + +``` +$text\\n$tooltip\\n$class* +``` + +*class* is a CSS class, to apply different styles in *style.css* + +# FORMAT REPLACEMENTS + +These replacements are available in *tooltip-format*. + +*{text}*: Output of the script. + +*{alt}*: The *alt* value from a json return type. + +*{percentage}* Percentage which can be set via a json return type. + +# EXAMPLES + +## Memory: + +``` +"custom-graph/memory": { + "interval": 60, + "graph_type": "gauge", + "width": 52, + "exec": "/path/mem.sh", + "signal": 8, + "return-type": "json" +}, +``` + +mem.sh: + +``` +#!/bin/bash + +mem_info=$(cat /proc/meminfo) +mem_total=$(echo "$mem_info" | grep '^MemTotal:' | awk '{print $2}') +mem_available=$(echo "$mem_info" | grep '^MemAvailable:' | awk '{print $2}') + +mem_used=$((mem_total - mem_available)) +mem_percent=$((mem_used * 100 / mem_total)) + +echo "{\"text\": \"${mem_percent}%\", \"percentage\": ${mem_percent},\"tooltip\": \"Memory: ${mem_used}KB used / ${mem_total}KB total\"}'" +``` + +# STYLE + +- *#custom-graph-* +- *#custom-graph-.* +- ** can be set by the script. For more information see *return-type* diff --git a/man/waybar-custom.5.scd b/man/waybar-custom.5.scd index 88b79151..bf19b955 100644 --- a/man/waybar-custom.5.scd +++ b/man/waybar-custom.5.scd @@ -61,8 +61,11 @@ Addressed by *custom/* The format, how information should be displayed. On {text} data gets inserted. *format-icons*: ++ - typeof: array ++ - Based on the set percentage, the corresponding icon gets selected. The order is *low* to *high*. + typeof: array or object or string ++ + If the type is an array, then based on the set percentage, the corresponding icon gets selected (the order is *low* to *high*). ++ + If the type is an object, then the icon is selected according to the *alt* string from the output. ++ + If the type is a string, it is pasted as is. ++ + Arrays can be nested into objects: icons are then selected first according to *alt*, then percentage. *rotate*: ++ typeof: integer ++ @@ -145,17 +148,36 @@ Addressed by *custom/* default: false ++ Enables this module to consume all left over space dynamically. +*image-path*: ++ + typeof: string ++ + Path to an image file to display in the module. + +*image-name*: ++ + typeof: string ++ + Name of a themed icon to display in the module. + +*icon-size*: ++ + typeof: integer ++ + The size (in pixels) of the image set via *image-path* or *image-name*. + # RETURN-TYPE When *return-type* is set to *json*, Waybar expects the *exec*-script to output its data in JSON format. This should look like this: ``` -{"text": "$text", "tooltip": "$tooltip", "class": "$class", "percentage": $percentage } +{"text": "$text", "alt": "$alt", "tooltip": "$tooltip", "class": "$class", "percentage": $percentage } ``` +The whole JSON object must be printed on a single line. This can be achieved by +piping the output of your script through *jq --unbuffered --compact-output*. + The *class* parameter also accepts an array of strings. +To have multiline tooltips, use *\\r* in your script to separate the lines. If +using PowerShell for scripting, use the standard newline operator "\`n" in double +quotes; *\\r* and *\\n* will not work. + If nothing or an invalid option is specified, Waybar expects i3blocks style output. Values are *newline* separated. This should look like this: @@ -167,12 +189,21 @@ $text\\n$tooltip\\n$class* # FORMAT REPLACEMENTS +*{}*: Output of the script. Equivalent to *{text}*. + *{text}*: Output of the script. +*{alt}*: The *alt* value from a json return type. + *{percentage}* Percentage which can be set via a json return type. *{icon}*: An icon from 'format-icons' according to percentage. +The *{}* placeholder is special: it automatically displays the text output of your +script, but it cannot be combined with other placeholders like *{icon}* in the same +format string. To display both an icon and text, use *{icon}* together with *{text}* +explicitly (e.g. *"format": "{icon} {text}"*). + # EXAMPLES ## Spotify: @@ -248,3 +279,36 @@ Under the premise that interval is not defined, you can use the signal and updat - *#custom-* - *#custom-.* - ** can be set by the script. For more information see *return-type* +- *.flat* and *.text-button* are always applied to the module's label. +- *.image-button* is always applied to the module's image (see *image-path*/*image-name*). + +# CONTINUOUS SCRIPTS + +The *exec* script may be continuous (i.e. contain some kind of infinite loop). The +display is updated for each new line of data printed on stdout (following the chosen +*return-type*). The *interval* option does not apply to a continuous script; use +*restart-interval* instead to restart the script if it stops after some time. + +Be aware that some languages buffer their output. If your module displays nothing +even though your script works as expected, the output may be held in a buffer. Look +up how to flush the output buffer for your language of choice (for example, in Ruby +call *$stdout.flush* after each print). + +# OUTPUT NAME + +The *exec* script is run with the *WAYBAR_OUTPUT_NAME* environment variable set to +the name of the output (monitor) the bar is displayed on. + +# TROUBLESHOOTING + +*Self-looping module does not show up* + +If your module is self-looping and it does not even show up in the bar, check that: + +- Its configuration does *not* include an *interval* parameter. +- Output to stdout is not buffered. + +*Custom json class not displayed* + +If a class set in your custom script is not picked up by *style.css*, ensure that +the output carrying the most variables is emitted first. diff --git a/man/waybar-disk.5.scd b/man/waybar-disk.5.scd index 00af2b90..d36fb608 100644 --- a/man/waybar-disk.5.scd +++ b/man/waybar-disk.5.scd @@ -6,17 +6,12 @@ waybar - disk module # DESCRIPTION -The *disk* module displays the current disk space used. +The *disk* module displays information of multiple disks. # CONFIGURATION Addressed by *disk* -*path*: ++ - typeof: string ++ - default: "/" ++ - Any path residing in the filesystem or mountpoint for which the information should be displayed. - *interval*: ++ typeof: integer++ default: 30 ++ @@ -24,8 +19,8 @@ Addressed by *disk* *format*: ++ typeof: string ++ - default: "{percentage_used}%" ++ - The format, how information should be displayed. + default: "{}%" ++ + The format, how information for each disk should be displayed. Note: in the default the positional field "{}" resolves to *{percentage_free}*. *rotate*: ++ typeof: integer ++ @@ -75,6 +70,26 @@ Addressed by *disk* typeof: string ++ Command to execute when scrolling down on the module. +*path*: ++ + typeof: string ++ + default: "/" ++ + Deprecated path of filesystem or mountpoint to monitor. + +*paths*: ++ + typeof: array ++ + default: ["/"] ++ + Array of paths residing in the filesystem or mountpoint for which the information should be displayed. + +*header*: ++ + typeof: string ++ + default: "" ++ + Text to appear before the disk information defined in the format. + +*separator*: ++ + typeof: string ++ + default: " " ++ + Separator string between multiple disk information. + *smooth-scrolling-threshold*: ++ typeof: double ++ Threshold to be used when scrolling. @@ -86,7 +101,7 @@ Addressed by *disk* *tooltip-format*: ++ typeof: string ++ - default: "{used} out of {total} used ({percentage_used}%)" ++ + default: "{used} used out of {total} on {path} ({percentage_used}%)" ++ The format of the information displayed in the tooltip. *unit*: ++ @@ -123,7 +138,7 @@ Addressed by *disk* *{free}*: Amount of available disk space for normal users. Automatically selects unit based on size remaining. -*{path}*: The path specified in the configuration. +*{path}*: The path for each disk specified in the configuration. *{specific_total}*: Total amount of space on the disk, partition, or mountpoint in a specific unit. Defaults to bytes. @@ -131,6 +146,29 @@ Addressed by *disk* *{specific_free}*: Amount of available disk space for normal users in a specific unit. Defaults to bytes. +# NUMBER FORMAT MODIFIERS + +*{total}*, *{used}* and *{free}* auto-scale with a binary prefix (KiB, GiB, …). +Their rendering can be tuned with fmt-style modifiers, e.g. *"{free:>}"* or +*"{free:G}"*, combined in any order: + +*<*, *=*, *>*: Alignment/padding (left, column-align, right). + +*u* / *U*: Hide (*u*) or show (*U*) the unit suffix. Shown by default with an auto +scale, hidden by default when a scale is forced. + +*#*, *k*, *M*, *G*, *T*, *P*: Force a fixed scale instead of auto-selecting +(*#* = base scale). Forcing a scale hides the scale prefix and, by default, the +unit. E.g. *"{free:G}"* always shows gibibytes. + +*i*: Force integer display (no decimals). + +*b* / *B*: Force decimal base 1000 (*b*) or binary base 1024 (*B*); disk values +are binary by default. + +A trailing number is a fixed width for the coefficient when a scale is forced; +overflow is shown as *#* characters (e.g. *"{free:=3#}"* → *###*). + # EXAMPLES ``` @@ -143,10 +181,22 @@ Addressed by *disk* ``` "disk": { "interval": 30, + "format": "{percentage_free}% free on {path}", + "header": "Disks: ", + "paths": ["/", "/home"], + "separator": " ", +} +``` + +``` +"disk": { + "interval": 30, + "paths": ["/"], "format": "{specific_free:0.2f} GB out of {specific_total:0.2f} GB available. Alternatively {free} out of {total} available", "unit": "GB" - // 1434.25 GB out of 2000.00 GB available. Alternatively 1.4TiB out of 1.9TiB available. } + + // 1434.25 GB out of 2000.00 GB available. Alternatively 1.4TiB out of 1.9TiB available. ``` # STYLE diff --git a/man/waybar-dwl-tags.5.scd b/man/waybar-dwl-tags.5.scd index 39d15b7a..e331eef9 100644 --- a/man/waybar-dwl-tags.5.scd +++ b/man/waybar-dwl-tags.5.scd @@ -8,6 +8,9 @@ waybar - dwl tags module The *tags* module displays the current state of tags in dwl. +Using this module requires patching dwl with the IPC patch +. + # CONFIGURATION Addressed by *dwl/tags* @@ -15,12 +18,18 @@ Addressed by *dwl/tags* *num-tags*: ++ typeof: uint ++ default: 9 ++ - The number of tags that should be displayed. Max 32. + The number of tags that should be displayed. Max 32. This should match the + number of tags configured in dwl. *tag-labels*: ++ typeof: array ++ The label to display for each tag. +*hide-vacant*: ++ + typeof: bool ++ + default: false ++ + If set to true, tags without clients and that are not active will be hidden. + *disable-click*: ++ typeof: bool ++ default: false ++ @@ -46,8 +55,9 @@ Addressed by *dwl/tags* - *#tags button.empty* - *#tags button.focused* - *#tags button.urgent* +- *#tags button.output* -Note that occupied/focused/urgent status may overlap. That is, a tag may be +Note that occupied/focused/urgent/output status may overlap. That is, a tag may be both occupied and focused at the same time. # SEE ALSO diff --git a/man/waybar-dwl-window.5.scd b/man/waybar-dwl-window.5.scd index 9ac33d94..696eadb6 100644 --- a/man/waybar-dwl-window.5.scd +++ b/man/waybar-dwl-window.5.scd @@ -8,15 +8,28 @@ waybar - dwl window module The *window* module displays the title of the currently focused window in DWL +Using this module requires patching dwl with the IPC patch +. + # CONFIGURATION Addressed by *dwl/window* *format*: ++ typeof: string ++ - default: {title} ++ + default: {} ++ The format, how information should be displayed. +*hide-empty*: ++ + typeof: bool ++ + default: false ++ + Option to hide the module when the content would be empty. + +*hide-inactive*: ++ + typeof: bool ++ + default: false ++ + Option to hide the module when the window is unfocused. + *rotate*: ++ typeof: integer ++ Positive value to rotate the text label (in 90 degree increments). @@ -109,6 +122,10 @@ If no expression matches, the format output is left unchanged. Invalid expressions (e.g., mismatched parentheses) are skipped. +# STYLE + +- *#window.active* + # EXAMPLES ``` diff --git a/man/waybar-ext-workspaces.5.scd b/man/waybar-ext-workspaces.5.scd index 3fdae606..15bf4760 100644 --- a/man/waybar-ext-workspaces.5.scd +++ b/man/waybar-ext-workspaces.5.scd @@ -8,6 +8,9 @@ waybar - wlr workspaces module The *workspaces* module displays the currently used workspaces in wayland compositor. +To use this module, your compositor must implement the *ext-workspace-v1* +Wayland protocol. + # CONFIGURATION Addressed by *ext/workspaces* @@ -37,6 +40,10 @@ Addressed by *ext/workspaces* default: false ++ Should workspaces be sorted by ID. Workspace ID will be sorted numerically when all ID are numbers. Takes precedence over any other sort-by option. +*sort-by-number*: ++ + typeof: bool ++ + Deprecated alias of *sort-by-id*. Setting it emits a warning; prefer *sort-by-id*. + *all-outputs*: ++ typeof: bool ++ default: false ++ @@ -52,6 +59,18 @@ Addressed by *ext/workspaces* default: true ++ If set to false hidden workspaces will be shown. +*on-click*: ++ + typeof: string ++ + The action to perform on left-click. See *CLICK ACTIONS* for the possible values. + +*on-click-middle*: ++ + typeof: string ++ + The action to perform on middle-click. See *CLICK ACTIONS* for the possible values. + +*on-click-right*: ++ + typeof: string ++ + The action to perform on right-click. See *CLICK ACTIONS* for the possible values. + # FORMAT REPLACEMENTS *{name}*: Name of workspace assigned by compositor. diff --git a/man/waybar-gamemode.5.scd b/man/waybar-gamemode.5.scd index bb82e0aa..bcc5592f 100644 --- a/man/waybar-gamemode.5.scd +++ b/man/waybar-gamemode.5.scd @@ -28,7 +28,7 @@ Feral Gamemode optimizations. *tooltip-format*: ++ typeof: string ++ - default: Games running: {glyph} ++ + default: Games running: {count} ++ The text format of the tooltip. *hide-not-running*: ++ @@ -54,7 +54,7 @@ Feral Gamemode optimizations. *icon-size*: ++ typeof: unsigned integer ++ default: 20 ++ - Defines the size of the icons. + Defines the size of the icons. Set to *0* for auto size. *icon-spacing*: ++ typeof: unsigned integer ++ diff --git a/man/waybar-gps.5.scd b/man/waybar-gps.5.scd index e7473601..d25204ea 100644 --- a/man/waybar-gps.5.scd +++ b/man/waybar-gps.5.scd @@ -26,7 +26,7 @@ libgps lives in: *format*: ++ typeof: string ++ - default: {glyph} ++ + default: {mode} ++ The text format. *tooltip*: ++ @@ -36,7 +36,7 @@ libgps lives in: *tooltip-format*: ++ typeof: string ++ - default: Games running: {glyph} ++ + default: The value of *format* ++ The text format of the tooltip. *interval*: ++ @@ -93,10 +93,10 @@ libgps lives in: "gps": { "format": "{mode}", "format-disabled": "", // an empty format will hide the module - "format-no-fix": "No fix", + "format-fix-none": "No fix", "format-fix-3d": "{status}", "tooltip-format": "{mode}", - "tooltip-format-no-fix": "{satellites_visible} satellites visible", + "tooltip-format-fix-none": "{satellites_visible} satellites visible", "tooltip-format-fix-2d": "{satellites_used}/{satellites_visible} satellites used", "tooltip-format-fix-3d": "Altitude: {altitude_hae}m", "hide-disconnected": false @@ -106,6 +106,7 @@ libgps lives in: - *#gps* - *#gps.disabled* Applied when GPS is disabled. +- *#gps.disconnected* Applied when no GPS receiver is present. - *#gps.fix-none* Applied when GPS is present, but there is no fix. - *#gps.fix-2d* Applied when there is a 2D fix. - *#gps.fix-3d* Applied when there is a 3D fix. diff --git a/man/waybar-hyprland-language.5.scd b/man/waybar-hyprland-language.5.scd index 5a7ba941..12ae0acc 100644 --- a/man/waybar-hyprland-language.5.scd +++ b/man/waybar-hyprland-language.5.scd @@ -21,6 +21,10 @@ Addressed by *hyprland/language* typeof: string++ Provide an alternative name to display per language where is the language of your choosing. Can be passed multiple times with multiple languages as shown by the example below. +*format--* ++ + typeof: string ++ + Like *format-* but also matches the layout variant, taking precedence over *format-* when both the language and variant match. + *keyboard-name*: ++ typeof: string ++ Specifies which keyboard to use from hyprctl devices output. Using the option that begins with "at-translated-set..." is recommended. @@ -38,6 +42,24 @@ Addressed by *hyprland/language* typeof: array ++ The actions corresponding to the buttons of the menu. +*tooltip*: ++ + typeof: boolean ++ + default: true ++ + Enables or disables the tooltip for the language module. By default, the tooltip is enabled. Set to *false* to disable. + +*tooltip-format*: ++ + typeof: string ++ + default: {long} ++ + Specifies the format of the tooltip when it is enabled. It follows the same format replacement rules as the *format* key. + +*tooltip-format-*: ++ + typeof: string ++ + Allows specifying a different tooltip format for each language. The ** should be replaced with the language code. This can be used to provide a custom tooltip for each language. + +*tooltip-format--*: ++ + typeof: string ++ + Like *tooltip-format-* but also matches the layout variant, taking precedence over *tooltip-format-* when both the language and variant match. + *expand*: ++ typeof: bool ++ default: false ++ @@ -46,7 +68,7 @@ Addressed by *hyprland/language* # FORMAT REPLACEMENTS -*{short}*: Short name of layout (e.g. "us"). Equals to {}. +*{short}*: Short name of layout (e.g. "us"). *{shortDescription}*: Short description of layout (e.g. "en"). @@ -66,6 +88,29 @@ Addressed by *hyprland/language* } ``` +``` +"hyprland/language": { + "format": "{}", + "format-en": "US", + "format-es": "ES", + "tooltip": true, + "tooltip-format": "{long}" +} +``` + +``` +"hyprland/language": { + "format": "{}", + "format-en": "US", + "format-es": "ES", + "tooltip": true, + "tooltip-format": "{}", + "tooltip-format-es": "{Español}", + "tooltip-format-en": "{English (american)}" +} +``` + # STYLE - *#language* +- *#language.* (per-layout class derived from the layout short name, e.g. '.us') diff --git a/man/waybar-hyprland-submap.5.scd b/man/waybar-hyprland-submap.5.scd index f6cdff94..0e540a93 100644 --- a/man/waybar-hyprland-submap.5.scd +++ b/man/waybar-hyprland-submap.5.scd @@ -15,7 +15,7 @@ Addressed by *hyprland/submap* *format*: ++ typeof: string ++ default: {} ++ - The format, how information should be displayed. On {} the currently active submap is displayed. + The format, how information should be displayed. This format supports two placeholders: *{submap}* for the currently active submap name and *{icon}* for the icon associated with the submap. *rotate*: ++ typeof: integer ++ @@ -80,6 +80,10 @@ Addressed by *hyprland/submap* default: Default ++ Option to set the submap name to display when not in an active submap. +*icons*: ++ + typeof: object ++ + Based on the submap name, the corresponding icon will be selected from this map and made available as the *{icon}* placeholder in the format string. The keys are submap names, and the values are the icons or strings to display for those submaps. + *menu*: ++ typeof: string ++ Action that popups the menu. @@ -103,9 +107,13 @@ Addressed by *hyprland/submap* ``` "hyprland/submap": { - "format": "✌️ {}", + "format": "{icon} {submap}", "max-length": 8, - "tooltip": false + "tooltip": false, + "icons": { + "resize": "", + "pause": "", + } } ``` diff --git a/man/waybar-hyprland-window.5.scd b/man/waybar-hyprland-window.5.scd index 34ebf89b..0ef9bdea 100644 --- a/man/waybar-hyprland-window.5.scd +++ b/man/waybar-hyprland-window.5.scd @@ -17,6 +17,10 @@ Addressed by *hyprland/window* default: {title} ++ The format, how information should be displayed. On {} the current window title is displayed. +*max-length*: ++ + typeof: integer ++ + The maximum length in character the module should display. + *rewrite*: ++ typeof: object ++ Rules to rewrite window title. See *rewrite rules*. @@ -25,6 +29,15 @@ Addressed by *hyprland/window* typeof: bool ++ Show the active window of the monitor the bar belongs to, instead of the focused window. +*fallback*: ++ + typeof: string ++ + Text to display when the focused window title is empty (for example when no window is focused). + +*tooltip-format*: ++ + typeof: string ++ + The format of the tooltip shown on hover. Supports the same replacements as *format*. Requires *tooltip* to be enabled. When unset, the tooltip falls back to the formatted label text. ++ + default: (the formatted label text) + *icon*: ++ typeof: bool ++ default: false ++ diff --git a/man/waybar-hyprland-workspaces.5.scd b/man/waybar-hyprland-workspaces.5.scd index 5284ce99..3010fb45 100644 --- a/man/waybar-hyprland-workspaces.5.scd +++ b/man/waybar-hyprland-workspaces.5.scd @@ -14,11 +14,11 @@ Addressed by *hyprland/workspaces* *format*: ++ typeof: string ++ - default: {id} ++ + default: {name} ++ The format, how information should be displayed. *format-icons*: ++ - typeof: array ++ + typeof: object ++ Based on the workspace ID and state, the corresponding icon gets selected. See *icons*. *window-rewrite*: ++ @@ -41,6 +41,19 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. The separator to be used between windows in a workspace. ++ This setting is ignored if *workspace-taskbar.enable* is set to true. +*window-rewrite-group-threshold*: ++ + typeof: int ++ + default: 0 ++ + When a workspace contains at least this many windows with the same rewrite result, they are collapsed into a single one using *window-rewrite-group-format*. ++ + Set to 0 to disable grouping. ++ + This setting is ignored if *workspace-taskbar.enable* is set to true. + +*window-rewrite-group-format*: ++ + typeof: string ++ + default: "{icon}×{count}" ++ + The format used to represent a group of collapsed windows. Available placeholders are {icon} (the icon being grouped) and {count} (how many windows share it). ++ + This setting is ignored if *workspace-taskbar.enable* is set to true. + *workspace-taskbar*: ++ typeof: object ++ Contains settings for the workspace taskbar, an alternative mode for the workspaces module which displays the window icons as images instead of text. @@ -75,6 +88,11 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. default: 16 ++ Size of the icons in the workspace taskbar. + *max-icons*: ++ + typeof: int ++ + default: 0 (unlimited) ++ + Maximum number of icons to show per workspace. When set, duplicate icons (windows with the same class) are removed first, then the list is trimmed to this limit. Set to 0 for unlimited icons. + *icon-theme*: ++ typeof: string | array ++ default: [] ++ @@ -98,6 +116,11 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. - {button} Pressed button number, see https://api.gtkd.org/gdk.c.types.GdkEventButton.button.html. ++ See https://github.com/Alexays/Waybar/wiki/Module:-Hyprland#workspace-taskbars-example for a full example. +*max-windows*: ++ + typeof: int ++ + default: 0 (unlimited) ++ + Maximum number of windows to show per workspace. When set, newest windows beyond the limit are not shown. Set to 0 for unlimited windows. + *show-special*: ++ typeof: bool ++ default: false ++ @@ -113,6 +136,12 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. default: false ++ If set to true, only persistent workspaces will be shown on bar. +*persistent-workspaces*: ++ + typeof: object ++ + default: empty ++ + Lists workspaces that should always be shown, even when they do not exist. Keys are workspace names and values are arrays of output names on which the workspace should be shown (an empty array means all outputs). See the examples below. ++ + Note: for persistent workspaces to actually work you must also declare them in your Hyprland config, e.g. *workspace = 1, monitor:eDP-1, persistent:true*. + *all-outputs*: ++ typeof: bool ++ default: false ++ @@ -123,6 +152,11 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. default: false ++ If set to true, only the active workspace will be shown. +*hide-active*: ++ + typeof: bool ++ + default: false ++ + If set to true, the active workspace will be hidden. Unless a workspace is persistent or special. + *move-to-monitor*: ++ typeof: bool ++ default: false ++ @@ -130,11 +164,24 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. Otherwise, the workspace will open on the monitor where it was previously assigned. Analog to using `focusworkspaceoncurrentmonitor` dispatcher instead of `workspace` in Hyprland. +*unique-icons*: ++ + typeof: bool ++ + default: false ++ + If set to true, only one instance of each window icon will be shown per workspace. + *enable-bar-scroll*: ++ typeof: bool ++ default: false ++ If set to false, you can't scroll to cycle throughout workspaces from the entire bar. If set to true this behaviour is enabled. +*on-scroll-up*: ++ + typeof: string ++ + Command to execute when scrolling up on the module. This replaces the default behaviour of workspace cycling. + +*on-scroll-down*: ++ + typeof: string ++ + Command to execute when scrolling down on the module. This replaces the default behaviour of workspace cycling. + *ignore-workspaces*: ++ typeof: array ++ default: [] ++ @@ -149,6 +196,15 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. If set to special-centered, workspaces will sort by default with special workspaces in the center. If none of those, workspaces will sort with default behavior. +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*tooltips*: ++ + typeof: object ++ + Based on the workspace ID and state, the corresponding tooltip gets selected. Selection works the same as *format-icons* do. Format replacements are supported. See *icons*. + *expand*: ++ typeof: bool ++ default: false ++ @@ -162,6 +218,34 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. *{icon}*: Icon, as defined in *format-icons*. +*{windows}*: The windows in the workspace, formatted according to *window-rewrite* and joined with *format-window-separator*. + +# WINDOW REWRITE RULES + +The rules in *window-rewrite* are regexes that may match against a window's +class, title, or both. There are four categories of rule, distinguished by how +they are written in the config: + +[- *Rule* +:- *Category* +|[ *something* +:[ Vague +|[ *class* +:[ Class-only +|[ *title* +:[ Title-only +|[ *class title* +:[ Hybrid + +When the config contains only "vague" rules, they are matched against window +*classes* only. This is both for backwards compatibility and for performance: +matching against the title requires listening to window title changes via +Hyprland's IPC, which is unnecessary when no title rule is in use. + +When the config contains *at least one* "title-only" or "hybrid" rule, then all +"vague" rules match against *both* class and title. This lets you define vague +rules where it does not matter whether the class or the title matched. + # ICONS Additional to workspace name matching, the following *format-icons* can be set. @@ -172,6 +256,7 @@ Additional to workspace name matching, the following *format-icons* can be set. - *empty*: Will be shown on non-active, non-special empty persistent workspaces - *visible*: Will be shown on workspaces that are visible but not active. For example: this is useful if you want your visible workspaces on other monitors to have the same look as active. - *persistent*: Will be shown on non-empty persistent workspaces +- *urgent*: Will be shown on non-active urgent workspaces # EXAMPLES @@ -238,6 +323,51 @@ Additional to workspace name matching, the following *format-icons* can be set. } ``` +``` +"hyprland/workspaces": { + "format": "{icon}", + "format-window-separator": ", ", + "tooltip": true, + "tooltips": { + "default": "{name}: {windows}", + "empty": "" // Will result in no tooltip + } + "format-icons": { + "1": "", + "2": "", + "3": "", + "4": "", + "5": "", + "active": "", + "default": "" + }, + // Window rewrites omitted for brevity +} +``` + +``` +"hyprland/workspaces": { + "format": "{icon}", + "format-window-separator": ", ", + "tooltip": true, + "tooltips": { + "1": "This is the first workspace", + "2": "This is the second", + "2": "And this is the third" + } + "format-icons": { + "1": "", + "2": "", + "3": "", + "4": "", + "5": "", + "active": "", + "default": "" + }, + // Window rewrites omitted for brevity +} +``` + # Style - *#workspaces* @@ -248,6 +378,8 @@ Additional to workspace name matching, the following *format-icons* can be set. - *#workspaces button.persistent* - *#workspaces button.special* - *#workspaces button.urgent* +- *#workspaces button.workspace-hover* (applied while the pointer is over the button) +- *#workspaces button.* (per-workspace class derived from the workspace name, sanitized to a valid CSS class name, e.g. a workspace named "1" yields '.ws-1'; special workspaces also get the raw name class) - *#workspaces button.hosting-monitor* (gets applied if workspace-monitor == waybar-monitor) - *#workspaces .workspace-label* - *#workspaces .taskbar-window* (each window in the taskbar, only if 'workspace-taskbar.enable' is true) diff --git a/man/waybar-idle-inhibitor.5.scd b/man/waybar-idle-inhibitor.5.scd index 405c8fc5..405258c1 100644 --- a/man/waybar-idle-inhibitor.5.scd +++ b/man/waybar-idle-inhibitor.5.scd @@ -41,15 +41,15 @@ screensaver, also known as "presentation mode". *on-click*: ++ typeof: string ++ - Command to execute when clicked on the module. A click also toggles the state + Command to execute when clicked on the module. A click also toggles the state (enable only if dynamic timeouts are enabled). *on-click-middle*: ++ typeof: string ++ - Command to execute when middle-clicked on the module using mousewheel. + Command to execute when middle-clicked on the module using mousewheel. (reset the timeout to the initial value if dynamic timeouts are enabled). *on-click-right*: ++ typeof: string ++ - Command to execute when you right-click on the module. + Command to execute when you right-click on the module. (deactivate the inhibit if dynamic timeouts are enabled). *on-update*: ++ typeof: string ++ @@ -57,11 +57,11 @@ screensaver, also known as "presentation mode". *on-scroll-up*: ++ typeof: string ++ - Command to execute when scrolling up on the module. + Command to execute when scrolling up on the module. (increase the timeout if dynamic timeouts are enabled). *on-scroll-down*: ++ typeof: string ++ - Command to execute when scrolling down on the module. + Command to execute when scrolling down on the module. (decrease the timeout if dynamic timeouts are enabled). *smooth-scrolling-threshold*: ++ typeof: double ++ @@ -76,6 +76,28 @@ screensaver, also known as "presentation mode". typeof: double ++ The number of minutes the inhibition should last. +*timeout-step*: ++ + typeof: double ++ + default: 10 ++ + The number of minutes to add or subtract when scrolling (when dynamic timeouts are enabled). + +*dynamic-timeouts*: ++ + typeof: bool ++ + default: false ++ + Option to enable dynamic timeouts. ++ + The legacy singular spelling *dynamic-timeout* is also accepted as an alias. + +*wait-for-activity*: ++ + typeof: bool ++ + default: *false* ++ + When enabled, the idle inhibitor remains active as long as there is keyboard or mouse activity on the bar. If there is no activity for the duration specified in *timeout*, the inhibitor will automatically toggle off. This option requires *timeout* to be set. + +*signal*: ++ + typeof: integer ++ + The signal number used to toggle the idle inhibitor externally. ++ + The number is valid between 1 and N, where *SIGRTMIN+N* = *SIGRTMAX*. ++ + Use `pkill -SIGRTMIN+N waybar` to toggle the idle inhibitor from scripts or keybindings. + *tooltip*: ++ typeof: bool ++ default: true ++ @@ -113,16 +135,64 @@ screensaver, also known as "presentation mode". *{icon}*: Icon, as defined in *format-icons* +*{timeout}*: Timeout in minutes + +*{timeleft}*: Time left in minutes + # EXAMPLES +Basic usage with timeout: + ``` "idle_inhibitor": { "format": "{icon}", + "format-icons": { + "activated": "", + "deactivated": "" + }, + "timeout": 30.5 +} +``` + +With dynamic timeouts (scroll to adjust the timeout): + +``` +"idle_inhibitor": { + "format": "{status} {timeleft}/{timeout}", "format-icons": { "activated": "", "deactivated": "" }, - "timeout": 30.5 + "timeout": 480, + "dynamic-timeouts": true, + "timeout-step": 10 +} +``` + +With external control via signals (can be toggled with `pkill -SIGRTMIN+8 waybar`): + +``` +"idle_inhibitor": { + "format": "{icon}", + "format-icons": { + "activated": "", + "deactivated": "" + }, + "signal": 8 +} +``` + +With wait-for-activity feature: + +``` +"idle_inhibitor": { + "format": "{icon}", + "format-icons": { + "activated": "", + "deactivated": "" + }, + "timeout": 5.0, + "wait-for-activity": true } ``` diff --git a/man/waybar-image.5.scd b/man/waybar-image.5.scd index 8be182a6..b75a2015 100644 --- a/man/waybar-image.5.scd +++ b/man/waybar-image.5.scd @@ -6,10 +6,19 @@ waybar - image module # DESCRIPTION -The *image* module displays an image from a path. +The *image* module displays container of images(or image) from a provided paths + +# REMARK + +This module has been rewritten to add multiple image rendering functionality. +To avoid users inconvenience of breaking changes, everything related to old(only +one *image*) implementation has been left untouched including configuration part. For this +reason this wiki page is split into two parts # CONFIGURATION +For single *image* + *path*: ++ typeof: string ++ The path to the image. @@ -91,7 +100,176 @@ $path\\n$tooltip } ``` +Using *exec* to fetch the image via a script: + +``` +"image#album-art": { + "exec": "~/.config/waybar/custom/spotify/album_art.sh", + "size": 32, + "interval": 30 +} +``` + +Where *album\_art.sh* looks like this: + +``` +#!/bin/bash +album_art=$(playerctl -p spotify metadata mpris:artUrl) +if [[ -z $album_art ]]; then + # spotify is dead, we should die too. + exit +fi +curl -s "${album_art}" --output "/tmp/cover.jpeg" +echo "/tmp/cover.jpeg" +``` + # STYLE - *#image* - *#image.empty* + +# CONFIGURATION + +For multiple *image* + +*size*: ++ + typeof: integer ++ + default: 16 ++ + Minumum size of the rendered image in pixels. + +*interval*: ++ + typeof: integer or float ++ + The interval (in seconds) to re-render the module. ++ + If set to a positive value, the minimum is 0.001 (1ms). Values smaller than 1ms will be set to 1ms. ++ + Zero or negative values (and the literal "once") are treated as "once". ++ + This is useful if image path or other property is being changed over time. ++ + If no *interval* is provided, the module will only be rendered once. + +*multiple*: ++ + typeof: bool ++ + default: false ++ + this parameter is used to decide if old *image* implementation should be ++ + used(therefore render only one image) or to pick a new implementation to ++ + rended multiple images provided from this config. default value of this ++ + parameter is false. + +*signal*: ++ + typeof: interger ++ + default: 0 ++ + The signal number is used to redraw the module. ++ + This is used if *interval* is not provided and module should change ++ + regularly depended on external events, e.g when clicking on it. ++ + The provided value is valid between 1 and N, where SIGRTMIN+N <= SIGRTMAX. ++ + e.g if *signal* = 7, 34 + 7 = 41, which means waybar process should ++ + receive signal 41 to redraw this module, since SIGRTMIN = 34 (see 'kill -l'). + +*entries*: ++ + typeof: array ++ + default: empty array ++ + Json array of objects consisting of several fields ++ + this is a main part of configuration where per image properties are defined + + *path*: ++ + typeof: string ++ + default: empty string ++ + file path of picture to draw + + *marker*: ++ + typeof: string ++ + default: empty string ++ + user defined per image keyword(marker) to identify them, e.g in styles.css ++ + e.g if you want to draw 3 pictures and one of them dimmed, you can give ++ + *marker*: "dimmed" value and in styles.css write something like this ++ + #image .dimmed { opacity: 0.3 } + + *tooltip*: ++ + typeof: string ++ + default: empty string ++ + description of the image displayed on hover + + *on-click*: ++ + typeof: string ++ + default: empty string ++ + action to perform when clicking on the image. ++ + The action can be any system or custom binary/script ++ + e.g if { "on-click": "notify-send \"hello world\"" } ++ + "hello world" will appear as notification. + +*exec*: ++ + typeof: string ++ + default: empty string ++ + same as *entries* but provided from external script which should dynamically ++ + return json array of objects consisting of same keys as *entries*. ++ + This option is specifically useful in combination with *signal* option without *interval* provided. + +# Examples + +With entries +``` +"image#wentr": { + "entries": [ + { + "path": "/home/user/Pictures/idk1.png", + "marker": "dimmed", + "tooltip": "dimmed image", + "on-click": "notify-send \"hello world\"" + }, + { + "path": "/home/user/Pictures/idk2.png", + "marker": "normal", + "on-click": "myCustomScript.py" + } + ], + "size": 32, + "interval": 1 // will redraw every 5 seconds +} +``` + +With exec +``` +"image#wexec": { + "exec": "imageProvider.py", + "size": 32, + "signal": 7 // e.g 'pkill -n waybar --signal 41' will trigger redraw +} +``` + +where imageProvider.py looks like this +``` +#!/usr/bin/python + +import json + +dir = "/home/user/Pictures/" +out = [ + {"path": dir + "idk1.png", "marker": "clickable", "on-click": "echo 'hello world'"}, + {"path": dir + "idk2.png", "marker": "normal", "tooltip": "sample tooltip"}, + {"path": dir + "idk3.png", "marker": "important"}, +] +res = json.dumps(out) +print(res) +``` + +## Example styles +``` +#image .important { + opacity: 0.9; +} + +#image button.important { + opacity: 0.2 +} + +#image .normal { + opacity: 0.3 +} +``` + +# STYLE + +- *#image* +- *#image*.empty /\*when image could not be rendered\*/ +- *#image* button /\*when on-click is provided; a Gtk::Button is matched by the element selector, not a class\*/ +- *#image*.*marker* /\*where *marker* is parameter from config\*/ +- *#image* button.*marker* + diff --git a/man/waybar-inhibitor.5.scd b/man/waybar-inhibitor.5.scd index fce6f4f8..2e9759ad 100644 --- a/man/waybar-inhibitor.5.scd +++ b/man/waybar-inhibitor.5.scd @@ -100,6 +100,12 @@ See *systemd-inhibit*(1) for more information. *{icon}*: Icon, as defined in *format-icons* +# STYLE + +- *#inhibitor* +- *#inhibitor.activated* +- *#inhibitor.deactivated* + # EXAMPLES ``` diff --git a/man/waybar-jack.5.scd b/man/waybar-jack.5.scd index 98adc8db..929f2d58 100644 --- a/man/waybar-jack.5.scd +++ b/man/waybar-jack.5.scd @@ -6,7 +6,13 @@ waybar - JACK module # DESCRIPTION -The *jack* module displays the current state of the JACK server. +The *jack* module displays the current state of the JACK server. It supports both +the JACK2 and PipeWire +implementations of the JACK API. + +PipeWire users are advised to run at least v0.3.57; earlier versions cause the +Waybar client to hang indefinitely if the server shuts down while the module is +running. # CONFIGURATION diff --git a/man/waybar-keyboard-state.5.scd b/man/waybar-keyboard-state.5.scd index 79498414..6dcd590e 100644 --- a/man/waybar-keyboard-state.5.scd +++ b/man/waybar-keyboard-state.5.scd @@ -8,7 +8,8 @@ waybar - keyboard-state module The *keyboard-state* module displays the state of number lock, caps lock, and scroll lock. -You must be a member of the input group to use this module. +You must be a member of the input group to use this module. Add yourself with +*usermod -a -G input * (as root), then reboot. # CONFIGURATION @@ -26,7 +27,10 @@ You must be a member of the input group to use this module. *format-icons*: ++ typeof: object ++ default: {"locked": "locked", "unlocked": "unlocked"} ++ - Based on the keyboard state, the corresponding icon gets selected. The same set of icons is used for number, caps, and scroll lock, but the icon is selected from the set independently for each. See *icons*. + Based on the keyboard state, the corresponding icon gets selected. Supports two syntaxes: + - Common format-icons: "locked" and "unlocked" keys apply to all lock types. + - Per-lock-type format-icons: per-lock-type objects with "numlock", "capslock", "scrolllock" keys, each containing "locked" and "unlocked" icons. + See *icons*. *numlock*: ++ typeof: bool ++ @@ -68,15 +72,21 @@ You must be a member of the input group to use this module. The following *format-icons* can be set. +## Common format-icons for all lock types: - *locked*: Will be shown when the keyboard state is locked. Default "locked". -- *unlocked*: Will be shown when the keyboard state is not locked. Default "unlocked" +- *unlocked*: Will be shown when the keyboard state is not locked. Default "unlocked". + +## Per-lock-type format-icons: +- *numlock*, *capslock*, *scrolllock*: Object containing "locked" and "unlocked" keys for lock-type-specific icons. Defaults to {"locked": "locked", "unlocked": "unlocked"} for each lock type. # EXAMPLE: +## Common format-icons for all lock types: ``` "keyboard-state": { "numlock": true, "capslock": true, + "scrolllock": true, "format": "{name} {icon}", "format-icons": { "locked": "", @@ -85,6 +95,30 @@ The following *format-icons* can be set. } ``` +## Per-lock-type format-icons: +``` +"keyboard-state": { + "numlock": true, + "capslock": true, + "scrolllock": true, + "format": "{name} {icon}", + "format-icons": { + "numlock": { + "locked": "1", + "unlocked": "0" + }, + "capslock": { + "locked": "A", + "unlocked": "a" + }, + "scrolllock": { + "locked": "S", + "unlocked": "s" + } + } +} +``` + # STYLE - *#keyboard-state* diff --git a/man/waybar-load.5.scd b/man/waybar-load.5.scd new file mode 100644 index 00000000..3a3ce5b9 --- /dev/null +++ b/man/waybar-load.5.scd @@ -0,0 +1,134 @@ +waybar-load(5) + +# NAME + +waybar - load module + +# DESCRIPTION + +The *load* module displays the current CPU load. + +# CONFIGURATION + +Addressed by *load* + +*interval*: ++ + typeof: integer++ + default: 10 ++ + The interval in which the information gets polled. + +*format*: ++ + typeof: string ++ + default: {load1} ++ + The format, how information should be displayed. + +*rotate*: ++ + typeof: integer ++ + Positive value to rotate the text label (in 90 degree increments). + +*states*: ++ + typeof: object ++ + A number of load states which get activated on certain thresholds, based on `load1`. See *waybar-states(5)*. + +*max-length*: ++ + typeof: integer ++ + The maximum length in character the module should display. + +*min-length*: ++ + typeof: integer ++ + The minimum length in characters the module should accept. + +*align*: ++ + typeof: float ++ + The alignment of the label within the module, where 0 is left-aligned and 1 is right-aligned. If the module is rotated, it will follow the flow of the text. + +*justify*: ++ + typeof: string ++ + The alignment of the text within the module's label, allowing options 'left', 'right', or 'center' to define the positioning. + +*on-click*: ++ + typeof: string ++ + Command to execute when clicked on the module. + +*on-click-middle*: ++ + typeof: string ++ + Command to execute when middle-clicked on the module using mousewheel. + +*on-click-right*: ++ + typeof: string ++ + Command to execute when you right-click on the module. + +*on-update*: ++ + typeof: string ++ + Command to execute when the module is updated. + +*on-scroll-up*: ++ + typeof: string ++ + Command to execute when scrolling up on the module. + +*on-scroll-down*: ++ + typeof: string ++ + Command to execute when scrolling down on the module. + +*smooth-scrolling-threshold*: ++ + typeof: double ++ + Threshold to be used when scrolling. + +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*tooltip-format*: ++ + typeof: string ++ + default: Load 1: {load1}\\nLoad 5: {load5}\\nLoad 15: {load15} ++ + The format of the tooltip shown on hover. Supports the same replacements as *format*. + +*menu*: ++ + typeof: string ++ + Action that popups the menu. + +*menu-file*: ++ + typeof: string ++ + Location of the menu descriptor file. There need to be an element of type + GtkMenu with id *menu* + +*menu-actions*: ++ + typeof: array ++ + The actions corresponding to the buttons of the menu. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables this module to consume all left over space dynamically. + +# FORMAT REPLACEMENTS + +*{}*: Equivalent to `{load1}` + +*{load1}*: Current CPU load average over the last minute + +*{load5}*: Current CPU load average over the last 5 minutes + +*{load15}*: Current CPU load average over the last 15 minutes + +# EXAMPLES + +``` +"load": { + "interval": 10, + "format": "load: {load1}", + "max-length": 10 +} +``` + +``` +"load": { + "interval": 1, + "format": "load: {load1} {load5} {load15}" +} +``` + +# STYLE + +- *#load* diff --git a/man/waybar-mango-keymode.5.scd b/man/waybar-mango-keymode.5.scd new file mode 100644 index 00000000..b96c1903 --- /dev/null +++ b/man/waybar-mango-keymode.5.scd @@ -0,0 +1,63 @@ +waybar-mango-keymode(5) + +# NAME + +waybar - mango keymode module + +# DESCRIPTION + +The *keymode* module displays the current keyboard mode (e.g. "resize", "default") in the Mango compositor. It is hidden when no mode is active. + +# CONFIGURATION + +Addressed by *mango/keymode* + +*format*: ++ + typeof: string ++ + default: {} ++ + The format, how the mode should be displayed. *{mode}* is replaced by the current mode name. + +*format-*: ++ + typeof: string ++ + Provide a custom format for a specific keymode. ** is the mode name as reported by Mango (e.g. "resize"). The value can contain *{mode}* as a placeholder. + If this option is set, it overrides the main *format* for that mode. + +*menu*: ++ + typeof: string ++ + Action that pops up a menu. + +*menu-file*: ++ + typeof: string ++ + Location of the menu descriptor file. + +*menu-actions*: ++ + typeof: array ++ + Actions for the menu buttons. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables the module to consume all leftover space. + +# FORMAT REPLACEMENTS + +*{mode}*: The name of the current keymode. + +# EXAMPLES + +``` +"mango/keymode": { + "format": "[{mode}]", + "format-resize": " Resizing" +} +``` + +# STYLE + +- *#keymode* + +A CSS class with the current mode name (e.g. *.resize*) is added to the widget, allowing per‑mode styling: + +``` +#keymode.resize { background: #ff0000; } +``` diff --git a/man/waybar-mango-language.5.scd b/man/waybar-mango-language.5.scd new file mode 100644 index 00000000..7b50f4ad --- /dev/null +++ b/man/waybar-mango-language.5.scd @@ -0,0 +1,78 @@ +waybar-mango-language(5) + +# NAME + +waybar - mango language module + +# DESCRIPTION + +The *language* module displays the currently active keyboard layout in the Mango compositor. + +# CONFIGURATION + +Addressed by *mango/language* + +*format*: ++ + typeof: string ++ + default: {} ++ + The format, how the layout should be displayed. See *FORMAT REPLACEMENTS*. + +*format-*: ++ + typeof: string ++ + Provide an alternative format string for a given language. + is the short description of the layout (e.g. "us", "de"). + The value is used as the replacement for *{}* in the main *format*. + This option can be repeated for multiple languages. + +*format--*: ++ + typeof: string ++ + Like *format-* but also matches the layout variant, taking precedence over *format-* when both the language and variant match. + +*menu*: ++ + typeof: string ++ + Action that pops up a menu. + +*menu-file*: ++ + typeof: string ++ + Location of the menu descriptor file. It must contain an element of type GtkMenu with id *menu*. + +*menu-actions*: ++ + typeof: array ++ + Actions corresponding to the buttons of the menu. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables this module to consume all leftover space dynamically. + +# FORMAT REPLACEMENTS + +*{short}*: Short name of the layout (e.g. "us"). + +*{shortDescription}*: Short description of the layout (same as *{short}* in most cases). + +*{long}*: Full name of the layout as reported by Mango (e.g. "English (US)"). The empty positional *{}* in the default format resolves to this value, so the long name is shown when no *format* is specified. + +*{variant}*: Variant of the layout, if any. + +# EXAMPLES + +``` +"mango/language": { + "format": " {long} ", + "format-us": "US", + "format-de": "DE" +} +``` + +# STYLE + +- *#language* + +A CSS class matching the current layout's short name is added to the widget. +This allows per‑layout styling: + +``` +#language.us { color: #00ff00; } +#language.de { color: #ff0000; } +``` diff --git a/man/waybar-mango-layout.5.scd b/man/waybar-mango-layout.5.scd new file mode 100644 index 00000000..fedff008 --- /dev/null +++ b/man/waybar-mango-layout.5.scd @@ -0,0 +1,75 @@ +waybar-mango-layout(5) + +# NAME + +waybar - mango layout module + +# DESCRIPTION + +The *layout* module displays the current layout symbol of the monitor (e.g. "S", "M") in the Mango compositor. +It supports dynamic CSS classes and custom formats based on the active layout symbol. + +# CONFIGURATION + +Addressed by *mango/layout* + +*format*: ++ + typeof: string ++ + default: {} ++ + The default format, how the layout symbol should be displayed. The positional *{}* (the default) and *{symbol}* are both replaced by the current layout symbol. + +*format-*: ++ + typeof: string ++ + default: *none* ++ + The custom format to use when a specific layout symbol is active (e.g., *format-S*, *format-M*). Note that the symbol string is strictly case-sensitive. If no match is found, it falls back to *format*. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables the module to consume all leftover space. + +# FORMAT REPLACEMENTS + +*{symbol}*: The layout symbol reported by Mango (e.g., "S", "M", "Dwindle"). + +# CUSTOM FORMATS + +You can define specific formats for different layouts by appending the exact layout symbol to the *format-* key in your configuration. + +For example, if your Mango compositor reports the symbol "S" for a spiral layout and "M" for a master layout, you can use *format-S* and *format-M* to define unique icons or text for each. Keep in mind that JSON keys are case-sensitive, so if the compositor sends "S", the key must be exactly *format-S*. + +# STYLE + +The layout module provides dynamic CSS classes based on the current layout symbol, allowing you to style each layout differently. + +* *#layout* +* *.* - The current layout symbol is dynamically added as a CSS class name (e.g., *.S*, *.M*). + +# EXAMPLES + +``` +"mango/layout": { + "format": "[] {symbol}", + "format-S": "󰌌 {symbol}", + "format-M": "󰕰 {symbol}" +} +``` + +## CSS Example + +``` +#layout { + color: #ffffff; + padding: 0 5px; +} + +/* Specific color for the "S" layout */ +#layout.S { + color: #a6e3a1; +} + +/* Specific color for the "M" layout */ +#layout.M { + color: #f38ba8; +} +``` diff --git a/man/waybar-mango-window.5.scd b/man/waybar-mango-window.5.scd new file mode 100644 index 00000000..8d026204 --- /dev/null +++ b/man/waybar-mango-window.5.scd @@ -0,0 +1,73 @@ +waybar-mango-window(5) + +# NAME + +waybar - mango window module + +# DESCRIPTION + +The *window* module displays the title and app ID of the currently focused window in the Mango compositor. + +# CONFIGURATION + +Addressed by *mango/window* + +*format*: ++ + typeof: string ++ + default: {title} ++ + The format string. See *FORMAT REPLACEMENTS*. + +*rewrite*: ++ + typeof: object ++ + Rules to rewrite the window title. Each key is a regular expression and its value is the replacement string. Captures can be used with *$1*, *$2*, etc. + +*icon*: ++ + typeof: bool ++ + default: false ++ + Whether to show the application icon. + +*icon-size*: ++ + typeof: integer ++ + default: 24 ++ + Size of the application icon in pixels. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables the module to consume all leftover space. + +# FORMAT REPLACEMENTS + +*{title}*: The current window title. + +*{app_id}*: The app ID of the focused window. + +# REWRITE RULES + +If the title matches a regular expression from the *rewrite* object, it is replaced by the corresponding value. Regular expression syntax follows ECMAScript rules. Unmatched titles are left unchanged. + +# EXAMPLES + +``` +"mango/window": { + "format": "{title}", + "rewrite": { + "(.*) - Mozilla Firefox": "🌎 $1", + "(.*) - zsh": "> [$1]" + }, + "icon": true, + "icon-size": 20 +} +``` + +# STYLE + +- *#window* + +The following classes are set on the module's event box (the container that +wraps *#window*), not on *#window* itself. Target them directly or with a +descendant selector such as *.solo #window*: + +- *.empty* – applied when no window is focused (module hidden by default) +- *.solo* – applied when only one window is present on the active workspace +- *.* – applied when a single window with the given app ID is on the workspace \ No newline at end of file diff --git a/man/waybar-mango-workspaces.5.scd b/man/waybar-mango-workspaces.5.scd new file mode 100644 index 00000000..985398e7 --- /dev/null +++ b/man/waybar-mango-workspaces.5.scd @@ -0,0 +1,112 @@ +waybar-mango-workspaces(5) + +# NAME + +waybar - mango workspaces module + +# DESCRIPTION + +The *workspaces* module displays the tags (workspaces) of the Mango compositor. It shows an overview button when the overview mode is active (active tag is 0), and individual tag buttons otherwise. + +# CONFIGURATION + +Addressed by *mango/workspaces* + +*format*: ++ + typeof: string ++ + default: {value} ++ + The format for each tag button. See *FORMAT REPLACEMENTS*. + +*format-icons*: ++ + typeof: object ++ + Icons to be used instead of the workspace index or name. Keys can be a workspace index (as a string), or one of the following special state keys: *default*, *active*, *urgent*, *empty*. + +*disable-markup*: ++ + typeof: bool ++ + default: false ++ + If true, the button label will not be interpreted as Pango markup. + +*current-only*: ++ + typeof: bool ++ + default: false ++ + If true, only the currently active workspace button is shown. + +*hide-empty*: ++ + typeof: bool ++ + default: false ++ + If true, buttons for empty (client_count == 0) workspaces are hidden, unless the workspace is active. + +*on-click*: ++ + typeof: string ++ + Command to execute on left click. Typically set to *activate* or *toggle*. + +*on-click-middle*: ++ + typeof: string ++ + Command for middle click. Same actions as *on-click*. + +*on-click-right*: ++ + typeof: string ++ + Command for right click. Same actions as *on-click*. + +*overview-label*: ++ + typeof: string ++ + default: "OVERVIEW" ++ + Label shown on the overview button when the overview is active. + +*expand*: ++ + typeof: bool ++ + default: false ++ + Enables the module to consume leftover space. + +# FORMAT REPLACEMENTS + +*{value}*: Workspace index (same as *{index}* for unnamed workspaces). + +*{name}*: Workspace name (equal to the index for unnamed workspaces in Mango). + +*{icon}*: Icon selected from *format-icons* based on workspace index and state. + +*{index}*: Numeric index of the workspace. + +*{output}*: Name of the output where the workspace is located. + +# CLICK ACTIONS + +When a tag button is clicked, the action from *on-click* (or its middle/right variants) is evaluated. +Supported actions: + +- *activate*: dispatch view, +- *toggle*: dispatch toggleview, + +When the overview button is clicked, the actions change to: + +- *activate*: dispatch overview +- *toggle*: dispatch toggleoverview + +# EXAMPLES + +``` +"mango/workspaces": { + "format": "{icon}", + "format-icons": { + "1": "一", + "2": "二", + "active": "", + "default": "", + "urgent": "", + "empty": "" + }, + "on-click": "activate", + "on-click-right": "toggle", + "overview-label": "" +} +``` + +# STYLE + +- *#workspaces button* +- *#workspaces button.active* – the workspace is active (visible) on its output. +- *#workspaces button.urgent* – the workspace has at least one urgent window. +- *#workspaces button.empty* – the workspace contains no clients. +- *#workspaces button.current_output* – the workspace belongs to the output where the bar is shown. +- *#workspaces button.overview* – the overview button (visible in overview mode). \ No newline at end of file diff --git a/man/waybar-memory.5.scd b/man/waybar-memory.5.scd index 99f57299..8ff51688 100644 --- a/man/waybar-memory.5.scd +++ b/man/waybar-memory.5.scd @@ -84,6 +84,11 @@ Addressed by *memory* default: true ++ Option to disable tooltip on hover. +*tooltip-format*: ++ + typeof: string ++ + default: {used:0.1f}GiB used ++ + The format of the tooltip shown on hover. Supports the same replacements as *format*. + *menu*: ++ typeof: string ++ Action that popups the menu. diff --git a/man/waybar-menu.5.scd b/man/waybar-menu.5.scd index 798917f0..00a5f53c 100644 --- a/man/waybar-menu.5.scd +++ b/man/waybar-menu.5.scd @@ -67,7 +67,7 @@ A module that implements a 'menu' needs 3 properties defined in its config : GtkMenu with id *menu*. *menu-actions*: ++ - typeof: array ++ + typeof: object ++ The actions corresponding to the buttons of the menu. The identifiers of each actions needs to exists as an id in the 'menu-file' for it to be linked properly. diff --git a/man/waybar-mpd.5.scd b/man/waybar-mpd.5.scd index d310efd4..bf331f08 100644 --- a/man/waybar-mpd.5.scd +++ b/man/waybar-mpd.5.scd @@ -8,17 +8,19 @@ waybar - mpd module The *mpd* module displays information about a running "Music Player Daemon" instance. +Note that Waybar must be compiled with MPD support in order to use this module. + # CONFIGURATION Addressed by *mpd* *server*: ++ typeof: string ++ - The network address or Unix socket path of the MPD server. If empty, connect to the default host. + The network address or Unix socket path of the MPD server. If empty, connect to the default host (`MPD_HOST`). *port*: ++ typeof: integer ++ - The port MPD listens to. If empty, use the default port. + The port MPD listens to. If empty, use the default port (`MPD_PORT`). *password*: ++ typeof: string ++ @@ -29,6 +31,11 @@ Addressed by *mpd* default: 5 ++ The interval in which the connection to the MPD server is retried +*playing-interval*: ++ + typeof: integer++ + default: 1000 ++ + The interval (in milliseconds) in which the playing state is updated. + *timeout*: ++ typeof: integer++ default: 30 ++ @@ -89,6 +96,11 @@ Addressed by *mpd* typeof: integer ++ Maximum length of the Title tag. +*ellipsis*: ++ + typeof: string ++ + default: "" ++ + The string appended to the Artist, Album, Album Artist and Title tags when they are truncated by the corresponding length option (`artist-len`, `album-len`, `album-artist-len`, `title-len`). + *rotate*: ++ typeof: integer ++ Positive value to rotate the text label (in 90 degree increments). diff --git a/man/waybar-mpris.5.scd b/man/waybar-mpris.5.scd index 9c192bd1..e03ac0eb 100644 --- a/man/waybar-mpris.5.scd +++ b/man/waybar-mpris.5.scd @@ -26,7 +26,7 @@ The *mpris* module displays currently playing media via libplayerctl. *format*: ++ typeof: string ++ - default: {player} ({status}) {dynamic} ++ + default: {player} ({status}): {dynamic} ++ The text format. *format-[status]*: ++ @@ -40,7 +40,7 @@ The *mpris* module displays currently playing media via libplayerctl. *tooltip-format*: ++ typeof: string ++ - default: {player} ({status}) {dynamic} ++ + default: {player} ({status}): {dynamic} ++ The tooltip text format. *tooltip-format-[status]*: ++ @@ -140,6 +140,21 @@ The *mpris* module displays currently playing media via libplayerctl. default: next track ++ Overwrite default action toggles. +*on-click-backward*: ++ + typeof: string ++ + default: previous track ++ + Overwrite the default action for the backward mouse button (button 8). + +*on-click-forward*: ++ + typeof: string ++ + default: next track ++ + Overwrite the default action for the forward mouse button (button 9). + +*prefer-album-artist*: ++ + typeof: bool ++ + default: false ++ + When true, use the album artist instead of the track artist for the _{artist}_ and _{dynamic}_ tags, falling back to the track artist when no album artist is available. + *player-icons*: ++ typeof: map[string]string ++ Allows setting _{player_icon}_ based on player-name property. @@ -168,6 +183,8 @@ The *mpris* module displays currently playing media via libplayerctl. *{length}*: Length of the track, formatted as HH:MM:SS +*{position}*: Current playback position of the track, formatted as HH:MM:SS + *{dynamic}*: Use _{artist}_, _{album}_, _{title}_ and _{length}_, automatically omit++ empty values diff --git a/man/waybar-network.5.scd b/man/waybar-network.5.scd index 9e146ac0..b950d431 100644 --- a/man/waybar-network.5.scd +++ b/man/waybar-network.5.scd @@ -56,6 +56,10 @@ Addressed by *network* typeof: string ++ This format is used when rfkill is blocking wlan interfaces. +*format-alt*: ++ + typeof: string ++ + On click, toggle between this format and the regular format. The click button is set with *format-alt-click* (defaults to the left button). + *format-icons*: ++ typeof: array/object ++ Based on the current signal strength, the corresponding icon gets selected. ++ @@ -176,6 +180,8 @@ Addressed by *network* *{signaldBm}*: Signal strength of the wireless network in dBm. +*{signalStrengthApp}*: Human-readable descriptor of the wireless connectivity derived from the signal strength (e.g. "Great Connectivity", "Streaming", "Poor Connectivity"). + *{frequency}*: Frequency of the wireless network in GHz. *{bandwidthUpBits}*: Instant up speed in bits/seconds. @@ -194,10 +200,48 @@ Addressed by *network* *{bandwidthDownBytes}*: Instant down speed in bytes/seconds. +*{bandwidthUpBytesCompact}*: Instant up speed in bytes/seconds, formatted compactly (e.g. 1.2K). + +*{bandwidthDownBytesCompact}*: Instant down speed in bytes/seconds, formatted compactly (e.g. 1.2K). + *{bandwidthTotalBytes}*: Instant total speed in bytes/seconds. +*{txBitrate}*: Link transmit bitrate (e.g., 866.7 Mb/s). + +*{rxBitrate}*: Link receive bitrate (e.g., 866.7 Mb/s). + +*{linkSpeed}*: Ethernet link speed. + *{icon}*: Icon, as defined in *format-icons*. +# NUMBER FORMAT MODIFIERS + +The bandwidth/bitrate replacements above (*{bandwidth\*}*, *{txBitrate}*, +*{rxBitrate}*, *{linkSpeed}*) auto-scale the number with an SI prefix (k, M, G, +…). Their rendering can be tuned with fmt-style modifiers, e.g. +*"{bandwidthDownBits:>}"* or *"{bandwidthDownBits:M}"*. Modifiers may be combined +in any order: + +*<*, *=*, *>*: Alignment/padding (left, column-align, right) — as before. + +*u* / *U*: Hide (*u*) or show (*U*) the unit suffix. The unit is shown by default +with an auto scale and hidden by default when a scale is forced (see below). + +*#*, *k*, *M*, *G*, *T*, *P*: Force a fixed scale instead of auto-selecting one +(*#* = base scale, no prefix). Forcing a scale hides the scale prefix and, by +default, the unit (re-enable it with *U*). E.g. *"{bandwidthDownBits:M}"* always +shows the value in megabits. + +*i*: Force integer display (no decimals). + +*b* / *B*: Force decimal base 1000 (*b*) or binary base 1024 with an *i* marker +(*B*), overriding the module default. + +A trailing number is a fixed width for the coefficient when a scale is forced; if +the value does not fit, it is shown as *#* characters (e.g. *"{bandwidthDownBits:=3#}"* +→ *###*). Without a forced scale the number is ignored (kept for config +compatibility). + # EXAMPLES ``` diff --git a/man/waybar-niri-language.5.scd b/man/waybar-niri-language.5.scd index 78cf14f8..ae5c7e28 100644 --- a/man/waybar-niri-language.5.scd +++ b/man/waybar-niri-language.5.scd @@ -21,6 +21,10 @@ Addressed by *niri/language* typeof: string++ Provide an alternative name to display per language where is the language of your choosing. Can be passed multiple times with multiple languages as shown by the example below. +*format--* ++ + typeof: string ++ + Like *format-* but also matches the layout variant, taking precedence over *format-* when both the language and variant match. + *menu*: ++ typeof: string ++ Action that popups the menu. @@ -40,11 +44,11 @@ Addressed by *niri/language* # FORMAT REPLACEMENTS -*{short}*: Short name of layout (e.g. "us"). Equals to {}. +*{short}*: Short name of layout (e.g. "us"). *{shortDescription}*: Short description of layout (e.g. "en"). -*{long}*: Long name of layout (e.g. "English (Dvorak)"). +*{long}*: Long name of layout (e.g. "English (Dvorak)"). Equals to {}. *{variant}*: Variant of layout (e.g. "dvorak"). diff --git a/man/waybar-niri-window.5.scd b/man/waybar-niri-window.5.scd index 8e886c29..784c3b39 100644 --- a/man/waybar-niri-window.5.scd +++ b/man/waybar-niri-window.5.scd @@ -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 @@ -48,6 +53,10 @@ See the output of "niri msg windows" for examples *{app_id}*: The current app ID of the focused window. +*{col}*: The current column of the focused window in the workspace. + +*{max_col}*: The maximum column inside the workspace of the focused window. + # REWRITE RULES *rewrite* is an object where keys are regular expressions and values are diff --git a/man/waybar-niri-workspaces.5.scd b/man/waybar-niri-workspaces.5.scd index 4be85eb3..636081a0 100644 --- a/man/waybar-niri-workspaces.5.scd +++ b/man/waybar-niri-workspaces.5.scd @@ -17,6 +17,28 @@ Addressed by *niri/workspaces* default: false ++ If set to false, workspaces will only be shown on the output they are on. If set to true all workspaces will be shown on every output. +*sort-by-name*: ++ + typeof: bool ++ + default: false ++ + Sort workspaces by name (numeric sort when all names are numbers). Unnamed workspaces fall back to their index on the output. + +*sort-by-coordinates*: ++ + typeof: bool ++ + default: false ++ + Sort workspaces by output and index. If both *sort-by-name* and *sort-by-coordinates* are true, sorting by name is applied. + +*sort-by-id*: ++ + typeof: bool ++ + default: false ++ + Sort workspaces by id, taking precedence over other sort options. + +*sort-by-number*: ++ + typeof: bool ++ + default: false ++ + Deprecated alias for *sort-by-id*; prefer *sort-by-id* instead. + +If none of the sorting options are enabled, workspaces keep their output/index order. + *format*: ++ typeof: string ++ default: {value} ++ @@ -31,6 +53,11 @@ Addressed by *niri/workspaces* default: false ++ If set to false, you can click to change workspace. If set to true this behaviour is disabled. +*enable-bar-scroll*: ++ + typeof: bool ++ + default: false ++ + If set to false, you can't scroll to cycle throughout workspaces from the entire bar. If set to true this behaviour is enabled. + *disable-markup*: ++ typeof: bool ++ default: false ++ @@ -41,6 +68,21 @@ Addressed by *niri/workspaces* default: false ++ If set to true, only the active or focused workspace will be shown. +*display-condition*: ++ + typeof: string ++ + oneof: "show-all", "keep-named", "only-populated" ++ + default: "show-all" ++ + Specifies the conditions for showing workspace widgets. ++ + ++ + *show-all*: displays widgets for all workspaces. ++ + *keep-named*: always displays named workspaces, irrespective of whether they contain windows or not. ++ + *only-populated*: only displays widgets for workspaces that have windows. + +*hide-empty*: ++ + typeof: bool ++ + default: false ++ + If set to true, empty workspaces will not be shown. + *on-update*: ++ typeof: string ++ Command to execute when the module is updated. @@ -49,6 +91,40 @@ 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: 16 ++ + Size of the icons in the workspace taskbar. + +*ignore-workspaces*: ++ + typeof: array ++ + default: [] ++ + Regexes to match against workspaces names and index's. If there's a match, the workspace will not be shown. + +*format-window-separator*: ++ + typeof: string ++ + default: " " ++ + Separator used between window representations generated by *window-rewrite*. + +*window-rewrite*: ++ + typeof: object ++ + default: {} ++ + Rules to map window app_id and/or title to a specific string representation (e.g., an icon). Keys are regular expressions matching `app_id` and/or `title`. Values are the replacement strings, which can contain `{app_id}` and `{title}` placeholders. + +*window-rewrite-default*: ++ + typeof: string ++ + default: "?" ++ + The default representation for windows that don't match any rule in *window-rewrite*. # FORMAT REPLACEMENTS @@ -63,6 +139,10 @@ as defined by niri. *{output}*: Output where the workspace is located. +*{total}*: The total number of workspaces. + +*{windows}*: Concatenated representation of windows in the workspace, using *window-rewrite* rules and *format-window-separator*. + # ICONS Additional to workspace name matching, the following *format-icons* can be set. @@ -77,18 +157,36 @@ Additional to workspace name matching, the following *format-icons* can be set. ``` "niri/workspaces": { - "format": "{icon}", - "format-icons": { - // Named workspaces - // (you need to configure them in niri) - "browser": "", - "discord": "", - "chat": "", + "format": "{icon} {windows}", + "format-window-separator": "|", + "window-rewrite-default": "", + "window-rewrite": { + "app_id": "", + "app_id": "", + "app_id title<.*Downloads.*>": "", + "app_id": "" + }, + "format-icons": { + // Named workspaces + // (you need to configure them in niri) + "browser": "", + "discord": "", + "chat": "", - // Icons by state - "active": "", - "default": "" - } + // Icons by state + "active": "", + "default": "" + } +} +``` + +``` +"niri/workspaces": { + "format": "{icon}", + "workspace-taskbar": { + "enable": true, + "icon-size": 18 + } } ``` @@ -103,3 +201,7 @@ 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. +- *#workspaces button .niri-taskbar-btn.focused*: The taskbar icon button of the + currently focused window. diff --git a/man/waybar-power-profiles-daemon.5.scd b/man/waybar-power-profiles-daemon.5.scd index 6488767b..808fe894 100644 --- a/man/waybar-power-profiles-daemon.5.scd +++ b/man/waybar-power-profiles-daemon.5.scd @@ -25,8 +25,8 @@ $XDG_CONFIG_HOME/waybar/config :[ Message displayed on the bar. {icon} and {profile} are respectively substituted with the icon representing the active profile and its full name. |[ *tooltip-format* :[ string -:[ "Power profile: {profile}\\nDriver: {driver}" -:[ Messaged displayed in the module tooltip. {icon} and {profile} are respectively substituted with the icon representing the active profile and its full name. +:[ "Power profile: {profile}\\nCPU driver: {cpu_driver}\\nPlatform driver: {platform_driver}" +:[ Messaged displayed in the module tooltip. {icon} and {profile} are respectively substituted with the icon representing the active profile and its full name. {cpu_driver} and {platform_driver} are substituted with the CPU and platform drivers reported by recent power-profiles-daemon versions. {driver} is kept for backward compatibility: it resolves to the legacy single driver on older daemons and falls back to the CPU driver on recent ones. |[ *tooltip* :[ bool :[ true @@ -44,6 +44,18 @@ $XDG_CONFIG_HOME/waybar/config +# FORMAT REPLACEMENTS + +*{profile}*: Active profile full name. + +*{icon}*: Icon (from *format-icons*) representing the active profile. + +*{cpu_driver}*: The CPU driver reported by recent power-profiles-daemon versions. *(tooltip)* + +*{platform_driver}*: The platform driver reported by recent power-profiles-daemon versions. *(tooltip)* + +*{driver}*: Kept for backward compatibility: resolves to the legacy single driver on older daemons and falls back to the CPU driver on recent ones. *(tooltip)* + # CONFIGURATION EXAMPLES Compact display (default config): @@ -51,7 +63,7 @@ Compact display (default config): ``` "power-profiles-daemon": { "format": "{icon}", - "tooltip-format": "Power profile: {profile}\nDriver: {driver}", + "tooltip-format": "Power profile: {profile}\nCPU driver: {cpu_driver}\nPlatform driver: {platform_driver}", "tooltip": true, "format-icons": { "default": "", @@ -67,7 +79,7 @@ Display the full profile name: ``` "power-profiles-daemon": { "format": "{icon} {profile}", - "tooltip-format": "Power profile: {profile}\nDriver: {driver}", + "tooltip-format": "Power profile: {profile}\nCPU driver: {cpu_driver}\nPlatform driver: {platform_driver}", "tooltip": true, "format-icons": { "default": "", @@ -77,3 +89,12 @@ Display the full profile name: } } ``` + +# STYLE + +- *#power-profiles-daemon* +- *#power-profiles-daemon.performance* Applied when the *performance* profile is active. +- *#power-profiles-daemon.balanced* Applied when the *balanced* profile is active. +- *#power-profiles-daemon.power-saver* Applied when the *power-saver* profile is active. + +*Note*: the module applies a CSS class equal to the name of the active profile, so any additional profile exposed by power-profiles-daemon can be styled by its name. diff --git a/man/waybar-privacy.5.scd b/man/waybar-privacy.5.scd index 1c4ef59d..754cdfa4 100644 --- a/man/waybar-privacy.5.scd +++ b/man/waybar-privacy.5.scd @@ -19,7 +19,7 @@ the screen or playing audio. *icon-size*: ++ typeof: integer ++ default: 20 ++ - The size of each privacy icon. + The size of each privacy icon. Set to *0* for auto size. *transition-duration*: ++ typeof: integer ++ @@ -28,7 +28,7 @@ the screen or playing audio. *modules* ++ typeof: array of objects ++ - default: [{"type": "screenshare"}, {"type": "audio-in"}] ++ + default: [{"type": "screenshare"}, {"type": "audio-in"}, {"type": "location"}] ++ Which privacy modules to monitor. See *MODULES CONFIGURATION* for++ more information. @@ -52,9 +52,14 @@ the screen or playing audio. *type*: ++ typeof: string ++ - values: "screenshare", "audio-in", "audio-out" ++ + values: "screenshare", "audio-in", "audio-out", "location" ++ Specifies which module to use and configure. +*icon-name*: ++ + typeof: string ++ + If set, changes the modules icon. Accepts any valid GTK icon name. ++ + Note: the icon is only visible when the resource is actively in use; the item is hidden when idle. + *tooltip*: ++ typeof: bool ++ default: true ++ @@ -95,6 +100,10 @@ the screen or playing audio. "type": "audio-in", "tooltip": true, "tooltip-icon-size": 24 + }, + { + "type": "location", + "icon-name": "location-services-active-symbolic" } ], "ignore-monitor": true, @@ -118,3 +127,4 @@ the screen or playing audio. - *#privacy-item.screenshare* - *#privacy-item.audio-in* - *#privacy-item.audio-out* +- *#privacy-item.location* diff --git a/man/waybar-pulseaudio-slider.5.scd b/man/waybar-pulseaudio-slider.5.scd index 0271e7c5..9830b37c 100644 --- a/man/waybar-pulseaudio-slider.5.scd +++ b/man/waybar-pulseaudio-slider.5.scd @@ -22,36 +22,85 @@ The volume can be controlled by dragging the slider across the bar or clicking o default: 100 ++ The maximum volume value the slider should display and set. +*target*: ++ + typeof: string ++ + default: sink ++ + Whether the slider will control the default audio source or default audio sink. ++ + Valid values are either "sink" or "source". + *orientation*: ++ typeof: string ++ default: horizontal ++ The orientation of the slider. Can be either `horizontal` or `vertical`. *expand*: ++ - typeof: bool ++ - default: false ++ - Enables this module to consume all left over space dynamically. + typeof: bool ++ + default: false ++ + Enables this module to consume all left over space dynamically. + +*zero-on-mute*: ++ + typeof: bool ++ + default: true ++ + `true` = The slider will be set to `min` when the source/sink is muted. ++ + `false` = The slider will continue to show the unmuted volume level when the source/sink is muted. + +*unmute-on-volume-change*: ++ + typeof: bool ++ + default: true ++ + Specifies whether to unmute a muted souce/sink when its volume is changed by the user moving the slider. + +*target*: ++ + typeof: string ++ + default: sink ++ + The audio target to control. Can be either `sink` (output/speakers) or `source` (input/microphone). + +*ignored-sinks*: ++ + typeof: array ++ + default: empty ++ + A list of sink descriptions to ignore when tracking the default sink, so switching to those sinks does not update the slider. + +*sink-mapping*: ++ + typeof: object ++ + Sinks named by the values of this mapping will be considered to be the current sink instead of the sinks named by the respective keys. # EXAMPLES ``` "modules-right": [ - "pulseaudio/slider", + "pulseaudio/slider#out", + "pulseaudio/slider#in", ], -"pulseaudio/slider": { +"pulseaudio/slider#out": { "min": 0, "max": 100, - "orientation": "horizontal" + "orientation": "horizontal", + "zero-on-mute": false, + "unmute-on-volume-change": false +}, +"pulseaudio/slider#in": { + "min": 0, + "max": 100, + "orientation": "horizontal", + "target": "source" } ``` # STYLE +Unlike most modules, the slider modules *require* styling to display correctly. +In particular you *need* to set *min-width* and/or *min-height* (depending on +whether the slider is vertical or horizontal) on the *trough* node, otherwise +the slider renders too small to be usable. This is a GTK detail, not a Waybar +issue. + The slider is a component with multiple CSS Nodes, of which the following are exposed: *#pulseaudio-slider*: ++ Controls the style of the box *around* the slider and bar. +*#pulseaudio-slider.muted*: ++ + Controls the style when the audio source/sink is muted. + *#pulseaudio-slider slider*: ++ Controls the style of the slider handle. @@ -85,4 +134,8 @@ The slider is a component with multiple CSS Nodes, of which the following are ex border-radius: 5px; background: green; } + +#pulseaudio-slider.muted highlight { + background-color: orange; +} ``` diff --git a/man/waybar-pulseaudio.5.scd b/man/waybar-pulseaudio.5.scd index a52d5b3e..f72f9c1d 100644 --- a/man/waybar-pulseaudio.5.scd +++ b/man/waybar-pulseaudio.5.scd @@ -108,6 +108,10 @@ Additionally, you can control the volume by scrolling *up* or *down* while the c default: true ++ Option to disable tooltip on hover. +*tooltip-format*: ++ + typeof: string ++ + The format of the tooltip shown on hover. When unset, the tooltip shows the current sink's description. + *max-volume*: ++ typeof: integer ++ default: 100 ++ @@ -115,7 +119,11 @@ Additionally, you can control the volume by scrolling *up* or *down* while the c *ignored-sinks*: ++ typeof: array ++ - Sinks in this list will not be shown as active sink by Waybar. Entries should be the sink's description field. + Sinks in this list will not be shown as active sink by Waybar. Entries should be the sink's description field. Use *pactl list sinks* to find the correct description. + +*sink-mapping*: ++ + typeof: object ++ + Sinks named by the values of this mapping will be considered to be the current sink instead of the sinks named by the respective keys. *menu*: ++ typeof: string ++ @@ -135,12 +143,21 @@ Additionally, you can control the volume by scrolling *up* or *down* while the c default: false ++ Enables this module to consume all left over space dynamically. +*target*: ++ + typeof: string ++ + default: sink ++ + The audio target to control when scrolling. Can be either `sink` (output/speakers) or `source` (input/microphone). + # FORMAT REPLACEMENTS *{desc}*: Pulseaudio port's description, for bluetooth it'll be the device name. *{volume}*: Volume in percentage. +*{source_volume}*: Source (input/microphone) volume in percentage. + +*{source_desc}*: Source's description, for bluetooth it'll be the device name. + *{icon}*: Icon, as defined in *format-icons*. *{format_source}*: Source format, *format-source*, *format-source-muted*. @@ -151,6 +168,8 @@ The following strings for *format-icons* are supported. - the device name +The device name looks something like *alsa\_output.pci-0000\_00\_1f.3.analog-stereo*. You can find it with a PulseAudio frontend such as *pacmd list-sinks* or *pamixer --list-sinks*. + If they are found in the current PulseAudio port name, the corresponding icons will be selected. - *default* (Shown, when no other port is found) @@ -196,3 +215,5 @@ to be selected when the corresponding audio device is muted. This applies to *de - *#pulseaudio* - *#pulseaudio.bluetooth* - *#pulseaudio.muted* +- *#pulseaudio.sink-muted* +- *#pulseaudio.source-muted* diff --git a/man/waybar-river-layout.5.scd b/man/waybar-river-layout.5.scd index a259b9ca..a81bf63c 100644 --- a/man/waybar-river-layout.5.scd +++ b/man/waybar-river-layout.5.scd @@ -19,6 +19,10 @@ Addressed by *river/layout* default: {} ++ The format, how information should be displayed. On {} data gets inserted. +*format-icons*: ++ + typeof: array ++ + Based on the current layout, the corresponding icon gets selected. + *rotate*: ++ typeof: integer ++ Positive value to rotate the text label (in 90 degree increments). @@ -69,16 +73,39 @@ Addressed by *river/layout* default: false ++ Enables this module to consume all left over space dynamically. -# EXAMPLE +# FORMAT REPLACEMENTS + +*{layout}*: Layout, as defined by your layout generator. By default this will be *rivertile(1)* + +*{icon}*: Icon, as defined in *format-icons* + +# EXAMPLES + +With layout name: ``` "river/layout": { - "format": "{}", + "format": "{layout}", "min-length": 4, "align": "right" } ``` +With icons, this sets icons for the 4 layouts available in rivertile: + +``` +"river/layout": { + "format": "{icon}", + "min-length": 3, + "format-icons": { + "left": "[]=", + "bottom": "TTT", + "top": "ꓕꓕꓕ", + "right": "=[]", + } +} +``` + # STYLE - *#layout* @@ -87,4 +114,4 @@ Addressed by *river/layout* # SEE ALSO -waybar(5), river(1) +waybar(5), river(1), rivertile(1) diff --git a/man/waybar-river-tags.5.scd b/man/waybar-river-tags.5.scd index 077f8b04..bb074c90 100644 --- a/man/waybar-river-tags.5.scd +++ b/man/waybar-river-tags.5.scd @@ -26,6 +26,14 @@ Addressed by *river/tags* default: false ++ If set to false, you can left-click to set focused tag. Right-click to toggle tag focus. If set to true this behaviour is disabled. +*set-tags*: ++ + typeof: array ++ + An array of tag bitmasks, one per tag button. Left-clicking a tag sets the focused tags to the corresponding bitmask instead of the single tag. Requires *disable-click* to be false. + +*toggle-tags*: ++ + typeof: array ++ + An array of tag bitmasks, one per tag button. Right-clicking a tag toggles the corresponding bitmask in the focused tags instead of the single tag. Requires *disable-click* to be false. + *expand*: ++ typeof: bool ++ default: false ++ @@ -44,6 +52,23 @@ Addressed by *river/tags* } ``` +Sticky tag: this enables 5 tags and, to support a sticky tag (tag 32), +*set-tags* is given bitmasks with the 32nd bit set so that tag stays selected +whichever button is clicked. + +``` +"river/tags": { + "num-tags": 5, + "set-tags": [ + 2147483649, + 2147483650, + 2147483652, + 2147483656, + 2147483664 + ] +} +``` + # STYLE - *#tags button* @@ -51,12 +76,16 @@ Addressed by *river/tags* - *#tags button.focused* - *#tags button.urgent* - *#tags button.current-view* +- *#tags button.output* +- *#tags button.tag-N* -Note that occupied/focused/urgent status may overlap. That is, a tag may be +Note that occupied/focused/urgent/output status may overlap. That is, a tag may be both occupied and focused at the same time. The current view is set on all tags on which the current view is visible. +The *output* style is applied when the river output (e.g. monitor) of the current bar is focused. + # SEE ALSO waybar(5), river(1) diff --git a/man/waybar-river-window.5.scd b/man/waybar-river-window.5.scd index e8155612..477f2777 100644 --- a/man/waybar-river-window.5.scd +++ b/man/waybar-river-window.5.scd @@ -53,6 +53,11 @@ Addressed by *river/window* typeof: string ++ Command to execute when you right-click on the module. +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to enable tooltip on hover. The tooltip shows the same text as the module label. + *menu*: ++ typeof: string ++ Action that popups the menu. diff --git a/man/waybar-states.5.scd b/man/waybar-states.5.scd index dea1beea..90ea500d 100644 --- a/man/waybar-states.5.scd +++ b/man/waybar-states.5.scd @@ -14,7 +14,7 @@ apply a class when the value matches the declared state value. Every entry (*state*) consists of a ** (typeof: *string*) and a ** (typeof: *integer*). - The state can be addressed as a CSS class in the *style.css*. The name of the CSS class is the ** of the state. - Each class gets activated when the current value is equal to or less than the configured ** for the *battery* module, or equal to or greater than the configured ** for all other modules. + Each class gets activated when the current value is equal to or less than the configured ** for the *battery*, *pulseaudio* and *wireplumber* modules, or equal to or greater than the configured ** for all other modules. - Also, each state can have its own *format*. Those can be configured via *format-*, or if you want to differentiate a bit more, as *format--*. diff --git a/man/waybar-styles.5.scd.in b/man/waybar-styles.5.scd.in index b11e15bd..f01d4cf0 100644 --- a/man/waybar-styles.5.scd.in +++ b/man/waybar-styles.5.scd.in @@ -8,20 +8,34 @@ waybar-styles - using stylesheets for waybar Waybar uses Cascading Style Sheets (CSS) to configure its appearance. -It uses the first file found in this search order: +When the system appearance is light, Waybar first looks for *style-light.css*. +When the system appearance is dark, Waybar first looks for *style-dark.css*. +If no appearance-specific stylesheet is found, it falls back to *style.css*. -- *$XDG_CONFIG_HOME/waybar/style.css* -- *~/.config/waybar/style.css* -- *~/waybar/style.css* -- */etc/xdg/waybar/style.css* -- *@sysconfdir@/xdg/waybar/style.css* +Waybar uses the first file found in this search order for each stylesheet name: + +- *$XDG_CONFIG_HOME/waybar/* +- *~/.config/waybar/* +- *~/waybar/* +- */etc/xdg/waybar/* +- *@sysconfdir@/xdg/waybar/* + +For example, a light theme is loaded from the first available file among: + +- *$XDG_CONFIG_HOME/waybar/style-light.css* +- *~/.config/waybar/style-light.css* +- *~/waybar/style-light.css* +- */etc/xdg/waybar/style-light.css* +- *@sysconfdir@/xdg/waybar/style-light.css* + +If no file is found there, Waybar repeats the same search using *style.css*. # EXAMPLE An example user-controlled stylesheet that just changes the color of the clock to be green on black, while keeping the rest of the system config the same would be: ``` -@import url("file:///etc/xdg/waybar/style.css") +@import url("file:///etc/xdg/waybar/style.css"); #clock { background: #000000; @@ -43,18 +57,22 @@ You can apply special styling to any module for when the cursor hovers it. Most, if not all, module types support setting the `cursor` option. This is configured in your `config.jsonc`. If set to `false`, when hovering the module a -"pointer"(as commonly known from web CSS styling `cursor: pointer`) style cursor +"pointer" (as commonly known from web CSS styling `cursor: pointer`) style cursor will not be shown. Default behavior is to indicate an interaction event is available. -There are more cursor types to choose from by setting the `cursor` option to -a number, see Gdk3 official docs for all possible cursor types: -https://docs.gtk.org/gdk3/enum.CursorType.html. -However, note that not all cursor options listed may be available on -your system. If you attempt to use a cursor which is not available, the -application will crash. +If set to a string value, it must be a valid cursor name +(e.g. `"pointer"`, `"default"`, `"grab"`, `"text"`, `"crosshair"`, etc.), +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. -Example of disabling pointer(`Gdk::Hand2`) cursor type on a custom module: +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: ``` "custom/my-custom-module": { @@ -63,13 +81,12 @@ Example of disabling pointer(`Gdk::Hand2`) cursor type on a custom module: } ``` -Example of setting cursor type to `Gdk::Boat`(according to -https://docs.gtk.org/gdk3/enum.CursorType.html#boat): +Example of setting the cursor type to `"grab"`: ``` "custom/my-custom-module": { ... - "cursor": 8, + "cursor": "grab", } ``` diff --git a/man/waybar-sway-language.5.scd b/man/waybar-sway-language.5.scd index 5710e69d..e008fa92 100644 --- a/man/waybar-sway-language.5.scd +++ b/man/waybar-sway-language.5.scd @@ -32,6 +32,18 @@ Addressed by *sway/language* default: true ++ Option to disable tooltip on hover. +*on-click*: ++ + typeof: string ++ + Command to execute when clicked on the module. + +*on-click-middle*: ++ + typeof: string ++ + Command to execute when you middle clicked on the module using mousewheel. + +*on-click-right*: ++ + typeof: string ++ + Command to execute when you right clicked on the module. + *menu*: ++ typeof: string ++ Action that popups the menu. @@ -67,6 +79,7 @@ Addressed by *sway/language* ``` "sway/language": { "format": "{}", + "on-click": "swaymsg input type:keyboard xkb_switch_layout next", }, "sway/language": { @@ -77,3 +90,4 @@ Addressed by *sway/language* # STYLE - *#language* +- *#language.* Where *layout-short-name* is the short name of the active layout (e.g. *#language.us* or *#language.de*). diff --git a/man/waybar-sway-window.5.scd b/man/waybar-sway-window.5.scd index 6fa9170b..c8bb160b 100644 --- a/man/waybar-sway-window.5.scd +++ b/man/waybar-sway-window.5.scd @@ -14,7 +14,7 @@ Addressed by *sway/window* *format*: ++ typeof: string ++ - default: {title} ++ + default: {} ++ The format, how information should be displayed. *rotate*: ++ @@ -101,13 +101,23 @@ Addressed by *sway/window* *icon*: ++ typeof: bool ++ default: false ++ - Option to hide the application icon. + Option to display the application icon. *icon-size*: ++ typeof: integer ++ default: 24 ++ Option to change the size of the application icon. +*icon-spacing*: ++ + typeof: integer ++ + default: 8 ++ + Spacing between the application icon and the label. + +*swap-icon-label*: ++ + typeof: bool ++ + default: false ++ + Option to swap the order of the application icon and the label. + *expand*: ++ typeof: bool ++ default: false ++ @@ -163,3 +173,11 @@ window widget: - *window#waybar.tabbed* When there is more than one window in the workspace and the workspace layout is tabbed - *window#waybar.tiled* When there is more than one window in the workspace and the workspace layout is splith or splitv - *window#waybar.* Where *app_id* is the app_id or *instance* name like (*chromium*) of the only window in the workspace + +These additional classes are applied to the whole Waybar (an element with the +name *window* and the id *waybar*) rather than to the module widget itself (an +element with the name *box* and the id *window*). In most cases you therefore +want to target *window#waybar. #window* to style the module. + +Switching layouts does not change the style unless the focus is changed. Sway +does not provide events for layout switching and the module does not poll. diff --git a/man/waybar-sway-workspaces.5.scd b/man/waybar-sway-workspaces.5.scd index 3d4e3ad0..7a1bd4d5 100644 --- a/man/waybar-sway-workspaces.5.scd +++ b/man/waybar-sway-workspaces.5.scd @@ -22,6 +22,11 @@ Addressed by *sway/workspaces* default: {value} ++ The format, how information should be displayed. +*format-for-negative-index*: ++ + typeof: string ++ + default: *format* ++ + An alternative format, which will be used for workspaces with no explict or negative index ("num" in sway terms). Requires *format* to be set. + *format-icons*: ++ typeof: array ++ Based on the workspace name and state, the corresponding icon gets selected. See *icons*. @@ -65,6 +70,11 @@ Addressed by *sway/workspaces* default: false ++ If set to true. Only focused workspaces will be shown. +*ignore-workspaces*: ++ + typeof: array ++ + default: empty ++ + List of regular expressions. Workspaces whose name matches any of the given patterns are not shown. + *persistent-workspaces*: ++ typeof: json (see below) ++ default: empty ++ @@ -78,10 +88,19 @@ Addressed by *sway/workspaces* typeof: bool ++ Whether to disable *workspace_auto_back_and_forth* when clicking on workspaces. If this is set to *true*, clicking on a workspace you are already on won't do anything, even if *workspace_auto_back_and_forth* is enabled in the Sway configuration. +*no-switch-output*: ++ + typeof: bool ++ + Whether to disable switching output when clicking on workspaces. If this is set to *true*, then clicking on a workspace on a different output will bring it to the current output instead of switching to that output. + *alphabetical_sort*: ++ typeof: bool ++ Whether to sort workspaces alphabetically. Please note this can make "swaymsg workspace prev/next" move to workspaces inconsistent with the ordering shown in Waybar. +*custom-sort*: ++ + typeof: array ++ + default: [] ++ + Defines an explicit ordering for workspace names. Entries earlier in the array appear first. Both full names and the trimmed portion after ":" are matched. Any workspace not listed here falls back to the default ordering (or alphabetical ordering when *alphabetical_sort* is true). + warp-on-scroll: ++ typeof: bool ++ default: true ++ @@ -110,6 +129,12 @@ warp-on-scroll: ++ default: false ++ Enables this module to consume all left over space dynamically. +*output-classes*: ++ + typeof: array ++ + Specify additional CSS classes to be added to workspace indicators based on the output on which the associated workspace is located. + Keys are output names and values are class names like *${output}: {output-class}*. + Assignment in config is used to keep the stylesheet independent of the available outputs. + # FORMAT REPLACEMENTS @@ -147,6 +172,7 @@ an empty list denoting all outputs. "3": [], // Always show a workspace with name '3', on all outputs if it does not exist "4": ["eDP-1"], // Always show a workspace with name '4', on output 'eDP-1' if it does not exist "5": ["eDP-1", "DP-2"] // Always show a workspace with name '5', on outputs 'eDP-1' and 'DP-2' if it does not exist + "6": ["MonitorMaker 3000 ABC0123"], // Always show a workspace with name '6' on outputs with an identifier 'MonitorMaker 3000 ABC0123' (usually a triple of vendor/model/serial) } } ``` @@ -187,6 +213,13 @@ n.b.: the list of outputs can be obtained from command line using *swaymsg -t ge } ``` +``` +"sway/workspaces": { + "format": "{index} - {name}", + "format-for-negative-index": "{name}" +} +``` + # Style - *#workspaces button* @@ -197,3 +230,4 @@ n.b.: the list of outputs can be obtained from command line using *swaymsg -t ge - *#workspaces button.empty* - *#workspaces button.current_output* - *#workspaces button#sway-workspace-${name}* +- *#workspaces button.${output-class}* diff --git a/man/waybar-systemd-failed-units.5.scd b/man/waybar-systemd-failed-units.5.scd index aaeee2d1..044dbc9a 100644 --- a/man/waybar-systemd-failed-units.5.scd +++ b/man/waybar-systemd-failed-units.5.scd @@ -84,7 +84,9 @@ Addressed by *systemd-failed-units* *{overall_state}:* Overall state of the systemd and user session. ("ok" or "degraded") *{failed_units_list}:* Bulleted list of failed units using *tooltip-unit-format*. Empty when -there are no failed units. +there are no failed units. This replacement is only available in *tooltip-format* and +*tooltip-format-ok*; it is not supplied to *format*/*format-ok* and using it there breaks +label rendering. The *tooltip-unit-format* string supports the following replacements: diff --git a/man/waybar-temperature.5.scd b/man/waybar-temperature.5.scd index 1e19ec82..cc8a3e71 100644 --- a/man/waybar-temperature.5.scd +++ b/man/waybar-temperature.5.scd @@ -27,9 +27,20 @@ Addressed by *temperature* The path of the hwmon-directory of the device, e.g. */sys/devices/pci0000:00/0000:00:18.3/hwmon*. (Note that the subdirectory *hwmon/hwmon#*, where *#* is a number is not part of the path!) Has to be used together with *input-filename*. This can also be an array of strings, for which, it just works like *hwmon-path*. +*hwmon-name*: ++ + typeof: string ++ + Select a hwmon device by its name (from /sys/class/hwmon/\*/name), e.g. *amdgpu*. + Requires *input-filename* to be set. + Cannot be used together with *hwmon-path* or *hwmon-path-abs*. + *input-filename*: ++ typeof: string ++ - The temperature filename of your *hwmon-path-abs*, e.g. *temp1_input* + The temperature filename of your *hwmon-path-abs* (also used by *hwmon-by-name*), e.g. *temp1_input* + +*hwmon-by-name*: ++ + typeof: string ++ + The substring to search for in */sys/class/hwmon/hwmonX/name* (where hwmonX is any folder in */sys/class/hwmon/*). + Waybar will search for every directory in */sys/class/hwmon/* and uses the directory in which the *name* matches *hwmon-by-name*. *warning-threshold*: ++ typeof: integer ++ @@ -145,6 +156,8 @@ Addressed by *temperature* *{temperatureK}*: Temperature in Kelvin. +*{icon}*: Icon, as selected from *format-icons* based on the current temperature. + # EXAMPLES ``` @@ -162,3 +175,24 @@ Addressed by *temperature* - *#temperature* - *#temperature.warning* - *#temperature.critical* + +# TROUBLESHOOTING + +## Finding your thermal zone + +To list all the thermal zone types, run: + +``` +for i in /sys/class/thermal/thermal_zone*; do echo "$i: $(<$i/type)"; done +``` + +## Finding an hwmon path + +If you do not have a thermal zone, use *sensors* to find the preferred +temperature source, then run: + +``` +for i in /sys/class/hwmon/hwmon*/temp*_input; do echo "$(<$(dirname $i)/name): $(cat ${i%_*}_label 2>/dev/null || echo $(basename ${i%_*})) $(readlink -f $i)"; done +``` + +to find the path to the desired file, then set it in *hwmon-path*. diff --git a/man/waybar-tray.5.scd b/man/waybar-tray.5.scd index dec5347f..30aeed99 100644 --- a/man/waybar-tray.5.scd +++ b/man/waybar-tray.5.scd @@ -42,16 +42,40 @@ Addressed by *tray* default: false ++ Enables this module to consume all left over space dynamically. +*icons*: ++ + typeof: object ++ + Per-application icon customization. Keys are application IDs (e.g., "spotify", "blueman"). ++ + Values can be: ++ + - *string*: Custom icon name or path to icon file ++ + - *false*: Hide this application's tray icon completely + +*orders*: ++ + typeof: object ++ + Orders for items to be placed in tray. When not set, orders are controlled by DBus. ++ + Key: name of item. ++ + Value: integer, higher value means to place this item to the right. + +Note: custom *icons* only work with actual image files, not font-based icons. They +might not work for some Electron apps (see https://github.com/electron/electron/issues/40936). + +*ignore-list*: ++ + typeof: array ++ + List of strings used to hide matching tray items. An item is hidden when any string in the list is found (as a substring) in the item's bus name, category, icon name, id or title. + # EXAMPLES ``` "tray": { "icon-size": 21, "spacing": 10, - "icons": { - "blueman": "bluetooth", - "TelegramDesktop": "$HOME/.local/share/icons/hicolor/16x16/apps/telegram.png" - } + "icons": { + "blueman": "bluetooth", + "TelegramDesktop": "$HOME/.local/share/icons/hicolor/16x16/apps/telegram.png", + "spotify": false + }, + "orders": { + "wechat": 99 + } } ``` @@ -59,6 +83,20 @@ Addressed by *tray* # STYLE - *#tray* -- *#tray > .passive* -- *#tray > .active* -- *#tray > .needs-attention* +- *#tray menu* for the context menu +- *#tray widget>image* for icons +- *#tray > .passive* for icons with status *Passive* +- *#tray > .active* for icons with status *Active* +- *#tray > .needs-attention* for icons with status *NeedsAttention* + +# TRAY APPLETS + +The following applets are known to work with the *tray* module: + +- *NetworkManager*: install networkmanager, enable it with + *systemctl enable NetworkManager.service*, and start the applet with + *nm-applet --indicator*. +- *Blueman*: enable the Bluetooth daemon, install blueman, and start + *blueman-applet*. +- *Electron apps*: apps like Slack, Telegram, Caprine and Discord should work + out of the box. diff --git a/man/waybar-upower.5.scd b/man/waybar-upower.5.scd index 303ee65e..64074891 100644 --- a/man/waybar-upower.5.scd +++ b/man/waybar-upower.5.scd @@ -26,7 +26,7 @@ compatible devices in the tooltip. *icon-size*: ++ typeof: integer ++ default: 20 ++ - Defines the size of the icons. + Defines the size of the icons. Set to *0* for automatic sizing. *format*: ++ typeof: string ++ @@ -59,6 +59,12 @@ compatible devices in the tooltip. default: 4 ++ Defines the spacing between the tooltip window edge and the tooltip content. +*tooltip-format*: ++ + typeof: string ++ + default: ++ + The format used for each device line shown in the tooltip. Accepts the ++ + same replacements as *format*. + *on-click*: ++ typeof: string ++ Command to execute when clicked on the module. @@ -88,6 +94,12 @@ compatible devices in the tooltip. *{time}*: An estimated time either until empty or until fully charged ++ depending on the charging state. +*{temperature}*: The battery temperature in degrees Celsius. + +*{model}*: The model name of the device. + +*{native-path}*: The native path of the device. + # EXAMPLES ``` diff --git a/man/waybar-user.5.scd b/man/waybar-user.5.scd new file mode 100644 index 00000000..b1603336 --- /dev/null +++ b/man/waybar-user.5.scd @@ -0,0 +1,94 @@ +waybar-user(5) "waybar-user" "User Manual" + +# NAME + +waybar - user module + +# DESCRIPTION + +The *user* module displays the currently logged in user, the system uptime and, +optionally, the user's avatar image. + +# CONFIGURATION + +Addressed by *user* + +[- *Option* +:- *Typeof* +:- *Default* +:- *Description* +|[ *interval* +:[ integer +:[ 60 +:[ The interval in which the information gets polled +|[ *format* +:[ string +:[ *{user} {work_H}:{work_M}* +:[ The format, how the information should be displayed. See *FORMAT REPLACEMENTS* below +|[ *icon* +:[ bool +:[ false +:[ When enabled, displays the user's avatar image next to the label +|[ *avatar* +:[ string +:[ *$HOME/.face* +:[ Path to the avatar image to display. Only used when *icon* is enabled. When unset, the default path *$HOME/.face* is used +|[ *height* +:[ integer +:[ 20 +:[ The height of the avatar image in pixels +|[ *width* +:[ integer +:[ 20 +:[ The width of the avatar image in pixels +|[ *open-on-click* +:[ bool +:[ false +:[ When enabled, left-clicking the module opens the user's home directory (or *open-path*) with the default file manager +|[ *open-path* +:[ string +:[ +:[ Overrides the path that is opened when *open-on-click* is enabled. When empty, the user's home directory is used +|[ *rotate* +:[ integer +:[ +:[ Positive value to rotate the text label (in 90 degree increments) +|[ *tooltip* +:[ bool +:[ true +:[ Option to enable tooltip on hover + +# FORMAT REPLACEMENTS + +- *{user}*: The login name of the currently logged in user, in uppercase +- *{work_d}*: Whole days the system has been up +- *{work_H}*: Hours part of the system uptime (00-23) +- *{work_M}*: Minutes part of the system uptime (00-59) +- *{work_S}*: Seconds part of the system uptime (00-59) +- *{up_H}*: Hour the system was booted (00-23) +- *{up_M}*: Minute the system was booted (00-59) +- *{up_d}*: Day of the month the system was booted (01-31) +- *{up_m}*: Month the system was booted (01-12) +- *{up_Y}*: Year the system was booted + +# EXAMPLES + +``` +"user": { + "format": "{user} up {work_H}:{work_M}:{work_S}", + "interval": 60, + "icon": true, + "avatar": "/home/alice/.face", + "height": 24, + "width": 24, + "open-on-click": true +} +``` + +# STYLE + +- *#user* + +# AUTHOR + +Alexis Rouillard diff --git a/man/waybar-wayfire-workspaces.5.scd b/man/waybar-wayfire-workspaces.5.scd index 53a179e8..89d67f9c 100644 --- a/man/waybar-wayfire-workspaces.5.scd +++ b/man/waybar-wayfire-workspaces.5.scd @@ -14,11 +14,11 @@ Addressed by *wayfire/workspaces* *format*: ++ typeof: string ++ - default: {value} ++ + default: {index} ++ The format, how information should be displayed. *format-icons*: ++ - typeof: array ++ + typeof: object ++ Based on the workspace name, index and state, the corresponding icon gets selected. See *icons*. *disable-click*: ++ @@ -26,6 +26,16 @@ Addressed by *wayfire/workspaces* default: false ++ If set to false, you can click to change workspace. If set to true this behaviour is disabled. +*disable-scroll*: ++ + typeof: bool ++ + default: false ++ + If set to false, you can scroll to cycle through workspaces. If set to true this behaviour is disabled. + +*enable-bar-scroll*: ++ + typeof: bool ++ + default: false ++ + If set to false, you can't scroll to cycle through workspaces from the entire bar. If set to true this behaviour is enabled. + *disable-markup*: ++ typeof: bool ++ default: false ++ diff --git a/man/waybar-wireplumber.5.scd b/man/waybar-wireplumber.5.scd index 8d97d7af..b360cae6 100644 --- a/man/waybar-wireplumber.5.scd +++ b/man/waybar-wireplumber.5.scd @@ -19,11 +19,37 @@ The *wireplumber* module displays the current volume reported by WirePlumber. typeof: string ++ This format is used when the sound is muted. +*format-bluetooth*: ++ + typeof: string ++ + This format is used when the active sink is a Bluetooth device (its node name contains *bluez* or *a2dp-sink*). + +*format-bluetooth-muted*: ++ + typeof: string ++ + This format is used when the active sink is a Bluetooth device and the sound is muted. + +*format-source*: ++ + typeof: string ++ + default: *{volume}%* ++ + The format used to render the source (microphone) volume sub-string that is exposed through the *{format_source}* replacement. + +*format-source-muted*: ++ + typeof: string ++ + The format used for the *{format_source}* sub-string when the source (microphone) is muted. + +*format-icons*: ++ + typeof: array ++ + Based on the current volume, the corresponding icon gets selected. The order is *low* to *high*. + *node-type*: ++ typeof: string ++ default: *Audio/Sink* ++ The WirePlumber node type to attach to. Use *Audio/Source* to manage microphones etc. +*only-physical*: ++ + typeof: bool ++ + default: *false* ++ + When enabled, resolve through virtual/filter-chain sinks to the underlying physical sink and report its volume instead. + *tooltip*: ++ typeof: bool ++ default: *true* ++ @@ -63,6 +89,19 @@ The *wireplumber* module displays the current volume reported by WirePlumber. default: 1.0 ++ The speed at which to change the volume when scrolling. +*scroll-scale*: ++ + typeof: string ++ + default: cubic_percent + The scale to use for the scrolling volume change. Options are 'linear', 'db', 'cubic', and 'cubic_percent'. + +*reverse-scrolling*: ++ + typeof: bool ++ + Option to reverse the scroll direction for touchpads. + +*reverse-mouse-scrolling*: ++ + typeof: bool ++ + Option to reverse the scroll direction for mice. + *on-click*: ++ typeof: string ++ Command to execute when clicked on the module. @@ -107,10 +146,30 @@ The *wireplumber* module displays the current volume reported by WirePlumber. # FORMAT REPLACEMENTS -*{volume}*: Volume in percentage. +*{volume}*: Volume in percentage, cubic scale as integer. + +*{volume_linear}*: Volume in linear scale as float. + +*{volume_cubic}*: Volume in cubic scale as float. + +*{volume_db}*: Volume in decibel scale as float. + +*{icon}*: Icon, as defined in *format-icons*. *{node_name}*: The node's nickname as reported by WirePlumber (*node.nick* property) +*{source_volume}*: Source (microphone) volume in percentage, cubic scale as integer. + +*{source_volume_linear}*: Source (microphone) volume in linear scale as float. + +*{source_volume_cubic}*: Source (microphone) volume in cubic scale as float. + +*{source_volume_db}*: Source (microphone) volume in decibel scale as float. + +*{source_desc}*: The source node's nickname/description as reported by WirePlumber. + +*{format_source}*: The pre-rendered source (microphone) volume sub-string, formatted with *format-source*/*format-source-muted*. + # EXAMPLES ## Basic: @@ -123,7 +182,7 @@ The *wireplumber* module displays the current volume reported by WirePlumber. } ``` -## Separate Sink and Source Widgets +## Separate Sink and Source Widgets ``` "wireplumber#sink": { @@ -143,7 +202,20 @@ The *wireplumber* module displays the current volume reported by WirePlumber. } ``` +## Use a Different Scale + +``` +"wireplumber": { + "format": "{volume_db:.2f}dB", + "scroll-scale": "db", + "scroll-step": 2.5 +} +``` + # STYLE - *#wireplumber* - *#wireplumber.muted* +- *#wireplumber.sink-muted* +- *#wireplumber.source-muted* +- *#wireplumber.bluetooth* diff --git a/man/waybar-wlr-taskbar.5.scd b/man/waybar-wlr-taskbar.5.scd index af1ba97f..5e20a541 100644 --- a/man/waybar-wlr-taskbar.5.scd +++ b/man/waybar-wlr-taskbar.5.scd @@ -18,6 +18,13 @@ Addressed by *wlr/taskbar* default: false ++ If set to false applications on the waybar's current output will be shown. Otherwise, all applications are shown. +*bar-css-states*: ++ + typeof: bool ++ + default: false ++ + If set to true, application state is exposed as CSS classes on the Waybar + window. Maximized and fullscreen state is aggregated across applications + known to belong to the active workspace. See *Bar state style* below. + *format*: ++ typeof: string ++ default: {icon} ++ @@ -52,11 +59,36 @@ Addressed by *wlr/taskbar* default: false ++ If set to true, always reorder the tasks in the taskbar so that the currently active one is first. Otherwise don't reorder. +*active-only*: ++ + typeof: bool ++ + default: false ++ + If set to true, only the currently active application button is shown. + Other applications remain tracked and reappear when activated. + *sort-by-app-id*: ++ typeof: bool ++ default: false ++ If set to true, group tasks by their app_id. Cannot be used with 'active-first'. +*homogeneous*: ++ + typeof: bool ++ + default: false ++ + If set to true, distribute every task button evenly across the taskbar's allocated width. Buttons will automatically resize so that 'N' visible tasks each take '1/N' of the width. + +*justify*: ++ + typeof: string ++ + The alignment of the text within the module's box, allowing options 'left', 'right', or 'center' to define the positioning. + +*expand*: ++ + typeof: bool ++ + default: false ++ + If set to true, task buttons stretch to fill the available space in the taskbar and long titles are ellipsized to fit. Only takes effect on a horizontal bar; on a vertical bar the buttons keep their content-based size. If set to false, buttons are sized to their content. + +*truncate*: ++ + typeof: bool ++ + default: false ++ + If set to true, the task button text will be ellipsized (truncated with …) when the available button width is smaller than the label text. + *on-click*: ++ typeof: string ++ The action which should be triggered when clicking on the application button with the left mouse button. @@ -77,6 +109,10 @@ Addressed by *wlr/taskbar* typeof: array ++ List of app_id/titles to be invisible. +*squash-list*: ++ + typeof: array ++ + List of app_id/titles whose multiple instances are collapsed into a single button. When more than one instance of a listed app is open, only one button is shown; when one instance closes, the next hidden instance reappears. The special value '\*' matches all applications. + *app_ids-mapping*: ++ typeof: object ++ Dictionary of app_id to be replaced with @@ -99,6 +135,11 @@ Addressed by *wlr/taskbar* *{short_state}*: The state (minimize == m, maximized == M, active == A, fullscreen == F) represented as one character of the application. +As with all format replacements in Waybar, a length limit such as *{title:.15}* is +measured in bytes, not characters. This may produce invalid text if a multi-byte +character is split, or (when *markup* is true) if the truncation cuts through a +value that must be escaped in XML. + # CLICK ACTIONS *activate*: Bring the application into foreground. @@ -121,6 +162,8 @@ captures of the expression. Regular expression and replacement follow ECMA-script rules. +An expression must match the format output *fully* to trigger its replacement. + If no expression matches, the format output is left unchanged. Invalid expressions (e.g., mismatched parentheses) are skipped. @@ -151,8 +194,41 @@ Invalid expressions (e.g., mismatched parentheses) are skipped. # Style - *#taskbar* +- *#taskbar.empty* - *#taskbar button* - *#taskbar button.maximized* - *#taskbar button.minimized* - *#taskbar button.active* - *#taskbar button.fullscreen* + +# Bar state style + +When *bar-css-states* is enabled, the following classes are added to +*window#waybar*: + +- *window#waybar.toplevel-active* +- *window#waybar.toplevel-maximized* +- *window#waybar.toplevel-minimized* +- *window#waybar.toplevel-fullscreen* + +The active, minimized classes describe the active application. The maximized +and fullscreen classes are set if any non-minimized application known to belong +to the active workspace has that state. + +Workspace membership is learned when an application is activated and requires +the compositor to support *ext-workspace-v1*. Before an application has been +activated during the current Waybar session, its workspace may be unknown. On +compositors without *ext-workspace-v1*, these classes fall back to the active +application's state. + +For example: + +``` +window#waybar { + background-color: rgba(0, 0, 0, 0.5); +} + +window#waybar.toplevel-maximized { + background-color: rgba(0, 0, 0, 1); +} +``` diff --git a/man/waybar-wwan.5.scd b/man/waybar-wwan.5.scd new file mode 100644 index 00000000..5d931cc8 --- /dev/null +++ b/man/waybar-wwan.5.scd @@ -0,0 +1,162 @@ +waybar-wwan(5) "waybar-wwan" "User Manual" + +# NAME + +waybar - wwan module + +# DESCRIPTION + +*wwan* module for ModemManager. + + +# FILES + +$XDG_CONFIG_HOME/waybar/config ++ + Per user configuration file + +# ADDITIONAL FILES + +libmm-glib lives in: + +. /usr/lib/libmm-glib.so or /usr/lib64/libmm-glib.so +. /usr/lib/pkgconfig/libmm-glib.pc or /usr/lib64/pkgconfig/libmm-glib.pc +. /usr/include/libmm-glib + +# CONFIGURATION + +*format*: ++ + typeof: string ++ + default: *{state}* ++ + The format, how information should be displayed. This format is used when other formats aren't specified. + +*format-failed*: ++ + typeof: string ++ + This format is used when the modem cannot start. + +*format-locked*: ++ + typeof: string ++ + This format is used when the SIM on the modem is locked. + +*format-disabled*: ++ + typeof: string ++ + This format is used when the modem is disabled. + +*format-enabled*: ++ + typeof: string ++ + This format is used when the modem is enabled, but isn't registered to any carrier network. + +*format-searching*: ++ + typeof: string ++ + This format is used when the modem is looking for a carrier network to register to. + +*format-registered*: ++ + typeof: string ++ + This format is used when the modem is registered to a network, but no data connection is active. + +*format-connected*: ++ + typeof: string ++ + This format is used when the modem is registered to a network, and there is an active data connection. + +*format-icons*: ++ + typeof: array/object ++ + Based on the current signal strength, the corresponding icon gets selected. ++ + The order is *low* to *high*. Or by the state if it is an object. + +*tooltip*: ++ + typeof: bool ++ + default: true ++ + Option to disable tooltip on hover. + +*tooltip-format*: ++ + typeof: strirng ++ + default: {state} ++ + The format, how information should be displayed in the tooltip. This format is used when other formats aren't specified. + +*tooltip-format-failed*: ++ + typeof: string ++ + This format is used when the modem cannot start. + +*tooltip-format-locked*: ++ + typeof: string ++ + This format is used when the SIM on the modem is locked. + +*tooltip-format-disabled*: ++ + typeof: string ++ + This format is used when the modem is disabled. + +*tooltip-format-enabled*: ++ + typeof: string ++ + This format is used when the modem is enabled, but isn't registered to any carrier network. + +*tooltip-format-searching*: ++ + typeof: string ++ + This format is used when the modem is looking for a carrier network to register to. + +*tooltip-format-registered*: ++ + typeof: string ++ + This format is used when the modem is registered to a network, but no data connection is active. + +*tooltip-format-connected*: ++ + typeof: string ++ + This format is used when the modem is registered to a network, and there is an active data connection. + +*interval*: ++ + typeof: integer ++ + default: 5 ++ + The interval in which the WWAN information gets polled (e.g. carrier name). + +*imei*: ++ + typeof: string ++ + Select only the modem with the corresponding imei. + +*path*: ++ + typeof: string ++ + Select only the modem with the corresponding hardware path. + +*hide-disconnected*: ++ + typeof: bool ++ + default: true ++ + Defines if the module should be hidden while the modem does not have an active data connection. + + +# FORMAT REPLACEMENTS + +*{state}*: Modem state. + +*{current_modes}*: List of all the currently active connection modes. + +*{preferred_mode}*: The preferred connection mode in use. + +*{signal_quality}*: Signal quality (0-100). + +*{operator_name}*: Name of the network operator. + +*{icon}*: Icon selected from *format-icons* according to the signal quality. + +*{power_state}*: Modem power state (On/Off/Low/Unknown). + +*{imei}*: Modem equipment identifier (IMEI). + +# EXAMPLES + +``` +"wwan": { + "format": "{state}", + "format-disabled": "", // an empty format will hide the module + "format-locked": "SIM Locked", + "format-connected": "{operator_name} {preferred_mode} | {signal_quality}%" + "tooltip-format": "{preferred_mode}", + "tooltip-format-connected": "{current_modes}", + "hide-disconnected": false +} +``` +# STYLE + +- *#wwan* +- *#wwan.failed* Applied when the modem cannot start. +- *#wwan.locked* Applied when the SIM on the modem is locked. +- *#wwan.disabled* Applied when the modem is disabled. +- *#wwan.enabled* Applied when the modem is enabled, but isn't registered to any carrier network. +- *#wwan.searching* Applied when the modem is looking for a carrier network to register to. +- *#wwan.registered* Applied when the modem is registered to a network, but no data connection is active. +- *#wwan.connected* Applied when the modem is registered to a network, and there is an active data connection. diff --git a/man/waybar.5.scd.in b/man/waybar.5.scd.in index 7d38d305..47265f7d 100644 --- a/man/waybar.5.scd.in +++ b/man/waybar.5.scd.in @@ -19,7 +19,7 @@ Valid locations for this file are: A good starting point is the default configuration found at https://github.com/Alexays/Waybar/blob/master/resources/config.jsonc Also, a minimal example configuration can be found at the bottom of this man page. -The visual display elements for waybar use a CSS stylesheet, see *waybar-styles(5)* for details. +The visual display elements for waybar use a CSS stylesheet. Waybar supports *style.css* and the appearance-specific *style-light.css* and *style-dark.css* files; see *waybar-styles(5)* for details. # BAR CONFIGURATION @@ -50,6 +50,10 @@ The visual display elements for waybar use a CSS stylesheet, see *waybar-styles( Output specification follows sway's and can either be the output port such as "HDMI-A-1" or a string consisting of the make, model, and serial such as "Some Company ABC123 0x00000000". See *sway-output(5)* for details. In an array, star '*\**' can be used at the end to accept all outputs, in case all previous entries are exclusions. +*output-dimensions* ++ + typeof: string|array ++ + If *output* is not specified, allows selecting outputs by dimensions. Format "(width/height) () (value)", e.g. "width > 1080". Multiple conditions can be chained. + *position* ++ typeof: string ++ default: top ++ @@ -168,6 +172,7 @@ The visual display elements for waybar use a CSS stylesheet, see *waybar-styles( # MODULE FORMAT You can use PangoMarkupFormat (See https://developer.gnome.org/pango/stable/PangoMarkupFormat.html#PangoMarkupFormat). +Tooltip appearance is generally controlled globally via CSS and cannot be truly scoped per module. Some modules (such as the clock) provide limited workarounds for customization. e.g. @@ -355,9 +360,19 @@ A group may hide all but one element, showing them only on mouse hover. In order *children-class*: ++ typeof: string ++ - default: "hidden" ++ + default: "drawer-child" ++ Defines the CSS class to be applied to the hidden elements. +*always-visible-class*: ++ + typeof: string ++ + default: "" ++ + Defines the CSS class that marks a child module as always visible. The module carrying this class remains shown while the drawer is collapsed and drives the drawer's visibility management. When empty, the leader (first) module is used. + +*reveal-delay*: ++ + typeof: integer ++ + default: 0 ++ + Delay in milliseconds to wait after the mouse enters the group before revealing the drawer. When 0, the drawer is revealed immediately. + *click-to-reveal*: ++ typeof: bool ++ default: false ++ @@ -378,6 +393,18 @@ A group may hide all but one element, showing them only on mouse hover. In order typeof: integer ++ If set, when waybar recives SIGRTMIN+N (where N is this value) it will toggle the visibility of the drawer. +*empty-if-drawer-empty*: ++ + typeof: bool ++ + default: false ++ + Defines whether the group should be assigned the "empty" CSS class when all modules inside the drawer are hidden or missing. If false, the group will not receive the "empty" class as long as the leader module remains visible. This allows users to dynamically style or hide the entire group using CSS when its drawer contents are unavailable. + +*reveal-by-default*: ++ + typeof: bool ++ + default: false ++ + Whether the child should be revealed when Waybar starts up. This has to be used with click-to-reveal to take effect. + +Group drawers are also given the `.expanded` CSS class when they are expanded. + ``` "group/power": { "orientation": "inherit", @@ -394,6 +421,37 @@ A group may hide all but one element, showing them only on mouse hover. In order ] }, ``` +To hide the power group if it is empty, apply to #power and all its children: +``` +#power.empty, +#power.empty * { + min-width: 0px; + min-height: 0px; + padding: 0px; + margin: 0px; + border: none; + font-size: 0px; + opacity: 0; +} +``` + +## Select outputs by dimensions + +``` +{ + "layer": "top", + "output-dimensions": "width > 1080", + ... +} +``` + +``` +{ + "layer": "top", + "output-dimensions": ["width < 3840", "height < 2160"], + ... +} +``` # SUPPORTED MODULES @@ -420,6 +478,7 @@ A group may hide all but one element, showing them only on mouse hover. In order - *waybar-inhibitor(5)* - *waybar-jack(5)* - *waybar-keyboard-state(5)* +- *waybar-load(5)* - *waybar-memory(5)* - *waybar-mpd(5)* - *waybar-mpris(5)* diff --git a/meson.build b/meson.build index 0c494eb2..5dc7338d 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']) @@ -94,6 +95,7 @@ xkbregistry = dependency('xkbregistry') libjack = dependency('jack', required: get_option('jack')) libwireplumber = dependency('wireplumber-0.5', required: get_option('wireplumber')) libgps = dependency('libgps', required: get_option('gps')) +libmm_glib = dependency('mm-glib', required: get_option('wwan')) libsndio = compiler.find_library('sndio', required: get_option('sndio')) if libsndio.found() @@ -111,6 +113,10 @@ gtk_layer_shell = dependency('gtk-layer-shell-0', version: ['>=0.9.0'], default_options: ['introspection=false', 'vapi=false'], fallback: ['gtk-layer-shell', 'gtk_layer_shell']) systemd = dependency('systemd', required: get_option('systemd')) +libsystemd = dependency('libsystemd', required: get_option('systemd')) +if libsystemd.found() + add_project_arguments('-DHAVE_LIBSYSTEMD', language: 'cpp') +endif cpp_lib_chrono = compiler.compute_int('__cpp_lib_chrono', prefix : '#include ') have_chrono_timezones = cpp_lib_chrono >= 201611 @@ -159,11 +165,13 @@ endif src_files = files( 'src/factory.cpp', + 'src/AGraph.cpp', 'src/AModule.cpp', 'src/ALabel.cpp', 'src/AIconLabel.cpp', 'src/AAppIconLabel.cpp', 'src/modules/custom.cpp', + 'src/modules/custom_graph.cpp', 'src/modules/disk.cpp', 'src/modules/idle_inhibitor.cpp', 'src/modules/image.cpp', @@ -177,16 +185,18 @@ src_files = files( 'src/config.cpp', 'src/group.cpp', 'src/util/portal.cpp', - 'src/util/enum.cpp', 'src/util/prepare_for_sleep.cpp', 'src/util/ustring_clen.cpp', 'src/util/sanitize_str.cpp', 'src/util/rewrite_string.cpp', + 'src/util/hosts_check.cpp', 'src/util/gtk_icon.cpp', 'src/util/icon_loader.cpp', 'src/util/regex_collection.cpp', 'src/util/css_reload_helper.cpp', - 'src/util/transform_8bit_to_rgba.cpp' + 'src/util/transform_8bit_to_rgba.cpp', + 'src/util/utf8_string.cpp', + 'src/util/command_line_stream.cpp' ) man_files = files( @@ -197,6 +207,7 @@ man_files = files( 'man/waybar-states.5.scd', 'man/waybar-menu.5.scd', 'man/waybar-temperature.5.scd', + 'man/waybar-user.5.scd', ) inc_dirs = ['include'] @@ -210,6 +221,7 @@ if is_linux 'src/modules/bluetooth.cpp', 'src/modules/cffi.cpp', 'src/modules/cpu.cpp', + 'src/modules/cpu_graph.cpp', 'src/modules/cpu_frequency/common.cpp', 'src/modules/cpu_frequency/linux.cpp', 'src/modules/cpu_usage/common.cpp', @@ -234,6 +246,7 @@ elif is_dragonfly or is_freebsd or is_netbsd or is_openbsd src_files += files( 'src/modules/cffi.cpp', 'src/modules/cpu.cpp', + 'src/modules/cpu_graph.cpp', 'src/modules/cpu_frequency/bsd.cpp', 'src/modules/cpu_frequency/common.cpp', 'src/modules/cpu_usage/bsd.cpp', @@ -340,6 +353,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', @@ -348,6 +362,25 @@ if get_option('niri') ) endif +if get_option('mango') + add_project_arguments('-DHAVE_MANGO', language: 'cpp') + src_files += files( + 'src/modules/mango/backend.cpp', + 'src/modules/mango/language.cpp', + 'src/modules/mango/keymode.cpp', + 'src/modules/mango/window.cpp', + 'src/modules/mango/workspaces.cpp', + 'src/modules/mango/layout.cpp' + ) + man_files += files( + 'man/waybar-mango-language.5.scd', + 'man/waybar-mango-keymode.5.scd', + 'man/waybar-mango-window.5.scd', + 'man/waybar-mango-workspaces.5.scd', + 'man/waybar-mango-layout.5.scd' + ) +endif + if true add_project_arguments('-DHAVE_WAYFIRE', language: 'cpp') src_files += files( @@ -355,6 +388,10 @@ if true 'src/modules/wayfire/window.cpp', 'src/modules/wayfire/workspaces.cpp', ) + man_files += files( + 'man/waybar-wayfire-window.5.scd', + 'man/waybar-wayfire-workspaces.5.scd', + ) endif if get_option('login-proxy') @@ -393,6 +430,7 @@ if pipewire.found() 'src/modules/privacy/privacy_item.cpp', 'src/util/pipewire/pipewire_backend.cpp', 'src/util/pipewire/privacy_node_info.cpp', + 'src/util/geoclue_backend.cpp', ) man_files += files('man/waybar-privacy.5.scd') endif @@ -499,7 +537,7 @@ else endif cava = dependency('libcava', - version : '>=0.10.7', + version : '>=1.0.0', required: get_option('cava'), fallback : ['libcava', 'cava_dep'], not_found_message: 'cava is not found. Building waybar without cava') @@ -524,6 +562,12 @@ if libgps.found() man_files += files('man/waybar-gps.5.scd') endif +if libmm_glib.found() + add_project_arguments('-DHAVE_LIBMM_GLIB', language: 'cpp') + src_files += files('src/modules/wwan.cpp') + man_files += files('man/waybar-wwan.5.scd') +endif + subdir('protocol') app_resources = [] @@ -542,6 +586,7 @@ executable( jsoncpp, wayland_cursor, gtkmm, + giomm, dbusmenu_gtk, giounix, libinput, @@ -550,6 +595,7 @@ executable( upower_glib, pipewire, playerctl, + libsystemd, libpulse, libjack, libwireplumber, @@ -564,7 +610,8 @@ executable( xkbregistry, cava, eproxy, - libgps + libgps, + libmm_glib ], include_directories: inc_dirs, install: true, diff --git a/meson_options.txt b/meson_options.txt index c0f63f28..139ed6cb 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -12,7 +12,7 @@ option('dbusmenu-gtk', type: 'feature', value: 'auto', description: 'Enable supp option('man-pages', type: 'feature', value: 'auto', description: 'Generate and install man pages') option('mpd', type: 'feature', value: 'auto', description: 'Enable support for the Music Player Daemon') option('rfkill', type: 'feature', value: 'auto', description: 'Enable support for RFKILL') -option('sndio', type: 'feature', value: 'auto', description: 'Enable support for sndio') +option('sndio', type: 'feature', value: 'auto', description: 'Enable support for volume control via sndio') option('logind', type: 'feature', value: 'auto', description: 'Enable support for logind') option('tests', type: 'feature', value: 'auto', description: 'Enable tests') option('experimental', type : 'boolean', value : false, description: 'Enable experimental features') @@ -20,5 +20,7 @@ option('jack', type: 'feature', value: 'auto', description: 'Enable support for option('wireplumber', type: 'feature', value: 'auto', description: 'Enable support for WirePlumber') option('cava', type: 'feature', value: 'auto', description: 'Enable support for Cava') option('niri', type: 'boolean', description: 'Enable support for niri') +option('mango', type: 'boolean', description: 'Enable support for mango') option('login-proxy', type: 'boolean', description: 'Enable interfacing with dbus login interface') option('gps', type: 'feature', value: 'auto', description: 'Enable support for gps') +option('wwan', type: 'feature', value: 'auto', description: 'Enable support for WWAN') diff --git a/nix/default.nix b/nix/default.nix index 1850a414..46514d4f 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -6,13 +6,13 @@ }: let libcava = rec { - version = "0.10.7"; + version = "1.0.0"; src = pkgs.fetchFromGitHub { owner = "LukashonakV"; repo = "cava"; # NOTE: Needs to match the cava.wrap tag = "${version}"; - hash = "sha256-zkyj1vBzHtoypX4Bxdh1Vmwh967DKKxN751v79hzmgQ="; + hash = "sha256-0r5aAmTs+FcmS501tNYKxG9H+Pq6i32BDRBEjWW6M74="; }; }; in @@ -36,6 +36,7 @@ waybar.overrideAttrs (oldAttrs: { ++ [ pkgs.wireplumber pkgs.gpsd + pkgs.modemmanager ]; postUnpack = '' diff --git a/protocol/ext-idle-notify-v1.xml b/protocol/ext-idle-notify-v1.xml new file mode 100644 index 00000000..db7d9c16 --- /dev/null +++ b/protocol/ext-idle-notify-v1.xml @@ -0,0 +1,131 @@ + + + + Copyright © 2015 Martin Gräßlin + Copyright © 2022 Simon Ser + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + + This interface allows clients to monitor user idle status. + + After binding to this global, clients can create ext_idle_notification_v1 + objects to get notified when the user is idle for a given amount of time. + + + + + Destroy the manager object. All objects created via this interface + remain valid. + + + + + + Create a new idle notification object. + + The notification object has a minimum timeout duration and is tied to a + seat. The client will be notified if the seat is inactive for at least + the provided timeout. See ext_idle_notification_v1 for more details. + + A zero timeout is valid and means the client wants to be notified as + soon as possible when the seat is inactive. + + + + + + + + + + + Create a new idle notification object to track input from the + user, such as keyboard and mouse movement. Because this object is + meant to track user input alone, it ignores idle inhibitors. + + The notification object has a minimum timeout duration and is tied to a + seat. The client will be notified if the seat is inactive for at least + the provided timeout. See ext_idle_notification_v1 for more details. + + A zero timeout is valid and means the client wants to be notified as + soon as possible when the seat is inactive. + + + + + + + + + + + This interface is used by the compositor to send idle notification events + to clients. + + Initially the notification object is not idle. The notification object + becomes idle when no user activity has happened for at least the timeout + duration, starting from the creation of the notification object. User + activity may include input events or a presence sensor, but is + compositor-specific. + + How this notification responds to idle inhibitors depends on how + it was constructed. If constructed from the + get_idle_notification request, then if an idle inhibitor is + active (e.g. another client has created a zwp_idle_inhibitor_v1 + on a visible surface), the compositor must not make the + notification object idle. However, if constructed from the + get_input_idle_notification request, then idle inhibitors are + ignored, and only input from the user, e.g. from a keyboard or + mouse, counts as activity. + + When the notification object becomes idle, an idled event is sent. When + user activity starts again, the notification object stops being idle, + a resumed event is sent and the timeout is restarted. + + + + + Destroy the notification object. + + + + + + This event is sent when the notification object becomes idle. + + It's a compositor protocol error to send this event twice without a + resumed event in-between. + + + + + + This event is sent when the notification object stops being idle. + + It's a compositor protocol error to send this event twice without an + idled event in-between. It's a compositor protocol error to send this + event prior to any idled event. + + + + diff --git a/protocol/meson.build b/protocol/meson.build index b16113b2..3da6ee47 100644 --- a/protocol/meson.build +++ b/protocol/meson.build @@ -29,6 +29,7 @@ client_protocols = [ ['river-status-unstable-v1.xml'], ['river-control-unstable-v1.xml'], ['dwl-ipc-unstable-v2.xml'], + ['ext-idle-notify-v1.xml'], ] if wayland_protos.version().version_compare('>=1.39') diff --git a/resources/config.jsonc b/resources/config.jsonc index 9ba8b97a..eb92feb4 100644 --- a/resources/config.jsonc +++ b/resources/config.jsonc @@ -50,6 +50,27 @@ // "default": "" // } // }, + "niri/workspaces": { + "format": "{icon} {windows}", + "format-window-separator": " ", + "window-rewrite-default": "", + "window-rewrite": { + "app_id": "", + "app_id": "", + "app_id": "", + "app_id": "", + "app_id": "" + }, + "format-icons": { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "active": "", + "default": "" + } + }, "keyboard-state": { "numlock": true, "capslock": true, @@ -133,7 +154,8 @@ "backlight": { // "device": "acpi_video1", "format": "{percent}% {icon}", - "format-icons": ["", "", "", "", "", "", "", "", ""] + "format-icons": ["", "", "", "", "", "", "", "", ""], + "min-brightness": 1 }, "battery": { "states": { @@ -155,7 +177,7 @@ }, "power-profiles-daemon": { "format": "{icon}", - "tooltip-format": "Power profile: {profile}\nDriver: {driver}", + "tooltip-format": "Power profile: {profile}\nCPU driver: {cpu_driver}\nPlatform driver: {platform_driver}", "tooltip": true, "format-icons": { "default": "", diff --git a/resources/custom_modules/__pycache__/mediaplayer.cpython-314.pyc b/resources/custom_modules/__pycache__/mediaplayer.cpython-314.pyc new file mode 100644 index 00000000..c185ad3d Binary files /dev/null and b/resources/custom_modules/__pycache__/mediaplayer.cpython-314.pyc differ diff --git a/resources/custom_modules/mediaplayer.py b/resources/custom_modules/mediaplayer.py index 524d4d2a..dfa1fdc4 100755 --- a/resources/custom_modules/mediaplayer.py +++ b/resources/custom_modules/mediaplayer.py @@ -10,10 +10,14 @@ import signal import gi import json import os +import dbus +import dbus.service +from dbus.mainloop.glib import DBusGMainLoop from typing import List logger = logging.getLogger(__name__) + def signal_handler(sig, frame): logger.info("Received signal to stop, exiting") sys.stdout.write("\n") @@ -37,8 +41,27 @@ class PlayerManager: self.selected_player = selected_player self.excluded_player = excluded_player.split(',') if excluded_player else [] + # use dbus to shift player (e.g. after playerctld shift) + bus = dbus.SessionBus(mainloop=DBusGMainLoop()) + bus.add_signal_receiver( + lambda *args, **kwargs: self.on_dbus_shift(*args, **kwargs), + signal_name="Shift", + dbus_interface="org.waybar.Player") + # register well-known bus name + bus.request_name("org.waybar.Player") + self.init_players() + def on_dbus_shift(self, *args, **kwargs): + # there is no implicit way to get current player, + # so we need to create a new manager to get the right order of players + new_manager = Playerctl.PlayerManager() + if len(new_manager.props.player_names) > 1: + current_player_name = new_manager.props.player_names[0].name + for player in self.get_players(): + if player.props.player_name == current_player_name: + self.on_metadata_changed(player, player.props.metadata) + def init_players(self): for player in self.manager.props.player_names: if player.name in self.excluded_player: @@ -105,24 +128,26 @@ class PlayerManager: current_player = self.get_first_playing_player() if current_player is not None: self.on_metadata_changed(current_player, current_player.props.metadata) - else: + else: self.clear_output() def on_metadata_changed(self, player, metadata, _=None): logger.debug(f"Metadata changed for player {player.props.player_name}") player_name = player.props.player_name artist = player.get_artist() - artist = artist.replace("&", "&") + artist = artist and artist.replace("&", "&") title = player.get_title() - title = title.replace("&", "&") + title = title and title.replace("&", "&") track_info = "" if player_name == "spotify" and "mpris:trackid" in metadata.keys() and ":ad:" in player.props.metadata["mpris:trackid"]: track_info = "Advertisement" elif artist is not None and title is not None: track_info = f"{artist} - {title}" - else: + elif title is not None: track_info = title + elif artist is not None: + track_info = artist if track_info: if player.props.status == "Playing": @@ -152,6 +177,7 @@ class PlayerManager: logger.info(f"Player {player.props.player_name} has vanished") self.show_most_important_player() + def parse_arguments(): parser = argparse.ArgumentParser() diff --git a/resources/custom_modules/power_menu.xml b/resources/custom_modules/power_menu.xml index aa2a42ca..972499d7 100644 --- a/resources/custom_modules/power_menu.xml +++ b/resources/custom_modules/power_menu.xml @@ -2,27 +2,27 @@ - - Suspend - - - - - Hibernate - - + + Suspend + + - - Shutdown - + + Hibernate + + + + + Shutdown + - - Reboot - + + Reboot + diff --git a/resources/icons/waybar-privacy-location-symbolic.svg b/resources/icons/waybar-privacy-location-symbolic.svg new file mode 100644 index 00000000..3b843e79 --- /dev/null +++ b/resources/icons/waybar-privacy-location-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/waybar_icons.gresource.xml b/resources/icons/waybar_icons.gresource.xml index 077049bf..a8584eef 100644 --- a/resources/icons/waybar_icons.gresource.xml +++ b/resources/icons/waybar_icons.gresource.xml @@ -4,5 +4,6 @@ waybar-privacy-audio-input-symbolic.svg waybar-privacy-audio-output-symbolic.svg waybar-privacy-screen-share-symbolic.svg + waybar-privacy-location-symbolic.svg diff --git a/resources/style.css b/resources/style.css index bcfcb5c8..86396cd2 100644 --- a/resources/style.css +++ b/resources/style.css @@ -302,7 +302,7 @@ label:focus { } #scratchpad.empty { - background-color: transparent; + background-color: transparent; } #privacy { @@ -325,3 +325,7 @@ label:focus { #privacy-item.audio-out { background-color: #0069d4; } + +#privacy-item.location { + background-color: #00afff; +} diff --git a/src/AGraph.cpp b/src/AGraph.cpp new file mode 100644 index 00000000..b0dd5f02 --- /dev/null +++ b/src/AGraph.cpp @@ -0,0 +1,307 @@ +#include "AGraph.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include "config.hpp" + +namespace waybar { + +AGraph::AGraph(const Json::Value& config, const std::string& name, const std::string& id, + uint16_t interval, bool enable_click, bool enable_scroll) + : AModule(config, name, id, + config["format-alt"].isString() || config["menu"].isString() || enable_click, + enable_scroll), + interval_( + config_["interval"] == "once" + ? std::chrono::milliseconds::max() + : std::chrono::milliseconds( + config_["interval"].isNumeric() + ? std::max(1L, static_cast(config_["interval"].asDouble() * 1000)) + : 1000L * static_cast(interval))) { + graph_.signal_draw().connect(sigc::mem_fun(*this, &AGraph::onDraw)); + graph_.set_name(name); + if (!id.empty()) { + graph_.get_style_context()->add_class(id); + } + graph_.get_style_context()->add_class(MODULE_CLASS); + if (config_["width"].isUInt()) { + graph_.set_size_request(config_["width"].asUInt(), -1); + } else { + graph_.set_size_request(100, -1); + } + + event_box_.add(graph_); + + if (config_["datapoints"].isUInt() && config_["datapoints"].asUInt() > 0) { + datapoints_ = config_["datapoints"].asUInt(); + } + + if (config_["graph_type"].isString()) { + std::string type = config_["graph_type"].asString(); + if (type == "line") { + graph_type_ = GraphType::LINE; + } else if (type == "bar") { + graph_type_ = GraphType::BAR; + } else if (type == "gauge") { + graph_type_ = GraphType::GAUGE; + } + } + + // If a GTKMenu is requested in the config + if (config_["menu"].isString()) { + // Create the GTKMenu widget + try { + // Check that the file exists + std::string menuFile = config_["menu-file"].asString(); + + // there might be "~" or "$HOME" in original path, try to expand it. + auto result = Config::tryExpandPath(menuFile, ""); + if (result.empty()) { + throw std::runtime_error("Failed to expand file: " + menuFile); + } + + menuFile = result.front(); + // Read the menu descriptor file + std::ifstream file(menuFile); + if (!file.is_open()) { + throw std::runtime_error("Failed to open file: " + menuFile); + } + std::stringstream fileContent; + fileContent << file.rdbuf(); + GtkBuilder* builder = gtk_builder_new(); + + // Make the GtkBuilder and check for errors in his parsing + if (gtk_builder_add_from_string(builder, fileContent.str().c_str(), -1, nullptr) == 0U) { + g_object_unref(builder); + throw std::runtime_error("Error found in the file " + menuFile); + } + + menu_ = gtk_builder_get_object(builder, "menu"); + if (menu_ == nullptr) { + g_object_unref(builder); + throw std::runtime_error("Failed to get 'menu' object from GtkBuilder"); + } + // Keep the menu alive after dropping the transient GtkBuilder. + g_object_ref(menu_); + submenus_ = std::map(); + menuActionsMap_ = std::map(); + + // Linking actions to the GTKMenu based on + for (Json::Value::const_iterator it = config_["menu-actions"].begin(); + it != config_["menu-actions"].end(); ++it) { + std::string key = it.key().asString(); + submenus_[key] = GTK_MENU_ITEM(gtk_builder_get_object(builder, key.c_str())); + menuActionsMap_[key] = it->asString(); + g_signal_connect(submenus_[key], "activate", G_CALLBACK(handleGtkMenuEvent), + (gpointer)menuActionsMap_[key].c_str()); + } + g_object_unref(builder); + } catch (std::runtime_error& e) { + spdlog::warn("Error while creating the menu : {}. Menu popup not activated.", e.what()); + } + } +} + +auto AGraph::update() -> void { + graph_.queue_draw(); + AModule::update(); +} + +void AGraph::handleGtkMenuEvent(GtkMenuItem* /*menuitem*/, gpointer data) { + waybar::util::command::res res = waybar::util::command::exec((char*)data, "GtkMenu"); +} + +void AGraph::addValue(const int n) { + if (datapoints_ > 0 && values_.size() >= datapoints_) { + values_.pop_front(); + } + values_.push_back(n); +} + +bool AGraph::onDraw(const Cairo::RefPtr& cr) { + const int width = graph_.get_allocated_width(); + const int height = graph_.get_allocated_height() - 1; + + if (values_.empty() || width <= 0 || height <= 0) { + return false; + } + + auto style_context = graph_.get_style_context(); + Gdk::RGBA fg_color = style_context->get_color(Gtk::STATE_FLAG_NORMAL); + Gdk::RGBA bg_color = fg_color; + bg_color.set_alpha(0.3); + + cr->set_line_width(1.0); + + const double step_width = static_cast(width) / datapoints_; + const int values_count = values_.size(); + const int empty_space = datapoints_ - values_count; + + std::vector> points; + points.reserve(values_count); + + for (int i = empty_space; i < datapoints_; ++i) { + double x = i * step_width; + int value_index = i - empty_space; + int value = values_[value_index]; + double y = height - (static_cast(value) / 100.0 * height); + points.emplace_back(x, y); + } + if (!points.empty()) { + switch (graph_type_) { + case GraphType::LINE: + drawFilledArea(cr, points, height, bg_color); + drawLine(cr, points, fg_color); + break; + case GraphType::BAR: + drawBars(cr, width, height, values_.empty() ? 0 : values_.back(), fg_color); + break; + case GraphType::GAUGE: + drawGauge(cr, width, height, values_.empty() ? 0 : values_.back(), fg_color); + break; + } + } + + return false; +} +void AGraph::drawFilledArea(const Cairo::RefPtr& cr, + const std::vector>& points, double height, + const Gdk::RGBA& bg_color) { + if (points.empty()) return; + + double first_x = points.front().first; + double last_x = points.back().first; + + drawPath(cr, points); + + cr->line_to(last_x, height); + cr->line_to(first_x, height); + cr->close_path(); + + cr->set_source_rgba(bg_color.get_red(), bg_color.get_green(), bg_color.get_blue(), + bg_color.get_alpha()); + cr->fill(); +} + +void AGraph::drawLine(const Cairo::RefPtr& cr, + const std::vector>& points, + const Gdk::RGBA& fg_color) { + if (points.empty()) return; + + cr->begin_new_path(); + drawPath(cr, points); + + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), + fg_color.get_alpha()); + cr->stroke(); +} + +void AGraph::drawPath(const Cairo::RefPtr& cr, + const std::vector>& points) { + if (points.empty()) return; + + bool first_point = true; + for (const auto& point : points) { + if (first_point) { + cr->move_to(point.first, point.second); + first_point = false; + } else { + cr->line_to(point.first, point.second); + } + } +} + +void AGraph::drawBars(const Cairo::RefPtr& cr, double width, double height, + int current_value, const Gdk::RGBA& fg_color) { + current_value = std::min(100, std::max(0, current_value)); + + double green_height = height * (std::min(current_value, 40) / 100.0); + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 0.5); + cr->rectangle(0, height - green_height, width, green_height); + cr->fill(); + + if (current_value > 40) { + double yellow_height = height * (std::min(current_value, 75) - 40) / 100.0; + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 0.7); + cr->rectangle(0, height - green_height - yellow_height, width, yellow_height); + cr->fill(); + } + + if (current_value > 75) { + double orange_height = height * (std::min(current_value, 85) - 75) / 100.0; + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 0.85); + double yellow_height = height * (std::min(current_value, 75) - 40) / 100.0; + cr->rectangle(0, height - green_height - yellow_height - orange_height, width, orange_height); + cr->fill(); + } + + if (current_value > 85) { + double red_height = height * (current_value - 85) / 100.0; + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 1.0); + double yellow_height = height * (std::min(current_value, 75) - 40) / 100.0; + double orange_height = height * (std::min(current_value, 85) - 75) / 100.0; + cr->rectangle(0, height - green_height - yellow_height - orange_height - red_height, width, + red_height); + cr->fill(); + } + + double value_height = height * (current_value / 100.0); + cr->set_source_rgba(0.2, 0.2, 0.2, 0.8); + cr->rectangle(0, height - value_height, width, 2); + cr->fill(); +} + +void AGraph::drawGauge(const Cairo::RefPtr& cr, double width, double height, + int current_value, const Gdk::RGBA& fg_color) { + double center_x = width / 2.0; + double center_y = height; + double radius = height / 2.0; + + cr->set_line_width(10.0); + + double angle1 = M_PI; + double angle2 = angle1 + 0.3 * angle1; + + // Green section (0-33%) + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 0.5); + cr->arc(center_x, center_y, radius, angle1, angle2); + cr->stroke(); + + // Yellow section (33-66%) + angle1 = angle2; + angle2 = angle1 + 0.3 * angle1; + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 0.75); + cr->arc(center_x, center_y, radius, angle1, angle2); + cr->stroke(); + + // Red section (66-100%) + angle1 = angle2; + angle2 = 0.0; + cr->set_source_rgba(fg_color.get_red(), fg_color.get_green(), fg_color.get_blue(), 1.0); + cr->arc(center_x, center_y, radius, angle1, angle2); + cr->stroke(); + + // Draw needle + double percentage = std::min(100, std::max(0, current_value)) / 100.0; + double needle_angle = M_PI * percentage; + double needle_length = radius; + + double needle_x = center_x - needle_length * cos(needle_angle); + double needle_y = center_y - needle_length * sin(needle_angle); + + cr->set_source_rgba(1.0, 1.0, 1.0, 1.0); + cr->set_line_width(2.0); + cr->begin_new_path(); + cr->move_to(center_x, center_y); + cr->line_to(needle_x, needle_y); + cr->stroke(); +} + +} // namespace waybar diff --git a/src/AIconLabel.cpp b/src/AIconLabel.cpp index 5c7df0ed..a54266ac 100644 --- a/src/AIconLabel.cpp +++ b/src/AIconLabel.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include namespace waybar { @@ -9,6 +11,10 @@ AIconLabel::AIconLabel(const Json::Value& config, const std::string& name, const const std::string& format, uint16_t interval, bool ellipsize, bool enable_click, bool enable_scroll) : ALabel(config, name, id, format, interval, ellipsize, enable_click, enable_scroll) { + if (config["icon-size"].isUInt()) { + app_icon_size_ = config["icon-size"].asUInt(); + } + image_.set_pixel_size(app_icon_size_); event_box_.remove(); label_.unset_name(); label_.get_style_context()->remove_class(MODULE_CLASS); @@ -55,13 +61,59 @@ AIconLabel::AIconLabel(const Json::Value& config, const std::string& name, const event_box_.add(box_); } +std::tuple AIconLabel::extractIcon(const std::string& input) { + std::string icon_result = ""; + std::string label_result = input; + try { + static const std::regex icon_search(R"((?=\\0icon\\1f).+?(?=\\n))"); + std::smatch icon_match; + if (std::regex_search(input, icon_match, icon_search)) { + icon_result = icon_match[0].str().substr(9); + + static const std::regex clean_label_pattern(R"(\\0icon\\1f.+?\\n)"); + label_result = std::regex_replace(input, clean_label_pattern, ""); + } + } catch (const std::exception& e) { + spdlog::warn("Error while parsing icon from label. {}", e.what()); + } + + return std::make_tuple(icon_result, label_result); +} + auto AIconLabel::update() -> void { + label_contains_icon = false; + + auto [iconLabel, cleanLabel] = extractIcon(label_.get_label().c_str()); + label_contains_icon = iconLabel.length() > 0; + + if (label_contains_icon) { + 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); + + auto surface = Gdk::Cairo::create_surface_from_pixbuf(pixbuf, image_.get_scale_factor(), + 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); + } + } + image_.set_visible(image_.get_visible() && iconEnabled()); ALabel::update(); } bool AIconLabel::iconEnabled() const { - return config_["icon"].isBool() ? config_["icon"].asBool() : false; + return label_contains_icon || (config_["icon"].isBool() ? config_["icon"].asBool() : false); } } // namespace waybar diff --git a/src/ALabel.cpp b/src/ALabel.cpp index 795f87f1..23ee7851 100644 --- a/src/ALabel.cpp +++ b/src/ALabel.cpp @@ -25,8 +25,15 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st ? std::chrono::milliseconds::max() : std::chrono::milliseconds( (config_["interval"].isNumeric() - ? std::max(1L, // Minimum 1ms due to millisecond precision - static_cast(config_["interval"].asDouble() * 1000)) + ? (config_["interval"].asDouble() > 0 + // Minimum 1ms due to millisecond precision + ? std::max(1L, static_cast( + config_["interval"].asDouble() * 1000)) + // Only modules with no periodic default use 0 as an + // event-driven sentinel. Periodic modules fall back to their + // default interval so interval:0 cannot busy-loop or hit + // modulo-by-zero clock code. + : (interval == 0 ? 0L : 1000L * static_cast(interval))) : 1000 * (long)interval))), default_format_(format_) { label_.set_name(name); @@ -35,6 +42,26 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st } label_.get_style_context()->add_class(MODULE_CLASS); event_box_.add(label_); + if (tooltipEnabled()) { + // Keep dynamic tooltip contents out of GtkWidget's tooltip-markup property. + // Setting that property queues a display-wide tooltip query, which restarts + // GTK's hover delay when modules update faster than the delay can expire. + label_.set_has_tooltip(true); + label_.signal_query_tooltip().connect( + [this](int, int, bool, const Glib::RefPtr& tooltip) { + if (!last_tooltip_markup_.has_value() || last_tooltip_markup_->empty()) { + active_tooltip_.reset(); + return false; + } + active_tooltip_ = tooltip; + tooltip->set_markup(*last_tooltip_markup_); + return true; + }); + event_box_.signal_leave_notify_event().connect([this](GdkEventCrossing*) { + active_tooltip_.reset(); + return false; + }); + } if (config_["max-length"].isUInt()) { label_.set_max_width_chars(config_["max-length"].asInt()); label_.set_ellipsize(Pango::EllipsizeMode::ELLIPSIZE_END); @@ -116,8 +143,9 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st } submenus_[key] = GTK_MENU_ITEM(item); menuActionsMap_[key] = it->asString(); - g_signal_connect(submenus_[key], "activate", G_CALLBACK(handleGtkMenuEvent), - (gpointer)menuActionsMap_[key].c_str()); + g_signal_connect_data(submenus_[key], "activate", G_CALLBACK(handleGtkMenuEvent), + g_strdup(menuActionsMap_[key].c_str()), (GClosureNotify)g_free, + (GConnectFlags)0); } g_object_unref(builder); } catch (std::runtime_error& e) { @@ -139,6 +167,28 @@ ALabel::ALabel(const Json::Value& config, const std::string& name, const std::st auto ALabel::update() -> void { AModule::update(); } +bool ALabel::setLabelMarkup(const Glib::ustring& markup) { + if (last_label_markup_ == markup.raw()) { + return false; + } + + label_.set_markup(markup); + last_label_markup_ = markup.raw(); + return true; +} + +bool ALabel::setTooltipMarkup(const Glib::ustring& markup) { + if (last_tooltip_markup_ == markup.raw()) { + return false; + } + + last_tooltip_markup_ = markup.raw(); + if (active_tooltip_) { + active_tooltip_->set_markup(markup); + } + return true; +} + std::string ALabel::getIcon(uint16_t percentage, const std::string& alt, uint16_t max) { auto format_icons = config_["format-icons"]; if (format_icons.isObject()) { @@ -150,7 +200,28 @@ std::string ALabel::getIcon(uint16_t percentage, const std::string& alt, uint16_ } if (format_icons.isArray()) { auto size = format_icons.size(); - if (size != 0U) { + if (size != 0U && format_icons[0].isObject()) { + std::string last_icon; + for (const auto& threshold : format_icons) { + if (!threshold.isObject() || !threshold["icon"].isString() || !threshold["max"].isUInt()) { + static bool warned = false; + if (!warned) { + spdlog::warn( + "format-icons: skipping invalid threshold object, expected {\"icon\": \"...\", " + "\"max\": N}"); + warned = true; + } + continue; + } + last_icon = threshold["icon"].asString(); + if (percentage <= threshold["max"].asUInt()) { + return last_icon; + } + } + if (!last_icon.empty()) { + return last_icon; + } + } else if (size != 0U) { auto divisor = std::max(1U, (max == 0 ? 100U : static_cast(max)) / size); auto idx = std::clamp(percentage / divisor, 0U, size - 1); format_icons = format_icons[idx]; @@ -177,7 +248,28 @@ std::string ALabel::getIcon(uint16_t percentage, const std::vector& } if (format_icons.isArray()) { auto size = format_icons.size(); - if (size != 0U) { + if (size != 0U && format_icons[0].isObject()) { + std::string last_icon; + for (const auto& threshold : format_icons) { + if (!threshold.isObject() || !threshold["icon"].isString() || !threshold["max"].isUInt()) { + static bool warned = false; + if (!warned) { + spdlog::warn( + "format-icons: skipping invalid threshold object, expected {\"icon\": \"...\", " + "\"max\": N}"); + warned = true; + } + continue; + } + last_icon = threshold["icon"].asString(); + if (percentage <= threshold["max"].asUInt()) { + return last_icon; + } + } + if (!last_icon.empty()) { + return last_icon; + } + } else if (size != 0U) { auto divisor = std::max(1U, (max == 0 ? 100U : static_cast(max)) / size); auto idx = std::clamp(percentage / divisor, 0U, size - 1); format_icons = format_icons[idx]; @@ -189,6 +281,10 @@ std::string ALabel::getIcon(uint16_t percentage, const std::vector& return ""; } +void ALabel::copyToClipboard(const std::string& literal) { + Gtk::Clipboard::get()->set_text(literal); +} + bool waybar::ALabel::handleToggle(GdkEventButton* const& e) { if (config_["format-alt-click"].isUInt() && e->button == config_["format-alt-click"].asUInt()) { alt_ = !alt_; @@ -198,6 +294,10 @@ bool waybar::ALabel::handleToggle(GdkEventButton* const& e) { format_ = default_format_; } } + + if (config_["on-click-copy"].isBool() && config_["on-click-copy"].asBool()) { + copyToClipboard(label_.get_text()); + } return AModule::handleToggle(e); } diff --git a/src/AModule.cpp b/src/AModule.cpp index a5ba69d3..65ffb433 100644 --- a/src/AModule.cpp +++ b/src/AModule.cpp @@ -1,5 +1,6 @@ #include "AModule.hpp" +#include #include #include @@ -17,10 +18,14 @@ AModule::AModule(const Json::Value& config, const std::string& name, const std:: isTooltip{config_["tooltip"].isBool() ? config_["tooltip"].asBool() : true}, isExpand{config_["expand"].isBool() ? config_["expand"].asBool() : false}, distance_scrolled_y_(0.0), - distance_scrolled_x_(0.0) { + distance_scrolled_x_(0.0), + cursor_timeout_conn_() { // Configure module action Map const Json::Value actions{config_["actions"]}; + disable_on_sleep_ = + config_["disable-on-sleep"].isBool() ? config_["disable-on-sleep"].asBool() : false; + for (Json::Value::const_iterator it = actions.begin(); it != actions.end(); ++it) { if (it.key().isString() && it->isString()) if (!eventActionMap_.contains(it.key().asString())) { @@ -42,7 +47,7 @@ AModule::AModule(const Json::Value& config, const std::string& name, const std:: std::find_if(eventMap_.cbegin(), eventMap_.cend(), [&config](const auto& eventEntry) { // True if there is any non-release type event return eventEntry.first.second != GdkEventType::GDK_BUTTON_RELEASE && - config[eventEntry.second].isString(); + (config[eventEntry.second].isString() || config[eventEntry.second].isBool()); }) != eventMap_.cend(); if (enable_click || hasUserEvents) { @@ -72,10 +77,21 @@ AModule::AModule(const Json::Value& config, const std::string& name, const std:: // Respect user configuration of cursor if (config_.isMember("cursor")) { - if (config_["cursor"].isBool() && config_["cursor"].asBool()) { - setCursor(Gdk::HAND2); - } else if (config_["cursor"].isInt()) { + if (config_["cursor"].isBool()) { + if (config_["cursor"].asBool()) { + setCursor("pointer"); + } else { + setCursor("default"); + } + } 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_); } @@ -83,6 +99,9 @@ AModule::AModule(const Json::Value& config, const std::string& name, const std:: } AModule::~AModule() { + if (cursor_timeout_conn_.connected()) { + cursor_timeout_conn_.disconnect(); + } for (const auto& pid : pid_children_) { if (pid != -1) { killpg(pid, SIGTERM); @@ -99,6 +118,7 @@ auto AModule::update() -> void { if (config_["on-update"].isString()) { pid_children_.push_back(util::command::forkExec(config_["on-update"].asString())); } + signal_updated.emit(this); } // Get mapping between event name and module action name // Then call overridden doAction in order to call appropriate module action @@ -110,15 +130,33 @@ auto AModule::doAction(const std::string& name) -> void { } } -void AModule::setCursor(Gdk::CursorType const& c) { +void AModule::setCursor(std::string const& c) { auto gdk_window = event_box_.get_window(); if (gdk_window) { - auto cursor = Gdk::Cursor::create(c); + 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 - Glib::signal_timeout().connect_seconds( + cursor_timeout_conn_ = Glib::signal_timeout().connect_seconds( + [this, c]() { + setCursor(c); + return false; + }, + 1); + } +} + +// 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; @@ -134,7 +172,7 @@ bool AModule::handleMouseEnter(GdkEventCrossing* const& e) { // Default behavior indicating event availability if (hasUserEvents_ && !config_.isMember("cursor")) { - setCursor(Gdk::HAND2); + setCursor("pointer"); } return false; @@ -147,7 +185,7 @@ bool AModule::handleMouseLeave(GdkEventCrossing* const& e) { // Default behavior indicating event availability if (hasUserEvents_ && !config_.isMember("cursor")) { - setCursor(Gdk::ARROW); + setCursor("default"); } return false; @@ -184,13 +222,40 @@ bool AModule::handleUserEvent(GdkEventButton* const& e) { } // Second call user scripts if (!format.empty()) { - if (config_[format].isString()) - format = config_[format].asString(); - else + // If the configured value for this event is a recognized built-in module + // action (registered in eventActionMap_), it has already been dispatched + // via doAction() above / handled by the module itself. Don't additionally + // run it as a shell command (issue #3284). Any other value is still treated + // as a user shell command. + const auto actionIt = eventActionMap_.find(format); + const bool isModuleAction = actionIt != eventActionMap_.cend() && config_[format].isString() && + config_[format].asString() == actionIt->second; + if (isModuleAction || !config_[format].isString()) format.clear(); + else + format = config_[format].asString(); } if (!format.empty()) { - pid_children_.push_back(util::command::forkExec(format)); + const int width = gdk_window_get_width(e->window); + const int height = gdk_window_get_height(e->window); + // Substitute {x}/{y} with the click position. The configured command is + // arbitrary user input that may contain literal braces which are not {x}/{y} + // (e.g. `echo ${HOME}`, `awk '{print $1}'`, brace expansions). Those make + // libfmt throw fmt::format_error; since we run inside a GTK signal handler an + // uncaught exception aborts the whole bar. Only format when a placeholder is + // actually present, and fall back to the raw command if formatting throws. + std::string cmd = format; + if (format.find("{x}") != std::string::npos || format.find("{y}") != std::string::npos) { + try { + cmd = fmt::format(fmt::runtime(format), fmt::arg("x", (int)round(100. * e->x / width)), + fmt::arg("y", (int)round(100. * e->y / height))); + } catch (const fmt::format_error& err) { + spdlog::warn("Failed to format command '{}': {}. Running it unformatted.", format, + err.what()); + cmd = format; + } + } + pid_children_.push_back(util::command::forkExec(cmd)); } dp.emit(); return true; diff --git a/src/bar.cpp b/src/bar.cpp index 7ff73c4f..d84aafed 100644 --- a/src/bar.cpp +++ b/src/bar.cpp @@ -3,12 +3,14 @@ #include #include +#include #include #include "client.hpp" #include "factory.hpp" #include "group.hpp" #include "util/enum.hpp" +#include "util/hosts_check.hpp" #include "util/kill_signal.hpp" #ifdef HAVE_SWAY @@ -172,6 +174,10 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) right_.set_spacing(spacing); } + if (config.isMember("height") && !config["height"].isUInt()) { + spdlog::warn("Invalid type for 'height', expected unsigned integer"); + } + height_ = config["height"].isUInt() ? config["height"].asUInt() : 0; width_ = config["width"].isUInt() ? config["width"].asUInt() : 0; @@ -263,6 +269,16 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) window.signal_map_event().connect_notify(sigc::mem_fun(*this, &Bar::onMap)); + unmap_conn_ = window.signal_unmap().connect([this]() { + spdlog::debug("Output {} unmapped (DPMS off), suspending modules", output->name); + toggleSuspend(true); + }); + + map_conn_ = window.signal_map().connect([this]() { + spdlog::debug("Output {} mapped (DPMS on), resuming modules", output->name); + toggleSuspend(false); + }); + #if HAVE_SWAY if (auto ipc = config["ipc"]; ipc.isBool() && ipc.asBool()) { bar_id = Client::inst()->bar_id; @@ -280,13 +296,12 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) } #endif - waybar::util::EnumParser m_signalActionEnumParser; const auto& configSigusr1 = config["on-sigusr1"]; if (configSigusr1.isString()) { auto strSigusr1 = configSigusr1.asString(); try { onSigusr1 = - m_signalActionEnumParser.parseStringToEnum(strSigusr1, util::userKillSignalActions); + util::parseStringToEnum(strSigusr1, util::userKillSignalActions); } catch (const std::invalid_argument& e) { onSigusr1 = util::SIGNALACTION_DEFAULT_SIGUSR1; spdlog::warn( @@ -298,7 +313,7 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) auto strSigusr2 = configSigusr2.asString(); try { onSigusr2 = - m_signalActionEnumParser.parseStringToEnum(strSigusr2, util::userKillSignalActions); + util::parseStringToEnum(strSigusr2, util::userKillSignalActions); } catch (const std::invalid_argument& e) { onSigusr2 = util::SIGNALACTION_DEFAULT_SIGUSR2; spdlog::warn( @@ -309,6 +324,21 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) setupWidgets(); window.show_all(); + /* + * If gtk-layer-shell's synchronous wait for the initial configure timed out, show_all() can + * return with a configured but not-yet-presented surface. Kick GTK/layer-shell once control has + * returned to the main loop, when any late initial configure has been dispatched and widgets have + * had a chance to allocate/draw. + */ + Glib::signal_idle().connect(sigc::track_obj( + [this] { + window.queue_resize(); + window.queue_draw(); + forceLayerCommit(); + return false; + }, + *this)); + if (spdlog::should_log(spdlog::level::debug)) { // Unfortunately, this function isn't in the C++ bindings, so we have to call the C version. char* gtk_tree = gtk_style_context_to_string( @@ -321,7 +351,12 @@ waybar::Bar::Bar(struct waybar_output* w_output, const Json::Value& w_config) } /* Need to define it here because of forward declared members */ -waybar::Bar::~Bar() = default; +waybar::Bar::~Bar() { + /* Destroying the window emits `unmap`, whose handler runs toggleSuspend() over + * modules_all_ -- already freed by this point. Disconnect first (#5182). */ + unmap_conn_.disconnect(); + map_conn_.disconnect(); +} void waybar::Bar::setMode(const std::string& mode) { using namespace std::literals::string_literals; @@ -374,7 +409,14 @@ void waybar::Bar::setMode(const struct bar_mode& mode) { * gtk-layer-shell schedules a commit on the next frame event in GTK, but this could fail in * certain scenarios, such as fully occluded bar. */ - gtk_layer_try_force_commit(gtk_window); + forceLayerCommit(); +} + +void waybar::Bar::forceLayerCommit() { + auto* gtk_window = window.gobj(); + if (gtk_window != nullptr && gtk_widget_get_realized(GTK_WIDGET(gtk_window))) { + gtk_layer_try_force_commit(gtk_window); + } wl_display_flush(Client::inst()->wl_display); } @@ -531,6 +573,11 @@ void waybar::Bar::getModules(const Factory& factory, const std::string& pos, for (const auto& name : module_list) { try { auto ref = name.asString(); + + if (config[ref].isMember("hosts") && !waybar::util::valid_host(config[ref])) { + continue; + } + AModule* module; if (ref.compare(0, 6, "group/") == 0 && ref.size() > 6) { @@ -545,8 +592,8 @@ void waybar::Bar::getModules(const Factory& factory, const std::string& pos, if (group_config["modules"].isNull()) { spdlog::warn("Group definition '{}' has not been found, group will be hidden", ref); } - auto group_module = std::make_unique( - id_name, class_name, group_config, vertical); + auto group_module = + std::make_unique(id_name, class_name, group_config, vertical); getModules(factory, ref, group_module.get()); module = group_module.release(); @@ -557,7 +604,7 @@ void waybar::Bar::getModules(const Factory& factory, const std::string& pos, std::shared_ptr module_sp(module); modules_all_.emplace_back(module_sp); if (group != nullptr) { - group->addWidget(*module); + group->addWidget(module); } else { if (pos == "modules-left") { modules_left_.emplace_back(module_sp); @@ -576,6 +623,7 @@ void waybar::Bar::getModules(const Factory& factory, const std::string& pos, spdlog::error("{}: {}", ref, e.what()); } }); + module->dp.emit(); } catch (const std::exception& e) { spdlog::warn("module {}: {}", name.asString(), e.what()); } @@ -652,6 +700,17 @@ void waybar::Bar::onConfigure(GdkEventConfigure* ev) { configureGlobalOffset(ev->width, ev->height); spdlog::info(BAR_SIZE_MSG, ev->width, ev->height, output->name); + + /* + * gtk-layer-shell waits for the compositor's initial configure while realizing the window. On a + * busy compositor (common during session startup) that wait can time out even though the initial + * configure arrives shortly afterwards. In that case GTK may not schedule the frame commit that + * presents the first layer-surface buffer, leaving an otherwise configured bar invisible. Force a + * commit after every configure so late initial configures, and later compositor-driven resizes, + * always result in a submitted surface state. + */ + window.queue_draw(); + forceLayerCommit(); } void waybar::Bar::configureGlobalOffset(int width, int height) { @@ -696,3 +755,20 @@ void waybar::Bar::configureGlobalOffset(int width, int height) { void waybar::Bar::onOutputGeometryChanged() { configureGlobalOffset(window.get_width(), window.get_height()); } + +void waybar::Bar::toggleSuspend(bool suspend) { + // Iterate the actual module objects. Modules are packed into the Gtk::Box via + // AModule::operator Gtk::Widget&(), which returns the member event_box_, so the + // box children are Gtk::EventBox, never AModule -- a dynamic_cast over them is + // always null and suspend()/resume() would never fire. modules_all_ holds the + // real module pointers (including group children), so use it instead. + for (auto const& module : modules_all_) { + if (module && module->shouldSuspend()) { + if (suspend) { + module->suspend(); + } else { + module->resume(); + } + } + } +} diff --git a/src/client.cpp b/src/client.cpp index 16dcf1fd..335b53f9 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -3,11 +3,13 @@ #include #include +#include #include #include #include #include "gtkmm/icontheme.h" +#include "ext-idle-notify-v1-client-protocol.h" #include "idle-inhibit-unstable-v1-client-protocol.h" #include "util/clara.hpp" #include "util/format.hpp" @@ -40,6 +42,12 @@ void waybar::Client::handleGlobal(void* data, struct wl_registry* registry, uint client->idle_inhibit_manager = static_cast( wl_registry_bind(registry, name, &zwp_idle_inhibit_manager_v1_interface, 1)); + } else if (strcmp(interface, ext_idle_notifier_v1_interface.name) == 0) { + // Bind version 2 if available (for get_input_idle_notification), otherwise version 1 + auto bind_version = std::min(version, 2u); + client->idle_notifier = static_cast( + wl_registry_bind(registry, name, &ext_idle_notifier_v1_interface, bind_version)); + spdlog::debug("Bound ext-idle-notifier-v1 at version {}", bind_version); } } @@ -51,7 +59,7 @@ void waybar::Client::handleGlobalRemove(void* data, struct wl_registry* /*regist void waybar::Client::handleOutput(struct waybar_output& output) { static const struct zxdg_output_v1_listener xdgOutputListener = { .logical_position = [](void*, struct zxdg_output_v1*, int32_t, int32_t) {}, - .logical_size = [](void*, struct zxdg_output_v1*, int32_t, int32_t) {}, + .logical_size = &handleOutputLogicalSize, .done = &handleOutputDone, .name = &handleOutputName, .description = &handleOutputDescription, @@ -72,7 +80,19 @@ struct waybar::waybar_output& waybar::Client::getOutput(void* addr) { } std::vector waybar::Client::getOutputConfigs(struct waybar_output& output) { - return config.getOutputConfigs(output.name, output.identifier); + return config.getOutputConfigs(output.name, output.identifier, output.width, output.height); +} + +void waybar::Client::handleOutputLogicalSize(void* data, struct zxdg_output_v1* object, + int32_t width, int32_t height) { + auto client = waybar::Client::inst(); + try { + auto& output = client->getOutput(data); + output.width = width; + output.height = height; + } catch (const std::exception& e) { + std::cerr << e.what() << std::endl; + } } void waybar::Client::handleOutputDone(void* data, struct zxdg_output_v1* /*xdg_output*/) { @@ -92,11 +112,14 @@ void waybar::Client::handleOutputDone(void* data, struct zxdg_output_v1* /*xdg_o output.xdg_output.reset(); spdlog::debug("Output detection done: {} ({})", output.name, output.identifier); - auto configs = client->getOutputConfigs(output); - if (!configs.empty()) { - for (const auto& config : configs) { - client->bars.emplace_back(std::make_unique(&output, config)); - } + client->pending_outputs_.push_back(&output); + + if (!client->bars_scheduled_) { + client->bars_scheduled_ = true; + + Glib::signal_idle().connect_once([client]() { + client->createBarsBatch(); + }, Glib::PRIORITY_HIGH_IDLE); } } } catch (const std::exception& e) { @@ -104,6 +127,25 @@ void waybar::Client::handleOutputDone(void* data, struct zxdg_output_v1* /*xdg_o } } +void waybar::Client::createBarsBatch() { + pending_outputs_.remove_if([this](auto* output) { return std::none_of(outputs_.begin(), outputs_.end(), [&output](const auto& o) { return &o == output; }); }); + for (auto* output : pending_outputs_) { + try { + auto configs = getOutputConfigs(*output); + if (!configs.empty()) { + for (const auto& config : configs) { + bars.emplace_back(std::make_unique(output, config)); + } + } + } catch (const std::exception& e) { + spdlog::warn("Error creating bar: {}", e.what()); + } + } + + pending_outputs_.clear(); + bars_scheduled_ = false; +} + void waybar::Client::handleOutputName(void* data, struct zxdg_output_v1* /*xdg_output*/, const char* name) { auto* client = waybar::Client::inst(); @@ -170,7 +212,7 @@ const std::string waybar::Client::getStyle(const std::string& style, if (style.empty()) { std::vector search_files; - switch (appearance.value_or(portal->getAppearance())) { + switch (appearance.value_or(portal ? portal->getAppearance() : waybar::Appearance::UNKNOWN)) { case waybar::Appearance::LIGHT: search_files.emplace_back("style-light.css"); gtk_settings->property_gtk_application_prefer_dark_theme() = false; @@ -245,6 +287,15 @@ void waybar::Client::bindInterfaces() { // Clear stale outputs from previous run outputs_.clear(); + // Also drop any batch state that was left pending from the previous run. On + // reload the GApplication is swapped but the default main context (and its + // queued PRIORITY_HIGH_IDLE createBarsBatch source) survives; pending_outputs_ + // would then hold dangling waybar_output* into the just-cleared outputs_ list, + // which createBarsBatch's address comparison can mis-match if the freed slot is + // reused. Reset so the next run schedules its batch from a clean state (#4129). + pending_outputs_.clear(); + bars_scheduled_ = false; + // add existing outputs and subscribe to updates for (auto i = 0; i < gdk_display->get_n_monitors(); ++i) { auto monitor = gdk_display->get_monitor(i); @@ -303,15 +354,26 @@ int waybar::Client::main(int argc, char* argv[]) { wl_display = gdk_wayland_display_get_wl_display(gdk_display->gobj()); config.load(config_opt); if (!portal) { - portal = std::make_unique(); + try { + portal = std::make_unique(); + } catch (const Glib::Error& e) { + spdlog::warn( + "Failed to connect to the desktop portal, light/dark theme detection disabled: {}", + std::string(e.what())); + } catch (...) { + spdlog::warn("Failed to connect to the desktop portal, light/dark theme detection disabled"); + } } m_cssFile = getStyle(style_opt); setupCss(m_cssFile); - m_cssReloadHelper = std::make_unique(m_cssFile, [&]() { setupCss(m_cssFile); }); - portal->signal_appearance_changed().connect([&](waybar::Appearance appearance) { - auto css_file = getStyle(style_opt, appearance); - setupCss(css_file); - }); + m_cssReloadHelper = std::make_unique(m_cssFile, [&](const std::string& css_file) { setupCss(css_file); }); + if (portal) { + portal->signal_appearance_changed().connect([&](waybar::Appearance appearance) { + auto css_file = getStyle(style_opt, appearance); + m_cssReloadHelper->changeCssFile(css_file); + setupCss(css_file); + }); + } auto m_config = config.getConfig(); if (m_config.isObject() && m_config["reload_style_on_change"].asBool()) { @@ -338,7 +400,9 @@ int waybar::Client::main(int argc, char* argv[]) { void waybar::Client::reset() { gtk_app->quit(); // delete signal handler for css changes - portal->signal_appearance_changed().clear(); + if (portal) { + portal->signal_appearance_changed().clear(); + } } bool waybar::Client::get_signal_state(int signum) const { diff --git a/src/config.cpp b/src/config.cpp index 266342ae..800b6b02 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -175,21 +175,38 @@ void Config::mergeConfig(Json::Value& a_config_, Json::Value& b_config_) { } } bool isValidOutput(const Json::Value& config, const std::string& name, - const std::string& identifier) { + const std::string& identifier, int32_t width, int32_t height) { + const auto isOutputMatches = [&](const std::string& output) -> bool { + if (output.substr(0, 1) == "$") { + auto* const environment_value = std::getenv(output.substr(1).c_str()); + if (environment_value != nullptr) { + const std::string output_from_env = environment_value; + return output_from_env == name || output_from_env == identifier; + } + + spdlog::warn("The environment value is unknown: {}", output); + } + + return output == name || output == identifier; + }; + if (config["output"].isArray()) { for (auto const& output_conf : config["output"]) { if (output_conf.isString()) { auto config_output = output_conf.asString(); + if (config_output.substr(0, 1) == "!") { - if (config_output.substr(1) == name || config_output.substr(1) == identifier) { + if (isOutputMatches(config_output.substr(1))) { return false; } continue; } - if (config_output == name || config_output == identifier) { + + if (isOutputMatches(config_output)) { return true; } + if (config_output.substr(0, 1) == "*") { return true; } @@ -200,14 +217,72 @@ bool isValidOutput(const Json::Value& config, const std::string& name, if (config["output"].isString()) { auto config_output = config["output"].asString(); + if (!config_output.empty()) { if (config_output.substr(0, 1) == "!") { - return config_output.substr(1) != name && config_output.substr(1) != identifier; + return !isOutputMatches(config_output.substr(1)); } - return config_output == name || config_output == identifier; + + return isOutputMatches(config_output); } } + // if "output-dimensions" is a string, make it an array of size 1 + Json::Value config_output_dimensions = config["output-dimensions"]; + if (config_output_dimensions.isString()) { + Json::Value jsonArray(Json::arrayValue); + jsonArray.append(config_output_dimensions); + config_output_dimensions = jsonArray; + } + if (config_output_dimensions.isArray()) { + for (auto const& config_output_dimension : config_output_dimensions) { + if (!config_output_dimension.isString()) { + continue; + } + std::string str = config_output_dimension.asString(); + auto first_space = str.find(' '); + if (first_space == std::string::npos) { + spdlog::warn( + "Ignoring malformed 'output-dimensions' entry (expected ' " + "'): '{}'", + str); + continue; + } + std::string dimension = str.substr(0, first_space); + str = str.substr(first_space + 1); + auto second_space = str.find(' '); + if (second_space == std::string::npos) { + spdlog::warn( + "Ignoring malformed 'output-dimensions' entry (expected ' " + "'): '{}'", + config_output_dimension.asString()); + continue; + } + std::string comparator = str.substr(0, second_space); + int value; + try { + value = std::stoi(str.substr(second_space + 1)); + } catch (const std::exception& e) { + spdlog::warn("Ignoring 'output-dimensions' entry with non-integer value: '{}'", + config_output_dimension.asString()); + continue; + } + + int comparison_value; + if (dimension == "height") { + comparison_value = height; + } else if (dimension == "width") { + comparison_value = width; + } else { + continue; + } + + if ((comparator == "<" && comparison_value >= value) || + (comparator == ">" && comparison_value <= value)) { + return false; + } + } + } return true; } @@ -223,15 +298,16 @@ void Config::load(const std::string& config) { } std::vector Config::getOutputConfigs(const std::string& name, - const std::string& identifier) { + const std::string& identifier, int32_t width, + int32_t height) { std::vector configs; if (config_.isArray()) { for (auto const& config : config_) { - if (config.isObject() && isValidOutput(config, name, identifier)) { + if (config.isObject() && isValidOutput(config, name, identifier, width, height)) { configs.push_back(config); } } - } else if (isValidOutput(config_, name, identifier)) { + } else if (isValidOutput(config_, name, identifier, width, height)) { configs.push_back(config_); } return configs; diff --git a/src/factory.cpp b/src/factory.cpp index 2fd3e3b8..86abb43d 100644 --- a/src/factory.cpp +++ b/src/factory.cpp @@ -42,6 +42,13 @@ #include "modules/niri/window.hpp" #include "modules/niri/workspaces.hpp" #endif +#ifdef HAVE_MANGO +#include "modules/mango/keymode.hpp" +#include "modules/mango/language.hpp" +#include "modules/mango/layout.hpp" +#include "modules/mango/window.hpp" +#include "modules/mango/workspaces.hpp" +#endif #ifdef HAVE_WAYFIRE #include "modules/wayfire/window.hpp" #include "modules/wayfire/workspaces.hpp" @@ -52,6 +59,7 @@ #if defined(HAVE_CPU_LINUX) || defined(HAVE_CPU_BSD) #include "modules/cpu.hpp" #include "modules/cpu_frequency.hpp" +#include "modules/cpu_graph.hpp" #include "modules/cpu_usage.hpp" #include "modules/load.hpp" #endif @@ -115,8 +123,12 @@ #include "modules/gps.hpp" #endif #include "modules/cava/cava_frontend.hpp" +#ifdef HAVE_LIBMM_GLIB +#include "modules/wwan.hpp" +#endif #include "modules/cffi.hpp" #include "modules/custom.hpp" +#include "modules/custom_graph.hpp" #include "modules/image.hpp" #include "modules/temperature.hpp" #include "modules/user.hpp" @@ -231,6 +243,23 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, return new waybar::modules::niri::Workspaces(id, bar_, config_[name]); } #endif +#ifdef HAVE_MANGO + if (ref == "mango/window") { + return new waybar::modules::mango::Window(id, bar_, config_[name]); + } + if (ref == "mango/workspaces") { + return new waybar::modules::mango::Workspaces(id, bar_, config_[name]); + } + if (ref == "mango/language") { + return new waybar::modules::mango::Language(id, bar_, config_[name]); + } + if (ref == "mango/keymode") { + return new waybar::modules::mango::Keymode(id, bar_, config_[name]); + } + if (ref == "mango/layout") { + return new waybar::modules::mango::Layout(id, bar_, config_[name]); + } +#endif #ifdef HAVE_WAYFIRE if (ref == "wayfire/window") { return new waybar::modules::wayfire::Window(id, bar_, config_[name]); @@ -251,6 +280,9 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, if (ref == "cpu") { return new waybar::modules::Cpu(id, config_[name]); } + if (ref == "cpu_graph") { + return new waybar::modules::CpuGraph(id, config_[name]); + } #if defined(HAVE_CPU_LINUX) if (ref == "cpu_frequency") { return new waybar::modules::CpuFrequency(id, config_[name]); @@ -340,7 +372,7 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, } #endif if (ref == "cava") { - return waybar::modules::cava::getModule(id, config_[name]); + return waybar::modules::cava::getModule(id, config_[name]).release(); } #ifdef HAVE_SYSTEMD_MONITOR if (ref == "systemd-failed-units") { @@ -351,6 +383,11 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, if (ref == "gps") { return new waybar::modules::Gps(id, config_[name]); } +#endif +#ifdef HAVE_LIBMM_GLIB + if (ref == "wwan") { + return new waybar::modules::Wwan(id, config_[name]); + } #endif if (ref == "temperature") { return new waybar::modules::Temperature(id, config_[name]); @@ -358,6 +395,9 @@ waybar::AModule* waybar::Factory::makeModule(const std::string& name, if (ref.compare(0, 7, "custom/") == 0 && ref.size() > 7) { return new waybar::modules::Custom(ref.substr(7), id, config_[name], bar_.output->name); } + if (ref.compare(0, 13, "custom-graph/") == 0 && ref.size() > 13) { + return new waybar::modules::CustomGraph(ref.substr(13), id, config_[name], bar_.output->name); + } if (ref.compare(0, 5, "cffi/") == 0 && ref.size() > 5) { return new waybar::modules::CFFI(ref.substr(5), id, config_[name]); } diff --git a/src/group.cpp b/src/group.cpp index b5a4bcc6..2f8fcb1c 100644 --- a/src/group.cpp +++ b/src/group.cpp @@ -31,6 +31,7 @@ Group::Group(const std::string& name, const std::string& id, const Json::Value& box{vertical ? Gtk::ORIENTATION_VERTICAL : Gtk::ORIENTATION_HORIZONTAL, 0}, revealer_box{vertical ? Gtk::ORIENTATION_VERTICAL : Gtk::ORIENTATION_HORIZONTAL, 0} { box.set_name(name_); + box.get_style_context()->add_class("empty"); if (!id.empty()) { box.get_style_context()->add_class(id); } @@ -67,24 +68,37 @@ Group::Group(const std::string& name, const std::string& id, const Json::Value& const bool left_to_right = (drawer_config["transition-left-to-right"].isBool() ? drawer_config["transition-left-to-right"].asBool() : true); + const bool reveal_by_default = + (drawer_config["reveal-by-default"].isBool() ? drawer_config["reveal-by-default"].asBool() + : false); + click_to_reveal = drawer_config["click-to-reveal"].asBool(); if (click_to_reveal && toggle_signal) { throw std::runtime_error( R"(A group cannot have both "click-to-reveal" and "toggle-signal".)"); } + always_visible_class = (drawer_config["always-visible-class"].isString() + ? drawer_config["always-visible-class"].asString() + : ""); + reveal_delay = drawer_config["reveal-delay"].asInt(); + const bool start_expanded = (drawer_config["start-expanded"].isBool() ? drawer_config["start-expanded"].asBool() : false); + empty_if_drawer_empty = (drawer_config["empty-if-drawer-empty"].isBool() + ? drawer_config["empty-if-drawer-empty"].asBool() + : false); auto transition_type = getPreferredTransitionType(vertical); revealer.set_transition_type(transition_type); revealer.set_transition_duration(transition_duration); - revealer.set_reveal_child(start_expanded); - - if (start_expanded) { + if ((click_to_reveal && reveal_by_default) || start_expanded) { box.set_state_flags(Gtk::StateFlags::STATE_FLAG_PRELIGHT); + revealer.set_reveal_child(true); + } else { + revealer.set_reveal_child(false); } revealer.get_style_context()->add_class("drawer"); @@ -109,25 +123,100 @@ void Group::toggle() { } } +Group::~Group() { + // Disconnect any pending reveal timeout so it cannot fire on a destroyed + // instance (the GLib source is not removed by sigc::connection's destructor). + if (reveal_timeout_.connected()) { + reveal_timeout_.disconnect(); + } +} + void Group::show_group() { box.set_state_flags(Gtk::StateFlags::STATE_FLAG_PRELIGHT); revealer.set_reveal_child(true); + box.get_style_context()->add_class("expanded"); +} + +void Group::hide_widget(Gtk::Widget& widget) { + widget.get_style_context()->add_class(add_class_to_drawer_children); + box.remove(widget); + revealer_box.pack_start(widget, false, false); +} + +void Group::show_widget(Gtk::Widget& widget) { + widget.get_style_context()->remove_class(add_class_to_drawer_children); + revealer_box.remove(widget); + box.pack_end(widget, false, false); +} + +void Group::hide_current_widget_if_inactive() { + for (auto* event_box : box.get_children()) { + if (event_box == &revealer) { + continue; + } + if (auto event_box_container = dynamic_cast(event_box)) { + for (auto* the_only_visible : event_box_container->get_children()) { + if (!the_only_visible->get_style_context()->has_class(always_visible_class)) { + hide_widget(*event_box); + } + } + } + } +} + +void Group::manage_visibility(AModule* module) { + Gtk::Widget& widget = *module; + + if (auto container = dynamic_cast(&widget)) { + for (auto* base_element : container->get_children()) { + if (base_element->get_style_context()->has_class(always_visible_class)) { + if (box.get_children().size() == 2) { + Group::hide_current_widget_if_inactive(); + } + show_widget(widget); + } else { + // Do not hide if it's the only widget + revealer + if (box.get_children().size() <= 2) { + return; + } + hide_widget(widget); + } + } + } } void Group::hide_group() { box.unset_state_flags(Gtk::StateFlags::STATE_FLAG_PRELIGHT); revealer.set_reveal_child(false); + box.get_style_context()->remove_class("expanded"); } bool Group::handleMouseEnter(GdkEventCrossing* const& e) { if (!click_to_reveal && !toggle_signal) { - show_group(); + if (reveal_delay > 0) { + if (reveal_timeout_.connected()) { + reveal_timeout_.disconnect(); + } + + reveal_timeout_ = Glib::signal_timeout().connect( + [this]() { + show_group(); + return false; + }, + reveal_delay); + } else { + show_group(); + } } return false; } bool Group::handleMouseLeave(GdkEventCrossing* const& e) { if (!click_to_reveal && e->detail != GDK_NOTIFY_INFERIOR && !toggle_signal) { + if (reveal_delay > 0 && reveal_timeout_.connected()) { + reveal_timeout_.disconnect(); + } + hide_group(); } return false; @@ -142,7 +231,44 @@ bool Group::handleToggle(GdkEventButton* const& e) { } auto Group::update() -> void { - // noop + bool has_visible_child = false; + bool has_visible_drawer_child = false; + + if (is_drawer) { + for (auto* rev_child : revealer_box.get_children()) { + if (rev_child->get_visible()) { + has_visible_drawer_child = true; + break; + } + } + } + + if (is_drawer && empty_if_drawer_empty) { + has_visible_child = has_visible_drawer_child; + } else { + for (auto* child : box.get_children()) { + if (child == &revealer) { + if (has_visible_drawer_child) { + has_visible_child = true; + break; + } + } else if (child->get_visible()) { + has_visible_child = true; + break; + } + } + } + + auto style = box.get_style_context(); + if (has_visible_child) { + if (style->has_class("empty")) { + style->remove_class("empty"); + } + } else { + if (!style->has_class("empty")) { + style->add_class("empty"); + } + } } void Group::refresh(int sig) { @@ -158,7 +284,9 @@ bool Group::handleScroll(GdkEventScroll* e) { Gtk::Box& Group::getBox() { return is_drawer ? (is_first_widget ? box : revealer_box) : box; } -void Group::addWidget(Gtk::Widget& widget) { +void Group::addWidget(AModule* module) { + Gtk::Widget& widget = *module; + getBox().pack_start(widget, false, false); if (is_drawer && !is_first_widget) { @@ -166,6 +294,13 @@ void Group::addWidget(Gtk::Widget& widget) { } is_first_widget = false; + + widget.property_visible().signal_changed().connect(sigc::mem_fun(*this, &Group::update)); + update(); + + if (!always_visible_class.empty()) { + module->signal_updated.connect(sigc::mem_fun(*this, &Group::manage_visibility)); + } } Group::operator Gtk::Widget&() { return event_box_; } diff --git a/src/main.cpp b/src/main.cpp index c3e1614a..b3e5e386 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,17 @@ #include #include +#ifdef HAVE_LIBSYSTEMD +#include +#include + +#include +#include +#include +#include +#include +#endif + #include "bar.hpp" #include "client.hpp" #include "util/SafeSignal.hpp" @@ -55,9 +66,11 @@ static void catchSignals(waybar::SafeSignal& signal_handler) { std::signal(SIGINT, writeSignalToPipe); std::signal(SIGCHLD, writeSignalToPipe); +#ifdef SIGRTMIN for (int sig = SIGRTMIN + 1; sig <= SIGRTMAX; ++sig) { std::signal(sig, writeSignalToPipe); } +#endif while (true) { int signum; @@ -119,6 +132,7 @@ void handleUserSignal(int signal, bool& reload) { // If this signal should restart or close the bar, this function will write // `true` or `false`, respectively, into `reload`. static void handleSignalMainThread(int signum, bool& reload) { +#ifdef SIGRTMIN if (signum >= SIGRTMIN + 1 && signum <= SIGRTMAX) { waybar::Client::inst()->toggle_signal_state(signum); for (auto& bar : waybar::Client::inst()->bars) { @@ -126,6 +140,7 @@ static void handleSignalMainThread(int signum, bool& reload) { } return; } +#endif switch (signum) { case SIGUSR1: @@ -159,7 +174,48 @@ static void handleSignalMainThread(int signum, bool& reload) { } } +static void logToJournalIfRunAsService() { +#ifdef HAVE_LIBSYSTEMD + /* Implementation of automatic protocol upgrading (from stderr to journal) + ** as described in https://systemd.io/JOURNAL_NATIVE_PROTOCOL */ + char const* journal_stream = std::getenv("JOURNAL_STREAM"); + + if (journal_stream != nullptr) { + dev_t device; + ino_t inode; + size_t len = std::strlen(journal_stream); + + auto result = std::from_chars(journal_stream, journal_stream + len, device); + if (result.ec == std::errc{}) + result = std::from_chars(result.ptr + 1, journal_stream + len, inode); + if (result.ec != std::errc{}) { + spdlog::warn("malformed JOURNAL_STREAM (\"{}\"): {}, logging to console", journal_stream, + std::make_error_condition(result.ec).message()); + } + + struct stat f_stderr; + + if (fstat(STDERR_FILENO, &f_stderr) != 0) { + spdlog::warn("unable to check stderr device and inode numbers: {}", strerror(errno)); + } else if (device == f_stderr.st_dev && inode == f_stderr.st_ino) { + auto journald = spdlog::systemd_logger_st("native_journal", "waybar", false); + /* systemd_logger_st is thread-safe with enable_formatter = false + ** thanks to underlying sd_journal_send being thread-safe + ** https://github.com/gabime/spdlog/issues/2320#issuecomment-1079766037 + */ + spdlog::set_default_logger(journald); + } else { + spdlog::info("JOURNAL_STREAM does not point to stderr, logging to console"); + } + } else { + spdlog::info("no JOURNAL_STREAM, logging to console"); + } +#endif +} + int main(int argc, char* argv[]) { + logToJournalIfRunAsService(); + try { auto* client = waybar::Client::inst(); diff --git a/src/modules/backlight.cpp b/src/modules/backlight.cpp index 8f0d583f..2f7bbdff 100644 --- a/src/modules/backlight.cpp +++ b/src/modules/backlight.cpp @@ -17,8 +17,6 @@ waybar::modules::Backlight::Backlight(const std::string& id, const Json::Value& : ALabel(config, "backlight", id, "{percent}%", 2), preferred_device_(config["device"].isString() ? config["device"].asString() : ""), backend(interval_, [this] { dp.emit(); }) { - dp.emit(); - // Set up scroll handler event_box_.add_events(Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); event_box_.signal_scroll_event().connect(sigc::mem_fun(*this, &Backlight::handleScroll)); @@ -36,9 +34,15 @@ auto waybar::modules::Backlight::update() -> void { if (best->get_powered()) { event_box_.show(); + const uint8_t percent = best->get_max() == 0 ? 100 : round(best->get_actual() * 100.0f / best->get_max()); + const uint8_t percent_exp = + best->get_max() == 0 + ? 100 + : roundf(powf((float)best->get_actual() / best->get_max(), 1.0f / 2.718f) * 100); + // Get the state and apply state-specific format if available auto state = getState(percent); std::string current_format = format_; @@ -49,22 +53,10 @@ auto waybar::modules::Backlight::update() -> void { } } - std::string desc = fmt::format(fmt::runtime(current_format), fmt::arg("percent", percent), - fmt::arg("icon", getIcon(percent))); - label_.set_markup(desc); - if (tooltipEnabled()) { - std::string tooltip_format; - if (config_["tooltip-format"].isString()) { - tooltip_format = config_["tooltip-format"].asString(); - } - if (!tooltip_format.empty()) { - label_.set_tooltip_markup(fmt::format(fmt::runtime(tooltip_format), - fmt::arg("percent", percent), - fmt::arg("icon", getIcon(percent)))); - } else { - label_.set_tooltip_markup(desc); - } - } + updateLabelAndTooltip(current_format, current_format, fmt::arg("percent", percent), + fmt::arg("percent_exp", percent_exp), + fmt::arg("icon", getIcon(percent)), + fmt::arg("icon_exp", getIcon(percent_exp))); } else { event_box_.hide(); } @@ -126,9 +118,16 @@ bool waybar::modules::Backlight::handleScroll(GdkEventScroll* e) { if (config_["min-brightness"].isDouble()) { min_brightness = config_["min-brightness"].asDouble(); } - if (backend.get_scaled_brightness(preferred_device_) <= min_brightness && - ct == util::ChangeType::Decrease) { - return true; + if (ct == util::ChangeType::Decrease) { + const double current = backend.get_scaled_brightness(preferred_device_); + if (current <= min_brightness) { + return true; + } + if (current - step < min_brightness) { + backend.set_scaled_brightness(preferred_device_, + static_cast(std::round(min_brightness))); + return true; + } } backend.set_brightness(preferred_device_, ct, step); diff --git a/src/modules/battery.cpp b/src/modules/battery.cpp index 25436baf..ffa6ff39 100644 --- a/src/modules/battery.cpp +++ b/src/modules/battery.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include "util/command.hpp" #if defined(__FreeBSD__) @@ -33,6 +36,11 @@ waybar::modules::Battery::Battery(const std::string& id, const Bar& bar, const J } udev_monitor_enable_receiving(mon_.get()); + if (config_["smooth-power"].isBool()) { + smoothPowerEnable_ = config_["smooth-power"].asBool(); + if (smoothPowerEnable_ && config_["smooth-power-time-constant"].isNumeric()) + time_constant_s_ = std::max(1.0, config_["smooth-power-time-constant"].asDouble()); + } if (config_["weighted-average"].isBool()) weightedAverage_ = config_["weighted-average"].asBool(); #endif spdlog::debug("battery: worker interval is {}", interval_.count()); @@ -153,7 +161,7 @@ void waybar::modules::Battery::refreshBatteries() { } } } catch (fs::filesystem_error& e) { - throw std::runtime_error(e.what()); + spdlog::warn("Battery directory tracking failed: {}", e.what()); } if (warnFirstTime_ && batteries_.empty()) { if (config_["bat"].isString()) { @@ -288,63 +296,73 @@ waybar::modules::Battery::getInfos() { int32_t _current_now_int = 0; bool current_now_exists = false; if (std::ifstream current_now_f{bat / "current_now"}) { - current_now_exists = true; - current_now_f >> _current_now_int; + if (current_now_f >> _current_now_int) { + current_now_exists = true; + } } else if (std::ifstream current_avg_f{bat / "current_avg"}) { - current_now_exists = true; - current_avg_f >> _current_now_int; + if (current_avg_f >> _current_now_int) { + current_now_exists = true; + } } // Documentation ABI allows a negative value when discharging, positive // value when charging. current_now = std::abs(_current_now_int); if (std::ifstream f{bat / "time_to_empty_now"}) { - time_to_empty_now_exists = true; - f >> time_to_empty_now; + if (f >> time_to_empty_now) { + time_to_empty_now_exists = true; + } } if (std::ifstream f{bat / "time_to_full_now"}) { - time_to_full_now_exists = true; - f >> time_to_full_now; + if (f >> time_to_full_now) { + time_to_full_now_exists = true; + } } uint32_t voltage_now = 0; bool voltage_now_exists = false; if (std::ifstream voltage_now_f{bat / "voltage_now"}) { - voltage_now_exists = true; - voltage_now_f >> voltage_now; + if (voltage_now_f >> voltage_now) { + voltage_now_exists = true; + } } else if (std::ifstream voltage_avg_f{bat / "voltage_avg"}) { - voltage_now_exists = true; - voltage_avg_f >> voltage_now; + if (voltage_avg_f >> voltage_now) { + voltage_now_exists = true; + } } uint32_t charge_full = 0; bool charge_full_exists = false; if (std::ifstream f{bat / "charge_full"}) { - charge_full_exists = true; - f >> charge_full; + if (f >> charge_full) { + charge_full_exists = true; + } } uint32_t charge_full_design = 0; bool charge_full_design_exists = false; if (std::ifstream f{bat / "charge_full_design"}) { - charge_full_design_exists = true; - f >> charge_full_design; + if (f >> charge_full_design) { + charge_full_design_exists = true; + } } uint32_t charge_now = 0; bool charge_now_exists = false; if (std::ifstream f{bat / "charge_now"}) { - charge_now_exists = true; - f >> charge_now; + if (f >> charge_now) { + charge_now_exists = true; + } } uint32_t power_now = 0; int32_t _power_now_int = 0; bool power_now_exists = false; if (std::ifstream f{bat / "power_now"}) { - power_now_exists = true; - f >> _power_now_int; + if (f >> _power_now_int) { + power_now_exists = true; + } } // Some drivers (example: Qualcomm) exposes use a negative value when // discharging, positive value when charging. @@ -353,22 +371,25 @@ waybar::modules::Battery::getInfos() { uint32_t energy_now = 0; bool energy_now_exists = false; if (std::ifstream f{bat / "energy_now"}) { - energy_now_exists = true; - f >> energy_now; + if (f >> energy_now) { + energy_now_exists = true; + } } uint32_t energy_full = 0; bool energy_full_exists = false; if (std::ifstream f{bat / "energy_full"}) { - energy_full_exists = true; - f >> energy_full; + if (f >> energy_full) { + energy_full_exists = true; + } } uint32_t energy_full_design = 0; bool energy_full_design_exists = false; if (std::ifstream f{bat / "energy_full_design"}) { - energy_full_design_exists = true; - f >> energy_full_design; + if (f >> energy_full_design) { + energy_full_design_exists = true; + } } uint16_t cycleCount = 0; @@ -404,8 +425,9 @@ waybar::modules::Battery::getInfos() { capacity_exists = true; capacity = 100 * (uint64_t)energy_now / (uint64_t)energy_full; } else if (std::ifstream f{bat / "capacity"}) { - capacity_exists = true; - f >> capacity; + if (f >> capacity) { + capacity_exists = true; + } } if (!voltage_now_exists) { @@ -563,11 +585,31 @@ waybar::modules::Battery::getInfos() { if (online && current_status != "Discharging") status = "Plugged"; } + if (total_energy_exists && total_power_exists && total_power != 0) { + if (!smoothPowerEnable_) { + smooth_power_ = total_power; + } else { + if (status != old_status_raw_) { + smooth_power_ = total_power; + last_t_ = std::chrono::steady_clock::now(); + } else { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + double dt_s = + std::chrono::duration_cast >(now - last_t_).count(); + smooth_power_ = smooth_power_ + ((1 - std::exp(-dt_s / time_constant_s_)) * + (total_power - smooth_power_)); + last_t_ = now; + } + + old_status_raw_ = status; + } + } + float time_remaining{0.0f}; 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 (total_power != 0) time_remaining = (float)total_energy / total_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; @@ -585,11 +627,12 @@ waybar::modules::Battery::getInfos() { float calculated_capacity{0.0f}; if (total_capacity_exists) { - if (total_capacity > 0.0f) + if (total_capacity > 0.0f) { calculated_capacity = (float)total_capacity / batteries_.size(); - else if (total_energy_full_exists && total_energy_exists) { - if (total_energy_full > 0.0f) + } else if (total_energy_full_exists && total_energy_exists) { + if (total_energy_full > 0.0f) { calculated_capacity = ((float)total_energy * 100.0f / (float)total_energy_full); + } } } @@ -686,13 +729,20 @@ auto waybar::modules::Battery::update() -> void { if (status == "Unknown") { status = getAdapterStatus(capacity); } + auto state = getState(capacity, true); + bool adapter_online = false; + if (!adapter_.empty()) { + std::ifstream(adapter_ / "online") >> adapter_online; + } + if (config_["full-at-plugged"].asBool() && status == "Full" && adapter_online) { + status = "Plugged"; + } auto status_pretty = status; // Transform to lowercase and replace space with dash std::ranges::transform(status.begin(), status.end(), status.begin(), [](char ch) { return ch == ' ' ? '-' : std::tolower(ch); }); auto format = format_; - auto state = getState(capacity, true); processEvents(state, status, capacity); setBarClass(state); auto time_remaining_formatted = formatTimeRemaining(time_remaining); @@ -717,7 +767,7 @@ auto waybar::modules::Battery::update() -> void { } else if (config_["tooltip-format"].isString()) { tooltip_format = config_["tooltip-format"].asString(); } - label_.set_tooltip_markup( + setTooltipMarkup( fmt::format(fmt::runtime(tooltip_format), fmt::arg("timeTo", tooltip_text_default), fmt::arg("power", power), fmt::arg("capacity", capacity), fmt::arg("time", time_remaining_formatted), fmt::arg("cycles", cycles), @@ -740,7 +790,7 @@ auto waybar::modules::Battery::update() -> void { } else { event_box_.show(); auto icons = std::vector{status + "-" + state, status, state}; - label_.set_markup(fmt::format( + setLabelMarkup(fmt::format( fmt::runtime(format), fmt::arg("capacity", capacity), fmt::arg("power", power), fmt::arg("icon", getIcon(capacity, icons)), fmt::arg("time", time_remaining_formatted), fmt::arg("cycles", cycles), fmt::arg("health", fmt::format("{:.3}", health)))); diff --git a/src/modules/bluetooth.cpp b/src/modules/bluetooth.cpp index c59af3b5..4c9994f4 100644 --- a/src/modules/bluetooth.cpp +++ b/src/modules/bluetooth.cpp @@ -83,6 +83,95 @@ auto getUcharProperty(GDBusProxy* proxy, const char* property_name) -> unsigned return 0; } +auto isChildPath(const std::string& child, const std::string& parent) -> bool { + return child.starts_with(parent); +} + +// Returns the configured controller alias, accepting either the documented "controller" key or +// its "controller-alias" synonym ("controller-alias" takes precedence when both are set). +auto getConfiguredControllerAlias(const Json::Value& config) -> std::optional { + if (config["controller-alias"].isString()) { + return config["controller-alias"].asString(); + } + if (config["controller"].isString()) { + return config["controller"].asString(); + } + return std::nullopt; +} + +// 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 { + 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, 2000, nullptr, &error); + if (error != nullptr) { + g_error_free(error); + return std::nullopt; + } + if (gvar == nullptr) { + return std::nullopt; + } + + GVariant* value_array = g_variant_get_child_value(gvar, 0); + gsize n_elements; + const auto* data = static_cast( + g_variant_get_fixed_array(value_array, &n_elements, sizeof(guchar))); + + std::optional result; + if (data != nullptr && n_elements > 0) { + result = data[0]; + } + + g_variant_unref(value_array); + g_variant_unref(gvar); + return result; +} + +auto hasUserDescriptionDescriptor(GList* objects, const std::string& char_path, + const std::string& user_description_uuid) -> bool { + for (GList* n = objects; n != nullptr; n = n->next) { + GDBusObject* desc_object = G_DBUS_OBJECT(n->data); + std::string desc_path = g_dbus_object_get_object_path(desc_object); + + if (!isChildPath(desc_path, char_path)) { + continue; + } + + GDBusProxy* proxy_desc = + G_DBUS_PROXY(g_dbus_object_get_interface(desc_object, "org.bluez.GattDescriptor1")); + if (proxy_desc == nullptr) { + continue; + } + + auto desc_uuid = getOptionalStringProperty(proxy_desc, "UUID"); + g_object_unref(proxy_desc); + + if (desc_uuid.has_value() && + desc_uuid.value().find(user_description_uuid) != std::string::npos) { + return true; + } + } + return false; +} + } // namespace waybar::modules::Bluetooth::Bluetooth(const std::string& id, const Json::Value& config) @@ -99,9 +188,8 @@ waybar::modules::Bluetooth::Bluetooth(const std::string& id, const Json::Value& } if (cur_controller_ = findCurController(); !cur_controller_) { - if (config_["controller-alias"].isString()) { - spdlog::warn("no bluetooth controller found with alias '{}'", - config_["controller-alias"].asString()); + if (auto controller_alias = getConfiguredControllerAlias(config_)) { + spdlog::warn("no bluetooth controller found with alias '{}'", *controller_alias); } else { spdlog::warn("no bluetooth controller found"); } @@ -125,8 +213,6 @@ waybar::modules::Bluetooth::Bluetooth(const std::string& id, const Json::Value& #ifdef WANT_RFKILL rfkill_.on_update.connect(sigc::hide(sigc::mem_fun(*this, &Bluetooth::update))); #endif - - dp.emit(); } auto waybar::modules::Bluetooth::update() -> void { @@ -222,7 +308,7 @@ auto waybar::modules::Bluetooth::update() -> void { event_box_.hide(); } else { event_box_.show(); - label_.set_markup(fmt::format( + setLabelMarkup(fmt::format( fmt::runtime(format_), fmt::arg("status", state_), fmt::arg("num_connections", connected_devices_.size()), fmt::arg("controller_address", cur_controller_ ? cur_controller_->address : "null"), @@ -232,8 +318,9 @@ auto waybar::modules::Bluetooth::update() -> void { fmt::arg("device_address", cur_focussed_device_.address), fmt::arg("device_address_type", cur_focussed_device_.address_type), fmt::arg("device_alias", cur_focussed_device_.alias), fmt::arg("icon", icon_label), - fmt::arg("device_battery_percentage", - cur_focussed_device_.battery_percentage.value_or(0)))); + fmt::arg("device_battery_percentage", cur_focussed_device_.battery_percentage.value_or(0)), + fmt::arg("device_battery_percentage_peripheral", + cur_focussed_device_.battery_percentage_peripheral.value_or(0)))); } if (tooltipEnabled()) { @@ -258,7 +345,16 @@ auto waybar::modules::Bluetooth::update() -> void { fmt::runtime(enumerate_format), fmt::arg("device_address", dev.address), fmt::arg("device_address_type", dev.address_type), fmt::arg("device_alias", dev.alias), fmt::arg("icon", enumerate_icon), - fmt::arg("device_battery_percentage", dev.battery_percentage.value_or(0))); + fmt::arg("device_battery_percentage", dev.battery_percentage.value_or(0)), + fmt::arg("device_battery_percentage_peripheral", + dev.battery_percentage_peripheral.value_or(0)), + // Also accept the module-level placeholders so {status} etc. don't + // throw "argument not found" in an enumerate format (#4384). + fmt::arg("status", state_), fmt::arg("num_connections", connected_devices_.size()), + fmt::arg("controller_address", cur_controller_ ? cur_controller_->address : "null"), + fmt::arg("controller_address_type", + cur_controller_ ? cur_controller_->address_type : "null"), + fmt::arg("controller_alias", cur_controller_ ? cur_controller_->alias : "null")); } } device_enumerate_ = ss.str(); @@ -267,7 +363,7 @@ auto waybar::modules::Bluetooth::update() -> void { device_enumerate_.erase(0, 1); } } - label_.set_tooltip_markup(fmt::format( + setTooltipMarkup(fmt::format( fmt::runtime(tooltip_format), fmt::arg("status", state_), fmt::arg("num_connections", connected_devices_.size()), fmt::arg("controller_address", cur_controller_ ? cur_controller_->address : "null"), @@ -278,6 +374,8 @@ auto waybar::modules::Bluetooth::update() -> void { fmt::arg("device_address_type", cur_focussed_device_.address_type), fmt::arg("device_alias", cur_focussed_device_.alias), fmt::arg("icon", icon_tooltip), fmt::arg("device_battery_percentage", cur_focussed_device_.battery_percentage.value_or(0)), + fmt::arg("device_battery_percentage_peripheral", + cur_focussed_device_.battery_percentage_peripheral.value_or(0)), fmt::arg("device_enumerate", device_enumerate_))); } @@ -290,9 +388,9 @@ auto waybar::modules::Bluetooth::onObjectAdded(GDBusObjectManager* manager, GDBu ControllerInfo info; Bluetooth* bt = static_cast(user_data); + auto controller_alias = getConfiguredControllerAlias(bt->config_); if (!bt->cur_controller_.has_value() && bt->getControllerProperties(object, info) && - (!bt->config_["controller-alias"].isString() || - bt->config_["controller-alias"].asString() == info.alias)) { + (!controller_alias.has_value() || controller_alias.value() == info.alias)) { bt->cur_controller_ = std::move(info); bt->dp.emit(); } @@ -398,6 +496,87 @@ auto waybar::modules::Bluetooth::getDeviceBatteryPercentage(GDBusObject* object) return std::nullopt; } +auto waybar::modules::Bluetooth::getDeviceGattBatteryLevels( + GDBusObject* device_object, std::optional& central_battery, + std::optional& peripheral_battery) -> void { + const std::string BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"; + const std::string BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"; + const std::string USER_DESCRIPTION_UUID = "00002901-0000-1000-8000-00805f9b34fb"; + + GList* objects = g_dbus_object_manager_get_objects(manager_.get()); + std::string device_path = g_dbus_object_get_object_path(device_object); + + for (GList* l = objects; l != nullptr; l = l->next) { + GDBusObject* service_object = G_DBUS_OBJECT(l->data); + std::string service_path = g_dbus_object_get_object_path(service_object); + + if (!isChildPath(service_path, device_path)) { + continue; + } + + GDBusProxy* proxy_service = + G_DBUS_PROXY(g_dbus_object_get_interface(service_object, "org.bluez.GattService1")); + if (proxy_service == nullptr) { + continue; + } + + auto service_uuid = getOptionalStringProperty(proxy_service, "UUID"); + g_object_unref(proxy_service); + + if (!service_uuid.has_value() || + service_uuid.value().find(BATTERY_SERVICE_UUID) == std::string::npos) { + continue; + } + + processBatteryServiceCharacteristics(objects, service_path, BATTERY_LEVEL_UUID, + USER_DESCRIPTION_UUID, central_battery, + peripheral_battery); + } + + g_list_free_full(objects, g_object_unref); +} + +auto waybar::modules::Bluetooth::processBatteryServiceCharacteristics( + GList* objects, const std::string& service_path, const std::string& battery_level_uuid, + const std::string& user_description_uuid, std::optional& central_battery, + std::optional& peripheral_battery) -> void { + for (GList* m = objects; m != nullptr; m = m->next) { + GDBusObject* char_object = G_DBUS_OBJECT(m->data); + std::string char_path = g_dbus_object_get_object_path(char_object); + + if (!isChildPath(char_path, service_path)) { + continue; + } + + GDBusProxy* proxy_char = + G_DBUS_PROXY(g_dbus_object_get_interface(char_object, "org.bluez.GattCharacteristic1")); + if (proxy_char == nullptr) { + continue; + } + + auto char_uuid = getOptionalStringProperty(proxy_char, "UUID"); + if (!char_uuid.has_value() || char_uuid.value().find(battery_level_uuid) == std::string::npos) { + g_object_unref(proxy_char); + continue; + } + + auto battery_value = readBatteryCharacteristicValue(proxy_char); + g_object_unref(proxy_char); + + if (!battery_value.has_value()) { + continue; + } + + if (hasUserDescriptionDescriptor(objects, char_path, user_description_uuid)) { + peripheral_battery = battery_value.value(); + } 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(); + } + } +} + auto waybar::modules::Bluetooth::getDeviceProperties(GDBusObject* object, DeviceInfo& device_info) -> bool { GDBusProxy* proxy_device = G_DBUS_PROXY(g_dbus_object_get_interface(object, "org.bluez.Device1")); @@ -418,6 +597,20 @@ auto waybar::modules::Bluetooth::getDeviceProperties(GDBusObject* object, Device g_object_unref(proxy_device); device_info.battery_percentage = getDeviceBatteryPercentage(object); + // 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 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; } @@ -457,9 +650,9 @@ auto waybar::modules::Bluetooth::findCurController() -> std::optionalnext) { GDBusObject* object = G_DBUS_OBJECT(l->data); ControllerInfo info; + auto controller_alias = getConfiguredControllerAlias(config_); if (getControllerProperties(object, info) && - (!config_["controller-alias"].isString() || - config_["controller-alias"].asString() == info.alias)) { + (!controller_alias.has_value() || controller_alias.value() == info.alias)) { controller_info = std::move(info); break; } diff --git a/src/modules/cava/cavaGLSL.cpp b/src/modules/cava/cavaGLSL.cpp index 4ab03bcd..755c6acd 100644 --- a/src/modules/cava/cavaGLSL.cpp +++ b/src/modules/cava/cavaGLSL.cpp @@ -2,88 +2,214 @@ #include +#include +#include +#include #include +#include +#include + +const std::map + waybar::modules::cava::CavaGLSL::actionMap_{{"mode", &CavaGLSL::pauseResume}}; waybar::modules::cava::CavaGLSL::CavaGLSL(const std::string& id, const Json::Value& config) : AModule(config, "cavaGLSL", id, false, false), backend_{waybar::modules::cava::CavaBackend::inst(config)} { - set_name(name_); + gl_area_.set_name(name_); if (config_["hide_on_silence"].isBool()) hide_on_silence_ = config_["hide_on_silence"].asBool(); if (!id.empty()) { - get_style_context()->add_class(id); + gl_area_.get_style_context()->add_class(id); } - get_style_context()->add_class(MODULE_CLASS); + gl_area_.get_style_context()->add_class(MODULE_CLASS); - set_use_es(true); - // set_auto_render(true); - signal_realize().connect(sigc::mem_fun(*this, &CavaGLSL::onRealize)); - signal_render().connect(sigc::mem_fun(*this, &CavaGLSL::onRender), false); + gl_area_.set_use_es(true); + gl_area_.signal_realize().connect(sigc::mem_fun(*this, &CavaGLSL::onRealize)); + gl_area_.signal_render().connect(sigc::mem_fun(*this, &CavaGLSL::onRender), false); + gl_area_.signal_map().connect([this]() { mapped_ = true; }); + gl_area_.signal_unmap().connect([this]() { mapped_ = false; }); - // Get parameters_config struct from the backend - prm_ = *backend_->getPrm(); + cacheConfigParams(backend_->getPrm()); - // Set widget length int length{0}; if (config_["min-length"].isUInt()) length = config_["min-length"].asUInt(); else if (config_["max-length"].isUInt()) length = config_["max-length"].asUInt(); else - length = prm_.sdl_width; + length = sdl_width_; - set_size_request(length, prm_.sdl_height); + gl_area_.set_size_request(length, sdl_height_); - // Subscribe for changes - backend_->signal_audio_raw_update().connect(sigc::mem_fun(*this, &CavaGLSL::onUpdate)); - // Subscribe for silence - backend_->signal_silence().connect(sigc::mem_fun(*this, &CavaGLSL::onSilence)); - event_box_.add(*this); + audio_raw_update_conn_ = + backend_->signalAudioRawUpdate().connect(sigc::mem_fun(*this, &CavaGLSL::onUpdate)); + silence_conn_ = backend_->signalSilence().connect(sigc::mem_fun(*this, &CavaGLSL::onSilence)); + config_changed_conn_ = + backend_->signalConfigChanged().connect(sigc::mem_fun(*this, &CavaGLSL::onBackendConfigChanged)); + event_box_.add(gl_area_); } -auto waybar::modules::cava::CavaGLSL::onUpdate(const ::cava::audio_raw& input) -> void { - Glib::signal_idle().connect_once([this, input]() { - m_data_ = std::make_shared<::cava::audio_raw>(input); - if (silence_) { - get_style_context()->remove_class("silent"); - if (!get_style_context()->has_class("updated")) get_style_context()->add_class("updated"); - show(); - silence_ = false; - } +waybar::modules::cava::CavaGLSL::~CavaGLSL() { + audio_raw_update_conn_.disconnect(); + silence_conn_.disconnect(); + config_changed_conn_.disconnect(); - queue_render(); - }); + if (gl_area_.get_realized()) { + gl_area_.make_current(); + cleanupGL(); + } +} + +void waybar::modules::cava::CavaGLSL::cleanupGL() { + if (shaderProgram_ != 0) { + glDeleteProgram(shaderProgram_); + shaderProgram_ = 0; + } + if (fbo_ != 0) { + glDeleteFramebuffers(1, &fbo_); + fbo_ = 0; + } + if (texture_ != 0) { + glDeleteTextures(1, &texture_); + texture_ = 0; + } + if (vbo_ != 0) { + glDeleteBuffers(1, &vbo_); + vbo_ = 0; + } + if (ibo_ != 0) { + glDeleteBuffers(1, &ibo_); + ibo_ = 0; + } + if (vao_ != 0) { + glDeleteVertexArrays(1, &vao_); + vao_ = 0; + } +} + +auto waybar::modules::cava::CavaGLSL::doAction(const std::string& name) -> void { + auto it = actionMap_.find(name); + if (it != actionMap_.end() && it->second) { + (this->*it->second)(); + } else { + spdlog::error("CavaGLSL. Unsupported action \"{0}\"", name); + } +} + +void waybar::modules::cava::CavaGLSL::pauseResume() { backend_->doPauseResume(); } + +auto waybar::modules::cava::CavaGLSL::onUpdate(const CavaBackend::AudioRaw& input) -> void { + m_data_ = input; + if (silence_) { + gl_area_.get_style_context()->remove_class("silent"); + if (!gl_area_.get_style_context()->has_class("updated")) + gl_area_.get_style_context()->add_class("updated"); + gl_area_.show(); + silence_ = false; + } + + if (mapped_) { + gl_area_.queue_render(); + } } auto waybar::modules::cava::CavaGLSL::onSilence() -> void { - Glib::signal_idle().connect_once([this]() { - if (!silence_) { - if (get_style_context()->has_class("updated")) get_style_context()->remove_class("updated"); + if (!silence_) { + if (gl_area_.get_style_context()->has_class("updated")) + gl_area_.get_style_context()->remove_class("updated"); - if (hide_on_silence_) hide(); - silence_ = true; - get_style_context()->add_class("silent"); - // Set clear color to black - glClearColor(0.0f, 0.0f, 0.0f, 1.0f); - queue_render(); + if (hide_on_silence_) gl_area_.hide(); + silence_ = true; + gl_area_.get_style_context()->add_class("silent"); + } +} + +void waybar::modules::cava::CavaGLSL::cacheConfigParams(const ::cava::config_params& src) { + sdl_width_ = src.sdl_width; + sdl_height_ = src.sdl_height; + bar_width_ = src.bar_width; + bar_spacing_ = src.bar_spacing; + gradient_count_ = src.gradient_count; + vertex_shader_ = src.vertex_shader ? src.vertex_shader : ""; + fragment_shader_ = src.fragment_shader ? src.fragment_shader : ""; + bcolor_ = src.bcolor ? src.bcolor : ""; + color_ = src.color ? src.color : ""; + for (size_t i = 0; i < gradient_colors_.size(); ++i) { + gradient_colors_[i] = (src.gradient_colors[i] ? src.gradient_colors[i] : ""); + } +} + +auto waybar::modules::cava::CavaGLSL::onBackendConfigChanged() -> void { + auto new_prm = backend_->getPrm(); + + bool dimensions_changed = + (new_prm.sdl_width != sdl_width_) || (new_prm.sdl_height != sdl_height_); + + bool shaders_changed = false; + std::string new_vertex = new_prm.vertex_shader ? new_prm.vertex_shader : ""; + std::string new_fragment = new_prm.fragment_shader ? new_prm.fragment_shader : ""; + shaders_changed = (vertex_shader_ != new_vertex) || (fragment_shader_ != new_fragment); + + bool surface_changed = false; + if (new_prm.bar_width != bar_width_) surface_changed = true; + if (new_prm.bar_spacing != bar_spacing_) surface_changed = true; + if (new_prm.gradient_count != gradient_count_) surface_changed = true; + std::string new_bcolor = new_prm.bcolor ? new_prm.bcolor : ""; + if (bcolor_ != new_bcolor) surface_changed = true; + std::string new_color = new_prm.color ? new_prm.color : ""; + if (color_ != new_color) surface_changed = true; + for (size_t i = 0; i < gradient_colors_.size(); ++i) { + std::string new_grad = new_prm.gradient_colors[i] ? new_prm.gradient_colors[i] : ""; + if (gradient_colors_[i] != new_grad) { + surface_changed = true; + break; } - }); + } + + cacheConfigParams(new_prm); + + if ((dimensions_changed || shaders_changed) && gl_area_.get_realized()) { + gl_area_.make_current(); + cleanupGL(); + initShaders(); + if (shaderProgram_ != 0) { + initGLSL(); + initSurface(); + } + } else if (surface_changed && gl_area_.get_realized()) { + gl_area_.make_current(); + glUseProgram(shaderProgram_); + initSurface(); + } + + int length{0}; + if (config_["min-length"].isUInt()) + length = config_["min-length"].asUInt(); + else if (config_["max-length"].isUInt()) + length = config_["max-length"].asUInt(); + else + length = sdl_width_; + + gl_area_.set_size_request(length, sdl_height_); } bool waybar::modules::cava::CavaGLSL::onRender(const Glib::RefPtr& context) { - if (!m_data_) return true; + if (m_data_.bars_raw.empty() || shaderProgram_ == 0) return true; + + glUseProgram(shaderProgram_); + glBindVertexArray(vao_); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture_); - glUniform1i(glGetUniformLocation(shaderProgram_, "inputTexture"), 0); + glUniform1i(uniform_input_texture_, 0); - glUniform1fv(uniform_bars_, m_data_->number_of_bars, m_data_->bars_raw); - glUniform1fv(uniform_previous_bars_, m_data_->number_of_bars, m_data_->previous_bars_raw); - glUniform1i(uniform_bars_count_, m_data_->number_of_bars); - ++frame_counter; - glUniform1f(uniform_time_, (frame_counter / backend_->getFrameTimeMilsec().count()) / 1e3); + glUniform1fv(uniform_bars_, m_data_.number_of_bars, m_data_.bars_raw.data()); + glUniform1fv(uniform_previous_bars_, m_data_.number_of_bars, m_data_.previous_bars_raw.data()); + glUniform1i(uniform_bars_count_, m_data_.number_of_bars); + ++frame_counter_; + glUniform1f(uniform_time_, + static_cast(frame_counter_) * backend_->getFrameTimeMilsec().count() / 1000.0f); - // glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glDrawElements(GL_TRIANGLE_FAN, 4, GL_UNSIGNED_INT, nullptr); glBindFramebuffer(GL_FRAMEBUFFER, fbo_); @@ -94,21 +220,35 @@ bool waybar::modules::cava::CavaGLSL::onRender(const Glib::RefPtrR, &color->G, &color->B); +static void parse_color(const char* color_string, struct Colors* color) { + if (color_string == nullptr) { + return; + } + if (color_string[0] != '#') { + return; + } + if (std::strlen(color_string) < 7) { + spdlog::warn("Invalid color string '{}': expected #RRGGBB", color_string); + return; + } + if (std::sscanf(color_string + 1, "%02hx%02hx%02hx", &color->R, &color->G, &color->B) != 3) { + spdlog::warn("Failed to parse color string '{}'", color_string); } } @@ -123,49 +263,44 @@ void waybar::modules::cava::CavaGLSL::initGLSL() { GLfloat vertexData[]{-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f}; GLint indexData[]{0, 1, 2, 3}; - GLuint gVBO{0}; - glGenBuffers(1, &gVBO); - glBindBuffer(GL_ARRAY_BUFFER, gVBO); + glGenBuffers(1, &vbo_); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBufferData(GL_ARRAY_BUFFER, 2 * 4 * sizeof(GLfloat), vertexData, GL_STATIC_DRAW); - GLuint gIBO{0}; - glGenBuffers(1, &gIBO); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gIBO); + glGenBuffers(1, &ibo_); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo_); glBufferData(GL_ELEMENT_ARRAY_BUFFER, 4 * sizeof(GLuint), indexData, GL_STATIC_DRAW); - GLuint gVAO{0}; - glGenVertexArrays(1, &gVAO); - glBindVertexArray(gVAO); + glGenVertexArrays(1, &vao_); + glBindVertexArray(vao_); glEnableVertexAttribArray(gVertexPos2DLocation); - glBindBuffer(GL_ARRAY_BUFFER, gVBO); + glBindBuffer(GL_ARRAY_BUFFER, vbo_); glVertexAttribPointer(gVertexPos2DLocation, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(GLfloat), nullptr); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gIBO); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo_); glGenFramebuffers(1, &fbo_); glBindFramebuffer(GL_FRAMEBUFFER, fbo_); - // Create a texture to attach the framebuffer glGenTextures(1, &texture_); glBindTexture(GL_TEXTURE_2D, texture_); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, prm_.sdl_width, prm_.sdl_height, 0, GL_RGBA, + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, sdl_width_, sdl_height_, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture_, 0); - // Check is framebuffer is complete if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { spdlog::error("{0}. Framebuffer not complete", name_); } - // Unbind the framebuffer glBindFramebuffer(GL_FRAMEBUFFER, 0); uniform_bars_ = glGetUniformLocation(shaderProgram_, "bars"); uniform_previous_bars_ = glGetUniformLocation(shaderProgram_, "previous_bars"); uniform_bars_count_ = glGetUniformLocation(shaderProgram_, "bars_count"); uniform_time_ = glGetUniformLocation(shaderProgram_, "shader_time"); + uniform_input_texture_ = glGetUniformLocation(shaderProgram_, "inputTexture"); GLuint err{glGetError()}; if (err != 0) { @@ -174,32 +309,32 @@ void waybar::modules::cava::CavaGLSL::initGLSL() { } void waybar::modules::cava::CavaGLSL::initSurface() { - colors color = {0}; + Colors color = {0}; GLint uniform_bg_col{glGetUniformLocation(shaderProgram_, "bg_color")}; - parse_color(prm_.bcolor, &color); - glUniform3f(uniform_bg_col, (float)color.R / 255.0, (float)color.G / 255.0, - (float)color.B / 255.0); + parse_color(bcolor_.c_str(), &color); + glUniform3f(uniform_bg_col, static_cast(color.R) / 255.0f, static_cast(color.G) / 255.0f, + static_cast(color.B) / 255.0f); GLint uniform_fg_col{glGetUniformLocation(shaderProgram_, "fg_color")}; - parse_color(prm_.color, &color); - glUniform3f(uniform_fg_col, (float)color.R / 255.0, (float)color.G / 255.0, - (float)color.B / 255.0); + parse_color(color_.c_str(), &color); + glUniform3f(uniform_fg_col, static_cast(color.R) / 255.0f, static_cast(color.G) / 255.0f, + static_cast(color.B) / 255.0f); GLint uniform_res{glGetUniformLocation(shaderProgram_, "u_resolution")}; - glUniform3f(uniform_res, (float)prm_.sdl_width, (float)prm_.sdl_height, 0.0f); + glUniform3f(uniform_res, static_cast(sdl_width_), static_cast(sdl_height_), 0.0f); GLint uniform_bar_width{glGetUniformLocation(shaderProgram_, "bar_width")}; - glUniform1i(uniform_bar_width, prm_.bar_width); + glUniform1i(uniform_bar_width, bar_width_); GLint uniform_bar_spacing{glGetUniformLocation(shaderProgram_, "bar_spacing")}; - glUniform1i(uniform_bar_spacing, prm_.bar_spacing); + glUniform1i(uniform_bar_spacing, bar_spacing_); GLint uniform_gradient_count{glGetUniformLocation(shaderProgram_, "gradient_count")}; - glUniform1i(uniform_gradient_count, prm_.gradient_count); + glUniform1i(uniform_gradient_count, std::max(0, gradient_count_)); GLint uniform_gradient_colors{glGetUniformLocation(shaderProgram_, "gradient_colors")}; - GLfloat gradient_colors[8][3]; - for (int i{0}; i < prm_.gradient_count; ++i) { - parse_color(prm_.gradient_colors[i], &color); - gradient_colors[i][0] = (float)color.R / 255.0; - gradient_colors[i][1] = (float)color.G / 255.0; - gradient_colors[i][2] = (float)color.B / 255.0; + GLfloat gradient_colors[8][3] = {}; + for (int i{0}; i < gradient_count_; ++i) { + parse_color(gradient_colors_[i].c_str(), &color); + gradient_colors[i][0] = static_cast(color.R) / 255.0f; + gradient_colors[i][1] = static_cast(color.G) / 255.0f; + gradient_colors[i][2] = static_cast(color.B) / 255.0f; } - glUniform3fv(uniform_gradient_colors, 8, (const GLfloat*)gradient_colors); + glUniform3fv(uniform_gradient_colors, std::max(0, std::min(gradient_count_, 8)), &gradient_colors[0][0]); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glDrawElements(GL_TRIANGLE_FAN, 4, GL_UNSIGNED_INT, nullptr); @@ -207,9 +342,23 @@ void waybar::modules::cava::CavaGLSL::initSurface() { void waybar::modules::cava::CavaGLSL::initShaders() { shaderProgram_ = glCreateProgram(); + if (shaderProgram_ == 0) { + spdlog::error("{0}. Failed to create shader program", name_); + gl_area_.hide(); + return; + } - GLuint vertexShader{loadShader(prm_.vertex_shader, GL_VERTEX_SHADER)}; - GLuint fragmentShader{loadShader(prm_.fragment_shader, GL_FRAGMENT_SHADER)}; + GLuint vertexShader{loadShader(vertex_shader_, GL_VERTEX_SHADER)}; + GLuint fragmentShader{loadShader(fragment_shader_, GL_FRAGMENT_SHADER)}; + + if (vertexShader == 0 || fragmentShader == 0) { + if (vertexShader != 0) glDeleteShader(vertexShader); + if (fragmentShader != 0) glDeleteShader(fragmentShader); + glDeleteProgram(shaderProgram_); + shaderProgram_ = 0; + gl_area_.hide(); + return; + } glAttachShader(shaderProgram_, vertexShader); glAttachShader(shaderProgram_, fragmentShader); @@ -219,14 +368,18 @@ void waybar::modules::cava::CavaGLSL::initShaders() { glDeleteShader(vertexShader); glDeleteShader(fragmentShader); - // Check for linking errors - GLint success, len; + GLint success{0}; glGetProgramiv(shaderProgram_, GL_LINK_STATUS, &success); if (!success) { + GLint len{0}; glGetProgramiv(shaderProgram_, GL_INFO_LOG_LENGTH, &len); - GLchar* infoLog{(char*)'\0'}; - glGetProgramInfoLog(shaderProgram_, len, &len, infoLog); - spdlog::error("{0}. Shader linking error: {1}", name_, infoLog); + std::vector infoLog(len + 1); + glGetProgramInfoLog(shaderProgram_, len, nullptr, infoLog.data()); + spdlog::error("{0}. Shader linking error: {1}", name_, infoLog.data()); + glDeleteProgram(shaderProgram_); + shaderProgram_ = 0; + gl_area_.hide(); + return; } glReleaseShaderCompiler(); @@ -236,35 +389,37 @@ void waybar::modules::cava::CavaGLSL::initShaders() { GLuint waybar::modules::cava::CavaGLSL::loadShader(const std::string& fileName, GLenum type) { spdlog::debug("{0}. loadShader: {1}", name_, fileName); - // Read shader source code from the file std::ifstream shaderFile{fileName}; - if (!shaderFile.is_open()) { spdlog::error("{0}. Could not open shader file: {1}", name_, fileName); + return 0; } std::ostringstream buffer; - buffer << shaderFile.rdbuf(); // read file content into stringstream + buffer << shaderFile.rdbuf(); std::string str{buffer.str()}; - const char* shaderSource = str.c_str(); shaderFile.close(); GLuint shaderID{glCreateShader(type)}; - if (shaderID == 0) spdlog::error("{0}. Error creating shader type: {0}", type); + if (shaderID == 0) { + spdlog::error("{0}. Error creating shader type: {1}", name_, type); + return 0; + } + + const char* shaderSource = str.c_str(); glShaderSource(shaderID, 1, &shaderSource, nullptr); glCompileShader(shaderID); - // Check for compilation errors - GLint success, len; - + GLint success{0}; glGetShaderiv(shaderID, GL_COMPILE_STATUS, &success); - if (!success) { + GLint len{0}; glGetShaderiv(shaderID, GL_INFO_LOG_LENGTH, &len); - - GLchar* infoLog{(char*)'\0'}; - glGetShaderInfoLog(shaderID, len, nullptr, infoLog); - spdlog::error("{0}. Shader compilation error in {1}: {2}", name_, fileName, infoLog); + std::vector infoLog(len + 1); + glGetShaderInfoLog(shaderID, len, nullptr, infoLog.data()); + spdlog::error("{0}. Shader compilation error in {1}: {2}", name_, fileName, infoLog.data()); + glDeleteShader(shaderID); + return 0; } return shaderID; diff --git a/src/modules/cava/cavaRaw.cpp b/src/modules/cava/cavaRaw.cpp index 2b0aca72..65ce093d 100644 --- a/src/modules/cava/cavaRaw.cpp +++ b/src/modules/cava/cavaRaw.cpp @@ -2,59 +2,68 @@ #include -waybar::modules::cava::Cava::Cava(const std::string& id, const Json::Value& config) +const std::map + waybar::modules::cava::CavaRaw::actionMap_{{"mode", &CavaRaw::pauseResume}}; + +waybar::modules::cava::CavaRaw::CavaRaw(const std::string& id, const Json::Value& config) : ALabel(config, "cava", id, "{}", 60, false, false, false), backend_{waybar::modules::cava::CavaBackend::inst(config)} { if (config_["hide_on_silence"].isBool()) hide_on_silence_ = config_["hide_on_silence"].asBool(); if (config_["format_silent"].isString()) format_silent_ = config_["format_silent"].asString(); - ascii_range_ = backend_->getAsciiRange(); - backend_->signal_update().connect(sigc::mem_fun(*this, &Cava::onUpdate)); - backend_->signal_silence().connect(sigc::mem_fun(*this, &Cava::onSilence)); - backend_->Update(); + update_conn_ = backend_->signalUpdate().connect(sigc::mem_fun(*this, &CavaRaw::onUpdate)); + silence_conn_ = backend_->signalSilence().connect(sigc::mem_fun(*this, &CavaRaw::onSilence)); + backend_->update(); } -auto waybar::modules::cava::Cava::doAction(const std::string& name) -> void { - if ((actionMap_[name])) { - (this->*actionMap_[name])(); - } else +waybar::modules::cava::CavaRaw::~CavaRaw() { + update_conn_.disconnect(); + silence_conn_.disconnect(); +} + +auto waybar::modules::cava::CavaRaw::doAction(const std::string& name) -> void { + auto it = actionMap_.find(name); + if (it != actionMap_.end() && it->second) { + (this->*it->second)(); + } else { spdlog::error("Cava. Unsupported action \"{0}\"", name); + } } // Cava actions -void waybar::modules::cava::Cava::pause_resume() { backend_->doPauseResume(); } -auto waybar::modules::cava::Cava::onUpdate(const std::string& input) -> void { - Glib::signal_idle().connect_once([this, input]() { - if (silence_) { - silence_ = false; - label_.get_style_context()->remove_class("silent"); - if (!label_.get_style_context()->has_class("updated")) - label_.get_style_context()->add_class("updated"); - } - label_text_.clear(); - for (auto& ch : input) - label_text_.append(getIcon((ch > ascii_range_) ? ascii_range_ : ch, "", ascii_range_ + 1)); +void waybar::modules::cava::CavaRaw::pauseResume() { backend_->doPauseResume(); } +auto waybar::modules::cava::CavaRaw::onUpdate(const std::string& input) -> void { + if (silence_) { + silence_ = false; + label_.get_style_context()->remove_class("silent"); + if (!label_.get_style_context()->has_class("updated")) + label_.get_style_context()->add_class("updated"); + } + label_text_.clear(); + auto ascii_range = backend_->getAsciiRange(); + for (auto& ch : input) { + auto uch = static_cast(ch); + label_text_.append(getIcon((uch > ascii_range) ? ascii_range : uch, "", ascii_range + 1)); + } - label_.set_markup(label_text_); - label_.show(); - ALabel::update(); - }); + label_.set_markup(label_text_); + label_.show(); + ALabel::update(); } -auto waybar::modules::cava::Cava::onSilence() -> void { - Glib::signal_idle().connect_once([this]() { - if (!silence_) { - if (label_.get_style_context()->has_class("updated")) - label_.get_style_context()->remove_class("updated"); +auto waybar::modules::cava::CavaRaw::onSilence() -> void { + if (!silence_) { + if (label_.get_style_context()->has_class("updated")) + label_.get_style_context()->remove_class("updated"); - if (hide_on_silence_) { - // Clear the label markup before hiding to prevent GTK from rendering a NULL Pango layout - label_.set_markup(""); - label_.hide(); - } else if (config_["format_silent"].isString()) - label_.set_markup(format_silent_); - silence_ = true; - label_.get_style_context()->add_class("silent"); + if (hide_on_silence_) { + // Clear the label markup before hiding to prevent GTK from rendering a NULL Pango layout + label_.set_markup(""); + label_.hide(); + } else if (!format_silent_.empty()) { + label_.set_markup(format_silent_); } - }); + silence_ = true; + label_.get_style_context()->add_class("silent"); + } } diff --git a/src/modules/cava/cava_backend.cpp b/src/modules/cava/cava_backend.cpp index 6eb540c9..0d1f2bdf 100644 --- a/src/modules/cava/cava_backend.cpp +++ b/src/modules/cava/cava_backend.cpp @@ -2,6 +2,23 @@ #include +#include +#include + +namespace { +struct CavaConfigGuard { + ::cava::config_params* prm; + bool released = false; + explicit CavaConfigGuard(::cava::config_params* p) : prm(p) {} + ~CavaConfigGuard() { + if (!released && prm) { + free_config(prm); + } + } + void release() { released = true; } +}; +} // namespace + std::shared_ptr waybar::modules::cava::CavaBackend::inst( const Json::Value& config) { static auto* backend = new CavaBackend(config); @@ -10,63 +27,84 @@ std::shared_ptr waybar::modules::cava::CavaB } waybar::modules::cava::CavaBackend::CavaBackend(const Json::Value& config) : config_(config) { - // Load waybar module config loadConfig(); - // Read audio source trough cava API. Cava orginizes this process via infinity loop read_thread_ = [this] { - try { - input_source_(&audio_data_); - } catch (const std::runtime_error& e) { - spdlog::warn("Cava backend. Read source error: {0}", e.what()); + while (read_thread_.isRunning()) { + try { + if (input_source_) { + input_source_(&audio_data_); + } + } catch (const std::exception& e) { + spdlog::warn("Cava backend. Read source error: {0}", e.what()); + } + if (!read_thread_.isRunning()) break; + read_thread_.sleep_for(fetch_input_delay_); + if (!read_thread_.isRunning()) break; + try { + loadConfig(); + } catch (const std::exception& e) { + spdlog::error("{}", e.what()); + } } - read_thread_.sleep_for(fetch_input_delay_); - loadConfig(); + { + std::lock_guard lk(read_thread_exit_mutex_); + read_thread_exited_ = true; + } + read_thread_exit_cv_.notify_one(); }; - // Write outcoming data. Emit signals out_thread_ = [this] { - doUpdate(false); - out_thread_.sleep_for(frame_time_milsec_); + try { + doUpdate(false); + } catch (const std::exception& e) { + spdlog::error("Cava backend. Output thread error: {0}", e.what()); + } + out_thread_.sleep_for(adaptive_delay_.current()); }; } waybar::modules::cava::CavaBackend::~CavaBackend() { + { + std::lock_guard lock(state_mutex_); + shutdown_ = true; + } + + pthread_mutex_lock(&audio_data_.lock); + audio_data_.terminate = 1; + pthread_mutex_unlock(&audio_data_.lock); + out_thread_.stop(); read_thread_.stop(); + std::unique_lock lk(read_thread_exit_mutex_); + if (!read_thread_exit_cv_.wait_for(lk, std::chrono::milliseconds(100), + [this] { return read_thread_exited_; })) { + spdlog::debug( + "Cava backend: read thread still blocked in input_source() on shutdown. " + "Proceeding with cleanup."); + } + + std::lock_guard lock(state_mutex_); freeBackend(); } -static bool upThreadDelay(std::chrono::milliseconds& delay, std::chrono::seconds& delta) { - if (delta == std::chrono::seconds{0}) { - delta += std::chrono::seconds{1}; - delay += delta; - return true; - } - return false; -} - -static bool downThreadDelay(std::chrono::milliseconds& delay, std::chrono::seconds& delta) { - if (delta > std::chrono::seconds{0}) { - delay -= delta; - delta -= std::chrono::seconds{1}; - return true; - } - return false; -} - -bool waybar::modules::cava::CavaBackend::isSilence() { +bool waybar::modules::cava::CavaBackend::isSilent() { + pthread_mutex_lock(&audio_data_.lock); + bool silent = true; for (int i{0}; i < audio_data_.input_buffer_size; ++i) { if (audio_data_.cava_in[i]) { - return false; + silent = false; + break; } } - - return true; + pthread_mutex_unlock(&audio_data_.lock); + return silent; } -int waybar::modules::cava::CavaBackend::getAsciiRange() { return prm_.ascii_range; } +int waybar::modules::cava::CavaBackend::getAsciiRange() const { + std::lock_guard lock(state_mutex_); + return prm_.ascii_range; +} -// Process: execute cava void waybar::modules::cava::CavaBackend::invoke() { pthread_mutex_lock(&audio_data_.lock); ::cava::cava_execute(audio_data_.cava_in, audio_data_.samples_counter, audio_raw_.cava_out, @@ -75,7 +113,6 @@ void waybar::modules::cava::CavaBackend::invoke() { pthread_mutex_unlock(&audio_data_.lock); } -// Do transformation under raw data void waybar::modules::cava::CavaBackend::execute() { invoke(); audio_raw_fetch(&audio_raw_, &prm_, &re_paint_, plan_); @@ -91,183 +128,226 @@ void waybar::modules::cava::CavaBackend::execute() { } void waybar::modules::cava::CavaBackend::doPauseResume() { + std::lock_guard lock(state_mutex_); pthread_mutex_lock(&audio_data_.lock); if (audio_data_.suspendFlag) { audio_data_.suspendFlag = false; pthread_cond_broadcast(&audio_data_.resumeCond); - downThreadDelay(frame_time_milsec_, suspend_silence_delay_); + adaptive_delay_.decrease(); } else { audio_data_.suspendFlag = true; - upThreadDelay(frame_time_milsec_, suspend_silence_delay_); + adaptive_delay_.increase(); } pthread_mutex_unlock(&audio_data_.lock); - Update(); + update(); } -waybar::modules::cava::CavaBackend::type_signal_update -waybar::modules::cava::CavaBackend::signal_update() { +waybar::modules::cava::CavaBackend::SignalUpdate& +waybar::modules::cava::CavaBackend::signalUpdate() { return m_signal_update_; } -waybar::modules::cava::CavaBackend::type_signal_audio_raw_update -waybar::modules::cava::CavaBackend::signal_audio_raw_update() { +waybar::modules::cava::CavaBackend::SignalAudioRawUpdate& +waybar::modules::cava::CavaBackend::signalAudioRawUpdate() { return m_signal_audio_raw_; } -waybar::modules::cava::CavaBackend::type_signal_silence -waybar::modules::cava::CavaBackend::signal_silence() { +waybar::modules::cava::CavaBackend::SignalSilence& +waybar::modules::cava::CavaBackend::signalSilence() { return m_signal_silence_; } -void waybar::modules::cava::CavaBackend::Update() { doUpdate(true); } +waybar::modules::cava::CavaBackend::SignalConfigChanged& +waybar::modules::cava::CavaBackend::signalConfigChanged() { + return m_signal_config_changed_; +} + +void waybar::modules::cava::CavaBackend::update() { doUpdate(true); } void waybar::modules::cava::CavaBackend::doUpdate(bool force) { + std::lock_guard lock(state_mutex_); + if (!plan_ || !input_source_) return; if (audio_data_.suspendFlag && !force) return; - silence_ = isSilence(); + silence_ = isSilent(); if (!silence_) sleep_counter_ = 0; if (silence_ && prm_.sleep_timer != 0) { if (sleep_counter_ <= - (int)(std::chrono::milliseconds(prm_.sleep_timer * 1s) / frame_time_milsec_)) { + static_cast(std::chrono::milliseconds(prm_.sleep_timer * std::chrono::seconds(1)) / + adaptive_delay_.current())) { ++sleep_counter_; silence_ = false; } } if (!silence_ || prm_.sleep_timer == 0) { - if (downThreadDelay(frame_time_milsec_, suspend_silence_delay_)) Update(); + while (adaptive_delay_.decrease()) {} execute(); if (re_paint_ == 1 || force || prm_.continuous_rendering) { m_signal_update_.emit(output_); - m_signal_audio_raw_.emit(audio_raw_); + m_signal_audio_raw_.emit(AudioRaw{audio_raw_}); } } else { - if (upThreadDelay(frame_time_milsec_, suspend_silence_delay_)) Update(); + while (adaptive_delay_.increase()) {} if (silence_ != silence_prev_ || force) m_signal_silence_.emit(); } silence_prev_ = silence_; } void waybar::modules::cava::CavaBackend::freeBackend() { - if (plan_ != NULL) { + input_source_ = nullptr; + + if (plan_ != nullptr) { cava_destroy(plan_); - plan_ = NULL; + plan_ = nullptr; } - audio_raw_clean(&audio_raw_); + if (audio_raw_initialized_) { + audio_raw_clean(&audio_raw_); + audio_raw_initialized_ = false; + } pthread_mutex_lock(&audio_data_.lock); audio_data_.terminate = 1; pthread_mutex_unlock(&audio_data_.lock); free_config(&prm_); + prm_ = {}; free(audio_data_.source); + audio_data_.source = nullptr; free(audio_data_.cava_in); + audio_data_.cava_in = nullptr; } void waybar::modules::cava::CavaBackend::loadConfig() { - freeBackend(); - // Load waybar module config - char cfgPath[PATH_MAX]; - cfgPath[0] = '\0'; + std::lock_guard lock(state_mutex_); + if (shutdown_.load()) { + return; + } - if (config_["cava_config"].isString()) strcpy(cfgPath, config_["cava_config"].asString().data()); - // Load cava config + const Json::Value& cfg = config_; + + struct ::cava::config_params new_prm{}; + CavaConfigGuard new_prm_guard(&new_prm); + std::vector cfgPath(PATH_MAX, '\0'); + if (cfg["cava_config"].isString()) { + const std::string& s = cfg["cava_config"].asString(); + auto len = std::min(s.size(), static_cast(PATH_MAX - 1)); + std::copy_n(s.begin(), len, cfgPath.begin()); + cfgPath[len] = '\0'; + } error_.length = 0; - if (!load_config(cfgPath, &prm_, &error_)) { - spdlog::error("cava backend. Error loading config. {0}", error_.message); - exit(EXIT_FAILURE); + if (!load_config(cfgPath.data(), &new_prm, &error_)) { + throw std::runtime_error(std::string{"cava backend: error loading config: "} + error_.message); } - // Override cava parameters by the user config - prm_.inAtty = 0; - auto const output{prm_.output}; - // prm_.output = ::cava::output_method::OUTPUT_RAW; - if (prm_.data_format) free(prm_.data_format); - // Default to ascii for format-icons output; allow user override - prm_.data_format = strdup( - config_["data_format"].isString() ? config_["data_format"].asString().c_str() : "ascii"); - if (config_["raw_target"].isString()) { - if (prm_.raw_target) free(prm_.raw_target); - prm_.raw_target = strdup(config_["raw_target"].asString().c_str()); + new_prm.inAtty = 0; + auto const output{new_prm.output}; + if (new_prm.data_format) free(new_prm.data_format); + new_prm.data_format = strdup( + cfg["data_format"].isString() ? cfg["data_format"].asString().c_str() : "ascii"); + if (cfg["raw_target"].isString()) { + if (new_prm.raw_target) free(new_prm.raw_target); + new_prm.raw_target = strdup(cfg["raw_target"].asString().c_str()); } - prm_.ascii_range = config_["format-icons"].size() - 1; - - if (config_["bar_spacing"].isInt()) prm_.bar_spacing = config_["bar_spacing"].asInt(); - if (config_["bar_width"].isInt()) prm_.bar_width = config_["bar_width"].asInt(); - if (config_["bar_height"].isInt()) prm_.bar_height = config_["bar_height"].asInt(); - prm_.orientation = ::cava::ORIENT_TOP; - prm_.xaxis = ::cava::xaxis_scale::NONE; - prm_.mono_opt = ::cava::AVERAGE; - prm_.autobars = 0; - if (config_["gravity"].isInt()) prm_.gravity = config_["gravity"].asInt(); - if (config_["integral"].isInt()) prm_.integral = config_["integral"].asInt(); - - if (config_["framerate"].isInt()) prm_.framerate = config_["framerate"].asInt(); - // Calculate delay for Update() thread - frame_time_milsec_ = std::chrono::milliseconds((int)(1e3 / prm_.framerate)); - if (config_["autosens"].isInt()) prm_.autosens = config_["autosens"].asInt(); - if (config_["sensitivity"].isInt()) prm_.sens = config_["sensitivity"].asInt(); - if (config_["bars"].isInt()) prm_.fixedbars = config_["bars"].asInt(); - if (config_["lower_cutoff_freq"].isNumeric()) - prm_.lower_cut_off = config_["lower_cutoff_freq"].asLargestInt(); - if (config_["higher_cutoff_freq"].isNumeric()) - prm_.upper_cut_off = config_["higher_cutoff_freq"].asLargestInt(); - if (config_["sleep_timer"].isInt()) prm_.sleep_timer = config_["sleep_timer"].asInt(); - if (config_["method"].isString()) - prm_.input = ::cava::input_method_by_name(config_["method"].asString().c_str()); - if (config_["source"].isString()) { - if (prm_.audio_source) free(prm_.audio_source); - prm_.audio_source = strdup(config_["source"].asString().c_str()); - } - if (config_["sample_rate"].isNumeric()) prm_.samplerate = config_["sample_rate"].asLargestInt(); - if (config_["sample_bits"].isInt()) prm_.samplebits = config_["sample_bits"].asInt(); - if (config_["stereo"].isBool()) prm_.stereo = config_["stereo"].asBool(); - if (config_["reverse"].isBool()) prm_.reverse = config_["reverse"].asBool(); - if (config_["bar_delimiter"].isInt()) prm_.bar_delim = config_["bar_delimiter"].asInt(); - if (config_["monstercat"].isBool()) prm_.monstercat = config_["monstercat"].asBool(); - if (config_["waves"].isBool()) prm_.waves = config_["waves"].asBool(); - if (config_["noise_reduction"].isDouble()) - prm_.noise_reduction = config_["noise_reduction"].asDouble(); - if (config_["input_delay"].isInt()) - fetch_input_delay_ = std::chrono::seconds(config_["input_delay"].asInt()); - if (config_["gradient"].isInt()) prm_.gradient = config_["gradient"].asInt(); - if (prm_.gradient == 0) - prm_.gradient_count = 0; - else if (config_["gradient_count"].isInt()) - prm_.gradient_count = config_["gradient_count"].asInt(); - if (config_["sdl_width"].isInt()) prm_.sdl_width = config_["sdl_width"].asInt(); - if (config_["sdl_height"].isInt()) prm_.sdl_height = config_["sdl_height"].asInt(); - - audio_raw_.height = prm_.ascii_range; - audio_data_.format = -1; - audio_data_.rate = 0; - audio_data_.samples_counter = 0; - audio_data_.channels = 2; - audio_data_.IEEE_FLOAT = 0; - audio_data_.input_buffer_size = BUFFER_SIZE * audio_data_.channels; - audio_data_.cava_buffer_size = audio_data_.input_buffer_size * 8; - audio_data_.terminate = 0; - audio_data_.suspendFlag = false; - input_source_ = get_input(&audio_data_, &prm_); - - if (!input_source_) { - spdlog::error("cava backend API didn't provide input audio source method"); - exit(EXIT_FAILURE); + { + auto icon_count = cfg["format-icons"].size(); + new_prm.ascii_range = (icon_count > 0) ? static_cast(icon_count) - 1 : 0; } - prm_.output = ::cava::output_method::OUTPUT_RAW; + if (cfg["bar_spacing"].isInt()) new_prm.bar_spacing = cfg["bar_spacing"].asInt(); + if (cfg["bar_width"].isInt()) new_prm.bar_width = cfg["bar_width"].asInt(); + if (cfg["bar_height"].isInt()) new_prm.bar_height = cfg["bar_height"].asInt(); + new_prm.orientation = ::cava::ORIENT_TOP; + new_prm.xaxis = ::cava::xaxis_scale::NONE; + new_prm.mono_opt = ::cava::AVERAGE; + new_prm.autobars = 0; - // Make cava parameters configuration - // Init cava plan, audio_raw structure - audio_raw_init(&audio_data_, &audio_raw_, &prm_, &plan_); - if (!plan_) spdlog::error("cava backend plan is not provided"); - audio_raw_.previous_frame[0] = -1; // For first Update() call need to rePaint text message + if (cfg["framerate"].isInt()) new_prm.framerate = cfg["framerate"].asInt(); + if (cfg["autosens"].isInt()) new_prm.autosens = cfg["autosens"].asInt(); + if (cfg["sensitivity"].isInt()) new_prm.sens = cfg["sensitivity"].asInt(); + if (cfg["bars"].isInt()) new_prm.fixedbars = cfg["bars"].asInt(); + if (cfg["lower_cutoff_freq"].isNumeric()) + new_prm.lower_cut_off = cfg["lower_cutoff_freq"].asLargestInt(); + if (cfg["higher_cutoff_freq"].isNumeric()) + new_prm.upper_cut_off = cfg["higher_cutoff_freq"].asLargestInt(); + if (cfg["sleep_timer"].isInt()) new_prm.sleep_timer = cfg["sleep_timer"].asInt(); + if (cfg["method"].isString()) + new_prm.input = ::cava::input_method_by_name(cfg["method"].asString().c_str()); + if (cfg["source"].isString()) { + if (new_prm.audio_source) free(new_prm.audio_source); + new_prm.audio_source = strdup(cfg["source"].asString().c_str()); + } + if (cfg["sample_rate"].isNumeric()) new_prm.samplerate = cfg["sample_rate"].asLargestInt(); + if (cfg["sample_bits"].isInt()) new_prm.samplebits = cfg["sample_bits"].asInt(); + if (cfg["stereo"].isBool()) new_prm.stereo = cfg["stereo"].asBool(); + if (cfg["reverse"].isBool()) new_prm.reverse = cfg["reverse"].asBool(); + if (cfg["bar_delimiter"].isInt()) new_prm.bar_delim = cfg["bar_delimiter"].asInt(); + if (cfg["monstercat"].isBool()) new_prm.monstercat = cfg["monstercat"].asBool(); + if (cfg["waves"].isBool()) new_prm.waves = cfg["waves"].asBool(); + if (cfg["noise_reduction"].isDouble()) + new_prm.noise_reduction = cfg["noise_reduction"].asDouble(); + if (cfg["input_delay"].isInt()) + fetch_input_delay_ = std::chrono::seconds(cfg["input_delay"].asInt()); + if (cfg["gradient"].isInt()) new_prm.gradient = cfg["gradient"].asInt(); + if (new_prm.gradient == 0) + new_prm.gradient_count = 0; + else if (cfg["gradient_count"].isInt()) + new_prm.gradient_count = cfg["gradient_count"].asInt(); + if (cfg["sdl_width"].isInt()) new_prm.sdl_width = cfg["sdl_width"].asInt(); + if (cfg["sdl_height"].isInt()) new_prm.sdl_height = cfg["sdl_height"].asInt(); + if (new_prm.framerate <= 0) { + throw std::runtime_error(std::string{"cava backend: framerate must be positive, got: "} + + std::to_string(new_prm.framerate)); + } + + adaptive_delay_.reset(std::chrono::milliseconds(static_cast(1e3 / new_prm.framerate))); + freeBackend(); + + try { + audio_raw_.height = new_prm.ascii_range; + audio_data_.format = -1; + audio_data_.rate = 0; + audio_data_.samples_counter = 0; + audio_data_.channels = 2; + audio_data_.IEEE_FLOAT = 0; + audio_data_.input_buffer_size = BUFFER_SIZE * audio_data_.channels; + audio_data_.cava_buffer_size = audio_data_.input_buffer_size * 8; + audio_data_.terminate = 0; + audio_data_.suspendFlag = false; + + input_source_ = get_input(&audio_data_, &new_prm); + if (!input_source_) { + throw std::runtime_error("cava backend: API didn't provide an input audio source"); + } + + new_prm.output = ::cava::output_method::OUTPUT_RAW; + + audio_raw_init(&audio_data_, &audio_raw_, &new_prm, &plan_); + if (!plan_) { + throw std::runtime_error("cava backend plan is not provided"); + } + audio_raw_.previous_frame[0] = -1; + audio_raw_initialized_ = true; + } catch (...) { + freeBackend(); + throw; + } + + prm_ = new_prm; + new_prm_guard.release(); prm_.output = output; + + m_signal_config_changed_.emit(); } -const struct ::cava::config_params* waybar::modules::cava::CavaBackend::getPrm() { return &prm_; } -std::chrono::milliseconds waybar::modules::cava::CavaBackend::getFrameTimeMilsec() { - return frame_time_milsec_; -}; +const ::cava::config_params& waybar::modules::cava::CavaBackend::getPrm() const { + std::lock_guard lock(state_mutex_); + return prm_; +} + +std::chrono::milliseconds waybar::modules::cava::CavaBackend::getFrameTimeMilsec() const { + std::lock_guard lock(state_mutex_); + return adaptive_delay_.current(); +} diff --git a/src/modules/clock.cpp b/src/modules/clock.cpp index 5fe5407b..bd0c828d 100644 --- a/src/modules/clock.cpp +++ b/src/modules/clock.cpp @@ -9,6 +9,7 @@ #include #include +#include "util/command.hpp" #include "util/ustring_clen.hpp" #ifdef HAVE_LANGINFO_1STDAY @@ -81,6 +82,20 @@ waybar::modules::Clock::Clock(const std::string& id, const Json::Value& config) iso8601Calendar_ = config_[kCldPlaceholder]["iso8601"].asBool(); } + if (config_[kCldPlaceholder]["weeks-numbering"].isString()) { + const std::string wn{config_[kCldPlaceholder]["weeks-numbering"].asString()}; + const std::map wnModes{{"iso", WeekNumbering::ISO}, + {"monday", WeekNumbering::MONDAY}, + {"sunday", WeekNumbering::SUNDAY}}; + if (wnModes.find(wn) != wnModes.end()) + weekNumbering_ = wnModes.at(wn); + else + spdlog::warn( + "Clock calendar configuration weeks-numbering \"{}\" is not recognized. " + "Locale default is used instead", + wn); + } + if (config_[kCldPlaceholder]["weeks-pos"].isString()) { if (config_[kCldPlaceholder]["weeks-pos"].asString() == "left") cldWPos_ = WS::LEFT; if (config_[kCldPlaceholder]["weeks-pos"].asString() == "right") cldWPos_ = WS::RIGHT; @@ -104,18 +119,26 @@ waybar::modules::Clock::Clock(const std::string& id, const Json::Value& config) cldBaseDay_ = year_month_day{floor(local_time)}.day(); } else fmtMap_.insert({3, "{}"}); + const auto weekFmt = [this]() -> std::string { + switch (weekNumbering_) { + case WeekNumbering::ISO: + return "{:%V}"; + case WeekNumbering::MONDAY: + return "{:%W}"; + case WeekNumbering::SUNDAY: + return "{:%U}"; + default: + return iso8601Calendar_ ? "{:%V}" : ((first_day_of_week() == Monday) ? "{:%W}" : "{:%U}"); + } + }(); if (config_[kCldPlaceholder]["format"]["weeks"].isString() && cldWPos_ != WS::HIDDEN) { - const auto defaultFmt = - iso8601Calendar_ ? "{:%V}" : ((first_day_of_week() == Monday) ? "{:%W}" : "{:%U}"); fmtMap_.insert({4, std::regex_replace(config_[kCldPlaceholder]["format"]["weeks"].asString(), - std::regex("\\{\\}"), defaultFmt)}); + std::regex("\\{\\}"), weekFmt)}); Glib::ustring tmp{std::regex_replace(fmtMap_[4], std::regex("]+>|\\{.*\\}"), "")}; cldWnLen_ += tmp.size(); } else { if (cldWPos_ != WS::HIDDEN) { - const auto defaultFmt = - iso8601Calendar_ ? "{:%V}" : ((first_day_of_week() == Monday) ? "{:%W}" : "{:%U}"); - fmtMap_.insert({4, defaultFmt}); + fmtMap_.insert({4, weekFmt}); } else { cldWnLen_ = 0; } @@ -162,31 +185,109 @@ auto waybar::modules::Clock::update() -> void { const auto* tz = tzList_[tzCurrIdx_] != nullptr ? tzList_[tzCurrIdx_] : local_zone(); const zoned_time now{tz, floor(system_clock::now())}; - label_.set_markup(fmt_lib::vformat(m_locale_, format_, fmt_lib::make_format_args(now))); + try { + setLabelMarkup(fmt_lib::vformat(m_locale_, format_, fmt_lib::make_format_args(now))); + } catch (const std::exception& e) { + // An unsupported/invalid specifier (e.g. the %-I / %OI padding modifiers, which the + // date/std::chrono formatter does not implement) must not take the whole module down. + // Warn once and fall back to a safe default so the bar still loads. + static bool warned = false; + if (!warned) { + spdlog::warn( + "Clock: could not format \"{}\": {}. Falling back to a default; check your format " + "specifiers.", + format_, e.what()); + warned = true; + } + try { + setLabelMarkup(fmt_lib::vformat(m_locale_, "{:%H:%M}", fmt_lib::make_format_args(now))); + } catch (...) { + setLabelMarkup(""); + } + } if (tooltipEnabled()) { const year_month_day today{floor(now.get_local_time())}; const auto shiftedDay{today + cldCurrShift_}; + // choose::earliest disambiguates the DST fall-back hour (ambiguous local + // time) and skips forward over the spring-forward gap (nonexistent local + // time); without it this constructor throws and aborts Waybar every minute + // during a DST transition. Fixes #2615 (and its many duplicates). const zoned_time shiftedNow{ - tz, local_days(shiftedDay) + (now.get_local_time() - floor(now.get_local_time()))}; + tz, local_days(shiftedDay) + (now.get_local_time() - floor(now.get_local_time())), + choose::earliest}; if (tzInTooltip_) tzText_ = getTZtext(now.get_sys_time()); if (cldInTooltip_) cldText_ = get_calendar(today, shiftedDay, tz); if (ordInTooltip_) ordText_ = get_ordinal_date(shiftedDay); - if (tzInTooltip_ || cldInTooltip_ || ordInTooltip_) { - // std::vformat doesn't support named arguments. - m_tlpText_ = - std::regex_replace(m_tlpFmt_, std::regex("\\{" + kTZPlaceholder + "\\}"), tzText_); - m_tlpText_ = std::regex_replace( - m_tlpText_, std::regex("\\{" + kCldPlaceholder + "\\}"), - fmt_lib::vformat(m_locale_, cldText_, fmt_lib::make_format_args(shiftedNow))); - m_tlpText_ = - std::regex_replace(m_tlpText_, std::regex("\\{" + kOrdPlaceholder + "\\}"), ordText_); - } else { - m_tlpText_ = m_tlpFmt_; + try { + if (tzInTooltip_ || cldInTooltip_ || ordInTooltip_) { + // std::vformat doesn't support named arguments. + m_tlpText_ = + std::regex_replace(m_tlpFmt_, std::regex("\\{" + kTZPlaceholder + "\\}"), tzText_); + m_tlpText_ = std::regex_replace( + m_tlpText_, std::regex("\\{" + kCldPlaceholder + "\\}"), + fmt_lib::vformat(m_locale_, cldText_, fmt_lib::make_format_args(shiftedNow))); + m_tlpText_ = + std::regex_replace(m_tlpText_, std::regex("\\{" + kOrdPlaceholder + "\\}"), ordText_); + } else { + m_tlpText_ = m_tlpFmt_; + } + + m_tlpText_ = fmt_lib::vformat(m_locale_, m_tlpText_, fmt_lib::make_format_args(now)); + } catch (const std::exception& e) { + // An unsupported/invalid specifier (e.g. %-I / %OI) in the tooltip-format or the + // calendar format must not take the whole module down every tick. Warn once and skip + // the tooltip for this update so the bar keeps working. + static bool tlpWarned = false; + if (!tlpWarned) { + spdlog::warn( + "Clock: could not format tooltip \"{}\": {}. Skipping tooltip; check your " + "tooltip-format/calendar format specifiers.", + m_tlpFmt_, e.what()); + tlpWarned = true; + } + m_tlpText_.clear(); + } + + // Pango doesn't support CSS classes but to continue using it while staying + // backwards compatible this approach uses post-posting to replace fake + // classes with attributes Pango does understand. + // + // The benefit of this approach is anyone using the original styling choices + // can continue doing that and folks can optionally opt into using classes. + // + // It's also forwards compatible to where if this implemention ever changes + // to support proper classes anyone using them will continue to work. + auto context = label_.get_style_context(); + + static const std::vector> calendar_class_map = { + {"calendar-today", "class='today'"}, + {"calendar-days", "class='days'"}, + {"calendar-weeks", "class='weeks'"}, + {"calendar-weekdays", "class='weekdays'"}, + {"calendar-months", "class='months'"}}; + + for (const auto& [css_class, search_str] : calendar_class_map) { + try { + context->add_class(css_class); + const Gdk::RGBA color = context->get_color(); + context->remove_class(css_class); + + const std::string replace_str = fmt::format( + "color='#{:02x}{:02x}{:02x}'", static_cast(color.get_red() * 255), + static_cast(color.get_green() * 255), static_cast(color.get_blue() * 255)); + + m_tlpText_ = std::regex_replace(m_tlpText_, std::regex(search_str), replace_str); + } catch (const Glib::Error& e) { + spdlog::warn("Clock: Failed to fetch CSS color for {}: {}", css_class, e.what().raw()); + continue; + } catch (...) { + // Catch-all for any other weirdness. + continue; + } } - m_tlpText_ = fmt_lib::vformat(m_locale_, m_tlpText_, fmt_lib::make_format_args(now)); m_tooltip_->set_markup(m_tlpText_); label_.trigger_tooltip_query(); } @@ -379,9 +480,10 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea fmt_lib::make_format_args( (line == 2) ? static_cast( - zoned_seconds{tz, local_days{ymTmp / 1}}) + zoned_seconds{tz, local_days{ymTmp / 1}, choose::earliest}) : static_cast(zoned_seconds{ - tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}}))) + tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))) << ' '; } else { os << pads; @@ -402,8 +504,13 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea data = g_utf8_find_next_char(data, end); } } + // Note: the stream's default fill character is already a space (L' ' on + // libstdc++'s wide FormatStream, ' ' on libc++'s narrow one), so no + // std::setfill is needed. Passing std::setfill(' ')/std::setfill(L' ') + // here is not portable because the fill char type must match the + // FormatStream's char type, which differs between standard libraries. os << Glib::ustring::format( - (cldWPos_ != WS::LEFT || line == 0) ? std::left : std::right, std::setfill(L' '), + (cldWPos_ != WS::LEFT || line == 0) ? std::left : std::right, std::setw(cldMonColLen_ + ((line < 2) ? cldWnLen_ - wideCharCount : 0)), calendarLine); @@ -415,11 +522,12 @@ auto waybar::modules::Clock::get_calendar(const year_month_day& today, const yea << fmt_lib::vformat( m_locale_, fmtMap_[4], fmt_lib::make_format_args( - (line == 2) ? static_cast( - zoned_seconds{tz, local_days{ymTmp / 1}}) - : static_cast( - zoned_seconds{tz, local_days{cldGetWeekForLine( - ymTmp, firstdow, line)}}))); + (line == 2) + ? static_cast( + zoned_seconds{tz, local_days{ymTmp / 1}, choose::earliest}) + : static_cast(zoned_seconds{ + tz, local_days{cldGetWeekForLine(ymTmp, firstdow, line)}, + choose::earliest}))); else os << pads; } @@ -472,6 +580,8 @@ auto waybar::modules::Clock::local_zone() -> const time_zone* { auto waybar::modules::Clock::doAction(const std::string& name) -> void { if (actionMap_[name]) { (this->*actionMap_[name])(); + } else if (auto key = name.substr(0, name.find(" ")); actionWithArgsMap_[key]) { + (this->*actionWithArgsMap_[key])(name); } else spdlog::error("Clock. Unsupported action \"{0}\"", name); } @@ -498,6 +608,14 @@ void waybar::modules::Clock::tz_down() { if (tzSize == 1) return; tzCurrIdx_ = (tzCurrIdx_ == 0) ? tzSize - 1 : tzCurrIdx_ - 1; } +void waybar::modules::Clock::action_exec(const std::string& action) { + 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 template @@ -509,6 +627,18 @@ using deleting_unique_ptr = std::unique_ptr>; // Computations done similarly to Linux cal utility. auto waybar::modules::Clock::first_day_of_week() -> weekday { + const auto firstdow = config_[kCldPlaceholder]["first-day-of-week"]; + if (firstdow.isInt()) { + const int firstDay = firstdow.asInt(); + if (!(firstDay >= 0 && firstDay <= 6)) { + spdlog::warn( + "Clock calender configuration first-day-of-week = {0} must be in range [0, 6]. Default " + "value is used instead", + firstDay); + } else { + return weekday{static_cast(firstDay)}; + } + } if (iso8601Calendar_) { return Monday; } diff --git a/src/modules/cpu.cpp b/src/modules/cpu.cpp index 8cfda2c1..ef9a13b6 100644 --- a/src/modules/cpu.cpp +++ b/src/modules/cpu.cpp @@ -41,6 +41,9 @@ auto waybar::modules::Cpu::update() -> void { auto icons = std::vector{state}; fmt::dynamic_format_arg_store store; store.push_back(fmt::arg("load", load1)); + store.push_back(fmt::arg("load1", load1)); + store.push_back(fmt::arg("load5", load5)); + store.push_back(fmt::arg("load15", load15)); store.push_back(fmt::arg("usage", total_usage)); store.push_back(fmt::arg("icon", getIcon(total_usage, icons))); store.push_back(fmt::arg("max_frequency", max_frequency)); @@ -48,23 +51,18 @@ auto waybar::modules::Cpu::update() -> void { store.push_back(fmt::arg("avg_frequency", avg_frequency)); std::vector arg_names; arg_names.reserve(cpu_usage.size() * 2); + std::string all_icons; for (size_t i = 1; i < cpu_usage.size(); ++i) { auto core_i = i - 1; arg_names.push_back(fmt::format("usage{}", core_i)); store.push_back(fmt::arg(arg_names.back().c_str(), cpu_usage[i])); + auto core_icon = getIcon(cpu_usage[i], icons); + all_icons += core_icon; arg_names.push_back(fmt::format("icon{}", core_i)); - store.push_back(fmt::arg(arg_names.back().c_str(), getIcon(cpu_usage[i], icons))); - } - label_.set_markup(fmt::vformat(format, store)); - - if (tooltipEnabled()) { - if (config_["tooltip-format"].isString()) { - tooltip = config_["tooltip-format"].asString(); - label_.set_tooltip_markup(fmt::vformat(tooltip, store)); - } else { - label_.set_tooltip_markup(tooltip); - } + store.push_back(fmt::arg(arg_names.back().c_str(), core_icon)); } + store.push_back(fmt::arg("icons", all_icons)); + updateLabelAndTooltipForState(state, format, tooltip, store); } // Call parent update diff --git a/src/modules/cpu_frequency/common.cpp b/src/modules/cpu_frequency/common.cpp index 05adc2b3..a4a9c7e9 100644 --- a/src/modules/cpu_frequency/common.cpp +++ b/src/modules/cpu_frequency/common.cpp @@ -32,24 +32,12 @@ auto waybar::modules::CpuFrequency::update() -> void { } else { event_box_.show(); auto icons = std::vector{state}; - fmt::dynamic_format_arg_store store; - store.push_back(fmt::arg("icon", getIcon(avg_frequency, icons))); - store.push_back(fmt::arg("max_frequency", max_frequency)); - store.push_back(fmt::arg("min_frequency", min_frequency)); - store.push_back(fmt::arg("avg_frequency", avg_frequency)); - label_.set_markup(fmt::vformat(format, store)); - - if (tooltipEnabled()) { - std::string tooltip; - if (config_["tooltip-format"].isString()) { - tooltip = config_["tooltip-format"].asString(); - label_.set_tooltip_markup(fmt::vformat(tooltip, store)); - } else { - tooltip = "Minimum frequency: {}\nAverage frequency: {}\nMaximum frequency: {}\n"; - label_.set_tooltip_markup( - fmt::format(fmt::runtime(tooltip), min_frequency, avg_frequency, max_frequency)); - } - } + updateLabelAndTooltip( + format, + "Minimum frequency: {min_frequency}\nAverage frequency: {avg_frequency}\nMaximum " + "frequency: {max_frequency}\n", + fmt::arg("icon", getIcon(avg_frequency, icons)), fmt::arg("max_frequency", max_frequency), + fmt::arg("min_frequency", min_frequency), fmt::arg("avg_frequency", avg_frequency)); } // Call parent update diff --git a/src/modules/cpu_graph.cpp b/src/modules/cpu_graph.cpp new file mode 100644 index 00000000..a9a29dd2 --- /dev/null +++ b/src/modules/cpu_graph.cpp @@ -0,0 +1,47 @@ +#include "modules/cpu_graph.hpp" + +#include "modules/cpu_frequency.hpp" +#include "modules/cpu_usage.hpp" +#include "modules/load.hpp" + +// In the 80000 version of fmt library authors decided to optimize imports +// and moved declarations required for fmt::dynamic_format_arg_store in new +// header fmt/args.h +#if (FMT_VERSION >= 80000) +#include +#else +#include +#endif + +waybar::modules::CpuGraph::CpuGraph(const std::string& id, const Json::Value& config) + : AGraph(config, "cpu_graph", id, 5) { + thread_ = [this] { + dp.emit(); + thread_.sleep_for(interval_); + }; +} + +auto waybar::modules::CpuGraph::update() -> void { + // TODO: as creating dynamic fmt::arg arrays is buggy we have to calc both + auto [cpu_usage, tooltip] = CpuUsage::getCpuUsage(prev_times_); + if (tooltipEnabled()) { + graph_.set_tooltip_text(tooltip); + } + auto total_usage = cpu_usage.empty() ? 0 : cpu_usage[0]; + addValue(total_usage); + + graph_.get_style_context()->remove_class(MODERATE_CLASS); + graph_.get_style_context()->remove_class(HIGH_CLASS); + graph_.get_style_context()->remove_class(INTENSIVE_CLASS); + + if (total_usage > 90) { + graph_.get_style_context()->add_class(INTENSIVE_CLASS); + } else if (total_usage > 70) { + graph_.get_style_context()->add_class(HIGH_CLASS); + } else if (total_usage > 30) { + graph_.get_style_context()->add_class(MODERATE_CLASS); + } + + // Call parent update + AGraph::update(); +} diff --git a/src/modules/cpu_usage/common.cpp b/src/modules/cpu_usage/common.cpp index 1571cb74..13e9cc4e 100644 --- a/src/modules/cpu_usage/common.cpp +++ b/src/modules/cpu_usage/common.cpp @@ -38,23 +38,18 @@ auto waybar::modules::CpuUsage::update() -> void { store.push_back(fmt::arg("icon", getIcon(total_usage, icons))); std::vector arg_names; arg_names.reserve(cpu_usage.size() * 2); + std::string all_icons; for (size_t i = 1; i < cpu_usage.size(); ++i) { auto core_i = i - 1; arg_names.push_back(fmt::format("usage{}", core_i)); store.push_back(fmt::arg(arg_names.back().c_str(), cpu_usage[i])); + auto core_icon = getIcon(cpu_usage[i], icons); + all_icons += core_icon; arg_names.push_back(fmt::format("icon{}", core_i)); - store.push_back(fmt::arg(arg_names.back().c_str(), getIcon(cpu_usage[i], icons))); - } - label_.set_markup(fmt::vformat(format, store)); - - if (tooltipEnabled()) { - if (config_["tooltip-format"].isString()) { - tooltip = config_["tooltip-format"].asString(); - label_.set_tooltip_markup(fmt::vformat(tooltip, store)); - } else { - label_.set_tooltip_markup(tooltip); - } + store.push_back(fmt::arg(arg_names.back().c_str(), core_icon)); } + store.push_back(fmt::arg("icons", all_icons)); + updateLabelAndTooltip(format, tooltip, store); } // Call parent update diff --git a/src/modules/custom.cpp b/src/modules/custom.cpp index 28def8c9..836a0d71 100644 --- a/src/modules/custom.cpp +++ b/src/modules/custom.cpp @@ -2,24 +2,22 @@ #include +#include +#include #include -#include "util/scope_guard.hpp" - waybar::modules::Custom::Custom(const std::string& name, const std::string& id, const Json::Value& config, const std::string& output_name) - : ALabel(config, "custom-" + name, id, "{}"), + : AIconLabel(config, "custom-" + name, id, "{}"), name_(name), output_name_(output_name), id_(id), tooltip_format_enabled_{config_["tooltip-format"].isString()}, - percentage_(0), - fp_(nullptr), - pid_(-1) { + percentage_(0) { if (config.isNull()) { spdlog::warn("There is no configuration for 'custom/{}', element will be hidden", name); } - dp.emit(); + if (!config_["signal"].empty() && config_["interval"].empty() && config_["restart-interval"].empty()) { waitingWorker(); @@ -28,24 +26,45 @@ waybar::modules::Custom::Custom(const std::string& name, const std::string& id, } else if (config_["exec"].isString()) { continuousWorker(); } + if (config_["image-path"].isString()) { + image_path_ = config_["image-path"].asString(); + } + if (config_["image-name"].isString()) { + image_name_ = config_["image-name"].asString(); + } + if (config["icon-size"].isUInt()) { + app_icon_size_ = config["icon-size"].asUInt(); + } } waybar::modules::Custom::~Custom() { - if (pid_ != -1) { - killpg(pid_, SIGTERM); - waitpid(pid_, NULL, 0); - pid_ = -1; + restart_connection_.disconnect(); + if (continuous_stream_) { + continuous_stream_->stop(); } } void waybar::modules::Custom::delayWorker() { - thread_ = [this] { - for (int i : this->pid_children_) { - int status; - waitpid(i, &status, 0); - } + if (!config_["exec"].isString() && !config_["exec-if"].isString()) { + dp.emit(); + return; + } - this->pid_children_.clear(); + thread_ = [this] { + for (auto it = this->pid_children_.begin(); it != this->pid_children_.end();) { + int status = 0; + const auto pid = static_cast(*it); + const auto waited = waitpid(pid, &status, WNOHANG); + if (waited == 0) { + ++it; + continue; + } + if (waited == -1 && errno != ECHILD) { + ++it; + continue; + } + it = this->pid_children_.erase(it); + } bool can_update = true; if (config_["exec-if"].isString()) { @@ -66,55 +85,64 @@ void waybar::modules::Custom::delayWorker() { } void waybar::modules::Custom::continuousWorker() { - auto cmd = config_["exec"].asString(); - pid_ = -1; - fp_ = util::command::open(cmd, pid_, output_name_); - if (!fp_) { - throw std::runtime_error("Unable to open " + cmd); - } - thread_ = [this, cmd] { - char* buff = nullptr; - waybar::util::ScopeGuard buff_deleter([&buff]() { - if (buff) { - free(buff); - } - }); - size_t len = 0; - if (getline(&buff, &len, fp_) == -1) { - int exit_code = 1; - if (fp_) { - exit_code = WEXITSTATUS(util::command::close(fp_, pid_)); - fp_ = nullptr; - } - if (exit_code != 0) { - output_ = {exit_code, ""}; + continuous_stream_ = std::make_unique( + output_name_, + [this](const std::string& output) { + output_ = {.exit_code = 0, .out = output}; dp.emit(); - spdlog::error("{} stopped unexpectedly, is it endless?", name_); - } - if (config_["restart-interval"].isNumeric()) { - pid_ = -1; - thread_.sleep_for(std::chrono::milliseconds( - std::max(1L, // Minimum 1ms due to millisecond precision - static_cast(config_["restart-interval"].asDouble() * 1000)))); - fp_ = util::command::open(cmd, pid_, output_name_); - if (!fp_) { - throw std::runtime_error("Unable to open " + cmd); - } - } else { - thread_.stop(); - return; - } - } else { - std::string output = buff; + }, + [this](int exit_code) { handleContinuousProcessExit(exit_code); }); + startContinuousProcess(true); +} - // Remove last newline - if (!output.empty() && output[output.length() - 1] == '\n') { - output.erase(output.length() - 1); - } - output_ = {0, output}; - dp.emit(); +void waybar::modules::Custom::startContinuousProcess(bool throw_on_failure) { + const auto cmd = config_["exec"].asString(); + + try { + continuous_stream_->start(cmd); + } catch (const Glib::SpawnError& e) { + if (throw_on_failure) { + throw std::runtime_error("Unable to open " + cmd + ": " + e.what().raw()); } - }; + output_ = {.exit_code = 1, .out = ""}; + dp.emit(); + spdlog::error("Unable to restart {}: {}", name_, e.what().raw()); + scheduleContinuousRestart(); + } catch (const std::exception& e) { + if (throw_on_failure) { + throw; + } + output_ = {.exit_code = 1, .out = ""}; + dp.emit(); + spdlog::error("Unable to restart {}: {}", name_, e.what()); + scheduleContinuousRestart(); + } +} + +void waybar::modules::Custom::handleContinuousProcessExit(int exit_code) { + if (exit_code != 0) { + output_ = {.exit_code = exit_code, .out = ""}; + dp.emit(); + spdlog::error("{} stopped unexpectedly, is it endless?", name_); + } + + scheduleContinuousRestart(); +} + +void waybar::modules::Custom::scheduleContinuousRestart() { + restart_connection_.disconnect(); + if (!config_["restart-interval"].isNumeric() || config_["restart-interval"].asDouble() <= 0) { + // A non-positive restart-interval must not busy-respawn the script + // (that starves the GTK main loop); treat it as "do not restart". + return; + } + + restart_connection_ = Glib::signal_timeout().connect( + [this] { + startContinuousProcess(false); + return false; + }, + std::max(1U, static_cast(config_["restart-interval"].asDouble() * 1000))); } void waybar::modules::Custom::waitingWorker() { @@ -138,9 +166,11 @@ void waybar::modules::Custom::waitingWorker() { } void waybar::modules::Custom::refresh(int sig) { +#ifdef SIGRTMIN if (config_["signal"].isInt() && sig == SIGRTMIN + config_["signal"].asInt()) { thread_.wake_up(); } +#endif } void waybar::modules::Custom::handleEvent() { @@ -177,10 +207,11 @@ auto waybar::modules::Custom::update() -> void { auto str = fmt::format(fmt::runtime(format_), fmt::arg("text", text_), fmt::arg("alt", alt_), fmt::arg("icon", getIcon(percentage_, alt_)), fmt::arg("percentage", percentage_)); - if ((config_["hide-empty-text"].asBool() && text_.empty()) || str.empty()) { + if ((config_["hide-empty-text"].asBool() && text_.empty()) || + (str.empty() && image_path_.empty() && image_name_.empty())) { event_box_.hide(); } else { - label_.set_markup(str); + setLabelMarkup(str); if (tooltipEnabled()) { std::string tooltip_markup; if (tooltip_format_enabled_) { @@ -195,10 +226,7 @@ auto waybar::modules::Custom::update() -> void { tooltip_markup = tooltip_; } - if (last_tooltip_markup_ != tooltip_markup) { - label_.set_tooltip_markup(tooltip_markup); - last_tooltip_markup_ = std::move(tooltip_markup); - } + setTooltipMarkup(tooltip_markup); } auto style = label_.get_style_context(); auto classes = style->list_classes(); @@ -209,10 +237,39 @@ 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- widget name (see AIconLabel), so #custom-. + // 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); + auto image_style = image_.get_style_context(); + image_style->add_class("image-button"); event_box_.show(); + if (!image_path_.empty()) { + 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_); + } + + label_.set_visible(!str.empty()); } } catch (const fmt::format_error& e) { if (std::strcmp(e.what(), "cannot switch from manual to automatic argument indexing") != 0) @@ -224,7 +281,14 @@ auto waybar::modules::Custom::update() -> void { } } // Call parent update - ALabel::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() { @@ -266,22 +330,32 @@ void waybar::modules::Custom::parseOutputJson() { std::istringstream output(output_.out); std::string line; class_.clear(); + // A script can emit invalid UTF-8; passing it unchecked to Pango/GTK aborts + // the whole bar in g_utf8_* (see parseOutputRaw, which validates the same way). + auto sanitize = [](const std::string& s) -> Glib::ustring { + Glib::ustring value = s; + if (!value.validate()) { + value = value.make_valid(); + } + return value; + }; while (getline(output, line)) { auto parsed = parser_.parse(line); - if (config_["escape"].isBool() && config_["escape"].asBool()) { - text_ = Glib::Markup::escape_text(parsed["text"].asString()); + const bool escape = config_["escape"].isBool() && config_["escape"].asBool(); + if (escape) { + text_ = Glib::Markup::escape_text(sanitize(parsed["text"].asString())); } else { - text_ = parsed["text"].asString(); + text_ = sanitize(parsed["text"].asString()); } - if (config_["escape"].isBool() && config_["escape"].asBool()) { - alt_ = Glib::Markup::escape_text(parsed["alt"].asString()); + if (escape) { + alt_ = Glib::Markup::escape_text(sanitize(parsed["alt"].asString())); } else { - alt_ = parsed["alt"].asString(); + alt_ = sanitize(parsed["alt"].asString()); } - if (config_["escape"].isBool() && config_["escape"].asBool()) { - tooltip_ = Glib::Markup::escape_text(parsed["tooltip"].asString()); + if (escape) { + tooltip_ = Glib::Markup::escape_text(sanitize(parsed["tooltip"].asString())); } else { - tooltip_ = parsed["tooltip"].asString(); + tooltip_ = sanitize(parsed["tooltip"].asString()); } if (parsed["class"].isString()) { class_.push_back(parsed["class"].asString()); @@ -290,6 +364,7 @@ void waybar::modules::Custom::parseOutputJson() { class_.push_back(c.asString()); } } + if (!parsed["percentage"].asString().empty() && parsed["percentage"].isNumeric()) { percentage_ = (int)lround(parsed["percentage"].asFloat()); } else { diff --git a/src/modules/custom_graph.cpp b/src/modules/custom_graph.cpp new file mode 100644 index 00000000..2a044abf --- /dev/null +++ b/src/modules/custom_graph.cpp @@ -0,0 +1,320 @@ +#include "modules/custom_graph.hpp" + +#include + +#include +#include +#include +#include + +#include "util/scope_guard.hpp" + +waybar::modules::CustomGraph::CustomGraph(const std::string& name, const std::string& id, + const Json::Value& config, const std::string& output_name) + : AGraph(config, "custom-graph-" + name, id), + name_(name), + output_name_(output_name), + id_(id), + tooltip_format_enabled_{config_["tooltip-format"].isString()}, + percentage_(0), + fp_(nullptr), + pid_(-1) { + if (config.isNull()) { + spdlog::warn("There is no configuration for 'custom-graph/{}', element will be hidden", name); + } + + if (!config_["signal"].empty() && config_["interval"].empty() && + config_["restart-interval"].empty()) { + waitingWorker(); + } else if (interval_.count() > 0) { + delayWorker(); + } else if (config_["exec"].isString()) { + continuousWorker(); + } +} + +waybar::modules::CustomGraph::~CustomGraph() { + if (pid_ != -1) { + killpg(pid_, SIGTERM); + waitpid(pid_, NULL, 0); + pid_ = -1; + } +} + +void waybar::modules::CustomGraph::delayWorker() { + thread_ = [this] { + for (int i : this->pid_children_) { + int status; + waitpid(i, &status, 0); + } + + this->pid_children_.clear(); + + bool can_update = true; + if (config_["exec-if"].isString()) { + output_ = util::command::execNoRead(config_["exec-if"].asString()); + if (output_.exit_code != 0) { + can_update = false; + dp.emit(); + } + } + if (can_update) { + if (config_["exec"].isString()) { + output_ = util::command::exec(config_["exec"].asString(), output_name_); + } + dp.emit(); + } + thread_.sleep_for(interval_); + }; +} + +void waybar::modules::CustomGraph::continuousWorker() { + auto cmd = config_["exec"].asString(); + pid_ = -1; + fp_ = util::command::open(cmd, pid_, output_name_); + if (!fp_) { + throw std::runtime_error("Unable to open " + cmd); + } + thread_ = [this, cmd] { + char* buff = nullptr; + waybar::util::ScopeGuard buff_deleter([&buff]() { + if (buff) { + free(buff); + } + }); + size_t len = 0; + if (getline(&buff, &len, fp_) == -1) { + int exit_code = 1; + if (fp_) { + exit_code = WEXITSTATUS(util::command::close(fp_, pid_)); + fp_ = nullptr; + } + if (exit_code != 0) { + output_ = {exit_code, ""}; + dp.emit(); + spdlog::error("{} stopped unexpectedly, is it endless?", name_); + } + if (config_["restart-interval"].isUInt()) { + pid_ = -1; + thread_.sleep_for(std::chrono::seconds(config_["restart-interval"].asUInt())); + fp_ = util::command::open(cmd, pid_, output_name_); + if (!fp_) { + // Letting this exception escape the SleeperThread would call + // std::terminate and kill all of Waybar. Degrade gracefully instead. + output_ = {1, ""}; + dp.emit(); + spdlog::error("Unable to restart {}: unable to open {}", name_, cmd); + thread_.stop(); + return; + } + } else { + thread_.stop(); + return; + } + } else { + std::string output = buff; + + // Remove last newline + if (!output.empty() && output[output.length() - 1] == '\n') { + output.erase(output.length() - 1); + } + output_ = {0, output}; + dp.emit(); + } + }; +} + +void waybar::modules::CustomGraph::waitingWorker() { + thread_ = [this] { + bool can_update = true; + if (config_["exec-if"].isString()) { + output_ = util::command::execNoRead(config_["exec-if"].asString()); + if (output_.exit_code != 0) { + can_update = false; + dp.emit(); + } + } + if (can_update) { + if (config_["exec"].isString()) { + output_ = util::command::exec(config_["exec"].asString(), output_name_); + } + dp.emit(); + } + thread_.sleep(); + }; +} + +void waybar::modules::CustomGraph::refresh(int sig) { +#ifdef SIGRTMIN + if (config_["signal"].isInt() && sig == SIGRTMIN + config_["signal"].asInt()) { + thread_.wake_up(); + } +#endif +} + +void waybar::modules::CustomGraph::handleEvent() { + if (!config_["exec-on-event"].isBool() || config_["exec-on-event"].asBool()) { + thread_.wake_up(); + } +} + +bool waybar::modules::CustomGraph::handleScroll(GdkEventScroll* e) { + auto ret = AGraph::handleScroll(e); + handleEvent(); + return ret; +} + +bool waybar::modules::CustomGraph::handleToggle(GdkEventButton* const& e) { + auto ret = AGraph::handleToggle(e); + handleEvent(); + return ret; +} + +auto waybar::modules::CustomGraph::update() -> void { + // Hide label if output is empty + if ((config_["exec"].isString() || config_["exec-if"].isString()) && + (output_.out.empty() || output_.exit_code != 0)) { + event_box_.hide(); + } else { + if (config_["return-type"].asString() == "json") { + parseOutputJson(); + } else { + parseOutputRaw(); + } + + try { + addValue(percentage_); + + if (tooltipEnabled()) { + if (tooltip_format_enabled_) { + auto tooltip = config_["tooltip-format"].asString(); + tooltip = fmt::format(fmt::runtime(tooltip), fmt::arg("text", text_), + fmt::arg("alt", alt_), fmt::arg("percentage", percentage_)); + graph_.set_tooltip_markup(tooltip); + } else { + if (graph_.get_tooltip_markup() != tooltip_) { + graph_.set_tooltip_markup(tooltip_); + } + } + } + auto style = graph_.get_style_context(); + auto classes = style->list_classes(); + for (auto const& c : classes) { + if (c == id_) continue; + style->remove_class(c); + } + for (auto const& c : class_) { + style->add_class(c); + } + style->add_class("flat"); + style->add_class(MODULE_CLASS); + event_box_.show(); + } catch (const fmt::format_error& e) { + if (std::strcmp(e.what(), "cannot switch from manual to automatic argument indexing") != 0) + throw; + + throw fmt::format_error( + "mixing manual and automatic argument indexing is no longer supported; " + "try replacing \"{}\" with \"{text}\" in your format specifier"); + } + } + // Call parent update + AGraph::update(); +} + +void waybar::modules::CustomGraph::parseOutputRaw() { + std::istringstream output(output_.out); + std::string line; + int i = 0; + while (getline(output, line)) { + Glib::ustring validated_line = line; + if (!validated_line.validate()) { + validated_line = validated_line.make_valid(); + } + + if (i == 0) { + if (config_["escape"].isBool() && config_["escape"].asBool()) { + text_ = Glib::Markup::escape_text(validated_line); + tooltip_ = Glib::Markup::escape_text(validated_line); + } else { + text_ = validated_line; + 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(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); + } else { + tooltip_ = validated_line; + } + } else if (i == 2) { + class_.push_back(validated_line); + } else { + break; + } + i++; + } +} + +void waybar::modules::CustomGraph::parseOutputJson() { + std::istringstream output(output_.out); + std::string line; + class_.clear(); + // A script can emit invalid UTF-8; passing it unchecked to Pango/GTK aborts + // the whole bar in g_utf8_* (see parseOutputRaw, which validates the same way). + auto sanitize = [](const std::string& s) -> Glib::ustring { + Glib::ustring value = s; + if (!value.validate()) { + value = value.make_valid(); + } + return value; + }; + while (getline(output, line)) { + auto parsed = parser_.parse(line); + const bool escape = config_["escape"].isBool() && config_["escape"].asBool(); + if (escape) { + text_ = Glib::Markup::escape_text(sanitize(parsed["text"].asString())); + } else { + text_ = sanitize(parsed["text"].asString()); + } + if (escape) { + alt_ = Glib::Markup::escape_text(sanitize(parsed["alt"].asString())); + } else { + alt_ = sanitize(parsed["alt"].asString()); + } + if (escape) { + tooltip_ = Glib::Markup::escape_text(sanitize(parsed["tooltip"].asString())); + } else { + tooltip_ = sanitize(parsed["tooltip"].asString()); + } + if (parsed["class"].isString()) { + class_.push_back(parsed["class"].asString()); + } else if (parsed["class"].isArray()) { + for (auto const& c : parsed["class"]) { + class_.push_back(c.asString()); + } + } + if (!parsed["percentage"].asString().empty() && parsed["percentage"].isNumeric()) { + percentage_ = (int)lround(parsed["percentage"].asFloat()); + } else { + percentage_ = 0; + } + break; + } +} diff --git a/src/modules/disk.cpp b/src/modules/disk.cpp index 462940b2..ba3db1b3 100644 --- a/src/modules/disk.cpp +++ b/src/modules/disk.cpp @@ -1,15 +1,32 @@ #include "modules/disk.hpp" +#include + using namespace waybar::util; waybar::modules::Disk::Disk(const std::string& id, const Json::Value& config) - : ALabel(config, "disk", id, "{}%", 30), path_("/") { + : ALabel(config, "disk", id, "{}%", 30), header_(""), paths_(), separator_(" ") { thread_ = [this] { dp.emit(); thread_.sleep_for(interval_); }; - if (config["path"].isString()) { - path_ = config["path"].asString(); + if (config["header"].isString()) { + header_ = config["header"].asString(); + } + if (config["path"].isString() && !config["paths"].isArray()) { + spdlog::warn("Disk: path is deprecated use paths instead!"); + paths_.push_back(config["path"].asString()); + } + if (config["paths"].isArray()) { + for (const auto& path : config["paths"]) { + paths_.push_back(path.asString()); + } + } + if (!config["path"].isString() && !config["paths"].isArray()) { + paths_.emplace_back("/"); + } + if (config["separator"].isString()) { + separator_ = config["separator"].asString(); } if (config["unit"].isString()) { unit_ = config["unit"].asString(); @@ -17,76 +34,108 @@ waybar::modules::Disk::Disk(const std::string& id, const Json::Value& config) } auto waybar::modules::Disk::update() -> void { - struct statvfs /* { - unsigned long f_bsize; // filesystem block size - unsigned long f_frsize; // fragment size - fsblkcnt_t f_blocks; // size of fs in f_frsize units - fsblkcnt_t f_bfree; // # free blocks - fsblkcnt_t f_bavail; // # free blocks for unprivileged users - fsfilcnt_t f_files; // # inodes - fsfilcnt_t f_ffree; // # free inodes - fsfilcnt_t f_favail; // # free inodes for unprivileged users - unsigned long f_fsid; // filesystem ID - unsigned long f_flag; // mount flags - unsigned long f_namemax; // maximum filename length - }; */ - stats; - int err = statvfs(path_.c_str(), &stats); + std::string tooltip_label; + std::string label = header_; - /* Conky options - fs_bar - Bar that shows how much space is used - fs_free - Free space on a file system - fs_free_perc - Free percentage of space - fs_size - File system size - fs_used - File system used space - */ + bool had_valid_disk = false; - if (err != 0 || stats.f_blocks == 0) { - event_box_.hide(); - return; - } + for (size_t i = 0; i < paths_.size(); ++i) { + const auto& path = paths_[i]; - float specific_free, specific_used, specific_total, divisor; + struct statvfs /* { + unsigned long f_bsize; // filesystem block size + unsigned long f_frsize; // fragment size + fsblkcnt_t f_blocks; // size of fs in f_frsize units + fsblkcnt_t f_bfree; // # free blocks + fsblkcnt_t f_bavail; // # free blocks for unprivileged users + fsfilcnt_t f_files; // # inodes + fsfilcnt_t f_ffree; // # free inodes + fsfilcnt_t f_favail; // # free inodes for unprivileged users + unsigned long f_fsid; // filesystem ID + unsigned long f_flag; // mount flags + unsigned long f_namemax; // maximum filename length + }; */ + stats; - divisor = calc_specific_divisor(unit_); - specific_free = (stats.f_bavail * stats.f_frsize) / divisor; - specific_used = ((stats.f_blocks - stats.f_bfree) * stats.f_frsize) / divisor; - specific_total = (stats.f_blocks * stats.f_frsize) / divisor; + int err = statvfs(path.c_str(), &stats); - auto free = pow_format(stats.f_bavail * stats.f_frsize, "B", true); - auto used = pow_format((stats.f_blocks - stats.f_bfree) * stats.f_frsize, "B", true); - auto total = pow_format(stats.f_blocks * stats.f_frsize, "B", true); - auto percentage_used = (stats.f_blocks - stats.f_bfree) * 100 / stats.f_blocks; + /* Conky options + fs_bar - Bar that shows how much space is used + fs_free - Free space on a file system + fs_free_perc - Free percentage of space + fs_size - File system size + fs_used - File system used space + */ - auto format = format_; - auto state = getState(percentage_used); - if (!state.empty() && config_["format-" + state].isString()) { - format = config_["format-" + state].asString(); - } + if (err != 0 || stats.f_blocks == 0) { + spdlog::warn("Disk: statvfs failed for path '{}' (errno={})", path, errno); + continue; + } - if (format.empty()) { - event_box_.hide(); - } else { - event_box_.show(); - label_.set_markup(fmt::format( - fmt::runtime(format), stats.f_bavail * 100 / stats.f_blocks, fmt::arg("free", free), - fmt::arg("percentage_free", stats.f_bavail * 100 / stats.f_blocks), fmt::arg("used", used), - fmt::arg("percentage_used", percentage_used), fmt::arg("total", total), - fmt::arg("path", path_), fmt::arg("specific_free", specific_free), - fmt::arg("specific_used", specific_used), fmt::arg("specific_total", specific_total))); - } + float specific_free, specific_used, specific_total, divisor; + + divisor = calc_specific_divisor(unit_); + specific_free = (stats.f_bavail * stats.f_frsize) / divisor; + specific_used = ((stats.f_blocks - stats.f_bfree) * stats.f_frsize) / divisor; + specific_total = (stats.f_blocks * stats.f_frsize) / divisor; + + auto free = pow_format(stats.f_bavail * stats.f_frsize, "B", true); + auto used = pow_format((stats.f_blocks - stats.f_bfree) * stats.f_frsize, "B", true); + auto total = pow_format(stats.f_blocks * stats.f_frsize, "B", true); + auto percentage_used = (stats.f_blocks - stats.f_bfree) * 100 / stats.f_blocks; + + std::string disk_format = format_; + auto state = getState(percentage_used); + if (!state.empty() && config_["format-" + state].isString()) { + disk_format = config_["format-" + state].asString(); + } + + if (!disk_format.empty()) { + if (had_valid_disk) { + label += separator_; + } + + label += fmt::format( + fmt::runtime(disk_format), stats.f_bavail * 100 / stats.f_blocks, fmt::arg("free", free), + fmt::arg("percentage_free", stats.f_bavail * 100 / stats.f_blocks), + fmt::arg("used", used), fmt::arg("percentage_used", percentage_used), + fmt::arg("total", total), fmt::arg("path", path), + fmt::arg("specific_free", specific_free), fmt::arg("specific_used", specific_used), + fmt::arg("specific_total", specific_total)); + } - if (tooltipEnabled()) { std::string tooltip_format = "{used} used out of {total} on {path} ({percentage_used}%)"; if (config_["tooltip-format"].isString()) { tooltip_format = config_["tooltip-format"].asString(); } - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_format), stats.f_bavail * 100 / stats.f_blocks, fmt::arg("free", free), - fmt::arg("percentage_free", stats.f_bavail * 100 / stats.f_blocks), fmt::arg("used", used), - fmt::arg("percentage_used", percentage_used), fmt::arg("total", total), - fmt::arg("path", path_), fmt::arg("specific_free", specific_free), - fmt::arg("specific_used", specific_used), fmt::arg("specific_total", specific_total))); + + if (!tooltip_format.empty()) { + if (had_valid_disk) { + tooltip_label += "\n"; + } + + tooltip_label += fmt::format( + fmt::runtime(tooltip_format), stats.f_bavail * 100 / stats.f_blocks, + fmt::arg("free", free), + fmt::arg("percentage_free", stats.f_bavail * 100 / stats.f_blocks), + fmt::arg("used", used), fmt::arg("percentage_used", percentage_used), + fmt::arg("total", total), fmt::arg("path", path), + fmt::arg("specific_free", specific_free), fmt::arg("specific_used", specific_used), + fmt::arg("specific_total", specific_total)); + } + + had_valid_disk = true; + } + if (had_valid_disk) { + event_box_.show(); + } else { + event_box_.hide(); + } + + setLabelMarkup(label); + + if (tooltipEnabled() && !tooltip_label.empty()) { + setTooltipMarkup(tooltip_label); } // Call parent update ALabel::update(); @@ -109,7 +158,7 @@ float waybar::modules::Disk::calc_specific_divisor(const std::string& divisor) { return 1000.0 * 1000.0 * 1000.0 * 1000.0; } else if (divisor == "TiB") { return 1024.0 * 1024.0 * 1024.0 * 1024.0; - } else { // default to Bytes if it is anything that we don't recongnise + } else { // default to Bytes if it is anything that we don't recognise return 1.0; } } diff --git a/src/modules/dwl/tags.cpp b/src/modules/dwl/tags.cpp index 243aaa92..3e6696af 100644 --- a/src/modules/dwl/tags.cpp +++ b/src/modules/dwl/tags.cpp @@ -26,7 +26,7 @@ static void toggle_visibility(void* data, zdwl_ipc_output_v2* zdwl_output_v2) { } static void active(void* data, zdwl_ipc_output_v2* zdwl_output_v2, uint32_t active) { - // Intentionally empty + static_cast(data)->handle_active_output(zdwl_output_v2, active); } static void set_tag(void* data, zdwl_ipc_output_v2* zdwl_output_v2, uint32_t tag, uint32_t state, @@ -70,32 +70,31 @@ static const zdwl_ipc_output_v2_listener output_status_listener_impl{ static void handle_global(void* data, struct wl_registry* registry, uint32_t name, const char* interface, uint32_t version) { - -if (std::strcmp(interface, zdwl_ipc_manager_v2_interface.name) == 0) { - auto* self = static_cast(data); + if (std::strcmp(interface, zdwl_ipc_manager_v2_interface.name) == 0) { + auto* self = static_cast(data); - if (self->status_manager_) { - zdwl_ipc_manager_v2_destroy(self->status_manager_); - self->status_manager_ = nullptr; -} + if (self->status_manager_) { + zdwl_ipc_manager_v2_destroy(self->status_manager_); + self->status_manager_ = nullptr; + } - self->status_manager_ = static_cast( - wl_registry_bind(registry, name, &zdwl_ipc_manager_v2_interface, 1)); -} - -if (std::strcmp(interface, wl_seat_interface.name) == 0) { - auto* self = static_cast(data); - - if (self->seat_) { - wl_seat_destroy(self->seat_); - self->seat_ = nullptr; + self->status_manager_ = static_cast( + wl_registry_bind(registry, name, &zdwl_ipc_manager_v2_interface, 1)); } - version = std::min(version, 1); + if (std::strcmp(interface, wl_seat_interface.name) == 0) { + auto* self = static_cast(data); - self->seat_ = static_cast( - wl_registry_bind(registry, name, &wl_seat_interface, version)); -} + if (self->seat_) { + wl_seat_destroy(self->seat_); + self->seat_ = nullptr; + } + + version = std::min(version, 1); + + self->seat_ = + static_cast(wl_registry_bind(registry, name, &wl_seat_interface, version)); + } } static void handle_global_remove(void* data, struct wl_registry* registry, uint32_t name) { /* Ignore event */ @@ -110,7 +109,11 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con seat_{nullptr}, bar_(bar), box_{bar.orientation, 0}, + hide_vacant_(false), output_status_{nullptr} { + if (config_["hide-vacant"].asBool()) { + hide_vacant_ = config_["hide-vacant"].asBool(); + } struct wl_display* display = Client::inst()->wl_display; struct wl_registry* registry = wl_display_get_registry(display); @@ -163,7 +166,7 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con i <<= 1; } - struct wl_output* output = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); + struct wl_output *output = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); output_status_ = zdwl_ipc_manager_v2_get_output(status_manager_, output); zdwl_ipc_output_v2_add_listener(output_status_, &output_status_listener_impl, this); @@ -221,6 +224,24 @@ void Tags::handle_view_tags(uint32_t tag, uint32_t state, uint32_t clients, uint } else { button.get_style_context()->remove_class("urgent"); } + + if (hide_vacant_ && !clients && !(state & TAG_ACTIVE)) { + button.set_visible(false); + } else { + button.set_visible(true); + } +} + +void Tags::handle_active_output(zdwl_ipc_output_v2* zdwl_output_v2, uint32_t active) { + if (output_status_ == zdwl_output_v2) { + for (size_t i = 0; i < buttons_.size(); ++i) { + if (active == 0) { + buttons_[i].get_style_context()->remove_class("output"); + } else { + buttons_[i].get_style_context()->add_class("output"); + } + } + } } } /* namespace waybar::modules::dwl */ diff --git a/src/modules/dwl/window.cpp b/src/modules/dwl/window.cpp index 56e67895..677d2ca0 100644 --- a/src/modules/dwl/window.cpp +++ b/src/modules/dwl/window.cpp @@ -19,7 +19,7 @@ static void toggle_visibility(void* data, zdwl_ipc_output_v2* zdwl_output_v2) { } static void active(void* data, zdwl_ipc_output_v2* zdwl_output_v2, uint32_t active) { - // Intentionally empty + static_cast(data)->handle_active(active); } static void set_tag(void* data, zdwl_ipc_output_v2* zdwl_output_v2, uint32_t tag, uint32_t state, @@ -74,7 +74,17 @@ static const wl_registry_listener registry_listener_impl = {.global = handle_glo .global_remove = handle_global_remove}; Window::Window(const std::string& id, const Bar& bar, const Json::Value& config) - : AAppIconLabel(config, "window", id, "{}", 0, true), bar_(bar) { + : AAppIconLabel(config, "window", id, "{}", 0, true), + bar_(bar), + active_(false), + hide_inactive_(false), + hide_empty_(false) { + if (config_["hide-inactive"].isBool()) { + hide_inactive_ = config["hide-inactive"].asBool(); + } + if (config_["hide-empty"].isBool()) { + hide_empty_ = config["hide-empty"].asBool(); + } struct wl_display* display = Client::inst()->wl_display; struct wl_registry* registry = wl_display_get_registry(display); @@ -102,6 +112,8 @@ void Window::handle_title(const char* title) { title_ = Glib::Markup::escape_tex void Window::handle_appid(const char* appid) { appid_ = Glib::Markup::escape_text(appid); } +void Window::handle_active(const uint32_t active) { active_ = active != 0; } + void Window::handle_layout_symbol(const char* layout_symbol) { layout_symbol_ = Glib::Markup::escape_text(layout_symbol); } @@ -118,6 +130,17 @@ void Window::handle_frame() { if (tooltipEnabled()) { label_.set_tooltip_markup(title_); } + if (hide_empty_ && title_.empty()) { + box_.set_visible(false); + } else { + if (active_) { + box_.get_style_context()->add_class("active"); + box_.set_visible(true); + } else { + box_.get_style_context()->remove_class("active"); + box_.set_visible(!hide_inactive_); + } + } } } // namespace waybar::modules::dwl diff --git a/src/modules/gamemode.cpp b/src/modules/gamemode.cpp index 691a2844..40970f0c 100644 --- a/src/modules/gamemode.cpp +++ b/src/modules/gamemode.cpp @@ -210,10 +210,7 @@ auto Gamemode::update() -> void { lastStatus = status; // Tooltip - if (tooltip) { - std::string text = fmt::format(fmt::runtime(tooltip_format), fmt::arg("count", gameCount)); - box_.set_tooltip_markup(text); - } + updateTooltip(box_, tooltip_format, fmt::arg("count", gameCount)); // Label format std::string str = fmt::format(fmt::runtime(showAltText ? format_alt : format), diff --git a/src/modules/gps.cpp b/src/modules/gps.cpp index a7bab659..285a6938 100644 --- a/src/modules/gps.cpp +++ b/src/modules/gps.cpp @@ -141,7 +141,7 @@ auto waybar::modules::Gps::update() -> void { // Show the module if (!event_box_.get_visible()) event_box_.set_visible(true); - std::string tooltip_format; + std::string tooltip_state; if (!alt_) { auto state = getFixModeName(); @@ -155,57 +155,27 @@ auto waybar::modules::Gps::update() -> void { } else { default_format_ = DEFAULT_FORMAT; } - if (config_["tooltip-format-" + state].isString()) { - tooltip_format = config_["tooltip-format-" + state].asString(); - } if (!label_.get_style_context()->has_class(state)) { label_.get_style_context()->add_class(state); } format_ = default_format_; state_ = state; + tooltip_state = state; } auto format = format_; - fmt::dynamic_format_arg_store store; - store.push_back(fmt::arg("mode", getFixModeString())); - store.push_back(fmt::arg("status", getFixStatusString())); - - store.push_back(fmt::arg("latitude", gps_data_.fix.latitude)); - store.push_back(fmt::arg("latitude_error", gps_data_.fix.epy)); - - store.push_back(fmt::arg("longitude", gps_data_.fix.longitude)); - store.push_back(fmt::arg("longitude_error", gps_data_.fix.epx)); - - store.push_back(fmt::arg("altitude_hae", gps_data_.fix.altHAE)); - store.push_back(fmt::arg("altitude_msl", gps_data_.fix.altMSL)); - store.push_back(fmt::arg("altitude_error", gps_data_.fix.epv)); - - store.push_back(fmt::arg("speed", gps_data_.fix.speed)); - store.push_back(fmt::arg("speed_error", gps_data_.fix.eps)); - - store.push_back(fmt::arg("climb", gps_data_.fix.climb)); - store.push_back(fmt::arg("climb_error", gps_data_.fix.epc)); - - store.push_back(fmt::arg("satellites_used", gps_data_.satellites_used)); - store.push_back(fmt::arg("satellites_visible", gps_data_.satellites_visible)); - - auto text = fmt::vformat(format, store); - - if (tooltipEnabled()) { - if (tooltip_format.empty() && config_["tooltip-format"].isString()) { - tooltip_format = config_["tooltip-format"].asString(); - } - if (!tooltip_format.empty()) { - auto tooltip_text = fmt::vformat(tooltip_format, store); - if (label_.get_tooltip_text() != tooltip_text) { - label_.set_tooltip_markup(tooltip_text); - } - } else if (label_.get_tooltip_text() != text) { - label_.set_tooltip_markup(text); - } - } - label_.set_markup(text); + updateLabelAndTooltipForState( + tooltip_state, format, format, fmt::arg("mode", getFixModeString()), + fmt::arg("status", getFixStatusString()), fmt::arg("latitude", gps_data_.fix.latitude), + fmt::arg("latitude_error", gps_data_.fix.epy), fmt::arg("longitude", gps_data_.fix.longitude), + fmt::arg("longitude_error", gps_data_.fix.epx), + fmt::arg("altitude_hae", gps_data_.fix.altHAE), + fmt::arg("altitude_msl", gps_data_.fix.altMSL), fmt::arg("altitude_error", gps_data_.fix.epv), + fmt::arg("speed", gps_data_.fix.speed), fmt::arg("speed_error", gps_data_.fix.eps), + fmt::arg("climb", gps_data_.fix.climb), fmt::arg("climb_error", gps_data_.fix.epc), + fmt::arg("satellites_used", gps_data_.satellites_used), + fmt::arg("satellites_visible", gps_data_.satellites_visible)); // Call parent update ALabel::update(); } diff --git a/src/modules/hyprland/backend.cpp b/src/modules/hyprland/backend.cpp index 08cf97c1..891bed1a 100644 --- a/src/modules/hyprland/backend.cpp +++ b/src/modules/hyprland/backend.cpp @@ -297,11 +297,40 @@ bool IPC::isLuaProtocol() { return *s_luaProtocolDetected_; } - // Probe: send a harmless old-style dispatch and check the error. - // In Lua-based Hyprland (>= 0.54) the error contains "hl.dispatch". - // In older versions it returns "ok" or a different error. - auto reply = getSocket1Reply("dispatch workspace __waybar_probe__"); - bool luaProto = reply.find("hl.dispatch") != std::string::npos; + // Detect the Lua-based dispatch protocol (Hyprland >= 0.54) via the read-only + // "version" query. This MUST have no side effects: an earlier probe issued a real + // "dispatch workspace __waybar_probe__", which on Hyprland < 0.54 actually switched + // the user to a junk workspace named __waybar_probe__ on the first click/scroll. + bool luaProto = false; + try { + util::JsonParser parser; + const Json::Value ver = parser.parse(getSocket1Reply("j/version")); + + // Prefer the numeric "version" field ("0.54.0"); fall back to the "tag" field + // ("v0.54.0" or "v0.54.0-16-gdeadbee"), which is present on all releases. + std::string versionStr = ver["version"].asString(); + if (versionStr.empty()) { + versionStr = ver["tag"].asString(); + } + + const size_t firstDigit = versionStr.find_first_of("0123456789"); + if (firstDigit != std::string::npos) { + // std::stoi parses the leading integer and stops at the first non-digit, so it + // tolerates the trailing ".patch-commits-ghash" suffix on the tag. + const int major = std::stoi(versionStr.substr(firstDigit)); + int minor = 0; + const size_t dot = versionStr.find('.', firstDigit); + if (dot != std::string::npos && dot + 1 < versionStr.size()) { + minor = std::stoi(versionStr.substr(dot + 1)); + } + luaProto = major > 0 || (major == 0 && minor >= 54); + } else { + spdlog::warn("Hyprland IPC: could not parse version '{}', assuming legacy protocol", + versionStr); + } + } catch (const std::exception& e) { + spdlog::warn("Hyprland IPC: version detection failed ({}), assuming legacy protocol", e.what()); + } if (luaProto) { spdlog::info("Hyprland IPC: detected Lua-based dispatch protocol (Hyprland >= 0.54)"); @@ -320,7 +349,7 @@ std::string IPC::buildLuaDispatch(const std::string& dispatcher, const std::stri // New format: /dispatch hl.dsp.focus({ workspace = "1" }) // // Old format: dispatch focusworkspaceoncurrentmonitor 2 - // New format: /dispatch hl.dsp.focus({ workspace = "2", monitor = "current" }) + // New format: /dispatch hl.dsp.focus({ workspace = "2", on_current_monitor = true }) // // Old format: dispatch togglespecialworkspace name // New format: /dispatch hl.dsp.workspace.toggle_special("name") @@ -329,7 +358,7 @@ std::string IPC::buildLuaDispatch(const std::string& dispatcher, const std::stri return "/dispatch hl.dsp.focus({ workspace = \"" + arg + "\" })"; } if (dispatcher == "focusworkspaceoncurrentmonitor") { - return "/dispatch hl.dsp.focus({ workspace = \"" + arg + "\", monitor = \"current\" })"; + return "/dispatch hl.dsp.focus({ workspace = \"" + arg + "\", on_current_monitor = true })"; } if (dispatcher == "togglespecialworkspace") { if (arg.empty()) { diff --git a/src/modules/hyprland/language.cpp b/src/modules/hyprland/language.cpp index 6e0fe23d..b0b1574f 100644 --- a/src/modules/hyprland/language.cpp +++ b/src/modules/hyprland/language.cpp @@ -30,6 +30,18 @@ Language::~Language() { auto Language::update() -> void { std::lock_guard lg(mutex_); + // Swap the layout CSS class here (main thread). onEvent() runs on the IPC + // thread and must never touch GTK -- that race corrupts the heap (#4665). + if (prev_short_name_ != layout_.short_name) { + if (!prev_short_name_.empty()) { + label_.get_style_context()->remove_class(prev_short_name_); + } + if (!layout_.short_name.empty()) { + label_.get_style_context()->add_class(layout_.short_name); + } + prev_short_name_ = layout_.short_name; + } + spdlog::debug("hyprland language update with full name {}", layout_.full_name); spdlog::debug("hyprland language update with short name {}", layout_.short_name); spdlog::debug("hyprland language update with short description {}", layout_.short_description); @@ -38,22 +50,62 @@ auto Language::update() -> void { std::string layoutName = std::string{}; if (config_.isMember("format-" + layout_.short_description + "-" + layout_.variant)) { const auto propName = "format-" + layout_.short_description + "-" + layout_.variant; - layoutName = fmt::format(fmt::runtime(format_), config_[propName].asString()); + layoutName = + trim(fmt::format(fmt::runtime(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("format-" + layout_.short_description)) { const auto propName = "format-" + layout_.short_description; - layoutName = fmt::format(fmt::runtime(format_), config_[propName].asString()); + layoutName = + trim(fmt::format(fmt::runtime(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 { layoutName = trim(fmt::format(fmt::runtime(format_), fmt::arg("long", layout_.full_name), fmt::arg("short", layout_.short_name), fmt::arg("shortDescription", layout_.short_description), fmt::arg("variant", layout_.variant))); } - spdlog::debug("hyprland language formatted layout name {}", layoutName); + std::string tooltipContent = std::string{}; + bool tooltip_enabled = tooltipEnabled(); + if (tooltip_enabled) { + // 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 = 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 = 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), + fmt::arg("short", layout_.short_name), + fmt::arg("shortDescription", layout_.short_description), + fmt::arg("variant", layout_.variant))); + } + spdlog::debug("hyprland language formatted tooltip content {}", tooltipContent); + } + if (!format_.empty()) { label_.show(); - label_.set_markup(layoutName); + setLabelMarkup(layoutName); + if (tooltip_enabled) { + setTooltipMarkup(tooltipContent); + } } else { label_.hide(); } @@ -74,8 +126,6 @@ void Language::onEvent(const std::string& ev) { spdlog::warn("hyprland language received malformed event payload: {}", ev); return; } - std::string kbName = payload.substr(0, kbSeparator); - // Last comma before variants parenthesis, eg: // activelayout>>micro-star-int'l-co.,-ltd.-msi-gk50-elite-gaming-keyboard,English (US, intl., // with dead keys) @@ -93,11 +143,19 @@ void Language::onEvent(const std::string& ev) { } auto layoutName = payload.substr(layoutSeparator + 1); - if (config_.isMember("keyboard-name") && kbName != config_["keyboard-name"].asString()) - return; // ignore + if (config_.isMember("keyboard-name")) { + const auto keyboardName = config_["keyboard-name"].asString(); + // The keyboard name itself can contain commas, so match it as a full prefix + // (followed by the ',' separator) rather than comparing against the substring + // before the first comma, which would truncate such names and drop the event. + if (payload.size() <= keyboardName.size() || payload[keyboardName.size()] != ',' || + payload.compare(0, keyboardName.size(), keyboardName) != 0) + return; // ignore + } layoutName = waybar::util::sanitize_string(layoutName); + // CSS class swap happens in update() on the main thread (#4665). layout_ = getLayout(layoutName); spdlog::debug("hyprland language onevent with {}", layoutName); diff --git a/src/modules/hyprland/submap.cpp b/src/modules/hyprland/submap.cpp index 36b9bf79..408e667e 100644 --- a/src/modules/hyprland/submap.cpp +++ b/src/modules/hyprland/submap.cpp @@ -20,7 +20,18 @@ Submap::Submap(const std::string& id, const Bar& bar, const Json::Value& config) // register for hyprland ipc m_ipc.registerForIPC("submap", this); - dp.emit(); + + if (config["icons"].isObject()) { + const Json::Value& icons = config["icons"]; + + for (const std::string& key : icons.getMemberNames()) { + const Json::Value& value = icons[key]; + + if (value.isString()) { + icons_[key] = value.asString(); + } + } + } } Submap::~Submap() { @@ -58,7 +69,8 @@ auto Submap::update() -> void { if (submap_.empty()) { event_box_.hide(); } else { - label_.set_markup(fmt::format(fmt::runtime(format_), submap_)); + label_.set_markup( + fmt::format(fmt::runtime(format_), fmt::arg("submap", submap_), fmt::arg("icon", icon_))); if (tooltipEnabled()) { label_.set_tooltip_markup(submap_); } @@ -84,6 +96,12 @@ void Submap::onEvent(const std::string& ev) { submap_ = submapName; + if (!icons_[submap_].empty()) { + icon_ = icons_[submap_]; + } else { + icon_ = ""; + } + if (submap_.empty() && always_on_) { submap_ = default_submap_; } diff --git a/src/modules/hyprland/window.cpp b/src/modules/hyprland/window.cpp index 2e3e0bb9..38f291f7 100644 --- a/src/modules/hyprland/window.cpp +++ b/src/modules/hyprland/window.cpp @@ -1,5 +1,6 @@ #include "modules/hyprland/window.hpp" +#include #include #include #include @@ -31,8 +32,6 @@ Window::Window(const std::string& id, const Bar& bar, const Json::Value& config) m_ipc.registerForIPC("changefloatingmode", this); m_ipc.registerForIPC("fullscreen", this); windowIpcUniqueLock.unlock(); - - dp.emit(); } Window::~Window() { @@ -53,13 +52,20 @@ auto Window::update() -> void { std::string label_text; if (!format_.empty()) { label_.show(); + + // If the focused window name is empty and fallback is configured, use fallback text + std::string displayTitle = windowName; + if (displayTitle.empty() && config_["fallback"].isString()) { + displayTitle = config_["fallback"].asString(); + } + label_text = waybar::util::rewriteString( - fmt::format(fmt::runtime(format_), fmt::arg("title", windowName), + fmt::format(fmt::runtime(format_), fmt::arg("title", displayTitle), fmt::arg("initialTitle", windowData_.initial_title), fmt::arg("class", windowData_.class_name), fmt::arg("initialClass", windowData_.initial_class_name)), config_["rewrite"]); - label_.set_markup(label_text); + setLabelMarkup(label_text); } else { label_.hide(); } @@ -70,13 +76,12 @@ auto Window::update() -> void { tooltip_format = config_["tooltip-format"].asString(); } if (!tooltip_format.empty()) { - label_.set_tooltip_markup( - fmt::format(fmt::runtime(tooltip_format), fmt::arg("title", windowName), - fmt::arg("initialTitle", windowData_.initial_title), - fmt::arg("class", windowData_.class_name), - fmt::arg("initialClass", windowData_.initial_class_name))); + setTooltipMarkup(fmt::format(fmt::runtime(tooltip_format), fmt::arg("title", windowName), + fmt::arg("initialTitle", windowData_.initial_title), + fmt::arg("class", windowData_.class_name), + fmt::arg("initialClass", windowData_.initial_class_name))); } else if (!label_text.empty()) { - label_.set_tooltip_markup(label_text); + setTooltipMarkup(label_text); } } @@ -108,21 +113,14 @@ auto Window::update() -> void { AAppIconLabel::update(); } -auto Window::getActiveWorkspace() -> Workspace { - const auto workspace = IPC::inst().getSocket1JsonReply("activeworkspace"); - - if (workspace.isObject()) { - return Workspace::parse(workspace); - } - - return {}; -} +auto Window::getActiveWorkspace() -> Workspace { return getActiveWorkspace(""); } auto Window::getActiveWorkspace(const std::string& monitorName) -> Workspace { const auto monitors = IPC::inst().getSocket1JsonReply("monitors"); if (monitors.isArray()) { - auto monitor = std::ranges::find_if( - monitors, [&](const Json::Value& monitor) { return monitor["name"] == monitorName; }); + auto monitor = std::ranges::find_if(monitors, [&](const Json::Value& monitor) { + return monitorName.empty() ? monitor["focused"].asBool() : monitor["name"] == monitorName; + }); if (monitor == std::end(monitors)) { spdlog::warn("Monitor not found: {}", monitorName); return Workspace{ @@ -132,7 +130,8 @@ auto Window::getActiveWorkspace(const std::string& monitorName) -> Workspace { .last_window_title = "", }; } - const int id = (*monitor)["activeWorkspace"]["id"].asInt(); + const int special_id = (*monitor)["specialWorkspace"]["id"].asInt(); + const int id = special_id != 0 ? special_id : (*monitor)["activeWorkspace"]["id"].asInt(); const auto workspaces = IPC::inst().getSocket1JsonReply("workspaces"); if (workspaces.isArray()) { @@ -220,9 +219,9 @@ void Window::queryActiveWorkspace() { std::vector visibleWindows; std::ranges::copy_if(workspaceWindows, std::back_inserter(visibleWindows), [&](const Json::Value& window) { return !window["hidden"].asBool(); }); - solo_ = 1 == std::count_if( - visibleWindows.begin(), visibleWindows.end(), - [&](const Json::Value& window) { return !window["floating"].asBool(); }); + solo_ = + 1 == std::count_if(visibleWindows.begin(), visibleWindows.end(), + [&](const Json::Value& window) { return !window["floating"].asBool(); }); allFloating_ = std::ranges::all_of( visibleWindows, [&](const Json::Value& window) { return window["floating"].asBool(); }); fullscreen_ = windowData_.fullscreen; diff --git a/src/modules/hyprland/windowcount.cpp b/src/modules/hyprland/windowcount.cpp index 5f5db80c..d0dcd96f 100644 --- a/src/modules/hyprland/windowcount.cpp +++ b/src/modules/hyprland/windowcount.cpp @@ -19,7 +19,6 @@ WindowCount::WindowCount(const std::string& id, const Bar& bar, const Json::Valu queryActiveWorkspace(); update(); - dp.emit(); // register for hyprland ipc m_ipc.registerForIPC("fullscreen", this); diff --git a/src/modules/hyprland/workspace.cpp b/src/modules/hyprland/workspace.cpp index 753893f2..79ff5c3d 100644 --- a/src/modules/hyprland/workspace.cpp +++ b/src/modules/hyprland/workspace.cpp @@ -1,7 +1,11 @@ +#include #include #include +#include +#include #include +#include #include #include @@ -9,6 +13,33 @@ #include "util/command.hpp" #include "util/icon_loader.hpp" +namespace { +constexpr std::string_view kCssClassPrefix = "ws-"; + +// Convert a workspace name to a valid CSS class name. +// Lowercases, replaces non-alphanumeric runs with single hyphens, +// and prefixes digit-leading names (CSS classes can't start with a digit). +std::string sanitizeCssClass(const std::string& name) { + std::string result; + result.reserve(name.size() + kCssClassPrefix.size()); + for (auto c : name) { + auto uc = static_cast(c); + if (std::isalnum(uc)) { + result += static_cast(std::tolower(uc)); + } else if (!result.empty() && result.back() != '-') { + result += '-'; + } + } + if (!result.empty() && result.back() == '-') { + result.pop_back(); + } + if (!result.empty() && std::isdigit(static_cast(result.front()))) { + result.insert(0, kCssClassPrefix); + } + return result; +} +} // namespace + namespace waybar::modules::hyprland { Workspace::Workspace(const Json::Value& workspace_data, Workspaces& workspace_manager, @@ -30,6 +61,11 @@ Workspace::Workspace(const Json::Value& workspace_data, Workspaces& workspace_ma } m_button.add_events(Gdk::BUTTON_PRESS_MASK); + m_button.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); + + m_button.signal_enter_notify_event().connect(sigc::mem_fun(*this, &Workspace::handleEnter)); + m_button.signal_leave_notify_event().connect(sigc::mem_fun(*this, &Workspace::handleLeave)); + m_button.signal_button_press_event().connect(sigc::mem_fun(*this, &Workspace::handleClicked), false); @@ -45,6 +81,12 @@ Workspace::Workspace(const Json::Value& workspace_data, Workspaces& workspace_ma initializeWindowMap(clients_data); } +Workspace::~Workspace() { + // Disconnect the hover-check timeout so it can't fire on this destroyed + // instance (Workspaces are removed at runtime while a check may be armed). + stopHoverCheck(); +} + void addOrRemoveClass(const Glib::RefPtr& context, bool condition, const std::string& class_name) { if (condition) { @@ -66,6 +108,100 @@ std::optional Workspace::closeWindow(WindowAddress const& addr) { return std::nullopt; } +bool Workspace::pointerInsideButton() { + auto display = Gdk::Display::get_default(); + if (!display) { + return false; + } + + auto seat = display->get_default_seat(); + if (!seat) { + return false; + } + + auto pointer = seat->get_pointer(); + if (!pointer) { + return false; + } + + Glib::RefPtr screen; + int pointerRootX = 0; + int pointerRootY = 0; + + pointer->get_position(screen, pointerRootX, pointerRootY); + + Gtk::Widget* toplevel = m_button.get_toplevel(); + if (toplevel == nullptr || !toplevel->get_window()) { + return false; + } + + int buttonX = 0; + int buttonY = 0; + + if (!m_button.translate_coordinates(*toplevel, 0, 0, buttonX, buttonY)) { + return false; + } + + int windowRootX = 0; + int windowRootY = 0; + toplevel->get_window()->get_root_origin(windowRootX, windowRootY); + + const auto allocation = m_button.get_allocation(); + + const int buttonRootX = windowRootX + buttonX; + const int buttonRootY = windowRootY + buttonY; + const int buttonWidth = allocation.get_width(); + const int buttonHeight = allocation.get_height(); + + return pointerRootX >= buttonRootX && pointerRootY >= buttonRootY && + pointerRootX < buttonRootX + buttonWidth && pointerRootY < buttonRootY + buttonHeight; +} + +bool Workspace::syncHoverClass() { + auto styleContext = m_button.get_style_context(); + + if (pointerInsideButton()) { + styleContext->add_class("workspace-hover"); + return true; + } + + styleContext->remove_class("workspace-hover"); + stopHoverCheck(); + return false; +} + +void Workspace::startHoverCheck() { + if (m_hoverCheckConnection.connected()) { + return; + } + + m_hoverCheckConnection = + Glib::signal_timeout().connect(sigc::mem_fun(*this, &Workspace::syncHoverClass), 50); +} + +void Workspace::stopHoverCheck() { + if (m_hoverCheckConnection.connected()) { + m_hoverCheckConnection.disconnect(); + } +} + +bool Workspace::handleEnter(GdkEventCrossing* /*event*/) { + m_button.get_style_context()->add_class("workspace-hover"); + startHoverCheck(); + return false; +} + +bool Workspace::handleLeave(GdkEventCrossing* /*event*/) { + /* + * Do not remove immediately. + * Workspace taskbar children can fire misleading leave events while the + * pointer is still visually inside the workspace button. + * + * The polling check will remove the class once the pointer really leaves. + */ + startHoverCheck(); + return false; +} bool Workspace::handleClicked(GdkEventButton* bt) const { if (bt->type == GDK_BUTTON_PRESS) { try { @@ -115,7 +251,10 @@ void Workspace::setActiveWindow(WindowAddress const& addr) { } auto activeWindowPos = m_workspaceManager.activeWindowPosition(); - if (activeIdx.has_value() && activeWindowPos != Workspaces::ActiveWindowPosition::NONE) { + const bool has_active_window = + activeIdx.has_value() && activeWindowPos != Workspaces::ActiveWindowPosition::NONE; + + if (has_active_window) { auto window = std::move(m_windowMap[*activeIdx]); m_windowMap.erase(m_windowMap.begin() + *activeIdx); if (activeWindowPos == Workspaces::ActiveWindowPosition::FIRST) { @@ -130,19 +269,24 @@ void Workspace::insertWindow(WindowCreationPayload create_window_payload) { if (!create_window_payload.isEmpty(m_workspaceManager)) { auto repr = create_window_payload.repr(m_workspaceManager); - if (!repr.empty() || m_workspaceManager.enableTaskbar()) { + const bool should_display = !repr.empty() || m_workspaceManager.enableTaskbar(); + + if (should_display) { auto addr = create_window_payload.getAddress(); auto it = std::ranges::find_if( m_windowMap, [&addr](const auto& window) { return window.address == addr; }); // If the vector contains the address, update the window representation, otherwise insert it if (it != m_windowMap.end()) { *it = repr; - } else { + } else if (!m_workspaceManager.uniqueIcons() || repr.repr_rewrite.empty() || + std::ranges::find_if(m_windowMap, [&repr](const auto& window) { + return window.repr_rewrite == repr.repr_rewrite; + }) == m_windowMap.end()) { m_windowMap.emplace_back(repr); } } } -}; +} bool Workspace::onWindowOpened(WindowCreationPayload const& create_window_payload) { if (create_window_payload.getWorkspaceName() == name()) { @@ -152,16 +296,37 @@ bool Workspace::onWindowOpened(WindowCreationPayload const& create_window_payloa return false; } -std::string& Workspace::selectIcon(std::map& icons_map) { - spdlog::trace("Selecting icon for workspace {}", name()); +std::string& Workspace::selectString(std::map& icons_map) { + spdlog::trace("Selecting string for workspace {}", name()); if (isUrgent()) { + auto urgentNamedIconIt = icons_map.find("urgent:" + name()); + if (urgentNamedIconIt != icons_map.end()) { + return urgentNamedIconIt->second; + } + auto urgentIconIt = icons_map.find("urgent"); if (urgentIconIt != icons_map.end()) { return urgentIconIt->second; } } + if (isActive() && isSpecial()) { + auto activeIconIt = icons_map.find("active:" + name()); + if (activeIconIt != icons_map.end()) { + return activeIconIt->second; + } + auto namedIconIt = icons_map.find(name()); + if (namedIconIt != icons_map.end()) { + return namedIconIt->second; + } + } + if (isActive()) { + auto activeNamedIconIt = icons_map.find("active:" + name()); + if (activeNamedIconIt != icons_map.end()) { + return activeNamedIconIt->second; + } + auto activeIconIt = icons_map.find("active"); if (activeIconIt != icons_map.end()) { return activeIconIt->second; @@ -169,6 +334,11 @@ std::string& Workspace::selectIcon(std::map& icons_map } if (isSpecial()) { + auto specialNamedIconIt = icons_map.find("special:" + name()); + if (specialNamedIconIt != icons_map.end()) { + return specialNamedIconIt->second; + } + auto specialIconIt = icons_map.find("special"); if (specialIconIt != icons_map.end()) { return specialIconIt->second; @@ -209,12 +379,21 @@ std::string& Workspace::selectIcon(std::map& icons_map return m_name; } -void Workspace::update(const std::string& workspace_icon) { +void Workspace::update(const std::string& workspace_icon, const std::string& workspace_tooltip) { if (this->m_workspaceManager.persistentOnly() && !this->isPersistent()) { m_button.hide(); return; } // clang-format off + if (this->m_workspaceManager.hideActive() && \ + this->isActive() && \ + !this->isPersistent() && \ + !this->isSpecial()) { + // clang-format on + m_button.hide(); + return; + } + // clang-format off if (this->m_workspaceManager.activeOnly() && \ !this->isActive() && \ !this->isPersistent() && \ @@ -234,29 +413,89 @@ void Workspace::update(const std::string& workspace_icon) { auto styleContext = m_button.get_style_context(); addOrRemoveClass(styleContext, isActive(), "active"); addOrRemoveClass(styleContext, isSpecial(), "special"); + addOrRemoveClass(styleContext, isSpecial(), name()); addOrRemoveClass(styleContext, isEmpty(), "empty"); addOrRemoveClass(styleContext, isPersistent(), "persistent"); addOrRemoveClass(styleContext, isUrgent(), "urgent"); addOrRemoveClass(styleContext, isVisible(), "visible"); addOrRemoveClass(styleContext, m_workspaceManager.getBarOutput() == output(), "hosting-monitor"); + // Add workspace name as CSS class for per-workspace styling + if (!m_prevNameClass.empty()) { + styleContext->remove_class(m_prevNameClass); + m_prevNameClass.clear(); + } + auto nameClass = sanitizeCssClass(name()); + if (!nameClass.empty()) { + styleContext->add_class(nameClass); + m_prevNameClass = nameClass; + } + std::string windows; // Optimization: The {windows} substitution string is only possible if the taskbar is disabled, no // need to compute this if enableTaskbar() is true if (!m_workspaceManager.enableTaskbar()) { auto windowSeparator = m_workspaceManager.getWindowSeparator(); + auto groupThreshold = m_workspaceManager.windowRewriteGroupThreshold(); - bool isNotFirst = false; + auto end_it = (m_workspaceManager.maxWindows() <= 0 || + static_cast(m_workspaceManager.maxWindows()) >= m_windowMap.size()) + ? m_windowMap.end() + : m_windowMap.begin() + m_workspaceManager.maxWindows(); - for (const auto& window_repr : m_windowMap) { - if (isNotFirst) { - windows.append(windowSeparator); + if (groupThreshold > 0) { + // Build ordered counts of each unique icon (including singular ones when threshold set to 1) + std::vector> iconCounts; + for (auto it = m_windowMap.begin(); it != end_it; ++it) { + const auto& window_repr = *it; + auto found = std::ranges::find_if( + iconCounts, [&](const auto& p) { return p.first == window_repr.repr_rewrite; }); + if (found != iconCounts.end()) { + found->second++; + } else { + iconCounts.emplace_back(window_repr.repr_rewrite, 1); + } + } + + // Format the group string + auto groupFormat = m_workspaceManager.getWindowRewriteGroupFormat(); + bool isNotFirst = false; + for (const auto& [icon, count] : iconCounts) { + if (count >= groupThreshold) { + if (isNotFirst) windows.append(windowSeparator); + isNotFirst = true; + try { + windows.append(fmt::format(fmt::runtime(groupFormat), fmt::arg("icon", icon), + fmt::arg("count", count))); + } catch (const fmt::format_error& e) { + spdlog::warn("Formatting window-rewrite-group-format error: {}", e.what()); + windows.append(icon); + } + } else { + for (int i = 0; i < count; ++i) { + if (isNotFirst) windows.append(windowSeparator); + isNotFirst = true; + windows.append(icon); + } + } + } + } else { + // Not grouping icons + bool isNotFirst = false; + for (auto it = m_windowMap.begin(); it != end_it; ++it) { + if (isNotFirst) windows.append(windowSeparator); + isNotFirst = true; + windows.append(it->repr_rewrite); } - isNotFirst = true; - windows.append(window_repr.repr_rewrite); } } + if (!workspace_tooltip.empty()) { + m_button.set_tooltip_text( + fmt::format(fmt::runtime(workspace_tooltip), fmt::arg("id", id()), fmt::arg("name", name()), + fmt::arg("icon", workspace_icon), fmt::arg("windows", windows))); + } + auto formatBefore = m_workspaceManager.formatBefore(); m_labelBefore.set_markup(fmt::format(fmt::runtime(formatBefore), fmt::arg("id", id()), fmt::arg("name", name()), fmt::arg("icon", workspace_icon), @@ -270,7 +509,9 @@ void Workspace::update(const std::string& workspace_icon) { bool Workspace::isEmpty() const { auto ignore_list = m_workspaceManager.getIgnoredWindows(); - if (ignore_list.empty()) { + const bool no_ignore_rules = ignore_list.empty(); + + if (no_ignore_rules) { return m_windows == 0; } // If there are windows but they are all ignored, consider the workspace empty @@ -286,11 +527,47 @@ void Workspace::updateTaskbar(const std::string& workspace_icon) { } } - bool isFirst = true; - auto processWindow = [&](const WindowRepr& window_repr) { + // Build a list of windows to display, removing duplicates by window_class + // and respecting max-icons limit + std::vector windowsToShow; + std::set seenClasses; + + auto addWindowIfUnique = [&](const WindowRepr& window_repr) { if (shouldSkipWindow(window_repr)) { - return; // skip + return; } + // Deduplicate by window_class + if (seenClasses.find(window_repr.window_class) != seenClasses.end()) { + return; + } + seenClasses.insert(window_repr.window_class); + windowsToShow.push_back(&window_repr); + }; + + if (m_workspaceManager.taskbarReverseDirection()) { + for (auto it = m_windowMap.rbegin(); it != m_windowMap.rend(); ++it) { + addWindowIfUnique(*it); + } + } else { + for (const auto& window_repr : m_windowMap) { + addWindowIfUnique(window_repr); + } + } + + // Apply max-icons limit if configured + int maxIcons = m_workspaceManager.taskbarMaxIcons(); + if (maxIcons > 0 && static_cast(windowsToShow.size()) > maxIcons) { + windowsToShow.resize(maxIcons); + } + + // Apply max-windows limit if configured + int maxWindows = m_workspaceManager.maxWindows(); + if (maxWindows > 0 && static_cast(windowsToShow.size()) > maxWindows) { + windowsToShow.resize(maxWindows); + } + + bool isFirst = true; + for (const auto* window_repr : windowsToShow) { if (isFirst) { isFirst = false; } else if (m_workspaceManager.getWindowSeparator() != "") { @@ -300,27 +577,29 @@ void Workspace::updateTaskbar(const std::string& workspace_icon) { } auto window_box = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL); - window_box->set_tooltip_markup(window_repr.window_title); - window_box->get_style_context()->add_class("taskbar-window"); - if (window_repr.isActive) { - window_box->get_style_context()->add_class("active"); + window_box->set_tooltip_markup(window_repr->window_title); + + auto button = Gtk::manage(new Gtk::Button()); + button->set_relief(Gtk::RELIEF_NONE); + button->add(*window_box); + button->get_style_context()->add_class("taskbar-window"); + if (window_repr->isActive) { + button->get_style_context()->add_class("active"); } - auto event_box = Gtk::manage(new Gtk::EventBox()); - event_box->add(*window_box); if (m_workspaceManager.onClickWindow() != "") { - event_box->signal_button_press_event().connect( - sigc::bind(sigc::mem_fun(*this, &Workspace::handleClick), window_repr.address)); + button->signal_button_press_event().connect( + sigc::bind(sigc::mem_fun(*this, &Workspace::handleClick), window_repr->address), false); } auto text_before = fmt::format(fmt::runtime(m_workspaceManager.taskbarFormatBefore()), - fmt::arg("title", window_repr.window_title)); + fmt::arg("title", window_repr->window_title)); if (!text_before.empty()) { auto window_label_before = Gtk::make_managed(text_before); window_box->pack_start(*window_label_before, true, true); } if (m_workspaceManager.taskbarWithIcon()) { - auto app_info_ = IconLoader::get_app_info_from_app_id_list(window_repr.window_class); + auto app_info_ = IconLoader::get_app_info_from_app_id_list(window_repr->window_class); int icon_size = m_workspaceManager.taskbarIconSize(); auto window_icon = Gtk::make_managed(); m_workspaceManager.iconLoader().image_load_icon(*window_icon, app_info_, icon_size); @@ -328,28 +607,20 @@ void Workspace::updateTaskbar(const std::string& workspace_icon) { } auto text_after = fmt::format(fmt::runtime(m_workspaceManager.taskbarFormatAfter()), - fmt::arg("title", window_repr.window_title)); + fmt::arg("title", window_repr->window_title)); if (!text_after.empty()) { auto window_label_after = Gtk::make_managed(text_after); window_box->pack_start(*window_label_after, true, true); } - m_content.pack_start(*event_box, true, false); - event_box->show_all(); - }; - - if (m_workspaceManager.taskbarReverseDirection()) { - for (auto it = m_windowMap.rbegin(); it != m_windowMap.rend(); ++it) { - processWindow(*it); - } - } else { - for (const auto& window_repr : m_windowMap) { - processWindow(window_repr); - } + m_content.pack_start(*button, true, false); + button->show_all(); } auto formatAfter = m_workspaceManager.formatAfter(); - if (!formatAfter.empty()) { + const bool has_format_after = !formatAfter.empty(); + + if (has_format_after) { m_labelAfter.set_markup(fmt::format(fmt::runtime(formatAfter), fmt::arg("id", id()), fmt::arg("name", name()), fmt::arg("icon", workspace_icon))); diff --git a/src/modules/hyprland/workspaces.cpp b/src/modules/hyprland/workspaces.cpp index 2496117f..6f43f208 100644 --- a/src/modules/hyprland/workspaces.cpp +++ b/src/modules/hyprland/workspaces.cpp @@ -37,6 +37,11 @@ Workspaces::~Workspaces() { if (m_scrollEventConnection_.connected()) { m_scrollEventConnection_.disconnect(); } + // Cancel any pending debounce timeout so it cannot fire on a freed `this`. + // Runs on the main thread, same as where the timer is armed. + if (m_debounceTimer.connected()) { + m_debounceTimer.disconnect(); + } m_ipc.unregisterForIPC(this); // wait for possible event handler to finish std::lock_guard lg(m_mutex); @@ -50,6 +55,7 @@ void Workspaces::init() { if (m_scrollEventConnection_.connected()) { m_scrollEventConnection_.disconnect(); } + if (barScroll()) { auto& window = const_cast(m_bar).window; window.add_events(Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); @@ -78,18 +84,13 @@ Json::Value Workspaces::createMonitorWorkspaceData(std::string const& name, void Workspaces::createWorkspace(Json::Value const& workspace_data, Json::Value const& clients_data) { - auto workspaceName = workspace_data["name"].asString(); auto workspaceId = workspace_data["id"].asInt(); - spdlog::debug("Creating workspace {}", workspaceName); + spdlog::debug("Creating workspace {}", workspaceId); // avoid recreating existing workspaces - auto workspace = std::ranges::find_if(m_workspaces, [&](std::unique_ptr const& w) { - if (workspaceId > 0) { - return w->id() == workspaceId; - } - return (workspaceName.starts_with("special:") && workspaceName.substr(8) == w->name()) || - workspaceName == w->name(); - }); + auto workspace = std::ranges::find_if( + m_workspaces, + [workspaceId](std::unique_ptr const& w) { return workspaceId == w->id(); }); if (workspace != m_workspaces.end()) { // don't recreate workspace, but update persistency if necessary @@ -97,14 +98,14 @@ void Workspaces::createWorkspace(Json::Value const& workspace_data, const auto* k = "persistent-rule"; if (std::ranges::find(keys, k) != keys.end()) { - spdlog::debug("Set dynamic persistency of workspace {} to: {}", workspaceName, + spdlog::debug("Set dynamic persistency of workspace {} to: {}", workspaceId, workspace_data[k].asBool() ? "true" : "false"); (*workspace)->setPersistentRule(workspace_data[k].asBool()); } k = "persistent-config"; if (std::ranges::find(keys, k) != keys.end()) { - spdlog::debug("Set config persistency of workspace {} to: {}", workspaceName, + spdlog::debug("Set config persistency of workspace {} to: {}", workspaceId, workspace_data[k].asBool() ? "true" : "false"); (*workspace)->setPersistentConfig(workspace_data[k].asBool()); } @@ -220,8 +221,6 @@ void Workspaces::initializeWorkspaces() { // a persistent workspace config is defined, so use that instead of workspace rules loadPersistentWorkspacesFromConfig(clientsJson); } - // load Hyprland's workspace rules - loadPersistentWorkspacesFromWorkspaceRules(clientsJson); } bool isDoubleSpecial(std::string const& workspace_name) { @@ -296,87 +295,54 @@ void Workspaces::loadPersistentWorkspacesFromConfig(Json::Value const& clientsJs } } -void Workspaces::loadPersistentWorkspacesFromWorkspaceRules(const Json::Value& clientsJson) { - spdlog::info("Loading persistent workspaces from Hyprland workspace rules"); - - auto const workspaceRules = m_ipc.getSocket1JsonReply("workspacerules"); - for (Json::Value const& rule : workspaceRules) { - if (!rule["workspaceString"].isString()) { - spdlog::warn("Workspace rules: invalid workspaceString, skipping: {}", rule); - continue; - } - if (!rule["persistent"].asBool()) { - continue; - } - auto workspace = rule.isMember("defaultName") ? rule["defaultName"].asString() - : rule["workspaceString"].asString(); - - // There could be persistent special workspaces, only show those when show-special is enabled. - if (workspace.starts_with("special:") && !showSpecial()) { - continue; - } - - // The prefix "name:" cause mismatches with workspace names taken anywhere else. - if (workspace.starts_with("name:")) { - workspace = workspace.substr(5); - } - auto const& monitor = rule["monitor"].asString(); - // create this workspace persistently if: - // 1. the allOutputs config option is enabled - // 2. the rule's monitor is the current monitor - // 3. no monitor is specified in the rule => assume it needs to be persistent on every monitor - if (allOutputs() || m_bar.output->name == monitor || monitor.empty()) { - // => persistent workspace should be shown on this monitor - auto workspaceData = createMonitorWorkspaceData(workspace, m_bar.output->name); - workspaceData["persistent-rule"] = true; - m_workspacesToCreate.emplace_back(workspaceData, clientsJson); - } else { - // This can be any workspace selector. - m_workspacesToRemove.emplace_back(workspace); - } - } -} - void Workspaces::onEvent(const std::string& ev) { - std::lock_guard lock(m_mutex); - const auto separator = ev.find(">>"); - if (separator == std::string::npos) { - spdlog::warn("Malformed Hyprland workspace event: {}", ev); - return; - } - std::string eventName = ev.substr(0, separator); - std::string payload = ev.substr(separator + 2); + { + std::lock_guard lock(m_mutex); + const auto separator = ev.find(">>"); + if (separator == std::string::npos) { + spdlog::warn("Malformed Hyprland workspace event: {}", ev); + return; + } + std::string eventName = ev.substr(0, separator); + std::string payload = ev.substr(separator + 2); - if (eventName == "workspacev2") { - onWorkspaceActivated(payload); - } else if (eventName == "activespecial") { - onSpecialWorkspaceActivated(payload); - } else if (eventName == "destroyworkspacev2") { - onWorkspaceDestroyed(payload); - } else if (eventName == "createworkspacev2") { - onWorkspaceCreated(payload); - } else if (eventName == "focusedmonv2") { - onMonitorFocused(payload); - } else if (eventName == "moveworkspacev2") { - onWorkspaceMoved(payload); - } else if (eventName == "openwindow") { - onWindowOpened(payload); - } else if (eventName == "closewindow") { - onWindowClosed(payload); - } else if (eventName == "movewindowv2") { - onWindowMoved(payload); - } else if (eventName == "urgent") { - setUrgentWorkspace(payload); - } else if (eventName == "renameworkspace") { - onWorkspaceRenamed(payload); - } else if (eventName == "windowtitlev2") { - onWindowTitleEvent(payload); - } else if (eventName == "activewindowv2") { - onActiveWindowChanged(payload); - } else if (eventName == "configreloaded") { - onConfigReloaded(); + if (eventName == "workspacev2") { + onWorkspaceActivated(payload); + } else if (eventName == "activespecial") { + onSpecialWorkspaceActivated(payload); + } else if (eventName == "destroyworkspacev2") { + onWorkspaceDestroyed(payload); + } else if (eventName == "changeworkspaceid") { + onWorkspaceIdChanged(payload); + } else if (eventName == "createworkspacev2") { + onWorkspaceCreated(payload); + } else if (eventName == "focusedmonv2") { + onMonitorFocused(payload); + } else if (eventName == "moveworkspacev2") { + onWorkspaceMoved(payload); + } else if (eventName == "openwindow") { + onWindowOpened(payload); + } else if (eventName == "closewindow") { + onWindowClosed(payload); + } else if (eventName == "movewindowv2") { + onWindowMoved(payload); + } else if (eventName == "urgent") { + setUrgentWorkspace(payload); + } else if (eventName == "renameworkspace") { + onWorkspaceRenamed(payload); + } else if (eventName == "windowtitlev2") { + onWindowTitleEvent(payload); + } else if (eventName == "activewindowv2") { + onActiveWindowChanged(payload); + } else if (eventName == "configreloaded") { + onConfigReloaded(); + } } + // Notify the main thread. dp (Glib::Dispatcher) is the only thread-safe way to + // hand off to the GTK main loop; GLib timer state must never be touched from the + // IPC listener thread. The debounce timer is owned entirely by the main-thread + // update() path (see Workspaces::update). dp.emit(); } @@ -485,6 +451,35 @@ void Workspaces::onWorkspaceRenamed(std::string const& payload) { sortWorkspaces(); } +void Workspaces::onWorkspaceIdChanged(std::string const& payload) { + spdlog::debug("Workspace ID changed: {}", payload); + + const auto [oldIdStr, newIdStr] = splitDoublePayload(payload); + + const auto oldId = parseWorkspaceId(oldIdStr); + const auto newId = parseWorkspaceId(newIdStr); + + if (!oldId.has_value() || !newId.has_value()) { + spdlog::warn("Invalid workspace ID change payload: {}", payload); + return; + } + + for (auto& workspace : m_workspaces) { + if (workspace->id() == *oldId) { + spdlog::debug("Changing workspace ID from {} to {}", *oldId, *newId); + workspace->setId(*newId); + break; + } + } + + if (m_activeWorkspaceId == *oldId) { + m_activeWorkspaceId = *newId; + } + + sortWorkspaces(); + updateWindowCount(); +} + void Workspaces::onMonitorFocused(std::string const& payload) { spdlog::trace("Monitor focused: {}", payload); @@ -652,19 +647,41 @@ auto Workspaces::parseConfig(const Json::Value& config) -> void { populateIconsMap(config["format-icons"]); } + m_withTooltip = tooltipEnabled(); + if (m_withTooltip && m_tooltipMap.empty()) { + const Json::Value& tooltipFormats = config["tooltips"]; + for (const auto& name : tooltipFormats.getMemberNames()) { + m_tooltipMap.emplace(name, tooltipFormats[name].asString()); + } + m_tooltipMap.emplace("", ""); + } + populateBoolConfig(config, "all-outputs", m_allOutputs); populateBoolConfig(config, "show-special", m_showSpecial); populateBoolConfig(config, "special-visible-only", m_specialVisibleOnly); populateBoolConfig(config, "persistent-only", m_persistentOnly); populateBoolConfig(config, "active-only", m_activeOnly); + populateBoolConfig(config, "hide-active", m_hideActive); populateBoolConfig(config, "move-to-monitor", m_moveToMonitor); + populateBoolConfig(config, "unique-icons", m_uniqueIcons); populateBoolConfig(config, "enable-bar-scroll", m_barScroll); m_persistentWorkspaceConfig = config.get("persistent-workspaces", Json::Value()); populateSortByConfig(config); populateIgnoreWorkspacesConfig(config); populateFormatWindowSeparatorConfig(config); + + const auto& groupThreshold = config["window-rewrite-group-threshold"]; + if (groupThreshold.isInt()) { + m_windowRewriteGroupThreshold = groupThreshold.asInt(); + } + const auto& groupFormat = config["window-rewrite-group-format"]; + if (groupFormat.isString()) { + m_windowRewriteGroupFormat = groupFormat.asString(); + } + populateWindowRewriteConfig(config); + populateMaxWindowsConfig(config); if (withWindows) { populateWorkspaceTaskbarConfig(config); @@ -696,7 +713,7 @@ auto Workspaces::populateSortByConfig(const Json::Value& config) -> void { if (configSortBy.isString()) { auto sortByStr = configSortBy.asString(); try { - m_sortBy = m_enumParser.parseStringToEnum(sortByStr, m_sortMap); + m_sortBy = waybar::util::parseStringToEnum(sortByStr, m_sortMap); } catch (const std::invalid_argument& e) { m_sortBy = SortMethod::DEFAULT; spdlog::warn( @@ -746,6 +763,15 @@ auto Workspaces::populateWindowRewriteConfig(const Json::Value& config) -> void [this](std::string& window_rule) { return windowRewritePriorityFunction(window_rule); }); } +auto Workspaces::populateMaxWindowsConfig(const Json::Value& config) -> void { + if (config["max-windows"].isInt()) { + m_maxWindows = config["max-windows"].asInt(); + if (m_maxWindows < 0) { + m_maxWindows = 0; + } + } +} + auto Workspaces::populateWorkspaceTaskbarConfig(const Json::Value& config) -> void { const auto& workspaceTaskbar = config["workspace-taskbar"]; if (!workspaceTaskbar.isObject()) { @@ -784,6 +810,9 @@ auto Workspaces::populateWorkspaceTaskbarConfig(const Json::Value& config) -> vo if (workspaceTaskbar["icon-size"].isInt()) { m_taskbarIconSize = workspaceTaskbar["icon-size"].asInt(); } + if (workspaceTaskbar["max-icons"].isInt()) { + m_taskbarMaxIcons = workspaceTaskbar["max-icons"].asInt(); + } if (workspaceTaskbar["orientation"].isString() && toLower(workspaceTaskbar["orientation"].asString()) == "vertical") { m_taskbarOrientation = Gtk::ORIENTATION_VERTICAL; @@ -808,7 +837,7 @@ auto Workspaces::populateWorkspaceTaskbarConfig(const Json::Value& config) -> vo auto posStr = workspaceTaskbar["active-window-position"].asString(); try { m_activeWindowPosition = - m_activeWindowEnumParser.parseStringToEnum(posStr, m_activeWindowPositionMap); + util::parseStringToEnum(posStr, m_activeWindowPositionMap); } catch (const std::invalid_argument& e) { spdlog::warn( "Invalid string representation for active-window-position. Falling back to 'none'."); @@ -829,6 +858,7 @@ auto Workspaces::registerIpc() -> void { m_ipc.registerForIPC("createworkspacev2", this); m_ipc.registerForIPC("destroyworkspacev2", this); m_ipc.registerForIPC("focusedmonv2", this); + m_ipc.registerForIPC("changeworkspaceid", this); m_ipc.registerForIPC("moveworkspacev2", this); m_ipc.registerForIPC("renameworkspace", this); m_ipc.registerForIPC("openwindow", this); @@ -1036,17 +1066,27 @@ void Workspaces::setUrgentWorkspace(std::string const& windowaddress) { } auto Workspaces::update() -> void { - doUpdate(); - AModule::update(); + // Debounce rapid events (e.g. out-of-order create/destroy workspace events from + // Hyprland) to prevent workspace button flicker. This runs on the GTK main thread + // (invoked via the dp dispatcher), so arming/disconnecting the GLib timer here is + // thread-safe. Each event re-arms the timer, coalescing bursts into one refresh. + if (m_debounceTimer.connected()) { + m_debounceTimer.disconnect(); + } + m_debounceTimer = Glib::signal_timeout().connect( + [this]() { + doUpdate(); + AModule::update(); + return false; + }, + 7); } void Workspaces::updateWindowCount() { const Json::Value workspacesJson = m_ipc.getSocket1JsonReply("workspaces"); for (auto const& workspace : m_workspaces) { - auto workspaceJson = std::ranges::find_if(workspacesJson, [&](Json::Value const& x) { - return x["name"].asString() == workspace->name() || - (workspace->isSpecial() && x["name"].asString() == "special:" + workspace->name()); - }); + auto workspaceJson = std::ranges::find_if( + workspacesJson, [&](Json::Value const& x) { return x["id"].asInt() == workspace->id(); }); uint32_t count = 0; if (workspaceJson != workspacesJson.end()) { try { @@ -1107,17 +1147,19 @@ void Workspaces::updateWorkspaceStates() { visibleWorkspaces.end()); std::string& workspaceIcon = m_iconsMap[""]; if (m_withIcon) { - workspaceIcon = workspace->selectIcon(m_iconsMap); + workspaceIcon = workspace->selectString(m_iconsMap); + } + std::string& workspaceTooltip = m_tooltipMap[""]; + if (m_withTooltip) { + workspaceTooltip = workspace->selectString(m_tooltipMap); } auto updatedWorkspace = std::ranges::find_if(updatedWorkspaces, [&workspace](const auto& w) { - auto wNameRaw = w["name"].asString(); - auto wName = wNameRaw.starts_with("special:") ? wNameRaw.substr(8) : wNameRaw; - return wName == workspace->name(); + return w["id"].asInt() == workspace->id(); }); if (updatedWorkspace != updatedWorkspaces.end()) { workspace->setOutput((*updatedWorkspace)["monitor"].asString()); } - workspace->update(workspaceIcon); + workspace->update(workspaceIcon, workspaceTooltip); } } @@ -1188,6 +1230,12 @@ bool Workspaces::handleScroll(GdkEventScroll* e) { if (gdk_event_get_pointer_emulated((GdkEvent*)e)) { return false; } + + // Check for custom scroll commands first; delegate to base class + if (config_["on-scroll-up"].isString() || config_["on-scroll-down"].isString()) { + return AModule::handleScroll(e); + } + auto dir = AModule::getScrollDir(e); if (dir == SCROLL_DIR::NONE) { return true; diff --git a/src/modules/idle_inhibitor.cpp b/src/modules/idle_inhibitor.cpp index a5fc9ac7..05b30ca2 100644 --- a/src/modules/idle_inhibitor.cpp +++ b/src/modules/idle_inhibitor.cpp @@ -1,30 +1,58 @@ #include "modules/idle_inhibitor.hpp" +#include "ext-idle-notify-v1-client-protocol.h" #include "idle-inhibit-unstable-v1-client-protocol.h" #include "util/command.hpp" std::list waybar::modules::IdleInhibitor::modules; bool waybar::modules::IdleInhibitor::status = false; +long waybar::modules::IdleInhibitor::deactivationTime = time(nullptr); waybar::modules::IdleInhibitor::IdleInhibitor(const std::string& id, const Bar& bar, const Json::Value& config) : ALabel(config, "idle_inhibitor", id, "{status}", 0, false, true), bar_(bar), idle_inhibitor_(nullptr), - pid_(-1) { + idle_notification_(nullptr), + idle_timeout_ms_(0), + pid_(-1), + timeout(config_["timeout"].asDouble()), + timeout_step(config_["timeout-step"].isDouble() ? config_["timeout-step"].asDouble() : 10), + wait_for_activity_(false) { if (waybar::Client::inst()->idle_inhibit_manager == nullptr) { throw std::runtime_error("idle-inhibit not available"); } + // Read the wait-for-activity config option + if (config_["wait-for-activity"].isBool()) { + wait_for_activity_ = config_["wait-for-activity"].asBool(); + + // Check if ext-idle-notify protocol is available when wait-for-activity is enabled + if (wait_for_activity_ && waybar::Client::inst()->idle_notifier == nullptr) { + throw std::runtime_error("wait-for-activity requires ext-idle-notify-v1 protocol support"); + } + } + if (waybar::modules::IdleInhibitor::modules.empty() && config_["start-activated"].isBool() && config_["start-activated"].asBool() != status) { toggleStatus(); } - event_box_.add_events(Gdk::BUTTON_PRESS_MASK); + deactivationTime = time(nullptr) + timeout * 60; + + event_box_.add_events(Gdk::BUTTON_PRESS_MASK | Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); 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); @@ -32,6 +60,8 @@ waybar::modules::IdleInhibitor::IdleInhibitor(const std::string& id, const Bar& } waybar::modules::IdleInhibitor::~IdleInhibitor() { + teardownIdleNotification(); + if (idle_inhibitor_ != nullptr) { zwp_idle_inhibitor_v1_destroy(idle_inhibitor_); idle_inhibitor_ = nullptr; @@ -63,52 +93,88 @@ auto waybar::modules::IdleInhibitor::update() -> void { } std::string status_text = status ? "activated" : "deactivated"; - label_.set_markup(fmt::format(fmt::runtime(format_), fmt::arg("status", status_text), - fmt::arg("icon", getIcon(0, status_text)))); + int timeleft = (deactivationTime - time(nullptr)) / 60; + updateLabelAndTooltipForState(status_text, format_, "{status}", fmt::arg("status", status_text), + fmt::arg("timeout", timeout), fmt::arg("timeleft", timeleft), + fmt::arg("icon", getIcon(0, status_text))); label_.get_style_context()->add_class(status_text); - if (tooltipEnabled()) { - auto config = config_[status ? "tooltip-format-activated" : "tooltip-format-deactivated"]; - auto tooltip_format = config.isString() ? config.asString() : "{status}"; - label_.set_tooltip_markup(fmt::format(fmt::runtime(tooltip_format), - fmt::arg("status", status_text), - fmt::arg("icon", getIcon(0, status_text)))); - } // Call parent update ALabel::update(); } -void waybar::modules::IdleInhibitor::toggleStatus() { +auto waybar::modules::IdleInhibitor::refresh(int sig) -> void { +#ifdef SIGRTMIN + if (config_["signal"].isInt() && sig == SIGRTMIN + config_["signal"].asInt()) { + toggleStatus(); + + // Make all other idle inhibitor modules update + for (auto const& module : waybar::modules::IdleInhibitor::modules) { + module->update(); + } + } +#endif +} + +void waybar::modules::IdleInhibitor::toggleStatus(int force_status) { status = !status; + if (force_status != -1) { + status = force_status; + } if (timeout_.connected()) { /* cancel any already active timeout handler */ timeout_.disconnect(); } - if (status && config_["timeout"].isNumeric()) { - auto timeoutMins = config_["timeout"].asDouble(); - int timeoutSecs = timeoutMins * 60; + if (status && timeout) { + idle_timeout_ms_ = static_cast(timeout * 60) * 1000; - timeout_ = Glib::signal_timeout().connect_seconds( - []() { - /* intentionally not tied to a module instance lifetime - * as the output with `this` can be disconnected - */ - spdlog::info("deactivating idle_inhibitor by timeout"); - status = false; - for (auto const& module : waybar::modules::IdleInhibitor::modules) { - module->update(); - } - /* disconnect */ - return false; - }, - timeoutSecs); + // If wait-for-activity is enabled, set up idle notification + if (wait_for_activity_) { + spdlog::debug("idle_inhibitor: wait-for-activity enabled, timeout: {} ms", idle_timeout_ms_); + // Tear down any existing notification first to ensure fresh setup + teardownIdleNotification(); + setupIdleNotification(); + } else { + // Original behavior: countdown timeout with per-minute updates + deactivationTime = time(nullptr) + timeout * 60; + timeout_ = Glib::signal_timeout().connect_seconds( + []() { + /* intentionally not tied to a module instance lifetime + * as the output with `this` can be disconnected + */ + bool continueRunning = true; + int timeleft = (deactivationTime - time(nullptr)) / 60; + spdlog::info("updating timeleft. deactivation timestamp: {}, minutes left: {}", + deactivationTime, timeleft); + if (timeleft <= 0) { + spdlog::info("deactivating idle_inhibitor by timeout"); + status = false; + continueRunning = false; + } + for (auto const& module : waybar::modules::IdleInhibitor::modules) { + module->update(); + } + return continueRunning; + }, + 60); + } + } else { + // When deactivated, tear down idle notification + teardownIdleNotification(); } } 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) { - toggleStatus(); + if (dynamic) { + toggleStatus(1); + } else { + toggleStatus(); + } // Make all other idle inhibitor modules update for (auto const& module : waybar::modules::IdleInhibitor::modules) { @@ -117,7 +183,137 @@ bool waybar::modules::IdleInhibitor::handleToggle(GdkEventButton* const& e) { } } } + if (e->button == 3 && dynamic) { + toggleStatus(0); + // Make all other idle inhibitor modules update + for (auto const& module : waybar::modules::IdleInhibitor::modules) { + if (module != this) { + module->update(); + } + } + } + if (e->button == 2 && dynamic) { + toggleStatus(0); + timeout = config_["timeout"].asDouble(); + } ALabel::handleToggle(e); return true; } + +bool waybar::modules::IdleInhibitor::handleScroll(GdkEventScroll* e) { + // 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) { + return true; + } + toggleStatus(0); + double step = dir == SCROLL_DIR::UP ? 1 : -1; + step *= timeout_step; + timeout += step; + if (timeout < 0) { + timeout = 0; + } + deactivationTime = time(nullptr) + timeout * 60; + + ALabel::handleScroll(e); + return true; +} + +void waybar::modules::IdleInhibitor::handleIdled(void* data, + ext_idle_notification_v1* /*notification*/) { + spdlog::info("deactivating idle_inhibitor due to user inactivity"); + status = false; + + // Clean up the notification since we're deactivating + auto* self = static_cast(data); + if (self != nullptr) { + self->teardownIdleNotification(); + } + + for (auto const& module : waybar::modules::IdleInhibitor::modules) { + module->update(); + } +} + +void waybar::modules::IdleInhibitor::handleResumed(void* data, + ext_idle_notification_v1* /*notification*/) { + // User became active again - notification will continue monitoring + spdlog::debug("user activity detected, idle_inhibitor still active"); +} + +void waybar::modules::IdleInhibitor::setupIdleNotification() { + spdlog::debug("idle_inhibitor: setting up idle notification"); + + // Clean up any existing notification first + if (idle_notification_ != nullptr) { + spdlog::debug("idle_inhibitor: cleaning up existing notification before setup"); + teardownIdleNotification(); + } + + auto* client = waybar::Client::inst(); + if (client->idle_notifier == nullptr) { + spdlog::error("ext-idle-notify protocol not available"); + return; + } + + // Get the wayland seat from the display + auto* gdk_seat = gdk_display_get_default_seat(client->gdk_display->gobj()); + if (gdk_seat == nullptr) { + spdlog::error("failed to get default seat"); + return; + } + auto* wl_seat = gdk_wayland_seat_get_wl_seat(gdk_seat); + + // Check protocol version to determine which function to use + uint32_t version = + wl_proxy_get_version(reinterpret_cast(client->idle_notifier)); + + spdlog::debug("idle_inhibitor: creating notification with timeout {} ms (protocol version {})", + idle_timeout_ms_, version); + + if (version >= 2) { + // Version 2+: Use get_input_idle_notification which ignores idle inhibitors + // This allows us to detect actual user inactivity even while the inhibitor is active + spdlog::debug("idle_inhibitor: using get_input_idle_notification (ignores inhibitors)"); + idle_notification_ = ext_idle_notifier_v1_get_input_idle_notification( + client->idle_notifier, idle_timeout_ms_, wl_seat); + } else { + // Version 1: Fall back to get_idle_notification + // WARNING: This respects idle inhibitors, so it won't fire while inhibitor is active + spdlog::warn( + "idle_inhibitor: ext-idle-notifier-v1 version {} doesn't support " + "get_input_idle_notification, " + "wait-for-activity may not work correctly", + version); + idle_notification_ = ext_idle_notifier_v1_get_idle_notification(client->idle_notifier, + idle_timeout_ms_, wl_seat); + } + + if (idle_notification_ == nullptr) { + spdlog::error("idle_inhibitor: failed to create idle notification"); + return; + } + + static const struct ext_idle_notification_v1_listener idle_notification_listener = { + .idled = &IdleInhibitor::handleIdled, + .resumed = &IdleInhibitor::handleResumed, + }; + + ext_idle_notification_v1_add_listener(idle_notification_, &idle_notification_listener, this); + wl_display_roundtrip(client->wl_display); + spdlog::debug("idle_inhibitor: idle notification setup complete"); +} + +void waybar::modules::IdleInhibitor::teardownIdleNotification() { + if (idle_notification_ != nullptr) { + spdlog::debug("idle_inhibitor: tearing down idle notification"); + ext_idle_notification_v1_destroy(idle_notification_); + idle_notification_ = nullptr; + } +} diff --git a/src/modules/image.cpp b/src/modules/image.cpp index 98bf3c46..8c8132d7 100644 --- a/src/modules/image.cpp +++ b/src/modules/image.cpp @@ -1,18 +1,12 @@ #include "modules/image.hpp" +#include + +#include + waybar::modules::Image::Image(const std::string& id, const Json::Value& config) - : AModule(config, "image", id), box_(Gtk::ORIENTATION_HORIZONTAL, 0) { - box_.pack_start(image_); - box_.set_name("image"); - if (!id.empty()) { - box_.get_style_context()->add_class(id); - } - box_.get_style_context()->add_class(MODULE_CLASS); - event_box_.add(box_); - - dp.emit(); - - size_ = config["size"].asInt(); + : AModule(config, "image", id) { + strategy_ = getStrategy(id, config, MODULE_CLASS, event_box_, tooltipEnabled()); const auto once = std::chrono::milliseconds::max(); if (!config_.isMember("interval") || config_["interval"].isNull() || @@ -31,48 +25,273 @@ waybar::modules::Image::Image(const std::string& id, const Json::Value& config) interval_ = once; } - if (size_ == 0) { - size_ = 16; + delayWorker(); +} + +auto waybar::modules::Image::getStrategy(const std::string& id, const Json::Value& cfg, + const std::string& module, Gtk::EventBox& evbox, + bool hasTooltip) + -> std::unique_ptr { + std::unique_ptr strat; + if (!cfg["multiple"].empty() && cfg["multiple"].asBool()) { + strat = std::make_unique(id, cfg, module, evbox); + } else { + strat = std::make_unique(id, cfg, module, evbox, + hasTooltip); } - delayWorker(); + return strat; } void waybar::modules::Image::delayWorker() { thread_ = [this] { + // Do the blocking work (e.g. running a user script) here on the worker + // thread; update() then only parses the result and draws on the main thread. + strategy_->fetch(); dp.emit(); thread_.sleep_for(interval_); }; } void waybar::modules::Image::refresh(int sig) { +#ifdef SIGRTMIN if (config_["signal"].isInt() && sig == SIGRTMIN + config_["signal"].asInt()) { thread_.wake_up(); } +#endif } auto waybar::modules::Image::update() -> void { + strategy_->update(); + + AModule::update(); +} + +namespace waybar::modules::image { + +MultipleImageStrategy::MultipleImageStrategy(const std::string& id, const Json::Value& config, + const std::string& module, Gtk::EventBox& evbox) + : IStrategy(), box_(Gtk::ORIENTATION_HORIZONTAL, 0) { + config_ = config; + + box_.set_name("image"); + box_.get_style_context()->add_class(id); + box_.get_style_context()->add_class(module); + evbox.add(box_); + + size_ = config["size"].asInt(); + if (size_ == 0) { + size_ = 16; + } +} + +void MultipleImageStrategy::fetch() { + // Run the (blocking) user script off the GTK main thread so the bar doesn't + // freeze for the script's duration on every interval. update() consumes the + // captured output. The static "entries" path takes priority and needs no exec. + if (config_["entries"].empty() && !config_["exec"].empty()) { + exec_output_ = util::command::exec(config_["exec"].asString(), "").out; + } +} + +void MultipleImageStrategy::update() { + // spdlog::info("update function run"); + + // clear box_, previous css classes and memory + if (box_.get_children().size() > 0) { + resetBoxAndMemory(); + } + + // set new images from config script + if (!config_["entries"].empty()) { + setImagesData(config_["entries"]); + } else if (!config_["exec"].empty()) { + // exec output was captured by fetch() on the worker thread + Json::Value as_json; + Json::Reader reader; + + if (!reader.parse(exec_output_, as_json)) { + spdlog::error("invalid json from exec {}", exec_output_); + return; + } + + setImagesData(as_json); + } else { + spdlog::error("no image files provded in config"); + return; + } + + setupAndDraw(); +} + +void MultipleImageStrategy::setupAndDraw() { + for (unsigned int i = 0; i < images_data_.size(); i++) { + images_data_[i].img = std::make_shared(); + images_data_[i].btn = std::make_shared(); + + auto img = images_data_[i].img; + auto data = images_data_[i]; + + auto path = data.path; + auto marker = data.marker; + auto tooltip = data.tooltip; + bool has_onclick = !data.on_click.empty(); + + Glib::RefPtr 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; + btn->set_name("button_" + path); + btn->get_style_context()->add_class(marker); + btn->set_tooltip_text(tooltip); + btn->set_image(*img); + box_.pack_start(*btn); + + btn->add_events(Gdk::BUTTON_PRESS_MASK); + btn->signal_clicked().connect( + sigc::bind(sigc::mem_fun(*this, &MultipleImageStrategy::handleClick), data.on_click)); + + if (pixbuf) { + btn->show_all(); + img->set(pixbuf); + box_.get_style_context()->remove_class("empty"); + } else { + btn->hide(); + img->clear(); + img->hide(); + box_.get_style_context()->add_class("empty"); + } + } else { + img->set_name(path); + img->get_style_context()->add_class(marker); + img->set_tooltip_text(tooltip); + box_.pack_start(*img); + // spdlog::info("added image -> {}:{}", marker, path); + + if (pixbuf) { + img->set(pixbuf); + img->show(); + box_.get_style_context()->remove_class("empty"); + } else { + img->clear(); + img->hide(); + box_.get_style_context()->add_class("empty"); + } + } + } +} + +void MultipleImageStrategy::setImagesData(const Json::Value& entries) { + for (unsigned int i = 0; i < entries.size(); i++) { + auto path = entries[i]["path"]; + auto marker = entries[i]["marker"]; + auto tooltip = entries[i]["tooltip"]; + auto onclick = entries[i]["on-click"]; + + bool has_tooltip_err = !tooltip.empty() && !tooltip.isString(); + bool has_onclick_err = !onclick.empty() && !onclick.isString(); + + if (!path.isString() || !marker.isString() || has_tooltip_err || has_onclick_err || + !Glib::file_test(path.asString(), Glib::FILE_TEST_EXISTS)) { + spdlog::error("invalid input in images config -> {}", entries[i]); + return; + } + ImageData data; + data.path = path.asString(); + data.marker = marker.asString(); + data.tooltip = !tooltip.empty() ? tooltip.asString() : ""; + data.on_click = onclick.asString(); + + images_data_.push_back(data); + } +} + +void MultipleImageStrategy::resetBoxAndMemory() { + auto children = box_.get_children(); + for (auto child : children) { + box_.remove(*child); + // spdlog::info("child removed with name -> {}", std::string(child->get_name())); + } + + images_data_.clear(); +} + +void MultipleImageStrategy::handleClick(const Glib::ustring& data) { + // Fire-and-forget: don't block the main loop waiting on the command's output. + util::command::forkExec(data); +} + +SingleImageStrategy::SingleImageStrategy(const std::string& id, const Json::Value& config, + const std::string& module, Gtk::EventBox& evbox, + bool tooltipEnabled) + : IStrategy(), box_(Gtk::ORIENTATION_HORIZONTAL, 0) { + config_ = config; + hasTooltip_ = tooltipEnabled; + + box_.pack_start(image_); + box_.set_name("image"); + if (!id.empty()) { + box_.get_style_context()->add_class(id); + } + box_.get_style_context()->add_class(module); + evbox.add(box_); + + size_ = config["size"].asInt(); + if (size_ == 0) { + size_ = 16; + } +} + +void SingleImageStrategy::update() { if (config_["path"].isString()) { - path_ = config_["path"].asString(); + 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(); - } else { - path_ = ""; + // expand path if "~" or "$HOME" is present in original path + auto result = Config::tryExpandPath(path_, ""); + path_ = (result.size() == 1) ? result.front() : path_; } + Glib::RefPtr pixbuf; if (Glib::file_test(path_, Glib::FILE_TEST_EXISTS)) { - Glib::RefPtr pixbuf; - int scaled_icon_size = size_ * image_.get_scale_factor(); - pixbuf = Gdk::Pixbuf::create_from_file(path_, scaled_icon_size, scaled_icon_size); + 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(); + } + } - auto surface = Gdk::Cairo::create_surface_from_pixbuf(pixbuf, image_.get_scale_factor(), - image_.get_window()); - image_.set(surface); + if (pixbuf) { + // Building a HiDPI-aware cairo surface requires a realized widget: it reads the + // GdkWindow and its scale factor. During startup update() can run before the + // Gtk::Image is realized, in which case get_window() is null; feeding that path a + // null window aborts startup. Fall back to setting the pixbuf directly while the + // widget is unrealized, and use the crisp surface path once a window is available. + auto window = image_.get_window(); + if (window) { + auto surface = + Gdk::Cairo::create_surface_from_pixbuf(pixbuf, image_.get_scale_factor(), window); + image_.set(surface); + } else { + image_.set(pixbuf); + } image_.show(); - if (tooltipEnabled() && !tooltip_.empty()) { + if (hasTooltip_ && !tooltip_.empty()) { if (box_.get_tooltip_markup() != tooltip_) { box_.set_tooltip_markup(tooltip_); } @@ -84,11 +303,9 @@ auto waybar::modules::Image::update() -> void { image_.hide(); box_.get_style_context()->add_class("empty"); } - - AModule::update(); } -void waybar::modules::Image::parseOutputRaw() { +void SingleImageStrategy::parseOutputRaw() { std::istringstream output(output_.out); std::string line; int i = 0; @@ -103,3 +320,5 @@ void waybar::modules::Image::parseOutputRaw() { i++; } } + +} // namespace waybar::modules::image diff --git a/src/modules/inhibitor.cpp b/src/modules/inhibitor.cpp index 43c1f83e..88b711d3 100644 --- a/src/modules/inhibitor.cpp +++ b/src/modules/inhibitor.cpp @@ -104,7 +104,6 @@ Inhibitor::Inhibitor(const std::string& id, const Bar& bar, const Json::Value& c inhibitors_(::getInhibitors(config)) { event_box_.add_events(Gdk::BUTTON_PRESS_MASK); event_box_.signal_button_press_event().connect(sigc::mem_fun(*this, &Inhibitor::handleToggle)); - dp.emit(); } Inhibitor::~Inhibitor() { diff --git a/src/modules/jack.cpp b/src/modules/jack.cpp index 578fb4e0..c7bd5d92 100644 --- a/src/modules/jack.cpp +++ b/src/modules/jack.cpp @@ -72,19 +72,10 @@ auto JACK::update() -> void { } else format = "{load}%"; - label_.set_markup(fmt::format(fmt::runtime(format), fmt::arg("load", std::round(load_)), - fmt::arg("bufsize", bufsize_), fmt::arg("samplerate", samplerate_), - fmt::arg("latency", fmt::format("{:.2f}", latency)), - fmt::arg("xruns", xruns_))); - - if (tooltipEnabled()) { - std::string tooltip_format = "{bufsize}/{samplerate} {latency}ms"; - if (config_["tooltip-format"].isString()) tooltip_format = config_["tooltip-format"].asString(); - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_format), fmt::arg("load", std::round(load_)), - fmt::arg("bufsize", bufsize_), fmt::arg("samplerate", samplerate_), - fmt::arg("latency", fmt::format("{:.2f}", latency)), fmt::arg("xruns", xruns_))); - } + updateLabelAndTooltip( + format, "{bufsize}/{samplerate} {latency}ms", fmt::arg("load", std::round(load_)), + fmt::arg("bufsize", bufsize_), fmt::arg("samplerate", samplerate_), + fmt::arg("latency", fmt::format("{:.2f}", latency)), fmt::arg("xruns", xruns_)); // Call parent update ALabel::update(); diff --git a/src/modules/keyboard_state.cpp b/src/modules/keyboard_state.cpp index a2207fdd..fa80ef64 100644 --- a/src/modules/keyboard_state.cpp +++ b/src/modules/keyboard_state.cpp @@ -78,6 +78,44 @@ auto supportsLockStates(const libevdev* dev) -> bool { libevdev_has_event_code(dev, EV_LED, LED_SCROLLL); } +auto isCommonFormatIcons(const Json::Value& config) -> bool { + return config["format-icons"].isObject() && (config["format-icons"]["locked"].isString() || + config["format-icons"]["unlocked"].isString()); +} + +auto keyStateToIcons(const Json::Value& config) + -> std::unordered_map> { + std::unordered_map> key_icon_states; + + if (isCommonFormatIcons(config)) { + std::vector icons = { + config["format-icons"]["unlocked"].isString() + ? config["format-icons"]["unlocked"].asString() + : "unlocked", + config["format-icons"]["locked"].isString() ? config["format-icons"]["locked"].asString() + : "locked", + }; + key_icon_states["Lock"] = icons; + return key_icon_states; + } + + const auto& format_icons = config["format-icons"]; + for (const auto& key : std::vector{"numlock", "capslock", "scrolllock"}) { + std::string map_key = key.substr(0, key.length() - 4); + map_key[0] = std::toupper(map_key[0]); + 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}; + } + + return key_icon_states; +} + waybar::modules::KeyboardState::KeyboardState(const std::string& id, const Bar& bar, const Json::Value& config) : AModule(config, "keyboard-state", id, false, !config["disable-scroll"].asBool()), @@ -98,12 +136,7 @@ waybar::modules::KeyboardState::KeyboardState(const std::string& id, const Bar& : "{name} {icon}"), interval_( std::chrono::seconds(config_["interval"].isUInt() ? config_["interval"].asUInt() : 1)), - icon_locked_(config_["format-icons"]["locked"].isString() - ? config_["format-icons"]["locked"].asString() - : "locked"), - icon_unlocked_(config_["format-icons"]["unlocked"].isString() - ? config_["format-icons"]["unlocked"].asString() - : "unlocked"), + key_icon_states_(keyStateToIcons(config_)), devices_path_("/dev/input/"), libinput_(nullptr), libinput_devices_({}) { @@ -235,10 +268,16 @@ waybar::modules::KeyboardState::KeyboardState(const std::string& id, const Bar& std::lock_guard lock(devices_mutex_); auto it = libinput_devices_.find(dev_path); if (it != libinput_devices_.end()) { - spdlog::info("Keyboard {} has been removed.", dev_path); - libinput_path_remove_device(it->second); - libinput_device_unref(it->second); + struct libinput_device* device = it->second; + // Erase from the map first so that a second IN_DELETE event for the + // same path becomes a no-op. This keeps removal idempotent and + // ensures libinput_path_remove_device()/libinput_device_unref() are + // called exactly once per device, avoiding a libinput list_remove + // assertion abort on double removal. libinput_devices_.erase(it); + spdlog::info("Keyboard {} has been removed.", dev_path); + libinput_path_remove_device(device); + libinput_device_unref(device); } } i += sizeof(struct inotify_event) + event->len; @@ -258,31 +297,47 @@ auto waybar::modules::KeyboardState::update() -> void { sleep(0); // Wait for keyboard status change int numl = 0, capsl = 0, scrolll = 0; - try { - std::string dev_path; - { - std::lock_guard lock(devices_mutex_); - if (libinput_devices_.empty()) { - return; - } - if (config_["device-path"].isString() && - libinput_devices_.find(config_["device-path"].asString()) != libinput_devices_.end()) { - dev_path = config_["device-path"].asString(); - } else { - dev_path = libinput_devices_.begin()->first; + std::vector dev_paths; + { + std::lock_guard lock(devices_mutex_); + if (libinput_devices_.empty()) { + return; + } + if (config_["device-path"].isString() && + libinput_devices_.find(config_["device-path"].asString()) != libinput_devices_.end()) { + // An explicit device was configured: read lock state from just that device. + dev_paths.push_back(config_["device-path"].asString()); + } else { + // No explicit device: a multi-node keyboard may expose several event + // devices where only one of them actually toggles the lock LEDs. OR the + // LED values across all devices so a lock is reported on if any device + // reports it on. + for (const auto& [dev_path, _] : libinput_devices_) { + dev_paths.push_back(dev_path); } } - int fd = openFile(dev_path, O_NONBLOCK | O_CLOEXEC | O_RDONLY); - auto dev = openDevice(fd); - numl = libevdev_get_event_value(dev, EV_LED, LED_NUML); - capsl = libevdev_get_event_value(dev, EV_LED, LED_CAPSL); - scrolll = libevdev_get_event_value(dev, EV_LED, LED_SCROLLL); - libevdev_free(dev); - closeFile(fd); - } catch (const errno_error& e) { - // ENOTTY just means the device isn't an evdev device, skip it - if (e.code != ENOTTY) { - spdlog::warn(e.what()); + } + for (const auto& dev_path : dev_paths) { + try { + int fd = openFile(dev_path, O_NONBLOCK | O_CLOEXEC | O_RDONLY); + libevdev* dev; + try { + dev = openDevice(fd); + } catch (...) { + // openDevice does not close the fd if libevdev_new_from_fd fails. + closeFile(fd); + throw; + } + numl |= libevdev_get_event_value(dev, EV_LED, LED_NUML); + capsl |= libevdev_get_event_value(dev, EV_LED, LED_CAPSL); + scrolll |= libevdev_get_event_value(dev, EV_LED, LED_SCROLLL); + libevdev_free(dev); + closeFile(fd); + } catch (const errno_error& e) { + // ENOTTY just means the device isn't an evdev device, skip it + if (e.code != ENOTTY) { + spdlog::warn(e.what()); + } } } @@ -290,7 +345,7 @@ auto waybar::modules::KeyboardState::update() -> void { bool state; Gtk::Label& label; const std::string& format; - const char* name; + const std::string name; } label_states[] = { {(bool)numl, numlock_label_, numlock_format_, "Num"}, {(bool)capsl, capslock_label_, capslock_format_, "Caps"}, @@ -298,8 +353,21 @@ auto waybar::modules::KeyboardState::update() -> void { }; for (auto& label_state : label_states) { std::string text; + std::string map_key = isCommonFormatIcons(config_) ? "Lock" : label_state.name; + + if (key_icon_states_.find(map_key) == key_icon_states_.end()) { + spdlog::warn("keyboard-state: Missing icon configuration for '{}'", map_key); + continue; + } + + auto& icons = key_icon_states_[map_key]; + if (icons.size() < 2) { + spdlog::warn("keyboard-state: Invalid icon vector size for '{}'", map_key); + continue; + } + text = fmt::format(fmt::runtime(label_state.format), - fmt::arg("icon", label_state.state ? icon_locked_ : icon_unlocked_), + fmt::arg("icon", label_state.state ? icons[1] : icons[0]), fmt::arg("name", label_state.name)); label_state.label.set_markup(text); if (label_state.state) { @@ -315,7 +383,14 @@ auto waybar::modules::KeyboardState::update() -> void { auto waybar::modules ::KeyboardState::tryAddDevice(const std::string& dev_path) -> void { try { int fd = openFile(dev_path, O_NONBLOCK | O_CLOEXEC | O_RDONLY); - auto dev = openDevice(fd); + libevdev* dev; + try { + dev = openDevice(fd); + } catch (...) { + // openDevice does not close the fd if libevdev_new_from_fd fails. + closeFile(fd); + throw; + } if (supportsLockStates(dev)) { spdlog::info("Found device {} at '{}'", libevdev_get_name(dev), dev_path); std::lock_guard lock(devices_mutex_); diff --git a/src/modules/mango/backend.cpp b/src/modules/mango/backend.cpp new file mode 100644 index 00000000..63aecb7b --- /dev/null +++ b/src/modules/mango/backend.cpp @@ -0,0 +1,363 @@ +#include "modules/mango/backend.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "util/scoped_fd.hpp" + +namespace waybar::modules::mango { + +int IPC::connectToSocket() { + const char* socket_path = getenv("MANGO_INSTANCE_SIGNATURE"); + if (!socket_path) { + throw std::runtime_error("Mango IPC: MANGO_INSTANCE_SIGNATURE not set"); + } + + struct sockaddr_un addr; + util::ScopedFd fd(socket(AF_UNIX, SOCK_STREAM, 0)); + if (fd == -1) throw std::runtime_error("socket() failed"); + + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); + addr.sun_path[sizeof(addr.sun_path) - 1] = 0; + + if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { + throw std::runtime_error("connect() failed"); + } + return fd.release(); +} + +Json::Value IPC::sendCommand(const std::string& cmd) { + util::ScopedFd fd(IPC::connectToSocket()); + std::string full_cmd = cmd + "\n"; + + ssize_t total_written = 0; + while (total_written < (ssize_t)full_cmd.size()) { + ssize_t res = write(fd, full_cmd.c_str() + total_written, full_cmd.size() - total_written); + if (res < 0) { + if (errno == EINTR) continue; + throw std::runtime_error("Failed to write command"); + } + total_written += res; + } + + char buf[4096]; + std::string response; + while (true) { + ssize_t n = read(fd, buf, sizeof(buf) - 1); + if (n <= 0) { + if (n == 0) break; + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue; + throw std::runtime_error("Read error"); + } + buf[n] = '\0'; + response += buf; + if (response.find('\n') != std::string::npos) break; + } + + Json::Value root; + std::istringstream iss(response); + Json::CharReaderBuilder builder; + std::string errors; + if (!Json::parseFromStream(builder, iss, &root, &errors)) { + throw std::runtime_error("JSON parse error: " + errors); + } + return root; +} + +Json::Value IPC::send(const Json::Value& request) { + if (!request.isMember("command")) { + throw std::runtime_error("Mango IPC: request must have 'command' field"); + } + return sendCommand(request["command"].asString()); +} + +void IPC::sendAsync(const Json::Value& request) { + if (!request.isMember("command")) { + spdlog::error("Mango IPC: request must have 'command' field"); + return; + } + std::string cmd = request["command"].asString(); + + std::thread([cmd]() { + try { + IPC::sendCommand(cmd); + } catch (const std::exception& e) { + spdlog::error("IPC async send failed: {}", e.what()); + } + }).detach(); +} + +IPC::IPC() : sockfd_(-1), active_client_(Json::nullValue) { startIPC(); } + +IPC::~IPC() { + running_ = false; + if (sockfd_ != -1) close(sockfd_); + if (ipc_thread_.joinable()) ipc_thread_.join(); +} + +void IPC::startIPC() { + // Connect synchronously so a missing socket (this WM isn't the active + // compositor) throws here and lets the module constructor fail, instead of + // the module always attaching with a permanently empty widget. + sockfd_ = IPC::connectToSocket(); + + ipc_thread_ = std::thread([this]() { + spdlog::info("Mango IPC thread started"); + + char buf[4096]; + std::string buffer; + bool have_initial_fd = true; + + // Reconnect loop: if the event stream drops (POLLHUP/POLLERR, read()==0 or + // an error) we back off briefly and re-establish the socket instead of + // leaving every mango module frozen forever with stale content. + while (running_) { + if (!have_initial_fd) { + try { + sockfd_ = IPC::connectToSocket(); + } catch (const std::exception& e) { + spdlog::error("Mango IPC: failed to reconnect: {}", e.what()); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + } + have_initial_fd = false; + + bool subscribed = true; + const std::vector subs = {"watch all-monitors"}; + for (const auto& cmd : subs) { + if (write(sockfd_, cmd.c_str(), cmd.size()) != (ssize_t)cmd.size() || + write(sockfd_, "\n", 1) != 1) { + spdlog::error("Failed to subscribe to {}", cmd); + subscribed = false; + break; + } + } + if (!subscribed) { + if (sockfd_ != -1) { + close(sockfd_); + sockfd_ = -1; + } + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + + struct pollfd pfd; + pfd.fd = sockfd_; + pfd.events = POLLIN; + buffer.clear(); + + bool connected = true; + while (running_ && connected) { + int ret = poll(&pfd, 1, 1000); + if (ret == 0) continue; + if (ret < 0) { + if (errno == EINTR) continue; + spdlog::error("IPC poll error: {}", strerror(errno)); + connected = false; + break; + } + + if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) { + spdlog::info("Mango IPC socket closed or invalid"); + connected = false; + break; + } + + if (pfd.revents & POLLIN) { + ssize_t n = read(sockfd_, buf, sizeof(buf)); + if (n == 0) { + spdlog::info("Mango IPC connection closed"); + connected = false; + break; + } + if (n < 0) { + if (errno == EINTR) continue; + spdlog::error("IPC read error: {}", strerror(errno)); + connected = false; + break; + } + buffer.append(buf, n); + + size_t pos; + while ((pos = buffer.find('\n')) != std::string::npos) { + std::string line = buffer.substr(0, pos); + buffer.erase(0, pos + 1); + if (line.empty()) continue; + try { + parseIPC(line); + } catch (const std::exception& e) { + spdlog::warn("Failed to parse IPC line: {} - {}", line, e.what()); + } + } + } + } + + // On shutdown leave the socket for the destructor to close (avoids a + // double close); on a genuine disconnect close it before reconnecting. + if (!running_) break; + if (sockfd_ != -1) { + close(sockfd_); + sockfd_ = -1; + } + spdlog::warn("Mango IPC: event stream closed, reconnecting"); + std::this_thread::sleep_for(std::chrono::seconds(2)); + } + + spdlog::info("Mango IPC thread stopping"); + }); +} + +void IPC::parseIPC(const std::string& line) { + Json::Value root; + Json::CharReaderBuilder builder; + std::string errors; + std::istringstream iss(line); + if (!Json::parseFromStream(builder, iss, &root, &errors)) { + throw std::runtime_error("JSON parse error: " + errors); + } + + if (root.isMember("monitors") && root["monitors"].isArray()) { + for (const auto& mon : root["monitors"]) { + handleMonitorUpdate(mon); + } + + Json::Value active_monitor; + for (const auto& mon : root["monitors"]) { + if (mon["active"].asBool()) { + active_monitor = mon; + break; + } + } + + if (!active_monitor.isNull()) { + const auto& active_client = active_monitor["active_client"]; + updateFocusingClient(active_client); + + if (active_monitor.isMember("keyboardlayout")) { + updateKeyboardLayout(active_monitor["keyboardlayout"].asString()); + } + + if (active_monitor.isMember("keymode")) { + std::lock_guard lock(data_mutex_); + keymode_ = active_monitor["keymode"].asString(); + } + } + + { + std::lock_guard lock(callback_mutex_); + for (auto& [ev, handler] : callbacks_) { + if (ev == "monitor") { + handler->onEvent(root); + } + } + } + + return; + } + + spdlog::debug("Unhandled IPC message: {}", line); +} + +std::unordered_map IPC::getMonitors() const { + std::lock_guard lock(data_mutex_); + return monitors_; +} + +IPC& IPC::getInstance() { + static IPC instance; + return instance; +} + +Json::Value IPC::getMonitor(const std::string& name) { + std::lock_guard lock(data_mutex_); + auto it = monitors_.find(name); + if (it != monitors_.end()) { + return it->second; + } + return Json::nullValue; +} + +std::string IPC::getKeyboardLayout() const { + std::lock_guard lock(data_mutex_); + return keyboard_layout_; +} + +std::string IPC::getKeymode() const { + std::lock_guard lock(data_mutex_); + return keymode_; +} + +Json::Value IPC::getActiveClientForMonitor(const std::string& name) const { + std::lock_guard lock(data_mutex_); + auto it = monitors_.find(name); + if (it != monitors_.end() && it->second.isMember("active_client")) { + return it->second["active_client"]; + } + return Json::nullValue; +} + +std::string IPC::getLayoutSymbolForMonitor(const std::string& name) const { + std::lock_guard lock(data_mutex_); + auto it = monitors_.find(name); + if (it != monitors_.end() && it->second.isMember("layout_symbol")) { + return it->second["layout_symbol"].asString(); + } + return {}; +} + +void IPC::handleMonitorUpdate(const Json::Value& mon) { + std::lock_guard lock(data_mutex_); + monitors_[mon["name"].asString()] = mon; +} + +void IPC::updateFocusingClient(const Json::Value& client) { + { + std::lock_guard lock(data_mutex_); + active_client_ = client; + + if (client.isNull() || !client.isObject() || client["id"].isNull()) { + focusing_client_id_ = 0; + } else { + focusing_client_id_ = client["id"].asUInt64(); + clients_[focusing_client_id_] = client; + } + } +} + +void IPC::updateKeyboardLayout(const std::string& layout) { + { + std::lock_guard lock(data_mutex_); + keyboard_layout_ = layout; + } +} + +void IPC::registerForIPC(const std::string& ev, EventHandler* handler) { + if (!handler) return; + std::lock_guard lock(callback_mutex_); + callbacks_.emplace_back(ev, handler); +} + +void IPC::unregisterForIPC(EventHandler* handler) { + if (!handler) return; + std::lock_guard lock(callback_mutex_); + for (auto it = callbacks_.begin(); it != callbacks_.end();) { + if (it->second == handler) + it = callbacks_.erase(it); + else + ++it; + } +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/mango/keymode.cpp b/src/modules/mango/keymode.cpp new file mode 100644 index 00000000..3d28a0c8 --- /dev/null +++ b/src/modules/mango/keymode.cpp @@ -0,0 +1,57 @@ +#include "modules/mango/keymode.hpp" + +#include + +namespace waybar::modules::mango { + +Keymode::Keymode(const std::string& id, const Bar& bar, const Json::Value& config) + : ALabel(config, "keymode", id, "{}", 0, false), bar_(bar) { + IPC::getInstance().registerForIPC("monitor", this); +} + +Keymode::~Keymode() { IPC::getInstance().unregisterForIPC(this); } + +void Keymode::onEvent(const Json::Value& ev) { dp.emit(); } + +void Keymode::doUpdate() { + std::lock_guard lock(mutex_); + + std::string current = IPC::getInstance().getKeymode(); + + // if keymode is empty, hide the label + if (current.empty()) { + label_.hide(); + last_keymode_.clear(); + return; + } + + // if keymode is the same as last time, skip style changes + if (current != last_keymode_) { + if (!last_keymode_.empty()) label_.get_style_context()->remove_class(last_keymode_); + label_.get_style_context()->add_class(current); + last_keymode_ = current; + } + + // support config's format-keymode custom format (such as format-default, format-resize, etc.) + std::string text; + std::string format_key = "format-" + current; + if (config_.isMember(format_key)) { + text = fmt::format(fmt::runtime(config_[format_key].asString()), fmt::arg("mode", current)); + } else { + text = fmt::format(fmt::runtime(format_), fmt::arg("mode", current)); + } + + if (!text.empty()) { + label_.show(); + label_.set_markup(text); + } else { + label_.hide(); + } +} + +void Keymode::update() { + doUpdate(); + ALabel::update(); +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/mango/language.cpp b/src/modules/mango/language.cpp new file mode 100644 index 00000000..c04492ee --- /dev/null +++ b/src/modules/mango/language.cpp @@ -0,0 +1,145 @@ +#include "modules/mango/language.hpp" + +#include +#include +#include + +#include "util/string.hpp" + +namespace waybar::modules::mango { + +Language::Language(const std::string& id, const Bar& bar, const Json::Value& config) + : ALabel(config, "language", id, "{}", 0, false), bar_(bar), rxkb_ctx_(nullptr) { + rxkb_ctx_ = rxkb_context_new(RXKB_CONTEXT_LOAD_EXOTIC_RULES); + if (rxkb_ctx_) { + rxkb_context_parse_default_ruleset(rxkb_ctx_); + } + + IPC::getInstance().registerForIPC("monitor", this); + updateFromIPC(); +} + +Language::~Language() { + IPC::getInstance().unregisterForIPC(this); + if (rxkb_ctx_) rxkb_context_unref(rxkb_ctx_); +} + +void Language::updateFromIPC() { + std::lock_guard lock(mutex_); + std::string layout = IPC::getInstance().getKeyboardLayout(); + + layouts_.clear(); + if (!layout.empty()) { + Layout l = getLayout(layout); + layouts_.push_back(l); + current_idx_ = 0; + } else { + current_idx_ = 0; + } +} + +void Language::doUpdate() { + std::lock_guard lock(mutex_); + if (layouts_.empty() || current_idx_ >= layouts_.size()) { + label_.hide(); + return; + } + const auto& layout = layouts_[current_idx_]; + + if (!last_short_name_.empty()) label_.get_style_context()->remove_class(last_short_name_); + if (!layout.short_name.empty()) { + label_.get_style_context()->add_class(layout.short_name); + last_short_name_ = layout.short_name; + } + + std::string layoutName; + std::string variant_key = "format-" + layout.short_description + "-" + layout.variant; + if (!layout.variant.empty() && config_.isMember(variant_key)) { + layoutName = + fmt::format(fmt::runtime(config_[variant_key].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("format-" + layout.short_description)) { + std::string key = "format-" + layout.short_description; + layoutName = + fmt::format(fmt::runtime(config_[key].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 { + layoutName = fmt::format(fmt::runtime(format_), fmt::arg("long", layout.full_name), + fmt::arg("short", layout.short_name), + fmt::arg("shortDescription", layout.short_description), + fmt::arg("variant", layout.variant)); + } + + if (!layoutName.empty()) { + label_.show(); + label_.set_markup(layoutName); + } else { + label_.hide(); + } +} + +void Language::update() { + updateFromIPC(); + doUpdate(); + ALabel::update(); +} + +void Language::onEvent(const Json::Value& ev) { + updateFromIPC(); + dp.emit(); +} + +Language::Layout Language::getLayout(const std::string& fullName) { + if (rxkb_ctx_) { + rxkb_layout* layout = rxkb_layout_first(rxkb_ctx_); + while (layout != nullptr) { + std::string desc = rxkb_layout_get_description(layout); + if (desc == fullName) { + std::string short_name = rxkb_layout_get_name(layout); + const char* variant_ptr = rxkb_layout_get_variant(layout); + std::string variant = variant_ptr ? variant_ptr : ""; + const char* brief_ptr = rxkb_layout_get_brief(layout); + std::string short_description = brief_ptr ? brief_ptr : ""; + + if (short_description.empty()) { + short_description = short_name; + } + + Layout info{desc, short_name, variant, short_description}; + return info; + } + layout = rxkb_layout_next(layout); + } + } + + spdlog::warn("mango language: rxkb failed to find layout '{}', using string parsing fallback", + fullName); + + Layout l; + l.full_name = fullName; + l.variant = ""; + + size_t paren_start = fullName.find('('); + size_t paren_end = fullName.find(')'); + if (paren_start != std::string::npos && paren_end != std::string::npos && + paren_end > paren_start) { + l.short_name = fullName.substr(paren_start + 1, paren_end - paren_start - 1); + } else if (fullName.length() >= 2) { + l.short_name = fullName.substr(0, 2); + } else { + l.short_name = fullName; + } + + std::transform(l.short_name.begin(), l.short_name.end(), l.short_name.begin(), + [](unsigned char c) { return std::tolower(c); }); + + l.short_description = l.short_name; + + return l; +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/mango/layout.cpp b/src/modules/mango/layout.cpp new file mode 100644 index 00000000..999b0a96 --- /dev/null +++ b/src/modules/mango/layout.cpp @@ -0,0 +1,55 @@ +#include "modules/mango/layout.hpp" + +#include + +namespace waybar::modules::mango { + +Layout::Layout(const std::string& id, const Bar& bar, const Json::Value& config) + : ALabel(config, "layout", id, "{}", 0, false), bar_(bar) { + IPC::getInstance().registerForIPC("monitor", this); +} + +Layout::~Layout() { IPC::getInstance().unregisterForIPC(this); } + +void Layout::onEvent(const Json::Value& ev) { dp.emit(); } + +void Layout::doUpdate() { + std::lock_guard lock(mutex_); + + std::string symbol = IPC::getInstance().getLayoutSymbolForMonitor(bar_.output->name); + + if (symbol.empty()) { + label_.hide(); + last_symbol_.clear(); + return; + } + + if (symbol != last_symbol_) { + if (!last_symbol_.empty()) label_.get_style_context()->remove_class(last_symbol_); + label_.get_style_context()->add_class(symbol); + last_symbol_ = symbol; + } + + std::string text; + std::string format_key = "format-" + symbol; + + if (config_.isMember(format_key)) { + text = fmt::format(fmt::runtime(config_[format_key].asString()), fmt::arg("symbol", symbol)); + } else { + text = fmt::format(fmt::runtime(format_), fmt::arg("symbol", symbol)); + } + + if (!text.empty()) { + label_.show(); + label_.set_markup(text); + } else { + label_.hide(); + } +} + +void Layout::update() { + doUpdate(); + ALabel::update(); +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/mango/window.cpp b/src/modules/mango/window.cpp new file mode 100644 index 00000000..db42b226 --- /dev/null +++ b/src/modules/mango/window.cpp @@ -0,0 +1,92 @@ +#include "modules/mango/window.hpp" + +#include + +#include "util/rewrite_string.hpp" +#include "util/sanitize_str.hpp" + +namespace waybar::modules::mango { + +Window::Window(const std::string& id, const Bar& bar, const Json::Value& config) + : AAppIconLabel(config, "window", id, "{title}", 0, true), bar_(bar) { + IPC::getInstance().registerForIPC("monitor", this); +} + +Window::~Window() { IPC::getInstance().unregisterForIPC(this); } + +void Window::onEvent(const Json::Value& ev) { dp.emit(); } + +void Window::doUpdate() { + std::lock_guard lock(mutex_); + + const Json::Value& client = IPC::getInstance().getActiveClientForMonitor(bar_.output->name); + + // judge whether to hide: active_client is null or title field is null + if (client.isNull() || !client.isObject() || client["title"].isNull()) { + event_box_.hide(); + label_.hide(); + updateAppIconName("", ""); + setClass("empty", true); + if (!oldAppId_.empty()) setClass(oldAppId_, false); + oldAppId_.clear(); + return; + } + + // if we have a valid client, show the label and update content + event_box_.show(); + label_.show(); + setClass("empty", false); + + std::string title = client["title"].asString(); + std::string appid = client["appid"].asString(); + std::string sanitized_title = waybar::util::sanitize_string(title); + std::string sanitized_appid = waybar::util::sanitize_string(appid); + + label_.set_markup(waybar::util::rewriteString( + fmt::format(fmt::runtime(format_), fmt::arg("title", sanitized_title), + fmt::arg("app_id", sanitized_appid)), + config_["rewrite"])); + + updateAppIconName(appid, ""); + if (tooltipEnabled()) label_.set_tooltip_markup(title); + + // Solo judgment + bool solo = false; + if (client.isMember("tags") && client["tags"].isArray() && client["tags"].size() == 1) { + int tag_idx = client["tags"][0].asInt(); + const auto& monitors = IPC::getInstance().getMonitors(); + auto mon_it = monitors.find(client["monitor"].asString()); + if (mon_it != monitors.end()) { + const auto& tags = mon_it->second["tags"]; + for (const auto& tag : tags) { + if (tag["index"].asInt() == tag_idx) { + solo = (tag["client_count"].asInt() == 1); + break; + } + } + } + } + setClass("solo", solo); + if (!appid.empty()) setClass(appid, solo); + + if (oldAppId_ != appid) { + if (!oldAppId_.empty()) setClass(oldAppId_, false); + oldAppId_ = appid; + } +} + +void Window::update() { + doUpdate(); + AAppIconLabel::update(); +} + +void Window::setClass(const std::string& className, bool enable) { + auto style_context = event_box_.get_style_context(); + if (enable) { + if (!style_context->has_class(className)) style_context->add_class(className); + } else { + style_context->remove_class(className); + } +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/mango/workspaces.cpp b/src/modules/mango/workspaces.cpp new file mode 100644 index 00000000..2c2b790c --- /dev/null +++ b/src/modules/mango/workspaces.cpp @@ -0,0 +1,252 @@ +#include "modules/mango/workspaces.hpp" + +#include + +#include + +namespace waybar::modules::mango { + +Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& config) + : AModule(config, "workspaces", id, false, false), bar_(bar), box_(bar.orientation, 0) { + box_.set_name("workspaces"); + if (!id.empty()) box_.get_style_context()->add_class(id); + box_.get_style_context()->add_class(MODULE_CLASS); + event_box_.add(box_); + + if (config_["on-click"].isString()) on_click_left_ = config_["on-click"].asString(); + if (config_["on-click-middle"].isString()) + on_click_middle_ = config_["on-click-middle"].asString(); + if (config_["on-click-right"].isString()) on_click_right_ = config_["on-click-right"].asString(); + + overview_button_ = new Gtk::Button("OVERVIEW"); + overview_button_->set_relief(Gtk::RELIEF_NONE); + box_.pack_start(*overview_button_, false, false, 0); + + if (!on_click_left_.empty() || !on_click_middle_.empty() || !on_click_right_.empty()) { + overview_button_->add_events(Gdk::BUTTON_PRESS_MASK); + overview_button_->signal_button_press_event().connect( + [this](GdkEventButton* event) -> bool { return handleButtonClick(event, 0, true); }, false); + } + + IPC::getInstance().registerForIPC("monitor", this); +} + +Workspaces::~Workspaces() { + IPC::getInstance().unregisterForIPC(this); + + if (overview_button_) { + box_.remove(*overview_button_); + delete overview_button_; + overview_button_ = nullptr; + } + + for (auto& [idx, btn] : buttons_) { + box_.remove(btn); + } + buttons_.clear(); +} + +void Workspaces::onEvent(const Json::Value& ev) { dp.emit(); } + +void Workspaces::doUpdate() { + Json::Value monitor = IPC::getInstance().getMonitor(bar_.output->name); + if (monitor.isNull()) return; + + const auto& tags = monitor["tags"]; + + bool overview_mode = false; + if (monitor.isMember("active_tags") && monitor["active_tags"].isArray()) { + const auto& active_tags = monitor["active_tags"]; + if (active_tags.size() == 1 && active_tags[0].asInt() == 0) { + overview_mode = true; + } + } + + for (auto& [idx, btn] : buttons_) { + btn.hide(); + } + + if (overview_mode) { + overview_button_->show(); + auto style = overview_button_->get_style_context(); + style->add_class("overview"); + if (monitor["active"].asBool()) + style->add_class("current_output"); + else + style->remove_class("current_output"); + + std::string label = + config_["overview-label"].isString() ? config_["overview-label"].asString() : "OVERVIEW"; + + if (!config_["disable-markup"].asBool()) { + if (auto gtk_label = dynamic_cast(overview_button_->get_child())) { + gtk_label->set_markup(label); + } + } else { + overview_button_->set_label(label); + } + } else { + overview_button_->hide(); + + for (auto btn_it = buttons_.begin(); btn_it != buttons_.end();) { + uint64_t id = btn_it->first; + bool found = std::any_of(tags.begin(), tags.end(), [id](const Json::Value& tag) { + return tag["index"].asUInt64() == id; + }); + + if (!found) { + box_.remove(btn_it->second); + btn_it = buttons_.erase(btn_it); + } else { + ++btn_it; + } + } + + for (const auto& tag : tags) { + uint64_t idx = tag["index"].asUInt64(); + auto btn_it = buttons_.find(idx); + Gtk::Button& button = (btn_it == buttons_.end()) ? addButton(idx) : btn_it->second; + updateButtonState(button, tag, monitor); + } + + std::vector indices; + for (const auto& tag : tags) indices.push_back(tag["index"].asUInt64()); + std::sort(indices.begin(), indices.end()); + int pos = 0; + for (uint64_t idx : indices) { + box_.reorder_child(buttons_[idx], pos + 1); + pos++; + } + } +} + +void Workspaces::update() { + doUpdate(); + AModule::update(); +} + +Gtk::Button& Workspaces::addButton(uint64_t idx) { + auto [it, _] = buttons_.emplace(idx, std::to_string(idx)); + auto& button = it->second; + box_.pack_start(button, false, false, 0); + button.set_relief(Gtk::RELIEF_NONE); + + if (!on_click_left_.empty() || !on_click_middle_.empty() || !on_click_right_.empty()) { + button.add_events(Gdk::BUTTON_PRESS_MASK); + button.signal_button_press_event().connect( + [this, idx](GdkEventButton* event) -> bool { return handleButtonClick(event, idx, false); }, + false); + } + + button.show_all(); + return button; +} + +void Workspaces::updateButtonState(Gtk::Button& button, const Json::Value& tag, + const Json::Value& monitor) { + auto style = button.get_style_context(); + bool active = tag["is_active"].asBool(); + bool urgent = tag["is_urgent"].asBool(); + bool empty = (tag["client_count"].asInt() == 0); + + if (active) + style->add_class("active"); + else + style->remove_class("active"); + + if (urgent) + style->add_class("urgent"); + else + style->remove_class("urgent"); + + if (empty) + style->add_class("empty"); + else + style->remove_class("empty"); + + if (monitor["active"].asBool()) + style->add_class("current_output"); + else + style->remove_class("current_output"); + + uint64_t idx = tag["index"].asUInt64(); + std::string name = std::to_string(idx); + if (config_["format"].isString()) { + name = fmt::format(fmt::runtime(config_["format"].asString()), + fmt::arg("icon", getIcon(name, tag)), fmt::arg("value", name), + fmt::arg("name", name), fmt::arg("index", idx), + fmt::arg("output", monitor["name"].asString())); + } + + if (!config_["disable-markup"].asBool()) { + if (auto gtk_label = dynamic_cast(button.get_child())) { + gtk_label->set_markup(name); + } + } else { + button.set_label(name); + } + + if (config_["current-only"].asBool()) { + if (active) + button.show(); + else + button.hide(); + } else if (config_["hide-empty"].asBool() && empty && !active) { + button.hide(); + } else { + button.show(); + } +} + +std::string Workspaces::getIcon(const std::string& value, const Json::Value& tag) { + const auto& icons = config_["format-icons"]; + if (!icons) return value; + + if (tag["is_urgent"].asBool() && icons["urgent"]) return icons["urgent"].asString(); + if (tag["is_active"].asBool() && icons["active"]) return icons["active"].asString(); + if (tag["client_count"].asInt() == 0 && icons["empty"]) return icons["empty"].asString(); + + std::string idx = std::to_string(tag["index"].asUInt()); + if (icons[idx]) return icons[idx].asString(); + if (icons["default"]) return icons["default"].asString(); + return value; +} + +bool Workspaces::handleButtonClick(GdkEventButton* event, uint64_t idx, bool isOverview) { + std::string action; + if (event->button == 1) + action = on_click_left_; + else if (event->button == 2) + action = on_click_middle_; + else if (event->button == 3) + action = on_click_right_; + + if (action.empty()) return true; + + try { + std::string cmd; + if (isOverview) { + if (action == "activate") + cmd = "dispatch overview"; + else if (action == "toggle") + cmd = "dispatch toggleoverview"; + } else { + if (action == "activate") + cmd = "dispatch view," + std::to_string(idx); + else if (action == "toggle") + cmd = "dispatch toggleview," + std::to_string(idx); + } + + if (!cmd.empty()) { + Json::Value req; + req["command"] = cmd; + IPC::sendAsync(req); + } + } catch (const std::exception& e) { + spdlog::error("Error sending IPC command: {}", e.what()); + } + + return true; +} + +} // namespace waybar::modules::mango \ No newline at end of file diff --git a/src/modules/memory/common.cpp b/src/modules/memory/common.cpp index 731f0b08..610c3830 100644 --- a/src/modules/memory/common.cpp +++ b/src/modules/memory/common.cpp @@ -1,5 +1,16 @@ +#include + #include "modules/memory.hpp" +namespace { +// /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 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) : ALabel(config, "memory", id, "{}%", 30) { thread_ = [this] { @@ -8,6 +19,11 @@ waybar::modules::Memory::Memory(const std::string& id, const Json::Value& config }; if (config["unit"].isString()) { unit_ = config["unit"].asString(); + if (!kUnits.contains(unit_)) { + unit_ = "GiB"; + } + } else { + unit_ = "GiB"; } } @@ -36,17 +52,20 @@ auto waybar::modules::Memory::update() -> void { if (memtotal > 0 && memfree >= 0) { int used_ram_percentage = 100 * (memtotal - memfree) / memtotal; int used_swap_percentage = 0; - if ((bool) swaptotal) { + if ((bool)swaptotal) { used_swap_percentage = 100 * (swaptotal - swapfree) / swaptotal; } - float divisor = calc_divisor(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; + float divisor = kUnits.at(unit_); + // 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); @@ -59,31 +78,14 @@ auto waybar::modules::Memory::update() -> void { } else { event_box_.show(); auto icons = std::vector{state}; - label_.set_markup(fmt::format( - fmt::runtime(format), used_ram_percentage, - fmt::arg("icon", getIcon(used_ram_percentage, icons)), - fmt::arg("total", total_ram), fmt::arg("swapTotal", total_swap), - fmt::arg("percentage", used_ram_percentage), + updateLabelAndTooltip( + format, fmt::format("{:.{}f}{} used", used_ram, 1, unit_), used_ram_percentage, + fmt::arg("icon", getIcon(used_ram_percentage, icons)), fmt::arg("total", total_ram), + fmt::arg("swapTotal", total_swap), fmt::arg("percentage", used_ram_percentage), fmt::arg("swapState", swaptotal == 0 ? "Off" : "On"), fmt::arg("swapPercentage", used_swap_percentage), fmt::arg("used", used_ram), fmt::arg("swapUsed", used_swap), fmt::arg("avail", available_ram), - fmt::arg("swapAvail", available_swap))); - } - - if (tooltipEnabled()) { - if (config_["tooltip-format"].isString()) { - auto tooltip_format = config_["tooltip-format"].asString(); - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_format), used_ram_percentage, - fmt::arg("total", total_ram), fmt::arg("swapTotal", total_swap), - fmt::arg("percentage", used_ram_percentage), - fmt::arg("swapState", swaptotal == 0 ? "Off" : "On"), - fmt::arg("swapPercentage", used_swap_percentage), fmt::arg("used", used_ram), - fmt::arg("swapUsed", used_swap), fmt::arg("avail", available_ram), - fmt::arg("swapAvail", available_swap))); - } else { - label_.set_tooltip_markup(fmt::format("{:.{}f}GiB used", used_ram, 1)); - } + fmt::arg("swapAvail", available_swap)); } } else { event_box_.hide(); @@ -91,25 +93,3 @@ auto waybar::modules::Memory::update() -> void { // Call parent update ALabel::update(); } - -float waybar::modules::Memory::calc_divisor(const std::string& divisor) { - if (divisor == "kB") { - return 1.0; - } else if (divisor == "kiB") { - return 1.024; - } else if (divisor == "MB") { - return 1.000 * 1000.0; - } else if (divisor == "MiB") { - return 1.024 * 1024.0; - } else if (divisor == "GB") { - return 1.000 * 1000.0 * 1000.0; - } else if (divisor == "GiB") { - return 1.024 * 1024.0 * 1024.0; - } else if (divisor == "TB") { - return 1.000 * 1000.0 * 1000.0 * 1000.0; - } else if (divisor == "TiB") { - return 1.024 * 1024.0 * 1024.0 * 1024.0; - } else { // default to GiB if it is anything that we don't recongnise - return 1.024 * 1024.0 * 1024.0; - } -} diff --git a/src/modules/mpd/mpd.cpp b/src/modules/mpd/mpd.cpp index 9874f458..035183a0 100644 --- a/src/modules/mpd/mpd.cpp +++ b/src/modules/mpd/mpd.cpp @@ -6,6 +6,7 @@ #include #include +#include using namespace waybar::util; #include "modules/mpd/state.hpp" @@ -22,9 +23,12 @@ waybar::modules::MPD::MPD(const std::string& id, const Json::Value& config) port_(config_["port"].isUInt() ? config["port"].asUInt() : 0), password_(config_["password"].empty() ? "" : config_["password"].asString()), timeout_(config_["timeout"].isUInt() ? config_["timeout"].asUInt() * 1'000 : 30'000), + playing_interval_(config_["playing-interval"].isUInt() ? config_["playing-interval"].asUInt() + : 1'000), connection_(nullptr, &mpd_connection_free), status_(nullptr, &mpd_status_free), - song_(nullptr, &mpd_song_free) { + song_(nullptr, &mpd_song_free), + 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_); } @@ -33,6 +37,10 @@ waybar::modules::MPD::MPD(const std::string& id, const Json::Value& config) spdlog::warn("{}: `timeout` configuration should be an unsigned int", module_name_); } + if (!config_["playing-interval"].isNull() && !config_["playing-interval"].isUInt()) { + spdlog::warn("{}: `playing-interval` configuration should be an unsigned int", module_name_); + } + if (!config["server"].isNull()) { if (!config_["server"].isString()) { spdlog::warn("{}:`server` configuration should be a string", module_name_); @@ -51,25 +59,11 @@ auto waybar::modules::MPD::update() -> void { ALabel::update(); } -void waybar::modules::MPD::queryMPD() { - if (connection_ != nullptr) { - spdlog::trace("{}: fetching state information", module_name_); - try { - fetchState(); - spdlog::trace("{}: fetch complete", module_name_); - } catch (std::exception const& e) { - spdlog::error("{}: {}", module_name_, e.what()); - state_ = MPD_STATE_UNKNOWN; - } - - dp.emit(); - } -} - std::string waybar::modules::MPD::getTag(mpd_tag_type type, unsigned idx) const { std::string result = config_["unknown-tag"].isString() ? config_["unknown-tag"].asString() : "N/A"; - const char* tag = mpd_song_get_tag(song_.get(), type, idx); + // song_ is null when there is no current song (e.g. after `mpc clear`) (#5183). + const char* tag = song_ ? mpd_song_get_tag(song_.get(), type, idx) : nullptr; // mpd_song_get_tag can return NULL, so make sure it's valid before setting if (tag) result = tag; @@ -78,6 +72,9 @@ std::string waybar::modules::MPD::getTag(mpd_tag_type type, unsigned idx) const } std::string waybar::modules::MPD::getFilename() const { + if (!song_) { + return ""; + } std::string path = mpd_song_get_uri(song_.get()); size_t position = path.find_last_of("/"); if (position == std::string::npos) { @@ -98,7 +95,7 @@ void waybar::modules::MPD::setLabel() { ? config_["format-disconnected"].asString() : "disconnected"; if (format.empty()) { - label_.set_markup(format); + setLabelMarkup(format); label_.show(); } else { label_.hide(); @@ -110,22 +107,22 @@ void waybar::modules::MPD::setLabel() { ? config_["tooltip-format-disconnected"].asString() : "MPD (disconnected)"; // Nothing to format - label_.set_tooltip_markup(tooltip_format); + setTooltipMarkup(tooltip_format); } return; } label_.get_style_context()->remove_class("disconnected"); auto format = format_; - Glib::ustring artist, album_artist, album, title; std::string date, filename, uri; int song_pos = 0, queue_length = 0, volume = 0; std::chrono::seconds elapsedTime, totalTime; std::string stateIcon = ""; bool no_song = song_.get() == nullptr; - if (stopped() || no_song) { - if (no_song) spdlog::warn("Bug in mpd: no current song but state is not stopped."); + bool is_stopped = stopped(); + if (is_stopped || no_song) { + if (no_song && !is_stopped) spdlog::warn("mpd: no current song while state is not stopped"); format = config_["format-stopped"].isString() ? config_["format-stopped"].asString() : "stopped"; label_.get_style_context()->add_class("stopped"); @@ -137,18 +134,15 @@ void waybar::modules::MPD::setLabel() { label_.get_style_context()->add_class("playing"); label_.get_style_context()->remove_class("paused"); } else if (paused()) { - format = config_["format-paused"].isString() ? config_["format-paused"].asString() - : config_["format"].asString(); + if (config_["format-paused"].isString()) { + format = config_["format-paused"].asString(); + } label_.get_style_context()->add_class("paused"); label_.get_style_context()->remove_class("playing"); } stateIcon = getStateIcon(); - artist = sanitize_string(getTag(MPD_TAG_ARTIST)); - album_artist = sanitize_string(getTag(MPD_TAG_ALBUM_ARTIST)); - album = sanitize_string(getTag(MPD_TAG_ALBUM)); - title = sanitize_string(getTag(MPD_TAG_TITLE)); date = sanitize_string(getTag(MPD_TAG_DATE)); filename = sanitize_string(getFilename()); uri = mpd_song_get_uri(song_.get()); @@ -170,17 +164,12 @@ void waybar::modules::MPD::setLabel() { std::string repeatIcon = getOptionIcon("repeat", repeatActivated); bool singleActivated = mpd_status_get_single(status_.get()); std::string singleIcon = getOptionIcon("single", singleActivated); - if (config_["artist-len"].isInt()) artist = artist.substr(0, config_["artist-len"].asInt()); - if (config_["album-artist-len"].isInt()) - album_artist = album_artist.substr(0, config_["album-artist-len"].asInt()); - if (config_["album-len"].isInt()) album = album.substr(0, config_["album-len"].asInt()); - if (config_["title-len"].isInt()) title = title.substr(0, config_["title-len"].asInt()); try { auto text = fmt::format( - fmt::runtime(format), fmt::arg("artist", artist.raw()), - fmt::arg("albumArtist", album_artist.raw()), fmt::arg("album", album.raw()), - fmt::arg("title", title.raw()), fmt::arg("date", date), fmt::arg("volume", volume), + fmt::runtime(format), fmt::arg("artist", getArtistStr(true)), + fmt::arg("albumArtist", getAlbumArtistStr(true)), fmt::arg("album", getAlbumStr(true)), + fmt::arg("title", getTitleStr(true)), fmt::arg("date", date), fmt::arg("volume", volume), fmt::arg("elapsedTime", elapsedTime), fmt::arg("totalTime", totalTime), fmt::arg("songPosition", song_pos), fmt::arg("queueLength", queue_length), fmt::arg("stateIcon", stateIcon), fmt::arg("consumeIcon", consumeIcon), @@ -190,7 +179,7 @@ void waybar::modules::MPD::setLabel() { label_.hide(); } else { label_.show(); - label_.set_markup(text); + setLabelMarkup(text); } } catch (fmt::format_error const& e) { spdlog::warn("mpd: format error: {}", e.what()); @@ -202,15 +191,15 @@ void waybar::modules::MPD::setLabel() { : "MPD (connected)"; try { auto tooltip_text = fmt::format( - fmt::runtime(tooltip_format), fmt::arg("artist", artist.raw()), - fmt::arg("albumArtist", album_artist.raw()), fmt::arg("album", album.raw()), - fmt::arg("title", title.raw()), fmt::arg("date", date), fmt::arg("volume", volume), + fmt::runtime(tooltip_format), fmt::arg("artist", getArtistStr(false)), + fmt::arg("albumArtist", getAlbumArtistStr(false)), fmt::arg("album", getAlbumStr(false)), + fmt::arg("title", getTitleStr(false)), fmt::arg("date", date), fmt::arg("volume", volume), fmt::arg("elapsedTime", elapsedTime), fmt::arg("totalTime", totalTime), fmt::arg("songPosition", song_pos), fmt::arg("queueLength", queue_length), fmt::arg("stateIcon", stateIcon), fmt::arg("consumeIcon", consumeIcon), fmt::arg("randomIcon", randomIcon), fmt::arg("repeatIcon", repeatIcon), fmt::arg("singleIcon", singleIcon), fmt::arg("filename", filename), fmt::arg("uri", uri)); - label_.set_tooltip_markup(tooltip_text); + setTooltipMarkup(tooltip_text); } catch (fmt::format_error const& e) { spdlog::warn("mpd: format error (tooltip): {}", e.what()); } @@ -257,6 +246,38 @@ std::string waybar::modules::MPD::getOptionIcon(const std::string& optionName, } } +std::string waybar::modules::MPD::getArtistStr(bool truncated) const { + std::string artist = getTag(MPD_TAG_ARTIST); + if (truncated && config_["artist-len"].isInt()) { + waybar::util::utf8_truncate(artist, ellipsis_, config_["artist-len"].asInt()); + } + return sanitize_string(artist); +} + +std::string waybar::modules::MPD::getAlbumArtistStr(bool truncated) const { + std::string album_artist = getTag(MPD_TAG_ALBUM_ARTIST); + if (truncated && config_["album-artist-len"].isInt()) { + waybar::util::utf8_truncate(album_artist, ellipsis_, config_["album-artist-len"].asInt()); + } + return sanitize_string(album_artist); +} + +std::string waybar::modules::MPD::getAlbumStr(bool truncated) const { + std::string album = getTag(MPD_TAG_ALBUM); + if (truncated && config_["album-len"].isInt()) { + waybar::util::utf8_truncate(album, ellipsis_, config_["album-len"].asInt()); + } + return sanitize_string(album); +} + +std::string waybar::modules::MPD::getTitleStr(bool truncated) const { + std::string title = getTag(MPD_TAG_TITLE); + if (truncated && config_["title-len"].isInt()) { + waybar::util::utf8_truncate(title, ellipsis_, config_["title-len"].asInt()); + } + return sanitize_string(title); +} + static bool isServerUnavailable(const std::error_code& ec) { if (ec.category() == std::system_category()) { switch (ec.value()) { @@ -277,8 +298,19 @@ void waybar::modules::MPD::tryConnect() { return; } - connection_ = - detail::unique_connection(mpd_connection_new(server_, port_, timeout_), &mpd_connection_free); + // tryConnect() runs on the GTK main thread (via Glib::signal_timeout), so a + // blocking connect freezes the whole bar. Bound the connect attempt to a + // short timeout so an unreachable MPD server fails fast instead of hanging + // the loop for the full user-facing `timeout_` (up to 30s by default, #1186). + // The third argument to mpd_connection_new() is also the default command + // read timeout, so restore `timeout_` once connected to avoid shortening + // reads for slow-but-alive servers. + static constexpr unsigned kConnectTimeoutMs = 2'000; + unsigned connect_timeout = + (timeout_ != 0 && timeout_ < kConnectTimeoutMs) ? timeout_ : kConnectTimeoutMs; + + connection_ = detail::unique_connection(mpd_connection_new(server_, port_, connect_timeout), + &mpd_connection_free); if (connection_ == nullptr) { spdlog::error("{}: Failed to connect to MPD", module_name_); @@ -286,6 +318,11 @@ void waybar::modules::MPD::tryConnect() { return; } + // Restore the user-configured timeout for subsequent command reads. + if (timeout_ != 0) { + mpd_connection_set_timeout(connection_.get(), timeout_); + } + try { checkErrors(connection_.get()); spdlog::debug("{}: Connected to MPD", module_name_); diff --git a/src/modules/mpd/state.cpp b/src/modules/mpd/state.cpp index 3d7c8561..834f97c9 100644 --- a/src/modules/mpd/state.cpp +++ b/src/modules/mpd/state.cpp @@ -19,39 +19,37 @@ auto format_as(enum mpd_idle val) { namespace waybar::modules::detail { -#define IDLE_RUN_NOIDLE_AND_CMD(...) \ - if (idle_connection_.connected()) { \ - idle_connection_.disconnect(); \ - auto conn = ctx_->connection().get(); \ - if (!mpd_run_noidle(conn)) { \ - if (mpd_connection_get_error(conn) != MPD_ERROR_SUCCESS) { \ - spdlog::error("mpd: Idle: failed to unregister for IDLE events"); \ - ctx_->checkErrors(conn); \ - } \ - } \ - __VA_ARGS__; \ +#define RUN_NOIDLE_AND_CMD(STATE, ...) \ + if (idle_connection_.connected()) { \ + idle_connection_.disconnect(); \ + auto conn = ctx_->connection().get(); \ + if (!mpd_run_noidle(conn)) { \ + if (mpd_connection_get_error(conn) != MPD_ERROR_SUCCESS) { \ + spdlog::error("mpd: STATE: failed to unregister for IDLE events"); \ + ctx_->checkErrors(conn); \ + } \ + } \ + __VA_ARGS__; \ } void Idle::play() { - IDLE_RUN_NOIDLE_AND_CMD(mpd_run_play(conn)); + RUN_NOIDLE_AND_CMD(Idle, mpd_run_play(conn)); ctx_->setState(std::make_unique(ctx_)); } void Idle::pause() { - IDLE_RUN_NOIDLE_AND_CMD(mpd_run_pause(conn, true)); + RUN_NOIDLE_AND_CMD(Idle, mpd_run_pause(conn, true)); ctx_->setState(std::make_unique(ctx_)); } void Idle::stop() { - IDLE_RUN_NOIDLE_AND_CMD(mpd_run_stop(conn)); + RUN_NOIDLE_AND_CMD(Idle, mpd_run_stop(conn)); ctx_->setState(std::make_unique(ctx_)); } -#undef IDLE_RUN_NOIDLE_AND_CMD - void Idle::update() noexcept { // This is intentionally blank. } @@ -60,16 +58,21 @@ void Idle::entry() noexcept { auto conn = ctx_->connection().get(); assert(conn != nullptr); - if (!mpd_send_idle_mask( - conn, static_cast(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) { - ctx_->checkErrors(conn); - spdlog::error("mpd: Idle: failed to register for IDLE events"); - } else { - spdlog::debug("mpd: Idle: watching FD"); - sigc::slot idle_slot = sigc::mem_fun(*this, &Idle::on_io); - idle_connection_ = - Glib::signal_io().connect(idle_slot, mpd_connection_get_fd(conn), - Glib::IO_IN | Glib::IO_PRI | Glib::IO_ERR | Glib::IO_HUP); + try { + if (!mpd_send_idle_mask( + conn, static_cast(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) { + ctx_->checkErrors(conn); + spdlog::error("mpd: Idle: failed to register for IDLE events"); + } else { + spdlog::debug("mpd: Idle: watching FD"); + sigc::slot idle_slot = sigc::mem_fun(*this, &Idle::on_io); + idle_connection_ = + 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(ctx_)); } } @@ -97,19 +100,16 @@ bool Idle::on_io(Glib::IOCondition const&) { } ctx_->fetchState(); + ctx_->emit(); mpd_state state = ctx_->state(); if (state == MPD_STATE_STOP) { - ctx_->emit(); ctx_->setState(std::make_unique(ctx_)); } else if (state == MPD_STATE_PLAY) { - ctx_->emit(); ctx_->setState(std::make_unique(ctx_)); } else if (state == MPD_STATE_PAUSE) { - ctx_->emit(); ctx_->setState(std::make_unique(ctx_)); } else { - ctx_->emit(); // self transition ctx_->setState(std::make_unique(ctx_)); } @@ -118,15 +118,53 @@ bool Idle::on_io(Glib::IOCondition const&) { } void Playing::entry() noexcept { - sigc::slot timer_slot = sigc::mem_fun(*this, &Playing::on_timer); - timer_connection_ = Glib::signal_timeout().connect_seconds(timer_slot, 1); - spdlog::debug("mpd: Playing: enabled 1 second periodic timer."); + timer(); + 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 { + if (idle_connection_.connected()) { + idle_connection_.disconnect(); + spdlog::debug("mpd: Playing: unwatching FD"); + } + if (timer_connection_.connected()) { timer_connection_.disconnect(); - spdlog::debug("mpd: Playing: disabled 1 second periodic timer."); + spdlog::debug("mpd: Playing: disabled {}ms periodic timer.", ctx_->playing_interval()); + } +} + +void Playing::timer() noexcept { + if (timer_connection_.connected()) { + timer_connection_.disconnect(); + } + + sigc::slot timer_slot = sigc::mem_fun(*this, &Playing::on_timer); + timer_connection_ = Glib::signal_timeout().connect(timer_slot, ctx_->playing_interval()); +} + +void Playing::idle() noexcept { + auto conn = ctx_->connection().get(); + assert(conn != nullptr); + + try { + if (!mpd_send_idle_mask( + conn, static_cast(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) { + ctx_->checkErrors(conn); + spdlog::error("mpd: Playing: failed to register for IDLE events"); + } else if (!idle_connection_.connected()) { + spdlog::trace("mpd: Playing: watching FD"); + sigc::slot idle_slot = sigc::mem_fun(*this, &Playing::on_io); + idle_connection_ = + 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(ctx_)); } } @@ -141,9 +179,12 @@ bool Playing::on_timer() { return false; } + RUN_NOIDLE_AND_CMD(Playing); + ctx_->fetchState(); if (!ctx_->is_playing()) { + ctx_->emit(); if (ctx_->is_paused()) { ctx_->setState(std::make_unique(ctx_)); } else { @@ -152,8 +193,51 @@ bool Playing::on_timer() { return false; } - ctx_->queryMPD(); ctx_->emit(); + + idle(); + } catch (std::exception const& e) { + spdlog::warn("mpd: Playing: error: {}", e.what()); + ctx_->setState(std::make_unique(ctx_)); + return false; + } + + return true; +} + +bool Playing::on_io(Glib::IOCondition const&) { + auto conn = ctx_->connection().get(); + + // callback should do this: + enum mpd_idle events = mpd_recv_idle(conn, /* ignore_timeout?= */ false); + spdlog::debug("mpd: Playing: recv_idle events -> {}", events); + + mpd_response_finish(conn); + try { + ctx_->checkErrors(conn); + + ctx_->fetchState(); + + if (!ctx_->is_playing()) { + ctx_->emit(); + if (ctx_->is_paused()) { + ctx_->setState(std::make_unique(ctx_)); + } else { + ctx_->setState(std::make_unique(ctx_)); + } + return false; + } + + ctx_->emit(); + + if (!mpd_send_idle_mask( + conn, static_cast(MPD_IDLE_PLAYER | MPD_IDLE_OPTIONS | MPD_IDLE_QUEUE))) { + ctx_->checkErrors(conn); + spdlog::error("mpd: Playing: failed to register for IDLE events"); + } + + // Defer the next timer + timer(); } catch (std::exception const& e) { spdlog::warn("mpd: Playing: error: {}", e.what()); ctx_->setState(std::make_unique(ctx_)); @@ -164,21 +248,23 @@ bool Playing::on_timer() { } void Playing::stop() { - if (timer_connection_.connected()) { - timer_connection_.disconnect(); + RUN_NOIDLE_AND_CMD( + Playing, if (timer_connection_.connected()) { + timer_connection_.disconnect(); - mpd_run_stop(ctx_->connection().get()); - } + mpd_run_stop(ctx_->connection().get()); + }); ctx_->setState(std::make_unique(ctx_)); } void Playing::pause() { - if (timer_connection_.connected()) { - timer_connection_.disconnect(); + RUN_NOIDLE_AND_CMD( + Playing, if (timer_connection_.connected()) { + timer_connection_.disconnect(); - mpd_run_pause(ctx_->connection().get(), true); - } + mpd_run_pause(ctx_->connection().get(), true); + }); ctx_->setState(std::make_unique(ctx_)); } @@ -212,7 +298,6 @@ bool Paused::on_timer() { } ctx_->fetchState(); - ctx_->emit(); if (ctx_->is_paused()) { @@ -283,7 +368,6 @@ bool Stopped::on_timer() { } ctx_->fetchState(); - ctx_->emit(); if (ctx_->is_stopped()) { @@ -387,4 +471,5 @@ bool Disconnected::on_timer() { void Disconnected::update() noexcept { ctx_->do_update(); } +#undef RUN_NOIDLE_AND_CMD } // namespace waybar::modules::detail diff --git a/src/modules/mpris/mpris.cpp b/src/modules/mpris/mpris.cpp index e0a27c92..e9b87e3f 100644 --- a/src/modules/mpris/mpris.cpp +++ b/src/modules/mpris/mpris.cpp @@ -7,6 +7,7 @@ #include #include "util/scope_guard.hpp" +#include "util/utf8_string.hpp" extern "C" { #include @@ -30,6 +31,7 @@ Mpris::Mpris(const std::string& id, const Json::Value& config) dynamic_separator_(" - "), truncate_hours_(true), tooltip_len_limits_(false), + prefer_album_artist_(false), // this character is used in Gnome so it's fine to use it here ellipsis_("\u2026"), player_("playerctld"), @@ -67,6 +69,9 @@ Mpris::Mpris(const std::string& id, const Json::Value& config) if (config_["enable-tooltip-len-limits"].isBool()) { tooltip_len_limits_ = config["enable-tooltip-len-limits"].asBool(); } + if (config_["prefer-album-artist"].isBool()) { + prefer_album_artist_ = config["prefer-album-artist"].asBool(); + } } if (config["artist-len"].isUInt()) { @@ -172,9 +177,6 @@ Mpris::Mpris(const std::string& id, const Json::Value& config) thread_.sleep_for(interval_); }; } - - // trigger initial update - dp.emit(); } Mpris::~Mpris() { @@ -202,69 +204,28 @@ auto Mpris::getIconFromJson(const Json::Value& icons, const std::string& key) -> // Wide characters count as two, zero-width characters count as zero // Modifies str in-place (unless width = std::string::npos) // Returns the total width of the string pre-truncating -size_t utf8_truncate(std::string& str, size_t width = std::string::npos) { - if (str.length() == 0) return 0; - - const gchar* trunc_end = nullptr; - - size_t total_width = 0; - - for (gchar *data = str.data(), *end = data + str.size(); data != nullptr;) { - gunichar c = g_utf8_get_char_validated(data, end - data); - if (c == -1U || c == -2U) { - // invalid unicode, treat string as ascii - if (width != std::string::npos && str.length() > width) str.resize(width); - return str.length(); - } else if (g_unichar_iswide(c)) { - total_width += 2; - } else if (!g_unichar_iszerowidth(c) && c != 0xAD) { // neither zero-width nor soft hyphen - total_width += 1; - } - - data = g_utf8_find_next_char(data, end); - if (width != std::string::npos && total_width <= width && !g_unichar_isspace(c)) - trunc_end = data; - } - - if (trunc_end) str.resize(trunc_end - str.data()); - - return total_width; -} - -size_t utf8_width(const std::string& str) { return utf8_truncate(const_cast(str)); } - -void truncate(std::string& s, const std::string& ellipsis, size_t max_len) { - if (max_len == 0) { - s.resize(0); - return; - } - size_t len = utf8_truncate(s, max_len); - if (len > max_len) { - size_t ellipsis_len = utf8_width(ellipsis); - if (max_len >= ellipsis_len) { - if (ellipsis_len) utf8_truncate(s, max_len - ellipsis_len); - s += ellipsis; - } else { - s.resize(0); - } - } -} auto Mpris::getArtistStr(const PlayerInfo& info, bool truncated) -> std::string { auto artist = info.artist.value_or(std::string()); - if (truncated && artist_len_ >= 0) truncate(artist, ellipsis_, artist_len_); + if (prefer_album_artist_) { + auto album_artist = info.album_artist.value_or(std::string()); + if (!album_artist.empty()) { + artist = album_artist; + } + } + if (truncated && artist_len_ >= 0) waybar::util::utf8_truncate(artist, ellipsis_, artist_len_); return artist; } auto Mpris::getAlbumStr(const PlayerInfo& info, bool truncated) -> std::string { auto album = info.album.value_or(std::string()); - if (truncated && album_len_ >= 0) truncate(album, ellipsis_, album_len_); + if (truncated && album_len_ >= 0) waybar::util::utf8_truncate(album, ellipsis_, album_len_); return album; } auto Mpris::getTitleStr(const PlayerInfo& info, bool truncated) -> std::string { auto title = info.title.value_or(std::string()); - if (truncated && title_len_ >= 0) truncate(title, ellipsis_, title_len_); + if (truncated && title_len_ >= 0) waybar::util::utf8_truncate(title, ellipsis_, title_len_); return title; } @@ -292,9 +253,9 @@ auto Mpris::getDynamicStr(const PlayerInfo& info, bool truncated, bool html) -> // keep position format same as length format auto position = getPositionStr(info, truncated && truncate_hours_ && length.length() < 6); - size_t artistLen = utf8_width(artist); - size_t albumLen = utf8_width(album); - size_t titleLen = utf8_width(title); + size_t artistLen = waybar::util::utf8_width(artist); + size_t albumLen = waybar::util::utf8_width(album); + size_t titleLen = waybar::util::utf8_width(title); size_t lengthLen = length.length(); size_t posLen = position.length(); @@ -313,7 +274,7 @@ auto Mpris::getDynamicStr(const PlayerInfo& info, bool truncated, bool html) -> // Since the first element doesn't present a separator and we don't know a priori which one // it will be, we add a "virtual separatorLen" to the dynamicLen, since we are adding the // separatorLen to all the other lengths. - size_t separatorLen = utf8_width(dynamic_separator_); + size_t separatorLen = waybar::util::utf8_width(dynamic_separator_); size_t dynamicLen = dynamic_len_ + separatorLen; if (showArtist) artistLen += separatorLen; if (showAlbum) albumLen += separatorLen; @@ -421,6 +382,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); @@ -441,8 +403,11 @@ auto Mpris::onPlayerNameVanished(PlayerctlPlayerManager* manager, PlayerctlPlaye if (mpris->player_ == "playerctld") { mpris->dp.emit(); } else if (mpris->player_ == player_name->name) { + // Don't touch GTK widgets directly from the playerctl callback: on resume + // from suspend this can run in a re-entrant / torn-down state and crash in + // Gtk::Widget::set_visible. Only update state + emit; update() (on the main + // thread) hides the module when there is no player. See #5124. mpris->player = nullptr; - mpris->event_box_.set_visible(false); mpris->dp.emit(); } } @@ -523,7 +488,13 @@ auto Mpris::getPlayerInfo() -> std::optional { continue; } auto* tmp = playerctl_player_new_from_name(pn, &error); - if (error || !tmp) continue; + if (error || !tmp) { + // Discard any error from this candidate so it doesn't leak into the next + // playerctl_player_new_from_name() call or the post-loop metadata calls, which + // assert that the passed GError is NULL (otherwise: GLib-CRITICAL / spurious errorexit). + g_clear_error(&error); + continue; + } if (!first_valid_player) { first_valid_player = tmp; first_valid_name = name; @@ -567,30 +538,45 @@ auto Mpris::getPlayerInfo() -> std::optional { .status_string = player_status, .artist = std::nullopt, .album = std::nullopt, + .album_artist = std::nullopt, .title = std::nullopt, .length = std::nullopt, }; - if (auto* artist_ = playerctl_player_get_artist(last_active_player_, &error)) { - spdlog::debug("mpris[{}]: artist = {}", info.name, artist_); - info.artist = artist_; - g_free(artist_); - } - if (error) goto errorexit; + auto sanitize = [ ]( char* raw_str ) { + std::string s( raw_str ); + std::replace( s.begin(), s.end(), '\n', ' ' ); + std::replace( s.begin(), s.end(), '\r', ' ' ); + return s; + }; - if (auto* album_ = playerctl_player_get_album(last_active_player_, &error)) { - spdlog::debug("mpris[{}]: album = {}", info.name, album_); - info.album = album_; - g_free(album_); +if(auto* artist_ = playerctl_player_get_artist( last_active_player_, &error )) { + spdlog::debug( "mpris[{}]: artist = {}", info.name, artist_ ); + info.artist = sanitize( artist_ ); + g_free( artist_ ); } - if (error) goto errorexit; + if(error) goto errorexit; - if (auto* title_ = playerctl_player_get_title(last_active_player_, &error)) { - spdlog::debug("mpris[{}]: title = {}", info.name, title_); - info.title = title_; - g_free(title_); + if(auto* album_artist_ = playerctl_player_print_metadata_prop( last_active_player_, "xesam:albumArtist", &error )) { + spdlog::debug( "mpris[{}]: albumArtist = {}", info.name, album_artist_ ); + info.album_artist = sanitize( album_artist_ ); + g_free( album_artist_ ); } - if (error) goto errorexit; + if(error) goto errorexit; + + if(auto* album_ = playerctl_player_get_album( last_active_player_, &error )) { + spdlog::debug( "mpris[{}]: album = {}", info.name, album_ ); + info.album = sanitize( album_ ); + g_free( album_ ); + } + if(error) goto errorexit; + + if(auto* title_ = playerctl_player_get_title( last_active_player_, &error )) { + spdlog::debug( "mpris[{}]: title = {}", info.name, title_ ); + info.title = sanitize( title_ ); + g_free( title_ ); + } + if(error) goto errorexit; if (auto* length_ = playerctl_player_print_metadata_prop(last_active_player_, "mpris:length", &error)) { @@ -763,7 +749,7 @@ auto Mpris::update() -> void { if (label_format.empty()) { label_.hide(); } else { - label_.set_markup(label_format); + setLabelMarkup(label_format); label_.show(); } } catch (fmt::format_error const& e) { @@ -773,17 +759,21 @@ 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", getArtistStr(info, tooltip_len_limits_)), - fmt::arg("title", getTitleStr(info, tooltip_len_limits_)), - fmt::arg("album", getAlbumStr(info, tooltip_len_limits_)), + fmt::arg("artist", + std::string(Glib::Markup::escape_text(getArtistStr(info, tooltip_len_limits_)))), + fmt::arg("title", + std::string(Glib::Markup::escape_text(getTitleStr(info, tooltip_len_limits_)))), + 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))); - label_.set_tooltip_markup(tooltip_text); + setTooltipMarkup(tooltip_text); } catch (fmt::format_error const& e) { spdlog::warn("mpris: format error (tooltip): {}", e.what()); } diff --git a/src/modules/network.cpp b/src/modules/network.cpp index f43893c5..14922210 100644 --- a/src/modules/network.cpp +++ b/src/modules/network.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -83,6 +84,24 @@ waybar::modules::Network::readBandwidthUsage() { return {{receivedBytes, transmittedBytes}}; } +uint32_t waybar::modules::Network::readLinkSpeed() const { + auto path = fmt::format("/sys/class/net/{}/speed", ifname_); + std::ifstream sysfs_speed(path); + + if (!sysfs_speed) return 0; + + // Read into a signed type: /sys/class/net//speed reports -1 when there is + // no carrier. Extracting -1 into an unsigned type would wrap to a huge value + // (and would not set failbit), so use a signed type and validate the result. + int64_t speed = 0; + sysfs_speed >> speed; + + if (sysfs_speed.fail() || speed < 0) // read fails on incompatible devices + return 0; + + return static_cast(speed); +} + waybar::modules::Network::Network(const std::string& id, const Json::Value& config) : ALabel(config, "network", id, DEFAULT_FORMAT, 60) { // Start with some "text" in the module's label_. update() will then @@ -121,7 +140,6 @@ waybar::modules::Network::Network(const std::string& id, const Json::Value& conf createEventSocket(); createInfoSocket(); - dp.emit(); // Ask for a dump of interfaces and then addresses to populate our // information. First the interface dump, and once done, the callback // will be called again which will ask for addresses dump. @@ -145,6 +163,10 @@ waybar::modules::Network::~Network() { nl_close(sock_); nl_socket_free(sock_); } + if (station_sock_ != nullptr) { + nl_close(station_sock_); + nl_socket_free(station_sock_); + } } void waybar::modules::Network::createEventSocket() { @@ -160,6 +182,12 @@ void waybar::modules::Network::createEventSocket() { if (nl_socket_set_nonblocking(ev_sock_)) { throw std::runtime_error("Can't set non-blocking on network socket"); } + // Enlarge the socket receive buffer so that a burst of link/address/route + // change notifications (e.g. a router reboot or a PPPoE redial) is less likely + // to overflow it and make the kernel drop messages (ENOBUFS). Overruns are + // still handled in worker() by resynchronising the state, but a larger buffer + // avoids most of them. The kernel caps the request at net.core.rmem_max. + nl_socket_set_buffer_size(ev_sock_, 1024 * 1024, 0); nl_socket_add_memberships(ev_sock_, RTNLGRP_LINK, RTNLGRP_IPV4_IFADDR, RTNLGRP_IPV6_IFADDR, 0); if (!config_["interface"].isString()) { nl_socket_add_memberships(ev_sock_, RTNLGRP_IPV4_ROUTE, RTNLGRP_IPV6_ROUTE, 0); @@ -203,6 +231,16 @@ void waybar::modules::Network::createInfoSocket() { if (nl80211_id_ < 0) { spdlog::warn("Can't resolve nl80211 interface"); } + + // Create and configure the station_sock_ for NL80211_CMD_GET_STATION + station_sock_ = nl_socket_alloc(); + if (genl_connect(station_sock_) != 0) { + throw std::runtime_error("Can't connect to station netlink socket"); + } + if (nl_socket_modify_cb(station_sock_, NL_CB_VALID, NL_CB_CUSTOM, handleStationGet, this) < 0) { + throw std::runtime_error("Can't set station callback"); + } + // nl80211_id_ is already resolved from the sock_ setup } void waybar::modules::Network::worker() { @@ -244,6 +282,27 @@ void waybar::modules::Network::worker() { rc = 0; break; } + if (rc == -NLE_NOMEM || errno == ENOBUFS) { + // The kernel dropped multicast notifications because our receive + // buffer overflowed. This happens during a burst of + // link/address/route changes such as a router reboot or a PPPoE + // redial. We have lost track of the current state -- in + // particular the RTM_NEWADDR carrying the interface's new IP + // address may have been dropped -- so request a fresh dump to + // resynchronise. Without this the address (cleared by the + // preceding RTM_DELADDR) would stay blank until Waybar is + // restarted, because nothing else re-queries it (#5122). + spdlog::warn("network: netlink receive buffer overrun, resyncing state"); + want_link_dump_ = true; + want_addr_dump_ = true; + if (!config_["interface"].isString()) { + want_route_dump_ = true; + } + askForStateDump(); + // Keep draining; the next recv proceeds normally now that the + // overrun has been reported. + continue; + } } if (rc < 0) { spdlog::error("nl_recvmsgs_default error: {}", nl_geterror(-rc)); @@ -259,6 +318,19 @@ void waybar::modules::Network::worker() { }; } +bool waybar::modules::Network::isWireless() const { + // The rfkill switch we monitor (and thus the "disabled" state) only applies + // to wireless radios. An interface is wireless if the kernel exposes an + // 802.11 phy (cfg80211/mac80211) or a legacy "wireless" node for it in sysfs. + if (ifname_.empty()) { + return false; + } + const auto base = "/sys/class/net/" + ifname_; + std::error_code ec; + return std::filesystem::exists(base + "/phy80211", ec) || + std::filesystem::exists(base + "/wireless", ec); +} + const std::string waybar::modules::Network::getNetworkState() const { if (ifid_ == -1 || !carrier_) { #ifdef WANT_RFKILL @@ -266,7 +338,14 @@ const std::string waybar::modules::Network::getNetworkState() const { if (config_["rfkill"].isBool()) { display_rfkill = config_["rfkill"].asBool(); } - if (rfkill_.getState() && display_rfkill) return "disabled"; + // The rfkill switch is for wireless (WLAN) radios only, so it must not mask + // a wired interface that merely lost its carrier (e.g. an unplugged ethernet + // cable): such an interface has to report "disconnected", not "disabled", + // otherwise cable-unplug detection breaks on ethernet modules (#4364). + // Only honor rfkill when there is no interface or the interface is wireless. + if (rfkill_.getState() && display_rfkill && (ifname_.empty() || isWireless())) { + return "disabled"; + } #endif return "disconnected"; } @@ -315,19 +394,27 @@ auto waybar::modules::Network::update() -> void { elapsed_seconds = std::chrono::duration(interval_).count(); } + link_speed_ = readLinkSpeed(); + auto threshold_state = getState(signal_strength_); + if (!alt_) { auto state = getNetworkState(); if (!state_.empty() && label_.get_style_context()->has_class(state_)) { label_.get_style_context()->remove_class(state_); } - if (config_["format-" + state].isString()) { + if (!threshold_state.empty() && config_["format-" + state + "-" + threshold_state].isString()) { + default_format_ = config_["format-" + state + "-" + threshold_state].asString(); + } else if (config_["format-" + state].isString()) { default_format_ = config_["format-" + state].asString(); } else if (config_["format"].isString()) { default_format_ = config_["format"].asString(); } else { default_format_ = DEFAULT_FORMAT; } - if (config_["tooltip-format-" + state].isString()) { + if (!threshold_state.empty() && + config_["tooltip-format-" + state + "-" + threshold_state].isString()) { + tooltip_format = config_["tooltip-format-" + state + "-" + threshold_state].asString(); + } else if (config_["tooltip-format-" + state].isString()) { tooltip_format = config_["tooltip-format-" + state].asString(); } if (!label_.get_style_context()->has_class(state)) { @@ -336,7 +423,6 @@ auto waybar::modules::Network::update() -> void { format_ = default_format_; state_ = state; } - getState(signal_strength_); std::string final_ipaddr_; if (addr_pref_ == ip_addr_pref::IPV4) { @@ -350,28 +436,48 @@ auto waybar::modules::Network::update() -> void { final_ipaddr_ += ipaddr6_; } - auto text = fmt::format( - fmt::runtime(format_), fmt::arg("essid", essid_), fmt::arg("bssid", bssid_), - fmt::arg("signaldBm", signal_strength_dbm_), fmt::arg("signalStrength", signal_strength_), - fmt::arg("signalStrengthApp", signal_strength_app_), fmt::arg("ifname", ifname_), - fmt::arg("netmask", netmask_), fmt::arg("netmask6", netmask6_), - fmt::arg("ipaddr", final_ipaddr_), fmt::arg("gwaddr", gwaddr_), fmt::arg("cidr", cidr_), - fmt::arg("cidr6", cidr6_), fmt::arg("frequency", fmt::format("{:.1f}", frequency_)), - fmt::arg("icon", getIcon(signal_strength_, state_)), - fmt::arg("bandwidthDownBits", pow_format(bandwidth_down * 8ull / elapsed_seconds, "b/s")), - fmt::arg("bandwidthUpBits", pow_format(bandwidth_up * 8ull / elapsed_seconds, "b/s")), + fmt::dynamic_format_arg_store store; + store.push_back(fmt::arg("essid", essid_)); + store.push_back(fmt::arg("bssid", bssid_)); + store.push_back(fmt::arg("signaldBm", signal_strength_dbm_)); + store.push_back(fmt::arg("signalStrength", signal_strength_)); + store.push_back(fmt::arg("signalStrengthApp", signal_strength_app_)); + store.push_back(fmt::arg("ifname", ifname_)); + store.push_back(fmt::arg("netmask", netmask_)); + store.push_back(fmt::arg("netmask6", netmask6_)); + store.push_back(fmt::arg("ipaddr", final_ipaddr_)); + store.push_back(fmt::arg("gwaddr", gwaddr_)); + store.push_back(fmt::arg("cidr", cidr_)); + store.push_back(fmt::arg("cidr6", cidr6_)); + store.push_back(fmt::arg("frequency", fmt::format("{:.1f}", frequency_))); + store.push_back(fmt::arg("icon", getIcon(signal_strength_, state_))); + store.push_back( + fmt::arg("bandwidthDownBits", pow_format(bandwidth_down * 8ull / elapsed_seconds, "b/s"))); + store.push_back( + fmt::arg("bandwidthUpBits", pow_format(bandwidth_up * 8ull / elapsed_seconds, "b/s"))); + store.push_back( fmt::arg("bandwidthTotalBits", - pow_format((bandwidth_up + bandwidth_down) * 8ull / elapsed_seconds, "b/s")), - fmt::arg("bandwidthDownOctets", pow_format(bandwidth_down / elapsed_seconds, "o/s")), - fmt::arg("bandwidthUpOctets", pow_format(bandwidth_up / elapsed_seconds, "o/s")), - fmt::arg("bandwidthTotalOctets", - pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "o/s")), - fmt::arg("bandwidthDownBytes", pow_format(bandwidth_down / elapsed_seconds, "B/s")), - fmt::arg("bandwidthUpBytes", pow_format(bandwidth_up / elapsed_seconds, "B/s")), - fmt::arg("bandwidthTotalBytes", - pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "B/s"))); - if (text.compare(label_.get_label()) != 0) { - label_.set_markup(text); + pow_format((bandwidth_up + bandwidth_down) * 8ull / elapsed_seconds, "b/s"))); + store.push_back( + fmt::arg("bandwidthDownOctets", pow_format(bandwidth_down / elapsed_seconds, "o/s"))); + store.push_back(fmt::arg("bandwidthUpOctets", pow_format(bandwidth_up / elapsed_seconds, "o/s"))); + store.push_back(fmt::arg("bandwidthTotalOctets", + pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "o/s"))); + store.push_back( + fmt::arg("bandwidthDownBytes", pow_format(bandwidth_down / elapsed_seconds, "B/s"))); + store.push_back(fmt::arg("bandwidthUpBytes", pow_format(bandwidth_up / elapsed_seconds, "B/s"))); + store.push_back(fmt::arg("bandwidthDownBytesCompact", + pow_format(bandwidth_down / elapsed_seconds, "B", false, false, 2))); + store.push_back(fmt::arg("bandwidthUpBytesCompact", + pow_format(bandwidth_up / elapsed_seconds, "B", false, false, 2))); + store.push_back(fmt::arg("bandwidthTotalBytes", + pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "B/s"))); + store.push_back(fmt::arg("rxBitrate", pow_format(rx_bitrate_, "b/s"))); + store.push_back(fmt::arg("txBitrate", pow_format(tx_bitrate_, "b/s"))); + store.push_back(fmt::arg("linkSpeed", pow_format(link_speed_ * 1000000ull, "b/s", false, true))); + + auto text = fmt::vformat(format_, store); + if (setLabelMarkup(text)) { if (text.empty()) { event_box_.hide(); } else { @@ -383,31 +489,9 @@ auto waybar::modules::Network::update() -> void { tooltip_format = config_["tooltip-format"].asString(); } if (!tooltip_format.empty()) { - auto tooltip_text = fmt::format( - fmt::runtime(tooltip_format), fmt::arg("essid", essid_), fmt::arg("bssid", bssid_), - fmt::arg("signaldBm", signal_strength_dbm_), fmt::arg("signalStrength", signal_strength_), - fmt::arg("signalStrengthApp", signal_strength_app_), fmt::arg("ifname", ifname_), - fmt::arg("netmask", netmask_), fmt::arg("netmask6", netmask6_), - fmt::arg("ipaddr", final_ipaddr_), fmt::arg("gwaddr", gwaddr_), fmt::arg("cidr", cidr_), - fmt::arg("cidr6", cidr6_), fmt::arg("frequency", fmt::format("{:.1f}", frequency_)), - fmt::arg("icon", getIcon(signal_strength_, state_)), - fmt::arg("bandwidthDownBits", pow_format(bandwidth_down * 8ull / elapsed_seconds, "b/s")), - fmt::arg("bandwidthUpBits", pow_format(bandwidth_up * 8ull / elapsed_seconds, "b/s")), - fmt::arg("bandwidthTotalBits", - pow_format((bandwidth_up + bandwidth_down) * 8ull / elapsed_seconds, "b/s")), - fmt::arg("bandwidthDownOctets", pow_format(bandwidth_down / elapsed_seconds, "o/s")), - fmt::arg("bandwidthUpOctets", pow_format(bandwidth_up / elapsed_seconds, "o/s")), - fmt::arg("bandwidthTotalOctets", - pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "o/s")), - fmt::arg("bandwidthDownBytes", pow_format(bandwidth_down / elapsed_seconds, "B/s")), - fmt::arg("bandwidthUpBytes", pow_format(bandwidth_up / elapsed_seconds, "B/s")), - fmt::arg("bandwidthTotalBytes", - pow_format((bandwidth_up + bandwidth_down) / elapsed_seconds, "B/s"))); - if (label_.get_tooltip_text() != tooltip_text) { - label_.set_tooltip_markup(tooltip_text); - } - } else if (label_.get_tooltip_text() != text) { - label_.set_tooltip_markup(text); + setTooltipMarkup(fmt::vformat(tooltip_format, store)); + } else { + setTooltipMarkup(text); } } @@ -494,7 +578,10 @@ void waybar::modules::Network::clearIface() { signal_strength_dbm_ = 0; signal_strength_ = 0; signal_strength_app_.clear(); + link_speed_ = 0; frequency_ = 0.0; + rx_bitrate_ = 0; + tx_bitrate_ = 0; } int waybar::modules::Network::handleEvents(struct nl_msg* msg, void* data) { @@ -911,6 +998,51 @@ int waybar::modules::Network::handleScan(struct nl_msg* msg, void* data) { return NL_OK; } +int waybar::modules::Network::handleStationGet(struct nl_msg* msg, void* data) { + auto net = static_cast(data); + auto gnlh = static_cast(nlmsg_data(nlmsg_hdr(msg))); + struct nlattr* tb[NL80211_ATTR_MAX + 1]; + + if (nla_parse(tb, NL80211_ATTR_MAX, genlmsg_attrdata(gnlh, 0), genlmsg_attrlen(gnlh, 0), + nullptr) < 0) { + return NL_SKIP; + } + + if (tb[NL80211_ATTR_STA_INFO] == nullptr) { + return NL_SKIP; + } + + struct nlattr* sinfo[NL80211_STA_INFO_MAX + 1]; + if (nla_parse_nested(sinfo, NL80211_STA_INFO_MAX, tb[NL80211_ATTR_STA_INFO], nullptr) != 0) { + return NL_SKIP; + } + + if (sinfo[NL80211_STA_INFO_TX_BITRATE] != nullptr) { + struct nlattr* tx_br_info[NL80211_RATE_INFO_MAX + 1]; + if (nla_parse_nested(tx_br_info, NL80211_RATE_INFO_MAX, sinfo[NL80211_STA_INFO_TX_BITRATE], + nullptr) == 0) { + if (tx_br_info[NL80211_RATE_INFO_BITRATE32] != nullptr) { + net->tx_bitrate_ = nla_get_u32(tx_br_info[NL80211_RATE_INFO_BITRATE32]) * pow(10, 5); + } else if (tx_br_info[NL80211_RATE_INFO_BITRATE] != nullptr) { + net->tx_bitrate_ = nla_get_u16(tx_br_info[NL80211_RATE_INFO_BITRATE]) * pow(10, 5); + } + } + } + + if (sinfo[NL80211_STA_INFO_RX_BITRATE] != nullptr) { + struct nlattr* rx_br_info[NL80211_RATE_INFO_MAX + 1]; + if (nla_parse_nested(rx_br_info, NL80211_RATE_INFO_MAX, sinfo[NL80211_STA_INFO_RX_BITRATE], + nullptr) == 0) { + if (rx_br_info[NL80211_RATE_INFO_BITRATE32] != nullptr) { + net->rx_bitrate_ = nla_get_u32(rx_br_info[NL80211_RATE_INFO_BITRATE32]) * pow(10, 5); + } else if (rx_br_info[NL80211_RATE_INFO_BITRATE] != nullptr) { + net->rx_bitrate_ = nla_get_u16(rx_br_info[NL80211_RATE_INFO_BITRATE]) * pow(10, 5); + } + } + } + return NL_OK; +} + void waybar::modules::Network::parseEssid(struct nlattr** bss) { if (bss[NL80211_BSS_INFORMATION_ELEMENTS] != nullptr) { auto ies = static_cast(nla_data(bss[NL80211_BSS_INFORMATION_ELEMENTS])); @@ -934,18 +1066,15 @@ void waybar::modules::Network::parseSignal(struct nlattr** bss) { if (bss[NL80211_BSS_SIGNAL_MBM] != nullptr) { // signalstrength in dBm from mBm signal_strength_dbm_ = nla_get_s32(bss[NL80211_BSS_SIGNAL_MBM]) / 100; - // WiFi-hardware usually operates in the range -90 to -30dBm. - // If a signal is too strong, it can overwhelm receiving circuity that is designed - // to pick up and process a certain signal level. The following percentage is scaled to - // punish signals that are too strong (>= -45dBm) or too weak (<= -45 dBm). - const int hardwareOptimum = -45; - const int hardwareMin = -90; - const int strength = - 100 - - ((abs(signal_strength_dbm_ - hardwareOptimum) / double{hardwareOptimum - hardwareMin}) * - 100); - signal_strength_ = std::clamp(strength, 0, 100); + // uses nmcli implementation for calculating strength + // https://github.com/NetworkManager/NetworkManager/blob/23ffa5fc6e7acbd7a96138c6c18f478f5127177d/src/libnm-platform/wifi/nm-wifi-utils-nl80211.c#L411 + + const int noise_floor_dbm = -90; + const int signal_max_dbm = -20; + signal_strength_dbm_ = CLAMP(signal_strength_dbm_, noise_floor_dbm, signal_max_dbm); + signal_strength_ = 100 - (70 * (((float)signal_max_dbm - (float)signal_strength_dbm_) / + ((float)signal_max_dbm - (float)noise_floor_dbm))); if (signal_strength_dbm_ >= -50) { signal_strength_app_ = "Great Connectivity"; @@ -1010,5 +1139,38 @@ auto waybar::modules::Network::getInfo() -> void { nlmsg_free(nl_msg); return; } - nl_send_sync(sock_, nl_msg); + int err = nl_send_sync(sock_, nl_msg); + if (err < 0) { + spdlog::warn("nl80211: nl_send_sync get_scan error {}", err); + // Proceeding here as get_scan might not be essential if we already have ifid_ + } + + // If connected to an AP, try to get station data for bitrate + if (ifid_ > 0 && !bssid_.empty() && nl80211_id_ >= 0) { + nl_msg = nlmsg_alloc(); + if (nl_msg == nullptr) { + return; + } + + // Convert BSSID string to uint8_t array + uint8_t bssid_mac[ETH_ALEN]; + if (sscanf(bssid_.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", &bssid_mac[0], &bssid_mac[1], + &bssid_mac[2], &bssid_mac[3], &bssid_mac[4], &bssid_mac[5]) == ETH_ALEN) { + if (genlmsg_put(nl_msg, NL_AUTO_PORT, NL_AUTO_SEQ, nl80211_id_, 0, 0, /* No DUMP flag */ + NL80211_CMD_GET_STATION, 0) == nullptr || + nla_put_u32(nl_msg, NL80211_ATTR_IFINDEX, ifid_) < 0 || + nla_put(nl_msg, NL80211_ATTR_MAC, ETH_ALEN, bssid_mac) < 0) { + nlmsg_free(nl_msg); + return; + } + // Use station_sock_ for NL80211_CMD_GET_STATION + err = nl_send_sync(station_sock_, nl_msg); + if (err < 0) { + spdlog::warn("nl80211: nl_send_sync get_station error {}", err); + } + } else { + spdlog::warn("nl80211: Failed to parse BSSID string: {}", bssid_); + nlmsg_free(nl_msg); + } + } } diff --git a/src/modules/niri/backend.cpp b/src/modules/niri/backend.cpp index de3f8a9a..19eeada0 100644 --- a/src/modules/niri/backend.cpp +++ b/src/modules/niri/backend.cpp @@ -21,7 +21,15 @@ namespace waybar::modules::niri { -IPC::IPC() { startIPC(); } +IPC::IPC() { + // Connect synchronously so a missing socket (this WM isn't the active + // compositor) throws here, same as before the reconnect loop below existed. + // That lets the module constructor fail and Factory disable the module, + // instead of the module always attaching with a permanently empty widget. + startIPC(connectToSocket()); +} + +IPC::~IPC() { running_ = false; } int IPC::connectToSocket() { const char* socket_path = getenv("NIRI_SOCKET"); @@ -52,43 +60,71 @@ int IPC::connectToSocket() { return socketfd.release(); } -void IPC::startIPC() { +void IPC::startIPC(int initial_socketfd) { // will start IPC and relay events to parseIPC - int socketfd = connectToSocket(); - - std::thread([this, socketfd]() { + std::thread([this, initial_socketfd]() { spdlog::info("Niri IPC starting"); - auto unix_istream = Gio::UnixInputStream::create(socketfd, true); - auto unix_ostream = Gio::UnixOutputStream::create(socketfd, false); - auto istream = Gio::DataInputStream::create(unix_istream); - auto ostream = Gio::DataOutputStream::create(unix_ostream); + bool have_initial_fd = true; - if (!ostream->put_string("\"EventStream\"\n") || !ostream->flush()) { - spdlog::error("Niri IPC: failed to start event stream"); - return; - } - - std::string line; - if (!istream->read_line(line) || line != R"({"Ok":"Handled"})") { - spdlog::error("Niri IPC: failed to start event stream"); - return; - } - - while (istream->read_line(line)) { - spdlog::debug("Niri IPC: received {}", line); - - try { - parseIPC(line); - } catch (std::exception& e) { - spdlog::warn("Failed to parse IPC message: {}, reason: {}", line, e.what()); - } catch (...) { - throw; + // Reconnect loop: if the event stream drops *after* the initial connect + // above succeeded, we back off briefly and re-establish the socket + // instead of leaving the module frozen forever. + while (running_) { + int socketfd; + if (have_initial_fd) { + socketfd = initial_socketfd; + have_initial_fd = false; + } else { + try { + socketfd = connectToSocket(); + } catch (std::exception& e) { + spdlog::error("Niri IPC: failed to connect: {}", e.what()); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } } - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + auto unix_istream = Gio::UnixInputStream::create(socketfd, true); + auto unix_ostream = Gio::UnixOutputStream::create(socketfd, false); + auto istream = Gio::DataInputStream::create(unix_istream); + auto ostream = Gio::DataOutputStream::create(unix_ostream); + + if (!ostream->put_string("\"EventStream\"\n") || !ostream->flush()) { + spdlog::error("Niri IPC: failed to start event stream"); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + + std::string line; + if (!istream->read_line(line) || line != R"({"Ok":"Handled"})") { + spdlog::error("Niri IPC: failed to start event stream"); + std::this_thread::sleep_for(std::chrono::seconds(2)); + continue; + } + + // Drain events as fast as they arrive; throttling here back-pressures the + // socket, fills niri's send buffer and makes niri drop the stream. + while (running_ && istream->read_line(line)) { + spdlog::debug("Niri IPC: received {}", line); + + try { + parseIPC(line); + } catch (std::exception& e) { + spdlog::warn("Failed to parse IPC message: {}, reason: {}", line, e.what()); + } catch (...) { + throw; + } + } + + if (!running_) break; + + spdlog::warn("Niri IPC: event stream closed, reconnecting"); + std::this_thread::sleep_for(std::chrono::seconds(2)); } + + spdlog::info("Niri IPC stopping"); }).detach(); } @@ -196,6 +232,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& values = payload["changes"]; + for (const auto& changed : values) { + const auto id = changed[0].asUInt64(); + const auto& change = changed[1]; + for (auto& win : windows_) { + if (win["id"].asUInt64() == id) { + win["layout"] = change; + break; + } + } + } } } @@ -237,7 +285,7 @@ void IPC::unregisterForIPC(EventHandler* ev_handler) { Json::Value IPC::send(const Json::Value& request) { util::ScopedFd socketfd(connectToSocket()); - auto unix_istream = Gio::UnixInputStream::create(socketfd, true); + auto unix_istream = Gio::UnixInputStream::create(socketfd, false); auto unix_ostream = Gio::UnixOutputStream::create(socketfd, false); auto istream = Gio::DataInputStream::create(unix_istream); auto ostream = Gio::DataOutputStream::create(unix_ostream); diff --git a/src/modules/niri/language.cpp b/src/modules/niri/language.cpp index 14ca555f..68d585af 100644 --- a/src/modules/niri/language.cpp +++ b/src/modules/niri/language.cpp @@ -18,7 +18,6 @@ Language::Language(const std::string& id, const Bar& bar, const Json::Value& con gIPC->registerForIPC("KeyboardLayoutSwitched", this); updateFromIPC(); - dp.emit(); } Language::~Language() { @@ -85,7 +84,7 @@ void Language::doUpdate() { spdlog::debug("niri language formatted layout name {}", layoutName); - if (!format_.empty()) { + if (!layoutName.empty()) { label_.show(); label_.set_markup(layoutName); } else { diff --git a/src/modules/niri/window.cpp b/src/modules/niri/window.cpp index 61b3a7dd..9f209bfb 100644 --- a/src/modules/niri/window.cpp +++ b/src/modules/niri/window.cpp @@ -17,8 +17,7 @@ Window::Window(const std::string& id, const Bar& bar, const Json::Value& config) gIPC->registerForIPC("WindowOpenedOrChanged", this); gIPC->registerForIPC("WindowClosed", this); gIPC->registerForIPC("WindowFocusChanged", this); - - dp.emit(); + gIPC->registerForIPC("WindowLayoutsChanged", this); } Window::~Window() { gIPC->unregisterForIPC(this); } @@ -54,20 +53,30 @@ void Window::doUpdate() { if (it != windows.cend()) { const auto& window = *it; + auto max_col = -1; + for (const auto &win : windows) { + if (win["workspace_id"].asUInt64() != window["workspace_id"].asUInt64()) { + continue; + } + const auto col = win["layout"]["pos_in_scrolling_layout"][0].asInt64(); + if (col > max_col) max_col = col; + } const auto title = window["title"].asString(); const auto appId = window["app_id"].asString(); + const auto col = window["layout"]["pos_in_scrolling_layout"][0].asInt64(); const auto sanitizedTitle = waybar::util::sanitize_string(title); const auto sanitizedAppId = waybar::util::sanitize_string(appId); label_.show(); label_.set_markup(waybar::util::rewriteString( fmt::format(fmt::runtime(format_), fmt::arg("title", sanitizedTitle), - fmt::arg("app_id", sanitizedAppId)), + fmt::arg("app_id", sanitizedAppId), fmt::arg("col", col), + fmt::arg("max_col", max_col)), config_["rewrite"])); 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(); @@ -82,7 +91,16 @@ void Window::doUpdate() { oldAppId_ = appId; } } else { - label_.hide(); + 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::arg("col", -1), fmt::arg("max_col", -1)), + config_["rewrite"])); + } else { + label_.hide(); + } + updateAppIconName("", ""); setClass("solo", false); if (!oldAppId_.empty()) setClass(oldAppId_, false); diff --git a/src/modules/niri/workspace.cpp b/src/modules/niri/workspace.cpp new file mode 100644 index 00000000..73445b50 --- /dev/null +++ b/src/modules/niri/workspace.cpp @@ -0,0 +1,261 @@ +#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, + const std::string& windows_str, std::size_t total) { + // ── 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()), fmt::arg("total", total), + fmt::arg("windows", windows_str)); + } + + 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 97d15215..720bb1cf 100644 --- a/src/modules/niri/workspaces.cpp +++ b/src/modules/niri/workspaces.cpp @@ -1,13 +1,38 @@ #include "modules/niri/workspaces.hpp" -#include -#include +#include // Needed for joining window representations #include +#include +#include + +#include "util/rewrite_string.hpp" // Needed for rewrite logic + namespace waybar::modules::niri { Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& config) : AModule(config, "workspaces", id, false, false), bar_(bar), box_(bar.orientation, 0) { + const auto config_sort_by_number = config_["sort-by-number"]; + if (config_sort_by_number.isBool()) { + spdlog::warn("[niri/workspaces]: Prefer sort-by-id instead of sort-by-number"); + sort_by_id_ = config_sort_by_number.asBool(); + } + + const auto config_sort_by_id = config_["sort-by-id"]; + if (config_sort_by_id.isBool()) { + sort_by_id_ = config_sort_by_id.asBool(); + } + + const auto config_sort_by_name = config_["sort-by-name"]; + if (config_sort_by_name.isBool()) { + sort_by_name_ = config_sort_by_name.asBool(); + } + + const auto config_sort_by_coordinates = config_["sort-by-coordinates"]; + if (config_sort_by_coordinates.isBool()) { + sort_by_coordinates_ = config_sort_by_coordinates.asBool(); + } + box_.set_name("workspaces"); if (!id.empty()) { box_.get_style_context()->add_class(id); @@ -15,124 +40,135 @@ Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& box_.get_style_context()->add_class(MODULE_CLASS); event_box_.add(box_); + // Parse ignore-workspaces rules. + auto ignoreWorkspaces = config["ignore-workspaces"]; + if (ignoreWorkspaces.isArray()) { + for (const auto& workspaceRegex : ignoreWorkspaces) { + if (workspaceRegex.isString()) { + std::string ruleString = workspaceRegex.asString(); + try { + const std::regex rule{ruleString, std::regex_constants::icase}; + ignoreWorkspaces_.emplace_back(rule); + } catch (const std::regex_error& e) { + spdlog::error("Invalid ignore-workspaces rule {}: {}", ruleString, e.what()); + } + } else { + spdlog::error("Not a string in ignore-workspaces: '{}'", workspaceRegex); + } + } + } + if (!gIPC) gIPC = std::make_unique(); + // Parse new config options first + populateWindowRewriteConfig(); + populateFormatWindowSeparatorConfig(); + + // Niri's WorkspacesChanged event already includes window info, + // so no need to register for separate window events like in Hyprland. gIPC->registerForIPC("WorkspacesChanged", this); gIPC->registerForIPC("WorkspaceActivated", this); gIPC->registerForIPC("WorkspaceActiveWindowChanged", this); gIPC->registerForIPC("WorkspaceUrgencyChanged", this); - dp.emit(); + gIPC->registerForIPC("WindowsChanged", this); + gIPC->registerForIPC("WindowOpenedOrChanged", this); + gIPC->registerForIPC("WindowLayoutsChanged", this); + gIPC->registerForIPC("WindowFocusChanged", this); + gIPC->registerForIPC("WindowClosed", this); + + if (config["enable-bar-scroll"].asBool()) { + auto& window = const_cast(bar_).window; + window.add_events(Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); + window.signal_scroll_event().connect(sigc::mem_fun(*this, &Workspaces::handleScroll)); + } } 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(); + // Debug: log global IPC lists + spdlog::debug("[niri/workspaces] gIPC workspaces count: {}", gIPC->workspaces().size()); + spdlog::debug("[niri/workspaces] gIPC windows count: {}", gIPC->windows().size()); + 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; - } - } - - // 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"); - } - - if (ws["active_window_id"].isNull()) - style_context->add_class("empty"); - else - style_context->remove_class("empty"); + const auto display_cond = config_["display-condition"].asString(); + 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) { 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); + if (isWorkspaceIgnored(name)) { + continue; } - 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(); - } else { - button.show(); + if (display_cond == "only-populated") { + if (ws["active_window_id"].isNull() && !ws["is_active"].asBool()) continue; + } else if (display_cond == "keep-named") { + if (ws["name"].isNull() && ws["active_window_id"].isNull() && !ws["is_active"].asBool()) + continue; + } + + if (alloutputs || ws["output"].asString() == bar_.output->name) { + my_workspaces.push_back(&ws); } } - // Refresh the button order. - for (auto it = my_workspaces.cbegin(); it != my_workspaces.cend(); ++it) { - const auto& ws = *it; + sortWorkspaces(my_workspaces); - auto pos = ws["idx"].asUInt() - 1; - if (alloutputs) pos = it - my_workspaces.cbegin(); + 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()); - auto& button = buttons_[ws["id"].asUInt64()]; - box_.reorder_child(button, pos); + 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; + } + + std::vector windows_vec(all_windows.begin(), all_windows.end()); + const auto windows_str = getWindowsRepresentation(ws); + (*it)->update(ws, windows_vec, windows_str, my_workspaces.size()); + } + + 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; + + const auto pos = static_cast(std::distance(my_workspaces.cbegin(), pos_it)); + + 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 +177,126 @@ 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) { +// --- Start New Helper Functions --- +void Workspaces::populateWindowRewriteConfig() { + // Reconstruct RegexCollection instead of clearing/adding + const Json::Value& rewrite_rules_config = config_["window-rewrite"]; + if (rewrite_rules_config.isObject()) { + // Assuming a constructor that takes the Json::Value object exists. + // If Niri needs rule prioritization like Hyprland, a priority function + // would be needed as a second argument here. + try { + m_windowRewriteRules = util::RegexCollection(rewrite_rules_config); + } catch (const std::exception& e) { + spdlog::error("Error initializing RegexCollection: {}", e.what()); + // Initialize with an empty collection if error occurs + m_windowRewriteRules = util::RegexCollection(Json::Value(Json::objectValue)); + } + + } else { + // Initialize with an empty collection if config is not an object + m_windowRewriteRules = util::RegexCollection(Json::Value(Json::objectValue)); + } + + if (config_.isMember("window-rewrite-default") && config_["window-rewrite-default"].isString()) { + m_windowRewriteDefault = config_["window-rewrite-default"].asString(); + } else { + m_windowRewriteDefault = "?"; // Default fallback + } +} + +void Workspaces::populateFormatWindowSeparatorConfig() { + if (config_.isMember("format-window-separator") && + config_["format-window-separator"].isString()) { + m_formatWindowSeparator = config_["format-window-separator"].asString(); + } else { + m_formatWindowSeparator = " "; // Default fallback + } +} + +std::string Workspaces::getRewrite(const std::string& app_id, const std::string& title) { + // Niri uses app_id, Hyprland uses class. Adapt the key format. + std::string lookup_key = "app_id<" + app_id + "> title<" + title + ">"; + std::string res = m_windowRewriteRules.get(lookup_key); + if (!res.empty()) { + // Create Json::Value for substitutions + Json::Value substitutions(Json::objectValue); + substitutions["app_id"] = app_id; + substitutions["title"] = title; + return util::rewriteString(res, substitutions); + } + // Fallback to app_id only + lookup_key = "app_id<" + app_id + ">"; + res = m_windowRewriteRules.get(lookup_key); + if (!res.empty()) { + // Create Json::Value for substitutions + Json::Value substitutions(Json::objectValue); + substitutions["app_id"] = app_id; + substitutions["title"] = title; + return util::rewriteString(res, substitutions); + } + // Fallback to title only + lookup_key = "title<" + title + ">"; + res = m_windowRewriteRules.get(lookup_key); + if (!res.empty()) { + // Create Json::Value for substitutions + Json::Value substitutions(Json::objectValue); + substitutions["app_id"] = app_id; + substitutions["title"] = title; + return util::rewriteString(res, substitutions); + } + + // No rule matched, return default + // Apply substitutions to default as well, in case it uses placeholders + Json::Value substitutions(Json::objectValue); + substitutions["app_id"] = app_id; + substitutions["title"] = title; + return util::rewriteString(m_windowRewriteDefault, substitutions); +} + +// Build the "{windows}" replacement string for a workspace using window-rewrite rules. +std::string Workspaces::getWindowsRepresentation(const Json::Value& ws) { + std::vector window_reps; + auto collect = [&](const Json::Value& win) { + std::string app_id = + win.isMember("app_id") && win["app_id"].isString() ? win["app_id"].asString() : ""; + std::string title = + win.isMember("title") && win["title"].isString() ? win["title"].asString() : ""; + if (!app_id.empty() || !title.empty()) { + window_reps.push_back(getRewrite(app_id, title)); + } + }; + + if (ws.isMember("windows") && ws["windows"].isArray()) { + for (const auto& win : ws["windows"]) { + collect(win); + } + } else { + // Fallback: collect from global windows list by matching workspace_id. + for (const auto& win : gIPC->windows()) { + if (!win.isMember("workspace_id")) continue; + if (win["workspace_id"].asUInt64() != ws["id"].asUInt64()) continue; + collect(win); + } + } + + return fmt::format("{}", fmt::join(window_reps, m_formatWindowSeparator)); +} +// --- End New Helper Functions --- + +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 +312,109 @@ std::string Workspaces::getIcon(const std::string& value, const Json::Value& ws) return value; } +bool Workspaces::isWorkspaceIgnored(const std::string& name) { + for (auto& rule : ignoreWorkspaces_) { + if (std::regex_match(name, rule)) { + return true; + } + } + return false; +} + +bool Workspaces::handleScroll(GdkEventScroll* e) { + if (gdk_event_get_pointer_emulated((GdkEvent*)e) != 0) { + /** + * Ignore emulated scroll events on window + */ + return false; + } + + auto dir = AModule::getScrollDir(e); + if (dir == SCROLL_DIR::NONE) { + return true; + } + + try { + Json::Value request(Json::objectValue); + auto& action = (request["Action"] = Json::Value(Json::objectValue)); + + std::string action_name; + + if (dir == SCROLL_DIR::DOWN || dir == SCROLL_DIR::RIGHT) { + action_name = "FocusWorkspaceDown"; + } else if (dir == SCROLL_DIR::UP || dir == SCROLL_DIR::LEFT) { + action_name = "FocusWorkspaceUp"; + } else { + return true; + } + + action[action_name] = Json::Value(Json::objectValue); + + IPC::send(request); + + } catch (const std::exception& e) { + spdlog::error("Workspaces: {}", e.what()); + return false; + } + + return true; +} + +void Workspaces::sortWorkspaces(std::vector& workspaces) const { + auto get_name = [](const Json::Value& ws) -> std::string { + if (ws["name"]) return ws["name"].asString(); + return std::to_string(ws["idx"].asUInt()); + }; + + auto is_numeric = [](const std::string& value) { + return !value.empty() && + std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c); }); + }; + + const bool names_are_numeric = + std::all_of(workspaces.begin(), workspaces.end(), + [&](const auto* ws) { return is_numeric(get_name(*ws)); }); + + auto compare_numeric_strings = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return a.size() < b.size(); + return a < b; + }; + + std::sort(workspaces.begin(), workspaces.end(), [&](const auto* ap, const auto* bp) { + const auto& a = *ap; + const auto& b = *bp; + if (sort_by_id_) { + return a["id"].asUInt64() < b["id"].asUInt64(); + } + + if (sort_by_name_) { + const auto a_name = get_name(a); + const auto b_name = get_name(b); + if (a_name == b_name) return a["id"].asUInt64() < b["id"].asUInt64(); + if (names_are_numeric) return compare_numeric_strings(a_name, b_name); + return a_name < b_name; + } + + if (sort_by_coordinates_) { + const auto& a_output = a["output"].asString(); + const auto& b_output = b["output"].asString(); + if (a_output == b_output) { + const auto a_idx = a["idx"].asUInt(); + const auto b_idx = b["idx"].asUInt(); + if (a_idx == b_idx) return a["id"].asUInt64() < b["id"].asUInt64(); + return a_idx < b_idx; + } + return a_output < b_output; + } + + // Default to sorting by workspace index on each output. + const auto& a_output = a["output"].asString(); + const auto& b_output = b["output"].asString(); + const auto a_idx = a["idx"].asUInt(); + const auto b_idx = b["idx"].asUInt(); + if (a_output == b_output) return a_idx < b_idx; + return a_output < b_output; + }); +} + } // namespace waybar::modules::niri diff --git a/src/modules/power_profiles_daemon.cpp b/src/modules/power_profiles_daemon.cpp index 566787f3..adf08b58 100644 --- a/src/modules/power_profiles_daemon.cpp +++ b/src/modules/power_profiles_daemon.cpp @@ -12,7 +12,8 @@ PowerProfilesDaemon::PowerProfilesDaemon(const std::string& id, const Json::Valu if (config_["tooltip-format"].isString()) { tooltipFormat_ = config_["tooltip-format"].asString(); } else { - tooltipFormat_ = "Power profile: {profile}\nDriver: {driver}"; + tooltipFormat_ = + "Power profile: {profile}\nCPU driver: {cpu_driver}\nPlatform driver: {platform_driver}"; } // Fasten your seatbelt, we're up for quite a ride. The rest of the // init is performed asynchronously. There's 2 callbacks involved. @@ -43,8 +44,6 @@ PowerProfilesDaemon::PowerProfilesDaemon(const std::string& id, const Json::Valu Gio::DBus::Proxy::create_for_bus(Gio::DBus::BusType::BUS_TYPE_SYSTEM, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", sigc::mem_fun(*this, &PowerProfilesDaemon::busConnectedCb)); - // Schedule update to set the initial visibility - dp.emit(); } void PowerProfilesDaemon::busConnectedCb(Glib::RefPtr& r) { @@ -95,15 +94,51 @@ void PowerProfilesDaemon::populateInitState() { powerProfilesProxy_->get_cached_property(profilesVariant, "Profiles"); for (auto& variantDict : profilesVariant.get()) { Glib::ustring name; + // Legacy single `Driver` property, still exposed by older + // power-profiles-daemon versions. Glib::ustring driver; + Glib::ustring cpuDriver; + Glib::ustring platformDriver; if (auto p = variantDict.find("Profile"); p != variantDict.end()) { name = p->second.get(); } if (auto d = variantDict.find("Driver"); d != variantDict.end()) { driver = d->second.get(); } + if (auto cd = variantDict.find("CpuDriver"); cd != variantDict.end()) { + cpuDriver = cd->second.get(); + } + if (auto pd = variantDict.find("PlatformDriver"); pd != variantDict.end()) { + platformDriver = pd->second.get(); + } + + // Recent power-profiles-daemon versions split the single `Driver` + // property into `CpuDriver` and `PlatformDriver`. When talking to an + // older daemon that only exposes `Driver`, fall back to it so the new + // {cpu_driver}/{platform_driver} placeholders still resolve. + if (cpuDriver.empty()) { + cpuDriver = driver; + } + if (platformDriver.empty()) { + platformDriver = driver; + } + // Conversely, keep the legacy {driver} placeholder working against a + // recent daemon that no longer exposes `Driver` by deriving it from + // the CPU driver. + if (driver.empty()) { + driver = cpuDriver; + } + + if (driver.empty()) { + driver = "Unavailable"; + cpuDriver = "Unavailable"; + platformDriver = "Unavailable"; + spdlog::warn("Cannot find power profiles daemon driver."); + } + if (!name.empty()) { - availableProfiles_.emplace_back(std::move(name), std::move(driver)); + availableProfiles_.emplace_back(std::move(name), std::move(driver), std::move(cpuDriver), + std::move(platformDriver)); } else { spdlog::error( "Power profiles daemon: power-profiles-daemon sent us an empty power profile name. " @@ -150,15 +185,14 @@ void PowerProfilesDaemon::switchToProfile(std::string const& str) { auto PowerProfilesDaemon::update() -> void { if (connected_ && activeProfile_ != availableProfiles_.end()) { auto profile = (*activeProfile_); - // Set label - fmt::dynamic_format_arg_store store; - store.push_back(fmt::arg("profile", profile.name)); - store.push_back(fmt::arg("driver", profile.driver)); - store.push_back(fmt::arg("icon", getIcon(0, profile.name))); - label_.set_markup(fmt::vformat(format_, store)); - if (tooltipEnabled()) { - label_.set_tooltip_markup(fmt::vformat(tooltipFormat_, store)); - } + // Set label and tooltip + updateLabelAndTooltip(format_, tooltipFormat_, fmt::arg("profile", profile.name), + // Legacy placeholder, kept for backward compatibility with existing + // configs. + fmt::arg("driver", profile.driver), + fmt::arg("cpu_driver", profile.cpuDriver), + fmt::arg("platform_driver", profile.platformDriver), + fmt::arg("icon", getIcon(0, profile.name))); // Set CSS class if (!currentStyle_.empty()) { diff --git a/src/modules/privacy/privacy.cpp b/src/modules/privacy/privacy.cpp index 904c8fd9..76762cf5 100644 --- a/src/modules/privacy/privacy.cpp +++ b/src/modules/privacy/privacy.cpp @@ -10,10 +10,11 @@ namespace waybar::modules::privacy { -using util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_INPUT; -using util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_OUTPUT; -using util::PipewireBackend::PRIVACY_NODE_TYPE_NONE; -using util::PipewireBackend::PRIVACY_NODE_TYPE_VIDEO_INPUT; +using waybar::util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_INPUT; +using waybar::util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_OUTPUT; +using waybar::util::PipewireBackend::PRIVACY_NODE_TYPE_LOCATION; +using waybar::util::PipewireBackend::PRIVACY_NODE_TYPE_NONE; +using waybar::util::PipewireBackend::PRIVACY_NODE_TYPE_VIDEO_INPUT; Privacy::Privacy(const std::string& id, const Json::Value& config, Gtk::Orientation orientation, const std::string& pos) @@ -21,7 +22,9 @@ Privacy::Privacy(const std::string& id, const Json::Value& config, Gtk::Orientat nodes_screenshare(), nodes_audio_in(), nodes_audio_out(), + location_in_use(false), visibility_conn(), + geoclue_timeout_conn(), box_(orientation, 0) { box_.set_name(name_); @@ -45,20 +48,21 @@ Privacy::Privacy(const std::string& id, const Json::Value& config, Gtk::Orientat // Initialize each privacy module Json::Value modules = config_["modules"]; - // Add Screenshare and Mic usage as default modules if none are specified + // Add Screenshare, Mic, and Location usage as default modules if none are specified if (!modules.isArray() || modules.empty()) { modules = Json::Value(Json::arrayValue); - for (const auto& type : {"screenshare", "audio-in"}) { + for (const auto& type : {"screenshare", "audio-in", "location"}) { Json::Value obj = Json::Value(Json::objectValue); obj["type"] = type; modules.append(obj); } } - std::map > typeMap = { + std::map*, PrivacyNodeType>> typeMap = { {"screenshare", {&nodes_screenshare, PRIVACY_NODE_TYPE_VIDEO_INPUT}}, {"audio-in", {&nodes_audio_in, PRIVACY_NODE_TYPE_AUDIO_INPUT}}, {"audio-out", {&nodes_audio_out, PRIVACY_NODE_TYPE_AUDIO_OUTPUT}}, + {"location", {nullptr, PRIVACY_NODE_TYPE_LOCATION}}, }; for (const auto& module : modules) { @@ -68,9 +72,23 @@ Privacy::Privacy(const std::string& id, const Json::Value& config, Gtk::Orientat auto iter = typeMap.find(type); if (iter != typeMap.end()) { auto& [nodePtr, nodeType] = iter->second; - auto* item = Gtk::make_managed(module, nodeType, nodePtr, orientation, pos, + PrivacyItem* item = nullptr; + switch (nodeType) { + case util::PipewireBackend::PRIVACY_NODE_TYPE_VIDEO_INPUT: + case util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_INPUT: + case util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_OUTPUT: + item = Gtk::make_managed(module, nodeType, nodePtr, orientation, pos, iconSize, transition_duration); + break; + case util::PipewireBackend::PRIVACY_NODE_TYPE_LOCATION: + item = Gtk::make_managed(module, orientation, pos, iconSize, + transition_duration); + break; + case util::PipewireBackend::PRIVACY_NODE_TYPE_NONE: + continue; + } box_.add(*item); + modules_.push_back(item); } } @@ -92,20 +110,31 @@ Privacy::Privacy(const std::string& id, const Json::Value& config, Gtk::Orientat ignore_monitor = config_["ignore-monitor"].asBool(); } - backend = util::PipewireBackend::PipewireBackend::getInstance(); - backend->privacy_nodes_changed_signal_event.connect( - sigc::mem_fun(*this, &Privacy::onPrivacyNodesChanged)); + pw_backend = util::PipewireBackend::PipewireBackend::getInstance(); + pw_backend->privacy_nodes_changed_signal_event.connect( + sigc::mem_fun(*this, &Privacy::onPWPrivacyNodesChanged)); - dp.emit(); + geoclue_backend = util::GeoClueBackend::GeoClueBackend::getInstance(); + geoclue_backend->in_use_changed_signal_event.connect( + sigc::mem_fun(*this, &Privacy::onGeoCluePrivacyNodesChanged)); } -void Privacy::onPrivacyNodesChanged() { +Privacy::~Privacy() { + // Both timeouts are attached to the global main context, which outlives this + // module when the bar is reloaded. sigc::connection's destructor does not + // disconnect, and the members are torn down after the backends they use, so a + // pending timeout would dispatch onto freed memory. + geoclue_timeout_conn.disconnect(); + visibility_conn.disconnect(); +} + +void Privacy::onPWPrivacyNodesChanged() { mutex_.lock(); nodes_audio_out.clear(); nodes_audio_in.clear(); nodes_screenshare.clear(); - for (auto& node : backend->privacy_nodes) { + for (auto& node : pw_backend->privacy_nodes) { if (ignore_monitor && node.second->is_monitor) continue; auto iter = ignore.find(std::pair(node.second->type, node.second->node_name)); @@ -123,7 +152,7 @@ void Privacy::onPrivacyNodesChanged() { case PRIVACY_NODE_TYPE_AUDIO_OUTPUT: nodes_audio_out.push_back(node.second); break; - case PRIVACY_NODE_TYPE_NONE: + default: continue; } break; @@ -136,38 +165,63 @@ void Privacy::onPrivacyNodesChanged() { dp.emit(); } +bool Privacy::locationTimeout(bool in_use) { + location_in_use.store(geoclue_backend->location_in_use); + dp.emit(); + + return false; +} +void Privacy::onGeoCluePrivacyNodesChanged() { + geoclue_timeout_conn.disconnect(); + + const bool in_use = geoclue_backend->location_in_use; + if (in_use) { + locationTimeout(in_use); + } else { + // The GeoClue service is really fast, so wait before hiding, so that the + // widget is at least visible for 3 seconds. + geoclue_timeout_conn = Glib::signal_timeout().connect_seconds( + sigc::bind(sigc::mem_fun(*this, &Privacy::locationTimeout), in_use), 3); + } +} + auto Privacy::update() -> void { // set in modules or not bool setScreenshare = false; bool setAudioIn = false; bool setAudioOut = false; + bool setLocation = false; // used or not bool useScreenshare = false; bool useAudioIn = false; bool useAudioOut = false; + bool useLocation = false; mutex_.lock(); - for (Gtk::Widget* widget : box_.get_children()) { - auto* module = dynamic_cast(widget); - if (module == nullptr) continue; + for (PrivacyItem* module : modules_) { switch (module->privacy_type) { - case util::PipewireBackend::PRIVACY_NODE_TYPE_VIDEO_INPUT: + case PRIVACY_NODE_TYPE_VIDEO_INPUT: setScreenshare = true; useScreenshare = !nodes_screenshare.empty(); module->set_in_use(useScreenshare); break; - case util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_INPUT: + case PRIVACY_NODE_TYPE_AUDIO_INPUT: setAudioIn = true; useAudioIn = !nodes_audio_in.empty(); module->set_in_use(useAudioIn); break; - case util::PipewireBackend::PRIVACY_NODE_TYPE_AUDIO_OUTPUT: + case PRIVACY_NODE_TYPE_AUDIO_OUTPUT: setAudioOut = true; useAudioOut = !nodes_audio_out.empty(); module->set_in_use(useAudioOut); break; - case util::PipewireBackend::PRIVACY_NODE_TYPE_NONE: + case PRIVACY_NODE_TYPE_LOCATION: + setLocation = true; + useLocation = location_in_use; + module->set_in_use(useLocation); + break; + case PRIVACY_NODE_TYPE_NONE: break; } } @@ -175,7 +229,7 @@ auto Privacy::update() -> void { // Hide the whole widget if none are in use bool isVisible = (setScreenshare && useScreenshare) || (setAudioIn && useAudioIn) || - (setAudioOut && useAudioOut); + (setAudioOut && useAudioOut) || (setLocation && useLocation); if (isVisible != event_box_.get_visible()) { // Disconnect any previous connection so that it doesn't get activated in @@ -188,12 +242,13 @@ auto Privacy::update() -> void { // have finished animating visibility_conn = Glib::signal_timeout().connect( sigc::track_obj( - [this, setScreenshare, setAudioOut, setAudioIn]() { + [this, setScreenshare, setAudioOut, setAudioIn, setLocation]() { mutex_.lock(); bool visible = false; visible |= setScreenshare && !nodes_screenshare.empty(); visible |= setAudioIn && !nodes_audio_in.empty(); visible |= setAudioOut && !nodes_audio_out.empty(); + visible |= setLocation && location_in_use; mutex_.unlock(); event_box_.set_visible(visible); return false; diff --git a/src/modules/privacy/privacy_item.cpp b/src/modules/privacy/privacy_item.cpp index 3a55eee7..aa650bf8 100644 --- a/src/modules/privacy/privacy_item.cpp +++ b/src/modules/privacy/privacy_item.cpp @@ -8,19 +8,16 @@ #include "gtkmm/label.h" #include "gtkmm/revealer.h" #include "gtkmm/tooltip.h" -#include "util/pipewire/privacy_node_info.hpp" namespace waybar::modules::privacy { PrivacyItem::PrivacyItem(const Json::Value& config_, enum PrivacyNodeType privacy_type_, - std::list* nodes_, Gtk::Orientation orientation, - const std::string& pos, const uint icon_size, + Gtk::Orientation orientation, const std::string& pos, const uint icon_size, const uint transition_duration) : Gtk::Revealer(), privacy_type(privacy_type_), - nodes(nodes_), - signal_conn(), tooltip_window(Gtk::ORIENTATION_VERTICAL, 0), + signal_conn(), box_(Gtk::ORIENTATION_HORIZONTAL, 0), icon_() { switch (privacy_type) { @@ -36,7 +33,10 @@ PrivacyItem::PrivacyItem(const Json::Value& config_, enum PrivacyNodeType privac box_.get_style_context()->add_class("screenshare"); iconName = "waybar-privacy-screen-share-symbolic"; break; - default: + case util::PipewireBackend::PRIVACY_NODE_TYPE_LOCATION: + box_.get_style_context()->add_class("location"); + iconName = "waybar-privacy-location-symbolic"; + break; case util::PipewireBackend::PRIVACY_NODE_TYPE_NONE: return; } @@ -75,13 +75,12 @@ PrivacyItem::PrivacyItem(const Json::Value& config_, enum PrivacyNodeType privac tooltipIconSize = config_["tooltip-icon-size"].asUInt(); } // Tooltip - if (config_["tooltip"].isString()) { + if (config_["tooltip"].isBool()) { tooltip = config_["tooltip"].asBool(); } set_has_tooltip(tooltip); if (tooltip) { // Sets the window to use when showing the tooltip - update_tooltip(); this->signal_query_tooltip().connect(sigc::track_obj( [this](int x, int y, bool keyboard_tooltip, const Glib::RefPtr& tooltip) { tooltip->set_custom(tooltip_window); @@ -103,21 +102,8 @@ void PrivacyItem::update_tooltip() { // work differently in GTK4. delete child; } - for (auto* node : *nodes) { - auto* box = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL, 4); - // Set device icon - auto* node_icon = Gtk::make_managed(); - node_icon->set_pixel_size(tooltipIconSize); - node_icon->set_from_icon_name(node->getIconName(), Gtk::ICON_SIZE_INVALID); - box->add(*node_icon); - - // Set model - auto* nodeName = Gtk::make_managed(node->getName()); - box->add(*nodeName); - - tooltip_window.add(*box); - } + set_tooltip(); tooltip_window.show_all(); } @@ -161,4 +147,27 @@ void PrivacyItem::set_in_use(bool in_use) { this->init = true; } +void GeoCluePrivacyItem::set_tooltip() { + auto* nodeName = Gtk::make_managed("Location in use"); + tooltip_window.add(*nodeName); +} + +void PWPrivacyItem::set_tooltip() { + for (auto* node : *nodes) { + auto* box = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL, 4); + + // Set device icon + auto* node_icon = Gtk::make_managed(); + node_icon->set_pixel_size(tooltipIconSize); + node_icon->set_from_icon_name(node->getIconName(), Gtk::ICON_SIZE_INVALID); + box->add(*node_icon); + + // Set model + auto* nodeName = Gtk::make_managed(node->getName()); + box->add(*nodeName); + + tooltip_window.add(*box); + } +} + } // namespace waybar::modules::privacy diff --git a/src/modules/pulseaudio.cpp b/src/modules/pulseaudio.cpp index 4d63ff3c..0db9fe59 100644 --- a/src/modules/pulseaudio.cpp +++ b/src/modules/pulseaudio.cpp @@ -7,6 +7,11 @@ waybar::modules::Pulseaudio::Pulseaudio(const std::string& id, const Json::Value backend = util::AudioBackend::getInstance([this] { this->dp.emit(); }); backend->setIgnoredSinks(config_["ignored-sinks"]); + backend->setSinkMapping(config_["sink-mapping"]); + + if (config_["target"].isString() && config_["target"].asString() == "source") { + target = util::PulseaudioTarget::Source; + } } bool waybar::modules::Pulseaudio::handleScroll(GdkEventScroll* e) { @@ -33,7 +38,7 @@ bool waybar::modules::Pulseaudio::handleScroll(GdkEventScroll* e) { ? util::ChangeType::Increase : util::ChangeType::Decrease; - backend->changeVolume(change_type, step, max_volume); + backend->changeVolume(change_type, step, max_volume, target); return true; } @@ -68,7 +73,6 @@ const std::vector waybar::modules::Pulseaudio::getPulseIcon() const auto waybar::modules::Pulseaudio::update() -> void { auto format = format_; - std::string tooltip_format; auto sink_volume = backend->getSinkVolume(); if (!alt_) { std::string format_name = "format"; @@ -115,30 +119,31 @@ auto waybar::modules::Pulseaudio::update() -> void { auto sink_desc = backend->getSinkDesc(); auto source_desc = backend->getSourceDesc(); - format_source = fmt::format(fmt::runtime(format_source), fmt::arg("volume", source_volume)); - auto text = fmt::format( - fmt::runtime(format), fmt::arg("desc", sink_desc), fmt::arg("volume", sink_volume), - fmt::arg("format_source", format_source), fmt::arg("source_volume", source_volume), - fmt::arg("source_desc", source_desc), fmt::arg("icon", getIcon(sink_volume, getPulseIcon()))); + format_source = fmt::format(fmt::runtime(format_source), fmt::arg("volume", source_volume), + fmt::arg("source_desc", source_desc)); + + fmt::dynamic_format_arg_store store; + store.push_back(fmt::arg("desc", sink_desc)); + store.push_back(fmt::arg("volume", sink_volume)); + store.push_back(fmt::arg("format_source", format_source)); + store.push_back(fmt::arg("source_volume", source_volume)); + store.push_back(fmt::arg("source_desc", source_desc)); + store.push_back(fmt::arg("icon", getIcon(sink_volume, getPulseIcon()))); + + auto text = fmt::vformat(format, store); if (text.empty()) { label_.hide(); } else { - label_.set_markup(text); + setLabelMarkup(text); label_.show(); } if (tooltipEnabled()) { - if (tooltip_format.empty() && config_["tooltip-format"].isString()) { - tooltip_format = config_["tooltip-format"].asString(); - } + auto tooltip_format = resolveTooltipFormat(""); if (!tooltip_format.empty()) { - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_format), fmt::arg("desc", sink_desc), - fmt::arg("volume", sink_volume), fmt::arg("format_source", format_source), - fmt::arg("source_volume", source_volume), fmt::arg("source_desc", source_desc), - fmt::arg("icon", getIcon(sink_volume, getPulseIcon())))); + setTooltipMarkup(fmt::vformat(tooltip_format, store)); } else { - label_.set_tooltip_markup(sink_desc); + setTooltipMarkup(sink_desc); } } diff --git a/src/modules/pulseaudio_slider.cpp b/src/modules/pulseaudio_slider.cpp index bf85584e..3fb930db 100644 --- a/src/modules/pulseaudio_slider.cpp +++ b/src/modules/pulseaudio_slider.cpp @@ -6,77 +6,56 @@ PulseaudioSlider::PulseaudioSlider(const std::string& id, const Json::Value& con : ASlider(config, "pulseaudio-slider", id) { backend = util::AudioBackend::getInstance([this] { this->dp.emit(); }); backend->setIgnoredSinks(config_["ignored-sinks"]); + backend->setSinkMapping(config_["sink-mapping"]); if (config_["target"].isString()) { std::string target = config_["target"].asString(); if (target == "sink") { - this->target = PulseaudioSliderTarget::Sink; + this->target = util::PulseaudioTarget::Sink; } else if (target == "source") { - this->target = PulseaudioSliderTarget::Source; + this->target = util::PulseaudioTarget::Source; } } + + if (config_["zero-on-mute"].isBool()) { + zero_on_mute = config_["zero-on-mute"].asBool(); + } + + if (config_["unmute-on-volume-change"].isBool()) { + unmute_on_volume_change = config_["unmute-on-volume-change"].asBool(); + } } void PulseaudioSlider::update() { - switch (target) { - case PulseaudioSliderTarget::Sink: - if (backend->getSinkMuted()) { - scale_.set_value(min_); - } else { - scale_.set_value(backend->getSinkVolume()); - } - break; + uint16_t display_value = backend->getVolume(target); + bool is_muted = backend->getMuted(target); - case PulseaudioSliderTarget::Source: - if (backend->getSourceMuted()) { - scale_.set_value(min_); - } else { - scale_.set_value(backend->getSourceVolume()); - } - break; + if (is_muted) { + if (zero_on_mute) { + display_value = min_; + } + if (!previously_muted) { + scale_.get_style_context()->add_class("muted"); + } + } else if (previously_muted) { + scale_.get_style_context()->remove_class("muted"); } + + scale_.set_value(display_value); + + previously_muted = is_muted; } void PulseaudioSlider::onValueChanged() { - bool is_mute = false; + uint16_t slider_value = scale_.get_value(); - switch (target) { - case PulseaudioSliderTarget::Sink: - if (backend->getSinkMuted()) { - is_mute = true; - } - break; - - case PulseaudioSliderTarget::Source: - if (backend->getSourceMuted()) { - is_mute = true; - } - break; - } - - uint16_t volume = scale_.get_value(); - - if (is_mute) { - // Avoid setting sink/source to volume 0 if the user muted if via another mean. - if (volume == 0) { - return; - } - - // If the sink/source is mute, but the user clicked the slider, unmute it! - else { - switch (target) { - case PulseaudioSliderTarget::Sink: - backend->toggleSinkMute(false); - break; - - case PulseaudioSliderTarget::Source: - backend->toggleSourceMute(false); - break; - } + // Avoid setting sink/source to volume 0 if the user muted it via other means. + if (!backend->getMuted(target) || slider_value != 0) { + if (unmute_on_volume_change) { + backend->unmute(target); } + backend->changeVolume(slider_value, min_, max_, target); } - - backend->changeVolume(volume, min_, max_); } -} // namespace waybar::modules \ No newline at end of file +} // namespace waybar::modules diff --git a/src/modules/river/layout.cpp b/src/modules/river/layout.cpp index 3fb536e6..d8a1fb65 100644 --- a/src/modules/river/layout.cpp +++ b/src/modules/river/layout.cpp @@ -151,7 +151,9 @@ void Layout::handle_name(const char* name) { } label_.get_style_context()->add_class(name); - label_.set_markup(fmt::format(fmt::runtime(format_), Glib::Markup::escape_text(name).raw())); + label_.set_markup(fmt::format(fmt::runtime(format_), + fmt::arg("layout", Glib::Markup::escape_text(name).raw()), + fmt::arg("icon", getIcon(0, name)))); label_.show(); } name_ = name; diff --git a/src/modules/river/tags.cpp b/src/modules/river/tags.cpp index 7957015b..b6d625a6 100644 --- a/src/modules/river/tags.cpp +++ b/src/modules/river/tags.cpp @@ -62,13 +62,15 @@ static const zriver_command_callback_v1_listener command_callback_listener_impl{ .failure = listen_command_failure, }; -static void listen_focused_output(void *data, struct zriver_seat_status_v1 *zriver_seat_status_v1, - struct wl_output *output) { - static_cast(data)->focused_output_ = output; +static void listen_focused_output(void* data, struct zriver_seat_status_v1* zriver_seat_status_v1, + struct wl_output* output) { + static_cast(data)->handle_focused_output(output); } -static void listen_unfocused_output(void *data, struct zriver_seat_status_v1 *zriver_seat_status_v1, - struct wl_output *output) {} +static void listen_unfocused_output(void* data, struct zriver_seat_status_v1* zriver_seat_status_v1, + struct wl_output* output) { + static_cast(data)->handle_unfocused_output(output); +} static void listen_focused_view(void *data, struct zriver_seat_status_v1 *zriver_seat_status_v1, const char *title, uint32_t tags) { @@ -78,7 +80,7 @@ static void listen_focused_view(void *data, struct zriver_seat_status_v1 *zriver static void listen_mode(void *data, struct zriver_seat_status_v1 *zriver_seat_status_v1, const char *mode) { - static_cast(data)->AModule::update(); + // This module doesn't care } static const zriver_seat_status_v1_listener seat_status_listener_impl{ @@ -124,11 +126,12 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con status_manager_{nullptr}, control_{nullptr}, seat_{nullptr}, - output_{nullptr}, focused_output_{nullptr}, + output_{nullptr}, bar_(bar), box_{bar.orientation, 0}, - output_status_{nullptr} { + output_status_{nullptr}, + seat_status_{nullptr} { struct wl_display* display = Client::inst()->wl_display; struct wl_registry* registry = wl_display_get_registry(display); wl_registry_add_listener(registry, ®istry_listener_impl, this); @@ -140,20 +143,28 @@ 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; } - 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); - + // Store the output this module belongs to; the river_output_status and + // river_seat_status objects (and their listeners) are created lazily in + // handle_show() to avoid leaking objects without listeners here. output_ = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); + // Parse hide-vacant once here, not in the wl callbacks: jsoncpp's asBool() + // throws on a string value ("true"), and an exception unwinding through + // libwayland's C dispatch aborts (#4078). Accept the string form too. + const auto& hv = config_["hide-vacant"]; + hide_vacant_ = hv.isString() ? hv.asString() == "true" : hv.asBool(); + box_.set_name("tags"); if (!id.empty()) { box_.get_style_context()->add_class(id); @@ -180,7 +191,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())); @@ -194,6 +205,7 @@ Tags::Tags(const std::string& id, const waybar::Bar& bar, const Json::Value& con button.signal_button_press_event().connect( sigc::bind(sigc::mem_fun(*this, &Tags::handle_button_press), (1 << tag))); } + button.get_style_context()->add_class("tag-" + std::to_string(tag + 1)); button.show(); } @@ -205,14 +217,14 @@ Tags::~Tags() { zriver_output_status_v1_destroy(output_status_); } - if (control_) { - zriver_control_v1_destroy(control_); - } - if (seat_status_) { zriver_seat_status_v1_destroy(seat_status_); } + if (control_) { + zriver_control_v1_destroy(control_); + } + if (status_manager_) { zriver_status_manager_v1_destroy(status_manager_); } @@ -220,15 +232,20 @@ Tags::~Tags() { void Tags::handle_show() { if (!status_manager_) return; - struct wl_output* output = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); - output_status_ = zriver_status_manager_v1_get_river_output_status(status_manager_, output); + 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"); @@ -238,6 +255,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 move view to tag on right mouse click zriver_command_callback_v1* callback; @@ -257,7 +275,7 @@ bool Tags::handle_button_press(GdkEventButton* event_button, uint32_t tag) { } void Tags::handle_focused_tags(uint32_t tags) { - auto hide_vacant = config_["hide-vacant"].asBool(); + const bool hide_vacant = hide_vacant_; for (size_t i = 0; i < buttons_.size(); ++i) { bool visible = buttons_[i].is_visible(); bool occupied = buttons_[i].get_style_context()->has_class("occupied"); @@ -283,7 +301,7 @@ void Tags::handle_view_tags(struct wl_array* view_tags) { for (; view_tag < end; ++view_tag) { tags |= *view_tag; } - auto hide_vacant = config_["hide-vacant"].asBool(); + const bool hide_vacant = hide_vacant_; for (size_t i = 0; i < buttons_.size(); ++i) { bool visible = buttons_[i].is_visible(); bool focused = buttons_[i].get_style_context()->has_class("focused"); @@ -303,7 +321,7 @@ void Tags::handle_view_tags(struct wl_array* view_tags) { } void Tags::handle_urgent_tags(uint32_t tags) { - auto hide_vacant = config_["hide-vacant"].asBool(); + const bool hide_vacant = hide_vacant_; for (size_t i = 0; i < buttons_.size(); ++i) { bool visible = buttons_[i].is_visible(); bool occupied = buttons_[i].get_style_context()->has_class("occupied"); @@ -332,4 +350,21 @@ void Tags::handle_focused_view(const char *title, uint32_t tags) { } } +void Tags::handle_focused_output(struct wl_output* output) { + focused_output_ = output; + if (output_ == output) { + for (size_t i = 0; i < buttons_.size(); ++i) { + buttons_[i].get_style_context()->add_class("output"); + } + } +} + +void Tags::handle_unfocused_output(struct wl_output* output) { + if (output_ == output) { + for (size_t i = 0; i < buttons_.size(); ++i) { + buttons_[i].get_style_context()->remove_class("output"); + } + } +} + } /* namespace waybar::modules::river */ diff --git a/src/modules/simpleclock.cpp b/src/modules/simpleclock.cpp index 59a2b1c1..b8fc2423 100644 --- a/src/modules/simpleclock.cpp +++ b/src/modules/simpleclock.cpp @@ -18,18 +18,7 @@ auto waybar::modules::Clock::update() -> void { tzset(); // Update timezone information auto now = std::chrono::system_clock::now(); auto localtime = fmt::localtime(std::chrono::system_clock::to_time_t(now)); - auto text = fmt::format(fmt::runtime(format_), localtime); - label_.set_markup(text); - - if (tooltipEnabled()) { - if (config_["tooltip-format"].isString()) { - auto tooltip_format = config_["tooltip-format"].asString(); - auto tooltip_text = fmt::format(fmt::runtime(tooltip_format), localtime); - label_.set_tooltip_markup(tooltip_text); - } else { - label_.set_tooltip_markup(text); - } - } + updateLabelAndTooltip(format_, format_, localtime); // Call parent update ALabel::update(); } diff --git a/src/modules/sni/host.cpp b/src/modules/sni/host.cpp index 567fbf9f..c55c8f2e 100644 --- a/src/modules/sni/host.cpp +++ b/src/modules/sni/host.cpp @@ -2,14 +2,21 @@ #include +#include + +#include "modules/sni/item.hpp" #include "util/scope_guard.hpp" namespace waybar::modules::SNI { -Host::Host(const std::size_t id, const Json::Value& config, const Bar& bar, +static const unsigned RETRY_DELAY_MS = 200; +static const unsigned MAX_RETRIES = 10; + +Host::Host(std::size_t id, const Json::Value& config, const Bar& bar, + const std::vector& ignore_list, const std::function&)>& on_add, const std::function&)>& on_remove, - const std::function& on_update) + const std::function& on_reorder, const std::function& on_update) : bus_name_("org.kde.StatusNotifierHost-" + std::to_string(getpid()) + "-" + std::to_string(id)), object_path_("/StatusNotifierHost/" + std::to_string(id)), @@ -17,11 +24,25 @@ Host::Host(const std::size_t id, const Json::Value& config, const Bar& bar, sigc::mem_fun(*this, &Host::busAcquired))), config_(config), bar_(bar), + ignore_list_(ignore_list), on_add_(on_add), on_remove_(on_remove), - on_update_(on_update) {} + on_reorder_(on_reorder), + on_update_(on_update) { + auto orders = config["orders"]; + if (!orders.isNull()) { + for (auto itr = orders.begin(); itr != orders.end(); ++itr) { + auto key = itr.name(); + auto& value = *itr; + assert(value.isInt()); + + orders_[key] = value.asInt(); + } + } +} Host::~Host() { + retry_connection_.disconnect(); if (bus_name_id_ > 0) { Gio::DBus::unown_name(bus_name_id_); bus_name_id_ = 0; @@ -35,13 +56,49 @@ Host::~Host() { g_clear_object(&watcher_); } -void Host::busAcquired(const Glib::RefPtr& conn, Glib::ustring name) { +void Host::checkIgnoreList(const std::vector& ignore_list, + const std::function&)>& on_remove) { + spdlog::debug("Host::checkIgnoreList - checking {} items against {} patterns", items_.size(), + ignore_list.size()); + + for (auto it = items_.begin(); it != items_.end();) { + auto& item = *it; + spdlog::debug(" Checking item: bus_name='{}', category='{}', icon_name='{}', title='{}'", + item->bus_name, item->category, item->icon_name, item->title); + + bool should_remove = false; + + for (const auto& ignored : ignore_list) { + if (item->bus_name.find(ignored) != std::string::npos || + item->category.find(ignored) != std::string::npos || + item->icon_name.find(ignored) != std::string::npos || + item->id.find(ignored) != std::string::npos || + item->title.find(ignored) != std::string::npos) { + spdlog::info( + "Host: Ignoring item bus_name='{}', category='{}', icon_name='{}', title='{}' - " + "matched pattern '{}'", + item->bus_name, item->category, item->icon_name, item->title, ignored); + on_remove(item); + should_remove = true; + break; + } + } + + if (should_remove) { + it = items_.erase(it); + } else { + ++it; + } + } +} + +void Host::busAcquired(const Glib::RefPtr& conn, const Glib::ustring& name) { watcher_id_ = Gio::DBus::watch_name(conn, "org.kde.StatusNotifierWatcher", sigc::mem_fun(*this, &Host::nameAppeared), sigc::mem_fun(*this, &Host::nameVanished)); } -void Host::nameAppeared(const Glib::RefPtr& conn, const Glib::ustring name, +void Host::nameAppeared(const Glib::RefPtr& conn, const Glib::ustring& name, const Glib::ustring& name_owner) { if (cancellable_ != nullptr) { // TODO @@ -52,7 +109,10 @@ void Host::nameAppeared(const Glib::RefPtr& conn, const G "/StatusNotifierWatcher", cancellable_, &Host::proxyReady, this); } -void Host::nameVanished(const Glib::RefPtr& conn, const Glib::ustring name) { +void Host::nameVanished(const Glib::RefPtr& conn, + const Glib::ustring& name) { + retry_connection_.disconnect(); + retry_count_ = 0; g_cancellable_cancel(cancellable_); g_clear_object(&cancellable_); g_clear_object(&watcher_); @@ -67,16 +127,48 @@ void Host::proxyReady(GObject* src, GAsyncResult* res, gpointer data) { } }); SnWatcher* watcher = sn_watcher_proxy_new_finish(res, &error); - if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED) != 0) { spdlog::error("Host: {}", error->message); return; } - auto host = static_cast(data); - host->watcher_ = watcher; + auto* host = static_cast(data); if (error != nullptr) { spdlog::error("Host: {}", error->message); + g_clear_object(&host->cancellable_); + if (host->retry_count_ >= MAX_RETRIES) { + spdlog::warn("Host: giving up on watcher proxy creation after {} retries", + host->retry_count_); + return; + } + host->retry_count_ += 1; + // 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]() -> 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(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); return; } + host->retry_count_ = 0; + host->watcher_ = watcher; sn_watcher_call_register_host(host->watcher_, host->object_path_.c_str(), host->cancellable_, &Host::registerHost, data); } @@ -89,20 +181,22 @@ void Host::registerHost(GObject* src, GAsyncResult* res, gpointer data) { } }); sn_watcher_call_register_host_finish(SN_WATCHER(src), res, &error); - if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED) != 0) { spdlog::error("Host: {}", error->message); return; } - auto host = static_cast(data); + auto* host = static_cast(data); if (error != nullptr) { spdlog::error("Host: {}", error->message); return; } g_signal_connect(host->watcher_, "item-registered", G_CALLBACK(&Host::itemRegistered), data); g_signal_connect(host->watcher_, "item-unregistered", G_CALLBACK(&Host::itemUnregistered), data); - auto items = sn_watcher_dup_registered_items(host->watcher_); + auto* items = sn_watcher_dup_registered_items(host->watcher_); if (items != nullptr) { + spdlog::info("Host: Found {} pre-registered SNI items", g_strv_length(items)); for (uint32_t i = 0; items[i] != nullptr; i += 1) { + spdlog::info("Host: Processing pre-registered item: {}", items[i]); host->addRegisteredItem(items[i]); } } @@ -110,13 +204,16 @@ void Host::registerHost(GObject* src, GAsyncResult* res, gpointer data) { } void Host::itemRegistered(SnWatcher* watcher, const gchar* service, gpointer data) { - auto host = static_cast(data); + auto* host = static_cast(data); + spdlog::info("Host::itemRegistered called with service: {}", service); host->addRegisteredItem(service); + // host->checkIgnoreList(host->ignore_list_, std::bind(&Host::itemUnregistered, host, + // std::placeholders::_1, std::placeholders::_2, data)); } void Host::itemUnregistered(SnWatcher* watcher, const gchar* service, gpointer data) { - auto host = static_cast(data); - auto [bus_name, object_path] = host->getBusNameAndObjectPath(service); + auto* host = static_cast(data); + auto [bus_name, object_path] = waybar::modules::SNI::Host::getBusNameAndObjectPath(service); for (auto it = host->items_.begin(); it != host->items_.end(); ++it) { if ((*it)->bus_name == bus_name && (*it)->object_path == object_path) { host->removeItem(it); @@ -163,7 +260,7 @@ void Host::clearItems() { } } -std::tuple Host::getBusNameAndObjectPath(const std::string service) { +std::tuple Host::getBusNameAndObjectPath(const std::string& service) { auto it = service.find('/'); if (it != std::string::npos) { return {service.substr(0, it), service.substr(it)}; @@ -172,18 +269,39 @@ std::tuple Host::getBusNameAndObjectPath(const std::st } void Host::addRegisteredItem(const std::string& service) { - std::string bus_name, object_path; + // Check service string directly before parsing + for (const auto& ignored : ignore_list_) { + if (service.find(ignored) != std::string::npos) { + spdlog::info("Host: Ignoring service '{}' - matched pattern '{}'", service, ignored); + return; + } + } + std::string bus_name; + std::string object_path; std::tie(bus_name, object_path) = getBusNameAndObjectPath(service); - auto it = std::find_if(items_.begin(), items_.end(), [&bus_name, &object_path](const auto& item) { + spdlog::debug("SNI item registered: bus_name={}, object_path={}, full_service={}", bus_name, + object_path, service); + auto it = std::ranges::find_if(items_, [&bus_name, &object_path](const auto& item) { return bus_name == item->bus_name && object_path == item->object_path; }); if (it == items_.end()) { + spdlog::debug("Adding SNI item: {}", bus_name); items_.emplace_back(std::make_unique( - bus_name, object_path, config_, bar_, - [this](Item& item) { itemReady(item); }, - [this](Item& item) { itemInvalidated(item); }, - on_update_)); + bus_name, object_path, config_, bar_, [this](Item& item) { itemReady(item); }, + [this](Item& item) { itemInvalidated(item); }, on_update_, *this, orders_)); } } +void Host::reorderItems() { + // Re-apply the configured ordering to the tray. This is invoked while an + // item's Id/order is first resolved (from Item::setCustomIcon), which happens + // *before* the item is marked ready and added. It must therefore only reorder + // the widgets that have already been added; re-running the full add path here + // would (a) re-parent widgets and reconnect signals for every item and (b) + // mutate items_ from within checkIgnoreList while it is being iterated, + // invalidating iterators/pointers. Delegating to on_reorder_ keeps this to a + // pure reordering of existing children. + on_reorder_(); +} + } // namespace waybar::modules::SNI diff --git a/src/modules/sni/item.cpp b/src/modules/sni/item.cpp index 2f368083..843f386b 100644 --- a/src/modules/sni/item.cpp +++ b/src/modules/sni/item.cpp @@ -6,13 +6,16 @@ #include #include +#include #include #include #include +#include #include "gdk/gdk.h" +#include "modules/sni/host.hpp" #include "modules/sni/icon_manager.hpp" -#include "util/format.hpp" +#include "util/format.hpp" // IWYU pragma: keep #include "util/gtk_icon.hpp" template <> @@ -40,7 +43,8 @@ static const unsigned UPDATE_DEBOUNCE_TIME = 10; Item::Item(const std::string& bn, const std::string& op, const Json::Value& config, const Bar& bar, const std::function& on_ready, - const std::function& on_invalidate, const std::function& on_updated) + const std::function& on_invalidate, const std::function& on_updated, + Host& host, const ItemOrderMap& orders) : bus_name(bn), object_path(op), icon_size(16), @@ -49,7 +53,9 @@ Item::Item(const std::string& bn, const std::string& op, const Json::Value& conf bar_(bar), on_ready_(on_ready), on_invalidate_(on_invalidate), - on_updated_(on_updated) { + on_updated_(on_updated), + host_(host), + orders_(orders) { if (config["icon-size"].isUInt()) { icon_size = config["icon-size"].asUInt(); } @@ -172,15 +178,23 @@ void Item::setProperty(const Glib::ustring& name, Glib::VariantBase& value) { * I still haven't found a way for it to pick from theme automatically, although * it might be my theme. */ + std::string icon_id = id; if (id == "chrome_status_icon_1") { Glib::VariantBase value; this->proxy_->get_cached_property(value, "ToolTip"); tooltip = get_variant(value); if (!tooltip.text.empty()) { - setCustomIcon(tooltip.text.lowercase()); + icon_id = tooltip.text.lowercase(); + setCustomIcon(icon_id); } } else { - setCustomIcon(id); + setCustomIcon(icon_id); + } + + // Check if this item should be hidden + if (IconManager::instance().isHidden(icon_id)) { + spdlog::debug("Hiding tray item with ID: {}", icon_id); + is_hidden_ = true; } } else if (name == "Title") { title = get_variant(value); @@ -190,9 +204,17 @@ void Item::setProperty(const Glib::ustring& name, Glib::VariantBase& value) { } else if (name == "Status") { setStatus(get_variant(value)); } else if (name == "IconName") { - icon_name = get_variant(value); + if (has_custom_icon_) { + spdlog::trace("Item '{}': ignoring IconName update, custom icon is set", id); + } else { + icon_name = get_variant(value); + } } else if (name == "IconPixmap") { - icon_pixmap = this->extractPixBuf(value.gobj()); + if (has_custom_icon_) { + spdlog::trace("Item '{}': ignoring IconPixmap update, custom icon is set", id); + } else { + icon_pixmap = this->extractPixBuf(value.gobj()); + } } else if (name == "OverlayIconName") { overlay_icon_name = get_variant(value); } else if (name == "OverlayIconPixmap") { @@ -230,7 +252,7 @@ void Item::setProperty(const Glib::ustring& name, Glib::VariantBase& value) { void Item::setStatus(const Glib::ustring& value) { status_ = value.lowercase(); - event_box.set_visible(show_passive_ || status_.compare("passive") != 0); + event_box.set_visible(!is_hidden_ && (show_passive_ || status_.compare("passive") != 0)); auto style = event_box.get_style_context(); for (const auto& class_name : style->list_classes()) { @@ -263,6 +285,17 @@ void Item::invalidate() { void Item::setCustomIcon(const std::string& id) { spdlog::debug("SNI tray id: {}", id); + if (order_ == -1) { + auto iter = orders_.find(id); + if (iter != orders_.end()) { + order_ = iter->second; + spdlog::debug("reordering tray item {}, order: {}", id, order_); + } else { + order_ = 0; + } + host_.reorderItems(); + } + std::string custom_icon = IconManager::instance().getIconForApp(id); if (!custom_icon.empty()) { if (std::filesystem::exists(custom_icon)) { @@ -270,11 +303,13 @@ void Item::setCustomIcon(const std::string& id) { Glib::RefPtr custom_pixbuf = Gdk::Pixbuf::create_from_file(custom_icon); icon_name = ""; // icon_name has priority over pixmap icon_pixmap = custom_pixbuf; + has_custom_icon_ = true; } catch (const Glib::Error& e) { spdlog::error("Failed to load custom icon {}: {}", custom_icon, e.what()); } } else { // if file doesn't exist it's most likely an icon_name icon_name = custom_icon; + has_custom_icon_ = true; } } } @@ -542,6 +577,15 @@ void Item::makeMenu() { if (dbus_menu != nullptr) { g_object_ref_sink(G_OBJECT(dbus_menu)); g_object_weak_ref(G_OBJECT(dbus_menu), (GWeakNotify)onMenuDestroyed, this); + // Provide an accel group to the dbusmenu client. Without one, items that export menu + // accelerators (e.g. Mattermost) trigger gtk_widget_set_accel_path() with a NULL accel group, + // which raises a Gtk-CRITICAL and corrupts menu state (or aborts under fatal-criticals). + DbusmenuGtkClient* client = dbusmenu_gtkmenu_get_client(DBUSMENU_GTKMENU(dbus_menu)); + if (client != nullptr) { + GtkAccelGroup* accel_group = gtk_accel_group_new(); + dbusmenu_gtkclient_set_accel_group(client, accel_group); + g_object_unref(accel_group); + } gtk_menu = Glib::wrap(GTK_MENU(dbus_menu)); gtk_menu->attach_to_widget(event_box); } diff --git a/src/modules/sni/tray.cpp b/src/modules/sni/tray.cpp index 114aba78..1751e55d 100644 --- a/src/modules/sni/tray.cpp +++ b/src/modules/sni/tray.cpp @@ -8,13 +8,37 @@ namespace waybar::modules::SNI { +static void initIconsConfig(const Json::Value& config) { + if (config["icons"].isObject()) { + IconManager::instance().setIconsConfig(config["icons"]); + } +} + +std::vector Tray::parseIgnoreList(const Json::Value& config) { + std::vector ignore_list; + if (config["ignore-list"].isArray()) { + spdlog::info("Tray: Found ignore-list with {} items", config["ignore-list"].size()); + for (const auto& item : config["ignore-list"]) { + if (item.isString()) { + ignore_list.push_back(item.asString()); + spdlog::info("Tray: Adding to ignore list: {}", item.asString()); + } + } + } else { + spdlog::info("Tray: No ignore-list configured"); + } + return ignore_list; +} + Tray::Tray(const std::string& id, const Bar& bar, const Json::Value& config) : AModule(config, "tray", id), box_(bar.orientation, 0), watcher_(SNI::Watcher::getInstance()), - host_(nb_hosts_, config, bar, std::bind(&Tray::onAdd, this, std::placeholders::_1), + ignore_list_(parseIgnoreList(config)), + host_((initIconsConfig(config), nb_hosts_), config, bar, ignore_list_, + std::bind(&Tray::onAdd, this, std::placeholders::_1), std::bind(&Tray::onRemove, this, std::placeholders::_1), - std::bind(&Tray::queueUpdate, this)) { + std::bind(&Tray::reorderBox, this), std::bind(&Tray::queueUpdate, this)) { box_.set_name("tray"); event_box_.add(box_); if (!id.empty()) { @@ -24,44 +48,97 @@ Tray::Tray(const std::string& id, const Bar& bar, const Json::Value& config) if (config_["spacing"].isUInt()) { box_.set_spacing(config_["spacing"].asUInt()); } - if (config["show-passive-items"].isBool()) { - show_passive_ = config["show-passive-items"].asBool(); - } nb_hosts_ += 1; - if (config_["icons"].isObject()) { - IconManager::instance().setIconsConfig(config_["icons"]); - } - dp.emit(); +} + +void Tray::checkIgnoreList(std::unique_ptr* item_ptr) { + // Delegate to Host's checkIgnoreList method + host_.checkIgnoreList(ignore_list_, std::bind(&Tray::onRemove, this, std::placeholders::_1)); } void Tray::queueUpdate() { dp.emit(); } void Tray::onAdd(std::unique_ptr& item) { + spdlog::info("Tray::onAdd - item bus_name='{}', category='{}', icon_name='{}', title='{}'", + item->bus_name, item->category, item->icon_name, item->title); + + // Idempotency guard: onAdd can be reached more than once for the same item + // (e.g. an item is processed while its Id/order is resolved and then again + // when it becomes ready). Re-adding would re-parent the event_box (GTK + // "widget already has a parent" critical), push a duplicate pointer into + // items_ and leak extra signal connections. + if (std::find(items_.begin(), items_.end(), item.get()) != items_.end()) { + spdlog::debug("Tray::onAdd - item already added, skipping"); + return; + } + if (config_["reverse-direction"].isBool() && config_["reverse-direction"].asBool()) { box_.pack_end(item->event_box); } else { box_.pack_start(item->event_box); } + items_.push_back(item.get()); + + auto show_conn = item->event_box.signal_show().connect([this] { dp.emit(); }); + auto hide_conn = item->event_box.signal_hide().connect([this] { dp.emit(); }); + item_connections_[item.get()] = {show_conn, hide_conn}; + + // Position the freshly added widget according to the configured order. This + // must happen before the ignore-list check below, which may erase `item`. + reorderBox(); + + // 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)); + dp.emit(); } void Tray::onRemove(std::unique_ptr& item) { + // May be called for items that were never added (e.g. the ignore-list check + // runs over items that are not yet ready). Only touch state we actually own. + auto it = std::find(items_.begin(), items_.end(), item.get()); + if (it == items_.end()) { + return; + } + + auto conn_it = item_connections_.find(item.get()); + if (conn_it != item_connections_.end()) { + conn_it->second.first.disconnect(); + conn_it->second.second.disconnect(); + item_connections_.erase(conn_it); + } + box_.remove(item->event_box); + items_.erase(it); dp.emit(); } +void Tray::reorderBox() { + const bool reverse = + config_["reverse-direction"].isBool() && config_["reverse-direction"].asBool(); + // Stable sort keeps insertion order among items sharing the same order value. + std::stable_sort(items_.begin(), items_.end(), + [](const Item* a, const Item* b) { return a->order_ < b->order_; }); + for (std::size_t i = 0; i < items_.size(); ++i) { + const int pos = reverse ? static_cast(items_.size() - 1 - i) : static_cast(i); + box_.reorder_child(items_[i]->event_box, pos); + } +} + auto Tray::update() -> void { - // Show tray only when items are available - std::vector children = box_.get_children(); - if (show_passive_) { - event_box_.set_visible(!children.empty()); - } else { - event_box_.set_visible(!std::all_of(children.begin(), children.end(), [](Gtk::Widget* child) { - return child->get_style_context()->has_class("passive"); - })); + // Check if any items should be ignored now that properties have loaded + if (!ignore_list_.empty()) { + spdlog::debug("Tray::update() - checking ignore list"); + host_.checkIgnoreList(ignore_list_, std::bind(&Tray::onRemove, this, std::placeholders::_1)); } - // Call parent update + // Show tray only when items are visible. Iterate the managed items_ list + // instead of box_.get_children() to avoid a use-after-free on raw widget + // pointers that may dangle after items are destroyed asynchronously. + event_box_.set_visible(std::any_of(items_.begin(), items_.end(), + [](Item* item) { return item->event_box.get_visible(); })); AModule::update(); } diff --git a/src/modules/sni/watcher.cpp b/src/modules/sni/watcher.cpp index 969806cf..be52a401 100644 --- a/src/modules/sni/watcher.cpp +++ b/src/modules/sni/watcher.cpp @@ -25,11 +25,12 @@ Watcher::~Watcher() { items_ = nullptr; } Gio::DBus::unown_name(bus_name_id_); - auto iface = G_DBUS_INTERFACE_SKELETON(watcher_); + auto* iface = G_DBUS_INTERFACE_SKELETON(watcher_); g_dbus_interface_skeleton_unexport(iface); } -void Watcher::busAcquired(const Glib::RefPtr& conn, Glib::ustring name) { +void Watcher::busAcquired(const Glib::RefPtr& conn, + Glib::ustring name) { // NOLINT GError* error = nullptr; waybar::util::ScopeGuard error_deleter([&error]() { if (error) { @@ -65,7 +66,7 @@ gboolean Watcher::handleRegisterHost(Watcher* obj, GDBusMethodInvocation* invoca "D-Bus bus name '%s' is not valid", bus_name); return TRUE; } - auto watch = gfWatchFind(obj->hosts_, bus_name, object_path); + auto* watch = gfWatchFind(obj->hosts_, bus_name, object_path); if (watch != nullptr) { g_warning("Status Notifier Host with bus name '%s' and object path '%s' is already registered", bus_name, object_path); @@ -74,7 +75,7 @@ gboolean Watcher::handleRegisterHost(Watcher* obj, GDBusMethodInvocation* invoca } watch = gfWatchNew(GF_WATCH_TYPE_HOST, service, bus_name, object_path, obj); obj->hosts_ = g_slist_prepend(obj->hosts_, watch); - if (!sn_watcher_get_is_host_registered(obj->watcher_)) { + if (sn_watcher_get_is_host_registered(obj->watcher_) == 0) { sn_watcher_set_is_host_registered(obj->watcher_, TRUE); sn_watcher_emit_host_registered(obj->watcher_); } @@ -96,7 +97,7 @@ gboolean Watcher::handleRegisterItem(Watcher* obj, GDBusMethodInvocation* invoca "D-Bus bus name '%s' is not valid", bus_name); return TRUE; } - auto watch = gfWatchFind(obj->items_, bus_name, object_path); + auto* watch = gfWatchFind(obj->items_, bus_name, object_path); if (watch != nullptr) { spdlog::debug("Ignoring duplicate Status Notifier Item registration for '{}' at '{}'", bus_name, object_path); @@ -116,7 +117,7 @@ gboolean Watcher::handleRegisterItem(Watcher* obj, GDBusMethodInvocation* invoca Watcher::GfWatch* Watcher::gfWatchFind(GSList* list, const gchar* bus_name, const gchar* object_path) { for (GSList* l = list; l != nullptr; l = g_slist_next(l)) { - auto watch = static_cast(l->data); + auto* watch = static_cast(l->data); if (g_strcmp0(watch->bus_name, bus_name) == 0 && g_strcmp0(watch->object_path, object_path) == 0) { return watch; @@ -126,7 +127,7 @@ Watcher::GfWatch* Watcher::gfWatchFind(GSList* list, const gchar* bus_name, } void Watcher::gfWatchFree(gpointer data) { - auto watch = static_cast(data); + auto* watch = static_cast(data); if (watch->watch_id > 0) { g_bus_unwatch_name(watch->watch_id); @@ -153,7 +154,7 @@ Watcher::GfWatch* Watcher::gfWatchNew(GfWatchType type, const gchar* service, co } void Watcher::nameVanished(GDBusConnection* connection, const char* name, gpointer data) { - auto watch = static_cast(data); + auto* watch = static_cast(data); if (watch->type == GF_WATCH_TYPE_HOST) { watch->watcher->hosts_ = g_slist_remove(watch->watcher->hosts_, watch); if (watch->watcher->hosts_ == nullptr) { @@ -174,7 +175,7 @@ void Watcher::updateRegisteredItems(SnWatcher* obj) { GVariantBuilder builder; g_variant_builder_init(&builder, G_VARIANT_TYPE("as")); for (GSList* l = items_; l != nullptr; l = g_slist_next(l)) { - auto watch = static_cast(l->data); + auto* watch = static_cast(l->data); gchar* item = g_strdup_printf("%s%s", watch->bus_name, watch->object_path); g_variant_builder_add(&builder, "s", item); g_free(item); diff --git a/src/modules/sway/ipc/client.cpp b/src/modules/sway/ipc/client.cpp index 3ebccccd..6d6c07ff 100644 --- a/src/modules/sway/ipc/client.cpp +++ b/src/modules/sway/ipc/client.cpp @@ -2,18 +2,56 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include + +#include "modules/sway/ipc/ipc.hpp" namespace waybar::modules::sway { +namespace { + +void sendAll(int fd, const char* data, size_t size, const char* what) { + size_t total = 0; + while (total < size) { + const auto res = ::send(fd, data + total, size - total, 0); + if (res < 0) { + if (errno == EINTR || errno == EAGAIN) { + continue; + } + throw std::runtime_error(what); + } + if (res == 0) { + throw std::runtime_error(what); + } + total += static_cast(res); + } +} + +} // namespace Ipc::Ipc() { - const std::string& socketPath = getSocketPath(); - fd_ = util::ScopedFd(open(socketPath)); - fd_event_ = util::ScopedFd(open(socketPath)); + socketPath_ = getSocketPath(); + fd_ = util::ScopedFd(open(socketPath_)); + fd_event_ = util::ScopedFd(open(socketPath_)); } Ipc::~Ipc() { + // Signal the worker before stopping it so an in-flight recv/reconnect bails + // out instead of trying to reconnect to a socket we're tearing down. + running_ = false; thread_.stop(); if (fd_ > 0) { @@ -29,49 +67,49 @@ Ipc::~Ipc() { } } -void Ipc::setWorker(std::function&& func) { thread_ = func; } +void Ipc::setWorker(std::function&& func) { thread_ = std::move(func); } -const std::string Ipc::getSocketPath() const { +std::string Ipc::getSocketPath() { const char* env = getenv("SWAYSOCK"); - if (env != nullptr) { - return std::string(env); + if (env != nullptr && env[0] != '\0') { + return {env}; } + + FILE* in = popen("sway --get-socketpath 2>/dev/null", "r"); + if (in == nullptr) { + throw std::runtime_error("Failed to get socket path"); + } + std::string str; - { - std::string str_buf; - FILE* in; - char buf[512] = {0}; - if ((in = popen("sway --get-socketpath 2>/dev/null", "r")) == nullptr) { - throw std::runtime_error("Failed to get socket path"); - } - while (fgets(buf, sizeof(buf), in) != nullptr) { - str_buf.append(buf, sizeof(buf)); - } - pclose(in); - str = str_buf; - if (str.empty()) { - throw std::runtime_error("Socket path is empty"); - } + char buf[512] = {0}; + while (fgets(buf, sizeof(buf), in) != nullptr) { + str.append(buf); } - if (str.back() == '\n') { + + if (pclose(in) == -1) { + throw std::runtime_error("Failed to get socket path"); + } + + if (str.ends_with('\n')) { str.pop_back(); } + if (str.empty()) { + throw std::runtime_error("Socket path is empty"); + } + return str; } -int Ipc::open(const std::string& socketPath) const { +int Ipc::open(const std::string& socketPath) { util::ScopedFd fd(socket(AF_UNIX, SOCK_STREAM, 0)); if (fd == -1) { throw std::runtime_error("Unable to open Unix socket"); } (void)fcntl(fd, F_SETFD, FD_CLOEXEC); - struct sockaddr_un addr; - memset(&addr, 0, sizeof(struct sockaddr_un)); - addr.sun_family = AF_UNIX; + struct sockaddr_un addr{.sun_family = AF_UNIX}; strncpy(addr.sun_path, socketPath.c_str(), sizeof(addr.sun_path) - 1); addr.sun_path[sizeof(addr.sun_path) - 1] = 0; - int l = sizeof(struct sockaddr_un); - if (::connect(fd, reinterpret_cast(&addr), l) == -1) { + if (::connect(fd, reinterpret_cast(&addr), sizeof addr) == -1) { throw std::runtime_error("Unable to connect to Sway"); } return fd.release(); @@ -80,55 +118,70 @@ int Ipc::open(const std::string& socketPath) const { struct Ipc::ipc_response Ipc::recv(int fd) { std::string header; header.resize(ipc_header_size_); - auto data32 = reinterpret_cast(header.data() + ipc_magic_.size()); - size_t total = 0; + size_t total = 0; while (total < ipc_header_size_) { - auto res = ::recv(fd, header.data() + total, ipc_header_size_ - total, 0); + const ssize_t res = ::recv(fd, header.data() + total, ipc_header_size_ - total, 0); if (fd_event_ == -1 || fd_ == -1) { // IPC is closed so just return an empty response - return {0, 0, ""}; + return {.size = 0, .type = 0, .payload = ""}; } - if (res <= 0) { + if (res < 0) { + if (errno == EINTR || errno == EAGAIN) { + continue; + } throw std::runtime_error("Unable to receive IPC header"); } - total += res; + if (res == 0) { + throw std::runtime_error("Unable to receive IPC header"); + } + total += static_cast(res); } - auto magic = std::string(header.data(), header.data() + ipc_magic_.size()); - if (magic != ipc_magic_) { + if (std::string_view(header.data(), ipc_magic_.size()) != ipc_magic_) { throw std::runtime_error("Invalid IPC magic"); } - total = 0; + uint32_t payload_size = 0; + uint32_t payload_type = 0; + memcpy(&payload_size, header.data() + ipc_magic_.size(), sizeof payload_size); + memcpy(&payload_type, header.data() + ipc_magic_.size() + sizeof payload_size, + sizeof payload_type); + std::string payload; - payload.resize(data32[0]); - while (total < data32[0]) { - auto res = ::recv(fd, payload.data() + total, data32[0] - total, 0); + payload.resize(payload_size); + + total = 0; + while (total < payload_size) { + const ssize_t res = ::recv(fd, payload.data() + total, payload_size - total, 0); if (res < 0) { if (errno == EINTR || errno == EAGAIN) { continue; } throw std::runtime_error("Unable to receive IPC payload"); } - total += res; + if (res == 0) { + throw std::runtime_error("Unable to receive IPC payload"); + } + total += static_cast(res); } - return {data32[0], data32[1], &payload.front()}; + + return {.size = payload_size, .type = payload_type, .payload = std::move(payload)}; } struct Ipc::ipc_response Ipc::send(int fd, uint32_t type, const std::string& payload) { std::string header; header.resize(ipc_header_size_); - auto data32 = reinterpret_cast(header.data() + ipc_magic_.size()); - memcpy(header.data(), ipc_magic_.c_str(), ipc_magic_.size()); - data32[0] = payload.size(); - data32[1] = type; + memcpy(header.data(), ipc_magic_.data(), ipc_magic_.size()); + if (payload.size() > std::numeric_limits::max()) { + throw std::runtime_error("IPC payload is too large"); + } + const auto payload_size = static_cast(payload.size()); + memcpy(header.data() + ipc_magic_.size(), &payload_size, sizeof payload_size); + memcpy(header.data() + ipc_magic_.size() + sizeof payload_size, &type, sizeof type); + + sendAll(fd, header.data(), ipc_header_size_, "Unable to send IPC header"); + sendAll(fd, payload.data(), payload.size(), "Unable to send IPC payload"); - if (::send(fd, header.data(), ipc_header_size_, 0) == -1) { - throw std::runtime_error("Unable to send IPC header"); - } - if (::send(fd, payload.c_str(), payload.size(), 0) == -1) { - throw std::runtime_error("Unable to send IPC payload"); - } return Ipc::recv(fd); } @@ -140,14 +193,58 @@ void Ipc::sendCmd(uint32_t type, const std::string& payload) { void Ipc::subscribe(const std::string& payload) { auto res = Ipc::send(fd_event_, IPC_SUBSCRIBE, payload); + // Events subscribed to by a previous subscribe() call may arrive on the + // socket before the reply to this one; deliver them and keep reading until + // the subscribe reply is found. + while ((res.type >> 31) != 0U) { + signal_event.emit(res); + res = Ipc::recv(fd_event_); + } if (res.payload != "{\"success\": true}") { throw std::runtime_error("Unable to subscribe ipc event"); } + // Remember the subscription so we can replay it if we have to reconnect. + subscribed_events_.push_back(payload); +} + +void Ipc::reconnectEvent() { + // Sway closed our event connection (typically because its send buffer filled + // up during an event flood). Re-establish the socket and re-subscribe to the + // same events, backing off between attempts so we don't busy-loop and peg a + // CPU while sway is unavailable or keeps dropping us. + while (running_) { + std::this_thread::sleep_for(std::chrono::seconds(2)); + if (!running_) { + return; + } + try { + fd_event_.reset(open(socketPath_)); + for (const auto& payload : subscribed_events_) { + const auto res = Ipc::send(fd_event_, IPC_SUBSCRIBE, payload); + if (res.payload != "{\"success\": true}") { + throw std::runtime_error("Unable to re-subscribe ipc event"); + } + } + spdlog::info("Reconnected to sway IPC event socket"); + return; + } catch (const std::exception& e) { + spdlog::warn("Failed to reconnect to sway IPC ({}), retrying", e.what()); + } + } } void Ipc::handleEvent() { - const auto res = Ipc::recv(fd_event_); - signal_event.emit(res); + try { + const auto res = Ipc::recv(fd_event_); + signal_event.emit(res); + } catch (const std::exception& e) { + if (!running_) { + // The Ipc is being torn down; the socket was closed on purpose. + return; + } + spdlog::warn("Lost sway IPC event connection ({}), reconnecting", e.what()); + reconnectEvent(); + } } } // namespace waybar::modules::sway diff --git a/src/modules/sway/language.cpp b/src/modules/sway/language.cpp index 2e1103d3..755c27df 100644 --- a/src/modules/sway/language.cpp +++ b/src/modules/sway/language.cpp @@ -42,7 +42,6 @@ Language::Language(const std::string& id, const Json::Value& config) spdlog::error("Language: {}", e.what()); } }); - dp.emit(); } void Language::onCmd(const struct Ipc::ipc_response& res) { @@ -54,21 +53,28 @@ void Language::onCmd(const struct Ipc::ipc_response& res) { std::lock_guard lock(mutex_); auto payload = parser_.parse(res.payload); std::vector used_layouts; - // Display current layout of a device with a maximum count of layouts, expecting that all will - // be OK + // Union layout names across every keyboard input so hot-plugged devices contribute their + // layouts to the map. Track the device with the most layouts to seed the initially displayed + // layout, matching the previous behaviour at startup. Json::ArrayIndex max_id = 0, max = 0; for (Json::ArrayIndex i = 0; i < payload.size(); i++) { - auto size = payload[i][XKB_LAYOUT_NAMES_KEY].size(); - if (size > max) { - max = size; + if (payload[i]["type"].asString() != "keyboard") continue; + const auto& names = payload[i][XKB_LAYOUT_NAMES_KEY]; + if (names.size() > max) { + max = names.size(); max_id = i; } + for (const auto& layout : names) { + const auto name = layout.asString(); + if (std::find(used_layouts.begin(), used_layouts.end(), name) == used_layouts.end()) { + used_layouts.push_back(name); + } + } } - for (const auto& layout : payload[max_id][XKB_LAYOUT_NAMES_KEY]) { - used_layouts.push_back(layout.asString()); - } - + // Rebuild from scratch so init_layouts_map's duplicate-suffix pass doesn't compound across + // refreshes (e.g. "us" -> "us1" -> "us11"). + layouts_map_.clear(); init_layouts_map(used_layouts); set_current_layout(payload[max_id][XKB_ACTIVE_LAYOUT_NAME_KEY].asString()); dp.emit(); @@ -82,13 +88,26 @@ void Language::onEvent(const struct Ipc::ipc_response& res) { return; } + bool refresh_inputs = false; try { - std::lock_guard lock(mutex_); - auto payload = parser_.parse(res.payload)["input"]; - if (payload["type"].asString() == "keyboard") { - set_current_layout(payload[XKB_ACTIVE_LAYOUT_NAME_KEY].asString()); + { + std::lock_guard lock(mutex_); + auto root = parser_.parse(res.payload); + auto change = root["change"].asString(); + auto payload = root["input"]; + if (payload["type"].asString() == "keyboard") { + // A device was added or its keymap changed - the layout set may have grown, so refresh + // layouts_map_ via IPC_GET_INPUTS once we've released mutex_. + refresh_inputs = (change == "added" || change == "xkb_keymap"); + set_current_layout(payload[XKB_ACTIVE_LAYOUT_NAME_KEY].asString()); + } + dp.emit(); + } + // sendCmd is synchronous: it blocks on the IPC reply and then emits signal_cmd on this same + // thread, which lands in onCmd and re-locks mutex_. Must call it with mutex_ released. + if (refresh_inputs) { + ipc_.sendCmd(IPC_GET_INPUTS); } - dp.emit(); } catch (const std::exception& e) { spdlog::error("Language: {}", e.what()); } @@ -96,6 +115,17 @@ void Language::onEvent(const struct Ipc::ipc_response& res) { auto Language::update() -> void { std::lock_guard lock(mutex_); + // Apply the CSS class here, on the GTK main thread. set_current_layout() runs on the IPC worker + // thread, so mutating label_'s style context there would crash (#3702). + if (layout_.short_name != applied_class_) { + if (!applied_class_.empty()) { + label_.get_style_context()->remove_class(applied_class_); + } + if (!layout_.short_name.empty()) { + label_.get_style_context()->add_class(layout_.short_name); + } + applied_class_ = layout_.short_name; + } if (hide_single_ && layouts_map_.size() <= 1) { event_box_.hide(); return; @@ -104,7 +134,7 @@ auto Language::update() -> void { fmt::runtime(format_), fmt::arg("short", layout_.short_name), fmt::arg("shortDescription", layout_.short_description), fmt::arg("long", layout_.full_name), fmt::arg("variant", layout_.variant), fmt::arg("flag", layout_.country_flag()))); - label_.set_markup(display_layout); + setLabelMarkup(display_layout); if (tooltipEnabled()) { if (tooltip_format_ != "") { auto tooltip_display_layout = trim( @@ -112,9 +142,9 @@ auto Language::update() -> void { fmt::arg("shortDescription", layout_.short_description), fmt::arg("long", layout_.full_name), fmt::arg("variant", layout_.variant), fmt::arg("flag", layout_.country_flag()))); - label_.set_tooltip_markup(tooltip_display_layout); + setTooltipMarkup(tooltip_display_layout); } else { - label_.set_tooltip_markup(display_layout); + setTooltipMarkup(display_layout); } } @@ -125,9 +155,18 @@ auto Language::update() -> void { } auto Language::set_current_layout(const std::string& current_layout) -> void { - label_.get_style_context()->remove_class(layout_.short_name); - layout_ = layouts_map_[current_layout]; - label_.get_style_context()->add_class(layout_.short_name); + // Runs on the IPC worker thread (via onEvent) as well as the main thread (via onCmd), so it must + // not touch GTK widgets - off-main-thread widget mutation caused SIGSEGV (#3702). Only record the + // target layout here; update() applies the matching CSS class on the main thread. + // + // Guard against unknown / empty layout names: transient virtual keyboards (e.g. wtype) and + // hot-plugged devices whose layouts haven't made it into the map yet would otherwise blank out + // layout_ via map::operator[]'s default-construct-on-miss. + auto it = layouts_map_.find(current_layout); + if (it == layouts_map_.end()) { + return; + } + layout_ = it->second; } auto Language::init_layouts_map(const std::vector& used_layouts) -> void { diff --git a/src/modules/sway/mode.cpp b/src/modules/sway/mode.cpp index 1f5f071a..cb351b65 100644 --- a/src/modules/sway/mode.cpp +++ b/src/modules/sway/mode.cpp @@ -16,7 +16,6 @@ Mode::Mode(const std::string& id, const Json::Value& config) spdlog::error("Mode: {}", e.what()); } }); - dp.emit(); } void Mode::onEvent(const struct Ipc::ipc_response& res) { diff --git a/src/modules/sway/scratchpad.cpp b/src/modules/sway/scratchpad.cpp index 17dc2707..94f35999 100644 --- a/src/modules/sway/scratchpad.cpp +++ b/src/modules/sway/scratchpad.cpp @@ -31,12 +31,12 @@ Scratchpad::Scratchpad(const std::string& id, const Json::Value& config) auto Scratchpad::update() -> void { if (count_ || show_empty_) { event_box_.show(); - label_.set_markup( + setLabelMarkup( fmt::format(fmt::runtime(format_), fmt::arg("icon", getIcon(count_, "", config_["format-icons"].size())), fmt::arg("count", count_))); if (tooltip_enabled_) { - label_.set_tooltip_markup(tooltip_text_); + setTooltipMarkup(tooltip_text_); } } else { event_box_.hide(); diff --git a/src/modules/sway/window.cpp b/src/modules/sway/window.cpp index 2908b85c..4faa7d77 100644 --- a/src/modules/sway/window.cpp +++ b/src/modules/sway/window.cpp @@ -43,7 +43,9 @@ void Window::onCmd(const struct Ipc::ipc_response& res) { auto output = payload["output"].isString() ? payload["output"].asString() : ""; std::tie(app_nb_, floating_count_, windowId_, window_, app_id_, app_class_, shell_, layout_, marks_) = getFocusedNode(payload["nodes"], output); - updateAppIconName(app_id_, app_class_); + // Do not resolve the app icon here: onCmd runs on the sway IPC worker thread and + // updateAppIconName() touches the global Gtk::IconTheme cache, which is not thread-safe. + // The icon is resolved in update() on the main thread instead (triggered by dp.emit()). dp.emit(); } catch (const std::exception& e) { spdlog::error("Window: {}", e.what()); @@ -94,14 +96,17 @@ auto Window::update() -> void { old_app_id_ = app_id_; } - label_.set_markup(waybar::util::rewriteString( + setLabelMarkup(waybar::util::rewriteString( fmt::format(fmt::runtime(format_), fmt::arg("title", window_), fmt::arg("app_id", app_id_), fmt::arg("shell", shell_), fmt::arg("marks", marks_)), config_["rewrite"])); if (tooltipEnabled()) { - label_.set_tooltip_markup(window_); + setTooltipMarkup(Glib::Markup::escape_text(window_)); } + // Resolve the app icon on the main thread to avoid racing with GTK draw on the + // global Gtk::IconTheme cache (see onCmd). + updateAppIconName(app_id_, app_class_); updateAppIcon(); // Call parent update diff --git a/src/modules/sway/workspaces.cpp b/src/modules/sway/workspaces.cpp index 6abe5383..86902ef5 100644 --- a/src/modules/sway/workspaces.cpp +++ b/src/modules/sway/workspaces.cpp @@ -50,6 +50,21 @@ Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& high_priority_named_.push_back(it.asString()); } } + if (config_["custom-sort"].isArray()) { + uint16_t priority = 0; + for (const auto& entry : config_["custom-sort"]) { + if (!entry.isString()) { + continue; + } + auto const name = entry.asString(); + custom_sort_priorities_.insert_or_assign(name, priority); + auto const trimmed = trimWorkspaceName(name); + if (trimmed != name) { + custom_sort_priorities_.insert_or_assign(trimmed, priority); + } + ++priority; + } + } box_.set_name("workspaces"); if (!id.empty()) { box_.get_style_context()->add_class(id); @@ -69,6 +84,7 @@ Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& m_windowRewriteRules = waybar::util::RegexCollection( windowRewrite, std::move(windowRewriteDefault), windowRewritePriorityFunction); } + populateIgnoreWorkspacesConfig(config); ipc_.subscribe(R"(["workspace"])"); ipc_.subscribe(R"(["window"])"); ipc_.signal_event.connect(sigc::mem_fun(*this, &Workspaces::onEvent)); @@ -97,6 +113,36 @@ void Workspaces::onEvent(const struct Ipc::ipc_response& res) { } } +auto Workspaces::populateIgnoreWorkspacesConfig(const Json::Value& config) -> void { + auto ignoreWorkspaces = config["ignore-workspaces"]; + if (ignoreWorkspaces.isArray()) { + for (const auto& workspaceRegex : ignoreWorkspaces) { + if (workspaceRegex.isString()) { + std::string ruleString = workspaceRegex.asString(); + try { + const std::regex rule{ruleString, std::regex_constants::icase}; + m_ignoreWorkspaces.emplace_back(rule); + } catch (const std::regex_error& e) { + spdlog::error("Invalid rule {}: {}", ruleString, e.what()); + } + } else { + spdlog::error("Not a string: '{}'", workspaceRegex); + } + } + } +} + +bool Workspaces::isWorkspaceIgnored(std::string const& name) { + for (auto& rule : m_ignoreWorkspaces) { + if (std::regex_match(name, rule)) { + return true; + break; + } + } + + return false; +} + void Workspaces::onCmd(const struct Ipc::ipc_response& res) { if (res.type == IPC_GET_TREE) { try { @@ -118,8 +164,9 @@ void Workspaces::onCmd(const struct Ipc::ipc_response& res) { }); for (auto& output : outputs) { - std::copy(output["nodes"].begin(), output["nodes"].end(), - std::back_inserter(workspaces_)); + std::copy_if( + output["nodes"].begin(), output["nodes"].end(), std::back_inserter(workspaces_), + [&](const auto& node) { return !(isWorkspaceIgnored(node["name"].asString())); }); std::copy(output["floating_nodes"].begin(), output["floating_nodes"].end(), std::back_inserter(workspaces_)); } @@ -143,7 +190,8 @@ void Workspaces::onCmd(const struct Ipc::ipc_response& res) { if (p_w.isArray() && !p_w.empty()) { // Adding to target outputs for (const Json::Value& output : p_w) { - if (output.asString() == bar_.output->name) { + auto output_name = output.asString(); + if (output_name == bar_.output->name || output_name == bar_.output->identifier) { Json::Value v; v["name"] = p_w_name; v["target_output"] = bar_.output->name; @@ -203,6 +251,20 @@ void Workspaces::onCmd(const struct Ipc::ipc_response& res) { int l = lhs["sort"].asInt(); int r = rhs["sort"].asInt(); + if (!custom_sort_priorities_.empty()) { + auto const lcustom = getCustomSortIndex(lname); + auto const rcustom = getCustomSortIndex(rname); + if (lcustom && rcustom) { + if (*lcustom != *rcustom) { + return *lcustom < *rcustom; + } + } else if (lcustom) { + return true; + } else if (rcustom) { + return false; + } + } + if (l == r || config_["alphabetical_sort"].asBool()) { // In case both integers are the same, lexicographical // sort. The code above already ensure that this will only @@ -229,7 +291,10 @@ bool Workspaces::filterButtons() { auto ws = std::find_if(workspaces_.begin(), workspaces_.end(), [it](const auto& node) { return node["name"].asString() == it->first; }); if (ws == workspaces_.end() || - (!config_["all-outputs"].asBool() && (*ws)["output"].asString() != bar_.output->name)) { + ((*ws).isMember("target_output") ? (*ws)["target_output"].asString() != bar_.output->name && + (*ws)["target_output"].asString() != "" + : !config_["all-outputs"].asBool() && + (*ws)["output"].asString() != bar_.output->name)) { it = buttons_.erase(it); needReorder = true; } else { @@ -321,6 +386,18 @@ auto Workspaces::update() -> void { button.get_style_context()->remove_class("empty"); } if ((*it)["output"].isString()) { + // Simply attempt to remove all output classes every time to reset output classes. This works + // even if a class has not been previously added to the style context. + for (const auto& oclass : config_["output-classes"]) { + button.get_style_context()->remove_class(oclass.asString()); + } + // If output-classes contains a class for output associated with current workspace button, add + // the class to its style context. + std::string output_name = (*it)["output"].asString(); + if (config_["output-classes"].isMember(output_name) && + config_["output-classes"][output_name].isString()) { + button.get_style_context()->add_class(config_["output-classes"][output_name].asString()); + } if (((*it)["output"].asString()) == bar_.output->name) { button.get_style_context()->add_class("current_output"); } else { @@ -329,24 +406,44 @@ auto Workspaces::update() -> void { } else { button.get_style_context()->remove_class("current_output"); } - std::string output = (*it)["name"].asString(); + std::string full_name; + if (!config_["disable-markup"].asBool()) { + full_name = g_markup_escape_text((*it)["name"].asString().c_str(), -1); + } else { + full_name = (*it)["name"].asString(); + } + std::string windows = ""; if (config_["window-rewrite"].isObject()) { updateWindows((*it), windows); } + + auto index = (*it)["num"].asInt(); + if (config_["format"].isString()) { - auto format = config_["format"].asString(); - output = fmt::format( - fmt::runtime(format), fmt::arg("icon", getIcon(output, *it)), fmt::arg("value", output), - fmt::arg("name", trimWorkspaceName(output)), fmt::arg("index", (*it)["num"].asString()), - fmt::arg("windows", - windows.substr(0, windows.length() - m_formatWindowSeparator.length())), - fmt::arg("output", (*it)["output"].asString())); + std::string format; + if (config_["format-for-negative-index"].isString() && index < 0) { + format = config_["format-for-negative-index"].asString(); + } else { + format = config_["format"].asString(); + } + + auto name = trimWorkspaceName(full_name); + auto output = (*it)["output"].asString(); + auto icon = getIcon(full_name, *it); + auto separated_windows = + windows.substr(0, windows.length() - m_formatWindowSeparator.length()); + + full_name = + fmt::format(fmt::runtime(format), fmt::arg("index", index), fmt::arg("name", name), + fmt::arg("value", full_name), fmt::arg("output", output), + fmt::arg("icon", icon), fmt::arg("windows", separated_windows)); } + if (!config_["disable-markup"].asBool()) { - static_cast(button.get_children()[0])->set_markup(output); + static_cast(button.get_children()[0])->set_markup(full_name); } else { - button.set_label(output); + button.set_label(full_name); } onButtonReady(*it, button); } @@ -369,11 +466,20 @@ Gtk::Button& Workspaces::addButton(const Json::Value& node) { node["name"].asString(), node["target_output"].asString(), "--no-auto-back-and-forth", node["name"].asString())); } else { - ipc_.sendCmd(IPC_COMMAND, fmt::format("workspace {} \"{}\"", - config_["disable-auto-back-and-forth"].asBool() - ? "--no-auto-back-and-forth" - : "", - node["name"].asString())); + std::string flag = + config_["disable-auto-back-and-forth"].asBool() ? "--no-auto-back-and-forth" : ""; + if (config_["no-switch-output"].asBool()) { + ipc_.sendCmd(IPC_COMMAND, + fmt::format("[workspace=\"^{}$\"] move workspace to output current; " + "workspace number {} \"{}\"", + node["name"].asString(), flag, node["name"].asString())); + } else if (node["num"].asInt() >= 0) { + ipc_.sendCmd(IPC_COMMAND, + fmt::format(workspace_switch_number_cmd_, flag, node["num"].asInt())); + } else { + ipc_.sendCmd(IPC_COMMAND, + fmt::format(workspace_switch_cmd_, flag, node["name"].asString())); + } } } catch (const std::exception& e) { spdlog::error("Workspaces: {}", e.what()); @@ -495,6 +601,24 @@ std::string Workspaces::trimWorkspaceName(const std::string& name) { return name; } +std::optional Workspaces::getCustomSortIndex(const std::string& name) const { + if (custom_sort_priorities_.empty()) { + return std::nullopt; + } + auto it = custom_sort_priorities_.find(name); + if (it != custom_sort_priorities_.end()) { + return it->second; + } + auto trimmed = trimWorkspaceName(name); + if (trimmed != name) { + auto trimmed_it = custom_sort_priorities_.find(trimmed); + if (trimmed_it != custom_sort_priorities_.end()) { + return trimmed_it->second; + } + } + return std::nullopt; +} + bool is_focused_recursive(const Json::Value& node) { // If a workspace has a focused container then get_tree will say // that the workspace itself isn't focused. Therefore we need to diff --git a/src/modules/systemd_failed_units.cpp b/src/modules/systemd_failed_units.cpp index 8d7e866f..75f1b233 100644 --- a/src/modules/systemd_failed_units.cpp +++ b/src/modules/systemd_failed_units.cpp @@ -84,9 +84,10 @@ SystemdFailedUnits::SystemdFailedUnits(const std::string& id, const Json::Value& } } + if (!user_props_proxy_ && !system_props_proxy_) + throw std::runtime_error("Neither system nor user status is requested."); + updateData(); - /* Always update for the first time. */ - dp.emit(); } auto SystemdFailedUnits::notify_cb(const Glib::ustring& sender_name, @@ -103,7 +104,7 @@ auto SystemdFailedUnits::notify_cb(const Glib::ustring& sender_name, void SystemdFailedUnits::RequestSystemState() { auto load = [](const char* kind, Glib::RefPtr& proxy) -> std::string { try { - if (!proxy) return "unknown"; + if (!proxy) return "ignored"; auto parameters = Glib::VariantContainerBase( g_variant_new("(ss)", "org.freedesktop.systemd1.Manager", "SystemState")); Glib::VariantContainerBase data = proxy->call_sync("Get", parameters); @@ -122,7 +123,9 @@ void SystemdFailedUnits::RequestSystemState() { system_state_ = load("systemwide", system_props_proxy_); user_state_ = load("user", user_props_proxy_); - if (system_state_ == "running" && user_state_ == "running") + + if ((system_state_ == "running" || system_state_ == "ignored") && + (user_state_ == "running" || user_state_ == "ignored")) overall_state_ = "ok"; else overall_state_ = "degraded"; @@ -276,23 +279,44 @@ auto SystemdFailedUnits::update() -> void { last_status_ = overall_state_; - label_.set_markup(fmt::format( - fmt::runtime(nr_failed_ == 0 ? format_ok_ : format_), fmt::arg("nr_failed", nr_failed_), - fmt::arg("nr_failed_system", nr_failed_system_), fmt::arg("nr_failed_user", nr_failed_user_), - fmt::arg("system_state", system_state_), fmt::arg("user_state", user_state_), - fmt::arg("overall_state", overall_state_))); + // A malformed user format/tooltip-format (e.g. an unknown {placeholder}) makes fmt throw a + // fmt::format_error; catch it so a bad config warns once instead of taking update() down. + try { + setLabelMarkup(fmt::format( + fmt::runtime(nr_failed_ == 0 ? format_ok_ : format_), fmt::arg("nr_failed", nr_failed_), + fmt::arg("nr_failed_system", nr_failed_system_), + fmt::arg("nr_failed_user", nr_failed_user_), fmt::arg("system_state", system_state_), + fmt::arg("user_state", user_state_), fmt::arg("overall_state", overall_state_))); + } catch (const std::exception& e) { + static bool labelWarned = false; + if (!labelWarned) { + spdlog::warn("systemd-failed-units: invalid format, using fallback: {}", e.what()); + labelWarned = true; + } + setLabelMarkup(fmt::format("{} failed", nr_failed_)); + } if (tooltipEnabled()) { std::string failed_list = BuildTooltipFailedList(); auto tooltip_template = overall_state_ == "ok" ? tooltip_format_ok_ : tooltip_format_; if (!tooltip_template.empty()) { - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_template), fmt::arg("nr_failed", nr_failed_), - fmt::arg("nr_failed_system", nr_failed_system_), - fmt::arg("nr_failed_user", nr_failed_user_), fmt::arg("system_state", system_state_), - fmt::arg("user_state", user_state_), fmt::arg("overall_state", overall_state_), - fmt::arg("failed_units_list", failed_list))); + try { + setTooltipMarkup(fmt::format( + fmt::runtime(tooltip_template), fmt::arg("nr_failed", nr_failed_), + fmt::arg("nr_failed_system", nr_failed_system_), + fmt::arg("nr_failed_user", nr_failed_user_), fmt::arg("system_state", system_state_), + fmt::arg("user_state", user_state_), fmt::arg("overall_state", overall_state_), + fmt::arg("failed_units_list", failed_list))); + } catch (const std::exception& e) { + static bool tooltipWarned = false; + if (!tooltipWarned) { + spdlog::warn("systemd-failed-units: invalid tooltip-format, skipping tooltip: {}", + e.what()); + tooltipWarned = true; + } + setTooltipMarkup(""); + } } else { - label_.set_tooltip_text(""); + setTooltipMarkup(""); } } ALabel::update(); diff --git a/src/modules/temperature.cpp b/src/modules/temperature.cpp index bb5ff02c..4b410cd3 100644 --- a/src/modules/temperature.cpp +++ b/src/modules/temperature.cpp @@ -1,6 +1,8 @@ #include "modules/temperature.hpp" #include +#include +#include #include #if defined(__FreeBSD__) @@ -20,12 +22,59 @@ waybar::modules::Temperature::Temperature(const std::string& id, const Json::Val if (check_set_path(item.asString())) break; }; - // if hwmon_path is an array, loop to find first valid item - traverseAsArray(config_["hwmon-path"], [this](const std::string& path) { - if (!std::filesystem::exists(path)) return false; - file_path_ = path; - return true; - }); + if (config_["hwmon-by-name"].isString() && config_["input-filename"].isString()) { + auto name = config_["hwmon-by-name"].asString(); + auto input_filename = config_["input-filename"].asString(); + for (const auto& entry : std::filesystem::directory_iterator("/sys/class/hwmon/")) { + if (std::filesystem::is_directory(entry) && file_path_.empty()) { + auto name_filepath = entry.path().string() + "/name"; + auto input_filepath = entry.path().string() + "/" + input_filename; + + if (std::filesystem::exists(name_filepath) && std::filesystem::exists(input_filepath)) { + std::ifstream name_file(name_filepath); + if (!name_file.is_open()) { + throw std::runtime_error("Error: Could not open file " + name_filepath); + } + + std::string line; + while (std::getline(name_file, line)) { + if (line.find(name) != std::string::npos) { + file_path_ = input_filepath; + break; + } + } + } + } + } + if (file_path_.empty()) throw std::runtime_error("Could not find hwmon by name " + name); + } + + auto find_hwmon_by_name = [](const std::string& name) -> std::optional { + for (const auto& entry : std::filesystem::directory_iterator("/sys/class/hwmon")) { + std::ifstream f(entry.path() / "name"); + std::string hwname; + if (f >> hwname && hwname == name) { + return entry.path(); + } + } + return std::nullopt; + }; + + // ensure either hwmon-name OR old paths are used, not both + if (config_["hwmon-name"].isString() && + (!config_["hwmon-path"].isNull() || !config_["hwmon-path-abs"].isNull())) { + throw std::runtime_error( + "hwmon-name cannot be used together with hwmon-path or hwmon-path-abs"); + } + + if (file_path_.empty()) { + // if hwmon_path is an array, loop to find first valid item + traverseAsArray(config_["hwmon-path"], [this](const std::string& path) { + if (!std::filesystem::exists(path)) return false; + file_path_ = path; + return true; + }); + } if (file_path_.empty() && config_["input-filename"].isString()) { // fallback to hwmon_paths-abs @@ -40,6 +89,20 @@ waybar::modules::Temperature::Temperature(const std::string& id, const Json::Val }); } + if (file_path_.empty() && config_["hwmon-name"].isString()) { + if (!config_["input-filename"].isString()) { + throw std::runtime_error("hwmon-name requires input-filename to be set"); + } + + auto hwmon = find_hwmon_by_name(config_["hwmon-name"].asString()); + + if (!hwmon) { + throw std::runtime_error("hwmon-name '" + config_["hwmon-name"].asString() + "' not found"); + } + + file_path_ = hwmon->string() + "/" + config_["input-filename"].asString(); + } + if (file_path_.empty()) { auto zone = config_["thermal-zone"].isInt() ? config_["thermal-zone"].asInt() : 0; file_path_ = fmt::format("/sys/class/thermal/thermal_zone{}/temp", zone); @@ -92,19 +155,10 @@ auto waybar::modules::Temperature::update() -> void { event_box_.show(); auto max_temp = config_["critical-threshold"].isInt() ? config_["critical-threshold"].asInt() : 0; - label_.set_markup(fmt::format(fmt::runtime(format), fmt::arg("temperatureC", temperature_c), - fmt::arg("temperatureF", temperature_f), - fmt::arg("temperatureK", temperature_k), - fmt::arg("icon", getIcon(temperature_c, "", max_temp)))); - if (tooltipEnabled()) { - std::string tooltip_format = "{temperatureC}°C"; - if (config_["tooltip-format"].isString()) { - tooltip_format = config_["tooltip-format"].asString(); - } - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltip_format), fmt::arg("temperatureC", temperature_c), - fmt::arg("temperatureF", temperature_f), fmt::arg("temperatureK", temperature_k))); - } + updateLabelAndTooltip(format, "{temperatureC}°C", fmt::arg("temperatureC", temperature_c), + fmt::arg("temperatureF", temperature_f), + fmt::arg("temperatureK", temperature_k), + fmt::arg("icon", getIcon(temperature_c, "", max_temp))); // Call parent update ALabel::update(); } @@ -155,3 +209,7 @@ bool waybar::modules::Temperature::isCritical(uint16_t temperature_c) { return config_["critical-threshold"].isInt() && temperature_c >= config_["critical-threshold"].asInt(); } + +void waybar::modules::Temperature::suspend() { thread_.pause(); } + +void waybar::modules::Temperature::resume() { thread_.resume(); } diff --git a/src/modules/upower.cpp b/src/modules/upower.cpp index d260ba83..f027a752 100644 --- a/src/modules/upower.cpp +++ b/src/modules/upower.cpp @@ -79,8 +79,6 @@ UPower::UPower(const std::string& id, const Json::Value& config) resetDevices(); setDisplayDevice(); - // Update the widget - dp.emit(); } UPower::~UPower() { @@ -223,7 +221,7 @@ auto UPower::update() -> void { return; } - label_.set_markup(getText(upDevice_, format_)); + setLabelMarkup(getText(upDevice_, format_)); // Set icon if (upDevice_.icon_name == NULL || !gtkTheme_->has_icon(upDevice_.icon_name)) upDevice_.icon_name = (char*)NO_BATTERY.c_str(); diff --git a/src/modules/user.cpp b/src/modules/user.cpp index 3ca5e431..87f5ea0f 100644 --- a/src/modules/user.cpp +++ b/src/modules/user.cpp @@ -7,7 +7,6 @@ #include #include -#include "gdkmm/cursor.h" #include "gdkmm/event.h" #include "gdkmm/types.h" #include "glibmm/fileutils.h" diff --git a/src/modules/wayfire/backend.cpp b/src/modules/wayfire/backend.cpp index 42976d20..b281b0f5 100644 --- a/src/modules/wayfire/backend.cpp +++ b/src/modules/wayfire/backend.cpp @@ -168,25 +168,29 @@ auto IPC::start() -> void { send("window-rules/get-focused-output", {}); std::thread([self = shared_from_this()] { - auto sock = connect(); + try { + auto sock = connect(); - { - Json::Value json; - json["method"] = "window-rules/events/watch"; + { + Json::Value json; + json["method"] = "window-rules/events/watch"; - pack_and_write(sock, Json::writeString(self->writer_builder, json)); - if (self->receive(sock)["result"] != "ok") { - spdlog::error( - "Wayfire IPC: method \"window-rules/events/watch\"" - " have failed"); - return; + pack_and_write(sock, Json::writeString(self->writer_builder, json)); + if (self->receive(sock)["result"] != "ok") { + spdlog::error( + "Wayfire IPC: method \"window-rules/events/watch\"" + " have failed"); + return; + } } - } - while (auto json = self->receive(sock)) { - auto ev = json["event"].asString(); - spdlog::debug("Wayfire IPC: received event \"{}\"", ev); - self->root_event_handler(ev, json); + while (auto json = self->receive(sock)) { + auto ev = json["event"].asString(); + 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(); } @@ -347,10 +351,13 @@ auto IPC::update_state_handler(const std::string& event, const Json::Value& data if (event == "output-wset-changed") { // data: { event, new-wset: wset.name, output: id, new-wset-data: wset, output-data: output } - auto& output = state.outputs.at(data["output-data"]["name"].asString()); - auto wset_idx = data["new-wset-data"]["index"].asUInt(); - state.wsets.at(wset_idx).output = output; - output.wset_idx = wset_idx; + try { + auto& output = state.outputs.at(data["output-data"]["name"].asString()); + auto wset_idx = data["new-wset-data"]["index"].asUInt(); + state.wsets.at(wset_idx).output = output; + output.wset_idx = wset_idx; + } catch (const std::exception&) { + } return; } diff --git a/src/modules/wayfire/window.cpp b/src/modules/wayfire/window.cpp index 8634f090..f40554b0 100644 --- a/src/modules/wayfire/window.cpp +++ b/src/modules/wayfire/window.cpp @@ -20,8 +20,6 @@ Window::Window(const std::string& id, const Bar& bar, const Json::Value& config) ipc->register_handler("view-app-id-changed", handler); ipc->register_handler("window-rules/get-focused-view", handler); - - dp.emit(); } Window::~Window() { ipc->unregister_handler(handler); } diff --git a/src/modules/wayfire/workspaces.cpp b/src/modules/wayfire/workspaces.cpp index 724a19f8..6e39e3a7 100644 --- a/src/modules/wayfire/workspaces.cpp +++ b/src/modules/wayfire/workspaces.cpp @@ -44,9 +44,6 @@ Workspaces::Workspaces(const std::string& id, const Bar& bar, const Json::Value& ipc->register_handler("window-rules/list-outputs", handler); ipc->register_handler("window-rules/list-wsets", handler); ipc->register_handler("window-rules/get-focused-output", handler); - - // initial render - dp.emit(); } Workspaces::~Workspaces() { ipc->unregister_handler(handler); } diff --git a/src/modules/wireplumber.cpp b/src/modules/wireplumber.cpp index 0c0d1dde..ddcdda6e 100644 --- a/src/modules/wireplumber.cpp +++ b/src/modules/wireplumber.cpp @@ -2,10 +2,42 @@ #include +#include +#include +#include +#include + bool isValidNodeId(uint32_t id) { return id > 0 && id < G_MAXUINT32; } std::list waybar::modules::Wireplumber::modules; +// Interval between reconnect attempts after PipeWire/WirePlumber goes away. Fixed (rather than +// growing) so the module recovers promptly whenever the service comes back, while still being +// a bounded, main-loop-friendly poll rather than a busy loop. +static constexpr unsigned kReconnectIntervalMs = 2000; + +// Async load/activation callbacks (onDefaultNodesApiLoaded, onMixerApiLoaded, onPluginActivated) +// are handed a raw `self` pointer with no GCancellable, and WirePlumber has no way to withdraw an +// in-flight callback. If the module is destroyed before such a callback fires (e.g. an output/bar +// is removed while a component load is still pending, or during an audio route transition), the +// callback would dereference a freed `self`. The destructor removes `this` from this registry +// before any teardown, so a missing entry means `self` is dangling and the callback must bail out +// without touching it. See https://github.com/Alexays/Waybar/issues/3974. +bool waybar::modules::Wireplumber::isModuleAlive(waybar::modules::Wireplumber* self) { + return std::find(modules.begin(), modules.end(), self) != modules.end(); +} + +namespace { +// user_data for the async load/activate callbacks. Pairs the module with the connection generation +// the call was scheduled under so a completion belonging to a torn-down connection can be dropped +// (see Wireplumber::connection_generation_ and #2882). Heap-allocated per call; the callback takes +// ownership and frees it. +struct AsyncCall { + waybar::modules::Wireplumber* self; + uint32_t generation; +}; +} // namespace + waybar::modules::Wireplumber::Wireplumber(const std::string& id, const Json::Value& config) : ALabel(config, "wireplumber", id, "{volume}%"), wp_core_(nullptr), @@ -25,24 +57,53 @@ waybar::modules::Wireplumber::Wireplumber(const std::string& id, const Json::Val source_node_id_(0), source_muted_(false), source_volume_(0.0), - default_source_name_(nullptr) { + default_source_name_(nullptr), + only_physical_(false), + form_factor_("") { waybar::modules::Wireplumber::modules.push_back(this); wp_init(WP_INIT_PIPEWIRE); - wp_core_ = wp_core_new(nullptr, nullptr, nullptr); - apis_ = g_ptr_array_new_with_free_func(g_object_unref); - om_ = wp_object_manager_new(); type_ = g_strdup(config_["node-type"].isString() ? config_["node-type"].asString().c_str() : "Audio/Sink"); + only_physical_ = config_["only-physical"].isBool() ? config_["only-physical"].asBool() : false; + + // Wire the scroll handler once, here, rather than in onMixerApiLoaded: the latter now re-runs on + // every reconnect and would accumulate duplicate handlers. handleScroll no-ops while mixer_api_ + // is null, so wiring it before the first successful connect is safe. + event_box_.add_events(Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); + event_box_.signal_scroll_event().connect(sigc::mem_fun(*this, &Wireplumber::handleScroll)); + + if (!setupConnection()) { + spdlog::error("[{}]: Could not connect to PipeWire: '{}'", name_, type_); + throw std::runtime_error("Could not connect to PipeWire\n"); + } +} + +// Creates a fresh WpCore/object manager, connects to PipeWire and kicks off async API loading. +// Used both at startup and when reconnecting after a PipeWire/WirePlumber restart, so all +// connection-scoped state is (re)built here. Returns false if the connection could not be +// initiated. See https://github.com/Alexays/Waybar/issues/2882. +bool waybar::modules::Wireplumber::setupConnection() { + // New connection generation: any async load/activate callback still in flight from a previous + // connection will see a mismatched generation and bail out instead of mutating this one's state. + ++connection_generation_; + + wp_core_ = wp_core_new(nullptr, nullptr, nullptr); + apis_ = g_ptr_array_new_with_free_func(g_object_unref); + om_ = wp_object_manager_new(); + pending_plugins_ = 0; prepare(this); + // Recover when PipeWire/WirePlumber goes away (service restart, crash). The "disconnected" + // signal fires on the GTK main loop; from there we schedule a reconnect attempt. + g_signal_connect_swapped(wp_core_, "disconnected", (GCallback)onCoreDisconnected, this); + spdlog::debug("[{}]: connecting to pipewire: '{}'...", name_, type_); if (wp_core_connect(wp_core_) == 0) { - spdlog::error("[{}]: Could not connect to PipeWire: '{}'", name_, type_); - throw std::runtime_error("Could not connect to PipeWire\n"); + return false; } spdlog::debug("[{}]: {} connected!", name_, type_); @@ -50,10 +111,13 @@ waybar::modules::Wireplumber::Wireplumber(const std::string& id, const Json::Val g_signal_connect_swapped(om_, "installed", (GCallback)onObjectManagerInstalled, this); asyncLoadRequiredApiModules(); + return true; } -waybar::modules::Wireplumber::~Wireplumber() { - waybar::modules::Wireplumber::modules.remove(this); +// Disconnects signal handlers and releases all connection-scoped WirePlumber objects. Safe to call +// when already partially/fully torn down (every pointer is null-checked and cleared), so it doubles +// as the reconnect reset and the destructor's cleanup. +void waybar::modules::Wireplumber::teardownConnection() { if (mixer_api_ != nullptr) { g_signal_handlers_disconnect_by_data(mixer_api_, this); } @@ -63,12 +127,62 @@ waybar::modules::Wireplumber::~Wireplumber() { if (om_ != nullptr) { g_signal_handlers_disconnect_by_data(om_, this); } - wp_core_disconnect(wp_core_); + if (wp_core_ != nullptr) { + g_signal_handlers_disconnect_by_data(wp_core_, this); + wp_core_disconnect(wp_core_); + } g_clear_pointer(&apis_, g_ptr_array_unref); g_clear_object(&om_); - g_clear_object(&wp_core_); g_clear_object(&mixer_api_); g_clear_object(&def_nodes_api_); + g_clear_object(&wp_core_); + // onObjectManagerInstalled re-populates these via out-params (which don't free the previous + // value), so clear them here to avoid leaking the old strings across a reconnect. + g_clear_pointer(&default_node_name_, g_free); + g_clear_pointer(&default_source_name_, g_free); + pending_plugins_ = 0; +} + +// "disconnected" signal handler on wp_core_. Runs during the core's own signal emission, so it must +// not tear down the core here; it only schedules a reconnect, which performs the teardown/rebuild +// once control has returned to the main loop. +void waybar::modules::Wireplumber::onCoreDisconnected(waybar::modules::Wireplumber* self) { + if (!isModuleAlive(self)) { + return; + } + spdlog::warn("[{}]: PipeWire connection lost; will attempt to reconnect", self->name_); + self->scheduleReconnect(); +} + +void waybar::modules::Wireplumber::scheduleReconnect() { + if (reconnect_timer_.connected()) { + return; // a reconnect attempt is already pending + } + reconnect_timer_ = Glib::signal_timeout().connect( + sigc::mem_fun(*this, &Wireplumber::onReconnectTimeout), kReconnectIntervalMs); +} + +// Runs on the GTK main loop. Rebuilds the connection from scratch; returns true to keep retrying at +// the fixed interval until PipeWire is back, or false to stop once reconnected (a future +// "disconnected" signal will re-arm the timer if needed). +bool waybar::modules::Wireplumber::onReconnectTimeout() { + teardownConnection(); + spdlog::info("[{}]: attempting to reconnect to PipeWire...", name_); + if (setupConnection()) { + spdlog::info("[{}]: reconnected to PipeWire", name_); + return false; + } + teardownConnection(); + spdlog::debug("[{}]: reconnect failed; retrying in {} ms", name_, kReconnectIntervalMs); + return true; +} + +waybar::modules::Wireplumber::~Wireplumber() { + // Remove from the live-module registry first so any in-flight async callback bails out (#3974), + // then cancel a pending reconnect so onReconnectTimeout can't fire on a half-destroyed module. + waybar::modules::Wireplumber::modules.remove(this); + reconnect_timer_.disconnect(); + teardownConnection(); g_free(default_node_name_); g_free(default_source_name_); g_free(type_); @@ -108,6 +222,29 @@ void waybar::modules::Wireplumber::updateNodeName(waybar::modules::Wireplumber* : description != nullptr ? description : "Unknown node name"; spdlog::debug("[{}]: Updating '{}' node name to: {}", self->name_, self->type_, self->node_name_); + + // 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); + + g_autoptr(WpDevice) dev = static_cast( + wp_object_manager_lookup(self->om_, WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_G_PROPERTY, + "bound-id", "=s", devid, nullptr)); + + 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, @@ -167,7 +304,12 @@ void waybar::modules::Wireplumber::updateVolume(waybar::modules::Wireplumber* se g_variant_lookup(variant, "volume", "d", &self->volume_); g_variant_lookup(variant, "step", "d", &self->min_step_); - g_variant_lookup(variant, "mute", "b", &self->muted_); + // GVariant "b" writes a gboolean (4 bytes); reading directly into the 1-byte bool member is an + // out-of-bounds write. Read into a gboolean temporary and assign back. + gboolean mute = FALSE; + if (g_variant_lookup(variant, "mute", "b", &mute)) { + self->muted_ = mute; + } g_clear_pointer(&variant, g_variant_unref); self->dp.emit(); @@ -192,7 +334,11 @@ void waybar::modules::Wireplumber::updateSourceVolume(waybar::modules::Wireplumb } g_variant_lookup(variant, "volume", "d", &self->source_volume_); - g_variant_lookup(variant, "mute", "b", &self->source_muted_); + // See updateVolume: GVariant "b" writes a gboolean (4 bytes), not a 1-byte bool. + gboolean mute = FALSE; + if (g_variant_lookup(variant, "mute", "b", &mute)) { + self->source_muted_ = mute; + } g_clear_pointer(&variant, g_variant_unref); self->dp.emit(); @@ -211,6 +357,7 @@ void waybar::modules::Wireplumber::onMixerChanged(waybar::modules::Wireplumber* return; } } + return; } spdlog::warn("[{}]: (onMixerChanged: {}) - Object with id {} not found", self->name_, @@ -235,28 +382,36 @@ void waybar::modules::Wireplumber::onDefaultNodesApiChanged(waybar::modules::Wir spdlog::debug("[{}]: (onDefaultNodesApiChanged: {})", self->name_, self->type_); // Handle sink - uint32_t defaultNodeId; + uint32_t defaultNodeId = 0; g_signal_emit_by_name(self->def_nodes_api_, "get-default-node", self->type_, &defaultNodeId); if (isValidNodeId(defaultNodeId)) { + uint32_t effective_id = + self->only_physical_ ? self->resolvePhysicalSink(defaultNodeId) : defaultNodeId; + + if (self->only_physical_ && effective_id != defaultNodeId) { + spdlog::info("[{}]: only-physical enabled: using sink {} instead of default {}", self->name_, + effective_id, defaultNodeId); + } + g_autoptr(WpNode) node = static_cast( wp_object_manager_lookup(self->om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", - "=u", defaultNodeId, nullptr)); + "=u", effective_id, nullptr)); if (node != nullptr) { - const gchar* defaultNodeName = + const gchar* effectiveNodeName = wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(node), "node.name"); - if (g_strcmp0(self->default_node_name_, defaultNodeName) != 0 || - self->node_id_ != defaultNodeId) { - spdlog::debug("[{}]: Default sink changed to -> Node(name: {}, id: {})", self->name_, - defaultNodeName, defaultNodeId); + if (g_strcmp0(self->default_node_name_, effectiveNodeName) != 0 || + self->node_id_ != effective_id) { + spdlog::debug("[{}]: Default sink resolved to -> Node(name: {}, id: {})", self->name_, + effectiveNodeName, effective_id); g_free(self->default_node_name_); - self->default_node_name_ = g_strdup(defaultNodeName); - self->node_id_ = defaultNodeId; - updateVolume(self, defaultNodeId); - updateNodeName(self, defaultNodeId); + self->default_node_name_ = g_strdup(effectiveNodeName); + self->node_id_ = effective_id; + updateVolume(self, effective_id); + updateNodeName(self, effective_id); } } } @@ -309,7 +464,16 @@ void waybar::modules::Wireplumber::onObjectManagerInstalled(waybar::modules::Wir // Get default sink g_signal_emit_by_name(self->def_nodes_api_, "get-default-configured-node-name", self->type_, &self->default_node_name_); - g_signal_emit_by_name(self->def_nodes_api_, "get-default-node", self->type_, &self->node_id_); + uint32_t initial_sink_id = 0; + g_signal_emit_by_name(self->def_nodes_api_, "get-default-node", self->type_, &initial_sink_id); + + if (self->only_physical_ && isValidNodeId(initial_sink_id)) { + self->node_id_ = self->resolvePhysicalSink(initial_sink_id); + spdlog::info("[{}]: only-physical enabled: initial physical sink {} (default was {})", + self->name_, self->node_id_, initial_sink_id); + } else { + self->node_id_ = initial_sink_id; + } // Get default source g_signal_emit_by_name(self->def_nodes_api_, "get-default-configured-node-name", "Audio/Source", @@ -338,7 +502,13 @@ void waybar::modules::Wireplumber::onObjectManagerInstalled(waybar::modules::Wir } void waybar::modules::Wireplumber::onPluginActivated(WpObject* p, GAsyncResult* res, - waybar::modules::Wireplumber* self) { + gpointer data) { + std::unique_ptr call(static_cast(data)); + auto* self = call->self; + if (!isModuleAlive(self) || call->generation != self->connection_generation_) { + return; + } + const auto* pluginName = wp_plugin_get_name(WP_PLUGIN(p)); spdlog::debug("[{}]: onPluginActivated: {}", self->name_, pluginName); g_autoptr(GError) error = nullptr; @@ -359,20 +529,38 @@ void waybar::modules::Wireplumber::activatePlugins() { WpPlugin* plugin = static_cast(g_ptr_array_index(apis_, i)); pending_plugins_++; wp_object_activate(WP_OBJECT(plugin), WP_PLUGIN_FEATURE_ENABLED, nullptr, - (GAsyncReadyCallback)onPluginActivated, this); + (GAsyncReadyCallback)onPluginActivated, + new AsyncCall{this, connection_generation_}); } } void waybar::modules::Wireplumber::prepare(waybar::modules::Wireplumber* self) { spdlog::debug("[{}]: preparing object manager: '{}'", name_, self->type_); - wp_object_manager_add_interest(om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, "media.class", - "=s", self->type_, nullptr); - wp_object_manager_add_interest(om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, "media.class", - "=s", "Audio/Source", nullptr); + if (only_physical_) { + wp_object_manager_add_interest(om_, WP_TYPE_NODE, nullptr); + wp_object_manager_add_interest(om_, WP_TYPE_LINK, nullptr); + wp_object_manager_add_interest(om_, WP_TYPE_PORT, nullptr); + wp_object_manager_add_interest(om_, WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_PW_PROPERTY, + "media.class", "=s", "Audio/Device", nullptr); + wp_object_manager_request_object_features(om_, WP_TYPE_GLOBAL_PROXY, WP_OBJECT_FEATURES_ALL); + } else { + wp_object_manager_add_interest(om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, "media.class", + "=s", self->type_, nullptr); + wp_object_manager_add_interest(om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, "media.class", + "=s", "Audio/Source", nullptr); + wp_object_manager_add_interest(om_, WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_PW_PROPERTY, + "media.class", "=s", "Audio/Device", nullptr); + } } void waybar::modules::Wireplumber::onDefaultNodesApiLoaded(WpObject* p, GAsyncResult* res, - waybar::modules::Wireplumber* self) { + gpointer data) { + std::unique_ptr call(static_cast(data)); + auto* self = call->self; + if (!isModuleAlive(self) || call->generation != self->connection_generation_) { + return; + } + gboolean success = FALSE; g_autoptr(GError) error = nullptr; @@ -389,11 +577,17 @@ void waybar::modules::Wireplumber::onDefaultNodesApiLoaded(WpObject* p, GAsyncRe spdlog::debug("[{}]: loading mixer api module", self->name_); wp_core_load_component(self->wp_core_, "libwireplumber-module-mixer-api", "module", nullptr, - "mixer-api", nullptr, (GAsyncReadyCallback)onMixerApiLoaded, self); + "mixer-api", nullptr, (GAsyncReadyCallback)onMixerApiLoaded, + new AsyncCall{self, call->generation}); } -void waybar::modules::Wireplumber::onMixerApiLoaded(WpObject* p, GAsyncResult* res, - waybar::modules::Wireplumber* self) { +void waybar::modules::Wireplumber::onMixerApiLoaded(WpObject* p, GAsyncResult* res, gpointer data) { + std::unique_ptr call(static_cast(data)); + auto* self = call->self; + if (!isModuleAlive(self) || call->generation != self->connection_generation_) { + return; + } + gboolean success = FALSE; g_autoptr(GError) error = nullptr; @@ -407,58 +601,117 @@ void waybar::modules::Wireplumber::onMixerApiLoaded(WpObject* p, GAsyncResult* r spdlog::debug("[{}]: loaded mixer API", self->name_); g_ptr_array_add(self->apis_, ({ WpPlugin* p = wp_plugin_find(self->wp_core_, "mixer-api"); - g_object_set(G_OBJECT(p), "scale", 1 /* cubic */, nullptr); + g_object_set(G_OBJECT(p), "scale", 0 /* linear */, nullptr); p; })); self->activatePlugins(); self->dp.emit(); - - self->event_box_.add_events(Gdk::SCROLL_MASK | Gdk::SMOOTH_SCROLL_MASK); - self->event_box_.signal_scroll_event().connect(sigc::mem_fun(*self, &Wireplumber::handleScroll)); } void waybar::modules::Wireplumber::asyncLoadRequiredApiModules() { spdlog::debug("[{}]: loading default nodes api module", name_); wp_core_load_component(wp_core_, "libwireplumber-module-default-nodes-api", "module", nullptr, "default-nodes-api", nullptr, (GAsyncReadyCallback)onDefaultNodesApiLoaded, - this); + new AsyncCall{this, connection_generation_}); +} + +static const std::array ports = { + "headphone", "speaker", "headset", "hands-free", "portable", "car", "hifi", +}; + +std::vector waybar::modules::Wireplumber::getWPIcon() { + std::vector res; + if (muted_) { + res.emplace_back(node_name_ + "-muted"); + } + res.push_back(node_name_); + res.push_back(source_name_); + std::transform(form_factor_.begin(), form_factor_.end(), form_factor_.begin(), ::tolower); + for (auto const& port : ports) { + if (form_factor_.find(port) != std::string::npos) { + if (muted_) { + res.emplace_back(port + "-muted"); + } + res.push_back(port); + break; + } + } + if (muted_) { + res.emplace_back("default-muted"); + } + return res; } auto waybar::modules::Wireplumber::update() -> void { auto format = format_; - std::string tooltipFormat; + std::string format_name = "format"; - // Handle sink mute state + // Handle sink bluetooth state + const std::string name = default_node_name_ != nullptr ? default_node_name_ : ""; + + auto bt = name.find("bluez") != std::string::npos || name.find("a2dp-sink") != std::string::npos; + if (bt) { + format_name += "-bluetooth"; + label_.get_style_context()->add_class("bluetooth"); + } else { + label_.get_style_context()->remove_class("bluetooth"); + } + + // A module configured with node-type "Audio/Source" tracks a source as its primary node, so its + // primary mute state (muted_) must drive the source-muted class rather than the sink classes. + const bool is_source_type = g_strcmp0(type_, "Audio/Source") == 0; + + // Handle primary node mute state if (muted_) { - format = config_["format-muted"].isString() ? config_["format-muted"].asString() : format; - label_.get_style_context()->add_class("muted"); - label_.get_style_context()->add_class("sink-muted"); + // Check muted bluetooth format exists, otherwise fall back to default muted format. + if (format_name != "format" && !config_[format_name + "-muted"].isString()) + format_name = "format"; + format_name += "-muted"; + if (is_source_type) { + label_.get_style_context()->add_class("source-muted"); + } else { + label_.get_style_context()->add_class("muted"); + label_.get_style_context()->add_class("sink-muted"); + } } else { - label_.get_style_context()->remove_class("muted"); - label_.get_style_context()->remove_class("sink-muted"); + if (is_source_type) { + label_.get_style_context()->remove_class("source-muted"); + } else { + label_.get_style_context()->remove_class("muted"); + label_.get_style_context()->remove_class("sink-muted"); + } } - // Handle source mute state - if (source_muted_) { - label_.get_style_context()->add_class("source-muted"); - } else { - label_.get_style_context()->remove_class("source-muted"); + // Handle the secondary source mute state (only relevant for sink modules, which additionally + // track the default source for {format_source}). A source module already owns source-muted above. + if (!is_source_type) { + if (source_muted_) { + label_.get_style_context()->add_class("source-muted"); + } else { + label_.get_style_context()->remove_class("source-muted"); + } } - int vol = round(volume_ * 100.0); - int source_vol = round(source_volume_ * 100.0); + // mixer-api is configured with the linear scale, so volume_ holds the raw linear gain. The + // perceptual "cubic" value shown by wpctl and used for {volume} is cbrt(linear), not linear^3. + // Cubing here drives small linear gains (typical for Bluetooth sinks) to 0%. + double vol_cube = cbrt(volume_); + double source_vol_cube = cbrt(source_volume_); + + int vol = round(vol_cube * 100.0); + int source_vol = round(source_vol_cube * 100.0); + + double vol_db = 20.0 * log10(volume_); + double source_vol_db = 20.0 * log10(source_volume_); // Get the state and apply state-specific format if available auto state = getState(vol); - if (!state.empty()) { - std::string format_name = muted_ ? "format-muted" : "format"; - std::string state_format_name = format_name + "-" + state; - if (config_[state_format_name].isString()) { - format = config_[state_format_name].asString(); - } - } + if (!state.empty() && config_[format_name + "-" + state].isString()) + format = config_[format_name + "-" + state].asString(); + else if (config_[format_name].isString()) + format = config_[format_name].asString(); // Prepare source format string (similar to PulseAudio) std::string format_source = "{volume}%"; @@ -476,24 +729,28 @@ auto waybar::modules::Wireplumber::update() -> void { std::string formatted_source = fmt::format(fmt::runtime(format_source), fmt::arg("volume", source_vol)); - std::string markup = - fmt::format(fmt::runtime(format), fmt::arg("node_name", node_name_), fmt::arg("volume", vol), - fmt::arg("icon", getIcon(vol)), fmt::arg("format_source", formatted_source), - fmt::arg("source_volume", source_vol), fmt::arg("source_desc", source_name_)); - label_.set_markup(markup); + fmt::dynamic_format_arg_store store; + store.push_back(fmt::arg("node_name", node_name_)); + store.push_back(fmt::arg("volume", vol)); + store.push_back(fmt::arg("icon", getIcon(vol, getWPIcon()))); + store.push_back(fmt::arg("format_source", formatted_source)); + store.push_back(fmt::arg("source_volume", source_vol)); + store.push_back(fmt::arg("source_desc", source_name_)); + store.push_back(fmt::arg("volume_linear", volume_)); + store.push_back(fmt::arg("volume_cubic", vol_cube)); + store.push_back(fmt::arg("volume_db", vol_db)); + store.push_back(fmt::arg("source_volume_linear", source_volume_)); + store.push_back(fmt::arg("source_volume_cubic", source_vol_cube)); + store.push_back(fmt::arg("source_volume_db", source_vol_db)); + + setLabelMarkup(fmt::vformat(format, store)); if (tooltipEnabled()) { - if (tooltipFormat.empty() && config_["tooltip-format"].isString()) { - tooltipFormat = config_["tooltip-format"].asString(); - } - + auto tooltipFormat = resolveTooltipFormat(""); if (!tooltipFormat.empty()) { - label_.set_tooltip_markup(fmt::format( - fmt::runtime(tooltipFormat), fmt::arg("node_name", node_name_), fmt::arg("volume", vol), - fmt::arg("icon", getIcon(vol)), fmt::arg("format_source", formatted_source), - fmt::arg("source_volume", source_vol), fmt::arg("source_desc", source_name_))); + setTooltipMarkup(fmt::vformat(tooltipFormat, store)); } else { - label_.set_tooltip_markup(node_name_); + setTooltipMarkup(node_name_); } } @@ -510,28 +767,61 @@ bool waybar::modules::Wireplumber::handleScroll(GdkEventScroll* e) { return true; } double maxVolume = 1; - double step = 1.0 / 100.0; + double step = 1.0; if (config_["scroll-step"].isDouble()) { - step = config_["scroll-step"].asDouble() / 100.0; + step = config_["scroll-step"].asDouble(); } if (config_["max-volume"].isDouble()) { - maxVolume = config_["max-volume"].asDouble() / 100.0; + // {volume} is displayed as cubic-percent (cbrt(volume_) * 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 -> pow(1.3, 3) linear -> 130%). + maxVolume = pow(config_["max-volume"].asDouble() / 100.0, 3); } - if (step < min_step_) step = min_step_; + double vol = volume_; + std::string scale = "cubic_percent"; + if (config_["scroll-scale"].isString()) { + scale = config_["scroll-scale"].asString(); + } - double newVol = volume_; + if (scale == "cubic") { + vol = cbrt(vol); + } else if (scale == "db") { + vol = log10(vol) * 20.0; + } else if (scale == "cubic_percent") { + vol = cbrt(vol) * 100.0; + } + + double newVol = vol; if (dir == SCROLL_DIR::UP) { - if (volume_ < maxVolume) { - newVol = volume_ + step; - if (newVol > maxVolume) newVol = maxVolume; + newVol = vol + step; + } else if (dir == SCROLL_DIR::DOWN) { + newVol = vol - step; + } + + if (scale == "cubic") { + newVol = pow(newVol, 3); + } else if (scale == "db") { + newVol = exp10(newVol / 20.0); + } else if (scale == "cubic_percent") { + newVol = pow(newVol / 100.0, 3); + } + + if (dir == SCROLL_DIR::UP) { + if (volume_ + min_step_ > newVol) { + newVol = volume_ + min_step_; } } else if (dir == SCROLL_DIR::DOWN) { - if (volume_ > 0) { - newVol = volume_ - step; - if (newVol < 0) newVol = 0; + if (volume_ - min_step_ < newVol) { + newVol = volume_ - min_step_; } } + + if (newVol < 0) + newVol = 0; + else if (newVol > maxVolume) + newVol = maxVolume; + if (newVol != volume_) { if (mixer_api_ == nullptr) return true; GVariant* variant = g_variant_new_double(newVol); @@ -541,3 +831,206 @@ bool waybar::modules::Wireplumber::handleScroll(GdkEventScroll* e) { } return true; } + +// Finds the output node for filter chains defined in pipewire, +// since their input nodes are NOT providing actual outputs +uint32_t waybar::modules::Wireplumber::findPlaybackNodeId(const gchar* description) { + if (!description || *description == '\0') { + return 0; + } + + spdlog::debug("[{}]: Searching for playback node with node.description = {}", name_, description); + + g_autoptr(WpIterator) it = wp_object_manager_new_filtered_iterator( + om_, WP_TYPE_NODE, WP_CONSTRAINT_TYPE_PW_PROPERTY, "node.description", "=s", description, + WP_CONSTRAINT_TYPE_PW_PROPERTY, "media.class", "=s", "Stream/Output/Audio", nullptr); + + uint32_t playback_id = 0; + + g_auto(GValue) item = G_VALUE_INIT; + if (wp_iterator_next(it, &item)) { + WpNode* output_node = WP_NODE(g_value_get_object(&item)); + playback_id = wp_proxy_get_bound_id(WP_PROXY(output_node)); + + spdlog::debug("[{}]: Found matching playback node id {}", name_, playback_id); + } + g_value_unset(&item); + + if (playback_id == 0) { + spdlog::debug("[{}]: No playback node found with description '{}'", name_, description); + } + + return playback_id; +} + +uint32_t waybar::modules::Wireplumber::get_linked_sink_id(WpObjectManager* om, + uint32_t from_node_id) { + spdlog::debug("[{}]: Searching for links connected to node {}", name_, from_node_id); + + g_autoptr(WpIterator) out_it = + wp_object_manager_new_filtered_iterator(om, WP_TYPE_LINK, WP_CONSTRAINT_TYPE_PW_PROPERTY, + "link.output.node", "=u", from_node_id, nullptr); + + g_auto(GValue) item = G_VALUE_INIT; + if (wp_iterator_next(out_it, &item)) { + WpLink* link = WP_LINK(g_value_get_object(&item)); + guint32 out_node, out_port, in_node, in_port; + wp_link_get_linked_object_ids(link, &out_node, &out_port, &in_node, &in_port); + + spdlog::debug("[{}]: Found outgoing link {} -> {}", name_, out_node, in_node); + + g_value_unset(&item); + return in_node; + } + g_value_unset(&item); + + spdlog::debug("[{}]: No links found connected to node {}", name_, from_node_id); + return 0; +} + +// Follow non-monitor output ports to the next node +uint32_t waybar::modules::Wireplumber::get_linked_node_from_output_ports(WpObjectManager* om, + uint32_t from_node_id) { + spdlog::debug("[{}]: Searching for non-monitor output ports on node {}", name_, from_node_id); + + g_autoptr(WpIterator) port_it = wp_object_manager_new_filtered_iterator( + om, WP_TYPE_PORT, WP_CONSTRAINT_TYPE_PW_PROPERTY, "node.id", "=u", from_node_id, + WP_CONSTRAINT_TYPE_PW_PROPERTY, "port.direction", "=s", "out", nullptr); + + g_auto(GValue) port_item = G_VALUE_INIT; + while (wp_iterator_next(port_it, &port_item)) { + WpPort* port = WP_PORT(g_value_get_object(&port_item)); + + g_autoptr(WpProperties) port_props = + wp_pipewire_object_get_properties(WP_PIPEWIRE_OBJECT(port)); + if (!port_props) { + g_value_unset(&port_item); + continue; + } + + const gchar* name = wp_properties_get(port_props, "port.name"); + + // WP_CONSTRAINT_VERB_MATCHES uses GPatternSpec and it is glob-like. Unfortunately, there is no + // way to express "not beginning with a string" in glob-style regex. Or at least I didn't figure + // out how to do that. Therefore, just filter them out with a conditional. Performance + // difference should be negligible anyway. + if (!name || g_str_has_prefix(name, "monitor_")) { + g_value_unset(&port_item); + continue; + } + + spdlog::debug("[{}]: Found non-monitor output port with name '{}'", name_, name); + + // Find outgoing link from this port + uint32_t port_id = wp_proxy_get_bound_id(WP_PROXY(port)); + + g_autoptr(WpIterator) link_it = + wp_object_manager_new_filtered_iterator(om, WP_TYPE_LINK, WP_CONSTRAINT_TYPE_PW_PROPERTY, + "link.output.port", "=u", port_id, nullptr); + + g_auto(GValue) link_item = G_VALUE_INIT; + if (wp_iterator_next(link_it, &link_item)) { + WpLink* link = WP_LINK(g_value_get_object(&link_item)); + guint32 out_node, out_port, in_node, in_port; + wp_link_get_linked_object_ids(link, &out_node, &out_port, &in_node, &in_port); + + spdlog::debug("[{}]: Found link from port {} (node {}) -> node {}", name_, port_id, + from_node_id, in_node); + g_value_unset(&link_item); + g_value_unset(&port_item); + return in_node; + } + g_value_unset(&link_item); + + g_value_unset(&port_item); + } + g_value_unset(&port_item); + + spdlog::debug("[{}]: No non-monitor output ports with links on node {}", name_, from_node_id); + return 0; +} + +uint32_t waybar::modules::Wireplumber::resolvePhysicalSink(uint32_t start_id) { + if (!isValidNodeId(start_id) || !only_physical_) { + return start_id; + } + + std::unordered_set visited; + uint32_t current_id = start_id; + int depth = 0; + const int max_depth = 10; + + spdlog::debug("[{}]: Starting physical sink resolution from id {}", name_, start_id); + + // Follow the output node chain until a physical device is found + while (visited.insert(current_id).second && depth++ < max_depth) { + g_autoptr(WpProxy) proxy = static_cast( + wp_object_manager_lookup(om_, WP_TYPE_GLOBAL_PROXY, WP_CONSTRAINT_TYPE_G_PROPERTY, + "bound-id", "=u", current_id, nullptr)); + + if (!proxy || !WP_IS_PIPEWIRE_OBJECT(proxy)) { + spdlog::warn("[{}]: Node {} not found during resolution", name_, current_id); + break; + } + + // 1: If it has a device.id, we found the physical sink + g_autoptr(WpProperties) props = wp_pipewire_object_get_properties(WP_PIPEWIRE_OBJECT(proxy)); + if (!props) props = wp_properties_new_empty(); + + const gchar* device_id = wp_properties_get(props, "device.id"); + if (device_id != nullptr) { + spdlog::debug("[{}]: Found physical sink {} (device.id = {})", name_, current_id, device_id); + break; + } + + spdlog::debug("[{}]: Node {} is virtual, trying direct output ports", name_, current_id); + + // 2: Try following non-monitor output ports + uint32_t next_id = get_linked_node_from_output_ports(om_, current_id); + if (next_id != 0) { + spdlog::debug("[{}]: Following direct output port link to node {}", name_, next_id); + current_id = next_id; + continue; + } + + // 3: Search for audio stream node + // (pipewire filter chains create a node for input and a separate node for output) + const gchar* description = wp_properties_get(props, "node.description"); + if (!description || *description == '\0') { + spdlog::warn("[{}]: Virtual node {} has no description/nick - cannot search playback node", + name_, current_id); + break; + } + + spdlog::debug("[{}]: No direct output ports, searching playback node for description '{}'", + name_, description); + + uint32_t playback_id = findPlaybackNodeId(description); + if (playback_id == 0) { + spdlog::warn("[{}]: No playback node found for virtual sink {} - stopping at virtual sink", + name_, current_id); + break; + } + + next_id = get_linked_sink_id(om_, playback_id); + if (next_id == 0) { + spdlog::warn("[{}]: Playback node {} has no outgoing links - stopping at virtual sink {}", + name_, playback_id, current_id); + break; + } + + spdlog::debug("[{}]: Following playback node link to node {}", name_, next_id); + current_id = next_id; + } + + GVariant* variant = nullptr; + g_signal_emit_by_name(mixer_api_, "get-volume", current_id, &variant); + + if (variant == nullptr) { + spdlog::warn("[{}]: Node {} does not support volume - fallback to default sink id", name_, + current_id); + current_id = start_id; + } + spdlog::info("[{}]: Final resolved sink id {}", name_, current_id); + return current_id; +} diff --git a/src/modules/wlr/taskbar.cpp b/src/modules/wlr/taskbar.cpp index bde90910..34960624 100644 --- a/src/modules/wlr/taskbar.cpp +++ b/src/modules/wlr/taskbar.cpp @@ -94,13 +94,61 @@ Task::Task(const waybar::Bar& bar, const Json::Value& config, Taskbar* tbar, button.set_relief(Gtk::RELIEF_NONE); - content_.add(text_before_); - content_.add(icon_); - content_.add(text_after_); + /* When "expand" is enabled the buttons stretch to fill the taskbar and the + * titles ellipsize to fit within the available space. This only makes sense + * on a horizontal bar; on a vertical bar the box grows along the vertical + * axis, so ellipsizing/forcing width_chars(1) would truncate every title to + * "…". Keep the historical behavior (buttons sized to their content) as the + * default and when the bar is vertical. */ + bool expand = config_["expand"].isBool() && config_["expand"].asBool(); + bool horizontal = bar.orientation == Gtk::ORIENTATION_HORIZONTAL; + + if (expand && horizontal) { + button.set_hexpand(true); + content_.set_hexpand(true); + text_before_.set_ellipsize(Pango::ELLIPSIZE_END); + text_before_.set_single_line_mode(true); + text_before_.set_width_chars(1); + text_before_.set_xalign(0.0); + text_after_.set_ellipsize(Pango::ELLIPSIZE_END); + text_after_.set_single_line_mode(true); + text_after_.set_width_chars(1); + text_after_.set_xalign(0.0); + + content_.pack_start(text_before_, true, true, 0); + content_.pack_start(icon_, false, false, 0); + content_.pack_start(text_after_, true, true, 0); + } else { + content_.add(text_before_); + content_.add(icon_); + content_.add(text_after_); + } + + if (config_["justify"].isString()) { + auto justify_str = config_["justify"].asString(); + if (justify_str == "left") { + content_.set_halign(Gtk::ALIGN_START); + } else if (justify_str == "right") { + content_.set_halign(Gtk::ALIGN_END); + } else if (justify_str == "center") { + content_.set_halign(Gtk::ALIGN_CENTER); + } + } content_.show(); button.add(content_); + // Apply optional label truncation (ellipsize). + if (config_["truncate"].isBool() && config_["truncate"].asBool()) { + text_before_.set_single_line_mode(true); + text_before_.set_ellipsize(Pango::ELLIPSIZE_END); + text_before_.set_line_wrap(false); + + text_after_.set_single_line_mode(true); + text_after_.set_ellipsize(Pango::ELLIPSIZE_END); + text_after_.set_line_wrap(false); + } + format_before_.clear(); format_after_.clear(); @@ -197,6 +245,7 @@ void Task::handle_title(const char* title) { } title_ = title; hide_if_ignored(); + hide_if_duplicate(); if ((!with_icon_ && !with_name_) || app_info_) { return; @@ -221,19 +270,34 @@ void Task::set_minimize_hint() { minimize_hint.y, minimize_hint.w, minimize_hint.h); } +void Task::hide_if_duplicate() { + const auto& squash_list = tbar_->squash_list(); + bool contains_app = + squash_list.contains("*") || squash_list.contains(title_) || squash_list.contains(app_id_); + + // Squashes if the app is in the squash list and more than 1 instance is open + if (contains_app && (tbar_->task_id_count(app_id_) > 1 || tbar_->task_title_count(title_) > 1)) { + squashed_ = true; + hide_button(); + } + + if (!squashed_ && !ignored_ && (tbar_->all_outputs() || on_bar_output_)) { + show_button(); + } +} + void Task::hide_if_ignored() { if (tbar_->ignore_list().count(app_id_) || tbar_->ignore_list().count(title_)) { ignored_ = true; - if (button_visible_) { - auto output = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); - handle_output_leave(output); - } - } else { - bool is_was_ignored = ignored_; + hide_button(); + return; + } + + if (ignored_) { + /* The app_id/title changed to a value that is no longer ignored */ ignored_ = false; - if (is_was_ignored) { - auto output = gdk_wayland_monitor_get_wl_output(bar_.output->monitor->gobj()); - handle_output_enter(output); + if (!squashed_ && (tbar_->all_outputs() || on_bar_output_)) { + show_button(); } } } @@ -246,6 +310,7 @@ void Task::handle_app_id(const char* app_id) { } app_id_ = app_id; hide_if_ignored(); + hide_if_duplicate(); auto ids_replace_map = tbar_->app_ids_replace_map(); if (ids_replace_map.count(app_id_)) { @@ -281,34 +346,67 @@ void Task::on_button_size_allocated(Gtk::Allocation& alloc) { } void Task::handle_output_enter(struct wl_output* output) { + spdlog::debug("{} entered output {}", repr(), (void*)output); + + if (tbar_->show_output(output)) { + on_bar_output_ = true; + } + if (ignored_) { spdlog::debug("{} is ignored", repr()); return; } + if (squashed_) { + spdlog::debug("{} was squashed", repr()); + return; + } - spdlog::debug("{} entered output {}", repr(), (void*)output); - - if (!button_visible_ && (tbar_->all_outputs() || tbar_->show_output(output))) { - /* The task entered the output of the current bar make the button visible */ - button.signal_size_allocate().connect_notify( - sigc::mem_fun(this, &Task::on_button_size_allocated)); - tbar_->add_button(button); - button.show(); - button_visible_ = true; - spdlog::debug("{} now visible on {}", repr(), bar_.output->name); + if (tbar_->all_outputs() || on_bar_output_) { + /* The task entered the output of the current bar, make the button visible */ + show_button(); } } void Task::handle_output_leave(struct wl_output* output) { spdlog::debug("{} left output {}", repr(), (void*)output); - if (button_visible_ && !tbar_->all_outputs() && tbar_->show_output(output)) { - /* The task left the output of the current bar, make the button invisible */ - tbar_->remove_button(button); - button.hide(); - button_visible_ = false; - spdlog::debug("{} now invisible on {}", repr(), bar_.output->name); + if (tbar_->show_output(output)) { + on_bar_output_ = false; } + + if (!tbar_->all_outputs() && !on_bar_output_) { + /* The task left the output of the current bar, make the button invisible */ + hide_button(); + } +} + +void Task::show_button() { + if (button_visible_) { + return; + } + if (!size_allocate_connected_) { + button.signal_size_allocate().connect_notify( + sigc::mem_fun(this, &Task::on_button_size_allocated)); + size_allocate_connected_ = true; + } + tbar_->add_button(button); + if (!config_["active-only"].asBool() || active()) { + button.show(); + } + button_visible_ = true; + spdlog::debug("{} now visible on {}", repr(), bar_.output->name); + tbar_->update_bar_css_classes(); +} + +void Task::hide_button() { + if (!button_visible_) { + return; + } + tbar_->remove_button(button); + button.hide(); + button_visible_ = false; + spdlog::debug("{} now invisible on {}", repr(), bar_.output->name); + tbar_->update_bar_css_classes(); } void Task::handle_state(struct wl_array* state) { @@ -350,6 +448,20 @@ void Task::handle_done() { button.get_style_context()->remove_class("fullscreen"); } + if (button_visible_ && config_["active-only"].asBool()) { + if (active()) { + button.show(); + } else { + button.hide(); + } + } + + if (active()) { + tbar_->assign_current_workspace(*this); + } + + tbar_->update_bar_css_classes(); + if (config_["active-first"].isBool() && config_["active-first"].asBool() && active()) tbar_->move_button(button, 0); @@ -364,6 +476,28 @@ void Task::handle_closed() { tbar_->remove_button(button); button_visible_ = false; } + + const auto& squash_list = tbar_->squash_list(); + const bool in_squash_list = + squash_list.contains("*") || squash_list.contains(title_) || squash_list.contains(app_id_); + if (in_squash_list && !squashed_ && + (tbar_->task_id_count(app_id_) > 1 || tbar_->task_title_count(title_) > 1)) { + // Find next squashed task with same title or id (excluding ourselves) + auto tasks = tbar_->tasks(); + const auto it = std::ranges::find_if(tasks, [this](auto&& task) { + return &task != this && task.squashed_ && + (task.app_id() == app_id_ || task.title() == title_); + }); + + if (it != tasks.end() && !(*it).ignored_) { + Task& task = *it; + task.squashed_ = false; + if (tbar_->all_outputs() || task.on_bar_output_) { + task.show_button(); + } + } + } + tbar_->remove_task(id_); } @@ -511,7 +645,7 @@ void Task::update() { if (markup) button.set_tooltip_markup(txt); else - button.set_tooltip_markup(txt); + button.set_tooltip_text(txt); } } @@ -551,6 +685,8 @@ static void handle_global(void* data, struct wl_registry* registry, uint32_t nam const char* interface, uint32_t version) { if (std::strcmp(interface, zwlr_foreign_toplevel_manager_v1_interface.name) == 0) { static_cast(data)->register_manager(registry, name, version); + } else if (std::strcmp(interface, ext_workspace_manager_v1_interface.name) == 0) { + static_cast(data)->register_workspace_manager(registry, name, version); } else if (std::strcmp(interface, wl_seat_interface.name) == 0) { static_cast(data)->register_seat(registry, name, version); } @@ -568,6 +704,7 @@ Taskbar::Taskbar(const std::string& id, const waybar::Bar& bar, const Json::Valu bar_(bar), box_{bar.orientation, 0}, manager_{nullptr}, + workspace_manager_{nullptr}, seat_{nullptr} { box_.set_name("taskbar"); if (!id.empty()) { @@ -577,6 +714,27 @@ Taskbar::Taskbar(const std::string& id, const waybar::Bar& bar, const Json::Valu box_.get_style_context()->add_class("empty"); event_box_.add(box_); + // wlr/taskbar interprets on-click* config values as built-in actions, handled + // per-task in Task::handle_clicked. Register the recognized action names so + // AModule dispatches them via doAction() instead of also running them as shell + // commands (issue #3284). Values that are not built-in actions are left alone + // and still run as user shell commands. + const auto is_builtin_action = [](const std::string& v) { + return v == "activate" || v == "minimize" || v == "minimize-raise" || v == "maximize" || + v == "fullscreen" || v == "close"; + }; + for (const auto* event : {"on-click", "on-click-middle", "on-click-right"}) { + if (config_[event].isString() && is_builtin_action(config_[event].asString())) { + eventActionMap_.insert({event, config_[event].asString()}); + } + } + + // Make task buttons distribute evenly across the available width. + if (config_["homogeneous"].isBool() && config_["homogeneous"].asBool()) { + box_.set_homogeneous(true); + box_.set_hexpand(true); + } + struct wl_display* display = Client::inst()->wl_display; struct wl_registry* registry = wl_display_get_registry(display); @@ -608,6 +766,13 @@ Taskbar::Taskbar(const std::string& id, const waybar::Bar& bar, const Json::Valu } } + // Load squash-list + if (config_["squash-list"].isArray()) { + for (auto& app_name : config_["squash-list"]) { + squash_list_.emplace(app_name.asString()); + } + } + // Load app_id remappings if (config_["app_ids-mapping"].isObject()) { const Json::Value& mapping = config_["app_ids-mapping"]; @@ -623,6 +788,27 @@ Taskbar::Taskbar(const std::string& id, const waybar::Bar& bar, const Json::Valu } Taskbar::~Taskbar() { + for (auto& workspace : workspaces_) { + ext_workspace_handle_v1_destroy(workspace->handle); + } + workspaces_.clear(); + for (auto* group : workspace_groups_) { + ext_workspace_group_handle_v1_destroy(group); + } + workspace_groups_.clear(); + + if (workspace_manager_) { + struct wl_display* display = Client::inst()->wl_display; + ext_workspace_manager_v1_stop(workspace_manager_); + wl_display_roundtrip(display); + + if (workspace_manager_) { + spdlog::warn("Workspace manager destroyed before .finished event"); + ext_workspace_manager_v1_destroy(workspace_manager_); + workspace_manager_ = nullptr; + } + } + if (manager_) { struct wl_display* display = Client::inst()->wl_display; /* @@ -639,6 +825,13 @@ Taskbar::~Taskbar() { manager_ = nullptr; } } + + if (config_["bar-css-states"].asBool()) { + set_bar_css_class("toplevel-active", false); + set_bar_css_class("toplevel-maximized", false); + set_bar_css_class("toplevel-minimized", false); + set_bar_css_class("toplevel-fullscreen", false); + } } void Taskbar::update() { @@ -674,6 +867,80 @@ static const struct zwlr_foreign_toplevel_manager_v1_listener toplevel_manager_i .finished = tm_handle_finished, }; +static void workspace_handle_id(void*, struct ext_workspace_handle_v1*, const char*) {} +static void workspace_handle_name(void*, struct ext_workspace_handle_v1*, const char*) {} +static void workspace_handle_coordinates(void*, struct ext_workspace_handle_v1*, struct wl_array*) { +} + +static void workspace_handle_state(void* data, struct ext_workspace_handle_v1*, uint32_t state) { + static_cast(data)->state = state; +} + +static void workspace_handle_capabilities(void*, struct ext_workspace_handle_v1*, uint32_t) {} + +static void workspace_handle_removed(void* data, struct ext_workspace_handle_v1* handle) { + static_cast(data)->taskbar->handle_workspace_removed(handle); +} + +static const struct ext_workspace_handle_v1_listener workspace_handle_impl = { + .id = workspace_handle_id, + .name = workspace_handle_name, + .coordinates = workspace_handle_coordinates, + .state = workspace_handle_state, + .capabilities = workspace_handle_capabilities, + .removed = workspace_handle_removed, +}; + +static void workspace_group_handle_capabilities(void*, struct ext_workspace_group_handle_v1*, + uint32_t) {} +static void workspace_group_handle_output_enter(void*, struct ext_workspace_group_handle_v1*, + struct wl_output*) {} +static void workspace_group_handle_output_leave(void*, struct ext_workspace_group_handle_v1*, + struct wl_output*) {} +static void workspace_group_handle_workspace_enter(void*, struct ext_workspace_group_handle_v1*, + struct ext_workspace_handle_v1*) {} +static void workspace_group_handle_workspace_leave(void*, struct ext_workspace_group_handle_v1*, + struct ext_workspace_handle_v1*) {} + +static void workspace_group_handle_removed(void* data, + struct ext_workspace_group_handle_v1* group) { + static_cast(data)->handle_workspace_group_removed(group); +} + +static const struct ext_workspace_group_handle_v1_listener workspace_group_impl = { + .capabilities = workspace_group_handle_capabilities, + .output_enter = workspace_group_handle_output_enter, + .output_leave = workspace_group_handle_output_leave, + .workspace_enter = workspace_group_handle_workspace_enter, + .workspace_leave = workspace_group_handle_workspace_leave, + .removed = workspace_group_handle_removed, +}; + +static void workspace_manager_handle_group(void* data, struct ext_workspace_manager_v1*, + struct ext_workspace_group_handle_v1* group) { + static_cast(data)->handle_workspace_group_create(group); +} + +static void workspace_manager_handle_workspace(void* data, struct ext_workspace_manager_v1*, + struct ext_workspace_handle_v1* workspace) { + static_cast(data)->handle_workspace_create(workspace); +} + +static void workspace_manager_handle_done(void* data, struct ext_workspace_manager_v1*) { + static_cast(data)->handle_workspace_done(); +} + +static void workspace_manager_handle_finished(void* data, struct ext_workspace_manager_v1*) { + static_cast(data)->handle_workspace_finished(); +} + +static const struct ext_workspace_manager_v1_listener workspace_manager_impl = { + .workspace_group = workspace_manager_handle_group, + .workspace = workspace_manager_handle_workspace, + .done = workspace_manager_handle_done, + .finished = workspace_manager_handle_finished, +}; + void Taskbar::register_manager(struct wl_registry* registry, uint32_t name, uint32_t version) { if (manager_) { spdlog::warn("Register foreign toplevel manager again although already existing!"); @@ -698,6 +965,18 @@ void Taskbar::register_manager(struct wl_registry* registry, uint32_t name, uint spdlog::debug("Failed to register manager"); } +void Taskbar::register_workspace_manager(struct wl_registry* registry, uint32_t name, + uint32_t version) { + if (workspace_manager_) { + return; + } + + version = std::min(version, ext_workspace_manager_v1_interface.version); + workspace_manager_ = static_cast( + wl_registry_bind(registry, name, &ext_workspace_manager_v1_interface, version)); + ext_workspace_manager_v1_add_listener(workspace_manager_, &workspace_manager_impl, this); +} + void Taskbar::register_seat(struct wl_registry* registry, uint32_t name, uint32_t version) { if (seat_) { spdlog::warn("Register seat again although already existing!"); @@ -717,8 +996,81 @@ void Taskbar::handle_finished() { manager_ = nullptr; } +void Taskbar::handle_workspace_group_create(struct ext_workspace_group_handle_v1* handle) { + ext_workspace_group_handle_v1_add_listener(handle, &workspace_group_impl, this); + workspace_groups_.push_back(handle); +} + +void Taskbar::handle_workspace_group_removed(struct ext_workspace_group_handle_v1* handle) { + const auto group = std::find(workspace_groups_.begin(), workspace_groups_.end(), handle); + if (group != workspace_groups_.end()) { + ext_workspace_group_handle_v1_destroy(*group); + workspace_groups_.erase(group); + } +} + +void Taskbar::handle_workspace_create(struct ext_workspace_handle_v1* handle) { + auto workspace = std::make_unique(WorkspaceState{this, handle}); + ext_workspace_handle_v1_add_listener(handle, &workspace_handle_impl, workspace.get()); + workspaces_.push_back(std::move(workspace)); +} + +void Taskbar::handle_workspace_done() { + const auto active_workspace = + std::find_if(workspaces_.begin(), workspaces_.end(), [](const auto& workspace) { + return workspace->state & EXT_WORKSPACE_HANDLE_V1_STATE_ACTIVE; + }); + current_workspace_ = + active_workspace == workspaces_.end() ? nullptr : (*active_workspace)->handle; + if (current_workspace_) { + const auto active_task = + std::find_if(tasks_.begin(), tasks_.end(), [](const auto& task) { return task->active(); }); + if (active_task != tasks_.end()) { + (*active_task)->set_workspace(current_workspace_); + } + } + update_bar_css_classes(); +} + +void Taskbar::handle_workspace_finished() { workspace_manager_ = nullptr; } + +void Taskbar::handle_workspace_removed(struct ext_workspace_handle_v1* handle) { + if (current_workspace_ == handle) { + current_workspace_ = nullptr; + } + for (auto& task : tasks_) { + if (task->workspace() == handle) { + task->set_workspace(nullptr); + } + } + + const auto workspace = + std::find_if(workspaces_.begin(), workspaces_.end(), + [handle](const auto& workspace) { return workspace->handle == handle; }); + if (workspace != workspaces_.end()) { + ext_workspace_handle_v1_destroy((*workspace)->handle); + workspaces_.erase(workspace); + } + update_bar_css_classes(); +} + void Taskbar::add_button(Gtk::Button& bt) { - box_.pack_start(bt, false, false); + /* When "homogeneous" is enabled, let every child expand and fill so the buttons + * divide the available width equally. Otherwise, only let the buttons expand to + * fill the taskbar when "expand" is enabled and the bar is horizontal (see the + * Task constructor for details). */ + const bool homogeneous = config_["homogeneous"].isBool() && config_["homogeneous"].asBool(); + bool expand = config_["expand"].isBool() && config_["expand"].asBool(); + bool horizontal = bar_.orientation == Gtk::ORIENTATION_HORIZONTAL; + if (homogeneous) { + box_.pack_start(bt, true, true); + bt.set_hexpand(true); + bt.set_halign(Gtk::ALIGN_FILL); + } else if (expand && horizontal) { + box_.pack_start(bt, true, true); + } else { + box_.pack_start(bt, false, false); + } box_.get_style_context()->remove_class("empty"); } @@ -741,6 +1093,56 @@ void Taskbar::remove_task(uint32_t id) { } tasks_.erase(it); + update_bar_css_classes(); +} + +void Taskbar::assign_current_workspace(Task& task) { + if (current_workspace_) { + task.set_workspace(current_workspace_); + } +} + +void Taskbar::update_bar_css_classes() { + if (!config_["bar-css-states"].asBool()) { + return; + } + + const auto active_task = std::find_if(tasks_.begin(), tasks_.end(), [](const TaskPtr& task) { + return task->visible() && task->active(); + }); + + const bool has_active_task = active_task != tasks_.end(); + const auto on_current_workspace = [this](const TaskPtr& task) { + if (!current_workspace_) { + return task->active(); + } + return task->workspace() == current_workspace_; + }; + const bool has_maximized_task = + std::any_of(tasks_.begin(), tasks_.end(), [&on_current_workspace](const TaskPtr& task) { + return task->visible() && !task->minimized() && on_current_workspace(task) && + task->maximized(); + }); + const bool has_fullscreen_task = + std::any_of(tasks_.begin(), tasks_.end(), [&on_current_workspace](const TaskPtr& task) { + return task->visible() && !task->minimized() && on_current_workspace(task) && + task->fullscreen(); + }); + set_bar_css_class("toplevel-active", has_active_task); + set_bar_css_class("toplevel-maximized", has_maximized_task); + set_bar_css_class("toplevel-minimized", has_active_task && (*active_task)->minimized()); + set_bar_css_class("toplevel-fullscreen", has_fullscreen_task); +} + +void Taskbar::set_bar_css_class(const std::string& class_name, bool enabled) { + const auto style = bar_.window.get_style_context(); + if (enabled && !style->has_class(class_name)) { + spdlog::trace("Adding bar class: {}", class_name); + style->add_class(class_name); + } else if (!enabled && style->has_class(class_name)) { + spdlog::trace("Removing bar class: {}", class_name); + style->remove_class(class_name); + } } bool Taskbar::show_output(struct wl_output* output) const { @@ -755,8 +1157,18 @@ const IconLoader& Taskbar::icon_loader() const { return icon_loader_; } const std::unordered_set& Taskbar::ignore_list() const { return ignore_list_; } +const std::unordered_set& Taskbar::squash_list() const { return squash_list_; } + const std::map& Taskbar::app_ids_replace_map() const { return app_ids_replace_map_; } +std::size_t Taskbar::task_id_count(std::string_view id) const { + return std::ranges::count_if(tasks_, [=](auto&& task) { return id == task->app_id(); }); +} + +std::size_t Taskbar::task_title_count(std::string_view title) const { + return std::ranges::count_if(tasks_, [=](auto&& task) { return title == task->title(); }); +} + } /* namespace waybar::modules::wlr */ diff --git a/src/modules/wwan.cpp b/src/modules/wwan.cpp new file mode 100644 index 00000000..c741405d --- /dev/null +++ b/src/modules/wwan.cpp @@ -0,0 +1,308 @@ +#include "modules/wwan.hpp" + +#include + +#include +#include + +#include "ModemManager.h" +#include "libmm-glib.h" + +// In the 80000 version of fmt library authors decided to optimize imports +// and moved declarations required for fmt::dynamic_format_arg_store in new +// header fmt/args.h +#if (FMT_VERSION >= 80000) +#include +#else +#include +#endif + +namespace { +using namespace waybar::util; +constexpr const char* DEFAULT_FORMAT = "{state}"; +} // namespace + +waybar::modules::Wwan::Wwan(const std::string& id, const Json::Value& config) + : ALabel(config, "wwan", id, "{}", 5) { + thread_ = [this] { + dp.emit(); + thread_.sleep_for(interval_); + }; + + GError* error = nullptr; + connection = g_bus_get_sync(G_BUS_TYPE_SYSTEM, nullptr, &error); + if (error) { + g_error_free(error); + return; + } + + manager = mm_manager_new_sync(connection, G_DBUS_OBJECT_MANAGER_CLIENT_FLAGS_NONE, + nullptr, // cancellable + &error); + + if (error) { + spdlog::error("Failed to create ModemManager proxy: " + std::string(error->message)); + g_error_free(error); + g_clear_object(&connection); + return; + } + + updateCurrentModem(); + + if (config_["hide-disconnected"].isBool()) { + hideDisconnected = config_["hide-disconnected"].asBool(); + } +} + +void waybar::modules::Wwan::updateCurrentModem() { + // get 1st modem (or modem with the specified hardware path) + + GList* modems = g_dbus_object_manager_get_objects(G_DBUS_OBJECT_MANAGER(manager)); + if (!modems) { + return; + } + + for (GList* m = modems; m; m = g_list_next(m)) { + MMObject* modem_obj = MM_OBJECT(m->data); + + MMModem* modem = mm_object_get_modem(modem_obj); + + if (config_["path"].isString()) { + std::string path = config_["path"].asString(); + gchar* physdev = mm_modem_dup_physdev(modem); + std::string other = physdev ? physdev : ""; + g_free(physdev); + + if (path != other) { + g_object_unref(modem); + modem = nullptr; + continue; + } + } + + if (config_["imei"].isString()) { + std::string imei = config_["imei"].asString(); + gchar* equipment_id = mm_modem_dup_equipment_identifier(modem); + std::string other = equipment_id ? equipment_id : ""; + g_free(equipment_id); + + if (imei != other) { + g_object_unref(modem); + modem = nullptr; + continue; + } + } + + current_modem = modem; + g_list_free_full(modems, g_object_unref); + return; + } + // no modems found + current_modem = nullptr; + g_list_free_full(modems, g_object_unref); +} + +std::string getModemStateString(MMModem* modem) { + switch (mm_modem_get_state(modem)) { + case MMModemState::MM_MODEM_STATE_FAILED: + return "Failed"; + case MMModemState::MM_MODEM_STATE_INITIALIZING: + return "Initializing"; + case MMModemState::MM_MODEM_STATE_LOCKED: + return "Locked"; + case MMModemState::MM_MODEM_STATE_DISABLED: + return "Disabled"; + case MMModemState::MM_MODEM_STATE_DISABLING: + return "Disabling"; + case MMModemState::MM_MODEM_STATE_ENABLED: + return "Enabled"; + case MMModemState::MM_MODEM_STATE_ENABLING: + return "Enabling"; + case MMModemState::MM_MODEM_STATE_SEARCHING: + return "Searching"; + case MMModemState::MM_MODEM_STATE_REGISTERED: + return "Registered"; + case MMModemState::MM_MODEM_STATE_DISCONNECTING: + return "Disconnecting"; + case MMModemState::MM_MODEM_STATE_CONNECTING: + return "Connecting"; + case MMModemState::MM_MODEM_STATE_CONNECTED: + return "Connected"; + default: + return "Unknown"; + } +} + +std::string getModemStateFormatString(MMModem* modem) { + switch (mm_modem_get_state(modem)) { + case MMModemState::MM_MODEM_STATE_FAILED: + return "failed"; + case MMModemState::MM_MODEM_STATE_LOCKED: + return "locked"; + case MMModemState::MM_MODEM_STATE_DISABLED: + return "disabled"; + case MMModemState::MM_MODEM_STATE_ENABLED: + return "enabled"; + case MMModemState::MM_MODEM_STATE_SEARCHING: + return "searching"; + case MMModemState::MM_MODEM_STATE_REGISTERED: + return "registered"; + case MMModemState::MM_MODEM_STATE_CONNECTED: + return "connected"; + default: + return ""; + } +} + +std::string getPreferredModeString(MMModem* modem) { + MMModemModeCombination combination; + mm_modem_get_current_modes(modem, &combination.allowed, &combination.preferred); + + switch (combination.preferred) { + case MMModemMode::MM_MODEM_MODE_CS: + return "CS"; + case MMModemMode::MM_MODEM_MODE_2G: + return "2G"; + case MMModemMode::MM_MODEM_MODE_3G: + return "3G"; + case MMModemMode::MM_MODEM_MODE_4G: + return "4G"; + case MMModemMode::MM_MODEM_MODE_5G: + return "5G"; + default: + return "No Service"; + } +} + +std::string getCurrentModesString(MMModem* modem) { + MMModemModeCombination combination; + mm_modem_get_current_modes(modem, &combination.allowed, &combination.preferred); + + std::string buffer; + + if (combination.allowed & MMModemMode::MM_MODEM_MODE_CS) buffer += "CS | "; + if (combination.allowed & MMModemMode::MM_MODEM_MODE_2G) buffer += "2G | "; + if (combination.allowed & MMModemMode::MM_MODEM_MODE_3G) buffer += "3G | "; + if (combination.allowed & MMModemMode::MM_MODEM_MODE_4G) buffer += "4G | "; + if (combination.allowed & MMModemMode::MM_MODEM_MODE_5G) buffer += "5G | "; + + if (buffer.length() > 2) { + buffer.erase(buffer.length() - 2); + } + return buffer; +} + +std::string getPowerStateString(MMModem* modem) { + MMModemPowerState state = mm_modem_get_power_state(modem); + + switch (state) { + case MM_MODEM_POWER_STATE_ON: + return "On"; + case MM_MODEM_POWER_STATE_OFF: + return "Off"; + case MM_MODEM_POWER_STATE_LOW: + return "Low"; + default: + return "Unknown"; + } +} + +std::string getOperatorNameString(MMModem* modem) { + MMSim* sim = mm_modem_get_sim_sync(modem, nullptr, nullptr); + + if (sim == nullptr) { + return "No SIM"; + } + + gchar* operator_name = mm_sim_dup_operator_name(sim); + std::string name = operator_name ? operator_name : ""; + g_free(operator_name); + g_object_unref(sim); + return name; +} + +auto waybar::modules::Wwan::update() -> void { + if (current_modem == nullptr) { + event_box_.set_visible(false); + updateCurrentModem(); + return; + } + + if (hideDisconnected && mm_modem_get_state(current_modem) != MM_MODEM_STATE_CONNECTED) { + event_box_.set_visible(false); + + return; + } + + // Show the module + if (!event_box_.get_visible()) event_box_.set_visible(true); + + std::string tooltip_format; + + if (!alt_) { + std::string state = getModemStateFormatString(current_modem); + if (!state_.empty() && label_.get_style_context()->has_class(state_)) { + label_.get_style_context()->remove_class(state_); + } + if (config_["format-" + state].isString()) { + default_format_ = config_["format-" + state].asString(); + } else if (config_["format"].isString()) { + default_format_ = config_["format"].asString(); + } else { + default_format_ = DEFAULT_FORMAT; + } + if (config_["tooltip-format-" + state].isString()) { + tooltip_format = config_["tooltip-format-" + state].asString(); + } + if (!label_.get_style_context()->has_class(state)) { + label_.get_style_context()->add_class(state); + } + format_ = default_format_; + state_ = state; + } + + auto format = format_; + + fmt::dynamic_format_arg_store store; + store.push_back(fmt::arg("state", getModemStateString(current_modem))); + + store.push_back(fmt::arg("current_modes", getCurrentModesString(current_modem))); + store.push_back(fmt::arg("preferred_mode", getPreferredModeString(current_modem))); + + int percentage = mm_modem_get_signal_quality(current_modem, nullptr); + store.push_back(fmt::arg("signal_quality", percentage)); + store.push_back(fmt::arg("icon", getIcon(percentage))); + + store.push_back(fmt::arg("power_state", getPowerStateString(current_modem))); + gchar* imei_raw = mm_modem_dup_equipment_identifier(current_modem); + std::string imei = imei_raw ? imei_raw : ""; + g_free(imei_raw); + store.push_back(fmt::arg("imei", imei)); + + store.push_back(fmt::arg("operator_name", getOperatorNameString(current_modem))); + + auto text = fmt::vformat(format, store); + + if (tooltipEnabled()) { + if (tooltip_format.empty() && config_["tooltip-format"].isString()) { + tooltip_format = config_["tooltip-format"].asString(); + } + if (!tooltip_format.empty()) { + auto tooltip_text = fmt::vformat(tooltip_format, store); + if (label_.get_tooltip_text() != tooltip_text) { + label_.set_tooltip_markup(tooltip_text); + } + } else if (label_.get_tooltip_text() != text) { + label_.set_tooltip_markup(text); + } + } + label_.set_markup(text); + // Call parent update + ALabel::update(); +} + +waybar::modules::Wwan::~Wwan() { + g_clear_object(¤t_modem); + g_clear_object(&manager); + g_clear_object(&connection); +} diff --git a/src/util/audio_backend.cpp b/src/util/audio_backend.cpp index 4087e096..7933835d 100644 --- a/src/util/audio_backend.cpp +++ b/src/util/audio_backend.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -24,8 +25,9 @@ AudioBackend::AudioBackend(std::function on_updated_cb, private_construc source_volume_(0), source_muted_(false), on_updated_cb_(std::move(on_updated_cb)) { - // Initialize pa_volume_ with safe defaults + // Initialize pa_volume_ and pa_source_volume_ with safe defaults pa_cvolume_init(&pa_volume_); + pa_cvolume_init(&pa_source_volume_); mainloop_ = pa_threaded_mainloop_new(); if (mainloop_ == nullptr) { throw std::runtime_error("pa_mainloop_new() failed."); @@ -73,6 +75,22 @@ void AudioBackend::connectContext() { } } +// Reconnect the context without ever throwing. This is safe to call from within +// a PulseAudio state callback (which runs in pure C libpulse frames), where an +// escaping C++ exception cannot be unwound and would abort the process. +bool AudioBackend::reconnectContext() noexcept { + try { + connectContext(); + return true; + } catch (const std::exception& e) { + spdlog::error("PulseAudio reconnect failed: {}", e.what()); + return false; + } catch (...) { + spdlog::error("PulseAudio reconnect failed: unknown error"); + return false; + } +} + void AudioBackend::contextStateCb(pa_context* c, void* data) { auto* backend = static_cast(data); switch (pa_context_get_state(c)) { @@ -87,27 +105,47 @@ void AudioBackend::contextStateCb(pa_context* c, void* data) { } break; case PA_CONTEXT_READY: - pa_context_get_server_info(c, serverInfoCb, data); + if (auto* o = pa_context_get_server_info(c, serverInfoCb, data)) pa_operation_unref(o); pa_context_set_subscribe_callback(c, subscribeCb, data); - pa_context_subscribe(c, - static_cast( - static_cast(PA_SUBSCRIPTION_MASK_SERVER) | - static_cast(PA_SUBSCRIPTION_MASK_SINK) | - static_cast(PA_SUBSCRIPTION_MASK_SINK_INPUT) | - static_cast(PA_SUBSCRIPTION_MASK_SOURCE) | - static_cast(PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT)), - nullptr, nullptr); + if (auto* o = pa_context_subscribe(c, + static_cast( + static_cast(PA_SUBSCRIPTION_MASK_SERVER) | + static_cast(PA_SUBSCRIPTION_MASK_SINK) | + static_cast(PA_SUBSCRIPTION_MASK_SINK_INPUT) | + static_cast(PA_SUBSCRIPTION_MASK_SOURCE) | + static_cast(PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT)), + nullptr, nullptr)) + pa_operation_unref(o); break; case PA_CONTEXT_FAILED: - // When pulseaudio server restarts, the connection is "failed". Try to reconnect. - // pa_threaded_mainloop_lock is already acquired in callback threads. - // So there is no need to lock it again. - if (backend->context_ != nullptr) { - pa_context_disconnect(backend->context_); - pa_context_unref(backend->context_); - backend->context_ = nullptr; + if (pa_context_errno(c) != PA_ERR_CONNECTIONREFUSED) { + // When pulseaudio server restarts, the connection is "failed". Try to reconnect. + // pa_threaded_mainloop_lock is already acquired in callback threads. + // So there is no need to lock it again. + // + // Guard against re-entrancy: pa_context_connect() can fire this callback + // synchronously with PA_CONTEXT_FAILED again, which would otherwise + // recurse (FAILED -> connect -> FAILED -> ...) and busy-loop. + if (backend->reconnecting_) { + break; + } + if (backend->context_ != nullptr) { + pa_context_disconnect(backend->context_); + pa_context_unref(backend->context_); + backend->context_ = nullptr; + } + backend->reconnecting_ = true; + // Never throw across the libpulse C callback boundary: a failed reconnect + // is logged and left for a later PA event to retry instead of aborting. + if (!backend->reconnectContext()) { + spdlog::warn("PulseAudio context reconnect failed; will retry on next event"); + if (backend->context_ != nullptr) { + pa_context_unref(backend->context_); + backend->context_ = nullptr; + } + } + backend->reconnecting_ = false; } - backend->connectContext(); break; case PA_CONTEXT_CONNECTING: case PA_CONTEXT_AUTHORIZING: @@ -128,15 +166,18 @@ void AudioBackend::subscribeCb(pa_context* context, pa_subscription_event_type_t return; } if (facility == PA_SUBSCRIPTION_EVENT_SERVER) { - pa_context_get_server_info(context, serverInfoCb, data); + if (auto* o = pa_context_get_server_info(context, serverInfoCb, data)) pa_operation_unref(o); } else if (facility == PA_SUBSCRIPTION_EVENT_SINK) { - pa_context_get_sink_info_by_index(context, idx, sinkInfoCb, data); + if (auto* o = pa_context_get_sink_info_by_index(context, idx, sinkInfoCb, data)) + pa_operation_unref(o); } else if (facility == PA_SUBSCRIPTION_EVENT_SINK_INPUT) { - pa_context_get_sink_info_list(context, sinkInfoCb, data); + if (auto* o = pa_context_get_sink_info_list(context, sinkInfoCb, data)) pa_operation_unref(o); } else if (facility == PA_SUBSCRIPTION_EVENT_SOURCE) { - pa_context_get_source_info_by_index(context, idx, sourceInfoCb, data); + if (auto* o = pa_context_get_source_info_by_index(context, idx, sourceInfoCb, data)) + pa_operation_unref(o); } else if (facility == PA_SUBSCRIPTION_EVENT_SOURCE_OUTPUT) { - pa_context_get_source_info_list(context, sourceInfoCb, data); + if (auto* o = pa_context_get_source_info_list(context, sourceInfoCb, data)) + pa_operation_unref(o); } } @@ -148,13 +189,29 @@ void AudioBackend::volumeModifyCb(pa_context* c, int success, void* data) { if (success != 0) { if ((backend->context_ != nullptr) && pa_context_get_state(backend->context_) == PA_CONTEXT_READY) { - pa_context_get_sink_info_by_index(backend->context_, backend->sink_idx_, sinkInfoCb, data); + if (auto* o = pa_context_get_sink_info_by_index(backend->context_, backend->sink_idx_, + sinkInfoCb, data)) + pa_operation_unref(o); } } else { spdlog::debug("Volume modification failed"); } } +void AudioBackend::sourceVolumeModifyCb(pa_context* c, int success, void* data) { + auto* backend = static_cast(data); + if (success != 0) { + if ((backend->context_ != nullptr) && + pa_context_get_state(backend->context_) == PA_CONTEXT_READY) { + if (auto* o = pa_context_get_source_info_by_index(backend->context_, backend->source_idx_, + sourceInfoCb, data)) + pa_operation_unref(o); + } + } else { + spdlog::debug("Source volume modification failed"); + } +} + /* * Called when the requested sink information is ready. */ @@ -182,21 +239,45 @@ void AudioBackend::sinkInfoCb(pa_context* /*context*/, const pa_sink_info* i, in } } - backend->default_sink_running_ = backend->default_sink_name == i->name && - (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE); - - if (i->name != backend->default_sink_name && !backend->default_sink_running_) { - return; - } - - if (backend->current_sink_name_ == i->name) { - backend->current_sink_running_ = (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE); - } - - if (!backend->current_sink_running_ && - (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE)) { + // Resolve which sink to report deterministically, independent of the order in + // which PulseAudio enumerates sinks during a sink_info_list sweep. + // + // When the default sink has an explicit mapping, the mapped target sink is the + // definitive selection ("the sink named by the value is considered to be the + // current sink instead of the one named by the key"): only that target sink may + // write the reported volume/mute state. The mapping is keyed on the stable + // default_sink_name (set once per server-info update) rather than on the mutable + // current_sink_name_. Previously the mapping override and the "pick a running + // sink" fallback both mutated current_sink_name_ mid-sweep, so the mapping could + // be overridden by whichever running sink happened to be enumerated, and the + // reported volume depended on enumeration order. + if (const auto mapping = backend->sink_mapping_.find(backend->default_sink_name); + mapping != backend->sink_mapping_.end()) { + if (i->name != mapping->second) { + // A mapping is in effect but this is not the mapped target sink; ignore it so + // it can never override the target's reported volume. + return; + } backend->current_sink_name_ = i->name; - backend->current_sink_running_ = true; + backend->current_sink_running_ = (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE); + } else { + backend->default_sink_running_ = backend->default_sink_name == i->name && + (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE); + + if (i->name != backend->default_sink_name && i->name != backend->current_sink_name_ && + !backend->default_sink_running_) { + return; + } + + if (backend->current_sink_name_ == i->name) { + backend->current_sink_running_ = (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE); + } + + if (!backend->current_sink_running_ && + (i->state == PA_SINK_RUNNING || i->state == PA_SINK_IDLE)) { + backend->current_sink_name_ = i->name; + backend->current_sink_running_ = true; + } } if (backend->current_sink_name_ == i->name) { @@ -234,6 +315,11 @@ void AudioBackend::sourceInfoCb(pa_context* /*context*/, const pa_source_info* i void* data) { auto* backend = static_cast(data); if (i != nullptr && backend->default_source_name_ == i->name) { + if (pa_cvolume_valid(&i->volume) != 0) { + backend->pa_source_volume_ = i->volume; + } else { + pa_cvolume_init(&backend->pa_source_volume_); + } auto source_volume = static_cast(pa_cvolume_avg(&(i->volume))) / float{PA_VOLUME_NORM}; backend->source_volume_ = std::round(source_volume * 100.0F); backend->source_idx_ = i->index; @@ -255,29 +341,43 @@ void AudioBackend::serverInfoCb(pa_context* context, const pa_server_info* i, vo backend->default_sink_name = i->default_sink_name ? i->default_sink_name : ""; backend->default_source_name_ = i->default_source_name ? i->default_source_name : ""; - pa_context_get_sink_info_list(context, sinkInfoCb, data); - pa_context_get_source_info_list(context, sourceInfoCb, data); + if (auto* o = pa_context_get_sink_info_list(context, sinkInfoCb, data)) pa_operation_unref(o); + if (auto* o = pa_context_get_source_info_list(context, sourceInfoCb, data)) pa_operation_unref(o); } -void AudioBackend::changeVolume(uint16_t volume, uint16_t min_volume, uint16_t max_volume) { +uint16_t AudioBackend::getVolume(PulseaudioTarget target) const { + if (target == PulseaudioTarget::Source) { + return source_volume_; + } else { + return volume_; + } +} + +void AudioBackend::changeVolume(uint16_t volume, uint16_t min_volume, uint16_t max_volume, + PulseaudioTarget target) { // Early return if context is not ready if ((context_ == nullptr) || pa_context_get_state(context_) != PA_CONTEXT_READY) { spdlog::error("PulseAudio context not ready"); return; } + bool is_source = target == PulseaudioTarget::Source; + + // Select the appropriate stored volume structure + auto& ref_volume = is_source ? pa_source_volume_ : pa_volume_; + // Prepare volume structure pa_cvolume pa_volume; pa_cvolume_init(&pa_volume); // Use existing volume structure if valid, otherwise create a safe default - if ((pa_cvolume_valid(&pa_volume_) != 0) && (pa_channels_valid(pa_volume_.channels) != 0)) { - pa_volume = pa_volume_; + if ((pa_cvolume_valid(&ref_volume) != 0) && (pa_channels_valid(ref_volume.channels) != 0)) { + pa_volume = ref_volume; } else { - // Set stereo as a safe default - pa_volume.channels = 2; - spdlog::debug("Using default stereo volume structure"); + // Mono for sources (microphones), stereo for sinks + pa_volume.channels = is_source ? 1 : 2; + spdlog::debug("Using default volume structure ({} channels)", pa_volume.channels); } // Set the volume safely @@ -291,39 +391,62 @@ void AudioBackend::changeVolume(uint16_t volume, uint16_t min_volume, uint16_t m // Apply the volume change pa_threaded_mainloop_lock(mainloop_); - pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, volumeModifyCb, this); + if (is_source) { + if (auto* o = pa_context_set_source_volume_by_index(context_, source_idx_, &pa_volume, + sourceVolumeModifyCb, this)) + pa_operation_unref(o); + } else { + if (auto* o = pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, + volumeModifyCb, this)) + pa_operation_unref(o); + } pa_threaded_mainloop_unlock(mainloop_); } -void AudioBackend::changeVolume(ChangeType change_type, double step, uint16_t max_volume) { +void AudioBackend::changeVolume(ChangeType change_type, double step, uint16_t max_volume, + PulseaudioTarget target) { // Early return if context is not ready if ((context_ == nullptr) || pa_context_get_state(context_) != PA_CONTEXT_READY) { spdlog::error("PulseAudio context not ready"); return; } + bool is_source = target == PulseaudioTarget::Source; + + // Select the appropriate stored volume structure and current volume + auto& ref_volume = is_source ? pa_source_volume_ : pa_volume_; + auto current_volume = is_source ? source_volume_ : volume_; + // Prepare volume structure pa_cvolume pa_volume; pa_cvolume_init(&pa_volume); // Use existing volume structure if valid, otherwise create a safe default - if ((pa_cvolume_valid(&pa_volume_) != 0) && (pa_channels_valid(pa_volume_.channels) != 0)) { - pa_volume = pa_volume_; + if ((pa_cvolume_valid(&ref_volume) != 0) && (pa_channels_valid(ref_volume.channels) != 0)) { + pa_volume = ref_volume; } else { - // Set stereo as a safe default - pa_volume.channels = 2; - spdlog::debug("Using default stereo volume structure"); + // Mono for sources (microphones), stereo for sinks + pa_volume.channels = is_source ? 1 : 2; + spdlog::debug("Using default volume structure ({} channels)", pa_volume.channels); // Initialize all channels to current volume level double volume_tick = static_cast(PA_VOLUME_NORM) / 100; - pa_volume_t vol = volume_ * volume_tick; + pa_volume_t vol = current_volume * volume_tick; for (uint8_t i = 0; i < pa_volume.channels; i++) { pa_volume.values[i] = vol; } // No need to continue with volume change if we had to create a new structure pa_threaded_mainloop_lock(mainloop_); - pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, volumeModifyCb, this); + if (is_source) { + if (auto* o = pa_context_set_source_volume_by_index(context_, source_idx_, &pa_volume, + sourceVolumeModifyCb, this)) + pa_operation_unref(o); + } else { + if (auto* o = pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, + volumeModifyCb, this)) + pa_operation_unref(o); + } pa_threaded_mainloop_unlock(mainloop_); return; } @@ -333,10 +456,10 @@ void AudioBackend::changeVolume(ChangeType change_type, double step, uint16_t ma pa_volume_t change; max_volume = std::min(max_volume, static_cast(PA_VOLUME_UI_MAX)); - if (change_type == ChangeType::Increase && volume_ < max_volume) { + if (change_type == ChangeType::Increase && current_volume < max_volume) { // Calculate how much to increase - if (volume_ + step > max_volume) { - change = round((max_volume - volume_) * volume_tick); + if (current_volume + step > max_volume) { + change = round((max_volume - current_volume) * volume_tick); } else { change = round(step * volume_tick); } @@ -345,10 +468,10 @@ void AudioBackend::changeVolume(ChangeType change_type, double step, uint16_t ma for (uint8_t i = 0; i < pa_volume.channels; i++) { pa_volume.values[i] = std::min(pa_volume.values[i] + change, PA_VOLUME_MAX); } - } else if (change_type == ChangeType::Decrease && volume_ > 0) { + } else if (change_type == ChangeType::Decrease && current_volume > 0) { // Calculate how much to decrease - if (volume_ - step < 0) { - change = round(volume_ * volume_tick); + if (current_volume - step < 0) { + change = round(current_volume * volume_tick); } else { change = round(step * volume_tick); } @@ -364,16 +487,33 @@ void AudioBackend::changeVolume(ChangeType change_type, double step, uint16_t ma // Apply the volume change pa_threaded_mainloop_lock(mainloop_); - pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, volumeModifyCb, this); + if (is_source) { + if (auto* o = pa_context_set_source_volume_by_index(context_, source_idx_, &pa_volume, + sourceVolumeModifyCb, this)) + pa_operation_unref(o); + } else { + if (auto* o = pa_context_set_sink_volume_by_index(context_, sink_idx_, &pa_volume, + volumeModifyCb, this)) + pa_operation_unref(o); + } pa_threaded_mainloop_unlock(mainloop_); } +bool AudioBackend::getMuted(PulseaudioTarget target) const { + if (target == PulseaudioTarget::Source) { + return source_muted_; + } else { + return muted_; + } +} + void AudioBackend::toggleSinkMute() { if (context_ == nullptr || pa_context_get_state(context_) != PA_CONTEXT_READY) return; muted_ = !muted_; pa_threaded_mainloop_lock(mainloop_); - pa_context_set_sink_mute_by_index(context_, sink_idx_, static_cast(muted_), nullptr, - nullptr); + if (auto* o = pa_context_set_sink_mute_by_index(context_, sink_idx_, static_cast(muted_), + nullptr, nullptr)) + pa_operation_unref(o); pa_threaded_mainloop_unlock(mainloop_); } @@ -381,8 +521,9 @@ void AudioBackend::toggleSinkMute(bool mute) { if (context_ == nullptr || pa_context_get_state(context_) != PA_CONTEXT_READY) return; muted_ = mute; pa_threaded_mainloop_lock(mainloop_); - pa_context_set_sink_mute_by_index(context_, sink_idx_, static_cast(muted_), nullptr, - nullptr); + if (auto* o = pa_context_set_sink_mute_by_index(context_, sink_idx_, static_cast(muted_), + nullptr, nullptr)) + pa_operation_unref(o); pa_threaded_mainloop_unlock(mainloop_); } @@ -390,8 +531,9 @@ void AudioBackend::toggleSourceMute() { if (context_ == nullptr || pa_context_get_state(context_) != PA_CONTEXT_READY) return; source_muted_ = !source_muted_; pa_threaded_mainloop_lock(mainloop_); - pa_context_set_source_mute_by_index(context_, source_idx_, static_cast(source_muted_), - nullptr, nullptr); + if (auto* o = pa_context_set_source_mute_by_index( + context_, source_idx_, static_cast(source_muted_), nullptr, nullptr)) + pa_operation_unref(o); pa_threaded_mainloop_unlock(mainloop_); } @@ -399,11 +541,20 @@ void AudioBackend::toggleSourceMute(bool mute) { if (context_ == nullptr || pa_context_get_state(context_) != PA_CONTEXT_READY) return; source_muted_ = mute; pa_threaded_mainloop_lock(mainloop_); - pa_context_set_source_mute_by_index(context_, source_idx_, static_cast(source_muted_), - nullptr, nullptr); + if (auto* o = pa_context_set_source_mute_by_index( + context_, source_idx_, static_cast(source_muted_), nullptr, nullptr)) + pa_operation_unref(o); pa_threaded_mainloop_unlock(mainloop_); } +void AudioBackend::unmute(PulseaudioTarget target) { + if (target == PulseaudioTarget::Source) { + toggleSourceMute(false); + } else { + toggleSinkMute(false); + } +} + bool AudioBackend::isBluetooth() { return monitor_.find("a2dp_sink") != std::string::npos || // PulseAudio monitor_.find("a2dp-sink") != std::string::npos || // PipeWire @@ -420,4 +571,14 @@ void AudioBackend::setIgnoredSinks(const Json::Value& config) { } } +void AudioBackend::setSinkMapping(const Json::Value& config) { + if (config.isObject()) { + for (auto it = config.begin(); it != config.end(); ++it) { + if (it.key().isString() && it->isString()) { + sink_mapping_.emplace(it.key().asString(), it->asString()); + } + } + } +} + } // namespace waybar::util diff --git a/src/util/backlight_backend.cpp b/src/util/backlight_backend.cpp index 61eb9b43..9ccf7250 100644 --- a/src/util/backlight_backend.cpp +++ b/src/util/backlight_backend.cpp @@ -73,6 +73,7 @@ static void upsert_device(std::vector& devices, udev_device* de const char* actual = udev_device_get_sysattr_value(dev, actual_brightness_attr); const char* max = udev_device_get_sysattr_value(dev, "max_brightness"); const char* power = udev_device_get_sysattr_value(dev, "bl_power"); + const char* subsystem = udev_device_get_subsystem(dev); auto found = std::find_if(devices.begin(), devices.end(), [name](const BacklightDevice& device) { return device.name() == name; @@ -111,13 +112,18 @@ static void upsert_device(std::vector& devices, udev_device* de if (power != nullptr) power_bool = std::stoi(power) == 0; } catch (const std::exception&) { } - devices.emplace_back(name, actual_int, max_int, power_bool); + devices.emplace_back(name, actual_int, max_int, power_bool, + subsystem != nullptr ? subsystem : "backlight"); } } static void enumerate_devices(std::vector& devices, udev* udev) { std::unique_ptr enumerate{udev_enumerate_new(udev)}; udev_enumerate_add_match_subsystem(enumerate.get(), "backlight"); + // Also enumerate keyboard-backlight LEDs (e.g. "white:kbd_backlight"), which + // live in the "leds" subsystem but expose the same brightness/max_brightness + // attributes the read path uses. + udev_enumerate_add_match_subsystem(enumerate.get(), "leds"); udev_enumerate_scan_devices(enumerate.get()); udev_list_entry* enum_devices = udev_enumerate_get_list_entry(enumerate.get()); udev_list_entry* dev_list_entry; @@ -129,11 +135,18 @@ static void enumerate_devices(std::vector& devices, udev* udev) } } -BacklightDevice::BacklightDevice(std::string name, int actual, int max, bool powered) - : name_(std::move(name)), actual_(actual), max_(max), powered_(powered) {} +BacklightDevice::BacklightDevice(std::string name, int actual, int max, bool powered, + std::string subsystem) + : name_(std::move(name)), + actual_(actual), + max_(max), + powered_(powered), + subsystem_(std::move(subsystem)) {} std::string BacklightDevice::name() const { return name_; } +std::string BacklightDevice::subsystem() const { return subsystem_; } + int BacklightDevice::get_actual() const { return actual_; } void BacklightDevice::set_actual(int actual) { actual_ = actual; } @@ -178,6 +191,10 @@ BacklightBackend::BacklightBackend(std::chrono::milliseconds interval, check_nn(mon.get(), "udev monitor new failed"); check_gte(udev_monitor_filter_add_match_subsystem_devtype(mon.get(), "backlight", nullptr), 0, "udev failed to add monitor filter: "); + // Also monitor the "leds" subsystem so keyboard-backlight changes are + // reflected live, mirroring the enumeration above. + check_gte(udev_monitor_filter_add_match_subsystem_devtype(mon.get(), "leds", nullptr), 0, + "udev failed to add monitor filter: "); udev_monitor_enable_receiving(mon.get()); auto udev_fd = udev_monitor_get_fd(mon.get()); @@ -213,9 +230,21 @@ BacklightBackend::BacklightBackend(std::chrono::milliseconds interval, upsert_device(devices, dev.get()); } - // Refresh state if timed out + // Refresh state if timed out. Only re-read the sysfs attributes of the + // devices we already track instead of re-enumerating the whole udev tree + // (udev_enumerate_scan_devices), which walks all of /sys/class/backlight + // and /sys/class/leds and floods the filesystem with open/close syscalls + // on every polling tick (#5020). Device add/remove is already delivered + // by the udev monitor above, so a periodic full re-scan is redundant. if (event_count == 0) { - enumerate_devices(devices, udev.get()); + for (const auto& device : devices) { + std::unique_ptr dev{ + udev_device_new_from_subsystem_sysname(udev.get(), device.subsystem().c_str(), + device.name().c_str())}; + if (dev) { + upsert_device(devices, dev.get()); + } + } } { std::scoped_lock lock(udev_thread_mutex_); @@ -235,9 +264,22 @@ const BacklightDevice* BacklightBackend::best_device(const std::vectorget_max(); const auto abs_val = static_cast(std::round(brightness * max / 100.0F)); - set_brightness_internal(best->name(), abs_val, best->get_max()); + set_brightness_internal(best->name(), abs_val, best->get_max(), best->subsystem()); } } @@ -275,12 +317,12 @@ void BacklightBackend::set_brightness(const std::string& preferred_device, Chang const int new_brightness = change_type == ChangeType::Increase ? best->get_actual() + abs_step : best->get_actual() - abs_step; - set_brightness_internal(best->name(), new_brightness, max); + set_brightness_internal(best->name(), new_brightness, max, best->subsystem()); } } void BacklightBackend::set_brightness_internal(const std::string& device_name, int brightness, - int max_brightness) { + int max_brightness, const std::string& subsystem) { if (!login_proxy_) { spdlog::error("Login proxy not available, cannot set brightness"); return; @@ -289,7 +331,7 @@ void BacklightBackend::set_brightness_internal(const std::string& device_name, i brightness = std::clamp(brightness, 0, max_brightness); auto call_args = Glib::VariantContainerBase( - g_variant_new("(ssu)", "backlight", device_name.c_str(), brightness)); + g_variant_new("(ssu)", subsystem.c_str(), device_name.c_str(), brightness)); login_proxy_->call_sync("SetBrightness", call_args); } diff --git a/src/util/command_line_stream.cpp b/src/util/command_line_stream.cpp new file mode 100644 index 00000000..7abb79b8 --- /dev/null +++ b/src/util/command_line_stream.cpp @@ -0,0 +1,219 @@ +#include "util/command_line_stream.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#endif +#ifdef __FreeBSD__ +#include +#endif + +#include "util/command.hpp" + +namespace { + +auto buildChildEnvironment(const std::string& output_name) -> std::vector { + auto names = Glib::listenv(); + std::vector envp; + envp.reserve(names.size() + 1); + + for (const auto& name : names) { + bool found = false; + auto value = Glib::getenv(name, found); + if (!found || name == "WAYBAR_OUTPUT_NAME") { + continue; + } + envp.push_back(name + "=" + value); + } + + if (!output_name.empty()) { + envp.push_back("WAYBAR_OUTPUT_NAME=" + output_name); + } + + return envp; +} + +void prepareChild() { + sigset_t mask; + sigfillset(&mask); + + (void)pthread_sigmask(SIG_UNBLOCK, &mask, nullptr); + + int deathsig = SIGTERM; +#ifdef __linux__ + (void)prctl(PR_SET_PDEATHSIG, deathsig); +#endif +#ifdef __FreeBSD__ + (void)procctl(P_PID, 0, PROC_PDEATHSIG_CTL, reinterpret_cast(&deathsig)); +#endif + + (void)setpgid(0, 0); +} + +void emitBufferedLines(std::string& buffer, + const waybar::util::command::LineStream::OutputCallback& on_output, + bool flush_trailing_line) { + for (auto newline_pos = buffer.find('\n'); newline_pos != std::string::npos; + newline_pos = buffer.find('\n')) { + auto line = buffer.substr(0, newline_pos); + buffer.erase(0, newline_pos + 1); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + on_output(line); + } + + if (flush_trailing_line && !buffer.empty()) { + auto line = std::move(buffer); + buffer.clear(); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + on_output(line); + } +} + +} // namespace + +waybar::util::command::LineStream::LineStream(std::string output_name, OutputCallback on_output, + ExitCallback on_exit) + : output_name_(std::move(output_name)), + on_output_(std::move(on_output)), + on_exit_(std::move(on_exit)), + pid_(0), + stdout_fd_(-1) {} + +waybar::util::command::LineStream::~LineStream() { stop(); } + +void waybar::util::command::LineStream::start(const std::string& cmd) { + stop(); + + std::vector argv{"/bin/sh", "-c", cmd}; + auto envp = buildChildEnvironment(output_name_); + Glib::spawn_async_with_pipes("", argv, envp, + Glib::SPAWN_DO_NOT_REAP_CHILD | Glib::SPAWN_CLOEXEC_PIPES, + sigc::ptr_fun(&prepareChild), &pid_, nullptr, &stdout_fd_, nullptr); + + const auto flags = fcntl(stdout_fd_, F_GETFL, 0); + if (flags == -1 || fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK) == -1) { + const auto saved_errno = errno; + stop(); + throw std::runtime_error("Unable to configure child stdout: " + + std::string(std::strerror(saved_errno))); + } + + stdout_connection_ = + Glib::signal_io().connect(sigc::mem_fun(*this, &LineStream::handleStdout), stdout_fd_, + Glib::IO_IN | Glib::IO_HUP | Glib::IO_ERR | Glib::IO_NVAL); + child_connection_ = + Glib::signal_child_watch().connect(sigc::mem_fun(*this, &LineStream::handleExit), pid_); +} + +void waybar::util::command::LineStream::stop() { + stdout_connection_.disconnect(); + child_connection_.disconnect(); + + if (pid_ != 0) { + killpg(pid_, SIGTERM); + waitpid(pid_, nullptr, 0); + Glib::spawn_close_pid(pid_); + pid_ = 0; + } + + closeStdout(); + buffer_.clear(); +} + +bool waybar::util::command::LineStream::running() const { return pid_ != 0; } + +bool waybar::util::command::LineStream::handleStdout(Glib::IOCondition condition) { + const auto should_flush = + static_cast(condition & (Glib::IO_HUP | Glib::IO_ERR | Glib::IO_NVAL)); + drainStdout(should_flush); + + if (!running() || should_flush) { + closeStdout(); + return false; + } + + return true; +} + +void waybar::util::command::LineStream::handleExit(Glib::Pid pid, int status) { + child_connection_.disconnect(); + + if (stdout_fd_ != -1) { + drainStdout(true); + stdout_connection_.disconnect(); + closeStdout(); + } + + if (pid_ == pid) { + Glib::spawn_close_pid(pid_); + pid_ = 0; + } else { + Glib::spawn_close_pid(pid); + } + + on_exit_(statusToExitCode(status)); +} + +void waybar::util::command::LineStream::closeStdout() { + if (stdout_fd_ != -1) { + ::close(stdout_fd_); + stdout_fd_ = -1; + } +} + +void waybar::util::command::LineStream::drainStdout(bool flush_trailing_line) { + if (stdout_fd_ == -1) { + return; + } + + std::array chunk = {}; + while (true) { + const auto bytes_read = ::read(stdout_fd_, chunk.data(), chunk.size()); + if (bytes_read > 0) { + buffer_.append(chunk.data(), static_cast(bytes_read)); + emitBufferedLines(buffer_, on_output_, false); + continue; + } + + if (bytes_read == 0) { + emitBufferedLines(buffer_, on_output_, flush_trailing_line); + return; + } + + if (errno == EINTR) { + continue; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return; + } + + spdlog::error("Reading command stdout failed: {}", std::strerror(errno)); + emitBufferedLines(buffer_, on_output_, flush_trailing_line); + return; + } +} + +int waybar::util::command::LineStream::statusToExitCode(int status) { + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } + if (WIFSIGNALED(status)) { + return 128 + WTERMSIG(status); + } + return 1; +} diff --git a/src/util/css_reload_helper.cpp b/src/util/css_reload_helper.cpp index be8c0a68..9c7b8a1f 100644 --- a/src/util/css_reload_helper.cpp +++ b/src/util/css_reload_helper.cpp @@ -23,7 +23,8 @@ namespace { const std::regex IMPORT_REGEX(R"(@import\s+(?:url\()?(?:"|')([^"')]+)(?:"|')\)?;)"); } -waybar::CssReloadHelper::CssReloadHelper(std::string cssFile, std::function callback) +waybar::CssReloadHelper::CssReloadHelper(std::string cssFile, + std::function callback) : m_cssFile(std::move(cssFile)), m_callback(std::move(callback)) {} std::string waybar::CssReloadHelper::getFileContents(const std::string& filename) { @@ -88,6 +89,12 @@ void waybar::CssReloadHelper::monitorChanges() { } } +void waybar::CssReloadHelper::changeCssFile(const std::string& newCssFile) { + m_fileMonitors.clear(); + m_cssFile = newCssFile; + monitorChanges(); +} + void waybar::CssReloadHelper::handleFileChange(Glib::RefPtr const& file, Glib::RefPtr const& other_type, Gio::FileMonitorEvent event_type) { @@ -95,7 +102,7 @@ void waybar::CssReloadHelper::handleFileChange(Glib::RefPtr const& fi // fire for one if (event_type == Gio::FileMonitorEvent::FILE_MONITOR_EVENT_CHANGES_DONE_HINT) { spdlog::debug("Reloading style, file changed: {}", file->get_path()); - m_callback(); + m_callback(m_cssFile); } } diff --git a/src/util/enum.cpp b/src/util/enum.cpp deleted file mode 100644 index 6b5d5562..00000000 --- a/src/util/enum.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include "util/enum.hpp" - -#include // for std::transform -#include // for std::toupper -#include -#include -#include -#include - -#include "modules/hyprland/workspaces.hpp" -#include "util/string.hpp" - -namespace waybar::util { - -template -EnumParser::EnumParser() = default; - -template -EnumParser::~EnumParser() = default; - -template -EnumType EnumParser::parseStringToEnum(const std::string& str, - const std::map& enumMap) { - // Convert the input string to uppercase - std::string uppercaseStr = capitalize(str); - - // Capitalize the map keys before searching - std::map capitalizedEnumMap; - std::transform( - enumMap.begin(), enumMap.end(), std::inserter(capitalizedEnumMap, capitalizedEnumMap.end()), - [](const auto& pair) { return std::make_pair(capitalize(pair.first), pair.second); }); - - // Return enum match of string - auto it = capitalizedEnumMap.find(uppercaseStr); - if (it != capitalizedEnumMap.end()) return it->second; - - // Throw error if it doesn't return - throw std::invalid_argument("Invalid string representation for enum"); -} - -// Explicit instantiations for specific EnumType types you intend to use -// Add explicit instantiations for all relevant EnumType types -template struct EnumParser; -template struct EnumParser; -template struct EnumParser; - -} // namespace waybar::util diff --git a/src/util/geoclue_backend.cpp b/src/util/geoclue_backend.cpp new file mode 100644 index 00000000..dbe09074 --- /dev/null +++ b/src/util/geoclue_backend.cpp @@ -0,0 +1,88 @@ +#include "util/geoclue_backend.hpp" + +#include + +#include + +namespace waybar::util::GeoClueBackend { + +GeoClueBackend::GeoClueBackend(PrivateConstructorTag tag) + : watcherID_(0), signal_conn(), connected(false), location_in_use(false) { + // Start watching DBUS + watcherID_ = + Gio::DBus::watch_name(Gio::DBus::BusType::BUS_TYPE_SYSTEM, "org.freedesktop.GeoClue2", + sigc::mem_fun(*this, &GeoClueBackend::onAppear), + sigc::mem_fun(*this, &GeoClueBackend::onVanished), + Gio::DBus::BusNameWatcherFlags::BUS_NAME_WATCHER_FLAGS_AUTO_START); +} + +GeoClueBackend::~GeoClueBackend() { + if (watcherID_ > 0) { + Gio::DBus::unwatch_name(watcherID_); + watcherID_ = 0; + } + signal_conn.disconnect(); +} + +std::shared_ptr GeoClueBackend::getInstance() { + PrivateConstructorTag tag; + return std::make_shared(tag); +} + +void GeoClueBackend::propertyChanged( + const Gio::DBus::Proxy::MapChangedProperties& changedProperties, + const std::vector& invalidatedProperties) { + if (!connected) { + return; + } + + if (auto in_use_variant = changedProperties.find("InUse"); + in_use_variant != changedProperties.end()) { + bool in_use = + Glib::VariantBase::cast_dynamic>(in_use_variant->second).get(); + location_in_use.store(in_use); + in_use_changed_signal_event.emit(); + } +} + +void GeoClueBackend::onAppear(const Glib::RefPtr& conn, + const Glib::ustring& name, const Glib::ustring& name_owner) { + location_in_use.store(false); + signal_conn.disconnect(); + connected = true; + + proxy = Gio::DBus::Proxy::create_sync(conn, "org.freedesktop.GeoClue2", + "/org/freedesktop/GeoClue2/Manager", + "org.freedesktop.GeoClue2.Manager"); + + // Subscribe DBus events + signal_conn = proxy->signal_properties_changed().connect( + sigc::mem_fun(*this, &GeoClueBackend::propertyChanged)); + + // Get the initial value + auto callArgs = Glib::Variant>::create( + std::make_tuple("org.freedesktop.GeoClue2.Manager", "InUse")); + Glib::VariantContainerBase base = + proxy->call_sync("org.freedesktop.DBus.Properties.Get", callArgs); + if (base.is_of_type(Glib::VariantType("(v)"))) { + Glib::Variant> variant_value; + base.get_child(variant_value, 0); + auto [value] = variant_value.get(); + if (location_in_use != value) { + location_in_use.store(value); + } + } + + in_use_changed_signal_event.emit(); +} + +void GeoClueBackend::onVanished(const Glib::RefPtr& conn, + const Glib::ustring& name) { + signal_conn.disconnect(); + connected = false; + + location_in_use.store(false); + in_use_changed_signal_event.emit(); +} + +} // namespace waybar::util::GeoClueBackend diff --git a/src/util/hosts_check.cpp b/src/util/hosts_check.cpp new file mode 100644 index 00000000..1769dd78 --- /dev/null +++ b/src/util/hosts_check.cpp @@ -0,0 +1,17 @@ +#include "util/hosts_check.hpp" + +#include +#include + +namespace waybar::util { +bool valid_host(const Json::Value& config) { + if (config.isMember("hosts") && config["hosts"].isArray()) { + const auto hostname = Glib::get_host_name(); + + if (!std::ranges::any_of(config["hosts"].begin(), config["hosts"].end(), + [&](const auto& h) { return h.asString() == hostname; })) + return false; + } + return true; +} +} // namespace waybar::util diff --git a/src/util/pipewire/pipewire_backend.cpp b/src/util/pipewire/pipewire_backend.cpp index 82ec0c0f..72528fb4 100644 --- a/src/util/pipewire/pipewire_backend.cpp +++ b/src/util/pipewire/pipewire_backend.cpp @@ -5,7 +5,7 @@ namespace waybar::util::PipewireBackend { static void getNodeInfo(void* data_, const struct pw_node_info* info) { - auto* pNodeInfo = static_cast(data_); + auto* pNodeInfo = static_cast(data_); pNodeInfo->handleNodeEventInfo(info); static_cast(pNodeInfo->data)->privacy_nodes_changed_signal_event.emit(); @@ -17,7 +17,7 @@ static const struct pw_node_events NODE_EVENTS = { }; static void proxyDestroy(void* data) { - static_cast(data)->handleProxyEventDestroy(); + static_cast(data)->handleProxyEventDestroy(); } static const struct pw_proxy_events PROXY_EVENTS = { @@ -127,12 +127,13 @@ void PipewireBackend::handleRegistryEventGlobal(uint32_t id, uint32_t permission return; } - auto* proxy = (pw_proxy*)pw_registry_bind(registry_, id, type, version, sizeof(PrivacyNodeInfo)); + auto* proxy = + (pw_proxy*)pw_registry_bind(registry_, id, type, version, sizeof(PWPrivacyNodeInfo)); if (proxy == nullptr) return; - auto* pNodeInfo = (PrivacyNodeInfo*)pw_proxy_get_user_data(proxy); - new (pNodeInfo) PrivacyNodeInfo{}; + auto* pNodeInfo = (PWPrivacyNodeInfo*)pw_proxy_get_user_data(proxy); + new (pNodeInfo) PWPrivacyNodeInfo{}; pNodeInfo->id = id; pNodeInfo->data = this; pNodeInfo->type = mediaType; @@ -152,7 +153,7 @@ void PipewireBackend::handleRegistryEventGlobalRemove(uint32_t id) { mutex_.lock(); auto iter = privacy_nodes.find(id); if (iter != privacy_nodes.end()) { - privacy_nodes[id]->~PrivacyNodeInfo(); + privacy_nodes[id]->~PWPrivacyNodeInfo(); privacy_nodes.erase(id); } mutex_.unlock(); diff --git a/src/util/pipewire/privacy_node_info.cpp b/src/util/pipewire/privacy_node_info.cpp index dbd6ed85..0da4b25b 100644 --- a/src/util/pipewire/privacy_node_info.cpp +++ b/src/util/pipewire/privacy_node_info.cpp @@ -2,7 +2,7 @@ namespace waybar::util::PipewireBackend { -std::string PrivacyNodeInfo::getName() { +std::string PWPrivacyNodeInfo::getName() { const std::vector names{&application_name, &node_name}; std::string name = "Unknown Application"; for (const auto& item : names) { @@ -15,7 +15,7 @@ std::string PrivacyNodeInfo::getName() { return name; } -std::string PrivacyNodeInfo::getIconName() { +std::string PWPrivacyNodeInfo::getIconName() { const std::vector names{&application_icon_name, &pipewire_access_portal_app_id, &application_name, &node_name}; std::string name = "application-x-executable-symbolic"; @@ -27,12 +27,12 @@ std::string PrivacyNodeInfo::getIconName() { return name; } -void PrivacyNodeInfo::handleProxyEventDestroy() { +void PWPrivacyNodeInfo::handleProxyEventDestroy() { spa_hook_remove(&proxy_listener); spa_hook_remove(&object_listener); } -void PrivacyNodeInfo::handleNodeEventInfo(const struct pw_node_info* info) { +void PWPrivacyNodeInfo::handleNodeEventInfo(const struct pw_node_info* info) { state = info->state; const struct spa_dict_item* item; diff --git a/src/util/rewrite_string.cpp b/src/util/rewrite_string.cpp index 3f6ae4ca..8c377d3f 100644 --- a/src/util/rewrite_string.cpp +++ b/src/util/rewrite_string.cpp @@ -18,7 +18,7 @@ std::string rewriteString(const std::string& value, const Json::Value& rules) { // malformated regexes will cause an exception. // in this case, log error and try the next rule. const std::regex rule{it.key().asString(), std::regex_constants::icase}; - if (std::regex_match(value, rule)) { + if (std::regex_match(res, rule)) { res = std::regex_replace(res, rule, it->asString()); } } catch (const std::regex_error& e) { diff --git a/src/util/transform_8bit_to_rgba.cpp b/src/util/transform_8bit_to_rgba.cpp index dc87a88e..b354a640 100644 --- a/src/util/transform_8bit_to_rgba.cpp +++ b/src/util/transform_8bit_to_rgba.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include 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}; diff --git a/src/util/utf8_string.cpp b/src/util/utf8_string.cpp new file mode 100644 index 00000000..f96f2bd1 --- /dev/null +++ b/src/util/utf8_string.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include + +namespace waybar::util { + +namespace { +// Wide characters count as two, zero-width characters count as zero +// Modifies str in-place (unless width = std::string::npos) +// Returns the total width of the string pre-truncating +size_t measure_and_truncate(std::string& str, size_t width = std::string::npos) { + if (str.length() == 0) return 0; + + const gchar* trunc_end = nullptr; + + size_t total_width = 0; + + for (gchar *data = str.data(), *end = data + str.size(); data != nullptr;) { + gunichar c = g_utf8_get_char_validated(data, end - data); + if (c == -1U || c == -2U) { + // invalid unicode, treat string as ascii + if (width != std::string::npos && str.length() > width) str.resize(width); + return str.length(); + } else if (g_unichar_iswide(c)) { + total_width += 2; + } else if (!g_unichar_iszerowidth(c) && c != 0xAD) { // neither zero-width nor soft hyphen + total_width += 1; + } + + data = g_utf8_find_next_char(data, end); + if (width != std::string::npos && total_width <= width && !g_unichar_isspace(c)) + trunc_end = data; + } + + if (trunc_end) str.resize(trunc_end - str.data()); + + return total_width; +} +} // namespace + +size_t utf8_width(const std::string& str) { + return measure_and_truncate(const_cast(str)); +} + +void utf8_truncate(std::string& s, const std::string& ellipsis, size_t max_len) { + if (max_len == 0) { + s.resize(0); + return; + } + size_t len = measure_and_truncate(s, max_len); + if (len > max_len) { + size_t ellipsis_len = utf8_width(ellipsis); + if (max_len >= ellipsis_len) { + if (ellipsis_len) measure_and_truncate(s, max_len - ellipsis_len); + s += ellipsis; + } else { + s.resize(0); + } + } +} +} // namespace waybar::util diff --git a/subprojects/libcava.wrap b/subprojects/libcava.wrap index d66d657b..821bbd5f 100644 --- a/subprojects/libcava.wrap +++ b/subprojects/libcava.wrap @@ -4,9 +4,9 @@ #depth = 1 [wrap-file] -directory = cava-0.10.7 -source_url = https://github.com/LukashonakV/cava/archive/0.10.7.tar.gz -source_filename = cava-0.10.7.tar.gz -source_hash = 50cc6413e9c96c503657f814744a2baf429a24ff9fed31a8343e0ed285269eff +directory = cava-1.0.0 +source_url = https://github.com/LukashonakV/cava/archive/1.0.0.tar.gz +source_filename = cava-1.0.0.tar.gz +source_hash = 437df0a29e52e555357a06238f4c5cbe6d51b5dd4225700cb7adbb19d8bb9474 [provide] libcava = cava_dep diff --git a/test/config.cpp b/test/config.cpp index 638d9663..27e84b9b 100644 --- a/test/config.cpp +++ b/test/config.cpp @@ -16,11 +16,11 @@ TEST_CASE("Load simple config", "[config]") { REQUIRE(data["height"].asInt() == 30); } SECTION("select configs for configured output") { - auto configs = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0"); + auto configs = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0", 0, 0); REQUIRE(configs.size() == 1); } SECTION("select configs for missing output") { - auto configs = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1"); + auto configs = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1", 0, 0); REQUIRE(configs.empty()); } } @@ -30,7 +30,7 @@ TEST_CASE("Load config with multiple bars", "[config]") { conf.load("test/config/multi.json"); SECTION("select multiple configs #1") { - auto data = conf.getOutputConfigs("DP-0", "Fake DisplayPort output #0"); + auto data = conf.getOutputConfigs("DP-0", "Fake DisplayPort output #0", 0, 0); REQUIRE(data.size() == 4); REQUIRE(data[0]["layer"].asString() == "bottom"); REQUIRE(data[0]["height"].asInt() == 20); @@ -43,7 +43,7 @@ TEST_CASE("Load config with multiple bars", "[config]") { REQUIRE(data[3]["height"].asInt() == 24); } SECTION("select multiple configs #2") { - auto data = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0"); + auto data = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0", 0, 0); REQUIRE(data.size() == 2); REQUIRE(data[0]["layer"].asString() == "bottom"); REQUIRE(data[0]["height"].asInt() == 20); @@ -52,7 +52,7 @@ TEST_CASE("Load config with multiple bars", "[config]") { REQUIRE(data[1]["height"].asInt() == 23); } SECTION("select single config by output description") { - auto data = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1"); + auto data = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1", 0, 0); REQUIRE(data.size() == 1); REQUIRE(data[0]["layer"].asString() == "overlay"); REQUIRE(data[0]["position"].asString() == "left"); @@ -75,11 +75,11 @@ TEST_CASE("Load simple config with include", "[config]") { REQUIRE((data.isMember("nullOption") && data["nullOption"].isNull())); } SECTION("select configs for configured output") { - auto configs = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0"); + auto configs = conf.getOutputConfigs("HDMI-0", "Fake HDMI output #0", 0, 0); REQUIRE(configs.size() == 1); } SECTION("select configs for missing output") { - auto configs = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1"); + auto configs = conf.getOutputConfigs("HDMI-1", "Fake HDMI output #1", 0, 0); REQUIRE(configs.empty()); } } @@ -116,25 +116,25 @@ TEST_CASE("Load multiple bar config with include", "[config]") { conf.load("test/config/include-multi.json"); SECTION("bar config with sole include") { - auto data = conf.getOutputConfigs("OUT-0", "Fake output #0"); + auto data = conf.getOutputConfigs("OUT-0", "Fake output #0", 0, 0); REQUIRE(data.size() == 1); REQUIRE(data[0]["height"].asInt() == 20); } SECTION("bar config with output and include") { - auto data = conf.getOutputConfigs("OUT-1", "Fake output #1"); + auto data = conf.getOutputConfigs("OUT-1", "Fake output #1", 0, 0); REQUIRE(data.size() == 1); REQUIRE(data[0]["height"].asInt() == 21); } SECTION("bar config with output override") { - auto data = conf.getOutputConfigs("OUT-2", "Fake output #2"); + auto data = conf.getOutputConfigs("OUT-2", "Fake output #2", 0, 0); REQUIRE(data.size() == 1); REQUIRE(data[0]["height"].asInt() == 22); } SECTION("multiple levels of include") { - auto data = conf.getOutputConfigs("OUT-3", "Fake output #3"); + auto data = conf.getOutputConfigs("OUT-3", "Fake output #3", 0, 0); REQUIRE(data.size() == 1); REQUIRE(data[0]["height"].asInt() == 23); } @@ -166,6 +166,7 @@ TEST_CASE("Load Hyprland Workspaces bar config", "[config]") { REQUIRE(hyprland["active-only"].asBool() == true); REQUIRE(hyprland["all-outputs"].asBool() == false); REQUIRE(hyprland["move-to-monitor"].asBool() == true); + REQUIRE(hyprland["unique-icons"].asBool() == true); REQUIRE(hyprland["format"].asString() == "{icon} {windows}"); REQUIRE(hyprland["format-window-separator"].asString() == " "); REQUIRE(hyprland["on-scroll-down"].asString() == "hyprctl dispatch workspace e-1"); diff --git a/test/config/hyprland-workspaces.json b/test/config/hyprland-workspaces.json index dd733897..903e94f9 100644 --- a/test/config/hyprland-workspaces.json +++ b/test/config/hyprland-workspaces.json @@ -29,6 +29,7 @@ "window-rewrite": { "title": "" }, + "unique-icons": true, "window-rewrite-default": "", "window-rewrite-separator": " ", "sort-by": "number" diff --git a/test/hyprland/backend.cpp b/test/hyprland/backend.cpp index 62d23ae4..f2e10dae 100644 --- a/test/hyprland/backend.cpp +++ b/test/hyprland/backend.cpp @@ -160,7 +160,7 @@ TEST_CASE("buildLuaDispatch focusworkspaceoncurrentmonitor", "[buildLuaDispatch] IPCTestHelper::buildLuaDispatch("focusworkspaceoncurrentmonitor", "3"); REQUIRE( result == - "/dispatch hl.dsp.focus({ workspace = \"3\", monitor = \"current\" })"); + "/dispatch hl.dsp.focus({ workspace = \"3\", on_current_monitor = true })"); } TEST_CASE("buildLuaDispatch togglespecialworkspace", "[buildLuaDispatch]") { diff --git a/test/smoke/README.md b/test/smoke/README.md new file mode 100644 index 00000000..86c3dcae --- /dev/null +++ b/test/smoke/README.md @@ -0,0 +1,69 @@ +# Smoke / render / interaction tests + +The [`smoke`](../../.github/workflows/smoke.yml) workflow builds Waybar **with +AddressSanitizer** and runs the real binary inside a **headless** compositor +(software-rendered, no GPU) to exercise it end to end. All helpers share +[`lib.sh`](lib.sh). + +## What it checks + +| Tier | Check | Script | +| --- | --- | --- | +| 0 | Launches, stays alive, and logs **no ASan / `Gtk-CRITICAL` / crash** — for every launch below | [`lib.sh`](lib.sh) `assert_clean` | +| — | **Coverage tripwire** — every `Factory` module must be classified (source-only, runs before the build) | [`coverage.sh`](coverage.sh) | +| 1 | **Renders** — deterministic config screenshotted with `grim`, bar strip not blank | [`run.sh`](run.sh) | +| 1 | **Per-module matrix** — each headless-safe module rendered standalone **and inside a group**, each torn down via SIGINT | [`modules.sh`](modules.sh) | +| 2 | **Golden** — screenshot vs [`reference.png`](reference.png) (fuzz 8%, 800px) | workflow | +| 2 | **Interaction** — inject pointer clicks via sway; assert `on-click` side effect + `format-alt` toggle | [`interact.sh`](interact.sh) | +| 2 | **Accessibility** — assert module labels via the AT-SPI tree (robust to fonts) | [`a11y.sh`](a11y.sh) / [`a11y.py`](a11y.py) | +| 3 | **Layout matrix** — top / bottom / left (vertical) + HiDPI (scale 2) | [`positions.sh`](positions.sh) | +| 3 | **Second compositor** — same launch under `labwc` | workflow (`COMPOSITOR=labwc`) | +| 4 | **Lifecycle / teardown** — clean SIGINT exit, runtime **output hotplug**, toggle + reload churn, fast-interval teardown (the C++ destructor path a `kill` never runs, #5182) | [`lifecycle.sh`](lifecycle.sh) | +| 4 | **Custom-backend fuzz** — empty / non-zero-exit / invalid-JSON / huge / non-UTF8 / empty-format exec output | [`fuzz.sh`](fuzz.sh) | +| 5 | **State transitions** *(best-effort)* — real `mpd` driven through an **empty queue** (#5183) + stop/clear, `pulseaudio` + slider on a null sink | [`state.sh`](state.sh) | +| 6 | **Fake hardware** *(best-effort)* — `backlight`, `backlight/slider` (#5179) and `battery` via **umockdev** | [`hardware.sh`](hardware.sh) | +| — | **Leak check** *(report-only)* — clean-exit run under LSan | [`leakcheck.sh`](leakcheck.sh) | + +AddressSanitizer (plus `-D_GLIBCXX_ASSERTIONS`) runs under *all* of the above, so +any memory bug in the real render/teardown path aborts the run. Tiers 5–6 and the +leak check are best-effort (`continue-on-error`): they depend on daemon setup / +mock devices that vary by runner. The interaction step is likewise best-effort — a +headless seat exposes no pointer capability, so injected clicks may be dropped +(hardening it needs a virtual pointer, uinput/wlr-virtual-pointer). + +Because the teardown path (`~Bar` → window unmap → `toggleSuspend`) is where the +exit-time crashes live, tests assert on a **clean SIGINT exit** via +`smoke::assert_clean_exit` rather than killing with SIGTERM, and re-check the log +for sanitizer reports emitted *during* destruction. + +## Updating the reference image + +`reference.png` must be generated by the CI environment (local rendering differs +from Ubuntu's). Two ways: + +- **One click:** Actions → *smoke* → **Run workflow** with `bless = true`. The + job regenerates the screenshot and commits it to your branch. +- **Manual:** download the `waybar-smoke` artifact from a run and commit its + `bar.png` as `test/smoke/reference.png`. + +Do this whenever an **intended** visual change trips the golden comparison. + +## Run locally + +On a machine with `sway`, `grim`, `imagemagick`, `jq` and Waybar built: + +```sh +export WAYBAR_BIN=$PWD/build/waybar +./test/smoke/coverage.sh # no compositor needed +./test/smoke/run.sh test/smoke/config.jsonc test/smoke/style.css /tmp/bar.png +./test/smoke/modules.sh /tmp/module-shots +./test/smoke/lifecycle.sh +./test/smoke/fuzz.sh +./test/smoke/interact.sh /tmp/interaction-shots +./test/smoke/positions.sh /tmp/layout-shots +dbus-run-session -- ./test/smoke/a11y.sh +# best-effort (need extra daemons / tools): +./test/smoke/state.sh # needs mpd + mpc, pulseaudio +./test/smoke/hardware.sh # needs umockdev +./test/smoke/leakcheck.sh +``` diff --git a/test/smoke/a11y.py b/test/smoke/a11y.py new file mode 100644 index 00000000..032e57bc --- /dev/null +++ b/test/smoke/a11y.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Tier 2 — assert on Waybar's accessibility (AT-SPI) tree instead of pixels. + +Walks the running Waybar's GTK accessibility tree and checks that the expected +module labels are exposed. This is robust to fonts/anti-aliasing (unlike the +golden image) and verifies actual widget content. +""" +import sys + +import pyatspi + +EXPECT = ["Waybar", "CI smoke test", "12:34"] + + +def collect(acc, out): + try: + name = acc.name or "" + except Exception: + name = "" + if name: + out.append(name) + try: + count = acc.childCount + except Exception: + count = 0 + for i in range(count): + try: + child = acc.getChildAtIndex(i) + except Exception: + child = None + if child is not None: + collect(child, out) + + +def main(): + desktop = pyatspi.Registry.getDesktop(0) + texts, found = [], False + for i in range(desktop.childCount): + app = desktop.getChildAtIndex(i) + if app is None: + continue + if (app.name or "").lower().startswith("waybar"): + found = True + collect(app, texts) + print("AT-SPI labels seen on waybar:", texts) + if not found: + print("ERROR: waybar did not appear on the AT-SPI bus") + return 1 + missing = [e for e in EXPECT if not any(e in t for t in texts)] + if missing: + print("ERROR: expected labels missing from AT-SPI tree:", missing) + return 1 + print("OK: all expected labels present via AT-SPI") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/smoke/a11y.sh b/test/smoke/a11y.sh new file mode 100755 index 00000000..19f66c76 --- /dev/null +++ b/test/smoke/a11y.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Tier 2 — accessibility assertions. Must run under a D-Bus session so the +# AT-SPI bus is available, e.g.: dbus-run-session -- test/smoke/a11y.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +# Make sure GTK registers with the AT-SPI bridge. +unset NO_AT_BRIDGE +export GTK_A11Y=1 + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT +smoke::launch_waybar "$DIR/config.jsonc" "$DIR/style.css" 6 +smoke::assert_alive +python3 "$DIR/a11y.py" diff --git a/test/smoke/config.jsonc b/test/smoke/config.jsonc new file mode 100644 index 00000000..461b04d8 --- /dev/null +++ b/test/smoke/config.jsonc @@ -0,0 +1,23 @@ +// Deterministic Waybar config for the CI smoke/screenshot test. +// Uses only static custom modules (interval "once") so the rendered bar is +// reproducible and can be compared against a reference image. +{ + "layer": "top", + "position": "top", + "height": 30, + "modules-left": ["custom/left"], + "modules-center": ["custom/center"], + "modules-right": ["custom/right"], + "custom/left": { + "exec": "echo Waybar", + "interval": "once" + }, + "custom/center": { + "exec": "echo 'CI smoke test'", + "interval": "once" + }, + "custom/right": { + "exec": "echo 12:34", + "interval": "once" + } +} diff --git a/test/smoke/coverage.sh b/test/smoke/coverage.sh new file mode 100755 index 00000000..9969d679 --- /dev/null +++ b/test/smoke/coverage.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Tier — coverage tripwire. Every module the Factory can build must be classified +# below: either smoke-tested headless (SAFE), or explicitly parked as needing real +# hardware/a daemon (HARDWARE, best-effort in state.sh / hardware.sh) or a specific +# compositor (COMPOSITOR). A newly added module that isn't listed fails this job, +# forcing a conscious "is it covered?" decision instead of silently escaping the +# smoke net -- which is how the slider/backlight crash class (#5179) slipped by. +# +# Usage: coverage.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$DIR/../.." && pwd)" +FACTORY_SRC="$ROOT/src/factory.cpp" + +mapfile -t FACTORY < <(grep -oE 'ref == "[^"]+"' "$FACTORY_SRC" | sed -E 's/.*"(.*)"/\1/' | sort -u) +[ "${#FACTORY[@]}" -gt 0 ] || { echo "::error::could not parse module names from $FACTORY_SRC"; exit 1; } + +# Rendered headless with no hardware/daemon/compositor dependency. +SAFE=(clock cpu cpu_usage cpu_graph cpu_frequency load memory disk + idle_inhibitor image user inhibitor) + +# Need real hardware or a system daemon; covered best-effort by hardware.sh / state.sh. +HARDWARE=(backlight backlight/slider battery upower power-profiles-daemon bluetooth + network wwan gps pulseaudio pulseaudio/slider wireplumber jack sndio cava + mpd mpris temperature keyboard-state systemd-failed-units privacy tray gamemode) + +# Need a specific compositor / its IPC to produce output. +COMPOSITOR=(sway/language sway/mode sway/scratchpad sway/window sway/workspaces + hyprland/language hyprland/submap hyprland/window hyprland/windowcount hyprland/workspaces + river/layout river/mode river/tags river/window + niri/language niri/window niri/workspaces + dwl/tags dwl/window + mango/keymode mango/language mango/layout mango/window mango/workspaces + wayfire/window wayfire/workspaces ext/workspaces wlr/taskbar) + +declare -A known=() +for m in "${SAFE[@]}" "${HARDWARE[@]}" "${COMPOSITOR[@]}"; do known["$m"]=1; done + +missing=() +for m in "${FACTORY[@]}"; do [ -n "${known[$m]:-}" ] || missing+=("$m"); done + +if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::modules not classified for smoke coverage: ${missing[*]}" + echo "Add each to SAFE / HARDWARE / COMPOSITOR in test/smoke/coverage.sh" + echo "(and, if it can run headless, to the matrix in modules.sh)." + exit 1 +fi +echo "✓ all ${#FACTORY[@]} factory modules are classified for smoke coverage" diff --git a/test/smoke/fuzz.sh b/test/smoke/fuzz.sh new file mode 100755 index 00000000..29f6a767 --- /dev/null +++ b/test/smoke/fuzz.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Tier — custom-backend fuzz. The custom module's exec output flows straight into +# formatting / sanitising / (optionally) JSON parsing, so feed it the pathological +# inputs that tend to segfault (empty, non-zero exit, invalid JSON, huge, non-UTF8, +# empty format) and assert no crash / critical, including on clean exit. +# +# Usage: fuzz.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +fail=0 + +# run_case +run_case() { + local name="$1" mod="$2" cfg + echo "::group::fuzz: $name" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" </dev/null; then + echo "(umockdev not installed, skipping hardware tier)"; exit 0 +fi + +# Kill only waybar between cases -- smoke::stop would `swaymsg exit` the shared +# compositor, and the next case couldn't open the display (compositor teardown is +# the EXIT trap's job). +kill_waybar() { kill "$WAYBAR_PID" 2>/dev/null || true; sleep 1; WAYBAR_PID=""; } + +smoke::setup +# waybar is linked with ASan, but umockdev-run injects its own LD_PRELOAD, which +# lands ahead of the ASan runtime and makes ASan abort before main() ("ASan +# runtime does not come first"). Disabling the link-order check lets the +# instrumented binary run under the preload. +export ASAN_OPTIONS="${ASAN_OPTIONS}:verify_asan_link_order=0" +smoke::start_compositor +trap smoke::stop EXIT + +fail=0 + +BL_DEV="$(mktemp --suffix=.umockdev)" +cat > "$BL_DEV" <<'EOF' +P: /devices/platform/fake_backlight/backlight/intel_backlight +E: SUBSYSTEM=backlight +A: brightness=500 +A: max_brightness=1000 +A: actual_brightness=500 +A: bl_power=0 +EOF + +BAT_DEV="$(mktemp --suffix=.umockdev)" +cat > "$BAT_DEV" <<'EOF' +P: /devices/platform/fake_battery/power_supply/BAT0 +E: SUBSYSTEM=power_supply +E: POWER_SUPPLY_NAME=BAT0 +A: type=Battery +A: present=1 +A: status=Discharging +A: capacity=55 +A: energy_now=5500000 +A: energy_full=10000000 +A: power_now=1000000 +EOF + +# run_mock +run_mock() { + local name="$1" dev="$2" mod="$3" cfg + echo "::group::hardware: $name" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" < "$cfg" <<'EOF' +{ "layer": "top", "position": "top", "height": 30, + "modules-center": ["group/bl"], + "group/bl": { "modules": ["backlight/slider"] }, + "backlight/slider": { "min": 0, "max": 100, "orientation": "horizontal" } } +EOF +WAYBAR_WRAP="umockdev-run --device $BL_DEV --" smoke::launch_waybar "$cfg" "$DIR/style.css" 5 +smoke::assert_alive || fail=1 +smoke::assert_clean || fail=1 +kill_waybar +echo "::endgroup::" + +[ "$fail" = 0 ] || { echo "::error::hardware tier failed"; exit 1; } +echo "✓ fake-hardware tier passed" diff --git a/test/smoke/interact.sh b/test/smoke/interact.sh new file mode 100755 index 00000000..d73c3ad4 --- /dev/null +++ b/test/smoke/interact.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Tier 2 — interaction: inject real pointer input via sway and assert on the +# behaviour (on-click side effect, format-alt toggle), not on pixels alone. +# +# Usage: interact.sh [screenshot-dir] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-interaction-shots}" +mkdir -p "$SHOTDIR" + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +SEAT="$(swaymsg -t get_seats 2>/dev/null | jq -r '.[0].name' 2>/dev/null || echo seat0)" +CX=$((WIDTH / 2)); CY=15 + +click_center() { + swaymsg "seat $SEAT cursor set $CX $CY" >/dev/null + swaymsg "seat $SEAT cursor press button1" >/dev/null + swaymsg "seat $SEAT cursor release button1" >/dev/null +} + +fail=0 + +# --- 1) on-click side effect --------------------------------------------- +echo "::group::on-click side effect" +SENTINEL="$(mktemp -u)" +cfg1="$(mktemp --suffix=.json)" +cat > "$cfg1" </dev/null || true; sleep 1 +echo "::endgroup::" + +# --- 2) format-alt toggle on click --------------------------------------- +echo "::group::format-alt toggle" +cfg2="$(mktemp --suffix=.json)" +cat > "$cfg2" <&1 || true)" +ae="$(printf '%s' "$ae" | grep -oE '^[0-9]+' || echo 0)" +echo "pixels changed after click: $ae" +if [ "$ae" -gt 100 ]; then + echo "✓ format-alt toggled the label on click" +else + echo "::error::format-alt did not change the bar on click ($ae px)"; fail=1 +fi +kill "$WAYBAR_PID" 2>/dev/null || true +echo "::endgroup::" + +[ "$fail" = 0 ] || exit 1 +echo "✓ interaction tests passed" diff --git a/test/smoke/leakcheck.sh b/test/smoke/leakcheck.sh new file mode 100755 index 00000000..005f5740 --- /dev/null +++ b/test/smoke/leakcheck.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Report-only leak check. Runs the deterministic config, exits cleanly (SIGINT, +# so LeakSanitizer's atexit hook actually runs -- a SIGTERM kill would skip it), +# and surfaces any leak report. Never fails the job: leaks in GTK/GLib are too +# noisy to gate on, but regressions in our own code are worth seeing. +# +# Usage: leakcheck.sh +set -uo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +export SMOKE_DETECT_LEAKS=1 +smoke::setup +# Report leaks without aborting: abort_on_error=1 (the default gate) would turn +# LSan's exit-time report into a core dump. This tier only reports. +export ASAN_OPTIONS="detect_leaks=1:halt_on_error=0:abort_on_error=0:detect_odr_violation=0" +smoke::start_compositor +trap smoke::stop EXIT + +smoke::launch_waybar "$DIR/config.jsonc" "$DIR/style.css" 5 +smoke::assert_alive || true + +# Clean exit so LSan runs. +kill -INT "$WAYBAR_PID" 2>/dev/null || true +for _ in $(seq 1 20); do kill -0 "$WAYBAR_PID" 2>/dev/null || break; sleep 0.5; done +kill -9 "$WAYBAR_PID" 2>/dev/null || true + +if grep -iqE 'LeakSanitizer|Direct leak|Indirect leak' "$SMOKE_LOG"; then + echo "::warning::LeakSanitizer reported leaks (report-only)" + echo "::group::leak report" + grep -iE 'leak|SUMMARY|#[0-9]+ 0x' "$SMOKE_LOG" || true + echo "::endgroup::" +else + echo "✓ no leaks reported" +fi +WAYBAR_PID="" diff --git a/test/smoke/lib.sh b/test/smoke/lib.sh new file mode 100755 index 00000000..82367206 --- /dev/null +++ b/test/smoke/lib.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Shared helpers for the headless Waybar smoke/render/interaction tests. +# +# A test sources this file, then typically: +# smoke::setup +# smoke::start_compositor +# smoke::launch_waybar [style] +# smoke::assert_alive +# smoke::assert_clean # ASan/UBSan/Gtk-CRITICAL/segfault gate +# smoke::screenshot out.png +# smoke::stop +# +# Environment knobs (optional): WIDTH HEIGHT SCALE COMPOSITOR WAYBAR_BIN +set -uo pipefail + +WIDTH="${WIDTH:-1920}" +HEIGHT="${HEIGHT:-1080}" +SCALE="${SCALE:-1}" +COMPOSITOR="${COMPOSITOR:-sway}" +WAYBAR_BIN="${WAYBAR_BIN:-waybar}" +# Optional command prefix for the waybar launch (e.g. `umockdev-run -d dev --`), +# used by the hardware tier to fake sysfs/udev devices. Empty by default. +WAYBAR_WRAP="${WAYBAR_WRAP:-}" + +SMOKE_LOG="" +COMP_PID="" +WAYBAR_PID="" + +smoke::setup() { + export XDG_RUNTIME_DIR="$(mktemp -d)" + chmod 700 "$XDG_RUNTIME_DIR" + export WLR_BACKENDS=headless + export WLR_RENDERER=pixman + export WLR_LIBINPUT_NO_DEVICES=1 + export LIBGL_ALWAYS_SOFTWARE=1 + # Keep ASan/UBSan noise low but fatal on real errors (leaks in GTK/glib are + # too noisy to gate on, so only memory-corruption and UB abort the run). + # Leak detection is opt-in (leakcheck.sh sets SMOKE_DETECT_LEAKS=1) and needs + # a clean exit for LSan's atexit hook to run. + export ASAN_OPTIONS="detect_leaks=${SMOKE_DETECT_LEAKS:-0}:halt_on_error=1:abort_on_error=1:detect_odr_violation=0" + export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" +} + +smoke::start_compositor() { + case "$COMPOSITOR" in + sway) + local cfg; cfg="$(mktemp)" + cat > "$cfg" </tmp/compositor.log 2>&1 & + COMP_PID=$! + ;; + labwc) + # labwc reads outputs from the wlroots headless backend directly. + labwc >/tmp/compositor.log 2>&1 & + COMP_PID=$! + ;; + *) + echo "::error::unknown compositor '$COMPOSITOR'"; return 1 ;; + esac + + local sock="" + for _ in $(seq 1 40); do + sock="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-*' ! -name '*.lock' 2>/dev/null | head -1 || true)" + [ -n "$sock" ] && break + sleep 0.5 + done + if [ -z "$sock" ]; then + echo "::error::$COMPOSITOR did not create a Wayland socket"; cat /tmp/compositor.log || true; return 1 + fi + export WAYLAND_DISPLAY="$(basename "$sock")" + + # sway IPC socket, so swaymsg (cursor injection, clean exit) can reach it. + if [ "$COMPOSITOR" = sway ]; then + for _ in $(seq 1 10); do + SWAYSOCK="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'sway-ipc.*.sock' 2>/dev/null | head -1 || true)" + [ -n "$SWAYSOCK" ] && break + sleep 0.3 + done + export SWAYSOCK + fi + echo "compositor '$COMPOSITOR' up on WAYLAND_DISPLAY=$WAYLAND_DISPLAY (${WIDTH}x${HEIGHT} scale ${SCALE})" +} + +# smoke::launch_waybar [style] [settle_seconds] +smoke::launch_waybar() { + local config="$1" style="${2:-}" settle="${3:-6}" + SMOKE_LOG="$(mktemp)" + local args=(-c "$config" -l debug) + [ -n "$style" ] && args+=(-s "$style") + local run=() + [ -n "$WAYBAR_WRAP" ] && read -ra run <<<"$WAYBAR_WRAP" + run+=("$WAYBAR_BIN" "${args[@]}") + "${run[@]}" >"$SMOKE_LOG" 2>&1 & + WAYBAR_PID=$! + sleep "$settle" +} + +smoke::log() { cat "$SMOKE_LOG" 2>/dev/null || true; } + +smoke::assert_alive() { + if ! kill -0 "$WAYBAR_PID" 2>/dev/null; then + echo "::error::waybar exited early (crashed on startup)" + echo "::group::waybar log"; smoke::log; echo "::endgroup::" + return 1 + fi + echo "✓ waybar is alive" +} + +# Fail on sanitizer reports and GTK/GLib criticals in the log. +smoke::assert_clean() { + local bad + bad="$(grep -iE 'AddressSanitizer|runtime error:|LeakSanitizer|Gtk-CRITICAL|GLib-CRITICAL|GLib-GObject-CRITICAL|assertion .*failed|segfault|terminate called|SUMMARY: .*Sanitizer|ASan runtime does not come first' "$SMOKE_LOG" || true)" + if [ -n "$bad" ]; then + echo "::error::waybar reported sanitizer/critical issues:" + echo "$bad" + echo "::group::full waybar log"; smoke::log; echo "::endgroup::" + return 1 + fi + echo "✓ no sanitizer/critical issues in log" +} + +smoke::screenshot() { + local out="$1" + grim "$out" + echo "✓ screenshot -> $out" +} + +# Assert the top bar strip actually rendered something (not a uniform color). +smoke::assert_not_blank() { + local img="$1" h="${2:-30}" + local sd + sd="$(convert "$img" -crop "${WIDTH}x${h}+0+0" +repage -colorspace Gray -format '%[fx:standard_deviation]' info:)" + echo "bar strip stddev = $sd" + awk "BEGIN{ exit !($sd > 0.01) }" || { echo "::error::bar strip is blank in $img"; return 1; } + echo "✓ bar rendered content" +} + +# Send a signal to waybar and give it a moment to act on it. +# smoke::signal [settle_seconds] +smoke::signal() { + kill -"$1" "$WAYBAR_PID" 2>/dev/null || true + sleep "${2:-2}" +} + +# Reload waybar's config in place (recreates bars + modules). +smoke::reload() { smoke::signal SIGUSR2 "${1:-3}"; } + +# Create a headless output at runtime (sway only). wlroots' headless backend +# lets sway hotplug outputs, which makes waybar build a new Bar + module set. +smoke::add_output() { + [ "$COMPOSITOR" = sway ] || { echo "(add_output: sway only, skipped)"; return 0; } + swaymsg create_output >/dev/null 2>&1 || echo "(create_output unsupported, skipped)" + sleep 2 +} + +# Remove every headless output except the primary HEADLESS-1. Destroying an +# output tears down its Bar -> ~Bar -> GtkWindow unmap -> toggleSuspend(), i.e. +# the exact runtime path that segfaulted in #5182 -- exercised here under ASan. +smoke::remove_output() { + [ "$COMPOSITOR" = sway ] || return 0 + local o + for o in $(swaymsg -t get_outputs 2>/dev/null | jq -r '.[].name' 2>/dev/null | grep -v '^HEADLESS-1$' || true); do + swaymsg output "$o" unplug >/dev/null 2>&1 || true + done + sleep 2 +} + +# Shut waybar down the clean way (SIGINT -> Client::reset -> gtk quit -> +# bars.clear -> ~Bar) and assert the destructor path neither crashed nor tripped +# a sanitizer report. A plain SIGTERM/kill never runs this path, which is why the +# exit-time use-after-free (#5182) went unnoticed. Call instead of smoke::stop's +# kill when you want the teardown itself checked. +smoke::assert_clean_exit() { + [ -n "$WAYBAR_PID" ] || { echo "(no waybar running)"; return 0; } + kill -INT "$WAYBAR_PID" 2>/dev/null || true + local _ ; for _ in $(seq 1 20); do kill -0 "$WAYBAR_PID" 2>/dev/null || break; sleep 0.5; done + if kill -0 "$WAYBAR_PID" 2>/dev/null; then + echo "::error::waybar did not exit on SIGINT (hung during teardown)" + echo "::group::waybar log"; smoke::log; echo "::endgroup::" + kill -9 "$WAYBAR_PID" 2>/dev/null || true; WAYBAR_PID=""; return 1 + fi + local st=0; wait "$WAYBAR_PID" 2>/dev/null || st=$? + WAYBAR_PID="" + if [ "$st" -ge 128 ]; then + echo "::error::waybar crashed on exit (killed by signal $((st - 128)))" + echo "::group::waybar log"; smoke::log; echo "::endgroup::"; return 1 + fi + smoke::assert_clean || return 1 + echo "✓ clean exit (status $st)" +} + +smoke::stop() { + kill "$WAYBAR_PID" 2>/dev/null || true + swaymsg -q exit 2>/dev/null || kill "$COMP_PID" 2>/dev/null || true + # Wait only on the processes we own. A bare `wait` reaps *every* background + # job of the caller's shell, so a tier that leaves an unrelated daemon + # running (state.sh's mpd, killed later in its own cleanup) would deadlock + # here until GitHub's 6h job timeout. + local p + for p in "$WAYBAR_PID" "$COMP_PID"; do + [ -n "$p" ] && wait "$p" 2>/dev/null || true + done +} diff --git a/test/smoke/lifecycle.sh b/test/smoke/lifecycle.sh new file mode 100755 index 00000000..d8210b89 --- /dev/null +++ b/test/smoke/lifecycle.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Tier 4 — lifecycle / teardown safety net. +# +# Everything above renders in steady state and kills waybar with SIGTERM, which +# never runs the C++ destructors. The crash classes that hurt most (exit, +# DPMS/output unmap, reload) all live in that create/destroy path, so exercise it +# explicitly under ASan: +# 1. clean exit on SIGINT (the real Bar teardown path, #5182) +# 2. runtime output hotplug (bar + module destruction while running, #5182) +# 3. visibility toggle + config reload churn (SIGUSR1 / SIGUSR2) +# 4. fast-interval modules torn down mid-tick (thread vs destructor races) +# +# Usage: lifecycle.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +CFG="$DIR/config.jsonc" +STYLE="$DIR/style.css" + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +fail=0 + +# --- 1) clean exit on SIGINT (destructor path, #5182) -------------------- +echo "::group::clean exit (SIGINT)" +smoke::launch_waybar "$CFG" "$STYLE" 5 +smoke::assert_alive || fail=1 +smoke::assert_clean_exit || fail=1 +echo "::endgroup::" + +# --- 2) output hotplug at runtime (bar/module destroy, #5182) ------------ +if [ "$COMPOSITOR" = sway ]; then + echo "::group::output hotplug" + smoke::launch_waybar "$CFG" "$STYLE" 5 + smoke::assert_alive || fail=1 + for _ in 1 2 3; do + smoke::add_output + smoke::assert_alive || { fail=1; break; } + smoke::remove_output # destroys that output's Bar -> ~Bar -> unmap + smoke::assert_alive || { fail=1; break; } + done + smoke::assert_clean || fail=1 + smoke::assert_clean_exit || fail=1 + echo "::endgroup::" +fi + +# --- 3) visibility toggle + reload churn --------------------------------- +echo "::group::signal churn (toggle + reload)" +smoke::launch_waybar "$CFG" "$STYLE" 5 +smoke::assert_alive || fail=1 +for _ in 1 2 3; do smoke::signal SIGUSR1 1; done # hide/show mode churn +smoke::reload # SIGUSR2: recreate bars/modules +smoke::assert_alive || fail=1 +smoke::assert_clean_exit || fail=1 +echo "::endgroup::" + +# --- 4) fast-interval modules torn down mid-tick ------------------------- +echo "::group::fast-interval teardown race" +cfg="$(mktemp --suffix=.json)" +cat > "$cfg" <<'EOF' +{ + "layer": "top", "position": "top", "height": 30, + "modules-center": ["clock", "cpu", "memory", "custom/tick"], + "clock": { "interval": 1 }, + "cpu": { "interval": 1 }, + "memory": { "interval": 1 }, + "custom/tick": { "exec": "date +%s", "interval": 1 } +} +EOF +smoke::launch_waybar "$cfg" "$STYLE" 8 +smoke::assert_alive || fail=1 +smoke::assert_clean_exit || fail=1 # SIGINT while worker threads are mid-tick +echo "::endgroup::" + +[ "$fail" = 0 ] || { echo "::error::lifecycle tier failed"; exit 1; } +echo "✓ lifecycle tier passed" diff --git a/test/smoke/modules.sh b/test/smoke/modules.sh new file mode 100755 index 00000000..b7e2541a --- /dev/null +++ b/test/smoke/modules.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Tier 1 — per-module matrix: render each headless-safe module in isolation and +# assert it loads, logs no sanitizer/critical error, and draws something. +# +# Usage: modules.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-module-shots}" +mkdir -p "$SHOTDIR" + +# module name -> its config options (JSON object). Only modules that work with +# no real hardware/daemon in a headless runner are included. +declare -A MODULES=( + [clock]='{}' + [cpu]='{}' + [memory]='{}' + [disk]='{}' + [idle_inhibitor]='{"format":"{status}"}' + [custom/echo]='{"exec":"echo hello","interval":"once"}' +) + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT + +fails=() +for mod in "${!MODULES[@]}"; do + safe="${mod//\//-}" + cfg="$(mktemp --suffix=.json)" + cat > "$cfg" < "$gcfg" +smoke::launch_waybar "$gcfg" "$DIR/style.css" 5 +ok=1 +smoke::assert_alive || ok=0 +smoke::assert_clean || ok=0 +[ "$ok" = 1 ] && { smoke::screenshot "$SHOTDIR/_group.png"; smoke::assert_not_blank "$SHOTDIR/_group.png" || ok=0; } +smoke::assert_clean_exit || ok=0 +[ "$ok" = 1 ] || fails+=("group") +echo "::endgroup::" + +if [ "${#fails[@]}" -gt 0 ]; then + echo "::error::modules failed to render cleanly: ${fails[*]}" + exit 1 +fi +echo "✓ all ${#MODULES[@]} modules rendered cleanly (standalone + grouped)" diff --git a/test/smoke/positions.sh b/test/smoke/positions.sh new file mode 100755 index 00000000..9573bb8a --- /dev/null +++ b/test/smoke/positions.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Tier 3 — layout matrix: bar at top/bottom (horizontal) and left (vertical), +# plus a HiDPI (scale 2) run. Each must launch and render without a crash or +# sanitizer/critical error. Screenshots are uploaded for visual inspection. +# +# Usage: positions.sh [screenshot-dir] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +SHOTDIR="${1:-layout-shots}" +mkdir -p "$SHOTDIR" + +smoke::setup +trap smoke::stop EXIT + +fails=() + +# run_case +run_case() { + local name="$1" pos="$2" scale="$3" + echo "::group::layout: $name (position=$pos scale=$scale)" + SCALE="$scale" smoke::start_compositor || { fails+=("$name"); echo "::endgroup::"; return; } + local cfg; cfg="$(mktemp --suffix=.json)" + cat > "$cfg" < [style] [screenshot.png] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +CONFIG="$1"; STYLE="${2:-}"; SHOT="${3:-}" + +smoke::setup +smoke::start_compositor +trap smoke::stop EXIT +smoke::launch_waybar "$CONFIG" "$STYLE" + +echo "::group::waybar log"; smoke::log; echo "::endgroup::" + +smoke::assert_alive +smoke::assert_clean +[ -n "$SHOT" ] && smoke::screenshot "$SHOT" diff --git a/test/smoke/state.sh b/test/smoke/state.sh new file mode 100755 index 00000000..697347f5 --- /dev/null +++ b/test/smoke/state.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Tier — state-transition coverage with real daemons. Steady-state rendering +# never hits the empty/edge states where modules crash. Drive an actual mpd and +# PulseAudio through those states under ASan: +# - mpd: connect with an EMPTY queue (no current song -> null song_, #5183), +# then clear/add/stop/play while waybar is live +# - pulseaudio + pulseaudio/slider against a null sink +# +# Best-effort: each sub-tier is skipped (not failed) if its daemon isn't +# installed. Run continue-on-error in CI while the fixtures settle. +# +# Usage: state.sh +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" + +fail=0 +MPD_PID="" +PA_STARTED="" + +cleanup() { + # Tear our daemons down first: smoke::stop waits on the compositor/waybar, + # and a still-running mpd/pulseaudio must not be left for anything to block on. + [ -n "$MPD_PID" ] && kill "$MPD_PID" 2>/dev/null || true + [ -n "$PA_STARTED" ] && pulseaudio --kill 2>/dev/null || true + smoke::stop + return 0 +} + +smoke::setup +smoke::start_compositor +trap cleanup EXIT + +# ---------------------------------------------------------------- mpd ----- +mpd_tier() { + if ! command -v mpd >/dev/null || ! command -v mpc >/dev/null; then + echo "(mpd/mpc not installed, skipping mpd tier)"; return 0 + fi + echo "::group::mpd: empty queue + transitions (#5183)" + local d conf; d="$(mktemp -d)"; conf="$d/mpd.conf" + mkdir -p "$d/music" "$d/playlists" + cat > "$conf" </tmp/mpd.log 2>&1 & + MPD_PID=$! + local up=0 _ + for _ in $(seq 1 20); do + mpc -h 127.0.0.1 -p 6600 status >/dev/null 2>&1 && { up=1; break; } + sleep 0.5 + done + if [ "$up" != 1 ]; then + echo "::warning::mpd did not come up; skipping"; cat /tmp/mpd.log || true; echo "::endgroup::"; return 0 + fi + + mpc -h 127.0.0.1 -p 6600 clear >/dev/null 2>&1 || true # empty queue: the crash trigger + + local cfg; cfg="$(mktemp --suffix=.json)" + cat > "$cfg" <<'EOF' +{ + "layer": "top", "position": "top", "height": 30, + "modules-center": ["mpd"], + "mpd": { "server": "127.0.0.1", "port": 6600, "interval": 1, + "format": "{album} - {artist} - {title}", "format-stopped": "stopped" } +} +EOF + smoke::launch_waybar "$cfg" "$DIR/style.css" 5 + smoke::assert_alive || fail=1 # would have segfaulted pre-#5183 fix + smoke::assert_clean || fail=1 + # Churn state while live: clear again, then stop/play. + mpc -h 127.0.0.1 -p 6600 clear >/dev/null 2>&1 || true; sleep 2 + mpc -h 127.0.0.1 -p 6600 stop >/dev/null 2>&1 || true; sleep 2 + smoke::assert_alive || fail=1 + smoke::assert_clean_exit || fail=1 + echo "::endgroup::" +} + +# -------------------------------------------------------- pulseaudio ------ +pulse_tier() { + if ! command -v pulseaudio >/dev/null; then + echo "(pulseaudio not installed, skipping pulse tier)"; return 0 + fi + echo "::group::pulseaudio + pulseaudio/slider (null sink)" + export PULSE_RUNTIME_PATH="$XDG_RUNTIME_DIR/pulse" + if pulseaudio --start --exit-idle-time=-1 --log-target=stderr >/tmp/pa.log 2>&1; then + PA_STARTED=1 + else + echo "::warning::pulseaudio failed to start; skipping"; cat /tmp/pa.log || true; echo "::endgroup::"; return 0 + fi + pactl load-module module-null-sink sink_name=smoke_null >/dev/null 2>&1 || true + + local cfg; cfg="$(mktemp --suffix=.json)" + cat > "$cfg" <<'EOF' +{ + "layer": "top", "position": "top", "height": 30, + "modules-center": ["pulseaudio", "pulseaudio/slider"], + "pulseaudio": { "format": "{volume}%" }, + "pulseaudio/slider": { "min": 0, "max": 100, "orientation": "horizontal" } +} +EOF + smoke::launch_waybar "$cfg" "$DIR/style.css" 5 + smoke::assert_alive || fail=1 + smoke::assert_clean || fail=1 + pactl set-sink-volume @DEFAULT_SINK@ 40% >/dev/null 2>&1 || true; sleep 1 + pactl set-sink-mute @DEFAULT_SINK@ 1 >/dev/null 2>&1 || true; sleep 1 + smoke::assert_alive || fail=1 + smoke::assert_clean_exit || fail=1 + echo "::endgroup::" +} + +mpd_tier +pulse_tier + +[ "$fail" = 0 ] || { echo "::error::state tier failed"; exit 1; } +echo "✓ state-transition tier passed" diff --git a/test/smoke/style.css b/test/smoke/style.css new file mode 100644 index 00000000..bf842ae1 --- /dev/null +++ b/test/smoke/style.css @@ -0,0 +1,27 @@ +/* Deterministic stylesheet for the CI smoke/screenshot test. + * A pinned font (DejaVu Sans, installed in CI) and fixed colors keep the + * rendered bar reproducible across runs for the golden comparison. */ +* { + font-family: "DejaVu Sans"; + font-size: 14px; + border: none; + border-radius: 0; + min-height: 0; +} + +window#waybar { + background-color: #1e1e2e; + color: #cdd6f4; +} + +#custom-left, +#custom-center, +#custom-right { + padding: 0 16px; + color: #cdd6f4; +} + +#custom-left { + font-weight: bold; + color: #89b4fa; +} diff --git a/test/utils/command_line_stream.cpp b/test/utils/command_line_stream.cpp new file mode 100644 index 00000000..b852a6e8 --- /dev/null +++ b/test/utils/command_line_stream.cpp @@ -0,0 +1,68 @@ +#if __has_include() +#include +#else +#include +#endif + +#include + +#include +#include +#include + +#include "util/command_line_stream.hpp" + +namespace { + +struct StreamResult { + std::vector lines; + std::optional exit_code; + bool timed_out = false; +}; + +auto run_stream_command(const std::string& cmd) -> StreamResult { + StreamResult result; + auto loop = Glib::MainLoop::create(); + + auto timeout = Glib::signal_timeout().connect( + [&]() { + result.timed_out = true; + loop->quit(); + return false; + }, + 3000); + + waybar::util::command::LineStream stream( + "", [&](const std::string& line) { result.lines.push_back(line); }, + [&](int exit_code) { + result.exit_code = exit_code; + loop->quit(); + }); + + stream.start(cmd); + loop->run(); + timeout.disconnect(); + return result; +} + +} // namespace + +TEST_CASE("command::LineStream emits complete lines and flushes trailing output", + "[util][command_line_stream]") { + const auto result = run_stream_command("printf 'first\\nsecond'"); + + REQUIRE_FALSE(result.timed_out); + REQUIRE(result.exit_code.has_value()); + REQUIRE(*result.exit_code == 0); + REQUIRE(result.lines == std::vector{"first", "second"}); +} + +TEST_CASE("command::LineStream does not emit an extra line after newline-terminated output", + "[util][command_line_stream]") { + const auto result = run_stream_command("printf 'first\\nsecond\\n'"); + + REQUIRE_FALSE(result.timed_out); + REQUIRE(result.exit_code.has_value()); + REQUIRE(*result.exit_code == 0); + REQUIRE(result.lines == std::vector{"first", "second"}); +} diff --git a/test/utils/css_reload_helper.cpp b/test/utils/css_reload_helper.cpp index f3888a83..0ba0bde4 100644 --- a/test/utils/css_reload_helper.cpp +++ b/test/utils/css_reload_helper.cpp @@ -10,7 +10,7 @@ class CssReloadHelperTest : public waybar::CssReloadHelper { public: - CssReloadHelperTest() : CssReloadHelper("/tmp/waybar_test.css", [this]() { callback(); }) {} + CssReloadHelperTest() : CssReloadHelper("/tmp/waybar_test.css", [this](const std::string&) { callback(); }) {} void callback() { m_callbackCounter++; } diff --git a/test/utils/fixtures/GlibTestsFixture.hpp b/test/utils/fixtures/GlibTestsFixture.hpp index a21c8e07..95ce7f95 100644 --- a/test/utils/fixtures/GlibTestsFixture.hpp +++ b/test/utils/fixtures/GlibTestsFixture.hpp @@ -6,10 +6,16 @@ class GlibTestsFixture : public sigc::trackable { public: GlibTestsFixture() : main_loop_{Glib::MainLoop::create()} {} + ~GlibTestsFixture() { timeout_.disconnect(); } void setTimeout(int timeout) { - Glib::signal_timeout().connect_once([]() { throw std::runtime_error("Test timed out"); }, - timeout); + timeout_.disconnect(); + timeout_ = Glib::signal_timeout().connect( + []() { + throw std::runtime_error("Test timed out"); + return false; + }, + timeout); } void run(std::function fn) { @@ -21,4 +27,5 @@ class GlibTestsFixture : public sigc::trackable { protected: Glib::RefPtr main_loop_; + sigc::connection timeout_; }; diff --git a/test/utils/format.cpp b/test/utils/format.cpp new file mode 100644 index 00000000..63dc4eaa --- /dev/null +++ b/test/utils/format.cpp @@ -0,0 +1,105 @@ +#include "util/format.hpp" + +#if __has_include() +#include +#else +#include +#endif + +#include + +// Helpers to build the two representative sample values used throughout the +// pow_format modifier tests. +static std::string fmtA(const char* spec) { + // 1536 bytes, binary (base 1024) -> auto "1.5kiB" + return fmt::format(fmt::runtime(std::string("{:") + spec + "}"), pow_format(1536, "B", true)); +} +static std::string fmtB(const char* spec) { + // 1500000 b/s, decimal (base 1000) -> auto "1.5Mb/s" + return fmt::format(fmt::runtime(std::string("{:") + spec + "}"), pow_format(1500000, "b/s")); +} + +TEST_CASE("pow_format default/auto rendering", "[format][pow_format]") { + REQUIRE(fmt::format("{}", pow_format(1536, "B", true)) == "1.5kiB"); + REQUIRE(fmt::format("{}", pow_format(1500000, "b/s")) == "1.5Mb/s"); + // Sub-scale values and unit-only rendering. + REQUIRE(fmt::format("{}", pow_format(500, "B")) == "500.0B"); +} + +TEST_CASE("pow_format hide/show unit (u/U)", "[format][pow_format]") { + REQUIRE(fmtA("u") == "1.5ki"); // unit hidden, scale prefix (ki) kept + REQUIRE(fmtA("U") == "1.5kiB"); // explicit show = auto default + REQUIRE(fmtB("u") == "1.5M"); + REQUIRE(fmtB("U") == "1.5Mb/s"); +} + +TEST_CASE("pow_format force integer (i)", "[format][pow_format]") { + REQUIRE(fmtA("i") == "2kiB"); // 1.5 -> 2 + REQUIRE(fmtA("iu") == "2ki"); + REQUIRE(fmtB("i") == "2Mb/s"); +} + +TEST_CASE("pow_format forced scale hides prefix and unit", "[format][pow_format]") { + // Base scale (#): no prefix; unit off by default, back on with U. + REQUIRE(fmtA("#") == "1536.0"); + REQUIRE(fmtA("#U") == "1536.0B"); + REQUIRE(fmtA("#i") == "1536"); + REQUIRE(fmtB("#") == "1500000.0"); + REQUIRE(fmtB("#U") == "1500000.0b/s"); + + // Force kilo: whole prefix (ki) suppressed, unit off by default. + REQUIRE(fmtA("k") == "1.5"); + REQUIRE(fmtA("kU") == "1.5B"); // unit shown, no 'i' (it lives on the prefix) + REQUIRE(fmtA("ki") == "2"); + REQUIRE(fmtB("k") == "1500.0"); + REQUIRE(fmtB("kU") == "1500.0b/s"); + REQUIRE(fmtB("ki") == "1500"); + + // Force a scale far above the value's magnitude -> underflow to 0, no bump. + REQUIRE(fmtA("M") == "0.0"); + REQUIRE(fmtA("MU") == "0.0B"); + REQUIRE(fmtB("G") == "0.0"); +} + +TEST_CASE("pow_format force base (b/B)", "[format][pow_format]") { + // Force decimal on a binary call-site value. + REQUIRE(fmtA("b") == "1.5kB"); // base 1000, prefix 'k', no 'i' + REQUIRE(fmtA("bu") == "1.5k"); + REQUIRE(fmtA("b#") == "1536.0"); + + // Force binary on a decimal call-site value. + REQUIRE(fmtB("B") == "1.4Mib/s"); // base 1024, prefix 'Mi' + REQUIRE(fmtB("Bu") == "1.4Mi"); + REQUIRE(fmtB("Bk") == "1464.8"); + REQUIRE(fmtB("BkU") == "1464.8b/s"); +} + +TEST_CASE("pow_format fixed width with # overflow", "[format][pow_format]") { + // Base scale, numeric field width 3: 1536.0 (6 chars) overflows -> ###. + REQUIRE(fmtA("=3#") == "###"); + REQUIRE(fmtA("=6#") == "1536.0"); + REQUIRE(fmtA("=3#U") == "###B"); + REQUIRE(fmtA("=6#U") == "1536.0B"); + REQUIRE(fmtB("=4k#") == "####"); + // Width digit position is flexible: {:=3#} and {:=#3} are equivalent. + REQUIRE(fmtA("=#3") == "###"); + // Force kilo, width 6, coefficient "1500" fits, shown with unit; the '=' + // column left-justifies the coefficient in its field. + REQUIRE(fmtB("=6kiU") == "1500 b/s"); +} + +TEST_CASE("pow_format modifiers compose with alignment", "[format][pow_format]") { + REQUIRE(fmtA(">u") == " 1.5ki"); // right-align, unit hidden (prefix kept) + REQUIRE(fmtB(">ki") == "1500"); // force kilo+int (unit off), no scale width + REQUIRE(fmtB("Bu") == "1.4Mi"); // force binary, unit hidden +} + +TEST_CASE("pow_format backward compatible alignment", "[format][pow_format]") { + // These specs predate the new modifiers and must render as before. + REQUIRE(fmt::format("{:>}", pow_format(1536, "B", true)) == " 1.5kiB"); + REQUIRE(fmt::format("{:<}", pow_format(1536, "B", true)) == "1.5kiB "); + REQUIRE(fmt::format("{:=}", pow_format(1536, "B", true)) == "1.5 kiB"); + // Width without a forced scale is still ignored (only used for compat). + REQUIRE(fmt::format("{:>9}", pow_format(1536, "B", true)) == " 1.5kiB"); + REQUIRE(fmt::format("{}", pow_format(1500000, "b/s")) == "1.5Mb/s"); +} diff --git a/test/utils/meson.build b/test/utils/meson.build index e8dd37fa..fb4ad60e 100644 --- a/test/utils/meson.build +++ b/test/utils/meson.build @@ -13,10 +13,13 @@ test_src = files( '../../src/config.cpp', 'JsonParser.cpp', 'SafeSignal.cpp', + 'format.cpp', 'sleeper_thread.cpp', 'command.cpp', + 'command_line_stream.cpp', 'css_reload_helper.cpp', '../../src/util/css_reload_helper.cpp', + '../../src/util/command_line_stream.cpp', ) if tz_dep.found()