From 275c4aef2bd5990bdac61ca81ac4a00f022dcfca Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:43:52 +0800 Subject: [PATCH 1/6] Release 0.2.0: media widget, wifi popover, control panel Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PxgMEoa2PWNkKnW88pbMBM --- 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 3ae3eff59efc34bb190cf33c0927caefbc372feb Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:43:52 +0800 Subject: [PATCH 2/6] 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 cd00e6ba9d3ef3e3a75bae8b1c32516cb9edfe5a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:54:36 +0800 Subject: [PATCH 3/6] chore: update Cargo.lock for v0.2.0 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PxgMEoa2PWNkKnW88pbMBM --- 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 673b5c52cfa5f028888520a2ad6e06c56a32a13d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 06:54:36 +0800 Subject: [PATCH 4/6] 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 e363e51c877324b2371930415cd4f8fa3eecf026 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 07:05:07 +0800 Subject: [PATCH 5/6] 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 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01PxgMEoa2PWNkKnW88pbMBM --- 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 31b2d10909f8cf95491705cc3110ff0962a5464c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 07:05:07 +0800 Subject: [PATCH 6/6] 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); }}\