From 4a3604f78ade438078f61227a6ce841c55808e9c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 19 May 2026 12:31:26 +0800 Subject: [PATCH] Fixes --- Cargo.lock | 33 ------- Cargo.toml | 13 ++- Pasted image.png | Bin 5517 -> 0 bytes src/bar/clock.rs | 3 +- src/bar/stats.rs | 193 +++++++++++++++++++++++++++++++------ src/bar/workspaces.rs | 4 +- src/main.rs | 115 +++++++++++++++++----- src/notifications/mod.rs | 31 +++++- src/notifications/popup.rs | 8 +- src/theme.rs | 78 ++++++++++++--- 10 files changed, 371 insertions(+), 107 deletions(-) delete mode 100644 Pasted image.png diff --git a/Cargo.lock b/Cargo.lock index 711b1b8..067087b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,29 +932,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pastey" version = "0.1.1" @@ -1022,15 +999,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "relm4" version = "0.11.0" @@ -1261,7 +1229,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 7d2d370..0096670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ name = "breadbar" version = "0.1.0" edition = "2021" +description = "Minimal status bar and notification daemon for Hyprland on Wayland" +license = "MIT" +authors = ["Breadway "] +repository = "https://github.com/breadway/breadbar" +keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] +categories = ["gui"] [dependencies] gtk4 = { version = "0.11", features = ["v4_12"] } @@ -10,6 +16,11 @@ relm4 = { version = "0.11", features = ["macros"] } hyprland = { version = "0.4.0-beta.3", features = ["tokio"] } futures-lite = "2" zbus = { version = "5", default-features = false, features = ["tokio"] } -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/Pasted image.png b/Pasted image.png deleted file mode 100644 index 0bff4e506488e37ac32894487be96daa97885b5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5517 zcmV;86>{o{P)Px~H%UZ6RCt{2TuIDj$5}n+tNY$R_F&oAZO2K7r^b#G z$2PG8iZTfyP>9ST0>J_)5>gUCf@~15V1W<eld8)v5EHs(at#bh?Cd8qgH0nTn0n34@$Q8+e(zDW>smCFL}wV;apMTXe$m@T`+pwoqC+=Kmi8U{;a$160+@|H0YTgo zW9>0d&Q%(RPXBI;5-x^C?xdPBZA!1*I*S@YuBW|iQsdoW2NPM%ooW|aj;5wd1Ownu zN`rOVtFxxRhpzeHn*fVs>leNC&@~@uyg<8h@=@FyERv1RZl2zQLS8dF6HlT)4P#5G z`)vHR-nfjZB_sWF1v2L%NNoPTy`CrX#~KAGurX$lEz?? zIC3?Tv27t=+v)|E1y%J!dtRomg~mvZgNvL`=CejnDV)wJzlZN3kbCGRm;S7f0UZJh3Uhu)OTQ`=GPWK(zWo5jR#)acmA{0-J?KLb10 z;JgSHw9?*WQ#?}_okiME!LZee z8gv!?q?}^1Y$JP}nw{^V3q57xQl=g0tY;oWw#+6=l{EbSsBb`zdiOP#ia z0TSo?)g)p#oY<_nZrx2f2dtUluNf_(gUL*>B@c>%MX+FmFstIM@_dks*VgYh&)5*a zoM3lnWB{3BZTVsM#XVr@g|yLSpM};c$~Jdjj-bsj7;DX&spB1mKBhCzGZ3!u?ZWs zcKo4T$EXHk6C&DKX@p0=A45r$Qkh_?&gAslYL`K`3+f;*=Py8j8P->)lSu&Ng4fK2 zrD>6${1r{1KJ~l~3xRjM_rM2lT>t9BXYRc3)a$DxUpE6lEQ0PF1pC%jZn^QAgZtJu zw|5?T^ywF0esy6p?K10B+pV8@-FaCI2fi)yv0u1t8Ybz(PFa5G-|x-J<_e=X*&KZ6 zX~c13=dy8E0&v+S2XB4XwO{$>1636p?Si%CX%VPSAgh&4US&U;;0-Xx@BGpwn=}8; zV;irlbnC{vd(D;=y9`YhUn%Ien+_j){-tl-f8@$T7ysPNhrjgodp#~FOLR}9dXt|u zt3@E_I?~j5=hyBt0LJ&J~>deNI z&pa1neC9_l09apLZqBNPQN0HXX05e}c{u2K#Nj&2Yrg%a!zWK~Jo?oCWDSop6Nf{t z{@ulDsTlxu@&n-EcU-wM+j;i67h9(av!!W}qqwf&0VX2>e8@}VP77e%=E;J$jY1Jlt-4tx@26*soZIZqO3 zk)cuO{x~cbc$$3mc_=+s<3l>%qx1%$9RsP2Y6=RnI(o{M4C^eQT??zvpn9&bjm!0VB%F&u&(( zM)o$f{x}XT&S4{bfF|Ch)j#$^$2^7PZSVe>ot^Trr=H0KWF1WxM5+&R+bK3a`4E}XFKtWh|#O)Yz)BmtU9wf zbG64s^Yv5UC(qcP0d><`*^T*ZcaF1?dcFCb*A&72^T8uoxwdAUYgYCsv^v|Fyufr4 z4qtWUBac5rz}+R$0#G%oXeecNsd;#}7h^}W$;>Q-U;fZ70tl+|5y!hAOV*RYLDR{L=^4SHAtg5rQ;I zQXgZZoH4BaoKEIG&i^edfBRc5JH4^>?DH?Z{VkVn&B~pfo!3%z-oM?4ujj)&ePeU` zU%qi)`fydiM?-Ij=sIE!tG#2i^W^Q-g88GrVhSFA17;prpq+IQCjr@!*x>DO9G zO9sP`F)Gakk|>Jct{DC5w_k1yAN$Dr((QX5e(XobUwY%9AG7fVE%|pIeDvmbUVZcR zSDo3|y62(C(LC|oHE{HJVlR5R({64qZEere_43kmju-;EBQgR)aMr`qmIU0&h^`7=IgHU$^`g*O0xx8azu;_p8r~bt4TG_cw z28CispUN1z(Z09v^E8e)xcIMBYn%>lbqqWfk0YPe>eP=Jr z01H)F2MQcShFxdAH)nZN;I4W0C@hBE<>$PQmpS=THn7sorE5HE=0!0 ztIzGNjq_y7%!To6Lq8c#A_}m`;i5m z`I)N_G@+%r?3!9OUQE~MWtivcPHIp0se93(kWuCvO=ursbZZ$Xn?aBBkx2qV^|R)A z;A}6``Y6ll%-NkPdM^%l-s#G1>-6!H$B&HNxlnfU_>rwM$5EfhC?C|Ejnz*~e<-B4 z@EgR`TV4WeLQR_&O~JmrSojrN_Ab?hMUOVJO?7rRd%<&5L-$S4;M#a(YqIPmkU2MDb0j% zL@&&ziCsj^b!(e@JcuOq($@;3j^s7xlQh2`es^(?*h<-j9fqLMP3oTv&H!%xprd4 z%Qk$IS4H&C=JQp)u&Q}|3qLN}P2`miC5XDvX;Vhrs6z*_x;$B0nh0=r8bh$b+%x-q zgAqIT6Sd<_ay;|DH1yms)4>C*o5_H6_CZ0GdGtIwS}@x<1d6KNLH>dwL{vw_RE z0J-%deM=LK`0SrpG~uPbY*%wf{}0p0o|N_r4u+7~T=s4anWF_qdr&qWFm^G5l9raj zbSb!}=cufr&&t^EUpBr?*^ZNiz^7Kz+kmxG0@5MB54WdiPBFzQ_iB7Rj3q- zf-8j?3Yrs9wNC^cbZG+85e*^&1hAA(MTZA7had}}OX4+JyGEn|m0ZAz7y*za6rkYP zuF44o$tF_;5`t>9M;M?2aEN3C+)zZBY&&2A6fS^eFuDrS87qj!;E0eRR1MLR3T_nz zGRO;870g8f!BH8U2rh#4&j|qLawhyC001LoiJ(PMCU=+s6GY0u7)gtelYuPcF@1Z) z2_u`I6KP+NCPb++6k-KfnoWuh(f|t4YACVX;mWeU@Hcl{`PS9Vv(>?UYcFl=eELuR z`H>&IQtV${0tl3rnv>D$)hJOgP$mIO5#qfCfQocT1fogdyg zz!^kZO{^7sKR)w&AHDk0%Rl>%U;n}P9$#NwgWLfV9B_k)Zb)dRd?L6(!jz;tNe3K0 zA%okX*^|gk6=U2MgWm8z?&skQ@=@)SZjP| zdM81_7=ijX38V;8h;SLxBp86yG(a*4G-lslCIAyaR8pV_6XX;@r=k$7y5iz})xPrB ziL-zB7k54U@QX_a)&QqS65eb}2zr7nA^{?}2QV7Ze0xzr5)lKJoE5!lHvEnWG*i@4 z(C>fZ_B%ds)g>2Cr%Th5XX2e-ee$n9fA6fUEKJpqlbcW>7@_Di3MM$=1cd-P;KCG? zC=!S!gD4^b6s?Y-4xxy)SqCP(J%2+rEbEG$MoC4CTC_oS# zk{PA>*JeScxo&tMEpV@)BNGBGp{jsIA-z$M4(yl$q2xeaZOse$+wue@CoDAyFFv+%peQ0EX_AXF zfbL+Z!@fCXAlzj{y&YmnkeHBB&9+jqnne@GFqR(H!pA@Oo(cS~|LI?U@bJ@<<<%&q z<2nc2VMa)D6hRWC6u#=$8o^p!23Z6gnnt++qR~tqoB(E|lZ9a5OkN;C02D}+l!*aW zq5e&pqC%*o`7rk^atgfvTLJW+lLlOytC`@4Khjs`Ss)a^u4go_5 zMRD1CD>DOvP!TXT+Ly6X6*oWm>$m*wZ`|zVW-M98nMakg$O&G0M37S4fe9s0ktBjBvNT31q*#&WlLYCkLwyj8oYk+? z*2+|1`;}yutnnW0NqgF6rT<;^`UXf?qH#b5SFKAdd&E=MV5C@&O9D`>s%wSKMmW+^ zbfXhXurM)M5vG&rG?-MRkY|>t22AWVH9iE6pAmj1x6abtwj|EYZpiv4y z6r(iT*t+Ax*Zt|Ie-YJbl~Z^OZiHwKpRKP?3nIp<3YHdMWiru-s{RH`S~PGXN<~5& zVQCQ)0ifs-$>=pC_#5p4Z6(A5!6*2=WIf)e4$a=_(q#Q0M4HlvH6)Ce?GNyHD^({IeC<1{X z5P<@40upIaq7V=w6a={x5?B!dbI}ubNMKCbB0X{x=z_7)wTUSKDTtyIm{JY}N75An zizI|B6q2PVM+q`#`(;UIl0{xqyOTk135nB%D*0DACic-^xp8^ARn00>42q@*Iuw>! z#BHl)Q_@mUgdqsQVKj4DgXscRh^ZlO&JHF@&-y^Z4GsmX*Lt%}^n#cMuB@-DuCGpl zg&f23WIC3rYAx?oJ`S0ShovX0AF?`$Qn*^a)^s)of9BCrV(bbq3_6aUzOYe2&77 zAG&z|^@o3Ivz!?KH-Iva6HV{!BqyUrz zO~E13se~H}K>%5$kYPluXy!x%#raf4$%wR>^291IPs=t1Lz>qI1rlWhTn00sGzJ)4 zAfuekkE%pUq(KfiNx7j05TGDdL_~^m=8{1MIBH5xZ$W!=mJ>k9lWI(WaLHga!c`gE znS@#r$pWImoI#Ans;L&iCN5M|iWE^US7lVh!Tn1ImyH#of+JMMC@d9vb<6+Y&+qx# zeJ@PcmL_1NKow#oMyVhRV@U~^05m1ePaAMRl1f6B#1746LL@5)^(IPSAYqi?j+o}S z0ImM!i3LdTNX!5eU8*FJCInBzP1J%5BrCE6EU675oq~u8q8Y(vpGv?LXc6>USGa+N zl3y@yXY5m1x9 z!3hURSWOaQ7Rn*1O2n)BG&k0#KDFEt;1-J}#6v~_o`@7CPfuAmnJ$eVezK0T&DRs! z-a^;yq{|D(41nDmA**`fp6UG0AXzhDO) { loop { sender.input(AppInput::ClockTick); // Sleep until the top of the next minute — display is HH:MM only. - let secs = gtk4::glib::DateTime::now_local() - .map_or(0, |dt| dt.second()); + let secs = gtk4::glib::DateTime::now_local().map_or(0, |dt| dt.second()); let wait = (60 - secs.rem_euclid(60)) as u64; tokio::time::sleep(std::time::Duration::from_secs(wait.max(1))).await; } diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 7a73c85..8a6b5d1 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -8,11 +8,29 @@ use std::{ LazyLock, Mutex, OnceLock, }, }; +use tokio::sync::OnceCell as AsyncOnce; + +static WIFI_IFACE: OnceLock> = OnceLock::new(); +static BT_CONN: AsyncOnce = AsyncOnce::const_new(); +static BT_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(BT_OFF)); +static BT_TICK: AtomicU8 = AtomicU8::new(0); pub const WIFI_STRONG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Strong.svg"); pub const WIFI_MEDIUM: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Medium.svg"); pub const WIFI_WEAK: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg"); -pub const WIFI_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Connecting.svg"); +pub const WIFI_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg"); + +pub const BAT_HIGH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg"); +pub const BAT_MID: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg"); +pub const BAT_LOW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg"); +pub const AC_POWER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/AC Power.svg"); + +pub const BT_OFF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Off.svg"); +pub const BT_ON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth.svg"); +pub const BT_CONNECTED: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/Bluetooth Connected.svg" +); #[derive(Debug)] pub struct Stats { @@ -20,6 +38,9 @@ pub struct Stats { pub mem: String, pub power: String, pub bat: String, + pub bat_icon: &'static str, + pub ac_connected: bool, + pub bt_icon: &'static str, pub wifi_ssid: String, pub wifi_icon: &'static str, } @@ -31,6 +52,7 @@ struct CpuSnapshot { static PREV_CPU: OnceLock> = OnceLock::new(); static BAT_PATH: OnceLock> = OnceLock::new(); +static AC_PATH: OnceLock> = OnceLock::new(); static WIFI_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF))); static WIFI_TICK: AtomicU8 = AtomicU8::new(0); @@ -38,16 +60,21 @@ static WIFI_TICK: AtomicU8 = AtomicU8::new(0); fn read_cpu() -> f32 { let text = fs::read_to_string("/proc/stat").unwrap_or_default(); let line = text.lines().next().unwrap_or_default(); - let vals: Vec = line - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse().ok()) - .collect(); - if vals.len() < 5 { + let mut total = 0u64; + let mut idle = 0u64; + let mut count = 0usize; + for (i, s) in line.split_whitespace().skip(1).enumerate() { + if let Ok(v) = s.parse::() { + total += v; + if i == 3 || i == 4 { + idle += v; + } + count += 1; + } + } + if count < 5 { return 0.0; } - let idle = vals[3] + vals.get(4).copied().unwrap_or(0); - let total: u64 = vals.iter().sum(); let state = PREV_CPU.get_or_init(|| Mutex::new(CpuSnapshot { total, idle })); let mut prev = state.lock().unwrap(); @@ -65,13 +92,23 @@ fn read_ram() -> u64 { let text = fs::read_to_string("/proc/meminfo").unwrap_or_default(); let mut total = 0u64; let mut avail = 0u64; + let mut found = 0u8; for line in text.lines() { let mut parts = line.split_whitespace(); match parts.next() { - Some("MemTotal:") => total = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0), - Some("MemAvailable:") => avail = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0), + Some("MemTotal:") => { + total = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + found += 1; + } + Some("MemAvailable:") => { + avail = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + found += 1; + } _ => {} } + if found == 2 { + break; + } } total.saturating_sub(avail) } @@ -83,7 +120,10 @@ fn bat_path() -> Option<&'static PathBuf> { .ok()? .filter_map(|e| e.ok()) .map(|e| e.path()) - .find(|p| p.file_name().map_or(false, |n| n.to_string_lossy().starts_with("BAT"))) + .find(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("BAT")) + }) }) .as_ref() } @@ -116,26 +156,103 @@ fn read_battery() -> Option { .ok() } -async fn read_wifi() -> (String, &'static str) { - let dev_out = tokio::process::Command::new("iw") - .arg("dev") - .output() - .await - .ok(); - let dev_stdout = match dev_out { - Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - _ => return ("—".into(), WIFI_OFF), - }; +fn bat_level_icon(pct: u8) -> &'static str { + if pct >= 67 { + BAT_HIGH + } else if pct >= 34 { + BAT_MID + } else { + BAT_LOW + } +} - let iface = dev_stdout - .lines() - .find_map(|l| l.trim().strip_prefix("Interface ").map(str::to_string)); - let Some(iface) = iface else { +fn read_ac() -> bool { + AC_PATH + .get_or_init(|| { + fs::read_dir("/sys/class/power_supply") + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + fs::read_to_string(p.join("type")) + .map(|t| t.trim() == "Mains") + .unwrap_or(false) + }) + }) + .as_ref() + .and_then(|p| fs::read_to_string(p.join("online")).ok()) + .map(|s| s.trim() == "1") + .unwrap_or(false) +} + +fn bt_rfkill_on() -> bool { + fs::read_dir("/sys/class/rfkill") + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .any(|e| { + let p = e.path(); + fs::read_to_string(p.join("type")) + .map(|t| t.trim() == "bluetooth") + .unwrap_or(false) + && fs::read_to_string(p.join("state")) + .map(|s| s.trim() == "1") + .unwrap_or(false) + }) +} + +async fn read_bt() -> &'static str { + if !bt_rfkill_on() { + return BT_OFF; + } + bt_connected_icon().await.unwrap_or(BT_ON) +} + +async fn bt_connected_icon() -> Option<&'static str> { + let conn = BT_CONN + .get_or_try_init(zbus::Connection::system) + .await + .ok()?; + let mgr = zbus::fdo::ObjectManagerProxy::builder(conn) + .destination("org.bluez") + .ok()? + .path("/") + .ok()? + .build() + .await + .ok()?; + let objects = mgr.get_managed_objects().await.ok()?; + let connected = objects + .values() + .filter_map(|ifaces| ifaces.get("org.bluez.Device1")) + .any(|props| { + props + .get("Connected") + .and_then(|v| bool::try_from(v.clone()).ok()) + .unwrap_or(false) + }); + Some(if connected { BT_CONNECTED } else { BT_ON }) +} + +fn wifi_iface() -> Option<&'static str> { + WIFI_IFACE + .get_or_init(|| { + fs::read_dir("/sys/class/net") + .ok()? + .filter_map(|e| e.ok()) + .find(|e| e.path().join("wireless").is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + }) + .as_deref() +} + +async fn read_wifi() -> (String, &'static str) { + let Some(iface) = wifi_iface() else { return ("—".into(), WIFI_OFF); }; let link_out = tokio::process::Command::new("iw") - .args(["dev", &iface, "link"]) + .args(["dev", iface, "link"]) .output() .await .ok(); @@ -172,11 +289,24 @@ 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 bat = read_battery().map_or_else(|| " —".into(), |p| format!("{p:3}%")); - // Refresh WiFi every 8 cycles (~16 s); cache the result in between. + let pct = read_battery(); + 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. + let bt_icon = { + let tick = BT_TICK.fetch_add(1, Ordering::Relaxed); + if tick.is_multiple_of(8) { + let fresh = read_bt().await; + *BT_CACHE.lock().unwrap() = fresh; + fresh + } else { + *BT_CACHE.lock().unwrap() + } + }; let (wifi_ssid, wifi_icon) = { let tick = WIFI_TICK.fetch_add(1, Ordering::Relaxed); - if tick % 8 == 0 { + if tick.is_multiple_of(8) { let fresh = read_wifi().await; *WIFI_CACHE.lock().unwrap() = fresh.clone(); fresh @@ -193,6 +323,9 @@ pub async fn poll() -> Stats { }, power, bat, + bat_icon, + ac_connected, + bt_icon, wifi_ssid, wifi_icon, } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 1c4f257..f3e2209 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -44,7 +44,9 @@ pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Bu } btn.connect_clicked(move |_| { use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; - let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id(id))); + let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( + id, + ))); }); btn } diff --git a/src/main.rs b/src/main.rs index 9e4827a..8abaa65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,13 +19,19 @@ pub struct App { active_ws: WorkspaceId, time_str: String, workspace_box: gtk4::Box, + button_map: std::collections::HashMap, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, bat_lbl: gtk4::Label, + bat_img: gtk4::Image, + bat_textures: std::collections::HashMap, + ac_img: gtk4::Image, + bt_img: gtk4::Image, + bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, - // Pre-loaded textures indexed by the WIFI_* constant pointer values. + // Pre-loaded textures indexed by constant pointer values. wifi_textures: std::collections::HashMap, } @@ -84,16 +90,45 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); - let cpu_lbl = stat_label(4); - let mem_lbl = stat_label(4); - let pwr_lbl = stat_label(5); - let bat_lbl = stat_label(4); + 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(12); - let wifi_img = gtk4::Image::from_paintable(Some(&svg_texture(asset!("WiFi Connecting.svg")))); + 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, + }; + let bat_textures: std::collections::HashMap = + [BAT_HIGH, BAT_MID, BAT_LOW] + .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 bt_img = gtk4::Image::from_paintable(Some( + bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), + )); - use bar::stats::{WIFI_OFF, WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK}; let wifi_textures = [WIFI_STRONG, WIFI_MEDIUM, WIFI_WEAK, WIFI_OFF] .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) @@ -104,10 +139,16 @@ impl SimpleComponent for App { 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, @@ -115,13 +156,25 @@ impl SimpleComponent for App { let widgets = view_output!(); model.workspace_box = widgets.workspace_box.clone(); - let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - stats_box.set_margin_end(8); + 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)); - stats_box.append(&stat_pair(asset!("Battery.svg"), &bat_lbl)); - let wifi_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + 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"); + 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"); + 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); @@ -145,8 +198,13 @@ impl SimpleComponent for App { self.rebuild_buttons(); } AppInput::ActiveWorkspace(id) => { + if let Some(old) = self.button_map.get(&self.active_ws) { + old.remove_css_class("active"); + } self.active_ws = id; - self.rebuild_buttons(); + if let Some(btn) = self.button_map.get(&self.active_ws) { + btn.add_css_class("active"); + } } AppInput::ClockTick => { self.time_str = bar::clock::current(); @@ -156,6 +214,13 @@ impl SimpleComponent for App { self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); self.bat_lbl.set_label(&stats.bat); + if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { + self.bat_img.set_paintable(Some(tex)); + } + self.ac_img.set_visible(stats.ac_connected); + 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.wifi_img.set_paintable(Some(tex)); @@ -166,20 +231,25 @@ impl SimpleComponent for App { } impl App { - fn rebuild_buttons(&self) { + fn rebuild_buttons(&mut self) { while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } + self.button_map.clear(); for ws in &self.workspaces { - self.workspace_box - .append(&bar::workspaces::make_button(ws.id, &ws.name, self.active_ws)); + let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws); + self.workspace_box.append(&btn); + self.button_map.insert(ws.id, btn); } } } fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { - let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - pair.append(>k4::Image::from_paintable(Some(&svg_texture(icon_path)))); + let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + pair.add_css_class("stat-pair"); + let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_path))); + img.add_css_class("stat-icon"); + pair.append(&img); pair.append(label); pair } @@ -187,15 +257,16 @@ fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { fn svg_texture(path: &str) -> gtk4::gdk::Texture { let svg = std::fs::read_to_string(path) .unwrap_or_default() - .replace("currentColor", "white"); + .replace("currentColor", "white") + .replace(r#"width="24" height="24""#, r#"width="16" height="16""#); let bytes = gtk4::glib::Bytes::from_owned(svg.into_bytes()); gtk4::gdk::Texture::from_bytes(&bytes).expect("svg load") } -fn stat_label(width_chars: i32) -> gtk4::Label { +fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); - lbl.set_width_chars(width_chars); - lbl.set_xalign(1.0); + lbl.add_css_class("stat-label"); + lbl.set_xalign(0.0); lbl } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 5ae7817..b197038 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -5,7 +5,13 @@ use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; pub enum NotifEvent { - Show { id: u32, app_name: String, summary: String, body: String, timeout_ms: u32 }, + Show { + id: u32, + app_name: String, + summary: String, + body: String, + timeout_ms: u32, + }, Close(u32), } @@ -16,6 +22,8 @@ struct NotifServer { #[zbus::interface(name = "org.freedesktop.Notifications")] impl NotifServer { + // The org.freedesktop.Notifications spec mandates exactly these 8 parameters. + #[allow(clippy::too_many_arguments)] async fn notify( &self, app_name: &str, @@ -32,7 +40,11 @@ impl NotifServer { } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; - let timeout_ms = if expire_timeout <= 0 { 5000 } else { expire_timeout as u32 }; + let timeout_ms = if expire_timeout <= 0 { + 5000 + } else { + expire_timeout as u32 + }; let _ = self .tx .send(NotifEvent::Show { @@ -55,7 +67,12 @@ impl NotifServer { } fn get_server_information(&self) -> (String, String, String, String) { - ("breadbar".into(), "breadway".into(), "0.1.0".into(), "1.2".into()) + ( + "breadbar".into(), + "breadway".into(), + "0.1.0".into(), + "1.2".into(), + ) } } @@ -63,7 +80,11 @@ pub fn spawn() { let (tx, rx) = mpsc::channel(32); relm4::spawn(async move { - let server = NotifServer { tx, next_id: AtomicU32::new(1) }; + let server = NotifServer { + tx, + next_id: AtomicU32::new(1), + }; + // Builder failures here would only occur with invalid static strings — safe to unwrap. let _conn = zbus::connection::Builder::session() .unwrap() .name("org.freedesktop.Notifications") @@ -72,7 +93,7 @@ pub fn spawn() { .unwrap() .build() .await - .expect("failed to claim org.freedesktop.Notifications"); + .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); std::future::pending::<()>().await }); diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 0cfa054..c8a7e50 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -21,7 +21,13 @@ pub async fn run(mut rx: Receiver) { while let Some(event) = rx.recv().await { match event { - NotifEvent::Show { id, app_name, summary, body, timeout_ms } => { + NotifEvent::Show { + id, + app_name, + summary, + body, + timeout_ms, + } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); diff --git a/src/theme.rs b/src/theme.rs index e5db59c..534e903 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,11 @@ use gtk4::CssProvider; use serde::Deserialize; +use std::cell::RefCell; + +thread_local! { + static PROVIDER: RefCell> = const { RefCell::new(None) }; + static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; +} #[derive(Deserialize)] struct WalColors { @@ -29,11 +35,16 @@ fn hex_to_rgba(hex: &str, alpha: f32) -> String { fn load_css() -> String { let home = std::env::var("HOME").unwrap_or_default(); - let text = std::fs::read_to_string(format!("{home}/.cache/wal/colors.json")) - .unwrap_or_default(); + let text = + std::fs::read_to_string(format!("{home}/.cache/wal/colors.json")).unwrap_or_default(); let (bg, surface, fg, accent) = if let Ok(wal) = serde_json::from_str::(&text) { - (wal.special.background, wal.colors.color0, wal.colors.color15, wal.colors.color1) + ( + wal.special.background, + wal.colors.color0, + wal.colors.color15, + wal.colors.color1, + ) } else { ( "#1e1e2e".to_string(), @@ -44,14 +55,18 @@ fn load_css() -> String { }; format!( - "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 12px; }}\ + "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ window.breadbar {{ background-color: {bg_rgba}; border-radius: 0; }}\ + label {{ color: {fg}; }}\ .workspace-btn {{ background: transparent; color: {fg}; opacity: 0.45;\ border-radius: 0 0 8px 8px; border: none; outline: none; box-shadow: none;\ min-width: 24px; padding: 2px 8px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ .workspace-btn.active {{ background: {accent}; opacity: 1; }}\ - label {{ color: {fg}; }}\ + .stats-box {{ margin-right: 8px; }}\ + .stat-pair {{ margin-right: 8px; }}\ + .stat-icon {{ margin-right: 3px; }}\ + .bt-icon {{ margin-right: 8px; }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); }}\ .notification-card {{ background: {surface}; border-radius: 6px;\ padding: 10px; margin-bottom: 4px; }}\ @@ -67,11 +82,50 @@ fn load_css() -> String { } pub fn apply() { - let provider = CssProvider::new(); - provider.load_from_string(&load_css()); - gtk4::style_context_add_provider_for_display( - >k4::gdk::Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + let css = load_css(); + let display = gtk4::gdk::Display::default().expect("no display"); + + PROVIDER.with(|cell| { + let mut guard = cell.borrow_mut(); + if let Some(p) = guard.as_ref() { + p.load_from_string(&css); + } else { + let p = CssProvider::new(); + p.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &display, + &p, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + *guard = Some(p); + } + }); + + // User override: ~/.config/breadbar/style.css — send SIGHUP to reload. + let home = std::env::var("HOME").unwrap_or_default(); + let user_path = format!("{home}/.config/breadbar/style.css"); + USER_PROVIDER.with(|cell| { + let mut guard = cell.borrow_mut(); + match std::fs::read_to_string(&user_path) { + Ok(user_css) => { + if let Some(p) = guard.as_ref() { + p.load_from_string(&user_css); + } else { + let p = CssProvider::new(); + p.load_from_string(&user_css); + gtk4::style_context_add_provider_for_display( + &display, + &p, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + *guard = Some(p); + } + } + Err(_) => { + if let Some(p) = guard.as_ref() { + p.load_from_string(""); + } + } + } + }); }