diff --git a/CMakeLists.txt b/CMakeLists.txt index 7425fe5..8d0cd17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(bar_lib src/bar/bar.cpp src/widgets/clock.cpp src/widgets/workspaceIndicator.cpp + src/widgets/volumeWidget.cpp src/services/hyprland.cpp src/services/tray.cpp src/widgets/tray.cpp diff --git a/include/bar/bar.hpp b/include/bar/bar.hpp index ecdd93f..0950cb5 100644 --- a/include/bar/bar.hpp +++ b/include/bar/bar.hpp @@ -27,6 +27,7 @@ class Bar : public Gtk::Window int m_monitorId; WorkspaceIndicator *m_workspaceIndicator = nullptr; TrayWidget *m_trayWidget = nullptr; + class VolumeWidget *m_volumeWidget = nullptr; void setup_ui(); void load_css(); diff --git a/include/helpers/systemHelper.hpp b/include/helpers/systemHelper.hpp index 0900846..a3d7e0e 100644 --- a/include/helpers/systemHelper.hpp +++ b/include/helpers/systemHelper.hpp @@ -1,7 +1,10 @@ #pragma once +#include #include #include +#include +#include class SystemHelper { @@ -25,4 +28,18 @@ class SystemHelper return result; } + + // Read an entire file into a string. Throws std::runtime_error on failure. + static std::string read_file_to_string(const std::string &path) + { + std::ifstream in(path, std::ios::in | std::ios::binary); + if (!in) + { + throw std::runtime_error("Failed to open file: " + path); + } + + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } }; diff --git a/include/widgets/spacer.hpp b/include/widgets/spacer.hpp new file mode 100644 index 0000000..c92f6c4 --- /dev/null +++ b/include/widgets/spacer.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + + +class Spacer : public Gtk::Label +{ + public: + Spacer() + { + set_hexpand(true); + set_text("|"); + set_name("spacer"); + } +}; \ No newline at end of file diff --git a/include/widgets/volumeWidget.hpp b/include/widgets/volumeWidget.hpp new file mode 100644 index 0000000..72fcab7 --- /dev/null +++ b/include/widgets/volumeWidget.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +class VolumeWidget : public Gtk::Box +{ + public: + VolumeWidget(); + virtual ~VolumeWidget(); + + // Refresh displayed volume from the system + void update(); + + protected: + // timeout handler for periodic polling; return true to keep polling + bool on_timeout(); + + private: + Gtk::Label m_label; + Glib::RefPtr m_click; + sigc::connection m_timeoutConn; +}; diff --git a/resources/bar.css b/resources/bar.css new file mode 100644 index 0000000..50f1b69 --- /dev/null +++ b/resources/bar.css @@ -0,0 +1,61 @@ +* { + all: unset; /* Tries to remove all styling */ +} + +window { +/* sleak modern */ + background-color: rgba(30, 30, 30, 0.8); + color: #ffffff; + font-family: "IBMPlexSans-Regular", sans-serif; + font-size: 14px; + padding: 2px 7px; + +} + +#clock-label { + font-weight: bold; + font-family: monospace; +} + +.workspace-pill { + background-color: rgba(255, 255, 255, 0.12); + padding: 2px 5px; + margin-right: 6px; + border-radius: 5px; +} + +.workspace-pill:last-child { + margin-right: 0; +} + +.workspace-pill-focused { + background-color: rgba(255, 255, 255, 0.18); +} + +.workspace-pill-active { + background-color: rgba(255, 255, 255, 0.25); +} + +.workspace-pill-urgent { + background-color: #ff5555; + color: #111; +} + +/* Hover effect: slightly brighten background and show pointer */ +.workspace-pill:hover { + background-color: rgba(255, 255, 255, 0.20); +} + +.tray-icon { + padding: 0; + margin: 0; + border: none; + background: transparent; + min-width: 0; + min-height: 0; +} + +#spacer { + color: rgba(255, 255, 255, 0.3); + padding: 0 5px; +} \ No newline at end of file diff --git a/src/bar/bar.cpp b/src/bar/bar.cpp index 47f1835..b866ac2 100644 --- a/src/bar/bar.cpp +++ b/src/bar/bar.cpp @@ -1,5 +1,10 @@ #include "bar/bar.hpp" +#include "gtk/gtk.h" +#include "widgets/spacer.hpp" #include "widgets/workspaceIndicator.hpp" +#include "widgets/volumeWidget.hpp" + +#include "helpers/systemHelper.hpp" #include #include @@ -11,6 +16,9 @@ Bar::Bar(GdkMonitor *monitor, HyprlandService &hyprlandService, TrayService &trayService, int monitorId) : m_hyprlandService(hyprlandService), m_trayService(trayService), m_monitorId(monitorId) { + // Name the window so CSS can be scoped specifically to this bar. + set_name("bar-window"); + gtk_layer_init_for_window(this->gobj()); if (monitor) @@ -64,6 +72,11 @@ void Bar::setup_ui() clock.set_halign(Gtk::Align::CENTER); clock.set_valign(Gtk::Align::CENTER); center_box.append(clock); + center_box.append(*(new Spacer())); + + // Volume widget placed after spacer + m_volumeWidget = Gtk::make_managed(); + center_box.append(*m_volumeWidget); m_trayWidget = Gtk::make_managed(m_trayService); right_box.append(*m_trayWidget); @@ -73,21 +86,12 @@ void Bar::load_css() { auto css_provider = Gtk::CssProvider::create(); - css_provider->load_from_data(R"( - #clock-label { font-weight: bold; font-family: monospace; } - .workspace-pill { background-color: rgba(255, 255, 255, 0.12); border-radius: 8px; padding: 2px 8px; margin-right: 6px; } - .workspace-pill:last-child { margin-right: 0; } - .workspace-pill-focused { background-color: rgba(255, 255, 255, 0.18); } - .workspace-pill-active { background-color: rgba(255, 255, 255, 0.25); } - .workspace-pill-urgent { background-color: #ff5555; color: #111; } - /* Hover effect: slightly brighten background and show pointer */ - .workspace-pill:hover { background-color: rgba(255, 255, 255, 0.20); } - .workspace-pill:hover { cursor: pointer; } // TODO: cursors has to be set differently in GTK4? - .tray-icon { padding: 0; margin: 0; border: none; background: transparent; min-width: 0; min-height: 0; } - )"); + // Load CSS from external resource file. Fall back to embedded CSS on error. + const std::string css = SystemHelper::read_file_to_string("resources/bar.css"); + css_provider->load_from_data(css); Gtk::StyleContext::add_provider_for_display( Gdk::Display::get_default(), css_provider, - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + GTK_STYLE_PROVIDER_PRIORITY_USER + 1); } diff --git a/src/widgets/volumeWidget.cpp b/src/widgets/volumeWidget.cpp new file mode 100644 index 0000000..396490e --- /dev/null +++ b/src/widgets/volumeWidget.cpp @@ -0,0 +1,112 @@ +#include "widgets/volumeWidget.hpp" + +#include "helpers/systemHelper.hpp" + +#include +#include +#include +#include + +VolumeWidget::VolumeWidget() + : Gtk::Box(Gtk::Orientation::HORIZONTAL) +{ + set_valign(Gtk::Align::CENTER); + set_halign(Gtk::Align::CENTER); + + m_label.set_halign(Gtk::Align::CENTER); + m_label.set_valign(Gtk::Align::CENTER); + m_label.set_text("Vol"); + + append(m_label); + + // Click toggles mute using wpctl + m_click = Gtk::GestureClick::create(); + m_click->set_button(GDK_BUTTON_PRIMARY); + // signal_released provides (int, double, double) — use lambda to ignore args + m_click->signal_released().connect([this](int /*n_press*/, double /*x*/, double /*y*/) + { + try + { + // Toggle mute then refresh + (void)SystemHelper::get_command_output("wpctl set-mute @DEFAULT_SINK@ toggle"); + } + catch (const std::exception &ex) + { + std::cerr << "[VolumeWidget] failed to toggle mute: " << ex.what() << std::endl; + } + this->update(); + }); + add_controller(m_click); + + // Initial read + update(); + + // Start polling every 1 second to keep the display up to date + m_timeoutConn = Glib::signal_timeout().connect(sigc::mem_fun(*this, &VolumeWidget::on_timeout), 100); +} + +VolumeWidget::~VolumeWidget() +{ + if (m_timeoutConn.connected()) + m_timeoutConn.disconnect(); +} + +void VolumeWidget::update() +{ + try + { + const std::string out = SystemHelper::get_command_output("wpctl get-volume @DEFAULT_SINK@"); + + // Attempt to parse a number (percentage or fraction) + std::smatch m; + std::regex r_percent(R"((\d+(?:\.\d+)?)%)"); + std::regex r_number(R"((\d+(?:\.\d+)?))"); + + std::string text = out; + int percent = -1; + + if (std::regex_search(text, m, r_percent)) + { + percent = static_cast(std::round(std::stod(m[1].str()))); + } + else if (std::regex_search(text, m, r_number)) + { + // If number looks like 0.8 treat as fraction + const double v = std::stod(m[1].str()); + if (v <= 1.0) + percent = static_cast(std::round(v * 100.0)); + else + percent = static_cast(std::round(v)); + } + + if (percent >= 0) + { + m_label.set_text(std::to_string(percent) + "%"); + } + else + { + // Fallback to raw output (trimmed) + auto pos = text.find_first_not_of(" \t\n\r"); + if (pos != std::string::npos) + { + auto end = text.find_last_not_of(" \t\n\r"); + m_label.set_text(text.substr(pos, end - pos + 1)); + } + else + { + m_label.set_text("?"); + } + } + } + catch (const std::exception &ex) + { + std::cerr << "[VolumeWidget] failed to read volume: " << ex.what() << std::endl; + m_label.set_text("N/A"); + } +} + +bool VolumeWidget::on_timeout() +{ + update(); + return true; // keep timeout active +}