Bind each bar to its output palette and finish the island chrome
Some checks failed
dev release / build (push) Failing after 3m37s

One layer-shell window per Hyprland output now loads that output's
bread-theme palette. Notifications, history, and OSD follow the
monitor they appear on. Pin bread-theme to v0.7.4.
This commit is contained in:
Breadway 2026-08-16 13:23:31 +08:00
parent ae1fee3591
commit 1806c6f912
14 changed files with 1652 additions and 754 deletions

View file

@ -1,11 +1,21 @@
use crate::{App, AppInput};
use relm4::ComponentSender;
pub fn now() -> gtk4::glib::DateTime {
gtk4::glib::DateTime::now_local().expect("local time")
}
pub fn time() -> String {
let dt = now();
format!("{:02}:{:02}", dt.hour(), dt.minute())
}
pub fn date() -> String {
now().format("%a %d/%m").expect("date format").to_string()
}
pub fn current() -> String {
let dt = gtk4::glib::DateTime::now_local().expect("local time");
let date = dt.format("%a %d/%m").expect("date format");
let time = format!("{:02}:{:02}", dt.hour(), dt.minute());
format!("{} {}", date, time)
format!("{} {}", date(), time())
}
pub fn spawn_ticker(sender: ComponentSender<App>) {

View file

@ -121,6 +121,7 @@ pub fn spawn_set_brightness(v: f64) {
});
}
#[allow(dead_code)]
pub fn spawn_set_sink(name: String) {
relm4::spawn(async move {
let _ = tokio::process::Command::new("pactl")

View file

@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "
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"));
/// Adwaita symbolic names — these are drawn for 16px status bars, not our
/// hand-cropped Lucide arcs.
pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic";
pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic";
pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic";
pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic";
pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic";
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"));
@ -65,6 +73,7 @@ pub struct Stats {
pub gpu_temp: Option<f32>,
pub net_rx_kbs: f32,
pub net_tx_kbs: f32,
pub volume_pct: u8,
}
struct CpuSnapshot {
@ -76,7 +85,7 @@ static PREV_CPU: OnceLock<Mutex<CpuSnapshot>> = OnceLock::new();
static BAT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
static AC_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
static WIFI_CACHE: LazyLock<Mutex<(String, &'static str)>> =
LazyLock::new(|| Mutex::new(("".to_string(), WIFI_OFF)));
LazyLock::new(|| Mutex::new(("".to_string(), WIFI_ICON_OFF)));
static WIFI_TICK: AtomicU8 = AtomicU8::new(0);
fn read_cpu() -> f32 {
@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> {
async fn read_wifi() -> (String, &'static str) {
let Some(iface) = wifi_iface() else {
return ("".into(), WIFI_OFF);
return ("".into(), WIFI_ICON_OFF);
};
let link_out = tokio::process::Command::new("iw")
@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) {
.ok();
let link_stdout = match link_out {
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
_ => return ("".into(), WIFI_OFF),
_ => return ("".into(), WIFI_ICON_OFF),
};
let mut ssid = None;
@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) {
}
let Some(ssid) = ssid else {
return ("".into(), WIFI_OFF);
return ("".into(), WIFI_ICON_OFF);
};
let icon = match rssi {
Some(r) if r >= -55 => WIFI_STRONG,
Some(r) if r >= -70 => WIFI_MEDIUM,
_ => WIFI_WEAK,
Some(r) if r >= -55 => WIFI_ICON_EXCELLENT,
Some(r) if r >= -70 => WIFI_ICON_GOOD,
Some(r) if r >= -80 => WIFI_ICON_OK,
_ => WIFI_ICON_WEAK,
};
(ssid, icon)
@ -413,7 +423,8 @@ pub async fn poll() -> Stats {
let power_watts = read_power();
let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W"));
let pct = read_battery();
let bat = pct.map_or_else(|| "".into(), |p| format!("{p}%"));
// Demo bar prints the bare number ("83"), not "83%".
let bat = pct.map_or_else(|| "".into(), |p| format!("{p}"));
let bat_icon = pct.map_or(BAT_MID, bat_level_icon);
let ac_connected = read_ac();
// BT and WiFi both refresh every 8 cycles (~16 s); cache in between.
@ -442,6 +453,7 @@ pub async fn poll() -> Stats {
let gpu_usage = read_gpu_usage();
let gpu_temp = read_gpu_temp();
let (net_rx_kbs, net_tx_kbs) = read_net_throughput();
let volume_pct = read_volume_pct();
Stats {
cpu: format!("{cpu:.0}%"),
cpu_pct: cpu,
@ -465,9 +477,30 @@ pub async fn poll() -> Stats {
gpu_temp,
net_rx_kbs,
net_tx_kbs,
volume_pct,
}
}
/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0150 percent
/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than
/// blocking the rest of the poll.
fn read_volume_pct() -> u8 {
let out = std::process::Command::new("wpctl")
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
.output()
.ok();
let Some(o) = out.filter(|o| o.status.success()) else {
return 0;
};
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix("Volume:")
.and_then(|s| s.split_whitespace().next())
.and_then(|s| s.parse::<f64>().ok())
.map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8)
.unwrap_or(0)
}
pub fn spawn_poller(sender: ComponentSender<App>) {
relm4::spawn(async move {
loop {

View file

@ -24,6 +24,8 @@ pub struct ScanEntry {
pub struct WifiPopoverData {
pub profiles: Vec<(String, bool)>, // (name, is_active)
pub scan: Vec<ScanEntry>,
/// False while nmcli is still listing APs — profiles must still be usable.
pub scan_ready: bool,
}
async fn fetch_status() -> Option<CrumbsStatus> {
@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> {
.collect()
}
async fn saved_ssids() -> std::collections::HashSet<String> {
let out = tokio::process::Command::new("nmcli")
.args(["-t", "-f", "NAME,TYPE", "connection", "show"])
.output()
.await;
let Ok(o) = out else {
return std::collections::HashSet::new();
};
String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|line| {
let (name, ty) = line.rsplit_once(':')?;
if ty == "802-11-wireless" || ty == "wifi" {
Some(name.to_string())
} else {
None
}
})
.collect()
}
/// Cached AP list (no rescan). Fast enough to paint next to profiles.
async fn fetch_scan() -> Vec<ScanEntry> {
let Ok(Ok(out)) = tokio::time::timeout(
Duration::from_secs(10),
tokio::process::Command::new("breadcrumbs")
.args(["scan-list", "--json"])
let out = tokio::time::timeout(
Duration::from_secs(4),
tokio::process::Command::new("nmcli")
.args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"])
.output(),
)
.await
else {
.await;
let Ok(Ok(o)) = out else {
return vec![];
};
let arr: Vec<serde_json::Value> =
serde_json::from_slice(&out.stdout).unwrap_or_default();
arr.into_iter()
.filter_map(|v| {
let ssid = v["ssid"].as_str()?.to_string();
if ssid.is_empty() {
let saved = saved_ssids().await;
let mut seen = std::collections::HashSet::new();
String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.rsplitn(3, ':');
let _in_use = parts.next()?;
let signal = parts.next()?.parse::<u8>().ok().unwrap_or(0);
let ssid = parts.next()?.replace("\\:", ":");
if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) {
return None;
}
let signal = v["signal"]
.as_str()
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
let saved = v["saved"].as_bool().unwrap_or(false);
Some(ScanEntry { ssid, signal, saved })
let saved = saved.contains(&ssid);
Some(ScanEntry {
ssid,
signal,
saved,
})
})
.collect()
}
@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender<App>) {
});
}
/// Called when the popover opens — loads profiles + scan in parallel.
/// Profiles first (so you can switch Home/Away immediately), then the
/// cached AP list. A background rescan refreshes the list if it finds more.
pub fn spawn_popover_load(sender: ComponentSender<App>) {
relm4::spawn(async move {
let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan());
sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan }));
let profiles = fetch_profile_list().await;
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
profiles: profiles.clone(),
scan: vec![],
scan_ready: false,
}));
let scan = fetch_scan().await;
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
profiles: profiles.clone(),
scan: scan.clone(),
scan_ready: true,
}));
let _ = tokio::process::Command::new("nmcli")
.args(["device", "wifi", "rescan"])
.output()
.await;
let scan = fetch_scan().await;
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
profiles,
scan,
scan_ready: true,
}));
});
}
@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) {
});
}
/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`.
/// Fire-and-forget: connect to a known SSID via NetworkManager.
pub fn spawn_join(ssid: String) {
relm4::spawn(async move {
let _ = tokio::process::Command::new("breadcrumbs")
.args(["join", &ssid])
let _ = tokio::process::Command::new("nmcli")
.args(["device", "wifi", "connect", &ssid])
.output()
.await;
});
}
/// Fire-and-forget: save a new network with its password, then join it.
/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli.
pub fn spawn_add_and_join(ssid: String, password: String) {
relm4::spawn(async move {
let added = tokio::process::Command::new("breadcrumbs")
let _ = tokio::process::Command::new("breadcrumbs")
.args(["add", &ssid, &password])
.output()
.await;
if matches!(added, Ok(o) if o.status.success()) {
let _ = tokio::process::Command::new("breadcrumbs")
.args(["join", &ssid])
.output()
.await;
}
let _ = tokio::process::Command::new("nmcli")
.args(["device", "wifi", "connect", &ssid, "password", &password])
.output()
.await;
});
}

View file

@ -1,7 +1,12 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use futures_lite::StreamExt;
use gtk4::glib::ControlFlow;
use gtk4::prelude::*;
use hyprland::{
data::{Workspace, Workspaces},
data::{Monitors, Workspaces},
event_listener::{Event, EventStream},
prelude::*,
shared::WorkspaceId,
@ -10,16 +15,62 @@ use relm4::ComponentSender;
use crate::AppInput;
/// Fetches the current workspace list + active workspace and pushes both to
/// the app — used both for the initial state and to re-sync after the event
/// stream reconnects (state may have changed while we were disconnected).
/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config
/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is
/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`.
async fn switch_workspace(id: hyprland::shared::WorkspaceId) {
let arg = id.to_string();
let stock = tokio::process::Command::new("hyprctl")
.args(["dispatch", "workspace", &arg])
.output()
.await;
if let Ok(o) = &stock {
let err = String::from_utf8_lossy(&o.stderr);
let out = String::from_utf8_lossy(&o.stdout);
if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") {
return;
}
}
let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))");
let lua = tokio::process::Command::new("hyprctl")
.args(["eval", &expr])
.output()
.await;
match lua {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"breadbar: workspace {arg}: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => eprintln!("breadbar: workspace {arg}: {e}"),
}
}
/// Stretch to the old→new span, then snap onto the destination — CSS
/// transitions cannot widen a pill across two buttons, so the trail's
/// Fixed allocation is interpolated on the frame clock instead.
const STRETCH_MS: f64 = 220.0;
const SNAP_MS: f64 = 380.0;
/// Full workspace + per-monitor active snapshot. Each bar filters this to
/// its own output so a second display does not inherit the laptop's set.
async fn sync_state(sender: &ComponentSender<crate::App>) {
if let Ok(ws) = Workspaces::get_async().await {
sender.input(AppInput::WorkspaceList(ws.to_vec()));
}
if let Ok(active) = Workspace::get_active_async().await {
sender.input(AppInput::ActiveWorkspace(active.id));
let workspaces = Workspaces::get_async()
.await
.map(|w| w.to_vec())
.unwrap_or_default();
let mut actives = std::collections::HashMap::new();
if let Ok(mons) = Monitors::get_async().await {
for m in mons {
if !m.disabled {
actives.insert(m.name, m.active_workspace.id);
}
}
}
sender.input(AppInput::WorkspaceSync {
workspaces,
actives,
});
}
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
while let Some(Ok(event)) = stream.next().await {
backoff = std::time::Duration::from_millis(500);
match event {
Event::WorkspaceChanged(data) => {
sender.input(AppInput::ActiveWorkspace(data.id));
Event::WorkspaceChanged(_)
| Event::WorkspaceAdded(_)
| Event::WorkspaceDeleted(_) => {
sync_state(&sender).await;
}
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => {
if let Ok(ws) = Workspaces::get_async().await {
sender.input(AppInput::WorkspaceList(ws.to_vec()));
}
Event::MonitorAdded(data) => {
sender.input(AppInput::MonitorAdded(data.name));
sync_state(&sender).await;
}
Event::MonitorRemoved(name) => {
sender.input(AppInput::MonitorRemoved(name));
sync_state(&sender).await;
}
Event::ActiveWindowChanged(_) => {
sender.input(AppInput::DismissPanels);
}
_ => {}
}
@ -65,17 +124,233 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
});
}
pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button {
pub fn make_button(
id: WorkspaceId,
name: &str,
active: WorkspaceId,
occupied: bool,
) -> gtk4::Button {
let btn = gtk4::Button::with_label(name);
btn.add_css_class("workspace-btn");
if occupied {
btn.add_css_class("occupied");
}
if id == active {
btn.add_css_class("active");
}
btn.set_valign(gtk4::Align::Center);
btn.set_vexpand(false);
btn.set_size_request(-1, crate::CHIP_HEIGHT);
btn.connect_clicked(move |_| {
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id(
id,
)));
relm4::spawn(async move {
switch_workspace(id).await;
});
});
btn
}
#[derive(Clone, Copy)]
struct Geom {
x: f64,
y: f64,
w: f64,
h: f64,
}
struct TrailInner {
tick: Option<gtk4::TickCallbackId>,
geom: Geom,
}
/// Overlay + Fixed pill sitting *behind* the workspace buttons. The
/// Overlay's measured size comes from the button row; the pill is the
/// main child so it paints underneath and never steals clicks.
pub struct WorkspaceTrail {
pub overlay: gtk4::Overlay,
pub buttons: gtk4::Box,
host: gtk4::Fixed,
pill: gtk4::Box,
inner: Rc<RefCell<TrailInner>>,
}
impl WorkspaceTrail {
pub fn new() -> Self {
let overlay = gtk4::Overlay::new();
overlay.add_css_class("workspace-overlay");
overlay.set_valign(gtk4::Align::Center);
overlay.set_vexpand(false);
let host = gtk4::Fixed::new();
host.set_can_target(false);
let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
pill.add_css_class("workspace-trail");
pill.set_can_target(false);
pill.set_visible(false);
host.put(&pill, 0.0, 0.0);
let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
buttons.set_halign(gtk4::Align::Fill);
buttons.set_valign(gtk4::Align::Center);
buttons.set_vexpand(false);
overlay.set_child(Some(&host));
overlay.add_overlay(&buttons);
overlay.set_measure_overlay(&buttons, true);
let inner = Rc::new(RefCell::new(TrailInner {
tick: None,
geom: Geom {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
},
}));
Self {
overlay,
buttons,
host,
pill,
inner,
}
}
pub fn cancel(&self) {
if let Some(id) = self.inner.borrow_mut().tick.take() {
id.remove();
}
}
pub fn clear(&self) {
self.cancel();
self.pill.set_visible(false);
self.inner.borrow_mut().geom.w = 0.0;
}
pub fn place(&self, btn: &gtk4::Button) {
self.cancel();
if let Some(g) = button_geom(btn, &self.overlay) {
self.apply(&g);
return;
}
// First map: the button exists but has no allocation yet.
let pill = self.pill.clone();
let host = self.host.clone();
let btn = btn.clone();
let inner = self.inner.clone();
let id = self.overlay.add_tick_callback(move |ov, _| {
let Some(g) = button_geom(&btn, ov) else {
return ControlFlow::Continue;
};
apply_geom(&host, &pill, &inner, &g);
inner.borrow_mut().tick = None;
ControlFlow::Break
});
self.inner.borrow_mut().tick = Some(id);
}
pub fn stretch(&self, from: Option<&gtk4::Button>, to: &gtk4::Button) {
let from_g = self.from_geom(from);
if let Some(to_g) = button_geom(to, &self.overlay) {
self.stretch_geom(from_g, to_g);
return;
}
// Destination just appeared (empty workspace becoming active) and
// has no allocation yet. Wait one layout pass, then stretch from
// the last pill — don't snap via place().
self.cancel();
let overlay = self.overlay.clone();
let pill = self.pill.clone();
let host = self.host.clone();
let inner = self.inner.clone();
let btn = to.clone();
let id = self.overlay.add_tick_callback(move |ov, _| {
let Some(to_g) = button_geom(&btn, ov) else {
return ControlFlow::Continue;
};
inner.borrow_mut().tick = None;
stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g);
ControlFlow::Break
});
self.inner.borrow_mut().tick = Some(id);
}
fn from_geom(&self, from: Option<&gtk4::Button>) -> Option<Geom> {
let st = self.inner.borrow();
if self.pill.is_visible() && st.geom.w > 0.5 {
return Some(st.geom);
}
drop(st);
from.and_then(|b| button_geom(b, &self.overlay))
}
fn stretch_geom(&self, from_g: Option<Geom>, to_g: Geom) {
stretch_geom_on(
&self.overlay,
&self.host,
&self.pill,
&self.inner,
from_g,
to_g,
);
}
fn apply(&self, g: &Geom) {
apply_geom(&self.host, &self.pill, &self.inner, g);
}
}
fn button_geom(btn: &gtk4::Button, overlay: &gtk4::Overlay) -> Option<Geom> {
let r = btn.compute_bounds(overlay)?;
let w = f64::from(r.width());
let h = f64::from(r.height());
if w < 1.0 || h < 1.0 {
return None;
}
Some(Geom {
x: f64::from(r.x()),
y: f64::from(r.y()),
w,
h,
})
}
fn apply_geom(host: &gtk4::Fixed, pill: &gtk4::Box, inner: &Rc<RefCell<TrailInner>>, g: &Geom) {
inner.borrow_mut().geom = Geom {
x: g.x,
y: g.y,
w: g.w,
h: g.h,
};
pill.set_size_request(g.w.max(1.0).round() as i32, g.h.max(1.0).round() as i32);
host.move_(pill, g.x, g.y);
pill.set_visible(true);
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom {
Geom {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t),
}
}
fn ease(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap.
fn ease_overshoot(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let c = 1.4;
let t1 = t - 1.0;
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
}

File diff suppressed because it is too large Load diff

View file

@ -175,13 +175,15 @@ pub fn build_window(store: Store) -> Ui {
let window = gtk4::Window::new();
window.add_css_class("breadbar-history");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, 48);
window.set_margin(Edge::Right, 20);
window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
window.set_default_width(360);
window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);
let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
outer.set_margin_top(10);
@ -284,8 +286,8 @@ fn make_row(entry: &Entry) -> gtk4::Box {
}
let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let show_app = !entry.app_name.is_empty()
&& !entry.app_name.eq_ignore_ascii_case(&entry.summary);
let show_app =
!entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary);
if show_app {
let app = gtk4::Label::new(Some(&entry.app_name));
app.add_css_class("notification-app");

View file

@ -170,15 +170,17 @@ fn create_window() -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-notification");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, 20);
window.set_margin(Edge::Right, 20);
window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
window.set_default_width(320);
// OnDemand so an inline-reply GtkEntry can take keys without the popup
// stealing every keystroke the rest of the time.
window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);
window
}

View file

@ -163,9 +163,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
container.set_margin_end(14);
window.set_child(Some(&container));
let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture(
crate::bar::stats::ICON_VOLUME,
)));
let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME);
icon.add_css_class("osd-icon");
container.append(&icon);
@ -184,6 +182,7 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
};
icon.set_paintable(Some(&crate::svg_texture(icon_svg)));
crate::prepare_icon(&icon, crate::ICON_PX);
if muted {
icon.add_css_class("osd-icon-muted");
} else {
@ -209,9 +208,11 @@ fn create_window() -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-osd");
window.init_layer_shell();
window.set_namespace(Some("breadbar-osd"));
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Bottom, true);
window.set_margin(Edge::Bottom, 80);
window.set_default_width(180);
crate::theme::bind_auto(&window);
window
}

View file

@ -20,11 +20,9 @@ use gtk4::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s
/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels,
/// gated on popover visibility) or a similar live-data popover load
/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves
/// placeholder dashes/"Scanning…" instead of real content.
/// Settle time for views whose content depends on a live-data popover load
/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing
/// any sooner leaves placeholder dashes/"Scanning…" instead of real content.
const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// Delay between the bar's own `map` and calling `popover.popup()`. Calling
@ -107,11 +105,11 @@ impl Cli {
/// outlive `init()` (never stored on `App`), so they have to be cloned out
/// before dispatch time same as `control_popover` always was.
pub struct Handles {
pub control_popover: gtk4::Popover,
pub connectivity_popover: gtk4::Popover,
pub control_panel: gtk4::Window,
pub connectivity_panel: gtk4::Window,
pub wifi_tab_btn: gtk4::ToggleButton,
pub bt_tab_btn: gtk4::ToggleButton,
pub media_popover: gtk4::Popover,
pub media_panel: gtk4::Window,
pub media_widget: gtk4::Box,
pub media_track_lbl: gtk4::Label,
/// Already built and primed with sample content by `main.rs` (via
@ -122,10 +120,10 @@ pub struct Handles {
pub osd_window: Option<gtk4::Window>,
}
/// The bar's fixed height — matches `root.set_exclusive_zone(32)` /
/// `set_default_height: 32` in `main.rs`. Unlike the other views' full
/// canvas, this never varies with `--width`/`--height`.
const BAR_HEIGHT: i32 = 32;
/// Capture height for the `bar` view: layer-shell top margin + widget
/// height (the exclusive zone). Unlike the other views' full canvas,
/// this never varies with `--width`/`--height`.
const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP;
pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) {
let output = req.output;
@ -141,15 +139,15 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
});
}
"control-panel" => {
open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-wifi" => {
handles.wifi_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-bluetooth" => {
handles.bt_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"media-popover" => {
// Real media state only shows the widget/text when something's
@ -157,8 +155,9 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
// run has nothing playing, so fake enough of it directly on the
// widgets to get a representative capture.
handles.media_widget.set_visible(true);
handles.media_widget.add_css_class("playing");
handles.media_track_lbl.set_text("Sample Track — Sample Artist");
open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height);
open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height);
}
"notification" | "notification-critical" => {
let Some(window) = handles.notification_window else {
@ -196,27 +195,25 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
}
}
/// Shared shape for every popover view: force it open shortly after the bar
/// maps (autohide disabled — a programmatic `popup()` has no real input
/// event serial to grab the Wayland seat with), then capture the whole
/// canvas after `settle` once the popover itself maps.
fn open_popover_on_root_map(
/// Shared shape for panel views: present the standalone layer window after
/// the bar maps, then capture the canvas once the panel itself maps.
fn open_panel_on_root_map(
root: &gtk4::ApplicationWindow,
popover: gtk4::Popover,
panel: gtk4::Window,
settle: Duration,
output: PathBuf,
width: i32,
height: i32,
) {
let popover_to_open = popover.clone();
let panel_to_open = panel.clone();
root.connect_map(move |_| {
popover_to_open.set_autohide(false);
let popover_to_open = popover_to_open.clone();
let panel_to_open = panel_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
popover_to_open.popup();
panel_to_open.set_visible(true);
panel_to_open.present();
});
});
popover.connect_map(move |_| {
panel.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(settle, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));

View file

@ -1,4 +1,5 @@
use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette};
use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette};
use gtk4::prelude::IsA;
use gtk4::CssProvider;
use std::cell::RefCell;
@ -7,7 +8,6 @@ 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()`).
// Colour is set on each surface (bar, active workspace pill, notification
@ -15,37 +15,78 @@ fn load_css() -> String {
// pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for
// that background — the pywal hues themselves are untouched.
//
// Shared tokens: one radius and one padding rhythm reused across every
// popover/card/OSD surface so they read as one design system rather than
// four different ones. `radius_pill` is only for the tiny transient OSD.
let radius = "10px";
let radius_sm = "0px";
let radius_pill = "20px";
let pad = "10px";
// Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD.
// Hyprland `layerrule = blur, breadbar` frosts the translucent fills —
// the CSS just leaves alpha. Colours are bread-theme tokens so pywal
// accents (`@accent`) flow through on SIGHUP / `bread-theme reload`.
let radius = "12px";
let radius_bar = "16px";
let radius_sm = "9px";
let radius_pill = "999px";
let pad = "12px";
format!(
"window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\
.workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\
.workspace-btn:hover {{ opacity: 0.8; }}\
.workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\
.stats-box {{ margin-right: 8px; }}\
.stat-pair {{ margin-right: 14px; }}\
.stat-icon {{ margin-right: 2px; }}\
.bt-icon {{ margin-right: 14px; }}\
separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\
window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\
window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\
border-radius: {radius}; }}\
.notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\
"@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
@keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\
@keyframes media-eq {{ to {{ min-height: 14px; }} }}\
@keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\
@keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\
window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\
window.breadbar button {{ min-height: 0; min-width: 0; }}\
.workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\
background-color: @accent; border-radius: 999px; }}\
.workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\
border-radius: 999px; border: none; outline: none; box-shadow: none;\
min-width: 32px; min-height: 32px; margin: 0 2px; padding: 0 12px;\
font-size: 22px; font-weight: bold;\
transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\
.workspace-btn.occupied {{ opacity: 0.78; }}\
.workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: transparent; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
.clock-box {{ padding: 0 4px; }}\
.clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-height: 0; padding: 0; margin-top: 3px; }}\
.clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\
.clock-colon {{ min-width: 10px; opacity: 0.7; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\
.stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\
.stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.stats-box {{ margin-right: 0; }}\
.stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
opacity 0.18s ease; }}\
.stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\
.stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\
.stat-pair.icon-only {{ padding: 6px; border-radius: 999px; }}\
.stat-icon {{ margin-right: 6px; }}\
.stat-pair.icon-only .stat-icon {{ margin-right: 0; }}\
.bt-icon {{ margin-right: 8px; }}
separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\
background: alpha(@on-bg, 0.10); }}\
window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\
window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\
padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\
border-left: 3px solid transparent;\
animation: notif-in 0.45s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
.notification-card.urgency-critical {{ border-left-color: @red; }}\
.notification-card.urgency-normal {{ border-left-color: @accent; }}\
.notification-summary {{ font-weight: bold; }}\
.notification-app {{ opacity: 0.6; }}\
.notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\
.notification-actions {{ margin-top: 6px; }}\
.notification-action {{ padding: 2px 8px; font-size: 11px; }}\
.notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\
.notification-reply {{ margin-top: 6px; }}\
.notification-reply-entry {{ min-width: 0; }}\
.history-title {{ font-weight: bold; font-size: 13px; }}\
@ -54,59 +95,126 @@ fn load_css() -> String {
.history-time {{ opacity: 0.5; font-size: 11px; }}\
.history-body {{ opacity: 0.75; }}\
.history-card {{ margin-bottom: 6px; }}\
window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\
window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\
animation: osd-in 0.4s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
.osd-icon {{ opacity: 0.85; margin-right: 8px; }}\
.osd-icon-muted {{ opacity: 0.35; }}\
progressbar.osd-bar {{ min-height: 6px; }}\
progressbar.osd-bar trough {{ background-image: none; background-color: {trough}; border-radius: 3px; min-height: 6px; }}\
progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\
.wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\
.wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\
.wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\
.popover-tab-row {{ margin-bottom: {pad}; }}\
.popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\
outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\
font-weight: bold; opacity: 0.55; }}\
progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\
border-radius: 3px; min-height: 6px; }}\
progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\
border-radius: 3px; min-height: 6px; }}\
.wifi-pair {{ padding: 6px; }}\
window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\
window.breadbar-dismiss, .breadbar-dismiss-hit {{\
background-color: alpha(#000000, 0.02); }}\
.popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\
background-color: @accent;\
background-image: linear-gradient(90deg, @accent, @teal);\
animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\
window.wifi-popover button {{ min-height: 0; min-width: 0; }}\
.popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\
padding: 3px; margin-bottom: 10px; }}\
.popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\
outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\
font-size: 17px; font-weight: bold; opacity: 0.55;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
opacity 0.22s ease, color 0.22s ease; }}\
.popover-tab:hover {{ opacity: 0.8; }}\
.popover-tab:checked {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\
.wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\
.wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\
.wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\
.wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\
.popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\
.popover-tab label {{ padding: 0; margin: 0; }}\
.wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\
.wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\
.wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\
.wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\
letter-spacing: 0.12em; }}\
.wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\
border-radius: {radius_sm}; padding: 4px 6px; }}\
.wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\
.wifi-popover-row-active {{ color: {accent}; }}\
outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\
transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.wifi-popover-row label {{ font-size: 18px; }}\
.wifi-popover-row:hover {{ background: alpha(@on-bg, 0.08); }}\
.wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\
.wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\
.row-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\
.stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\
.stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\
.stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\
.stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\
.stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\
.wifi-popover-row-unsaved {{ opacity: 0.4; }}\
.wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\
window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px;\
border-radius: {radius}; }}\
window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\
switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\
switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\
border: none; outline: none; box-shadow: none; background-image: none;\
border-radius: 99px; }}\
switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\
transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
switch.bt-switch:checked {{ background-color: @accent; }}\
switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\
border-radius: 99px; border: none; outline: none; box-shadow: none;\
background-image: none; background-color: @on-bg; }}\
window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\
border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
window.wifi-add-dialog headerbar {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-top-left-radius: {radius}; border-top-right-radius: {radius};\
border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\
border-bottom: 1px solid alpha(@on-bg, 0.10); box-shadow: none; }}\
.confirm-button {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
.media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\
.media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\
.media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\
.media-track-lbl {{ font-size: 12px; }}\
.media-controls {{ padding: 2px; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; }}\
.control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\
.media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\
.media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.media-eq {{ min-height: 14px; margin-right: 4px; }}\
.media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\
border-radius: 2px; }}\
.media-widget.playing .media-eq-bar {{\
animation: media-eq 0.85s ease-in-out infinite alternate; }}\
.media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\
.media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\
.media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\
.media-track-lbl {{ font-size: 17px; }}\
.media-controls {{ padding: 4px; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\
transition: background-color 0.18s ease; }}\
.media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\
.control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\
opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\
background: transparent; border: none; outline: none; box-shadow: none;\
transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
opacity 0.18s ease; }}\
.control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\
.control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\
.control-panel {{ }}\
.control-panel-inner {{ min-width: 220px; padding: {pad}; }}\
.control-panel-row {{ margin: 4px 0; }}\
.control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\
.control-panel-slider {{ margin: 0; }}\
.control-panel-stats {{ margin: {pad} 0; }}\
.control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\
.control-panel-section {{ margin: {pad} 0; }}\
.control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\
letter-spacing: 0.08em; margin-bottom: 4px; }}\
.control-panel-sink-dropdown {{ }}\
.power-row {{ margin-top: 2px; }}\
.power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\
separator {{ margin: 4px 0; }}\
.control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\
opacity: 0.45; margin-bottom: 8px; }}\
.control-panel-row {{ margin: 8px 0; }}\
.control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\
.control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\
scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\
background-image: none; background-color: alpha(@on-bg, 0.12);\
border: none; outline: none; box-shadow: none; }}\
scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\
background-image: none; background-color: @accent; }}\
scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\
padding: 0; opacity: 0; background: transparent; border: none;\
outline: none; box-shadow: none; }}\
.control-panel-section {{ margin: 8px 0 0; }}\
.power-row {{ margin-top: 8px; }}\
.power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\
background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\
outline: none; box-shadow: none;\
transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\
.power-btn:active {{ background: alpha(@accent, 0.22); }}\
.notification-action {{ transition: background-color 0.18s ease; }}\
.tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\
separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\
/* Lua-declared widgets (see Documentation.md's Widgets §style): the\
slot rule below is what the four inline `.bread-widget-slot`\
containers in main.rs rely on for the same 12px stat-pair rhythm\
@ -157,14 +265,11 @@ fn load_css() -> String {
.bread-padding-xs {{ padding: 4px; }}\
.bread-padding-sm {{ padding: 8px; }}\
.bread-padding-md {{ padding: 12px; }}",
bg_plain = p.background,
bg_rgba = hex_to_rgba(&p.background, 0.92),
surface = p.color0,
accent = p.color4,
critical = p.color1,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
trough = hex_to_rgba(&p.color4, 0.25),
radius = radius,
radius_bar = radius_bar,
radius_sm = radius_sm,
radius_pill = radius_pill,
pad = pad,
)
}
@ -175,6 +280,31 @@ pub fn fg_color() -> String {
ink_on(&load_palette().background).to_string()
}
/// Ink colour for the given Hyprland output's wallpaper palette.
#[allow(dead_code)]
pub fn fg_color_for(output: &str) -> String {
ink_on(&load_palette_for(output).background).to_string()
}
/// Bind this window (and its popover children) to `output`'s palette.
///
/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css`
/// resolves them against that output. Display-level [`apply`] stays as the
/// SIGHUP / single-output fallback.
pub fn bind_output(widget: &impl IsA<gtk4::Widget>, output: &str) {
bgtk::bind_window_with_app_css(widget, output, load_css_for);
}
/// Bind a satellite window (notification, history, OSD, wifi dialog) to
/// whichever output it is actually rendered on.
pub fn bind_auto(window: &impl IsA<gtk4::Native>) {
bgtk::bind_window_auto_with_app_css(window, load_css_for);
}
fn load_css_for(_palette: &Palette) -> String {
load_css()
}
/// 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

View file

@ -24,7 +24,8 @@ const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32;
fn bundled_icon(name: &str) -> Option<&'static str> {
use crate::bar::stats::{
AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK,
ICON_VOLUME, WIFI_OFF, WIFI_STRONG,
ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG,
WIFI_WEAK,
};
Some(match name {
"ac-power" => AC_POWER,
@ -34,8 +35,13 @@ fn bundled_icon(name: &str) -> Option<&'static str> {
"bluetooth-on" => BT_ON,
"bluetooth-off" => BT_OFF,
"wifi-strong" => WIFI_STRONG,
"wifi-medium" => WIFI_MEDIUM,
"wifi-weak" => WIFI_WEAK,
"wifi-off" => WIFI_OFF,
"lock" => ICON_LOCK,
"sleep" => ICON_SLEEP,
"restart" => ICON_RESTART,
"shutdown" => ICON_SHUTDOWN,
"volume" => ICON_VOLUME,
"brightness" => ICON_BRIGHTNESS,
_ => return None,
@ -171,6 +177,7 @@ pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget {
let px = size.unwrap_or(16).max(1) as u32;
let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px);
let image = gtk4::Image::from_paintable(texture.as_ref());
crate::prepare_icon(&image, px as i32);
image.upcast()
}
WidgetNode::Progress { value, .. } => {