From 570d4224a0aa1c7d53bcb9ad69dc8ea2723ddc24 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 19 Jun 2026 08:38:38 +0800 Subject: [PATCH 1/9] CI: use /tmp for ecosystem clone, avoid permissions conflict --- .github/workflows/release.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5e7668..f02a494 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: env: DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci jobs: build: @@ -37,12 +37,8 @@ jobs: - name: ensure bread-ecosystem run: | - if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then - git -C "${ECOSYSTEM_DIR}" pull --ff-only - else - mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" - git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" - fi + rm -rf "${ECOSYSTEM_DIR}" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" - name: regenerate index.json run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" From 3ae3eff59efc34bb190cf33c0927caefbc372feb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:43:52 +0800 Subject: [PATCH 2/9] Release 0.2.0: media widget, wifi popover, control panel --- Cargo.toml | 2 +- src/bar/control.rs | 131 ++++++++ src/bar/media.rs | 85 ++++++ src/bar/mod.rs | 3 + src/bar/stats.rs | 107 ++++++- src/bar/wifi.rs | 144 +++++++++ src/main.rs | 726 ++++++++++++++++++++++++++++++++++++++++----- src/theme.rs | 40 ++- 8 files changed, 1157 insertions(+), 81 deletions(-) create mode 100644 src/bar/control.rs create mode 100644 src/bar/media.rs create mode 100644 src/bar/wifi.rs diff --git a/Cargo.toml b/Cargo.toml index 0950f3a..0794931 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.7" +version = "0.2.0" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/bar/control.rs b/src/bar/control.rs new file mode 100644 index 0000000..ed99568 --- /dev/null +++ b/src/bar/control.rs @@ -0,0 +1,131 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct AudioSink { + pub name: String, + pub description: String, + pub is_default: bool, +} + +#[derive(Debug, Clone)] +pub struct ControlPanelData { + pub volume: f64, + pub brightness: f64, + pub sinks: Vec, +} + +async fn fetch_volume() -> f64 { + let out = tokio::time::timeout( + Duration::from_secs(2), + tokio::process::Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output(), + ) + .await; + match out { + Ok(Ok(o)) if o.status.success() => String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix("Volume:") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.5) + .clamp(0.0, 1.5), + _ => 0.5, + } +} + +async fn fetch_brightness() -> f64 { + let cur = tokio::process::Command::new("brightnessctl") + .arg("get") + .output() + .await + .ok() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::().ok()) + .unwrap_or(0.0); + let max = tokio::process::Command::new("brightnessctl") + .arg("max") + .output() + .await + .ok() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::().ok()) + .unwrap_or(255.0); + if max == 0.0 { + 0.5 + } else { + (cur / max).clamp(0.0, 1.0) + } +} + +async fn fetch_sinks() -> Vec { + let default = tokio::process::Command::new("pactl") + .args(["info"]) + .output() + .await + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .find(|l| l.starts_with("Default Sink:")) + .map(|l| l.trim_start_matches("Default Sink:").trim().to_string()) + }) + .unwrap_or_default(); + + let out = tokio::process::Command::new("pactl") + .args(["-f", "json", "list", "sinks"]) + .output() + .await; + + let Ok(o) = out else { return vec![] }; + let arr: Vec = serde_json::from_slice(&o.stdout).unwrap_or_default(); + arr.into_iter() + .filter_map(|v| { + let name = v["name"].as_str()?.to_string(); + let description = v["description"].as_str().unwrap_or(&name).to_string(); + let is_default = name == default; + Some(AudioSink { name, description, is_default }) + }) + .collect() +} + +pub fn spawn_load(sender: ComponentSender) { + relm4::spawn(async move { + let (volume, brightness, sinks) = + tokio::join!(fetch_volume(), fetch_brightness(), fetch_sinks()); + sender.input(AppInput::ControlPanelData(ControlPanelData { + volume, + brightness, + sinks, + })); + }); +} + +pub fn spawn_set_volume(v: f64) { + relm4::spawn(async move { + let pct = format!("{:.0}%", (v * 100.0).clamp(0.0, 150.0)); + let _ = tokio::process::Command::new("wpctl") + .args(["set-volume", "@DEFAULT_AUDIO_SINK@", &pct]) + .output() + .await; + }); +} + +pub fn spawn_set_brightness(v: f64) { + relm4::spawn(async move { + let pct = format!("{:.0}%", (v * 100.0).clamp(1.0, 100.0)); + let _ = tokio::process::Command::new("brightnessctl") + .args(["set", &pct]) + .output() + .await; + }); +} + +pub fn spawn_set_sink(name: String) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("pactl") + .args(["set-default-sink", &name]) + .output() + .await; + }); +} diff --git a/src/bar/media.rs b/src/bar/media.rs new file mode 100644 index 0000000..a48ecff --- /dev/null +++ b/src/bar/media.rs @@ -0,0 +1,85 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct MediaState { + pub title: String, + pub artist: String, + pub playing: bool, + pub has_player: bool, +} + +async fn fetch() -> MediaState { + let none = || MediaState { + title: String::new(), + artist: String::new(), + playing: false, + has_player: false, + }; + + let status_out = tokio::time::timeout( + Duration::from_secs(2), + tokio::process::Command::new("playerctl") + .args(["status"]) + .output(), + ) + .await; + + let status = match status_out { + Ok(Ok(out)) if out.status.success() => { + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + _ => return none(), + }; + + if status == "Stopped" { + return none(); + } + + let playing = status == "Playing"; + + let meta_out = tokio::time::timeout( + Duration::from_secs(2), + tokio::process::Command::new("playerctl") + .args(["metadata", "--format", "{{artist}}\t{{title}}"]) + .output(), + ) + .await; + + let (artist, title) = match meta_out { + Ok(Ok(out)) if out.status.success() => { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let mut parts = s.splitn(2, '\t'); + let a = parts.next().unwrap_or("").to_string(); + let t = parts.next().unwrap_or("").to_string(); + (a, t) + } + _ => (String::new(), String::new()), + }; + + MediaState { + title, + artist, + playing, + has_player: true, + } +} + +pub fn spawn_poller(sender: ComponentSender) { + relm4::spawn(async move { + loop { + sender.input(AppInput::MediaUpdate(fetch().await)); + tokio::time::sleep(Duration::from_secs(2)).await; + } + }); +} + +pub fn spawn_cmd(cmd: &'static str) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("playerctl") + .arg(cmd) + .output() + .await; + }); +} diff --git a/src/bar/mod.rs b/src/bar/mod.rs index 7b0d41d..2563f23 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,4 +1,7 @@ pub mod clock; +pub mod control; +pub mod media; pub mod stats; pub mod tray; +pub mod wifi; pub mod workspaces; diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 2d4908b..7dc9dc2 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -11,6 +11,8 @@ use std::{ use tokio::sync::OnceCell as AsyncOnce; static WIFI_IFACE: OnceLock> = OnceLock::new(); +static NET_PREV: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); static BT_CONN: AsyncOnce = AsyncOnce::const_new(); static BT_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(BT_OFF)); static BT_TICK: AtomicU8 = AtomicU8::new(0); @@ -46,6 +48,11 @@ pub struct Stats { pub bt_icon: &'static str, pub wifi_ssid: String, pub wifi_icon: &'static str, + pub wifi_profile: Option, + pub cpu_temp: Option, + pub gpu_usage: Option, + pub net_rx_kbs: f32, + pub net_tx_kbs: f32, } struct CpuSnapshot { @@ -288,12 +295,97 @@ async fn read_wifi() -> (String, &'static str) { (ssid, icon) } +fn read_cpu_temp() -> Option { + for entry in fs::read_dir("/sys/class/hwmon").ok()?.flatten() { + let path = entry.path(); + let name = fs::read_to_string(path.join("name")).ok()?; + if name.trim() == "k10temp" { + let raw = fs::read_to_string(path.join("temp1_input")).ok()?; + return Some(raw.trim().parse::().ok()? / 1000.0); + } + } + None +} + +fn read_gpu_usage() -> Option { + for entry in fs::read_dir("/sys/class/drm").ok()?.flatten() { + let path = entry.path().join("device/gpu_busy_percent"); + if path.exists() { + return fs::read_to_string(&path).ok()?.trim().parse().ok(); + } + } + None +} + +fn read_net_throughput() -> (f32, f32) { + let text = match fs::read_to_string("/proc/net/dev") { + Ok(t) => t, + Err(_) => return (0.0, 0.0), + }; + let mut total_rx = 0u64; + let mut total_tx = 0u64; + for line in text.lines().skip(2) { + let colon = match line.find(':') { + Some(i) => i, + None => continue, + }; + let iface = line[..colon].trim(); + if matches!(iface, "lo") + || iface.starts_with("docker") + || iface.starts_with("veth") + || iface.starts_with("br-") + || iface.starts_with("virbr") + { + continue; + } + let fields: Vec<&str> = line[colon + 1..].split_whitespace().collect(); + if fields.len() >= 9 { + total_rx += fields[0].parse::().unwrap_or(0); + total_tx += fields[8].parse::().unwrap_or(0); + } + } + let now = std::time::Instant::now(); + let mut guard = NET_PREV.lock().unwrap(); + let result = if let Some((last_rx, last_tx, last_t)) = *guard { + let dt = now.duration_since(last_t).as_secs_f32().max(0.001); + let rx = total_rx.saturating_sub(last_rx) as f32 / 1024.0 / dt; + let tx = total_tx.saturating_sub(last_tx) as f32 / 1024.0 / dt; + (rx, tx) + } else { + (0.0, 0.0) + }; + *guard = Some((total_rx, total_tx, now)); + result +} + +fn read_crumbs_profile() -> Option { + let state_home = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::var_os("HOME") + .map(|h| PathBuf::from(h).join(".local/state")) + .unwrap_or_else(|| PathBuf::from("/tmp")) + }); + let text = fs::read_to_string(state_home.join("breadcrumbs/state.toml")).ok()?; + for line in text.lines() { + if let Some(rest) = line.trim().strip_prefix("profile") { + let val = rest + .trim_start_matches(|c: char| c == ' ' || c == '=') + .trim_matches('"'); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + pub async fn poll() -> Stats { let cpu = read_cpu(); let mem = read_ram(); - let power = read_power().map_or_else(|| " —W".into(), |w| format!("{w:4.1}W")); + let power = read_power().map_or_else(|| "—W".into(), |w| format!("{w:.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| " —".into(), |p| format!("{p:3}%")); + let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); let bat_icon = pct.map_or(BAT_MID, bat_level_icon); let ac_connected = read_ac(); // BT and WiFi both refresh every 8 cycles (~16 s); cache in between. @@ -317,8 +409,12 @@ pub async fn poll() -> Stats { WIFI_CACHE.lock().unwrap().clone() } }; + let wifi_profile = read_crumbs_profile(); + let cpu_temp = read_cpu_temp(); + let gpu_usage = read_gpu_usage(); + let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); Stats { - cpu: format!("{cpu:3.0}%"), + cpu: format!("{cpu:.0}%"), mem: if mem >= 1024 * 1024 { format!("{:.1}G", mem as f32 / (1024.0 * 1024.0)) } else { @@ -331,6 +427,11 @@ pub async fn poll() -> Stats { bt_icon, wifi_ssid, wifi_icon, + wifi_profile, + cpu_temp, + gpu_usage, + net_rx_kbs, + net_tx_kbs, } } diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs new file mode 100644 index 0000000..13c25fd --- /dev/null +++ b/src/bar/wifi.rs @@ -0,0 +1,144 @@ +use crate::{App, AppInput}; +use relm4::ComponentSender; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct CrumbsStatus { + pub profile: String, + pub ssid: Option, + pub ip: Option, + pub internet: bool, + pub captive_portal: bool, + pub tailscale_ok: bool, + pub tailscale_required: bool, +} + +#[derive(Debug, Clone)] +pub struct ScanEntry { + pub ssid: String, + pub signal: u8, // 0–100 percentage + pub saved: bool, +} + +#[derive(Debug, Clone)] +pub struct WifiPopoverData { + pub profiles: Vec<(String, bool)>, // (name, is_active) + pub scan: Vec, +} + +async fn fetch_status() -> Option { + let out = tokio::time::timeout( + Duration::from_secs(8), + tokio::process::Command::new("breadcrumbs") + .args(["status", "--json"]) + .output(), + ) + .await + .ok()? + .ok()?; + + if !out.status.success() { + return None; + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + Some(CrumbsStatus { + profile: v["profile"].as_str().unwrap_or("").to_string(), + ssid: v["ssid"].as_str().filter(|s| !s.is_empty()).map(str::to_string), + ip: v["ip"].as_str().filter(|s| !s.is_empty()).map(str::to_string), + internet: v["internet"].as_bool().unwrap_or(true), + captive_portal: v["captive_portal"].is_string(), + tailscale_ok: v["tailscale"]["ok"].as_bool().unwrap_or(true), + tailscale_required: v["tailscale"]["required"].as_bool().unwrap_or(false), + }) +} + +async fn fetch_profile_list() -> Vec<(String, bool)> { + let Ok(Ok(out)) = tokio::time::timeout( + Duration::from_secs(4), + tokio::process::Command::new("breadcrumbs") + .args(["profile", "list"]) + .output(), + ) + .await + else { + return vec![]; + }; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let active = line.starts_with('*'); + let name = line.trim_start_matches(['*', ' ']).trim().to_string(); + if name.is_empty() { None } else { Some((name, active)) } + }) + .collect() +} + +async fn fetch_scan() -> Vec { + let Ok(Ok(out)) = tokio::time::timeout( + Duration::from_secs(10), + tokio::process::Command::new("breadcrumbs") + .args(["scan-list", "--json"]) + .output(), + ) + .await + else { + return vec![]; + }; + let arr: Vec = + serde_json::from_slice(&out.stdout).unwrap_or_default(); + arr.into_iter() + .filter_map(|v| { + let ssid = v["ssid"].as_str()?.to_string(); + if ssid.is_empty() { + return None; + } + let signal = v["signal"] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let saved = v["saved"].as_bool().unwrap_or(false); + Some(ScanEntry { ssid, signal, saved }) + }) + .collect() +} + +/// Background poller — updates internet/TS status every 30 s. +pub fn spawn_status_poller(sender: ComponentSender) { + relm4::spawn(async move { + loop { + if let Some(status) = fetch_status().await { + sender.input(AppInput::CrumbsStatus(status)); + } + tokio::time::sleep(Duration::from_secs(30)).await; + } + }); +} + +/// Called when the popover opens — loads profiles + scan in parallel. +pub fn spawn_popover_load(sender: ComponentSender) { + relm4::spawn(async move { + let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan()); + sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan })); + }); +} + +/// Fire-and-forget: set the active breadcrumbs profile (applies it). +pub fn spawn_profile_set(name: String) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["profile", "set", &name]) + .output() + .await; + }); +} + +/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`. +pub fn spawn_join(ssid: String) { + relm4::spawn(async move { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) + .output() + .await; + }); +} + diff --git a/src/main.rs b/src/main.rs index 877490c..2a148b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,3 @@ -// Embed asset SVGs into the binary at compile time. Previously these were -// referenced by their build-time filesystem path (CARGO_MANIFEST_DIR), which -// does not exist on an installed system — so the packaged binary loaded empty -// bytes and panicked. include_str! bakes the contents in instead. macro_rules! asset { ($n:literal) => { include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/", $n)) @@ -18,13 +14,21 @@ use gtk4_layer_shell::{Edge, Layer, LayerShell}; use hyprland::data::Workspace; use hyprland::shared::WorkspaceId; use relm4::prelude::*; +use std::cell::Cell; +use std::rc::Rc; pub struct App { + // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, - time_str: String, workspace_box: gtk4::Box, button_map: std::collections::HashMap, + + // ── Clock ───────────────────────────────────────────────────────────── + time_str: String, + clock_lbl: gtk4::Label, + + // ── Stats bar ───────────────────────────────────────────────────────── cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, @@ -36,8 +40,36 @@ pub struct App { bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - // Pre-loaded textures indexed by constant pointer values. wifi_textures: std::collections::HashMap, + + // ── WiFi popover ────────────────────────────────────────────────────── + wifi_popover_box: gtk4::Box, + crumbs_status: Option, + wifi_popover_data: Option, + wifi_profile: Option, + current_ssid: String, + + // ── Media ───────────────────────────────────────────────────────────── + media_widget: gtk4::Box, + media_track_lbl: gtk4::Label, + media_play_btn: gtk4::Button, + media_last: Option, + media_paused_at: Option, + + // ── Control panel ───────────────────────────────────────────────────── + control_popover: gtk4::Popover, + panel_vol_slider: gtk4::Scale, + panel_bright_slider: gtk4::Scale, + panel_loading: Rc>, + panel_sink_store: gtk4::StringList, + panel_sink_dropdown: gtk4::DropDown, + panel_sink_signal: Option, + panel_sinks: Vec, + panel_temp_lbl: gtk4::Label, + panel_gpu_lbl: gtk4::Label, + panel_net_lbl: gtk4::Label, + + // ── Tray ────────────────────────────────────────────────────────────── tray_box: gtk4::Box, tray_items: std::collections::HashMap, } @@ -49,6 +81,11 @@ pub enum AppInput { ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), + CrumbsStatus(bar::wifi::CrumbsStatus), + WifiPopoverData(bar::wifi::WifiPopoverData), + SetProfile(String), + MediaUpdate(bar::media::MediaState), + ControlPanelData(bar::control::ControlPanelData), } #[relm4::component(pub)] @@ -76,12 +113,6 @@ impl SimpleComponent for App { set_spacing: 4, } }, - - #[wrap(Some)] - set_center_widget = >k::Label { - #[watch] - set_label: &model.time_str, - }, } } } @@ -98,19 +129,7 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); - let cpu_lbl = stat_label(); - let mem_lbl = stat_label(); - let pwr_lbl = stat_label(); - let bat_lbl = stat_label(); - let wifi_lbl = gtk4::Label::new(None); - wifi_lbl.add_css_class("stat-label"); - wifi_lbl.add_css_class("wifi-label"); - wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); - wifi_lbl.set_max_width_chars(22); - wifi_lbl.set_xalign(0.0); - let wifi_img = - gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - + // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK, @@ -120,90 +139,347 @@ impl SimpleComponent for App { .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - // BAT_MID was just inserted into bat_textures above — key is always present. - let bat_img = gtk4::Image::from_paintable(Some( - bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), - )); - let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); - ac_img.set_visible(false); - let bt_textures: std::collections::HashMap = [BT_OFF, BT_ON, BT_CONNECTED] .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - // BT_OFF was just inserted into bt_textures above — key is always present. + let wifi_textures: std::collections::HashMap = + [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] + .into_iter() + .map(|p| (p.as_ptr() as usize, svg_texture(p))) + .collect(); + + // ── Stat labels ────────────────────────────────────────────────── + let cpu_lbl = stat_label(); + let mem_lbl = stat_label(); + let pwr_lbl = stat_label(); + let bat_lbl = stat_label(); + + let bat_img = gtk4::Image::from_paintable(Some( + bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), + )); + let ac_img = gtk4::Image::from_paintable(Some(&svg_texture(AC_POWER))); + ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); - let wifi_textures = [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] - .into_iter() - .map(|p| (p.as_ptr() as usize, svg_texture(p))) - .collect(); + // ── WiFi pair + popover ────────────────────────────────────────── + let wifi_lbl = gtk4::Label::new(None); + wifi_lbl.add_css_class("stat-label"); + wifi_lbl.add_css_class("wifi-label"); + wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + wifi_lbl.set_max_width_chars(28); + wifi_lbl.set_xalign(0.0); + let wifi_img = + gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); - let mut model = App { - workspaces: vec![], - active_ws: 1, - time_str: bar::clock::current(), - workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), - button_map: std::collections::HashMap::new(), - cpu_lbl: cpu_lbl.clone(), - mem_lbl: mem_lbl.clone(), - pwr_lbl: pwr_lbl.clone(), - bat_lbl: bat_lbl.clone(), - bat_img: bat_img.clone(), - bat_textures, - ac_img: ac_img.clone(), - bt_img: bt_img.clone(), - bt_textures, - wifi_lbl: wifi_lbl.clone(), - wifi_img: wifi_img.clone(), - wifi_textures, - tray_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), - tray_items: std::collections::HashMap::new(), - }; - let widgets = view_output!(); - model.workspace_box = widgets.workspace_box.clone(); + let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + wifi_pair.add_css_class("stat-pair"); + wifi_pair.add_css_class("wifi-pair"); + wifi_img.add_css_class("stat-icon"); + wifi_pair.append(&wifi_img); + wifi_pair.append(&wifi_lbl); + let wifi_popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + wifi_popover_box.add_css_class("wifi-popover-inner"); + wifi_popover_box.set_margin_top(4); + wifi_popover_box.set_margin_bottom(4); + wifi_popover_box.set_margin_start(4); + wifi_popover_box.set_margin_end(4); + let loading_lbl = gtk4::Label::new(Some("Scanning…")); + loading_lbl.add_css_class("wifi-popover-loading"); + wifi_popover_box.append(&loading_lbl); + + let wifi_popover = gtk4::Popover::new(); + wifi_popover.add_css_class("wifi-popover"); + wifi_popover.set_child(Some(&wifi_popover_box)); + wifi_popover.set_parent(&wifi_pair); + + let wpop = wifi_popover.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + if wpop.is_visible() { wpop.popdown(); } else { wpop.popup(); } + }); + wifi_pair.add_controller(gesture); + + let sender_wp = sender.clone(); + wifi_popover.connect_show(move |_| { + bar::wifi::spawn_popover_load(sender_wp.clone()); + }); + + // ── Media widget (center) ──────────────────────────────────────── + let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + media_widget.add_css_class("media-widget"); + media_widget.set_visible(false); + + let media_indicator = gtk4::Label::new(Some("▶")); + media_indicator.add_css_class("media-indicator"); + + let media_track_lbl = gtk4::Label::new(None); + media_track_lbl.add_css_class("media-track-lbl"); + media_track_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); + media_track_lbl.set_max_width_chars(42); + media_track_lbl.set_xalign(0.0); + + media_widget.append(&media_indicator); + media_widget.append(&media_track_lbl); + + // Media controls popover + let media_controls_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + media_controls_box.add_css_class("media-controls"); + media_controls_box.set_margin_top(4); + media_controls_box.set_margin_bottom(4); + media_controls_box.set_margin_start(4); + media_controls_box.set_margin_end(4); + + let prev_btn = gtk4::Button::with_label("⏮"); + prev_btn.add_css_class("flat"); + prev_btn.add_css_class("media-btn"); + prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); + + let media_play_btn = gtk4::Button::with_label("⏸"); + media_play_btn.add_css_class("flat"); + media_play_btn.add_css_class("media-btn"); + media_play_btn.add_css_class("media-play-btn"); + media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); + + let next_btn = gtk4::Button::with_label("⏭"); + next_btn.add_css_class("flat"); + next_btn.add_css_class("media-btn"); + next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); + + media_controls_box.append(&prev_btn); + media_controls_box.append(&media_play_btn); + media_controls_box.append(&next_btn); + + let media_popover = gtk4::Popover::new(); + media_popover.add_css_class("media-popover"); + media_popover.set_child(Some(&media_controls_box)); + media_popover.set_parent(&media_widget); + + let mpop = media_popover.clone(); + let mgesture = gtk4::GestureClick::new(); + mgesture.connect_released(move |_, _, _, _| { + if mpop.is_visible() { mpop.popdown(); } else { mpop.popup(); } + }); + media_widget.add_controller(mgesture); + + // Clock label + let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); + clock_lbl.add_css_class("clock-label"); + + // Center area: [media_widget · clock] + let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + center_area.add_css_class("center-area"); + center_area.append(&media_widget); + center_area.append(&clock_lbl); + + // ── Stats box (right side) ─────────────────────────────────────── let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); stats_box.append(&stat_pair(asset!("CPU.svg"), &cpu_lbl)); stats_box.append(&stat_pair(asset!("RAM Usage.svg"), &mem_lbl)); stats_box.append(&stat_pair(asset!("Power Draw.svg"), &pwr_lbl)); + let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); + ac_img.set_margin_start(6); bat_box.append(&bat_img); bat_box.append(&bat_lbl); bat_box.append(&ac_img); stats_box.append(&bat_box); + bt_img.add_css_class("bt-icon"); + bt_img.add_css_class("clickable"); + let bt_gesture = gtk4::GestureClick::new(); + bt_gesture.connect_released(|_, _, _, _| { + relm4::spawn(async { + let _ = tokio::process::Command::new("blueman-manager").spawn(); + }); + }); + bt_img.add_controller(bt_gesture); stats_box.append(&bt_img); - let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - wifi_pair.add_css_class("stat-pair"); - wifi_img.add_css_class("stat-icon"); - wifi_pair.append(&wifi_img); - wifi_pair.append(&wifi_lbl); stats_box.append(&wifi_pair); - model.tray_box.add_css_class("tray-box"); - stats_box.append(&model.tray_box); + + // ── Control panel popover ──────────────────────────────────────── + let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + panel_inner.add_css_class("control-panel-inner"); + + // Volume row + let vol_row = build_slider_row("🔊", 0.0, 1.5, 0.02); + let panel_vol_slider = vol_row.1.clone(); + panel_inner.append(&vol_row.0); + + // Brightness row + let bright_row = build_slider_row("☀", 0.0, 1.0, 0.02); + let panel_bright_slider = bright_row.1.clone(); + panel_inner.append(&bright_row.0); + + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + // Stats section + let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + stats_section.add_css_class("control-panel-stats"); + + let panel_temp_lbl = gtk4::Label::new(Some("CPU —")); + panel_temp_lbl.add_css_class("control-panel-stat"); + panel_temp_lbl.set_xalign(0.0); + + let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); + panel_gpu_lbl.add_css_class("control-panel-stat"); + panel_gpu_lbl.set_xalign(0.0); + + let panel_net_lbl = gtk4::Label::new(Some("↓ — ↑ —")); + panel_net_lbl.add_css_class("control-panel-stat"); + panel_net_lbl.set_xalign(0.0); + + stats_section.append(&panel_temp_lbl); + stats_section.append(&panel_gpu_lbl); + stats_section.append(&panel_net_lbl); + panel_inner.append(&stats_section); + + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + // Audio output section + let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + sink_section.add_css_class("control-panel-section"); + let sink_header = gtk4::Label::new(Some("Audio Output")); + sink_header.add_css_class("control-panel-section-header"); + sink_header.set_xalign(0.0); + + let panel_sink_store = gtk4::StringList::new(&[]); + let panel_sink_dropdown = gtk4::DropDown::new( + Some(panel_sink_store.clone().upcast::()), + Option::::None, + ); + panel_sink_dropdown.add_css_class("control-panel-sink-dropdown"); + panel_sink_dropdown.set_hexpand(true); + + sink_section.append(&sink_header); + sink_section.append(&panel_sink_dropdown); + panel_inner.append(&sink_section); + + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + // Tray section + let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + tray_section.add_css_class("control-panel-section"); + let tray_header = gtk4::Label::new(Some("Apps")); + tray_header.add_css_class("control-panel-section-header"); + tray_header.set_xalign(0.0); + let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + tray_box.add_css_class("tray-box"); + tray_section.append(&tray_header); + tray_section.append(&tray_box); + panel_inner.append(&tray_section); + + let control_popover = gtk4::Popover::new(); + control_popover.add_css_class("control-panel"); + control_popover.set_child(Some(&panel_inner)); + + // Hamburger button + let hamburger_btn = gtk4::Button::with_label("☰"); + hamburger_btn.add_css_class("flat"); + hamburger_btn.add_css_class("control-panel-btn"); + + control_popover.set_parent(&hamburger_btn); + + let cpop = control_popover.clone(); + hamburger_btn.connect_clicked(move |_| { + if cpop.is_visible() { cpop.popdown(); } else { cpop.popup(); } + }); + + let sender_cp = sender.clone(); + control_popover.connect_show(move |_| { + bar::control::spawn_load(sender_cp.clone()); + }); + + // Slider signals — use Rc> to suppress feedback during data load + let panel_loading = Rc::new(Cell::new(false)); + + let loading_v = panel_loading.clone(); + panel_vol_slider.connect_value_changed(move |s| { + if loading_v.get() { return; } + bar::control::spawn_set_volume(s.value()); + }); + + let loading_b = panel_loading.clone(); + panel_bright_slider.connect_value_changed(move |s| { + if loading_b.get() { return; } + bar::control::spawn_set_brightness(s.value()); + }); + + stats_box.append(&hamburger_btn); + + // ── Assemble ───────────────────────────────────────────────────── + let widgets = view_output!(); + widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); + let mut model = App { + workspaces: vec![], + active_ws: 1, + workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + button_map: std::collections::HashMap::new(), + time_str: bar::clock::current(), + clock_lbl, + cpu_lbl, + mem_lbl, + pwr_lbl, + bat_lbl, + bat_img, + bat_textures, + ac_img, + bt_img, + bt_textures, + wifi_lbl, + wifi_img, + wifi_textures, + wifi_popover_box, + crumbs_status: None, + wifi_popover_data: None, + wifi_profile: None, + current_ssid: "—".to_string(), + media_widget, + media_track_lbl, + media_play_btn, + media_last: None, + media_paused_at: None, + control_popover, + panel_vol_slider, + panel_bright_slider, + panel_loading, + panel_sink_store, + panel_sink_dropdown, + panel_sink_signal: None, + panel_sinks: vec![], + panel_temp_lbl, + panel_gpu_lbl, + panel_net_lbl, + tray_box, + tray_items: std::collections::HashMap::new(), + }; + model.workspace_box = widgets.workspace_box.clone(); + theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); bar::tray::spawn_watcher(sender.clone()); + bar::wifi::spawn_status_poller(sender.clone()); + bar::media::spawn_poller(sender.clone()); notifications::spawn(); osd::spawn(); ComponentParts { model, widgets } } - fn update(&mut self, msg: Self::Input, _: ComponentSender) { + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { AppInput::WorkspaceList(list) => { let mut sorted = list; @@ -222,6 +498,7 @@ impl SimpleComponent for App { } AppInput::ClockTick => { self.time_str = bar::clock::current(); + self.clock_lbl.set_label(&self.time_str); } AppInput::StatsUpdate(stats) => { self.cpu_lbl.set_label(&stats.cpu); @@ -235,10 +512,41 @@ impl SimpleComponent for App { if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } - self.wifi_lbl.set_label(&stats.wifi_ssid); - if let Some(tex) = self.wifi_textures.get(&(stats.wifi_icon.as_ptr() as usize)) { + self.current_ssid = stats.wifi_ssid.clone(); + if stats.wifi_profile.is_some() { + self.wifi_profile = stats.wifi_profile; + } + self.apply_wifi_label(); + let internet_ok = self + .crumbs_status + .as_ref() + .map(|s| s.internet && !s.captive_portal) + .unwrap_or(true); + let icon = if !internet_ok && stats.wifi_ssid != "—" { + bar::stats::WIFI_OFF + } else { + stats.wifi_icon + }; + if let Some(tex) = self.wifi_textures.get(&(icon.as_ptr() as usize)) { self.wifi_img.set_paintable(Some(tex)); } + + // Live-update control panel stats while open + if self.control_popover.is_visible() { + match stats.cpu_temp { + Some(t) => self.panel_temp_lbl.set_label(&format!("CPU {t:.0}°C")), + None => self.panel_temp_lbl.set_label("CPU —"), + } + match stats.gpu_usage { + Some(g) => self.panel_gpu_lbl.set_label(&format!("GPU {g}%")), + None => self.panel_gpu_lbl.set_label("GPU —"), + } + self.panel_net_lbl.set_label(&format!( + "↓ {} ↑ {}", + fmt_speed(stats.net_rx_kbs), + fmt_speed(stats.net_tx_kbs), + )); + } } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { @@ -260,11 +568,101 @@ impl SimpleComponent for App { self.tray_box.remove(&btn); } } + AppInput::CrumbsStatus(status) => { + self.crumbs_status = Some(status); + } + AppInput::WifiPopoverData(data) => { + self.wifi_popover_data = Some(data); + self.rebuild_wifi_popover(&sender); + } + AppInput::SetProfile(name) => { + self.wifi_profile = Some(name); + self.apply_wifi_label(); + } + AppInput::MediaUpdate(state) => { + if state.has_player { + let label = if state.artist.is_empty() { + state.title.clone() + } else { + format!("{} · {}", state.artist, state.title) + }; + self.media_track_lbl.set_label(&label); + self.media_play_btn + .set_label(if state.playing { "⏸" } else { "▶" }); + + if state.playing { + self.media_paused_at = None; + } else if self.media_paused_at.is_none() { + self.media_paused_at = Some(std::time::Instant::now()); + } + + let within_linger = self + .media_paused_at + .map_or(true, |t| t.elapsed().as_secs() < 30 * 60); + self.media_last = Some(state); + self.media_widget.set_visible(within_linger); + } else { + // Player gone — honour linger from last pause + if let Some(paused_at) = self.media_paused_at { + if paused_at.elapsed().as_secs() < 30 * 60 { + self.media_widget.set_visible(true); + } else { + self.media_widget.set_visible(false); + self.media_last = None; + self.media_paused_at = None; + } + } else { + self.media_widget.set_visible(false); + self.media_last = None; + } + } + } + AppInput::ControlPanelData(data) => { + // Suppress slider value-changed signals during programmatic update + self.panel_loading.set(true); + self.panel_vol_slider.set_value(data.volume); + self.panel_bright_slider.set_value(data.brightness); + self.panel_loading.set(false); + + // Rebuild sink dropdown — disconnect, repopulate, reconnect + if let Some(id) = self.panel_sink_signal.take() { + self.panel_sink_dropdown.disconnect(id); + } + // Clear store + let n = self.panel_sink_store.n_items(); + for i in (0..n).rev() { + self.panel_sink_store.remove(i); + } + for sink in &data.sinks { + self.panel_sink_store.append(&sink.description); + } + if let Some(idx) = data.sinks.iter().position(|s| s.is_default) { + self.panel_sink_dropdown.set_selected(idx as u32); + } + self.panel_sinks = data.sinks; + + let sinks = self.panel_sinks.clone(); + let id = self.panel_sink_dropdown.connect_selected_notify(move |dd| { + let idx = dd.selected() as usize; + if let Some(sink) = sinks.get(idx) { + bar::control::spawn_set_sink(sink.name.clone()); + } + }); + self.panel_sink_signal = Some(id); + } } } } impl App { + fn apply_wifi_label(&self) { + let label = match &self.wifi_profile { + Some(p) => format!("{p} · {}", self.current_ssid), + None => self.current_ssid.clone(), + }; + self.wifi_lbl.set_label(&label); + } + fn rebuild_buttons(&mut self) { while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); @@ -276,6 +674,191 @@ impl App { self.button_map.insert(ws.id, btn); } } + + fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { + while let Some(child) = self.wifi_popover_box.first_child() { + self.wifi_popover_box.remove(&child); + } + + if let Some(st) = &self.crumbs_status { + let header = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + header.add_css_class("wifi-popover-header"); + header.set_margin_bottom(6); + + let ssid_str = st.ssid.as_deref().filter(|s| !s.is_empty()).unwrap_or("—"); + let ssid_lbl = gtk4::Label::new(Some(ssid_str)); + ssid_lbl.add_css_class("wifi-popover-ssid"); + ssid_lbl.set_xalign(0.0); + header.append(&ssid_lbl); + + if let Some(ip) = &st.ip { + let ip_lbl = gtk4::Label::new(Some(ip.as_str())); + ip_lbl.add_css_class("wifi-popover-ip"); + ip_lbl.set_xalign(0.0); + header.append(&ip_lbl); + } + + let mut parts = Vec::new(); + if st.captive_portal { + parts.push("captive portal"); + } else if st.internet { + parts.push("internet ✓"); + } else { + parts.push("internet ✗"); + } + if st.tailscale_required { + parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); + } + let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); + status_lbl.add_css_class("wifi-popover-status"); + status_lbl.set_xalign(0.0); + header.append(&status_lbl); + + self.wifi_popover_box.append(&header); + self.wifi_popover_box + .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + } + + let Some(data) = &self.wifi_popover_data else { + let lbl = gtk4::Label::new(Some("Scanning…")); + lbl.add_css_class("wifi-popover-loading"); + self.wifi_popover_box.append(&lbl); + return; + }; + + let ph = gtk4::Label::new(Some("Profiles")); + ph.add_css_class("wifi-popover-section"); + ph.set_xalign(0.0); + ph.set_margin_top(6); + ph.set_margin_bottom(2); + self.wifi_popover_box.append(&ph); + + for (name, active) in &data.profiles { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + if *active { + row.add_css_class("wifi-popover-row-active"); + } + let lbl = gtk4::Label::new(Some(&format!( + "{}{}", + if *active { "● " } else { " " }, + name + ))); + lbl.set_xalign(0.0); + row.set_child(Some(&lbl)); + + let name_clone = name.clone(); + let sender_clone = sender.clone(); + row.connect_clicked(move |btn| { + sender_clone.input(AppInput::SetProfile(name_clone.clone())); + bar::wifi::spawn_profile_set(name_clone.clone()); + close_parent_popover(btn); + }); + self.wifi_popover_box.append(&row); + } + + if !data.scan.is_empty() { + self.wifi_popover_box + .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + let nh = gtk4::Label::new(Some("Nearby")); + nh.add_css_class("wifi-popover-section"); + nh.set_xalign(0.0); + nh.set_margin_top(6); + nh.set_margin_bottom(2); + self.wifi_popover_box.append(&nh); + + for entry in &data.scan { + let row = gtk4::Button::new(); + row.add_css_class("flat"); + row.add_css_class("wifi-popover-row"); + if !entry.saved { + row.add_css_class("wifi-popover-row-unsaved"); + row.set_sensitive(false); + } + let is_current = entry.ssid == self.current_ssid; + if is_current { + row.add_css_class("wifi-popover-row-active"); + } + + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let icon_svg = wifi_icon_for_signal(entry.signal); + if let Some(tex) = self.wifi_textures.get(&(icon_svg.as_ptr() as usize)) { + let img = gtk4::Image::from_paintable(Some(tex)); + img.add_css_class("stat-icon"); + row_box.append(&img); + } + let lbl = gtk4::Label::new(Some(&format!( + "{}{}", + if is_current { "● " } else { " " }, + entry.ssid, + ))); + lbl.set_xalign(0.0); + row_box.append(&lbl); + row.set_child(Some(&row_box)); + + if entry.saved { + let ssid_clone = entry.ssid.clone(); + row.connect_clicked(move |btn| { + bar::wifi::spawn_join(ssid_clone.clone()); + close_parent_popover(btn); + }); + } + self.wifi_popover_box.append(&row); + } + } + + self.wifi_popover_box.set_visible(true); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn build_slider_row(icon: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + row.add_css_class("control-panel-row"); + row.set_margin_top(2); + row.set_margin_bottom(2); + + let icon_lbl = gtk4::Label::new(Some(icon)); + icon_lbl.add_css_class("control-panel-row-icon"); + icon_lbl.set_width_chars(2); + + let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); + slider.set_draw_value(false); + slider.set_hexpand(true); + slider.set_width_request(180); + slider.add_css_class("control-panel-slider"); + + row.append(&icon_lbl); + row.append(&slider); + (row, slider) +} + +fn fmt_speed(kbs: f32) -> String { + if kbs >= 1024.0 { + format!("{:.1} MB/s", kbs / 1024.0) + } else { + format!("{:.0} KB/s", kbs) + } +} + +fn wifi_icon_for_signal(pct: u8) -> &'static str { + use bar::stats::{WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG, WIFI_WEAK}; + match pct { + 75..=100 => WIFI_STRONG, + 50..=74 => WIFI_MEDIUM, + 25..=49 => WIFI_WEAK, + _ => WIFI_OFF, + } +} + +fn close_parent_popover(widget: >k4::Button) { + if let Some(w) = widget.ancestor(gtk4::Popover::static_type()) { + if let Ok(p) = w.downcast::() { + p.popdown(); + } + } } fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { @@ -288,9 +871,6 @@ fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { pair } -// Rasterise an (embedded) SVG to a texture. Done in pure Rust with resvg -// because librsvg dropped its gdk-pixbuf SVG loader, so gdk::Texture::from_bytes -// can no longer decode SVG on a stock system. fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; let fg = theme::fg_color(); diff --git a/src/theme.rs b/src/theme.rs index e02ed01..b104966 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -22,9 +22,9 @@ fn load_css() -> String { .workspace-btn:hover {{ opacity: 0.8; }}\ .workspace-btn.active {{ background: {accent}; color: {on_accent}; opacity: 1; }}\ .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 12px; }}\ - .stat-icon {{ margin-right: 5px; }}\ - .bt-icon {{ margin-right: 12px; }}\ + .stat-pair {{ margin-right: 14px; }}\ + .stat-icon {{ margin-right: 2px; }}\ + .bt-icon {{ margin-right: 14px; }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ .notification-card {{ background: {surface}; color: {on_surface}; border-radius: 8px;\ padding: 12px; margin-bottom: 8px; }}\ @@ -35,7 +35,39 @@ fn load_css() -> String { .osd-pct {{ font-weight: bold; font-size: 12px; }}\ progressbar.osd-bar {{ min-height: 8px; }}\ progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 4px; min-height: 8px; }}\ - progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 4px; min-height: 8px; }}", + progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 4px; min-height: 8px; }}\ + .clickable {{ cursor: pointer; }}\ + .wifi-pair {{ border-radius: 4px; padding: 0 2px; }}\ + .wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ + .wifi-popover-inner {{ min-width: 180px; padding: 2px; }}\ + .wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ + .wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ + .wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ + .wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ + .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ + border-radius: 4px; padding: 2px 6px; }}\ + .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ + .wifi-popover-row-active {{ color: {accent}; }}\ + .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ + .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ + .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ + .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ + .media-track-lbl {{ font-size: 12px; }}\ + .media-controls {{ padding: 2px; }}\ + .media-btn {{ font-size: 16px; min-width: 36px; padding: 2px 8px; }}\ + .control-panel-btn {{ font-size: 14px; padding: 0 6px; margin-left: 6px; border-radius: 4px; }}\ + .control-panel {{ }}\ + .control-panel-inner {{ min-width: 240px; padding: 8px; }}\ + .control-panel-row {{ margin: 4px 0; }}\ + .control-panel-row-icon {{ opacity: 0.75; }}\ + .control-panel-slider {{ margin: 0; }}\ + .control-panel-stats {{ margin: 8px 0; }}\ + .control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ + .control-panel-section {{ margin: 6px 0; }}\ + .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ + letter-spacing: 0.08em; margin-bottom: 4px; }}\ + .control-panel-sink-dropdown {{ }}\ + separator {{ margin: 4px 0; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0, From 673b5c52cfa5f028888520a2ad6e06c56a32a13d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:54:36 +0800 Subject: [PATCH 3/9] chore: update Cargo.lock for v0.2.0 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index aa34dc1..a36dfbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.7" +version = "0.2.0" dependencies = [ "bread-theme", "futures-lite", From 31b2d10909f8cf95491705cc3110ff0962a5464c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 07:05:07 +0800 Subject: [PATCH 4/9] chore: repo cleanup for v0.2.0 - Fix read_cpu_temp loop: ok()? was aborting iteration on unreadable hwmon entries instead of continuing to the next; use let Ok(...) else continue - Fix osd.rs: replace stdout.unwrap() with defensive let-else guard - PKGBUILD: bump pkgver to 0.2.0, replace libpulse dep with wireplumber + pipewire-pulse + brightnessctl (actual runtime tools used) - bakery.toml: same dep fix as PKGBUILD - Cargo.toml: fix repository URL casing (breadway -> Breadway) - theme.rs: add wifi-popover-row-unsaved opacity rule --- Cargo.toml | 2 +- bakery.toml | 2 +- packaging/arch/PKGBUILD | 4 ++-- src/bar/stats.rs | 2 +- src/osd.rs | 2 +- src/theme.rs | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0794931..ce5e979 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" authors = ["Breadway "] -repository = "https://github.com/breadway/breadbar" +repository = "https://github.com/Breadway/breadbar" keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] diff --git a/bakery.toml b/bakery.toml index 03f03fe..bd1153f 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,7 @@ name = "breadbar" description = "Minimal status bar and notification daemon for Hyprland" binaries = ["breadbar"] -system_deps = ["gtk4", "gtk4-layer-shell", "iw", "libpulse"] +system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] optional_system_deps = ["hyprland"] bread_deps = [] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index c021698..bb55a53 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=breadbar -pkgver=0.1.0 +pkgver=0.2.0 pkgrel=1 pkgdesc="Minimal status bar and notification daemon for Hyprland" arch=('x86_64') @@ -11,7 +11,7 @@ license=('MIT') # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. options=(!lto !debug) -depends=('gtk4' 'gtk4-layer-shell' 'libpulse' 'iw') +depends=('gtk4' 'gtk4-layer-shell' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw') optdepends=( 'hyprland: workspace and window data integration' ) diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 7dc9dc2..71f5015 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -298,7 +298,7 @@ async fn read_wifi() -> (String, &'static str) { fn read_cpu_temp() -> Option { for entry in fs::read_dir("/sys/class/hwmon").ok()?.flatten() { let path = entry.path(); - let name = fs::read_to_string(path.join("name")).ok()?; + let Ok(name) = fs::read_to_string(path.join("name")) else { continue }; if name.trim() == "k10temp" { let raw = fs::read_to_string(path.join("temp1_input")).ok()?; return Some(raw.trim().parse::().ok()? / 1000.0); diff --git a/src/osd.rs b/src/osd.rs index 74f0ec7..55106ec 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -32,7 +32,7 @@ fn volume_watcher(tx: mpsc::Sender) { return; }; - let stdout = child.stdout.take().unwrap(); + let Some(stdout) = child.stdout.take() else { return }; let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { diff --git a/src/theme.rs b/src/theme.rs index b104966..ba3c011 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -48,6 +48,7 @@ fn load_css() -> String { border-radius: 4px; padding: 2px 6px; }}\ .wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ .wifi-popover-row-active {{ color: {accent}; }}\ + .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ From db5db79471e2a9f94041496f78785898df76f300 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 14:10:01 +0800 Subject: [PATCH 5/9] CI: migrate release workflow from GitHub Actions to Forgejo Actions GitHub Actions self-hosted runners need per-repo registration on a personal account; Forgejo Actions' runner already serves every repo with zero setup. Moves release publishing there (dl.breadway.dev stays the primary bakery target; GitHub release upload is kept as the fallback via an explicit token, since Forgejo Actions has no ambient GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync automatically. --- .forgejo/workflows/mirror.yml | 2 - .forgejo/workflows/release.yml | 53 ++++++++++++++++++++++++++ .github/workflows/release.yml | 57 ---------------------------- README.md | 59 ++++++++++++++++++++++------- assets/icons-needed.txt | 48 ++++++++++++++++++++++++ src/bar/stats.rs | 22 ++++++++--- src/main.rs | 68 +++++++++++++++++++++++++++------- src/theme.rs | 2 + 8 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 .forgejo/workflows/release.yml delete mode 100644 .github/workflows/release.yml create mode 100644 assets/icons-needed.txt diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index ac655db..26a9b37 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -14,8 +14,6 @@ jobs: set -euo pipefail git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git - # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); - # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..8124ec6 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,53 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp src/target/release/breadbar "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" + + - name: regenerate index.json + run: | + set -euo pipefail + rm -rf /tmp/bread-ecosystem-ci + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci + bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadbar/${VERSION}" + gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadbar \ + --title "breadbar v${VERSION}" --generate-notes 2>/dev/null || true + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadbar \ + "${PKG_DIR}/breadbar-x86_64" \ + "${PKG_DIR}/breadbar-x86_64.sha256" \ + --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f02a494..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -env: - DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - uses: actions/checkout@v4 - - - name: install build deps - run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config iw 2>/dev/null || true - - - name: build - run: cargo build --release --locked - - - name: prepare artifacts - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/breadbar/${VERSION}" - mkdir -p "${PKG_DIR}" - cp target/release/breadbar "${PKG_DIR}/breadbar-x86_64" - strip "${PKG_DIR}/breadbar-x86_64" - sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/breadbar-x86_64.sha256" - cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/breadbar/latest" - - - name: ensure bread-ecosystem - run: | - rm -rf "${ECOSYSTEM_DIR}" - git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" - - - name: regenerate index.json - run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" - - - name: upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/breadbar/${VERSION}" - gh release create "${GITHUB_REF_NAME}" \ - --title "breadbar v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" \ - "${PKG_DIR}/breadbar-x86_64" \ - "${PKG_DIR}/breadbar-x86_64.sha256" \ - --clobber diff --git a/README.md b/README.md index c823661..7e58eff 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,69 @@ Minimal status bar and notification daemon for [Hyprland](https://hyprland.org/) on Wayland. -A single Rust binary that provides a full-width top bar, a system tray, and a standards-compliant D-Bus notification daemon. No launcher, no wallpaper logic. +A single Rust binary that provides a full-width top bar, a D-Bus notification daemon, a volume/brightness OSD, and an SNI system tray housed in a control panel popover. ## Features **Status bar** (anchored to the top of every monitor via `gtk4-layer-shell`): - Left: live workspace buttons sourced from Hyprland IPC, active workspace highlighted -- Centre: clock (`HH:MM`, updates at the top of each minute) -- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength, system tray (SNI) +- Centre: media widget (track/artist from `playerctl`, click to open prev/play-pause/next controls; hidden when no player is active, lingers up to 30 minutes after the last pause) + clock (`HH:MM`, updates at the top of each minute) +- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth icon (click to open `blueman-manager`), WiFi SSID with signal-strength icon (click for details popover), hamburger control panel button + +**WiFi popover** (click the WiFi area): + +- Shows current SSID, IP address, and internet/Tailscale connectivity status via `breadcrumbs status` +- Lists saved `breadcrumbs` profiles for one-click switching +- Shows nearby SSIDs from `breadcrumbs scan-list` (saved networks are clickable to join) +- Degrades gracefully if `breadcrumbs` is not installed + +**Control panel** (hamburger button on the right): + +- Volume slider (reads/writes via `wpctl`, up to 150%) +- Brightness slider (reads/writes via `brightnessctl`) +- Live CPU%, GPU%, and network throughput (download/upload) +- Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately) +- System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons +- Power buttons: lock (`hyprlock`), suspend, reboot, poweroff **Notification daemon**: -- Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard notification sender (`notify-send`, etc.) +- Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) -- Supports `CloseNotification` +- Supports `CloseNotification` and `replaces_id` + +**Volume/brightness OSD**: + +- Overlay window at the bottom of the screen, auto-dismisses after 2 s +- Appears automatically on any `pactl` sink-change event or `sysfs` backlight change **Theming**: -- Reads `~/.cache/wal/colors.json` (pywal) on startup for a palette that matches your wallpaper -- Falls back to a Catppuccin Mocha palette if pywal is not present +- Uses `bread-theme` for palette loading; reads `~/.cache/wal/colors.json` (pywal) if present, falls back to a Catppuccin Mocha palette - User CSS override: `~/.config/breadbar/style.css` - Send `SIGHUP` to reload the theme at runtime (integrates with wallpaper-change hooks) ## Dependencies -Runtime: +Runtime (required): - GTK4 (≥ 4.12) - `gtk4-layer-shell` - `iw` — for WiFi SSID/signal (`iw dev link`) +- `wpctl` (WirePlumber) — volume read/write +- `pactl` (PipeWire-Pulse) — audio sink listing and OSD volume events +- `brightnessctl` — brightness read/write - A running Hyprland compositor - D-Bus session bus -Bluetooth status is read from `/sys/class/rfkill` and BlueZ D-Bus; it degrades gracefully if unavailable. +Runtime (optional, degrade gracefully if absent): + +- `playerctl` — media widget; hidden if no player is found +- `breadcrumbs` — WiFi popover enrichment (profiles, internet/Tailscale status); basic SSID/signal still shown without it +- `blueman-manager` — opened when the Bluetooth icon is clicked; Bluetooth state still shown without it + +Bluetooth state is read from `/sys/class/rfkill` and BlueZ D-Bus and degrades gracefully if unavailable. ## Building @@ -50,7 +79,7 @@ Requirements: Rust 1.77+ (uses `LazyLock`), a GTK4 development environment (`lib On Arch Linux: ```sh -sudo pacman -S gtk4 gtk4-layer-shell iw +sudo pacman -S gtk4 gtk4-layer-shell wireplumber pipewire-pulse brightnessctl iw cargo build --release ``` @@ -72,7 +101,7 @@ breadbar claims `org.freedesktop.Notifications` on the session D-Bus on startup. ### pywal integration -breadbar reads `~/.cache/wal/colors.json` automatically. To reload after a wallpaper change: +breadbar reads `~/.cache/wal/colors.json` automatically (via `bread-theme`). To reload after a wallpaper change: ```sh pkill -HUP breadbar @@ -87,7 +116,7 @@ pkill -HUP breadbar ### Custom CSS -Drop a `~/.config/breadbar/style.css` file and send `SIGHUP` to reload. This CSS is applied at a higher priority than the pywal palette so you can override anything. +Drop a `~/.config/breadbar/style.css` file and send `SIGHUP` to reload. This CSS is applied at a higher priority than the generated palette so you can override anything. Example — change the font size: @@ -105,10 +134,14 @@ Example — change the font size: | `src/bar/workspaces.rs` | Hyprland IPC event stream, workspace buttons | | `src/bar/clock.rs` | Minute-tick clock | | `src/bar/stats.rs` | Polling loop: CPU, RAM, power, battery, Bluetooth, WiFi | +| `src/bar/media.rs` | `playerctl` polling, media widget and controls popover | +| `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | +| `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | -| `src/theme.rs` | pywal reader, GTK CSS provider injection | +| `src/osd.rs` | Volume/brightness on-screen display | +| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`. diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt new file mode 100644 index 0000000..e228054 --- /dev/null +++ b/assets/icons-needed.txt @@ -0,0 +1,48 @@ +SVG icons needed for breadbar +============================== +24×24 viewBox. Use `currentColor` for all fill/stroke so icons recolour +automatically with the bar theme. Drop finished files in this directory. + + +Control panel — slider row icons +--------------------------------- +Volume.svg + Speaker / soundwave icon for the volume slider row. + Currently placeholder: 🔊 emoji label. + Usage: control panel, left of volume slider. + +Brightness.svg + Sun / light bulb icon for the brightness slider row. + Currently placeholder: ☀ emoji label. + Usage: control panel, left of brightness slider. + + +Power section buttons +----------------------- +Lock.svg + Padlock icon — triggers hyprlock (lock screen). + Currently placeholder: 🔒 + +Sleep.svg + Crescent moon or Zzz icon — triggers systemctl suspend. + Currently placeholder: 💤 + +Restart.svg + Circular arrow icon — triggers systemctl reboot. + Currently placeholder: 🔄 + +Shutdown.svg + Power symbol (⏻) icon — triggers systemctl poweroff. + Currently placeholder: ⏻ + + +How to wire up icons once SVGs are ready +----------------------------------------- +Each power button and slider row icon is currently a gtk4::Label with an emoji. +To replace with an SVG: + + 1. Add the SVG to this directory. + 2. In main.rs, replace the emoji Label with: + gtk4::Image::from_paintable(Some(&svg_texture(asset!("Icon Name.svg")))) + 3. For slider rows, replace the icon_lbl in build_slider_row() calls, + or add an overload that takes an image widget instead of a string. diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 71f5015..3991324 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -51,6 +51,7 @@ pub struct Stats { pub wifi_profile: Option, pub cpu_temp: Option, pub gpu_usage: Option, + pub gpu_temp: Option, pub net_rx_kbs: f32, pub net_tx_kbs: f32, } @@ -295,23 +296,32 @@ async fn read_wifi() -> (String, &'static str) { (ssid, icon) } -fn read_cpu_temp() -> Option { +fn read_hwmon_temp(driver_name: &str) -> Option { for entry in fs::read_dir("/sys/class/hwmon").ok()?.flatten() { let path = entry.path(); let Ok(name) = fs::read_to_string(path.join("name")) else { continue }; - if name.trim() == "k10temp" { - let raw = fs::read_to_string(path.join("temp1_input")).ok()?; - return Some(raw.trim().parse::().ok()? / 1000.0); + if name.trim() == driver_name { + let Ok(raw) = fs::read_to_string(path.join("temp1_input")) else { continue }; + return raw.trim().parse::().ok().map(|v| v / 1000.0); } } None } +fn read_cpu_temp() -> Option { + read_hwmon_temp("k10temp") +} + +fn read_gpu_temp() -> Option { + read_hwmon_temp("amdgpu") +} + fn read_gpu_usage() -> Option { for entry in fs::read_dir("/sys/class/drm").ok()?.flatten() { let path = entry.path().join("device/gpu_busy_percent"); if path.exists() { - return fs::read_to_string(&path).ok()?.trim().parse().ok(); + let Ok(raw) = fs::read_to_string(&path) else { continue }; + return raw.trim().parse().ok(); } } None @@ -412,6 +422,7 @@ pub async fn poll() -> Stats { let wifi_profile = read_crumbs_profile(); let cpu_temp = read_cpu_temp(); let gpu_usage = read_gpu_usage(); + let gpu_temp = read_gpu_temp(); let (net_rx_kbs, net_tx_kbs) = read_net_throughput(); Stats { cpu: format!("{cpu:.0}%"), @@ -430,6 +441,7 @@ pub async fn poll() -> Stats { wifi_profile, cpu_temp, gpu_usage, + gpu_temp, net_rx_kbs, net_tx_kbs, } diff --git a/src/main.rs b/src/main.rs index 2a148b4..34bdd97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,7 +65,7 @@ pub struct App { panel_sink_dropdown: gtk4::DropDown, panel_sink_signal: Option, panel_sinks: Vec, - panel_temp_lbl: gtk4::Label, + panel_cpu_lbl: gtk4::Label, panel_gpu_lbl: gtk4::Label, panel_net_lbl: gtk4::Label, @@ -326,9 +326,9 @@ impl SimpleComponent for App { let stats_section = gtk4::Box::new(gtk4::Orientation::Vertical, 6); stats_section.add_css_class("control-panel-stats"); - let panel_temp_lbl = gtk4::Label::new(Some("CPU —")); - panel_temp_lbl.add_css_class("control-panel-stat"); - panel_temp_lbl.set_xalign(0.0); + let panel_cpu_lbl = gtk4::Label::new(Some("CPU —")); + panel_cpu_lbl.add_css_class("control-panel-stat"); + panel_cpu_lbl.set_xalign(0.0); let panel_gpu_lbl = gtk4::Label::new(Some("GPU —")); panel_gpu_lbl.add_css_class("control-panel-stat"); @@ -338,7 +338,7 @@ impl SimpleComponent for App { panel_net_lbl.add_css_class("control-panel-stat"); panel_net_lbl.set_xalign(0.0); - stats_section.append(&panel_temp_lbl); + stats_section.append(&panel_cpu_lbl); stats_section.append(&panel_gpu_lbl); stats_section.append(&panel_net_lbl); panel_inner.append(&stats_section); @@ -378,6 +378,40 @@ impl SimpleComponent for App { tray_section.append(&tray_box); panel_inner.append(&tray_section); + panel_inner.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + // Power section + let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + power_section.add_css_class("control-panel-section"); + let power_header = gtk4::Label::new(Some("Power")); + power_header.add_css_class("control-panel-section-header"); + power_header.set_xalign(0.0); + power_section.append(&power_header); + + let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + power_row.add_css_class("power-row"); + for (label, cmd) in [ + ("🔒", vec!["hyprlock"]), + ("💤", vec!["systemctl", "suspend"]), + ("🔄", vec!["systemctl", "reboot"]), + ("⏻", vec!["systemctl", "poweroff"]), + ] { + let btn = gtk4::Button::with_label(label); + btn.add_css_class("flat"); + btn.add_css_class("power-btn"); + btn.connect_clicked(move |_| { + let args = cmd.to_vec(); + relm4::spawn(async move { + let _ = tokio::process::Command::new(args[0]) + .args(&args[1..]) + .spawn(); + }); + }); + power_row.append(&btn); + } + power_section.append(&power_row); + panel_inner.append(&power_section); + let control_popover = gtk4::Popover::new(); control_popover.add_css_class("control-panel"); control_popover.set_child(Some(&panel_inner)); @@ -458,7 +492,7 @@ impl SimpleComponent for App { panel_sink_dropdown, panel_sink_signal: None, panel_sinks: vec![], - panel_temp_lbl, + panel_cpu_lbl, panel_gpu_lbl, panel_net_lbl, tray_box, @@ -533,14 +567,20 @@ impl SimpleComponent for App { // Live-update control panel stats while open if self.control_popover.is_visible() { - match stats.cpu_temp { - Some(t) => self.panel_temp_lbl.set_label(&format!("CPU {t:.0}°C")), - None => self.panel_temp_lbl.set_label("CPU —"), - } - match stats.gpu_usage { - Some(g) => self.panel_gpu_lbl.set_label(&format!("GPU {g}%")), - None => self.panel_gpu_lbl.set_label("GPU —"), - } + let cpu_str = match (stats.cpu_temp, stats.cpu.as_str()) { + (Some(t), pct) => format!("CPU {pct} {t:.0}°C"), + (None, pct) => format!("CPU {pct}"), + }; + self.panel_cpu_lbl.set_label(&cpu_str); + + let gpu_str = match (stats.gpu_usage, stats.gpu_temp) { + (Some(u), Some(t)) => format!("GPU {u}% {t:.0}°C"), + (Some(u), None) => format!("GPU {u}%"), + (None, Some(t)) => format!("GPU {t:.0}°C"), + (None, None) => "GPU —".to_string(), + }; + self.panel_gpu_lbl.set_label(&gpu_str); + self.panel_net_lbl.set_label(&format!( "↓ {} ↑ {}", fmt_speed(stats.net_rx_kbs), diff --git a/src/theme.rs b/src/theme.rs index ba3c011..fb7819b 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -68,6 +68,8 @@ fn load_css() -> String { .control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ letter-spacing: 0.08em; margin-bottom: 4px; }}\ .control-panel-sink-dropdown {{ }}\ + .power-row {{ margin-top: 2px; }}\ + .power-btn {{ font-size: 16px; min-width: 44px; padding: 4px; border-radius: 6px; }}\ separator {{ margin: 4px 0; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), From 3aad7109c04fb707ac8d7f4454278202edf05741 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:49:04 +0800 Subject: [PATCH 6/9] Bump bread-theme to v0.2.10 (fixed dark background/surface, accent-only pywal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the fix for panels turning brown/off-hue when pywal extracts a light or muddy background from the wallpaper — background/surface now stay BOS's fixed dark constants regardless of what the wallpaper looks like. --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a36dfbe..395cafc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ "dirs", "gtk4", @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index ce5e979..de62e7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From e057f97e82b758ca30ce2c9670eb88a268811735 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:50:13 +0800 Subject: [PATCH 7/9] bump version to 0.2.2 (v0.2.1 tag already taken) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 395cafc..7438d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.1" +version = "0.2.2" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index de62e7a..e4d513d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.1" +version = "0.2.2" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 8471cc02fec504ab1f10e732238d38f7f20882e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 10 Jul 2026 13:37:56 +0800 Subject: [PATCH 8/9] Make unsaved Nearby networks clickable, add password-entry dialog Rows for scanned networks not yet in breadcrumbs' saved config were both dimmed and disabled (set_sensitive(false)), so there was no way to add them from the popover. They now stay visually dimmed via the existing wifi-popover-row-unsaved style but remain clickable, opening a small password dialog that saves + joins the network via `breadcrumbs add` + `breadcrumbs join`. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bar/wifi.rs | 16 +++++++++ src/main.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++---- src/theme.rs | 1 + 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7438d58..78efbfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.2" +version = "0.2.3" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index e4d513d..0f562ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.2" +version = "0.2.3" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/bar/wifi.rs b/src/bar/wifi.rs index 13c25fd..6531ece 100644 --- a/src/bar/wifi.rs +++ b/src/bar/wifi.rs @@ -142,3 +142,19 @@ pub fn spawn_join(ssid: String) { }); } +/// Fire-and-forget: save a new network with its password, then join it. +pub fn spawn_add_and_join(ssid: String, password: String) { + relm4::spawn(async move { + let added = tokio::process::Command::new("breadcrumbs") + .args(["add", &ssid, &password]) + .output() + .await; + if matches!(added, Ok(o) if o.status.success()) { + let _ = tokio::process::Command::new("breadcrumbs") + .args(["join", &ssid]) + .output() + .await; + } + }); +} + diff --git a/src/main.rs b/src/main.rs index 34bdd97..b10c130 100644 --- a/src/main.rs +++ b/src/main.rs @@ -814,7 +814,6 @@ impl App { row.add_css_class("wifi-popover-row"); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); - row.set_sensitive(false); } let is_current = entry.ssid == self.current_ssid; if is_current { @@ -837,13 +836,16 @@ impl App { row_box.append(&lbl); row.set_child(Some(&row_box)); - if entry.saved { - let ssid_clone = entry.ssid.clone(); - row.connect_clicked(move |btn| { + let ssid_clone = entry.ssid.clone(); + let saved = entry.saved; + row.connect_clicked(move |btn| { + if saved { bar::wifi::spawn_join(ssid_clone.clone()); - close_parent_popover(btn); - }); - } + } else { + show_add_network_dialog(btn, ssid_clone.clone()); + } + close_parent_popover(btn); + }); self.wifi_popover_box.append(&row); } } @@ -901,6 +903,76 @@ fn close_parent_popover(widget: >k4::Button) { } } +/// Small modal prompting for a password, then saves + joins the network via +/// `breadcrumbs add` + `breadcrumbs join`. +fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { + let dialog = gtk4::Window::new(); + dialog.set_title(Some(&format!("Add “{ssid}”"))); + dialog.set_resizable(false); + dialog.add_css_class("wifi-add-dialog"); + if let Some(root) = anchor.root() { + if let Ok(win) = root.downcast::() { + dialog.set_transient_for(Some(&win)); + dialog.set_modal(true); + } + } + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + body.set_margin_top(12); + body.set_margin_bottom(12); + body.set_margin_start(12); + body.set_margin_end(12); + + let lbl = gtk4::Label::new(Some(&format!("Password for {ssid}"))); + lbl.set_xalign(0.0); + body.append(&lbl); + + let entry = gtk4::PasswordEntry::new(); + entry.set_show_peek_icon(true); + body.append(&entry); + + let btn_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + btn_row.set_halign(gtk4::Align::End); + let cancel_btn = gtk4::Button::with_label("Cancel"); + let connect_btn = gtk4::Button::with_label("Connect"); + connect_btn.add_css_class("suggested-action"); + btn_row.append(&cancel_btn); + btn_row.append(&connect_btn); + body.append(&btn_row); + + dialog.set_child(Some(&body)); + + let d = dialog.clone(); + cancel_btn.connect_clicked(move |_| d.close()); + + let dialog_for_connect = dialog.clone(); + let entry_for_connect = entry.clone(); + let ssid_for_connect = ssid.clone(); + connect_btn.connect_clicked(move |_| { + let password = entry_for_connect.text().to_string(); + if password.is_empty() { + return; + } + bar::wifi::spawn_add_and_join(ssid_for_connect.clone(), password); + dialog_for_connect.close(); + }); + + let dialog_for_activate = dialog.clone(); + let entry_for_activate = entry.clone(); + let ssid_for_activate = ssid.clone(); + entry.connect_activate(move |_| { + let password = entry_for_activate.text().to_string(); + if password.is_empty() { + return; + } + bar::wifi::spawn_add_and_join(ssid_for_activate.clone(), password); + dialog_for_activate.close(); + }); + + dialog.present(); + entry.grab_focus(); +} + fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); diff --git a/src/theme.rs b/src/theme.rs index fb7819b..dc7e149 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -50,6 +50,7 @@ fn load_css() -> String { .wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ + window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ .media-widget {{ border-radius: 4px; padding: 0 6px; cursor: pointer; }}\ .media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ .media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ From e8d5fd5a522f3a9bc45203588eea94444992ee72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 10 Jul 2026 18:04:31 +0800 Subject: [PATCH 9/9] Set layer-shell namespace to breadbar, bump version to 0.2.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78efbfb..a277271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.3" +version = "0.2.4" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 0f562ff..de80fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.2.3" +version = "0.2.4" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/main.rs b/src/main.rs index b10c130..2767846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,6 +123,7 @@ impl SimpleComponent for App { sender: ComponentSender, ) -> ComponentParts { root.init_layer_shell(); + root.set_namespace(Some("breadbar")); root.set_layer(Layer::Top); root.set_anchor(Edge::Top, true); root.set_anchor(Edge::Left, true);