diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f02a494..c5e7668 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: env: DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem jobs: build: @@ -37,8 +37,12 @@ jobs: - name: ensure bread-ecosystem run: | - rm -rf "${ECOSYSTEM_DIR}" - git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi - name: regenerate index.json run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" diff --git a/Cargo.lock b/Cargo.lock index a36dfbe..aa34dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.2.0" +version = "0.1.7" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index ce5e979..0950f3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "breadbar" -version = "0.2.0" +version = "0.1.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 bd1153f..03f03fe 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", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] +system_deps = ["gtk4", "gtk4-layer-shell", "iw", "libpulse"] optional_system_deps = ["hyprland"] bread_deps = [] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index bb55a53..c021698 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=breadbar -pkgver=0.2.0 +pkgver=0.1.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' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw') +depends=('gtk4' 'gtk4-layer-shell' 'libpulse' 'iw') optdepends=( 'hyprland: workspace and window data integration' ) diff --git a/src/bar/control.rs b/src/bar/control.rs deleted file mode 100644 index ed99568..0000000 --- a/src/bar/control.rs +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index a48ecff..0000000 --- a/src/bar/media.rs +++ /dev/null @@ -1,85 +0,0 @@ -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 2563f23..7b0d41d 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,7 +1,4 @@ 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 71f5015..2d4908b 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -11,8 +11,6 @@ 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); @@ -48,11 +46,6 @@ 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 { @@ -295,97 +288,12 @@ 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 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); - } - } - 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:.1}W")); + let power = read_power().map_or_else(|| " —W".into(), |w| format!("{w:4.1}W")); let pct = read_battery(); - let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%")); + let bat = pct.map_or_else(|| " —".into(), |p| format!("{p:3}%")); 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. @@ -409,12 +317,8 @@ 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:.0}%"), + cpu: format!("{cpu:3.0}%"), mem: if mem >= 1024 * 1024 { format!("{:.1}G", mem as f32 / (1024.0 * 1024.0)) } else { @@ -427,11 +331,6 @@ 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 deleted file mode 100644 index 13c25fd..0000000 --- a/src/bar/wifi.rs +++ /dev/null @@ -1,144 +0,0 @@ -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 2a148b4..877490c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +// 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)) @@ -14,21 +18,13 @@ 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, @@ -40,36 +36,8 @@ 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, } @@ -81,11 +49,6 @@ 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)] @@ -113,6 +76,12 @@ impl SimpleComponent for App { set_spacing: 4, } }, + + #[wrap(Some)] + set_center_widget = >k::Label { + #[watch] + set_label: &model.time_str, + }, } } } @@ -129,7 +98,19 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); - // ── SVG icon sets ──────────────────────────────────────────────── + 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")))); + 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, @@ -139,347 +120,90 @@ impl SimpleComponent for App { .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); - 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(); - 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(); - + // 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 bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); - // ── 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 wifi_textures = [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] + .into_iter() + .map(|p| (p.as_ptr() as usize, svg_texture(p))) + .collect(); - 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 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_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); - - // ── 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)); + model.tray_box.add_css_class("tray-box"); + stats_box.append(&model.tray_box); 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, sender: ComponentSender) { + fn update(&mut self, msg: Self::Input, _: ComponentSender) { match msg { AppInput::WorkspaceList(list) => { let mut sorted = list; @@ -498,7 +222,6 @@ 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); @@ -512,41 +235,10 @@ 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.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_lbl.set_label(&stats.wifi_ssid); + if let Some(tex) = self.wifi_textures.get(&(stats.wifi_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) { @@ -568,101 +260,11 @@ 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); @@ -674,191 +276,6 @@ 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 { @@ -871,6 +288,9 @@ 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/osd.rs b/src/osd.rs index 55106ec..74f0ec7 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -32,7 +32,7 @@ fn volume_watcher(tx: mpsc::Sender) { return; }; - let Some(stdout) = child.stdout.take() else { return }; + let stdout = child.stdout.take().unwrap(); 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 ba3c011..e02ed01 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: 14px; }}\ - .stat-icon {{ margin-right: 2px; }}\ - .bt-icon {{ margin-right: 14px; }}\ + .stat-pair {{ margin-right: 12px; }}\ + .stat-icon {{ margin-right: 5px; }}\ + .bt-icon {{ margin-right: 12px; }}\ 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,40 +35,7 @@ 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; }}\ - .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-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); }}\ - .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; }}", + progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 4px; min-height: 8px; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0,