From b8a3424834589271aad016089a10ed0e04197554 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 19 May 2026 12:30:12 +0800 Subject: [PATCH 01/26] Prepare repo for public GitHub release Add README, MIT LICENSE, expanded .gitignore, and updated SVG icon set to make the repository presentable for open-source publication. --- .gitignore | 37 +++++++- LICENSE | 21 +++++ README.md | 116 ++++++++++++++++++++++++++ assets/AC Power.svg | 1 + assets/Battery 1 Bar.svg | 1 + assets/Battery 2 Bars.svg | 1 + assets/Battery 3 Bars.svg | 1 + assets/Bluetooth Connected.svg | 1 + assets/Bluetooth Off.svg | 1 + assets/{Battery.svg => Bluetooth.svg} | 2 +- assets/WiFi Disconnect.svg | 1 + 11 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/AC Power.svg create mode 100644 assets/Battery 1 Bar.svg create mode 100644 assets/Battery 2 Bars.svg create mode 100644 assets/Battery 3 Bars.svg create mode 100644 assets/Bluetooth Connected.svg create mode 100644 assets/Bluetooth Off.svg rename assets/{Battery.svg => Bluetooth.svg} (61%) create mode 100644 assets/WiFi Disconnect.svg diff --git a/.gitignore b/.gitignore index b9410c9..816e2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,37 @@ -/target +# Build artifacts +/target/ + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# Environment / secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ + +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid + +# Claude Code session data +.claude/ + +# Internal design documents (not for distribution) aster-brief.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..56e6a3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Riley Horsham + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e577e8 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# breadbar + +Minimal status bar and notification daemon for [Hyprland](https://hyprland.org/) on Wayland. + +A single Rust binary that provides a full-width top bar and a standards-compliant D-Bus notification daemon, with no system tray, no launcher, and no wallpaper logic. + +## Features + +**Status bar** (anchored to the top of every monitor via `gtk4-layer-shell`): + +- Left: live workspace buttons sourced from Hyprland IPC, active workspace highlighted +- Centre: clock (`HH:MM`, updates at the top of each minute) +- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength + +**Notification daemon**: + +- Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard notification sender (`notify-send`, etc.) +- Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) +- Supports `CloseNotification` + +**Theming**: + +- Reads `~/.cache/wal/colors.json` (pywal) on startup for a palette that matches your wallpaper +- Falls back to a Catppuccin Mocha palette if pywal is not present +- User CSS override: `~/.config/breadbar/style.css` +- Send `SIGHUP` to reload the theme at runtime (integrates with wallpaper-change hooks) + +## Dependencies + +Runtime: + +- GTK4 (≥ 4.12) +- `gtk4-layer-shell` +- `iw` — for WiFi SSID/signal (`iw dev link`) +- A running Hyprland compositor +- D-Bus session bus + +Bluetooth status is read from `/sys/class/rfkill` and BlueZ D-Bus; it degrades gracefully if unavailable. + +## Building + +```sh +cargo build --release +``` + +The binary is at `target/release/breadbar`. + +Requirements: Rust 1.77+ (uses `LazyLock`), a GTK4 development environment (`libgtk-4-dev` / `gtk4` package). + +On Arch Linux: + +```sh +sudo pacman -S gtk4 gtk4-layer-shell iw +cargo build --release +``` + +## Running + +```sh +./target/release/breadbar +``` + +Typically launched from your Hyprland config: + +``` +exec-once = /path/to/breadbar +``` + +breadbar claims `org.freedesktop.Notifications` on the session D-Bus on startup. If another notification daemon is already running, startup will fail — stop the other daemon first. + +## Theming + +### pywal integration + +breadbar reads `~/.cache/wal/colors.json` automatically. To reload after a wallpaper change: + +```sh +pkill -HUP breadbar +``` + +Or hook it into your wallpaper script: + +```sh +wal -i /path/to/wallpaper.jpg +pkill -HUP breadbar +``` + +### Custom CSS + +Drop a `~/.config/breadbar/style.css` file and send `SIGHUP` to reload. This CSS is applied at a higher priority than the pywal palette so you can override anything. + +Example — change the font size: + +```css +* { + font-size: 13px; +} +``` + +## Architecture + +| Module | Responsibility | +|---|---| +| `src/main.rs` | GTK4 app entry point, widget tree, `relm4` component | +| `src/bar/workspaces.rs` | Hyprland IPC event stream, workspace buttons | +| `src/bar/clock.rs` | Minute-tick clock | +| `src/bar/stats.rs` | Polling loop: CPU, RAM, power, battery, Bluetooth, WiFi | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | +| `src/notifications/popup.rs` | Layer-shell popup window and card stack | +| `src/theme.rs` | pywal reader, GTK CSS provider injection | + +Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`. + +## License + +MIT diff --git a/assets/AC Power.svg b/assets/AC Power.svg new file mode 100644 index 0000000..2bae44c --- /dev/null +++ b/assets/AC Power.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Battery 1 Bar.svg b/assets/Battery 1 Bar.svg new file mode 100644 index 0000000..4ca262e --- /dev/null +++ b/assets/Battery 1 Bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Battery 2 Bars.svg b/assets/Battery 2 Bars.svg new file mode 100644 index 0000000..0e09af1 --- /dev/null +++ b/assets/Battery 2 Bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Battery 3 Bars.svg b/assets/Battery 3 Bars.svg new file mode 100644 index 0000000..61af371 --- /dev/null +++ b/assets/Battery 3 Bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Bluetooth Connected.svg b/assets/Bluetooth Connected.svg new file mode 100644 index 0000000..d581cf9 --- /dev/null +++ b/assets/Bluetooth Connected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Bluetooth Off.svg b/assets/Bluetooth Off.svg new file mode 100644 index 0000000..b2a109d --- /dev/null +++ b/assets/Bluetooth Off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/Battery.svg b/assets/Bluetooth.svg similarity index 61% rename from assets/Battery.svg rename to assets/Bluetooth.svg index 11b82f5..becb3fa 100644 --- a/assets/Battery.svg +++ b/assets/Bluetooth.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/WiFi Disconnect.svg b/assets/WiFi Disconnect.svg new file mode 100644 index 0000000..5b42390 --- /dev/null +++ b/assets/WiFi Disconnect.svg @@ -0,0 +1 @@ + \ No newline at end of file From 4a3604f78ade438078f61227a6ce841c55808e9c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 19 May 2026 12:31:26 +0800 Subject: [PATCH 02/26] 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(""); + } + } + } + }); } From 4b66efa87c9533ccca30ca2b26132d0a67ae563c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:10 +0800 Subject: [PATCH 03/26] Refactor theme onto bread-theme; add bakery.toml and release workflow - Cargo.toml: depend on bread-theme (path dep for local dev, git dep for production) with gtk feature; remove local theme dependencies - src/theme.rs: replace local pywal/Catppuccin impl with bread_theme::gtk helpers; local bar-specific CSS is preserved - bakery.toml: describes breadbar for bakery install - release.yml: builds on hestia self-hosted runner, publishes binary to dl.breadway.dev and GitHub Releases on v* tags --- .github/workflows/release.yml | 56 +++++++++++ Cargo.lock | 173 ++++++++++++++++++++++++++++++-- Cargo.toml | 3 + bakery.toml | 11 ++ src/bar/clock.rs | 4 +- src/main.rs | 5 +- src/osd.rs | 184 ++++++++++++++++++++++++++++++++++ src/theme.rs | 134 ++++++------------------- 8 files changed, 456 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml create mode 100644 src/osd.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..77de25a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install system deps + run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell iw 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbar/${VERSION}" + mkdir -p "${PKG_DIR}" + cp target/release/breadbar "${PKG_DIR}/breadbar-x86_64" + strip "${PKG_DIR}/breadbar-x86_64" + sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadbar-x86_64.sha256" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbar/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadbar/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadbar-x86_64" \ + "${PKG_DIR}/breadbar-x86_64.sha256" \ + --clobber diff --git a/Cargo.lock b/Cargo.lock index 067087b..b3b2530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,10 +76,21 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bread-theme" +version = "0.1.0" +dependencies = [ + "dirs", + "gtk4", + "serde", + "serde_json", +] + [[package]] name = "breadbar" version = "0.1.0" dependencies = [ + "bread-theme", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -189,6 +200,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "either" version = "1.15.0" @@ -235,7 +267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -464,6 +496,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -518,7 +561,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -839,6 +882,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -883,7 +935,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -892,6 +944,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -999,6 +1057,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "relm4" version = "0.11.0" @@ -1052,7 +1121,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1165,7 +1234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1217,7 +1286,27 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1234,7 +1323,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1338,7 +1427,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1485,6 +1574,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1494,6 +1592,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "1.0.3" @@ -1626,7 +1781,7 @@ dependencies = [ "tracing", "uds_windows", "uuid", - "windows-sys", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", diff --git a/Cargo.toml b/Cargo.toml index 0096670..30b6e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] +# Path dep for local dev; replace with git dep on first tag: +# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } +bread-theme = { path = "../bread-ecosystem/bread-theme", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..a334ea4 --- /dev/null +++ b/bakery.toml @@ -0,0 +1,11 @@ +name = "breadbar" +description = "Minimal status bar and notification daemon for Hyprland" +binaries = ["breadbar"] +system_deps = ["gtk4", "gtk4-layer-shell", "iw"] +bread_deps = [] + +[config] +dir = "~/.config/breadbar" + +[install] +post_install = [] diff --git a/src/bar/clock.rs b/src/bar/clock.rs index 76639d5..4501fde 100644 --- a/src/bar/clock.rs +++ b/src/bar/clock.rs @@ -3,7 +3,9 @@ use relm4::ComponentSender; pub fn current() -> String { let dt = gtk4::glib::DateTime::now_local().expect("local time"); - format!("{:02}:{:02}", dt.hour(), dt.minute()) + let date = dt.format("%a %d/%m").expect("date format"); + let time = format!("{:02}:{:02}", dt.hour(), dt.minute()); + format!("{} {}", date, time) } pub fn spawn_ticker(sender: ComponentSender) { diff --git a/src/main.rs b/src/main.rs index 8abaa65..10cca56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ macro_rules! asset { mod bar; mod notifications; +mod osd; mod theme; use gtk4::prelude::*; @@ -185,6 +186,7 @@ impl SimpleComponent for App { bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender); notifications::spawn(); + osd::spawn(); ComponentParts { model, widgets } } @@ -255,9 +257,10 @@ fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { } fn svg_texture(path: &str) -> gtk4::gdk::Texture { + let fg = theme::fg_color(); let svg = std::fs::read_to_string(path) .unwrap_or_default() - .replace("currentColor", "white") + .replace("currentColor", &fg) .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") diff --git a/src/osd.rs b/src/osd.rs new file mode 100644 index 0000000..61f7ea4 --- /dev/null +++ b/src/osd.rs @@ -0,0 +1,184 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use tokio::sync::mpsc; + +enum OsdEvent { + Volume { pct: u8, muted: bool }, + Brightness { pct: u8 }, +} + +pub fn spawn() { + let (tx, rx) = mpsc::channel::(8); + + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); + + relm4::spawn_local(run_osd(rx)); +} + +fn volume_watcher(tx: mpsc::Sender) { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + + let Ok(mut child) = Command::new("pactl") + .args(["subscribe"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return; + }; + + let stdout = child.stdout.take().unwrap(); + let reader = BufReader::new(stdout); + + for line in reader.lines().flatten() { + if line.contains("'change' on sink") { + if let Some(evt) = query_volume() { + let _ = tx.blocking_send(evt); + } + } + } +} + +fn query_volume() -> Option { + use std::process::Command; + + let vol = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + .ok()?; + let mute = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .ok()?; + + let vol_str = String::from_utf8_lossy(&vol.stdout); + let mute_str = String::from_utf8_lossy(&mute.stdout); + + // "Volume: front-left: 45875 / 70% / -8.58 dB, ..." + let pct: u8 = vol_str + .split('/') + .nth(1)? + .trim() + .trim_end_matches('%') + .trim() + .parse() + .ok()?; + + let muted = mute_str.contains(": yes"); + + Some(OsdEvent::Volume { pct, muted }) +} + +fn brightness_watcher(tx: mpsc::Sender) { + use std::fs; + + let base = match fs::read_dir("/sys/class/backlight") + .ok() + .and_then(|mut d| d.next()) + .and_then(|e| e.ok()) + .map(|e| e.path()) + { + Some(p) => p, + None => return, + }; + + let bright_path = base.join("brightness"); + let max_path = base.join("max_brightness"); + + let max: u64 = match fs::read_to_string(&max_path) + .ok() + .and_then(|s| s.trim().parse().ok()) + { + Some(v) if v > 0 => v, + _ => return, + }; + + // Initialize to current value so startup doesn't trigger OSD. + let mut last: u64 = fs::read_to_string(&bright_path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(u64::MAX); + + loop { + if let Some(val) = fs::read_to_string(&bright_path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + if val != last { + last = val; + let pct = ((val * 100) / max).min(100) as u8; + let _ = tx.blocking_send(OsdEvent::Brightness { pct }); + } + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +async fn run_osd(mut rx: mpsc::Receiver) { + let window = create_window(); + + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + container.set_margin_top(12); + container.set_margin_bottom(12); + container.set_margin_start(16); + container.set_margin_end(16); + window.set_child(Some(&container)); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let kind_lbl = gtk4::Label::new(Some("Volume")); + kind_lbl.add_css_class("osd-kind"); + kind_lbl.set_hexpand(true); + kind_lbl.set_xalign(0.0); + let pct_lbl = gtk4::Label::new(Some("0%")); + pct_lbl.add_css_class("osd-pct"); + header.append(&kind_lbl); + header.append(&pct_lbl); + container.append(&header); + + let pbar = gtk4::ProgressBar::new(); + pbar.add_css_class("osd-bar"); + container.append(&pbar); + + let dismiss_token = Rc::new(Cell::new(0u32)); + + while let Some(event) = rx.recv().await { + let (kind, pct) = match event { + OsdEvent::Volume { pct, muted } => { + (if muted { "Volume (Muted)" } else { "Volume" }, pct) + } + OsdEvent::Brightness { pct } => ("Brightness", pct), + }; + + kind_lbl.set_label(kind); + pct_lbl.set_label(&format!("{pct}%")); + pbar.set_fraction(pct as f64 / 100.0); + window.set_visible(true); + + let token = dismiss_token.get().wrapping_add(1); + dismiss_token.set(token); + let dtok = dismiss_token.clone(); + let win = window.clone(); + relm4::spawn_local(async move { + gtk4::glib::timeout_future(Duration::from_millis(2000)).await; + if dtok.get() == token { + win.set_visible(false); + } + }); + } +} + +fn create_window() -> gtk4::Window { + let window = gtk4::Window::new(); + window.add_css_class("breadbar-osd"); + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_anchor(Edge::Bottom, true); + window.set_margin(Edge::Bottom, 80); + window.set_default_width(280); + window +} diff --git a/src/theme.rs b/src/theme.rs index 534e903..62ce751 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,5 @@ +use bread_theme::{gtk as bgtk, hex_to_rgba, load_palette}; use gtk4::CssProvider; -use serde::Deserialize; use std::cell::RefCell; thread_local! { @@ -7,125 +7,53 @@ thread_local! { static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; } -#[derive(Deserialize)] -struct WalColors { - special: Special, - colors: Colors, -} - -#[derive(Deserialize)] -struct Special { - background: String, -} - -#[derive(Deserialize)] -struct Colors { - color0: String, - color1: String, - color15: String, -} - -fn hex_to_rgba(hex: &str, alpha: f32) -> String { - let h = hex.trim_start_matches('#'); - let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0); - let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0); - let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0); - format!("rgba({r},{g},{b},{alpha})") -} - 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 (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, - ) - } else { - ( - "#1e1e2e".to_string(), - "#181825".to_string(), - "#cdd6f4".to_string(), - "#89b4fa".to_string(), - ) - }; - + let p = load_palette(); format!( - "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + "* {{ font-family: 'Varela Round', sans-serif; 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; }}\ + min-width: 24px; padding: 4px 8px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ .workspace-btn.active {{ background: {accent}; opacity: 1; }}\ .stats-box {{ margin-right: 8px; }}\ - .stat-pair {{ margin-right: 8px; }}\ - .stat-icon {{ margin-right: 3px; }}\ - .bt-icon {{ margin-right: 8px; }}\ + .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); }}\ - .notification-card {{ background: {surface}; border-radius: 6px;\ - padding: 10px; margin-bottom: 4px; }}\ + .notification-card {{ background: {surface}; border-radius: 8px;\ + padding: 12px; margin-bottom: 8px; }}\ .notification-summary {{ font-weight: bold; color: {fg}; }}\ .notification-app {{ color: {fg}; opacity: 0.6; }}\ - .notification-body {{ color: {fg}; }}", - bg_plain = bg, - bg_rgba = hex_to_rgba(&bg, 0.92), - surface = surface, - fg = fg, - accent = accent, + .notification-body {{ color: {fg}; }}\ + window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); border-radius: 8px; }}\ + .osd-kind {{ color: {fg}; opacity: 0.75; font-size: 12px; }}\ + .osd-pct {{ color: {fg}; 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; }}", + bg_plain = p.background, + bg_rgba = hex_to_rgba(&p.background, 0.92), + surface = p.color0, + fg = p.foreground, + accent = p.color4, + trough = hex_to_rgba(&p.color4, 0.25), ) } +/// Returns the current foreground colour (used for icon tinting in the stats bar). +pub fn fg_color() -> String { + load_palette().foreground +} + +/// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { let css = load_css(); - let display = gtk4::gdk::Display::default().expect("no display"); + PROVIDER.with(|cell| bgtk::apply_css(&css, cell)); - 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(""); - } - } - } - }); + let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); + USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); } From 5c649a301ed60aa466602d7d88d479af8b15e4fa Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:47:41 +0800 Subject: [PATCH 04/26] fix: use apt-get on hestia runner (Ubuntu, not Arch) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77de25a..add6d62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: install system deps - run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell iw 2>/dev/null || true + run: sudo apt-get install -y libgtk-4-dev iw 2>/dev/null || true - name: build run: cargo build --release --locked From b3b3b86a767d2204fc4ee0fab3e4ef7cdb424831 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:53 +0800 Subject: [PATCH 05/26] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add6d62..d66c631 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install system deps - run: sudo apt-get install -y libgtk-4-dev iw 2>/dev/null || true + - name: install build deps + run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config iw 2>/dev/null || true - name: build run: cargo build --release --locked From 2db421d8ebae55339fa4d8f556eaa17213290a47 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:26:20 +0800 Subject: [PATCH 06/26] fix: switch bread-theme to git dep (v0.1.0) for CI --- Cargo.lock | 97 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 4 +-- 2 files changed, 50 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3b2530..e31b4e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,19 +66,20 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.1.0" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" dependencies = [ "dirs", "gtk4", @@ -104,9 +105,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -139,9 +140,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -223,9 +224,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "endi" @@ -854,9 +855,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -908,15 +909,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -929,9 +930,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1174,9 +1175,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1229,9 +1230,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1272,9 +1273,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -1363,9 +1364,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -1438,9 +1439,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -1450,9 +1451,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "js-sys", "serde_core", @@ -1491,9 +1492,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1504,9 +1505,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1514,9 +1515,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -1527,9 +1528,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -1760,9 +1761,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zbus" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-recursion", @@ -1790,9 +1791,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1822,9 +1823,9 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", @@ -1836,9 +1837,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1849,9 +1850,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 30b6e81..e127970 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -# Path dep for local dev; replace with git dep on first tag: -# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0", features = ["gtk"] } -bread-theme = { path = "../bread-ecosystem/bread-theme", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } From 4c28b6cc0e96c9eb1991a6d49f32f4a70bd78487 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:39 +0800 Subject: [PATCH 07/26] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d66c631..72811b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/breadbar/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadbar v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadbar-x86_64" \ "${PKG_DIR}/breadbar-x86_64.sha256" \ From 0632d83c01a546a2a862174a96ae3a42e1e43330 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:47 +0800 Subject: [PATCH 08/26] fix: add contents: write permission for GitHub Release creation --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72811b6..e3e4ff1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 2b47f796d2191c926a0c1eeedc26d698449421ef Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:02:38 +0800 Subject: [PATCH 09/26] fix: use relative symlink for latest to work inside Docker containers --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3e4ff1..c5e7668 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/breadbar-x86_64.sha256" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/breadbar/latest" + ln -sfn "${VERSION}" "${DL_DIR}/breadbar/latest" - name: ensure bread-ecosystem run: | From 9d2a4c9b547badac8588ecb3b8876639495b04c0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:40 +0800 Subject: [PATCH 10/26] fix: add missing libpulse dep, add optional_system_deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libpulse (pactl) was missing — breadbar shells pactl 3x for volume. Optional: hyprland (workspace display, not a linked dep). --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index a334ea4..03f03fe 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "breadbar" description = "Minimal status bar and notification daemon for Hyprland" binaries = ["breadbar"] -system_deps = ["gtk4", "gtk4-layer-shell", "iw"] +system_deps = ["gtk4", "gtk4-layer-shell", "iw", "libpulse"] +optional_system_deps = ["hyprland"] bread_deps = [] [config] From 432c2da18c9ebd985aee79b9591922181eb41d73 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:28 +0800 Subject: [PATCH 11/26] chore: bump version to 0.1.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e127970..8191caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.0" +version = "0.1.1" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From a036737823f2482496004b4de62645b6d81dcab5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:27:58 +0800 Subject: [PATCH 12/26] chore: update Cargo.lock for v0.1.1 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e31b4e6..5835492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bread-theme", "futures-lite", From 44a8d95887ee5e853565aa08539adbc43bf3ec72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 22:31:25 +0800 Subject: [PATCH 13/26] feat: add system tray (StatusNotifierWatcher / SNI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements org.kde.StatusNotifierWatcher as a D-Bus service so apps like Nextcloud can register their tray icons. Icons are rendered from SNI ARGB pixmaps (falling back to icon-name theme lookup), click calls Activate(0,0), and NameOwnerChanged cleans up ghost icons when an app exits. Styling follows the Bread Design System (4px tertiary radius, xs/sm spacing, opacity transitions). Also fixes a latent infinite-loop risk in osd.rs (.flatten → .map_while) and syncs the notifications server version string to CARGO_PKG_VERSION. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 5 +- src/bar/mod.rs | 1 + src/bar/tray.rs | 240 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 30 ++++- src/notifications/mod.rs | 2 +- src/osd.rs | 2 +- 8 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/bar/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 5835492..dd6d212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index 8191caa..4594c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/README.md b/README.md index 1e577e8..c823661 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Minimal status bar and notification daemon for [Hyprland](https://hyprland.org/) on Wayland. -A single Rust binary that provides a full-width top bar and a standards-compliant D-Bus notification daemon, with no system tray, no launcher, and no wallpaper logic. +A single Rust binary that provides a full-width top bar, a system tray, and a standards-compliant D-Bus notification daemon. No launcher, no wallpaper logic. ## Features @@ -10,7 +10,7 @@ A single Rust binary that provides a full-width top bar and a standards-complian - Left: live workspace buttons sourced from Hyprland IPC, active workspace highlighted - Centre: clock (`HH:MM`, updates at the top of each minute) -- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength +- Right: CPU%, RAM, power draw (W), battery level + AC indicator, Bluetooth state, WiFi SSID with signal strength, system tray (SNI) **Notification daemon**: @@ -105,6 +105,7 @@ Example — change the font size: | `src/bar/workspaces.rs` | Hyprland IPC event stream, workspace buttons | | `src/bar/clock.rs` | Minute-tick clock | | `src/bar/stats.rs` | Polling loop: CPU, RAM, power, battery, Bluetooth, WiFi | +| `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | | `src/theme.rs` | pywal reader, GTK CSS provider injection | diff --git a/src/bar/mod.rs b/src/bar/mod.rs index 24c75b5..7b0d41d 100644 --- a/src/bar/mod.rs +++ b/src/bar/mod.rs @@ -1,3 +1,4 @@ pub mod clock; pub mod stats; +pub mod tray; pub mod workspaces; diff --git a/src/bar/tray.rs b/src/bar/tray.rs new file mode 100644 index 0000000..421b7c8 --- /dev/null +++ b/src/bar/tray.rs @@ -0,0 +1,240 @@ +use crate::{App, AppInput}; +use futures_lite::StreamExt; +use gtk4::prelude::Cast; +use relm4::ComponentSender; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use zbus::{interface, object_server::SignalEmitter}; + +#[derive(Debug)] +pub enum TrayIconData { + Pixels { width: i32, height: i32, data: Vec }, + Name(String), +} + +#[derive(Debug)] +pub enum TrayUpdate { + Add { id: String, icon: Option, title: String }, + Remove { id: String }, +} + +struct WatcherState { + items: Vec, +} + +struct Watcher { + state: Arc>, + tx: tokio::sync::mpsc::UnboundedSender<(String, String)>, +} + +#[interface(name = "org.kde.StatusNotifierWatcher")] +impl Watcher { + async fn register_status_notifier_item( + &self, + service: String, + #[zbus(header)] header: zbus::message::Header<'_>, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) { + let sender_name = header.sender().map(|s| s.to_string()).unwrap_or_default(); + let (bus, path) = parse_service(&service, &sender_name); + let full = format!("{}{}", bus, path); + { + let mut state = self.state.lock().unwrap(); + if !state.items.contains(&full) { + state.items.push(full.clone()); + } + } + let _ = Self::status_notifier_item_registered(&ctx, &full).await; + let _ = self.tx.send((bus, path)); + } + + async fn register_status_notifier_host( + &self, + _service: String, + #[zbus(signal_emitter)] ctx: SignalEmitter<'_>, + ) { + let _ = Self::status_notifier_host_registered(&ctx).await; + } + + #[zbus(property)] + fn registered_status_notifier_items(&self) -> Vec { + self.state.lock().unwrap().items.clone() + } + + #[zbus(property)] + fn is_status_notifier_host_registered(&self) -> bool { + true + } + + #[zbus(property)] + fn protocol_version(&self) -> i32 { + 0 + } + + #[zbus(signal)] + async fn status_notifier_item_registered( + ctx: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_item_unregistered( + ctx: &SignalEmitter<'_>, + service: &str, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn status_notifier_host_registered(ctx: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +fn parse_service(service: &str, sender: &str) -> (String, String) { + if service.starts_with('/') { + return (sender.to_string(), service.to_string()); + } + match service.find('/') { + Some(slash) => (service[..slash].to_string(), service[slash..].to_string()), + None => (service.to_string(), "/StatusNotifierItem".to_string()), + } +} + +async fn read_item( + conn: &zbus::Connection, + bus: &str, + path: &str, +) -> (Option, String) { + let Ok(proxy) = zbus::Proxy::new(conn, bus, path, "org.kde.StatusNotifierItem").await else { + return (None, String::new()); + }; + let icon = read_icon(&proxy).await; + let title = proxy.get_property::("Title").await.unwrap_or_default(); + (icon, title) +} + +async fn read_icon(proxy: &zbus::Proxy<'_>) -> Option { + let pixmaps: Vec<(i32, i32, Vec)> = + proxy.get_property("IconPixmap").await.unwrap_or_default(); + + if !pixmaps.is_empty() { + return pixmaps + .into_iter() + .filter(|(w, h, _)| *w > 0 && *h > 0) + .min_by_key(|(w, h, _)| (w.max(h) - 22).abs()) + .map(|(width, height, data)| TrayIconData::Pixels { width, height, data }); + } + + let name: String = proxy.get_property("IconName").await.ok()?; + if name.is_empty() { + return None; + } + Some(TrayIconData::Name(name)) +} + +/// Call `Activate(0, 0)` on the SNI item identified by `id` (`{bus}{path}`). +pub fn spawn_activate(id: String) { + relm4::spawn(async move { + let (bus, path) = match id.find('/') { + Some(slash) => (id[..slash].to_string(), id[slash..].to_string()), + None => (id, "/StatusNotifierItem".to_string()), + }; + let Ok(conn) = zbus::Connection::session().await else { return }; + let Ok(proxy) = zbus::Proxy::new(&conn, bus.as_str(), path.as_str(), "org.kde.StatusNotifierItem").await else { return }; + let _ = proxy.call_method("Activate", &(0i32, 0i32)).await; + }); +} + +pub fn spawn_watcher(sender: ComponentSender) { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>(); + // Maps bus name → item ids, shared between registration and cleanup tasks. + let bus_map: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let bus_map_cleanup = bus_map.clone(); + let sender_cleanup = sender.clone(); + + // Registration task — owns the watcher service and processes new items. + relm4::spawn(async move { + let watcher = Watcher { + state: Arc::new(Mutex::new(WatcherState { items: Vec::new() })), + tx, + }; + // Builder steps fail only on invalid static strings — safe to unwrap. + let conn = zbus::connection::Builder::session() + .unwrap() + .name("org.kde.StatusNotifierWatcher") + .unwrap() + .serve_at("/StatusNotifierWatcher", watcher) + .unwrap() + .build() + .await + .expect("failed to register org.kde.StatusNotifierWatcher"); + + while let Some((bus, path)) = rx.recv().await { + let (icon, title) = read_item(&conn, &bus, &path).await; + let id = format!("{}{}", bus, path); + bus_map.lock().unwrap().entry(bus).or_default().push(id.clone()); + sender.input(AppInput::TrayUpdate(TrayUpdate::Add { id, icon, title })); + } + }); + + // Cleanup task — watches NameOwnerChanged and removes items when their owner exits. + relm4::spawn(async move { + let Ok(conn) = zbus::Connection::session().await else { return }; + let Ok(proxy) = zbus::fdo::DBusProxy::new(&conn).await else { return }; + let Ok(mut stream) = proxy.receive_name_owner_changed().await else { return }; + while let Some(signal) = stream.next().await { + let Ok(args) = signal.args() else { continue }; + if args.new_owner().is_none() { + let gone = args.name().to_string(); + if let Some(ids) = bus_map_cleanup.lock().unwrap().remove(&gone) { + for id in ids { + sender_cleanup.input(AppInput::TrayUpdate(TrayUpdate::Remove { id })); + } + } + } + } + }); +} + +/// Convert SNI ARGB pixel data (network byte order) to a GTK4 `Image`. +/// Falls back to an icon-name lookup or a placeholder on failure. +pub fn make_tray_image(icon: Option<&TrayIconData>) -> gtk4::Image { + let img = match icon { + Some(TrayIconData::Pixels { width, height, data }) => pixels_to_image(*width, *height, data), + Some(TrayIconData::Name(name)) => { + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(16); + Some(img) + } + None => None, + }; + img.unwrap_or_else(|| { + let img = gtk4::Image::from_icon_name("image-missing"); + img.set_pixel_size(16); + img + }) +} + +fn pixels_to_image(width: i32, height: i32, data: &[u8]) -> Option { + if data.len() != (width * height * 4) as usize { + return None; + } + // SNI delivers ARGB big-endian: bytes are [A, R, G, B] per pixel. + // GTK4 R8g8b8a8 expects [R, G, B, A] per pixel. + let mut rgba = Vec::with_capacity(data.len()); + for chunk in data.chunks_exact(4) { + rgba.push(chunk[1]); + rgba.push(chunk[2]); + rgba.push(chunk[3]); + rgba.push(chunk[0]); + } + let bytes = gtk4::glib::Bytes::from_owned(rgba); + let tex = gtk4::gdk::MemoryTexture::new( + width, + height, + gtk4::gdk::MemoryFormat::R8g8b8a8, + &bytes, + (width * 4) as usize, + ); + let tex: gtk4::gdk::Texture = tex.upcast(); + let img = gtk4::Image::from_paintable(Some(&tex)); + img.set_pixel_size(16); + Some(img) +} diff --git a/src/main.rs b/src/main.rs index 10cca56..f1d77b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ pub struct App { wifi_img: gtk4::Image, // Pre-loaded textures indexed by constant pointer values. wifi_textures: std::collections::HashMap, + tray_box: gtk4::Box, + tray_items: std::collections::HashMap, } #[derive(Debug)] @@ -42,6 +44,7 @@ pub enum AppInput { ActiveWorkspace(WorkspaceId), ClockTick, StatsUpdate(bar::stats::Stats), + TrayUpdate(bar::tray::TrayUpdate), } #[relm4::component(pub)] @@ -153,6 +156,8 @@ impl SimpleComponent for App { 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(); @@ -179,12 +184,15 @@ impl SimpleComponent for App { 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); widgets.center_box.set_end_widget(Some(&stats_box)); theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); - bar::stats::spawn_poller(sender); + bar::stats::spawn_poller(sender.clone()); + bar::tray::spawn_watcher(sender.clone()); notifications::spawn(); osd::spawn(); @@ -228,6 +236,26 @@ impl SimpleComponent for App { self.wifi_img.set_paintable(Some(tex)); } } + AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { + if self.tray_items.contains_key(&id) { + return; + } + let btn = gtk4::Button::new(); + btn.add_css_class("tray-btn"); + btn.set_child(Some(&bar::tray::make_tray_image(icon.as_ref()))); + if !title.is_empty() { + btn.set_tooltip_text(Some(&title)); + } + let id_click = id.clone(); + btn.connect_clicked(move |_| bar::tray::spawn_activate(id_click.clone())); + self.tray_box.append(&btn); + self.tray_items.insert(id, btn); + } + AppInput::TrayUpdate(bar::tray::TrayUpdate::Remove { id }) => { + if let Some(btn) = self.tray_items.remove(&id) { + self.tray_box.remove(&btn); + } + } } } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b197038..b6c60b2 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -70,7 +70,7 @@ impl NotifServer { ( "breadbar".into(), "breadway".into(), - "0.1.0".into(), + env!("CARGO_PKG_VERSION").into(), "1.2".into(), ) } diff --git a/src/osd.rs b/src/osd.rs index 61f7ea4..74f0ec7 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -35,7 +35,7 @@ fn volume_watcher(tx: mpsc::Sender) { let stdout = child.stdout.take().unwrap(); let reader = BufReader::new(stdout); - for line in reader.lines().flatten() { + for line in reader.lines().map_while(Result::ok) { if line.contains("'change' on sink") { if let Some(evt) = query_volume() { let _ = tx.blocking_send(evt); From 50bb249b3a7e4046d245a40810bd4b01cc7be74e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 12:12:41 +0800 Subject: [PATCH 14/26] Add packaging/arch PKGBUILD and Forgejo Actions workflows - packaging/arch/PKGBUILD: builds and publishes breadbar to [breadway] repo - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo. --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 32 +++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..1ff2822 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..43b2d97 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=breadbar-${VERSION}/ \ + HEAD > packaging/arch/breadbar-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/breadbar-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..32d8c5a --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,32 @@ +# Maintainer: Breadway + +pkgname=breadbar +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Minimal status bar and notification daemon for Hyprland" +arch=('x86_64') +url="https://github.com/Breadway/breadbar" +license=('MIT') +depends=('gtk4' 'gtk4-layer-shell' 'libpulse' 'iw') +optdepends=( + 'hyprland: workspace and window data integration' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadbar "${pkgdir}/usr/bin/breadbar" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} From 00dbb1df5f6075b775b662bb88fabcf45c5474d3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:02:07 +0800 Subject: [PATCH 15/26] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 1ff2822..2d9ec37 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 43b2d97..e18c4bb 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=breadbar-${VERSION}/ \ - HEAD > packaging/arch/breadbar-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="breadbar-${VERSION}/" HEAD \ + > packaging/arch/breadbar-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/breadbar-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From 7c23265eab873d6e3723fc63aa75eb7ccb75ca9f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:49 +0800 Subject: [PATCH 16/26] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 2d9ec37..bc3249a 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 4b4d222784ea5b41545539b61ba0c1df5ae38a78 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:13 +0800 Subject: [PATCH 17/26] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index bc3249a..ac655db 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index e18c4bb..89cb69f 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="breadbar-${VERSION}/" HEAD \ > packaging/arch/breadbar-${VERSION}.tar.gz From 289fc1c827f97b572f5ad5fc670e0ac905b992a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:54 +0800 Subject: [PATCH 18/26] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 32d8c5a..d2eb2dd 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="Minimal status bar and notification daemon for Hyprland" arch=('x86_64') url="https://github.com/Breadway/breadbar" license=('MIT') +# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's +# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, +# causing undefined-symbol errors. Disable LTO. +options=(!lto) depends=('gtk4' 'gtk4-layer-shell' 'libpulse' 'iw') optdepends=( 'hyprland: workspace and window data integration' From ea441a2de38767f7148edd87523484836c6dad94 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:40 +0800 Subject: [PATCH 19/26] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 89cb69f..2895a76 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From ec24ed63714dede4eb3595e3678aac4d8f50caaf Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:49 +0800 Subject: [PATCH 20/26] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index d2eb2dd..c021698 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. -options=(!lto) +options=(!lto !debug) depends=('gtk4' 'gtk4-layer-shell' 'libpulse' 'iw') optdepends=( 'hyprland: workspace and window data integration' From b09817805874522ece1996685b3e00a580260d39 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 14 Jun 2026 19:36:38 +0800 Subject: [PATCH 21/26] Embed SVG assets and rasterise with resvg The packaged binary panicked on startup ("svg load: Unrecognized image file format"): asset SVGs were referenced by their build-time CARGO_MANIFEST_DIR path (absent on an installed system, so read_to_string returned empty bytes), and gdk::Texture::from_bytes can no longer decode SVG since librsvg dropped its gdk-pixbuf loader. - include_str! the SVGs into the binary (no runtime asset files) - rasterise via resvg/tiny-skia into a gdk::MemoryTexture (no system loader) --- Cargo.lock | 278 +++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 5 +- src/bar/stats.rs | 27 +++-- src/main.rs | 35 ++++-- 4 files changed, 317 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd6d212..3ba78d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -70,6 +88,18 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -89,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.2" +version = "0.1.5" dependencies = [ "bread-theme", "futures-lite", @@ -97,6 +127,7 @@ dependencies = [ "gtk4-layer-shell", "hyprland", "relm4", + "resvg", "serde", "serde_json", "tokio", @@ -109,6 +140,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" @@ -121,7 +158,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -172,12 +209,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "derive_more" version = "2.1.1" @@ -271,6 +323,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -301,6 +362,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -311,6 +381,22 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "flume" version = "0.12.0" @@ -591,7 +677,7 @@ version = "0.22.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -720,7 +806,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "gdk4", "glib", "glib-sys", @@ -835,6 +921,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.14.0" @@ -871,6 +963,17 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -928,6 +1031,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -939,6 +1052,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -997,6 +1119,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1009,6 +1137,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1103,6 +1244,35 @@ dependencies = [ "syn", ] +[[package]] +name = "resvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1118,7 +1288,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -1216,6 +1386,27 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1247,6 +1438,25 @@ dependencies = [ "lock_api", ] +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.117" @@ -1310,6 +1520,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1449,6 +1685,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "usvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +dependencies = [ + "base64", + "data-url", + "flate2", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + [[package]] name = "uuid" version = "1.23.2" @@ -1563,7 +1821,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1723,7 +1981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -1759,6 +2017,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "zbus" version = "5.16.0" diff --git a/Cargo.toml b/Cargo.toml index 4594c93..ddcf280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.2" +version = "0.1.5" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" @@ -20,6 +20,9 @@ zbus = { version = "5", default-features = false, features = ["tokio"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons +# are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. +resvg = { version = "0.44", default-features = false } [profile.release] lto = "thin" diff --git a/src/bar/stats.rs b/src/bar/stats.rs index 8a6b5d1..2d4908b 100644 --- a/src/bar/stats.rs +++ b/src/bar/stats.rs @@ -15,22 +15,25 @@ 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 Disconnect.svg"); +// Embedded SVG contents (not paths). These &str constants double as stable +// HashMap keys via their .as_ptr(); include_str! keeps each one a single +// 'static literal, so pointer identity still holds. +pub const WIFI_STRONG: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Strong.svg")); +pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Medium.svg")); +pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg")); +pub const WIFI_OFF: &str = include_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 BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg")); +pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg")); +pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg")); +pub const AC_POWER: &str = include_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!( +pub const BT_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Off.svg")); +pub const BT_ON: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth.svg")); +pub const BT_CONNECTED: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/Bluetooth Connected.svg" -); +)); #[derive(Debug)] pub struct Stats { diff --git a/src/main.rs b/src/main.rs index f1d77b3..877490c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,10 @@ +// 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) => { - concat!(env!("CARGO_MANIFEST_DIR"), "/assets/", $n) + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/", $n)) }; } @@ -274,24 +278,39 @@ impl App { } } -fn stat_pair(icon_path: &str, label: >k4::Label) -> gtk4::Box { +fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); - let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_path))); + let img = gtk4::Image::from_paintable(Some(&svg_texture(icon_svg))); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } -fn svg_texture(path: &str) -> gtk4::gdk::Texture { +// 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(); - let svg = std::fs::read_to_string(path) - .unwrap_or_default() + let svg = svg_src .replace("currentColor", &fg) .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") + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); + let size = tree.size().to_int_size(); + let (w, h) = (size.width(), size.height()); + let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); + resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut()); + let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); + gtk4::gdk::MemoryTexture::new( + w as i32, + h as i32, + gtk4::gdk::MemoryFormat::R8g8b8a8Premultiplied, + &bytes, + (w * 4) as usize, + ) + .upcast() } fn stat_label() -> gtk4::Label { From 1959a86157594cc719f5d8f646131c85fc51f7df Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 15 Jun 2026 18:51:42 +0800 Subject: [PATCH 22/26] Default workspace-button rounding to 0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/theme.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ba78d5..1970c7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.5" +version = "0.1.6" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index ddcf280..755020b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.5" +version = "0.1.6" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" diff --git a/src/theme.rs b/src/theme.rs index 62ce751..747ca1d 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -14,7 +14,7 @@ fn load_css() -> String { 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;\ + border-radius: 0; border: none; outline: none; box-shadow: none;\ min-width: 24px; padding: 4px 8px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ .workspace-btn.active {{ background: {accent}; opacity: 1; }}\ From 0cb27ec1c47b647e42bc1e5830b68be63bacf59c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:56:52 +0800 Subject: [PATCH 23/26] theme: load the shared bread-theme stylesheet Call bread_theme::gtk::apply_shared() before breadbar's own rules so fonts, palette, and generic widgets come from the one ecosystem stylesheet (and recolour live). Keep only breadbar-specific CSS (bar window, workspace buttons, stats, notifications, OSD). Bump bread-theme dep to v0.2.6. --- Cargo.toml | 2 +- src/theme.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 755020b..ec2f241 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/src/theme.rs b/src/theme.rs index 747ca1d..820b69a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -9,9 +9,10 @@ thread_local! { fn load_css() -> String { let p = load_palette(); + // breadbar-specific rules only — fonts, base colours, and generic widgets + // come from the shared ecosystem stylesheet (applied first in `apply()`). format!( - "* {{ font-family: 'Varela Round', sans-serif; font-size: 14px; }}\ - window.breadbar {{ background-color: {bg_rgba}; border-radius: 0; }}\ + "window.breadbar {{ background-color: {bg_rgba}; border-radius: 0; }}\ label {{ color: {fg}; }}\ .workspace-btn {{ background: transparent; color: {fg}; opacity: 0.45;\ border-radius: 0; border: none; outline: none; box-shadow: none;\ @@ -50,6 +51,10 @@ pub fn fg_color() -> String { /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { + // Shared ecosystem base (fonts, palette, generic widgets) — applied first + // so breadbar's own rules below layer on top. + bgtk::apply_shared(); + let css = load_css(); PROVIDER.with(|cell| bgtk::apply_css(&css, cell)); From 37b9a342e169dac97a68e139605c695f2279ccfe Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:31:18 +0800 Subject: [PATCH 24/26] Release 0.1.7: shared bread-theme stylesheet Pin bread-theme v0.2.6 and load the shared ecosystem stylesheet. --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1970c7a..cb922a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,8 +108,8 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" -version = "0.1.0" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" dependencies = [ "dirs", "gtk4", @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "breadbar" -version = "0.1.6" +version = "0.1.7" dependencies = [ "bread-theme", "futures-lite", diff --git a/Cargo.toml b/Cargo.toml index ec2f241..f647dba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadbar" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Minimal status bar and notification daemon for Hyprland on Wayland" license = "MIT" From 0893724e5fd62481dde735aeb12cced30f6d12ca Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:40:52 +0800 Subject: [PATCH 25/26] Fix illegible text on light pywal palettes + hot-reload Use bread-theme 0.2.7's luminance-picked ink (@on-*) for text on coloured backgrounds: the active workspace pill and notification cards previously kept the pywal foreground, which vanished when those slots came out light. Drop the blanket label colour rule (it overrode the per-surface ink on child labels). Switch to bread_theme::gtk::apply_app_css so the bar recolours live on `bread-theme reload` instead of only at startup. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/theme.rs | 56 +++++++++++++++++++++++++++++----------------------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb922a6..4f918a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" dependencies = [ "dirs", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index f647dba..a05db04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/src/theme.rs b/src/theme.rs index 820b69a..e02ed01 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,9 +1,8 @@ -use bread_theme::{gtk as bgtk, hex_to_rgba, load_palette}; +use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; use gtk4::CssProvider; use std::cell::RefCell; thread_local! { - static PROVIDER: RefCell> = const { RefCell::new(None) }; static USER_PROVIDER: RefCell> = const { RefCell::new(None) }; } @@ -11,52 +10,59 @@ fn load_css() -> String { let p = load_palette(); // breadbar-specific rules only — fonts, base colours, and generic widgets // come from the shared ecosystem stylesheet (applied first in `apply()`). + // Colour is set on each surface (bar, active workspace pill, notification + // card) and child labels inherit it, so text stays legible whatever lightness + // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for + // that background — the pywal hues themselves are untouched. format!( - "window.breadbar {{ background-color: {bg_rgba}; border-radius: 0; }}\ - label {{ color: {fg}; }}\ - .workspace-btn {{ background: transparent; color: {fg}; opacity: 0.45;\ + "window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ + .workspace-btn {{ background: transparent; opacity: 0.45;\ border-radius: 0; border: none; outline: none; box-shadow: none;\ min-width: 24px; padding: 4px 8px; }}\ .workspace-btn:hover {{ opacity: 0.8; }}\ - .workspace-btn.active {{ background: {accent}; opacity: 1; }}\ + .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; }}\ - window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); }}\ - .notification-card {{ background: {surface}; border-radius: 8px;\ + 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; }}\ - .notification-summary {{ font-weight: bold; color: {fg}; }}\ - .notification-app {{ color: {fg}; opacity: 0.6; }}\ - .notification-body {{ color: {fg}; }}\ - window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); border-radius: 8px; }}\ - .osd-kind {{ color: {fg}; opacity: 0.75; font-size: 12px; }}\ - .osd-pct {{ color: {fg}; font-weight: bold; font-size: 12px; }}\ + .notification-summary {{ font-weight: bold; }}\ + .notification-app {{ opacity: 0.6; }}\ + window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: 8px; }}\ + .osd-kind {{ opacity: 0.75; font-size: 12px; }}\ + .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; }}", - bg_plain = p.background, - bg_rgba = hex_to_rgba(&p.background, 0.92), - surface = p.color0, - fg = p.foreground, - accent = p.color4, - trough = hex_to_rgba(&p.color4, 0.25), + bg_plain = p.background, + bg_rgba = hex_to_rgba(&p.background, 0.92), + surface = p.color0, + accent = p.color4, + on_bg = ink_on(&p.background), + on_surface = ink_on(&p.color0), + on_accent = ink_on(&p.color4), + trough = hex_to_rgba(&p.color4, 0.25), ) } -/// Returns the current foreground colour (used for icon tinting in the stats bar). +/// Returns the ink colour for icon tinting in the stats bar — the same +/// luminance-picked colour the bar's text uses, so icons stay legible on the bar +/// whatever lightness pywal gives the background. pub fn fg_color() -> String { - load_palette().foreground + ink_on(&load_palette().background).to_string() } /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. pub fn apply() { // Shared ecosystem base (fonts, palette, generic widgets) — applied first - // so breadbar's own rules below layer on top. + // (and self-reloading) so breadbar's own rules below layer on top. bgtk::apply_shared(); - let css = load_css(); - PROVIDER.with(|cell| bgtk::apply_css(&css, cell)); + // breadbar's own rules, hot-reloaded on `bread-theme reload`: the closure + // re-reads the pywal palette each time so the bar recolours without restart. + bgtk::apply_app_css(load_css); let home = std::env::var("HOME").unwrap_or_default(); let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); From aa34aa13198fc1099c61648a71b97e7bf5cb462f Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:55:47 +0800 Subject: [PATCH 26/26] Bump bread-theme to v0.2.8 (live-reload fix) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f918a3..aa34dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" dependencies = [ "dirs", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index a05db04..0950f3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] }