Compare commits

..

No commits in common. "main" and "v0.3.3" have entirely different histories.
main ... v0.3.3

17 changed files with 287 additions and 3757 deletions

6
.gitignore vendored
View file

@ -35,9 +35,3 @@ logs/
# Internal design documents (not for distribution) # Internal design documents (not for distribution)
aster-brief.md aster-brief.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# Local-only source overrides (see .cargo/config.toml).
.cargo/

55
Cargo.lock generated
View file

@ -150,29 +150,31 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bread-launcher"
version = "0.7.5"
dependencies = [
"bread-utils 0.7.5",
"gtk4",
"serde_json",
]
[[package]] [[package]]
name = "bread-screenshots" name = "bread-screenshots"
version = "0.7.5" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)", "bread-utils",
"tracing", "tracing",
] ]
[[package]]
name = "bread-shared"
version = "0.7.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
dependencies = [
"dirs 5.0.1",
"serde",
"serde_json",
"toml 0.8.23",
]
[[package]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.8.0" version = "0.8.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"serde", "serde",
@ -183,33 +185,21 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.7.5" version = "0.7.4"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
dependencies = [ dependencies = [
"anyhow",
"dirs 5.0.1", "dirs 5.0.1",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23",
"tracing",
] ]
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.7.5" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"bread-shared", "bread-shared 0.7.0",
"dirs 5.0.1",
"serde",
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [
"bread-shared",
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
"serde_json", "serde_json",
@ -220,11 +210,10 @@ name = "breadbar"
version = "0.3.3" version = "0.3.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-launcher",
"bread-screenshots", "bread-screenshots",
"bread-shared", "bread-shared 0.8.0",
"bread-theme", "bread-theme",
"bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)", "bread-utils",
"clap", "clap",
"futures-lite", "futures-lite",
"gtk4", "gtk4",

View file

@ -10,22 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"]
categories = ["gui"] categories = ["gui"]
[dependencies] [dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe)
# for talking to breadd's IPC socket, and bread-shared purely for the # for talking to breadd's IPC socket, and bread-shared purely for the
# WidgetSpec/WidgetNode wire types so we deserialize into real structs # WidgetSpec/WidgetNode wire types so we deserialize into real structs
# instead of hand-parsing serde_json::Value. See src/widgets/. # instead of hand-parsing serde_json::Value. See src/widgets/.
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
# bread_shared::widget wire types; match the bread-shared tag bread-utils # v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if
# pins (v0.8.0) so only one copy of bread-shared is linked. # ecosystem crates move independently.
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0" } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" } bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme
# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via
# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for
# the results widget itself.
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8" gtk4-layer-shell = "0.8"
relm4 = { version = "0.11", features = ["macros"] } relm4 = { version = "0.11", features = ["macros"] }

View file

@ -18,19 +18,6 @@ pub fn current() -> String {
format!("{} {}", date(), time()) format!("{} {}", date(), time())
} }
/// `modules.clock.format` rendered against GLib's own `DateTime::format`
/// (a strftime subset — `%H`, `%M`, `%a`, `%d`, `%m`, ... all work). Falls
/// back to [`time`]'s hardcoded "HH:MM" on a malformed format string rather
/// than propagating an error — a broken theme's clock format must degrade,
/// not crash the bar, same as every other "malformed theme" fallback in
/// this system.
pub fn formatted(format: &str) -> String {
now()
.format(format)
.map(|s| s.to_string())
.unwrap_or_else(|_| time())
}
pub fn spawn_ticker(sender: ComponentSender<App>) { pub fn spawn_ticker(sender: ComponentSender<App>) {
relm4::spawn(async move { relm4::spawn(async move {
loop { loop {

View file

@ -2,7 +2,6 @@ pub mod bluetooth;
pub mod clock; pub mod clock;
pub mod control; pub mod control;
pub mod media; pub mod media;
pub mod slots;
pub mod stats; pub mod stats;
pub mod tray; pub mod tray;
pub mod wifi; pub mod wifi;

View file

@ -1,86 +0,0 @@
//! Module registry for the theme manifest's `[bar.slots]` (plan Phase 3a),
//! extended in Phase 3b to also route `widget:<key>` entries.
//!
//! Each bar module (`workspaces`, `media`, `clock`, `volume`, `wifi`,
//! `battery`, `control`, …) is still built exactly where it always was in
//! `main.rs` — this registry only decouples the ORDER in which those
//! already-constructed widgets get appended into the left/centre/right
//! containers from the fixed source-code order they were built in. main.rs
//! registers each widget by its manifest module name once construction is
//! done, then walks `ShellTheme::slots()` to append them in the theme's
//! order instead of a hardcoded sequence.
//!
//! A slot entry may also be `widget:<key>`, where `<key>` is either a
//! `WidgetPlacement` alias (`right_of_workspaces`, `left_of_clock`,
//! `right_of_clock`, `left_of_stats`, `tray`) or a Lua module name (see
//! `bread_shared::widget::WidgetSpec::module`) — these route through
//! `for_each_in_slot`'s `on_widget` callback rather than this registry,
//! since their containers are Lua-widget slots created on demand by the
//! caller, not modules registered here. `WidgetPlacement` itself is a wire
//! type from `bread-shared` and is never referenced in this file.
use gtk4::prelude::*;
use std::collections::HashMap;
/// Maps a `[bar.slots]` module name to its already-built widget.
#[derive(Default)]
pub struct ModuleRegistry(HashMap<&'static str, gtk4::Widget>);
impl ModuleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Registers `widget` under `name` (a `[bar.slots]` module name, e.g.
/// `"workspaces"` or `"clock"`).
pub fn register(&mut self, name: &'static str, widget: &impl IsA<gtk4::Widget>) {
self.0.insert(name, widget.clone().upcast());
}
/// Walks every entry named in `names` (a manifest slot list, in theme
/// order). A `widget:<key>` entry calls `on_widget(key)`, letting the
/// caller create-or-fetch that Lua widget container and append it at
/// this exact position. Anything else is looked up as a registered
/// module name and passed to `on_module`; a name with no registered
/// widget is logged and skipped — an unrecognized or unmapped module in
/// a theme manifest must never crash the bar.
pub fn for_each_in_slot(
&self,
names: &[String],
mut on_module: impl FnMut(&str, &gtk4::Widget),
mut on_widget: impl FnMut(&str),
) {
for name in names {
if let Some(key) = name.strip_prefix("widget:") {
on_widget(key);
continue;
}
match self.0.get(name.as_str()) {
Some(widget) => on_module(name, widget),
None => eprintln!("breadbar: [bar.slots] names unknown module '{name}' — skipping"),
}
}
}
}
/// Returns the widget container keyed `key` in `containers`, creating it
/// (a plain horizontal box, styled like every other Lua widget slot) on
/// first use. Called from `for_each_in_slot`'s `on_widget` callback so a
/// `widget:<key>` slot entry gets a container the first time a theme
/// places one there, regardless of whether `key` is a `WidgetPlacement`
/// alias or a Lua module name — `reconcile_widgets` (main.rs) is what
/// gives that distinction meaning when it routes specs into these
/// containers.
pub fn widget_slot_container(
containers: &mut HashMap<String, gtk4::Box>,
key: &str,
) -> gtk4::Box {
containers
.entry(key.to_string())
.or_insert_with(|| {
let b = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
b.add_css_class("bread-widget-slot");
b
})
.clone()
}

View file

@ -402,7 +402,7 @@ fn read_crumbs_profile() -> Option<String> {
for line in text.lines() { for line in text.lines() {
if let Some(rest) = line.trim().strip_prefix("profile") { if let Some(rest) = line.trim().strip_prefix("profile") {
let val = rest let val = rest
.trim_start_matches([' ', '=']) .trim_start_matches(|c: char| c == ' ' || c == '=')
.trim_matches('"'); .trim_matches('"');
if !val.is_empty() { if !val.is_empty() {
return Some(val.to_string()); return Some(val.to_string());

View file

@ -142,13 +142,7 @@ pub fn make_button(
btn.set_halign(gtk4::Align::Center); btn.set_halign(gtk4::Align::Center);
btn.set_vexpand(false); btn.set_vexpand(false);
btn.set_hexpand(false); btn.set_hexpand(false);
// `crate::theme::approved_chip_height`, not `tokens().chip_height()`: btn.set_size_request(-1, crate::CHIP_HEIGHT);
// the latter is the stale pre-demo `breadbar::CHIP_HEIGHT` token (32
// for this Trail/Pill style's theme) and, as a hard `set_size_request`
// minimum, would out-rank the CSS `min-height` the demo actually wants
// (26px Trail / 22px Pill) — see that function's doc comment.
let style = crate::theme::shell_theme().modules().workspaces.style;
btn.set_size_request(-1, crate::theme::approved_chip_height(style) as i32);
if let Some(child) = btn.child() { if let Some(child) = btn.child() {
child.set_halign(gtk4::Align::Center); child.set_halign(gtk4::Align::Center);
child.set_valign(gtk4::Align::Center); child.set_valign(gtk4::Align::Center);
@ -161,111 +155,6 @@ pub fn make_button(
btn btn
} }
/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH
/// encodes `windows` (0/1/2/3-or-more open). Distinct from [`make_button`]
/// (Trail/Pill) rather than a variant of it because dots carry no text at
/// all (`04-spotlight.html`'s `.dots button` has no label); reusing
/// `Button::with_label("")` would still measure/lay out an empty label box
/// that a genuinely childless button doesn't. Width is a hard
/// `set_size_request` snap, not animated — GTK CSS min-width transitions
/// don't participate in a directly-set size request the way an opacity/
/// background-color transition does, and the plan only calls out the
/// capsule's own expand/collapse as worth the `anim::spring_to` treatment.
///
/// `_dot_widths` (the manifest's `modules.workspaces.dot_widths`) is
/// accepted but deliberately unused — see `APPROVED_DOT_WIDTHS` below,
/// which overrides it with the approved Option B numbers the manifest's
/// own value predates. Kept in the signature rather than dropped so the
/// call site still documents where a real per-theme width would flow from
/// once `theme.toml` catches up.
pub fn make_dot_button(
id: WorkspaceId,
active: WorkspaceId,
windows: i32,
_dot_widths: bread_theme::shell::DotWidths,
) -> gtk4::Button {
let btn = gtk4::Button::new();
btn.add_css_class("workspace-dot");
if windows > 0 {
btn.add_css_class("occupied");
}
if id == active {
btn.add_css_class("active");
}
btn.set_valign(gtk4::Align::Center);
btn.set_halign(gtk4::Align::Center);
btn.set_vexpand(false);
btn.set_hexpand(false);
// Height is a deliberate departure from `04-spotlight.html`'s own 6px
// (see the demo's `.dots button { height: 6px }`): reported as "too
// small and hard to click", and also genuinely hard to *see* on a real
// display, not just hard to hit. Option B (approved): 10px tall — up
// from an earlier 9px pass that undershot the approved number by 1px.
// Keeps the dots reading as slim pills rather than growing into little
// chips (which would fight the capsule's minimal, text-first look),
// while being clearly perceptible against the 36px-tall bar.
const DOT_HEIGHT: i32 = 10;
// Option B widths (approved): 8/13/17/22px for 0/1/2/3-or-more open
// windows — `[8, 13, 17, 22]`, not the `dot_widths` parameter's own
// manifest value. `theme.toml`'s `modules.workspaces.dot_widths =
// [6, 10, 14, 18]` predates this pass and was never updated to match;
// hardcoded here (ignoring the passed-in `dot_widths`) rather than
// edited upstream, since bread-ecosystem is a sibling agent's repo
// this pass. Flagged in the task report — `dot_widths` should become
// `[8, 13, 17, 22]` in `assets/shell/spotlight/theme.toml`.
const APPROVED_DOT_WIDTHS: bread_theme::shell::DotWidths = [8, 13, 17, 22];
btn.set_size_request(
APPROVED_DOT_WIDTHS[dot_width_index(windows)],
DOT_HEIGHT,
);
btn.connect_clicked(move |_| {
relm4::spawn(async move {
switch_workspace(id).await;
});
});
btn
}
/// Maps an open-window count to a [`bread_theme::shell::DotWidths`] index:
/// 0/1/2 pass through, 3-or-more all collapse onto index 3 (the demo's own
/// `.dots button[data-n="3"]` never has a "4" variant). Pulled out of
/// [`make_dot_button`] as its own pure function purely so it's testable
/// without a GTK display — the isolated screenshot harness
/// (`bread-capture`) has no Hyprland IPC, so it can never exercise a
/// nonzero window count, and this is what stands in for that visual proof
/// (see the task notes on that gap).
fn dot_width_index(windows: i32) -> usize {
(windows.max(0) as usize).min(3)
}
#[cfg(test)]
mod dot_width_tests {
use super::dot_width_index;
#[test]
fn zero_and_one_and_two_pass_through() {
assert_eq!(dot_width_index(0), 0);
assert_eq!(dot_width_index(1), 1);
assert_eq!(dot_width_index(2), 2);
}
#[test]
fn three_or_more_all_collapse_onto_index_three() {
assert_eq!(dot_width_index(3), 3);
assert_eq!(dot_width_index(4), 3);
assert_eq!(dot_width_index(50), 3);
}
#[test]
fn negative_windows_clamps_to_zero_rather_than_panicking() {
// Hyprland's `windows` count is unsigned (u16) in practice, but
// `make_dot_button` takes a plain i32 — a negative value must
// never underflow the `dot_widths` index and panic.
assert_eq!(dot_width_index(-1), 0);
assert_eq!(dot_width_index(i32::MIN), 0);
}
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Geom { struct Geom {
x: f64, x: f64,
@ -277,13 +166,6 @@ struct Geom {
struct TrailInner { struct TrailInner {
tick: Option<gtk4::TickCallbackId>, tick: Option<gtk4::TickCallbackId>,
geom: Geom, geom: Geom,
/// Last width/height measured from a button that was actually allocated.
/// A row rebuild (switching to an empty workspace makes Hyprland create
/// and destroy it) can leave every button unallocated for a frame, and
/// without a remembered size the trail had nothing safe to animate from
/// and fell back to an instant `place()` — which is what made a switch
/// snap instead of move.
natural: Option<(f64, f64)>,
} }
/// Overlay + Fixed pill sitting *behind* the workspace buttons. The /// Overlay + Fixed pill sitting *behind* the workspace buttons. The
@ -324,7 +206,6 @@ impl WorkspaceTrail {
let inner = Rc::new(RefCell::new(TrailInner { let inner = Rc::new(RefCell::new(TrailInner {
tick: None, tick: None,
natural: None,
geom: Geom { geom: Geom {
x: 0.0, x: 0.0,
y: 0.0, y: 0.0,
@ -377,7 +258,7 @@ impl WorkspaceTrail {
pub fn stretch(&self, from: Option<&gtk4::Button>, to: &gtk4::Button) { pub fn stretch(&self, from: Option<&gtk4::Button>, to: &gtk4::Button) {
self.cancel(); self.cancel();
let Some(from_g) = self.from_geom(from, to) else { let Some(from_g) = self.from_geom(from) else {
self.place(to); self.place(to);
return; return;
}; };
@ -408,18 +289,6 @@ impl WorkspaceTrail {
} else { } else {
(to_g, true) (to_g, true)
}; };
// Squash-and-stretch (ANIMATION WORK #1): compress the pill's
// height a few px while it's mid-flight and let it spring back
// — slightly taller than normal, then settling — as it lands,
// so the trail reads as having weight instead of sliding like a
// rigid box. A pure post-process on top of the already-correct
// x/w/y trajectory above, applied only to the frame actually
// painted (`g`) — never fed back into `TrailInner::natural`
// (`from_geom`'s own width/height memory for the *next*
// animation), which stays driven solely by real button geometry
// as before. Skipped on the final `done` frame so the resting
// geometry is exactly `to_g`, unperturbed.
let g = if done { g } else { squash_geom(g, squash_factor(elapsed)) };
apply_geom(&host, &pill, &inner, &g); apply_geom(&host, &pill, &inner, &g);
if done { if done {
inner.borrow_mut().tick = None; inner.borrow_mut().tick = None;
@ -431,65 +300,15 @@ impl WorkspaceTrail {
self.inner.borrow_mut().tick = Some(id); self.inner.borrow_mut().tick = Some(id);
} }
// `from` is a noun here (the source button we animate away from), not a fn from_geom(&self, from: Option<&gtk4::Button>) -> Option<Geom> {
// conversion — `&self` is correct.
#[allow(clippy::wrong_self_convention)]
fn from_geom(&self, from: Option<&gtk4::Button>, to: &gtk4::Button) -> Option<Geom> {
let st = self.inner.borrow(); let st = self.inner.borrow();
let live = if self.pill.is_visible() && st.geom.w > 0.5 { // A leftover mid-stretch can be as wide as the whole row — never
Some(st.geom) // treat that as the start of the next animation.
} else { if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W {
None return Some(st.geom);
}; }
let cached = st.natural;
drop(st); drop(st);
from.and_then(|b| button_geom(b, &self.host).map(inset_pill))
// Width must always come from a button that is CURRENTLY IN THE ROW.
// Switching to an empty workspace makes Hyprland create and destroy it,
// which rebuilds the button row mid-animation and can leave `from`
// detached — `button_geom` then returns None. Falling back to the live
// geometry there handed the wide mid-stretch span straight back in,
// which is exactly the accumulation this function exists to prevent
// (reproduced by spamming between workspace 1 and an empty 6). The
// destination button is always live, so it is the correct fallback.
let natural = from
.and_then(|b| button_geom(b, &self.host).map(inset_pill))
.or_else(|| button_geom(to, &self.host).map(inset_pill));
// Continuity without accumulation.
//
// Interrupting an in-flight stretch should carry the pill's CURRENT
// POSITION into the next animation, so a rapid sequence of switches
// reads as one continuous movement. It must not carry the current
// WIDTH: mid-stretch the pill deliberately spans both the old and new
// buttons, and `ease_overshoot` (c = 1.4) pushes it wider still past
// the target. Feeding that span back in as the next `from` made each
// interrupted switch start wider than the last, so spamming workspace
// switches grew the pill until it hit MAX_CHIP_W — which only capped
// the runaway, it never stopped the compounding.
//
// Taking position from the live geometry and width from the source
// button's natural size keeps the motion continuous while making width
// a pure function of which button we started from.
if let Some(n) = natural {
self.inner.borrow_mut().natural = Some((n.w, n.h));
}
// Only x comes from the live geometry. That is what makes an
// interrupted switch continue from where the pill currently is instead
// of jumping back. y/w/h always come from a real button: taking y from
// a mid-animation or post-rebuild geometry is what made the pill sit
// low, and taking w from it is what let the width compound.
let size = natural.map(|n| (n.w, n.h)).or(cached);
match (live, natural, size) {
(Some(live), _, Some((w, h))) => Some(Geom {
x: live.x,
y: natural.map(|n| n.y).unwrap_or(live.y),
w,
h,
}),
(None, Some(natural), _) => Some(natural),
_ => None,
}
} }
} }
@ -592,88 +411,3 @@ fn ease_overshoot(t: f64) -> f64 {
let t1 = t - 1.0; let t1 = t - 1.0;
1.0 + t1 * t1 * ((c + 1.0) * t1 + c) 1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
} }
/// Lowest fraction of the resting height the pill compresses to, at the
/// peak of the stretch phase (fastest travel).
const SQUASH_MIN: f64 = 0.80;
/// The squash-and-stretch height multiplier for a given point in
/// `stretch`'s own STRETCH_MS-then-SNAP_MS timeline: eases DOWN to
/// `SQUASH_MIN` across the stretch phase (using the same `ease` curve the
/// width stretch already uses), then eases back UP to 1.0 across the snap
/// phase using `ease_overshoot` — which legitimately overshoots past 1.0
/// partway through, so the pill also plumps up slightly taller than its
/// resting height right before settling, the same "spring" read the width
/// snap already has. Past both phases (a caller-side `elapsed` this large
/// only happens if something calls this after `stretch`'s own `done` cutoff)
/// this is exactly 1.0, i.e. a no-op.
fn squash_factor(elapsed: f64) -> f64 {
if elapsed < STRETCH_MS {
lerp(1.0, SQUASH_MIN, ease(elapsed / STRETCH_MS))
} else if elapsed < STRETCH_MS + SNAP_MS {
lerp(SQUASH_MIN, 1.0, ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS))
} else {
1.0
}
}
/// Applies a height multiplier to `g`, keeping it vertically centred on
/// `g`'s own centre (so the squash reads as compression, not a pill that
/// sinks or rises) and leaving x/w untouched.
fn squash_geom(g: Geom, factor: f64) -> Geom {
let h = g.h * factor;
Geom {
x: g.x,
y: g.y + (g.h - h) * 0.5,
w: g.w,
h,
}
}
#[cfg(test)]
mod squash_tests {
use super::*;
#[test]
fn factor_starts_and_ends_at_one() {
assert!((squash_factor(0.0) - 1.0).abs() < 1e-9);
assert!((squash_factor(STRETCH_MS + SNAP_MS) - 1.0).abs() < 1e-9);
assert_eq!(squash_factor(STRETCH_MS + SNAP_MS + 500.0), 1.0);
}
#[test]
fn factor_dips_below_one_mid_stretch() {
let mid_stretch = STRETCH_MS * 0.5;
assert!(squash_factor(mid_stretch) < 1.0);
assert!(squash_factor(mid_stretch) >= SQUASH_MIN);
}
#[test]
fn squash_geom_keeps_the_vertical_centre_fixed() {
let g = Geom {
x: 10.0,
y: 20.0,
w: 30.0,
h: 26.0,
};
let centre = g.y + g.h * 0.5;
let squashed = squash_geom(g, 0.8);
assert!((squashed.h - 20.8).abs() < 1e-9);
assert!((squashed.y + squashed.h * 0.5 - centre).abs() < 1e-9);
assert_eq!(squashed.x, g.x);
assert_eq!(squashed.w, g.w);
}
#[test]
fn squash_geom_factor_one_is_identity() {
let g = Geom {
x: 1.0,
y: 2.0,
w: 3.0,
h: 4.0,
};
let out = squash_geom(g, 1.0);
assert_eq!(out.y, g.y);
assert_eq!(out.h, g.h);
}
}

View file

@ -1,53 +0,0 @@
//! Subscribes to `bread.command.box.open` and focuses/opens the capsule —
//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md
//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing
//! contract: it's honored today only while `breadbox listen` is running, and
//! is a silent no-op at the bus with no subscriber. breadbar becomes a
//! SECOND subscriber of the exact same verb here — under an embedded theme,
//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`)
//! redirects a direct launch to this same event instead of mapping its own
//! overlay window, specifically so this module can pick it up. `breadbox
//! listen`'s own handling of the same event is separately made a no-op
//! under an embedded theme (see its `handle_open`) — there is exactly one
//! real handler for this event at a time, whichever theme is active.
//!
//! Same connection pattern as `widgets::client` (this crate's other
//! `BreadClient::subscribe` user): a fire-and-forget connect, a background
//! subscription thread with its own reconnect/backoff, and the handle is
//! leaked rather than threaded through `App` — there's no natural point to
//! stop it before the process exits.
use crate::{App, AppInput};
use bread_theme::shell::LauncherMode;
use bread_utils::bread_client::BreadClient;
use relm4::ComponentSender;
/// Starts the subscription iff the active shell theme's launcher is
/// `Embedded`. A no-op call under every other theme — never connects to
/// breadd at all, matching the "effectively a no-op under every other
/// theme" pattern the rest of the capsule wiring already follows (main.rs's
/// `open_fn`/`close_fn` doc comment).
pub fn spawn(sender: ComponentSender<App>) {
if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded {
return;
}
let client = BreadClient::connect(crate::widgets::client::APP_ID);
// Two verbs, deliberately.
//
// `bread.box.open_requested` is what breadbox actually emits when the
// active theme is embedded: an app may only publish inside its own
// `bread.<app_id>.*` namespace, so breadbox (app id `box`) cannot emit a
// `bread.command.*` event at all — bread-client refuses it outright.
//
// `bread.command.box.open` is kept because it is the addressed-TO-an-app
// command form, which is what an external trigger (the `bread` CLI, a
// keybind, another app) would legitimately send. Honouring both means the
// capsule opens whether it was asked directly or told by breadbox.
for verb in ["bread.box.open_requested", "bread.command.box.open"] {
let sender = sender.clone();
let subscription = client.subscribe(verb, move |_event| {
sender.input(AppInput::OpenLauncher);
});
std::mem::forget(subscription);
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{KeyboardMode, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::Urgency; use super::Urgency;
@ -176,16 +176,12 @@ pub fn build_window(store: Store) -> Ui {
window.add_css_class("breadbar-history"); window.add_css_class("breadbar-history");
window.init_layer_shell(); window.init_layer_shell();
window.set_namespace(Some("breadbar-notif")); window.set_namespace(Some("breadbar-notif"));
crate::surface::apply(&window, "breadbar-notif"); window.set_layer(Layer::Overlay);
// Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast window.set_anchor(Edge::Top, true);
// popup's width — see `surface::apply`'s doc comment): the history window.set_anchor(Edge::Right, true);
// window genuinely wants a different width on the same namespace, and window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
// that isn't something the manifest schema models today. Both calls window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
// must be overridden, not just `set_default_width` — `apply()` also
// pins `set_size_request` to the toast's 320px, and a bare width alone
// would lose to that pin the same way it lost to a wide child before.
window.set_default_width(360); window.set_default_width(360);
window.set_size_request(360, -1);
window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window); crate::theme::bind_auto(&window);

View file

@ -1,8 +1,7 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Instant}; use std::{cell::RefCell, collections::HashMap, rc::Rc};
use gtk4::glib::ControlFlow;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{KeyboardMode, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY};
@ -35,23 +34,6 @@ pub fn build_window() -> (gtk4::Window, gtk4::Box) {
cards_box.set_margin_start(8); cards_box.set_margin_start(8);
cards_box.set_margin_end(8); cards_box.set_margin_end(8);
window.set_child(Some(&cards_box)); window.set_child(Some(&cards_box));
// NOTIFICATION INTERACTION #B: the surface (and `window.surface()`)
// doesn't exist until map, same constraint `surface::click_through`
// documents — this is the initial-region counterpart of that hook,
// recomputing against whatever's already in `cards_box` at the moment
// the toast becomes visible (empty on the very first map, but this
// window remaps on every reappearance after being fully dismissed —
// see `dismiss`'s `window.set_visible(false)` — and by the time a new
// notification's `Show` handler calls `set_visible(true)` again, its
// card is already a child of `cards_box`). `refresh_hit_region`'s own
// per-event calls below are the steady-state path; this is the
// just-in-case one for the map race itself.
let cbox_for_map = cards_box.clone();
window.connect_map(move |win| {
apply_hit_region(win, &cbox_for_map);
});
(window, cards_box) (window, cards_box)
} }
@ -103,15 +85,6 @@ pub async fn run(
cards_box.prepend(&card); cards_box.prepend(&card);
cards.borrow_mut().insert(id, card.clone()); cards.borrow_mut().insert(id, card.clone());
window.set_visible(true); window.set_visible(true);
// ANIMATION WORK #6: spring the new card's own height in
// from 0 to its natural content height instead of it
// appearing at full size in one frame — since it's
// `prepend`ed (the vertical box's first child), the
// existing cards below get pushed down smoothly as this
// grows, rather than jumping straight to their new
// position.
spring_in_card(&card);
refresh_hit_region(&window, &cards_box);
if let Some(ui) = &history_ui { if let Some(ui) = &history_ui {
history::refresh_if_visible(ui); history::refresh_if_visible(ui);
} }
@ -159,13 +132,7 @@ pub async fn run(
/// Removes `id`'s card if present. Returns whether a card was actually /// Removes `id`'s card if present. Returns whether a card was actually
/// removed, so callers only emit `NotificationClosed` for a real dismissal /// removed, so callers only emit `NotificationClosed` for a real dismissal
/// (not a no-op on an id that's already gone or was never shown). Every /// (not a no-op on an id that's already gone or was never shown).
/// caller (auto-expire, `CloseNotification`, an action/reply invocation,
/// and the card's own dismiss button) goes through this one function, so
/// this is also the one place that needs to recompute the hit region
/// (NOTIFICATION INTERACTION #B) on removal — a card gone from `cards_box`
/// but still counted in the input region would leave a dead click-through
/// hole where a live button used to be.
fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32) -> bool { fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32) -> bool {
let removed = cards.borrow_mut().remove(&id); let removed = cards.borrow_mut().remove(&id);
let Some(card) = removed else { let Some(card) = removed else {
@ -175,7 +142,6 @@ fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32)
if cards.borrow().is_empty() { if cards.borrow().is_empty() {
window.set_visible(false); window.set_visible(false);
} }
refresh_hit_region(window, cards_box);
true true
} }
@ -200,135 +166,20 @@ async fn emit_closed(conn: &Option<zbus::Connection>, id: u32, reason: u32) {
} }
} }
/// ANIMATION WORK #6: springs `card`'s own height from 0 up to its natural
/// content height (`bread_theme::anim::spring_to` + `set_size_request`,
/// same "GTK4 CSS has no height transition" technique `main.rs`'s
/// `animate_drawer_height` already uses for the capsule drawer) instead of
/// it appearing at full size in one frame. Measured AFTER `card` is already
/// a child of `cards_box` (the caller's job), not before: an unparented
/// widget isn't rooted under this window's style provider chain yet, so its
/// `measure()` wouldn't see the real `.notification-card` padding/border —
/// only a widget that's actually in the tree gets an accurate natural size.
///
/// One-shot and self-contained — no cancellation bookkeeping, unlike
/// `animate_drawer_height`/`animate_osd_fill`'s own `Rc<RefCell<..>>`
/// tick-id storage: a card's entrance can't be interrupted by a second one,
/// since a same-id replacement tears the whole card down and builds a
/// fresh one (see the `Show` handler's `cards_box.remove(&old)`) rather
/// than reusing it.
const CARD_GROW_MS: f64 = 380.0;
fn spring_in_card(card: &gtk4::Box) {
let (_, target_h, _, _) = card.measure(gtk4::Orientation::Vertical, -1);
card.set_size_request(-1, 0);
let target = card.clone();
bread_theme::anim::spring_to(card, 0, target_h, CARD_GROW_MS, move |h| {
target.set_size_request(-1, h.max(0));
});
}
thread_local! {
// NOTIFICATION INTERACTION #B: the tick callback that keeps
// `refresh_hit_region` recomputing the toast's input region while a
// card's entrance (`spring_in_card` above) or the stack's push-down
// reflow could still be moving a button. One process-wide toast window
// (this crate registers a single `org.freedesktop.Notifications` name),
// so a thread-local — not a field threaded through every call site — is
// enough, same reasoning as `theme::SHELL_THEME_MONITOR`.
static HIT_TRACKER: RefCell<Option<gtk4::TickCallbackId>> = const { RefCell::new(None) };
}
/// How long after a card set/layout change to keep recomputing the hit
/// region every frame — long enough to cover both `spring_in_card`'s
/// `CARD_GROW_MS` and the CSS `notif-in` keyframe's 0.45s slide-in (see
/// `theme.rs`'s `.notification-card` rule), whichever finishes last.
const HIT_TRACK_MS: f64 = 700.0;
/// Recomputes the toast surface's clickable input region immediately, then
/// keeps recomputing it every frame for `HIT_TRACK_MS` — covering both a
/// newly-shown card's own entrance and the stack's push-down settle, either
/// of which can still be moving a button on the frame this is called.
/// Called any time the card set could have changed: shown, dismissed
/// (including via the new dismiss button — see `dismiss` above), or
/// expired. Cancels any previous tracking run first, so a rapid burst of
/// notifications doesn't accumulate overlapping tick callbacks.
fn refresh_hit_region(window: &gtk4::Window, cards_box: &gtk4::Box) {
apply_hit_region(window, cards_box);
if let Some(id) = HIT_TRACKER.with(|c| c.borrow_mut().take()) {
id.remove();
}
let started = Instant::now();
let win = window.clone();
let cbox = cards_box.clone();
let id = window.add_tick_callback(move |_, _| {
apply_hit_region(&win, &cbox);
if started.elapsed().as_secs_f64() * 1000.0 >= HIT_TRACK_MS {
HIT_TRACKER.with(|c| c.borrow_mut().take());
return ControlFlow::Break;
}
ControlFlow::Continue
});
HIT_TRACKER.with(|c| *c.borrow_mut() = Some(id));
}
/// One frame's worth of `refresh_hit_region`'s work: walk `cards_box` for
/// every currently-interactive widget (action/dismiss buttons, the
/// inline-reply entry) and hand their rectangles to
/// `surface::set_hit_region`. Split out from `refresh_hit_region` so the
/// initial immediate call and the tracking tick callback share the exact
/// same logic.
fn apply_hit_region(window: &gtk4::Window, cards_box: &gtk4::Box) {
let mut widgets = Vec::new();
collect_interactive(cards_box.upcast_ref::<gtk4::Widget>(), &mut widgets);
crate::surface::set_hit_region(window, &widgets);
}
/// Depth-first walk of `root`'s widget tree collecting every `GtkButton`
/// (action buttons, the reply-send button, the dismiss button) and
/// `GtkEntry` (the inline-reply field) — the only things on a card a user
/// should ever be able to click into. Everything else (the summary/body
/// labels, the card's own background) stays click-through, same as the
/// blanket empty region did before NOTIFICATION INTERACTION #B. Walking
/// the real widget tree rather than tracking a flat list as cards/buttons
/// are built means this can't drift out of sync with `make_card`'s own
/// structure (e.g. the dismiss button living inside a `gtk4::Overlay`
/// rather than directly under `card`).
fn collect_interactive(root: &gtk4::Widget, out: &mut Vec<gtk4::Widget>) {
let mut child = root.first_child();
while let Some(w) = child {
if w.is::<gtk4::Button>() || w.is::<gtk4::Entry>() {
out.push(w.clone());
}
collect_interactive(&w, out);
child = w.next_sibling();
}
}
fn create_window() -> gtk4::Window { fn create_window() -> gtk4::Window {
let window = gtk4::Window::new(); let window = gtk4::Window::new();
window.add_css_class("breadbar-notification"); window.add_css_class("breadbar-notification");
window.init_layer_shell(); window.init_layer_shell();
window.set_namespace(Some("breadbar-notif")); window.set_namespace(Some("breadbar-notif"));
crate::surface::apply(&window, "breadbar-notif"); window.set_layer(Layer::Overlay);
// Toasts are purely informational — they never grab keyboard focus, window.set_anchor(Edge::Top, true);
// full stop, regardless of what's clickable on them (KeyboardMode::None window.set_anchor(Edge::Right, true);
// stays; do NOT change this — see the NOTIFICATION INTERACTION #B task window.set_margin(Edge::Top, crate::BAR_MARGIN_TOP + crate::BAR_HEIGHT + 8);
// note). Historically ("stop toast popups from stealing focus or window.set_margin(Edge::Right, crate::BAR_MARGIN_SIDES);
// blocking clicks") that also meant a fully empty input region: every window.set_default_width(320);
// pointer event passed straight through to whatever's underneath, but // OnDemand so an inline-reply GtkEntry can take keys without the popup
// that made `make_card`'s own action buttons, its inline-reply // stealing every keystroke the rest of the time.
// `GtkEntry`, and the dismiss button below permanently unreachable too. window.set_keyboard_mode(KeyboardMode::OnDemand);
// `crate::surface::set_hit_region` (called from `build_window`'s
// `connect_map` and from `refresh_hit_region` below, any time the card
// set or layout could have changed) replaces the old blanket
// `surface::click_through` empty region with the union of just those
// widgets' own rectangles — everywhere else on the surface stays
// click-through, same as before. A toast that genuinely has none of
// them yet (`cards_box` empty) still gets the same all-empty region
// `click_through` set, since a rectangle union over zero widgets is
// the empty region.
window.set_keyboard_mode(KeyboardMode::None);
crate::theme::bind_auto(&window); crate::theme::bind_auto(&window);
window window
} }
@ -397,34 +248,7 @@ fn make_card(spec: CardSpec<'_>) -> gtk4::Box {
content.add_controller(gesture); content.add_controller(gesture);
} }
// NOTIFICATION INTERACTION #A: a direct dismiss control. Floated in card.append(&content);
// the card's top-right corner via an Overlay rather than a full extra
// header row, so it doesn't add vertical bulk the approved demo's own
// card never has (see the `.notification-dismiss` CSS in theme.rs).
// `collect_interactive` (this module) picks it up the same way it
// picks up the action/reply buttons below, by walking the real widget
// tree — it doesn't need to know this button lives one level deeper,
// inside the overlay, than they do.
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&content));
let dismiss_btn = gtk4::Button::with_label("×");
dismiss_btn.add_css_class("notification-dismiss");
dismiss_btn.set_halign(gtk4::Align::End);
dismiss_btn.set_valign(gtk4::Align::Start);
let dismiss_invoke = Invoke {
conn: spec.conn.clone(),
cards: spec.cards.clone(),
cards_box: spec.cards_box.clone(),
window: spec.window.clone(),
id: spec.id,
};
dismiss_btn.connect_clicked(move |_| {
dismiss_card(dismiss_invoke.clone());
});
overlay.add_overlay(&dismiss_btn);
card.append(&overlay);
let visible: Vec<&Action> = spec let visible: Vec<&Action> = spec
.actions .actions
@ -527,22 +351,6 @@ fn invoke_action(invoke: Invoke, key: &str) {
}); });
} }
/// NOTIFICATION INTERACTION #A: the card's own dismiss button. Unlike
/// `invoke_action`, this never emits `ActionInvoked` — there's no action
/// key here, the user just closed the toast unprompted — only the
/// spec-mandated `NotificationClosed(id, reason)`, with
/// `close_reason::DISMISSED_BY_USER` (freedesktop value 2, "dismissed by
/// the user") so clients are told properly, same reason code
/// `invoke_action` above and `submit_reply` below already use for their
/// own user-initiated dismissals.
fn dismiss_card(invoke: Invoke) {
relm4::spawn_local(async move {
if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) {
emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await;
}
});
}
fn submit_reply(entry: &gtk4::Entry, invoke: Invoke) { fn submit_reply(entry: &gtk4::Entry, invoke: Invoke) {
let text = entry.text().to_string(); let text = entry.text().to_string();
if text.trim().is_empty() { if text.trim().is_empty() {

View file

@ -1,13 +1,7 @@
use std::{ use std::{cell::Cell, rc::Rc, time::Duration};
cell::{Cell, RefCell},
process::Child,
rc::Rc,
sync::{Mutex, Once},
time::Duration,
};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::{Edge, Layer, LayerShell};
use tokio::sync::mpsc; use tokio::sync::mpsc;
enum OsdEvent { enum OsdEvent {
@ -24,46 +18,6 @@ pub enum SampleKind {
Brightness, Brightness,
} }
/// Live `pactl subscribe` children spawned by [`volume_watcher`]. Kept so a
/// best-effort cleanup can kill them when breadbar exits: `pactl subscribe`
/// blocks until the server connection dies, so without this every breadbar
/// restart orphaned one that kept its PulseAudio connection open — until
/// pipewire-pulse's client cap filled up and new clients (settings apps
/// included) were refused, showing "no devices". Also reaped here, so a
/// watcher that dies on its own never lingers as a zombie.
static WATCHER_CHILDREN: Mutex<Vec<Child>> = Mutex::new(Vec::new());
static REGISTER_EXIT_HOOK: Once = Once::new();
extern "C" {
/// libc `atexit(3)`. Declared directly rather than pulling the libc
/// crate in for a single function.
fn atexit(cb: extern "C" fn()) -> i32;
}
extern "C" fn exit_cleanup() {
kill_watchers();
}
fn register_exit_hook() {
// Safety: `atexit` is provided by libc on every Linux target; the
// callback is a `static` C-ABI fn valid for the whole process.
unsafe {
let _ = atexit(exit_cleanup);
}
}
/// Kill any live `pactl subscribe` watcher children and reap them. Safe to
/// call more than once (an already-dead child is a no-op). Runs from the
/// process-exit hook and the SIGINT/SIGTERM handlers in `main`.
pub fn kill_watchers() {
if let Ok(mut children) = WATCHER_CHILDREN.lock() {
for mut child in children.drain(..) {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl SampleKind { impl SampleKind {
fn sample_event(&self) -> OsdEvent { fn sample_event(&self) -> OsdEvent {
match self { match self {
@ -115,14 +69,6 @@ fn volume_watcher(tx: mpsc::Sender<OsdEvent>) {
}; };
let Some(stdout) = child.stdout.take() else { return }; let Some(stdout) = child.stdout.take() else { return };
// The child is meant to outlive this reader loop (it blocks until
// breadbar itself dies), so hand it to `kill_watchers` — the exit
// hook plus the SIGINT/SIGTERM handlers in main — instead of letting
// it orphan on restart.
REGISTER_EXIT_HOOK.call_once(register_exit_hook);
if let Ok(mut children) = WATCHER_CHILDREN.lock() {
children.push(child);
}
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
@ -228,15 +174,6 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
container.append(&pbar); container.append(&pbar);
let dismiss_token = Rc::new(Cell::new(0u32)); let dismiss_token = Rc::new(Cell::new(0u32));
// OSD fill overshoot (ANIMATION WORK #5): current fraction (as a whole
// percent, matching `anim::spring_to`'s `i32` interpolation), the
// in-flight tick callback (so a fast double-tap of volume-up cancels the
// previous run instead of fighting it), and a generation token so a
// superseded run's queued second leg (see `animate_osd_fill` below)
// never applies after a newer event has already taken over.
let fill_pct = Rc::new(Cell::new(0i32));
let fill_anim: Rc<RefCell<Option<gtk4::TickCallbackId>>> = Rc::new(RefCell::new(None));
let fill_token = Rc::new(Cell::new(0u32));
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
let (icon_svg, pct, muted) = match event { let (icon_svg, pct, muted) = match event {
@ -245,13 +182,13 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
}; };
icon.set_paintable(Some(&crate::svg_texture(icon_svg))); icon.set_paintable(Some(&crate::svg_texture(icon_svg)));
crate::prepare_icon(&icon, crate::theme::shell_theme().tokens().icon_px() as i32); crate::prepare_icon(&icon, crate::ICON_PX);
if muted { if muted {
icon.add_css_class("osd-icon-muted"); icon.add_css_class("osd-icon-muted");
} else { } else {
icon.remove_css_class("osd-icon-muted"); icon.remove_css_class("osd-icon-muted");
} }
animate_osd_fill(&pbar, &fill_pct, &fill_anim, &fill_token, pct); pbar.set_fraction(pct as f64 / 100.0);
window.set_visible(true); window.set_visible(true);
let token = dismiss_token.get().wrapping_add(1); let token = dismiss_token.get().wrapping_add(1);
@ -267,85 +204,15 @@ async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
} }
} }
/// How many percentage points the fill runs past its real target before
/// easing back — subtle, matching the rest of the theme's "spring" motion
/// rather than a dramatic bounce.
const OSD_OVERSHOOT_PCT: i32 = 4;
/// Leg 1 (toward the overshoot point) / leg 2 (settling back onto the real
/// target) durations. Two legs, not one: `anim::spring_to` clamps every
/// frame to `[min(from, to), max(from, to)]` (see its own doc comment and
/// tests) specifically so a caller like the capsule drawer can never end up
/// with a negative size request — which also means a single `spring_to`
/// call can *never* visibly overshoot `to`, no matter how much the
/// underlying curve wants to. Chaining two calls — first to a point past
/// the target, then back onto it — is what actually produces the overshoot.
const OSD_LEG1_MS: f64 = 160.0;
const OSD_LEG2_MS: f64 = 200.0;
/// Animates `pbar`'s fraction from wherever `current` says it currently is
/// to `target`, overshooting slightly past it and settling back — see the
/// constants above for why this takes two `spring_to` legs instead of one.
/// Cancels any run already in flight (a fast double volume-step must
/// continue from the current visual position, not fight or restart it) and
/// stamps a fresh generation token so a superseded run's leg 2, queued via
/// `timeout_add_local_once` below, is a no-op if it fires after a newer
/// call has already taken over.
fn animate_osd_fill(
pbar: &gtk4::ProgressBar,
current: &Rc<Cell<i32>>,
anim: &Rc<RefCell<Option<gtk4::TickCallbackId>>>,
token: &Rc<Cell<u32>>,
target: u8,
) {
if let Some(id) = anim.borrow_mut().take() {
id.remove();
}
let my_token = token.get().wrapping_add(1);
token.set(my_token);
let from = current.get();
let to = i32::from(target);
if from == to {
pbar.set_fraction(f64::from(to) / 100.0);
return;
}
let overshoot = if to > from {
(to + OSD_OVERSHOOT_PCT).min(100)
} else {
(to - OSD_OVERSHOOT_PCT).max(0)
};
let leg1_bar = pbar.clone();
let leg1_current = current.clone();
let id = bread_theme::anim::spring_to(pbar, from, overshoot, OSD_LEG1_MS, move |v| {
leg1_current.set(v);
leg1_bar.set_fraction(f64::from(v) / 100.0);
});
*anim.borrow_mut() = Some(id);
let leg2_bar = pbar.clone();
let leg2_current = current.clone();
let leg2_anim = anim.clone();
let leg2_token = token.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(OSD_LEG1_MS as u64), move || {
if leg2_token.get() != my_token {
return; // superseded by a newer OSD event before leg 1 finished
}
let inner_bar = leg2_bar.clone();
let id = bread_theme::anim::spring_to(&leg2_bar, overshoot, to, OSD_LEG2_MS, move |v| {
leg2_current.set(v);
inner_bar.set_fraction(f64::from(v) / 100.0);
});
*leg2_anim.borrow_mut() = Some(id);
});
}
fn create_window() -> gtk4::Window { fn create_window() -> gtk4::Window {
let window = gtk4::Window::new(); let window = gtk4::Window::new();
window.add_css_class("breadbar-osd"); window.add_css_class("breadbar-osd");
window.init_layer_shell(); window.init_layer_shell();
window.set_namespace(Some("breadbar-osd")); window.set_namespace(Some("breadbar-osd"));
crate::surface::apply(&window, "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); crate::theme::bind_auto(&window);
window window
} }

View file

@ -5,34 +5,13 @@
//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from //! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from
//! the right. //! the right.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gtk4::gdk::Key; use gtk4::gdk::Key;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use crate::{bind_layer_monitor, theme}; use crate::{bind_layer_monitor, theme, BAR_HEIGHT, BAR_MARGIN_SIDES, BAR_MARGIN_TOP};
/// Outside this rectangle's local x/y span, the dismiss window's own real const BELOW_BAR: i32 = BAR_MARGIN_TOP + BAR_HEIGHT + 8;
/// size (Wayland clips an input region to the surface's actual bounds, same
/// as `surface::set_hit_region`'s empty-region trick) — big enough to cover
/// any realistic monitor layout, including a negative-origin secondary
/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own
/// DVI-I-1). Centered on the origin so it's safe regardless of which way a
/// hole's coordinates end up signed.
const HOLE_CANVAS_SPAN: i32 = 20_000;
/// A boxed, ref-counted, optionally-unset click-away callback — see
/// `PanelSet::on_dismiss`'s own doc comment.
type DismissCallback = Rc<RefCell<Option<Rc<dyn Fn()>>>>;
/// The capsule-column hole punched in the dismiss scrim's input region —
/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc
/// comment. Shared (not just passed by value) so `make_dismiss`'s
/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin`
/// call agree on the current value.
type DismissHole = Rc<Cell<Option<(i32, i32, i32, i32)>>>;
#[derive(Clone)] #[derive(Clone)]
pub struct PanelSet { pub struct PanelSet {
@ -40,22 +19,6 @@ pub struct PanelSet {
pub control: gtk4::Window, pub control: gtk4::Window,
pub media: gtk4::Window, pub media: gtk4::Window,
dismiss: gtk4::Window, dismiss: gtk4::Window,
// Theme 04/spotlight's capsule (plan §7 phase 6c): an extra click-away
// callback invoked alongside the popover-dismiss path below, so the
// SAME `breadbar-dismiss` surface/click-catcher also collapses the
// capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and
// `set_on_dismiss`. `None` under every other theme (never set).
on_dismiss: DismissCallback,
// The rectangle (local to `dismiss`'s own surface coordinates) that
// should stay click-through even while the scrim otherwise covers the
// screen — `show_capsule_dismiss`'s own doc comment explains why this
// exists and how it's computed. `None` = no hole, the plain
// margin-based popover behaviour applies instead. Read inside
// `dismiss`'s own `connect_map` (the input region can only be set once
// the surface is real — see `surface::set_hit_region`'s doc comment for
// the same constraint) and, for the case where `dismiss` is already
// mapped from a prior show, applied immediately too.
dismiss_hole: DismissHole,
} }
impl PanelSet { impl PanelSet {
@ -68,16 +31,13 @@ impl PanelSet {
let connectivity = make_panel("wifi-popover", connectivity_child, monitor); let connectivity = make_panel("wifi-popover", connectivity_child, monitor);
let control = make_panel("control-panel", control_child, monitor); let control = make_panel("control-panel", control_child, monitor);
let media = make_panel("media-popover", media_child, monitor); let media = make_panel("media-popover", media_child, monitor);
let dismiss_hole: DismissHole = Rc::new(Cell::new(None)); let dismiss = make_dismiss(monitor);
let dismiss = make_dismiss(monitor, &dismiss_hole);
let set = Self { let set = Self {
connectivity, connectivity,
control, control,
media, media,
dismiss, dismiss,
on_dismiss: Rc::new(RefCell::new(None)),
dismiss_hole,
}; };
set.wire_dismiss(); set.wire_dismiss();
set.wire_escape(); set.wire_escape();
@ -94,10 +54,6 @@ impl PanelSet {
pub fn show(&self, which: &gtk4::Window) { pub fn show(&self, which: &gtk4::Window) {
self.hide_panels(); self.hide_panels();
// A prior capsule search (see `show_capsule_dismiss`) may have left
// the shared dismiss surface's top margin pushed down past its
// popover-shaped default — restore it before this popover uses it.
self.reset_dismiss_margin();
// Dismiss first so the panel maps above it (same Overlay layer). // Dismiss first so the panel maps above it (same Overlay layer).
self.dismiss.set_visible(true); self.dismiss.set_visible(true);
self.dismiss.present(); self.dismiss.present();
@ -116,82 +72,12 @@ impl PanelSet {
self.media.set_visible(false); self.media.set_visible(false);
} }
/// Theme 04/spotlight's capsule (plan §7 phase 6c): registers `cb` to
/// run whenever the shared dismiss surface is clicked, alongside the
/// popovers' own `hide_all`. `cb` is expected to no-op when the capsule
/// isn't actually open (matching `close_fn`'s own guard in main.rs), so
/// this firing on an ordinary popover click-away is harmless.
pub fn set_on_dismiss(&self, cb: impl Fn() + 'static) {
*self.on_dismiss.borrow_mut() = Some(Rc::new(cb));
}
/// Shows the dismiss scrim with its clickable region starting at
/// `top_margin` px from the screen top, rather than the theme's own
/// popover-shaped default. See the call site in main.rs's capsule
/// `open_fn` for why this needs to be at least the capsule's own row
/// height plus the drawer's maximum possible height: the dismiss
/// surface's layer (`overlay`) always renders above the bar's own
/// (`top`), so if its clickable region ever reached up into where the
/// drawer is actually drawn, it would swallow clicks meant for a
/// result row instead of forwarding them.
///
/// Before this fix, that safety was bought with a `set_margin` that
/// pushed the scrim's *entire width* down by `top_margin` — leaving a
/// full-screen-wide dead band above it (up to ~470px on a 1200px-tall
/// display) where a click neither dismissed nor hit anything else, the
/// "it only sometimes is dismissed when you click somewhere else"
/// report. `hole`, when known (local-to-this-surface x-start/width, in
/// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same
/// vertical safety margin but scopes the dead band to the capsule's
/// own column instead of the full width, so everywhere else in that
/// band is dismiss-clickable too. `None` (geometry unavailable, e.g.
/// `hyprctl` failed) falls back to the old full-width behaviour rather
/// than risk a hole in the wrong place.
pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) {
match hole {
Some((x, w)) if w > 0 => {
self.dismiss.set_margin(Edge::Top, 0);
self.dismiss_hole.set(Some((x, 0, w, top_margin)));
}
_ => {
self.dismiss.set_margin(Edge::Top, top_margin);
self.dismiss_hole.set(None);
}
}
apply_dismiss_hole(&self.dismiss, &self.dismiss_hole);
self.dismiss.set_visible(true);
self.dismiss.present();
}
/// Hides the dismiss scrim and restores its margin to the theme's own
/// popover default, so a later popover `show()` isn't left with a
/// leftover capsule-sized gap.
pub fn hide_dismiss(&self) {
self.reset_dismiss_margin();
self.dismiss.set_visible(false);
}
fn reset_dismiss_margin(&self) {
let theme = theme::shell_theme();
if let Some(surf) = theme.surfaces().get("breadbar-dismiss") {
let top = surf.offset.first().copied().unwrap_or(0.0) as i32;
self.dismiss.set_margin(Edge::Top, top);
}
// A stale capsule-shaped hole must not leak into a popover's own
// full-width dead zone.
self.dismiss_hole.set(None);
apply_dismiss_hole(&self.dismiss, &self.dismiss_hole);
}
fn wire_dismiss(&self) { fn wire_dismiss(&self) {
let set = self.clone(); let set = self.clone();
let click = gtk4::GestureClick::new(); let click = gtk4::GestureClick::new();
click.set_button(0); click.set_button(0);
click.connect_pressed(move |_, _, _, _| { click.connect_pressed(move |_, _, _, _| {
set.hide_all(); set.hide_all();
if let Some(cb) = set.on_dismiss.borrow().as_ref() {
cb();
}
}); });
if let Some(child) = self.dismiss.child() { if let Some(child) = self.dismiss.child() {
child.add_controller(click); child.add_controller(click);
@ -225,7 +111,11 @@ fn make_panel(class: &str, child: &impl IsA<gtk4::Widget>, monitor: &str) -> gtk
window.set_resizable(false); window.set_resizable(false);
window.init_layer_shell(); window.init_layer_shell();
window.set_namespace(Some("breadbar-panel")); window.set_namespace(Some("breadbar-panel"));
crate::surface::apply(&window, "breadbar-panel"); window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, BELOW_BAR);
window.set_margin(Edge::Right, BAR_MARGIN_SIDES);
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_keyboard_mode(KeyboardMode::OnDemand);
window.set_child(Some(child)); window.set_child(Some(child));
@ -235,20 +125,19 @@ fn make_panel(class: &str, child: &impl IsA<gtk4::Widget>, monitor: &str) -> gtk
window window
} }
fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window { fn make_dismiss(monitor: &str) -> gtk4::Window {
let window = gtk4::Window::new(); let window = gtk4::Window::new();
window.add_css_class("breadbar-dismiss"); window.add_css_class("breadbar-dismiss");
window.init_layer_shell(); window.init_layer_shell();
window.set_namespace(Some("breadbar-dismiss")); window.set_namespace(Some("breadbar-dismiss"));
// Overlay with the panels, but mapped first so they sit above it. // Overlay with the panels, but mapped first so they sit above it.
// Top margin keeps the island's chips clickable. // Top margin keeps the island's chips clickable.
// window.set_layer(Layer::Overlay);
// NOTE — deliberate, not a bug: this surface's top margin is 8px less window.set_anchor(Edge::Top, true);
// than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in window.set_anchor(Edge::Bottom, true);
// the active theme). The panels start 8px lower than the dismiss window.set_anchor(Edge::Left, true);
// scrim's clickable region. This predates Phase 2 and is preserved window.set_anchor(Edge::Right, true);
// exactly for pixel-identical rendering — do not "fix" this gap. window.set_margin(Edge::Top, BAR_MARGIN_TOP + BAR_HEIGHT);
crate::surface::apply(&window, "breadbar-dismiss");
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::None); window.set_keyboard_mode(KeyboardMode::None);
// An empty window never maps a hit region. A filling child + a hair of // An empty window never maps a hit region. A filling child + a hair of
@ -261,57 +150,5 @@ fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window {
bind_layer_monitor(&window, monitor); bind_layer_monitor(&window, monitor);
theme::bind_output(&window, monitor); theme::bind_output(&window, monitor);
window.set_visible(false); window.set_visible(false);
// The underlying `GdkSurface` (and therefore `window.surface()`, which
// `apply_dismiss_hole` needs) doesn't exist until the window is mapped
// — same constraint `surface::set_hit_region` documents. This surface
// gets hidden/shown repeatedly (every popover open/close, every
// capsule search), and GTK4 unmaps-then-remaps a toplevel each time
// its visibility toggles off then on, so re-applying here on every
// `map` (not just the first) is what keeps a freshly (re)shown surface
// honouring whatever hole was set before this particular `present()`.
{
let hole = Rc::clone(hole);
window.connect_map(move |win| apply_dismiss_hole(win, &hole));
}
window window
} }
/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole`
/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why.
/// Only takes effect once `dismiss.surface()` is real, i.e. the window is
/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in
/// `make_dismiss` re-runs this the moment that stops being true).
fn apply_dismiss_hole(dismiss: &gtk4::Window, hole: &DismissHole) {
let Some(surface) = dismiss.surface() else {
return;
};
match hole.get() {
Some((x, y, w, h)) => {
let canvas = gtk4::cairo::RectangleInt::new(
-HOLE_CANVAS_SPAN,
-HOLE_CANVAS_SPAN,
HOLE_CANVAS_SPAN * 2,
HOLE_CANVAS_SPAN * 2,
);
let region = gtk4::cairo::Region::create_rectangle(&canvas);
let punch = gtk4::cairo::RectangleInt::new(x, y, w, h);
if region.subtract_rectangle(&punch).is_ok() {
surface.set_input_region(Some(&region));
} else {
// Punching the hole failed for some reason (an invalid
// cairo status on a plain rectangle op, effectively
// unreachable in practice) — falling back to `None` (the
// protocol's documented "no input region set: whole
// surface hits") is still safer than leaving whatever
// region predates this call in place, which could be
// stale from a completely different mode (e.g. an old
// popover-shaped margin-only region with no hole at all).
eprintln!(
"breadbar: could not punch capsule hole in dismiss scrim's input region"
);
surface.set_input_region(None);
}
}
None => surface.set_input_region(None),
}
}

View file

@ -45,21 +45,6 @@ const KNOWN_VIEWS: &[&str] = &[
"osd-volume", "osd-volume",
"osd-brightness", "osd-brightness",
"wifi-add-dialog", "wifi-add-dialog",
// Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new
// match arms. "capsule-collapsed" captures the same region "bar"
// always has (bar_capture_height already reflects the active theme's
// own window height/margin); it exists as its own name purely so a
// verification script doesn't have to already know "bar" means "the
// capsule, collapsed" under spotlight specifically.
"capsule-collapsed",
"capsule-expanded",
// Phase 6c: query sections and the `=` calc mode — see `dispatch`'s
// two new match arms below. "capsule-expanded" already exercises the
// search-state width/radius change (item E: it types a query, which
// now also drives `open_fn`'s `.searching` root class + capsule-width
// spring animation), so that gap doesn't need its own view.
"capsule-sections",
"capsule-calc",
]; ];
#[derive(Parser)] #[derive(Parser)]
@ -133,59 +118,23 @@ pub struct Handles {
pub notification_window: Option<gtk4::Window>, pub notification_window: Option<gtk4::Window>,
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
pub osd_window: Option<gtk4::Window>, pub osd_window: Option<gtk4::Window>,
/// Theme 04/spotlight's capsule centre module — see the
/// "capsule-expanded" view, which focuses it and types a query to
/// drive the drawer open before capturing.
pub launcher_entry: gtk4::Entry,
/// The `drawer` slot's own container — read back after the open
/// animation settles so "capsule-expanded"'s capture height matches
/// however tall the results actually grew, rather than a guessed
/// constant.
pub drawer_box: gtk4::Box,
} }
/// Capture height for the `bar` view: layer-shell top margin + widget /// Capture height for the `bar` view: layer-shell top margin + widget
/// height (the exclusive zone). Unlike the other views' full canvas, /// height (the exclusive zone). Unlike the other views' full canvas,
/// this never varies with `--width`/`--height`. /// this never varies with `--width`/`--height`.
fn bar_capture_height() -> i32 { const BAR_HEIGHT: i32 = crate::BAR_HEIGHT + crate::BAR_MARGIN_TOP;
let window = crate::theme::shell_theme().window().clone();
window.height + window.margin.top
}
/// Y-origin for the `bar`/`capsule-collapsed` capture rectangle (axis 2,
/// daylight). Every theme through spotlight anchors top, so the bar's own
/// pixels always start at the canvas's own top edge (`y = 0`) — that's what
/// every existing call site here hardcoded. A bottom-anchored bar's real
/// on-screen footprint is instead the LAST `bar_capture_height()` pixels of
/// the canvas; capturing from `y = 0` unchanged grabs the isolated
/// compositor's empty background/gradient near the top of the output and
/// misses the actual bar entirely (confirmed empirically while verifying
/// this task — `bread-capture --app breadbar --view bar` under
/// `BREAD_SHELL_THEME=daylight` returned a plain gradient with no dock in
/// frame at all, before this fix). `canvas_height` is the capture canvas's
/// own height (`req.height`, i.e. `--height`/`isolate_height`), not this
/// crate's own bar height.
fn bar_capture_y(canvas_height: i32, bar_height: i32) -> i32 {
let window = crate::theme::shell_theme().window().clone();
if window.anchors.iter().any(|a| a == "bottom") {
(canvas_height - bar_height).max(0)
} else {
0
}
}
pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) { pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) {
let output = req.output; let output = req.output;
let (width, height) = (req.width as i32, req.height as i32); let (width, height) = (req.width as i32, req.height as i32);
let bar_height = bar_capture_height();
let bar_y = bar_capture_y(height, bar_height);
match req.view.as_str() { match req.view.as_str() {
"bar" => { "bar" => {
root.connect_map(move |_| { root.connect_map(move |_| {
let output = output.clone(); let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output)); finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output));
}); });
}); });
} }
@ -224,95 +173,6 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
}; };
capture_standalone_window(window, output, width, height); capture_standalone_window(window, output, width, height);
} }
"capsule-collapsed" => {
// Identical to "bar" — see KNOWN_VIEWS's doc comment on why
// this has its own name anyway.
root.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output));
});
});
}
"capsule-expanded" => {
// Focus + a real query, the same way a person opens the
// capsule (`connect_changed`/`EventControllerFocus::enter` in
// main.rs's capsule wiring do the rest: `open_fn` runs,
// `results.set_query` filters, and `animate_drawer_height`
// grows `drawer_box`). Capture height is bar height + however
// tall the drawer actually settled, not a hardcoded guess —
// this stays correct even if `dot_widths`/font/entry-count
// numbers change later.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("f");
let output = output.clone();
let drawer_box = drawer_box.clone();
// Past the 360ms spring_to run plus a normal capture
// settle, so the drawer's size_request (and the actual
// on-screen layer-shell surface it grows) has reached
// its final height before grabbing pixels.
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"capsule-sections" => {
// Focus with NO query typed — the idle browse view
// (`ResultsList::new`'s "Recent"/"Apps" header rows are visible
// from construction; `set_query` is what would hide them, and
// it's never called here). Same settle timing as
// "capsule-expanded", just without the `entry.set_text` step.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"capsule-calc" => {
// `=` mode (item C): the drawer's `mode_list` shows a single
// evaluated result row instead of `launcher_results.scroller`
// (see `populate_mode_list`'s `QueryKind::Calc` arm).
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("=6*7");
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"wifi-add-dialog" => { "wifi-add-dialog" => {
let anchor = handles.wifi_tab_btn; let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| { root.connect_map(move |_| {

View file

@ -1,166 +0,0 @@
//! Applies a `[surfaces.<namespace>]` entry (plan §4/§6, Phase 2) to a
//! satellite layer-shell window: anchor, margin (from `offset`), width and
//! layer. Deliberately narrow — it only understands the four anchor shapes
//! breadbar's four built-in surfaces actually use today ("breadbar-notif",
//! "breadbar-osd", "breadbar-panel", "breadbar-dismiss": `top_right`,
//! `bottom_right`, `bottom_centre`, `fill`), not a general anchor DSL (the
//! plan's own anti-goal, §2). `bottom_right` was added for daylight (plan
//! §11 phase 7, axis 2) — the first bottom-anchored bar, whose satellites
//! need to hug the bottom-right corner the way every top-anchored theme's
//! already hug the top-right one. `exclusive` zone and `keyboard` mode
//! aren't part of the `[surfaces.*]` schema (`bread_theme::shell::Surface`
//! has no such fields) and stay hardcoded at each call site, same as before
//! this refactor.
use bread_theme::shell::SurfaceWidth;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
/// `namespace` should be a key in the active theme's `[surfaces.*]` table —
/// every call site in this crate passes one of breadbar's own namespace
/// literals, so a miss here means the active theme fell out of sync with
/// the Rust source, not a bad runtime value. Logs and leaves the window at
/// gtk4-layer-shell's own defaults rather than panicking, matching every
/// other "malformed/incomplete theme" fallback in this system.
///
/// The width applied here is not authoritative for a namespace shared by
/// more than one window with genuinely different widths (`breadbar-notif`'s
/// live toast is 320px, its history sibling is 360px, and only the toast's
/// width is modeled in `[surfaces.*]` — see the Phase 0 constant inventory);
/// callers that need a different width than the theme's own set it
/// explicitly afterward. Because a `Px` width is pinned with BOTH
/// `set_default_width` and `set_size_request` (see below — the latter is
/// what actually holds against a wide child), such a caller must override
/// both, not just `set_default_width`, or the pin from here wins.
pub fn apply(window: &gtk4::Window, namespace: &str) {
let theme = crate::theme::shell_theme();
let Some(surf) = theme.surfaces().get(namespace) else {
eprintln!(
"breadbar: no [surfaces.{namespace}] entry in the active theme; \
window left at layer-shell defaults"
);
return;
};
window.set_layer(if surf.layer == "top" {
Layer::Top
} else {
Layer::Overlay
});
match surf.anchor.as_str() {
"top_right" => {
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
// offset = [right, top] for this anchor shape.
let right = surf.offset.first().copied().unwrap_or(0.0) as i32;
let top = surf.offset.get(1).copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Right, right);
window.set_margin(Edge::Top, top);
}
// Axis 2 (daylight): every theme through spotlight anchors its bar
// to the TOP, so `top_right` always put breadbar-notif/
// breadbar-panel naturally close to the bar. A bottom-anchored bar
// has nothing in the original three shapes that keeps its
// satellites near it — `top_right` would land them at the opposite
// corner of the screen from the dock they belong to. Mirrors
// `top_right` exactly, just on the bottom edge.
"bottom_right" => {
window.set_anchor(Edge::Bottom, true);
window.set_anchor(Edge::Right, true);
// offset = [right, bottom], same convention as top_right's
// [right, top].
let right = surf.offset.first().copied().unwrap_or(0.0) as i32;
let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Right, right);
window.set_margin(Edge::Bottom, bottom);
}
"bottom_centre" => {
window.set_anchor(Edge::Bottom, true);
let bottom = surf.offset.first().copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Bottom, bottom);
}
"fill" => {
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
// offset = [top, bottom] — a fullscreen click-away scrim that
// leaves a gap clear of the bar on whichever edge the bar
// actually anchors to. Every theme through spotlight anchors
// top, so only `offset[0]` (top) was ever meaningful before
// daylight; a single-value `offset` (every existing theme's
// manifest) still means exactly what it always did, since
// `offset.get(1)` falls back to 0 — a bottom-anchored theme is
// the first to give this a real, nonzero bottom value instead.
let top = surf.offset.first().copied().unwrap_or(0.0) as i32;
let bottom = surf.offset.get(1).copied().unwrap_or(0.0) as i32;
window.set_margin(Edge::Top, top);
window.set_margin(Edge::Bottom, bottom);
}
other => eprintln!(
"breadbar: surfaces.{namespace}.anchor = \"{other}\" is not one of \
top_right|bottom_right|bottom_centre|fill breadbar's satellite \
windows don't understand any other shape yet, leaving this window \
unanchored"
),
}
if let SurfaceWidth::Px(px) = surf.width {
window.set_default_width(px);
// set_default_width alone is only a preference — a wide child (an
// unwrapped app-name label, or a long summary/body before GTK has
// any allocation narrower than its natural width to wrap against)
// overrides it, so the window renders wider than the theme's
// requested px and stops matching the theme. Same trap, same fix,
// as main.rs's capsule `Width::Px` handling — see its comment.
window.set_size_request(px, -1);
}
}
/// Sets `window`'s input region to the union of `widgets`' current
/// allocations, each measured relative to `window` itself (the surface's
/// own coordinate space, same as `bar::workspaces::button_geom`'s own
/// `compute_bounds` call relative to its Fixed host) — everywhere else on
/// the surface stays click-through. An empty (or all-invisible/all-
/// unallocated) `widgets` slice is not a special case:
/// `Region::create_rectangles(&[])` is already the fully click-through
/// empty region — passing `&[]` here is exactly the old blanket
/// `Region::create()` this function replaces (see git history around
/// `notifications/popup.rs`'s "stop toast popups from stealing focus or
/// blocking clicks" fix, and NOTIFICATION INTERACTION #B in the current
/// task notes: a toast must never block clicks or steal focus from
/// whatever's underneath it EXCEPT on its own buttons — an all-empty
/// region made those unreachable too).
///
/// Only meaningful after `window.surface()` exists (i.e. from
/// `connect_map` onward — the surface doesn't exist before the window is
/// mapped) and after `widgets` have a real allocation — a widget with no
/// allocation yet (`compute_bounds` returning `None`) is simply skipped
/// rather than contributing a garbage rectangle, so a call made one frame
/// too early just yields a smaller-than-intended region for that one frame
/// rather than a wrong one.
///
/// Callers are responsible for RECOMPUTING this every time the hittable
/// set could have moved: a widget added or removed, or a layout pass (an
/// entrance animation, a push-down reflow) still in flight. A stale region
/// either swallows clicks meant for the window below or leaves a real
/// button dead.
pub fn set_hit_region(window: &gtk4::Window, widgets: &[gtk4::Widget]) {
let Some(surface) = window.surface() else {
return;
};
let rects: Vec<gtk4::cairo::RectangleInt> = widgets
.iter()
.filter(|w| w.is_visible())
.filter_map(|w| {
let b = w.compute_bounds(window)?;
Some(gtk4::cairo::RectangleInt::new(
b.x().floor() as i32,
b.y().floor() as i32,
b.width().ceil() as i32,
b.height().ceil() as i32,
))
})
.collect();
surface.set_input_region(Some(&gtk4::cairo::Region::create_rectangles(&rects)));
}

View file

@ -1,53 +1,10 @@
use bread_theme::shell::ShellTheme;
use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette}; use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette};
use gtk4::prelude::IsA; use gtk4::prelude::IsA;
use gtk4::CssProvider; use gtk4::CssProvider;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc;
thread_local! { thread_local! {
static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) }; static USER_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
// Loaded lazily on first access and cached — the underlying
// `bread_theme::shell::load()` call happens at most once per process,
// not once per read site (plan §5/§6, Phase 2). Stored as an `Rc` so
// window/surface-geometry call sites elsewhere in the crate can hold a
// cheap clone rather than re-reading the cell each time.
static SHELL_THEME: RefCell<Rc<ShellTheme>> =
RefCell::new(Rc::new(bread_theme::shell::load()));
}
/// The active shell theme — window geometry, `[surfaces.*]`, and CSS tokens
/// (plan §5). Every consumer (this module's own `load_css`, plus main.rs,
/// osd.rs, panel.rs, notifications/, and `surface::apply`) reads through
/// this single shared instance instead of calling `bread_theme::shell::load()`
/// itself.
pub fn shell_theme() -> Rc<ShellTheme> {
SHELL_THEME.with(|cell| cell.borrow().clone())
}
/// Replaces the shared shell theme in place. Only used by the optional
/// `theme.toml` hot-reload watch (see `watch_hot_reload` below) — per plan
/// §10, a window-spec change (anchors, margins, exclusive zone, keyboard)
/// still needs a restart to take effect, since those are read once at
/// window-construction time; only CSS token values re-resolve live, the
/// next time `load_css` runs.
pub fn set_shell_theme(theme: ShellTheme) {
SHELL_THEME.with(|cell| *cell.borrow_mut() = Rc::new(theme));
}
/// The one bar-chip height every chip in the row shares (vol/wifi/battery/
/// menu/media, and the workspace pills for Trail/Pill styles) — see the
/// `chip_h` local in `load_css` for why this is a hardcoded per-`
/// WorkspaceStyle` override rather than `Tokens::chip_height()`. Used both
/// by that CSS and by `bar::workspaces::make_button`'s `set_size_request`,
/// which otherwise still forces the stale token value as a hard GTK
/// minimum that a CSS `min-height` alone cannot out-rank.
pub fn approved_chip_height(style: bread_theme::shell::WorkspaceStyle) -> i64 {
use bread_theme::shell::WorkspaceStyle::*;
match style {
Trail => 26,
Pill | Dots => 22,
}
} }
fn load_css() -> String { fn load_css() -> String {
@ -62,284 +19,11 @@ fn load_css() -> String {
// Hyprland `layerrule = blur, breadbar` frosts the translucent fills — // Hyprland `layerrule = blur, breadbar` frosts the translucent fills —
// the CSS just leaves alpha. Colours are bread-theme tokens so pywal // the CSS just leaves alpha. Colours are bread-theme tokens so pywal
// accents (`@accent`) flow through on SIGHUP / `bread-theme reload`. // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`.
// let radius = "12px";
// These ~250 lines are breadbar-specific chrome (notifications, wifi let radius_bar = "16px";
// popover, control panel, media widget) that `ShellTheme::css()` does let radius_sm = "9px";
// not template — only the window/workspace/clock chrome the manifest's let radius_pill = "999px";
// own concepts model does (plan §6 scope note). This function stays let pad = "12px";
// hand-written CSS; it now just reads its radius/pad/easing numbers from
// the theme's tokens instead of hardcoding them.
let theme = shell_theme();
let tokens = theme.tokens();
let radius = format!("{}px", tokens.radius_card());
let radius_bar = format!("{}px", tokens.radius_bar());
let radius_sm = format!("{}px", tokens.radius_sm());
let radius_pill = format!("{}px", tokens.radius_pill());
let pad = format!("{}px", tokens.pad());
// Two curves, not one: `spring` is the overshoot/bounce curve (clock
// flips, pop-ins, the workspace caret draw); `spring_settle` is the
// flatter curve used for hovers and background/opacity transitions.
// Do not collapse these — they read differently and cover different
// sites below (see the Phase 0 constant inventory).
let spring = tokens.spring();
let spring_settle = tokens.spring_settle();
let bg_alpha = tokens.bg_alpha();
// Palette token NAME (never hex — see every builtin theme.toml's own
// comment on this), used below by the dots/launcher-entry/drawer rules.
// liquid-motion/glass-workbench never render those (see the match arms
// and unconditional-but-unused block below), so this being "accent" vs
// "green" vs "pink" per theme has no visible effect on them.
let accent_from = tokens.accent_from();
// `accent_to` (Trail's own gradient end stop) and `accent2` (a second,
// independent accent — daylight's amber equaliser, distinct from its
// teal workspace-trail) — both palette token names, never hex, same
// reasoning as `accent_from` above.
let accent_to = tokens.accent_to();
let accent2 = tokens.accent2();
// Axis 1 (daylight, plan §11 phase 7): `tokens.light()` — see that
// method's doc comment (bread-theme) for the full reasoning. Every
// surface/ink pair in this stylesheet through spotlight hardcoded
// `@bg` (a FIXED, never-pywal-derived dark hex — see
// `bread_theme::palette`'s `FIXED_BACKGROUND`) as the translucent
// surface fill and `@on-bg` (that fixed dark colour's computed-legible,
// therefore always near-white, ink) as the text/wash colour. That is
// exactly backwards for an ink-on-paper theme: `panel`/`ink` swap which
// of those two FIXED, anti-correlated tokens plays which role. This is
// NOT a general "pick any light surface colour" mechanism — it works
// only because `@bg` is pinned dark and `@on-bg` is its computed
// opposite, by construction, regardless of pywal. See the task report
// for the full inventory of every site this swap had to reach.
let light = tokens.light();
let (panel, ink): (&str, &str) = if light { ("@on-bg", "@bg") } else { ("@bg", "@on-bg") };
// The notification/history/OSD/wifi-add-dialog cards (0.70) and the
// control/wifi/media popover window (0.72) hardcode their own alpha
// literals independent of `tokens.bg_alpha()` — reasonable for a dark
// "glass" surface (0.70-0.72 alpha over a dark fill still reads as
// deliberately translucent glass), but daylight's demo draws these as
// fully OPAQUE paper (`.note`/`.osd { background: var(--paper) }`, no
// alpha at all) — at the old 0.70 literal, daylight's near-white
// `{panel}` fill instead reads as pale, background-tinted glass, which
// is exactly the "physical object" read the design brief calls for
// NOT doing. Swapping these two literals wholesale to `{bg_alpha}`
// would also move liquid-motion/glass-workbench/spotlight's own
// rendering (0.70/0.72 -> 0.72/0.82 for spotlight) — a real, visible
// regression the task rules out — so this only substitutes `bg_alpha`
// (0.94 for daylight) in for `light` themes and keeps the exact prior
// literal for every other theme.
let card_alpha = if light { bg_alpha } else { 0.70 };
let panel_surface_alpha = if light { bg_alpha } else { 0.72 };
// The OSD/widget-node progress troughs' UNFILLED track is
// `alpha(@accent, 0.25)` — a teal-at-25%-alpha tint that reads as a
// faint, visible track against a dark pill (every theme through
// spotlight), but is nearly indistinguishable from daylight's own
// near-white pill (confirmed empirically: the OSD volume slider's
// empty track was barely visible against its own paper background).
// `{ink}`-based for light themes gives a neutral faint-dark track
// instead, independent of whatever hue the accent happens to be;
// every other theme's literal `alpha(@accent, 0.25)` is unchanged.
let trough_bg = if light {
format!("alpha({ink}, 0.14)")
} else {
"alpha(@accent, 0.25)".to_string()
};
// `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode::
// Embedded` only (spotlight); `.launcher().radius` itself already
// equals `radius_bar` for that theme (see its own theme.toml comment),
// so a theme that omits `search_radius` gets `radius_search ==
// radius_bar` here too, i.e. no visible shrink, matching bread-theme's
// own "default to the idle value" fallback.
let radius_search = format!("{}px", theme.launcher().search_radius);
// `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid-
// motion's floating island) draws a border on all four edges; "bottom"
// (glass-workbench's flush edge-to-edge bar) draws only the hairline
// the demo's `.bar { border-bottom: 1px solid #ffffff12 }` calls for —
// a full border on a bar flush against the screen's top/left/right
// edges would otherwise show as a stray line along those edges an
// island never has to worry about. Reused below for the centerbox's
// horizontal padding too: the flush bar's demo padding (`0 12px`,
// symmetric) differs from the island's own asymmetric `0 8px 0 6px`.
let flush = tokens.bar_border() == "bottom";
// Axis 3 (daylight, plan §11 phase 7): `bar_border == "segmented"` —
// `window.breadbar` itself draws NO fill/border/radius/shadow at all
// (fully transparent); the bar's three slot-group containers
// (`workspace_row`/`center_area`/`stats_box`, each carrying a
// `.bar-segment` class added unconditionally in main.rs) draw their own
// pill surfaces instead — see `segment_css` below. This is what lets
// one GTK window read as three detached floating pills rather than one
// continuous strip; see the task report for exactly what this can and
// can't express (the window is still ONE input-hit-testable surface —
// clicking in a transparent gap between pills does not click through to
// whatever's behind the bar, only true multi-window segmentation would
// do that).
let segmented = tokens.bar_border() == "segmented";
let window_chrome = if segmented {
"background-color: transparent; border: none; box-shadow: none;".to_string()
} else if flush {
format!(
"background-color: alpha({panel}, {bg_alpha}); border: none; \
border-bottom: 1px solid alpha({ink}, 0.07);"
)
} else {
format!("background-color: alpha({panel}, {bg_alpha}); border: 1px solid alpha({ink}, 0.08);")
};
let bar_radius = if segmented {
"0px".to_string()
} else {
radius_bar.clone()
};
let centerbox_padding = if flush || segmented { "0 14px" } else { "0 8px 0 6px" };
// The three detached pills themselves — a no-op empty rule under every
// non-segmented theme, so `.bar-segment` (added unconditionally to all
// three slot-group boxes in main.rs) renders nothing extra for them.
// Demo: `.seg { background: rgba(255,255,255,.94); border: 1px solid
// rgba(26,29,34,.10); border-radius: 14px;
// box-shadow: 0 2px 10px rgba(26,29,34,.13), 0 0 0 .5px rgba(255,255,255,.7) inset }`.
// Safe to pair a real box-shadow with an unblurred surface (axis 4:
// daylight's own `[compositor.breadbar].blur = false`) — a box-shadow
// above a BLURRED, `ignore_alpha`d surface is the shadow-halo bug fixed
// in breadbox by removing the shadow outright; with blur off there is
// no blur pass to catch this rectangle inside.
let segment_css = if segmented {
format!(
".bar-segment {{ background-color: alpha({panel}, {bg_alpha}); \
border: 1px solid alpha({ink}, 0.10); border-radius: {radius_bar}; \
box-shadow: 0 2px 10px alpha({ink}, 0.13); }}\
"
)
} else {
String::new()
};
// Radius for `.stat-pair` (vol/wifi/battery/hamburger chips): radius_sm
// for liquid-motion (9px) and glass-workbench (6px, exact match to that
// demo's `.chip` radius) reads as "the same small-control rounding this
// theme uses everywhere else" — but spotlight's overall language is
// dramatically rounder (radius_bar 22px, workspace dots at radius_pill
// 999px) than either sibling theme, so its one `.stat-pair` occupant
// (battery — the only slot entry besides a Lua widget under
// `[bar.slots].right`) looked like a stray sharp-cornered rectangle
// dropped inside a capsule and next to fully-round dots (reported:
// "the spotlight battery chip... radii that don't match their
// neighbours"). Keying off the same `WorkspaceStyle` enum
// `workspace_css` below already switches on, rather than the theme id,
// so this stays in step if a future theme ever reuses the "dots" style.
let chip_radius = match theme.modules().workspaces.style {
bread_theme::shell::WorkspaceStyle::Dots => radius_pill.clone(),
_ => radius_sm.clone(),
};
// ONE chip highlight height per bar, vertically centred — every chip
// (vol/wifi/battery/menu/media, icon-only and labelled alike) shares
// it so their fills align, instead of each sizing to its own content
// box (reported: battery sits high, wifi/menu are taller than their
// neighbours). Liquid Motion 26px / Glass Workbench 22px / Spotlight
// 22px, per the approved demo spec.
//
// HARDCODED, not `tokens.chip_height()`: that token is `breadbar::
// CHIP_HEIGHT` (32) carried over from before this design pass and was
// never updated for the three builtin `theme.toml`s (32/20/36) — it
// predates and disagrees with the demo numbers above. bread-ecosystem
// is owned by a sibling agent this pass, so this stays a local
// override (same `WorkspaceStyle` this file already keys `chip_radius`
// off) rather than an edit to that repo's schema/manifests. Flagged in
// the task report: `chip_height` should become 26/22/22 upstream.
let chip_h = approved_chip_height(theme.modules().workspaces.style);
let chip_height_px = format!("{chip_h}px");
// `modules.workspaces.style` (plan §11 Phase 5): "trail" (default,
// liquid-motion) is exactly today's CSS, unchanged byte-for-byte —
// dimmed/translucent buttons with the gradient trail overlay supplying
// the active fill. "pill"/"dots" (glass-workbench, Phase 6+) render the
// active state as a solid accent fill on the button itself instead,
// since neither style ever calls `WorkspaceTrail::place`/`stretch`
// (see `App::rebuild_buttons`) — the trail's own `.workspace-trail`
// pill CSS is therefore irrelevant for them (it's never made visible).
let workspace_css = match theme.modules().workspaces.style {
// Radius and height were hardcoded (12px, 28px) instead of reading
// from this theme's own tokens/demo — 12px is neither radius_sm
// (9px) nor any other token this theme defines, and the demo's
// `.ws-btn`/`.trail` both draw a 26px-tall, 9px-radius pill (not
// 28px/12px). radius_sm happens to be an exact match for the
// demo's 9px here, unlike glass-workbench's Pill style below where
// it's also used but for a different, already-correct reason.
// Reported: "the pills on liquid motion just look off".
bread_theme::shell::WorkspaceStyle::Trail => format!(
// `@{{accent_from}}`/`@{{accent_to}}`, not the literal
// `@accent`/`@teal` this hardcoded through spotlight: harmless
// while every Trail-style theme's own accent_from/accent_to
// happened to BE "accent"/"teal" (liquid-motion — the only
// other Trail theme so far), but daylight sets
// accent_from = accent_to = "teal" for a FLAT fill, and the old
// hardcode would have painted a stray blue-to-teal gradient
// over it regardless of that setting. See Tokens::accent_to's
// doc comment (bread-theme) — this is the fix that finally
// consumes it for real.
".workspace-trail {{ background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});\
background-color: @{accent_from}; border-radius: {radius_sm}; }}\
.workspace-btn {{ background: transparent; opacity: 0.36; color: {ink};\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
min-width: 28px; min-height: {chip_height_px}; margin: 0; padding: 0 7px;\
font-size: 22px; font-weight: bold;\
transition: opacity 0.22s {spring_settle},\
background-color 0.22s {spring_settle}; }}\
.workspace-btn:hover {{ opacity: 0.85; background: alpha({ink}, 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 {spring_settle} both; }}",
),
bread_theme::shell::WorkspaceStyle::Pill => {
let accent = theme.tokens().accent_from();
format!(
".workspace-btn {{ background: transparent; opacity: 1; color: alpha({ink}, 0.4);\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\
min-width: 22px; min-height: {chip_height_px}; margin: 0; padding: 0 6px;\
font-size: 12px; font-weight: 600;\
transition: background-color 0.22s {spring_settle},\
color 0.22s {spring_settle}, opacity 0.22s {spring_settle}; }}\
.workspace-btn:hover {{ background: alpha({ink}, 0.08); }}\
.workspace-btn.occupied {{ color: alpha({ink}, 0.8); }}\
.workspace-btn:not(.occupied):not(.active) {{ opacity: 0.35; }}\
.workspace-btn.active {{ background: @{accent}; color: @on-accent; opacity: 1; }}\
.workspace-btn.active:hover {{ background: @{accent}; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
)
}
// "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes
// from `modules.workspaces.dot_widths` and is set directly via
// `Widget::set_size_request` in `bar::workspaces::make_dot_button`
// — GTK CSS has no per-instance variable width, so unlike the demo's
// `.dots button[data-n="N"]` rules this class only supplies colour/
// opacity/radius, never a width. `04-spotlight.html`'s own base
// rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the
// bar's ink colour — approximated here as a low-alpha `@on-bg` fill
// so it still tracks pywal instead of hardcoding a hex that would
// clash with a light palette.
bread_theme::shell::WorkspaceStyle::Dots => {
let accent = theme.tokens().accent_from();
format!(
// 9px, not the demo's 6px — kept in sync with
// `bar::workspaces::make_dot_button`'s `DOT_HEIGHT` const,
// which is the value that actually governs the rendered
// size (a direct `set_size_request`, not CSS min-height
// participating in layout the normal way) — see that
// constant's own doc comment for why. This min-height
// exists mainly so the property isn't silently absent from
// the stylesheet a reader would expect to define it.
".workspace-dot {{ background-color: alpha({ink}, 0.35); color: transparent;\
border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\
min-height: 9px; margin: 0; padding: 0;\
transition: background-color 0.25s {spring_settle},\
opacity 0.25s {spring_settle}; }}\
.workspace-dot:hover {{ background-color: alpha({ink}, 0.55); }}\
.workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\
.workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\
.workspace-dot.active:hover {{ background-color: @{accent}; }}",
)
}
};
format!( format!(
"@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\ "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
@ -350,113 +34,54 @@ fn load_css() -> String {
@keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\ @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 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; }} }}\ @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
/* ANIMATION WORK #3, bar entrance on first map: opacity ONLY —\ window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
no margin/geometry term so this can never perturb any\ border-radius: {radius_bar}; border: 1px solid alpha(@on-bg, 0.08); }}\
descendant's own box-model size (see main.rs's own long\ window.breadbar > centerbox {{ padding: 0 8px 0 6px; }}\
comment on this, next to where `bar-entrance` gets added, for\ window.breadbar button {{ min-height: 0; min-width: 0; }}\
why that matters to the workspace trail specifically).\ .workspace-trail {{ background-image: linear-gradient(90deg, @accent, @teal);\
Liquid Motion additionally springs the surface's own\ background-color: @accent; border-radius: 12px; }}\
layer-shell top margin via `anim::spring_to` in Rust, which\ .workspace-btn {{ background: transparent; opacity: 0.36; color: @on-bg;\
this keyframe knows nothing about; glass-workbench never adds\ border-radius: 12px; border: none; outline: none; box-shadow: none;\
this class at all. */\ min-width: 28px; min-height: 28px; margin: 0; padding: 0 7px;\
@keyframes bar-in {{ from {{ opacity: 0; }} }}\ font-size: 22px; font-weight: bold;\
.bar-entrance {{ animation: bar-in 0.4s {spring_settle} both; }}\ transition: opacity 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
window.breadbar {{ color: {ink}; border-radius: {bar_radius}; {window_chrome}\ background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
transition: border-radius 0.3s {spring_settle}; }}\ .workspace-btn:hover {{ opacity: 0.85; background: alpha(@on-bg, 0.08); }}\
/* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\ .workspace-btn.occupied {{ opacity: 0.78; }}\
`launcher_entry` never gets focus under any other theme, so\ .workspace-btn.active {{ background: transparent; color: @on-accent; opacity: 1; }}\
`.searching` never lands on `window.breadbar` there). */\ .workspace-btn.active:hover {{ background: transparent; }}\
window.breadbar.searching {{ border-radius: {radius_search}; }}\ .workspace-btn.ws-in {{ animation: row-in 0.32s cubic-bezier(0.22, 1.2, 0.36, 1) both; }}\
/* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\
row + drawer, plan §2) as of the `drawer` slot wiring every\
theme's centerbox is now one level deeper than before, this\
selector just follows it there. Since `padding` doesn't depend\
on nesting depth, liquid-motion/glass-workbench render byte-\
identical CSS either way. */\
window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\
/* `color` here for the same reason `window.breadbar-panel button`\
(below) needs it: a real GtkButton's own text (the bar's\
hamburger, `.control-panel-btn`) doesn't inherit `color` from\
this window the shared, ecosystem-wide `button {{ color:\
@on-surface }}` rule (lib.rs) matches it directly first. See\
that rule's own comment for the full explanation. */\
window.breadbar button {{ min-height: 0; min-width: 0; color: {ink}; }}\
{segment_css}\
{workspace_css}\
.clock-box {{ padding: 0 4px; }}\ .clock-box {{ padding: 0 4px; }}\
.clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ .clock-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-height: 0; padding: 0; margin-top: 3px; }}\ min-height: 0; padding: 0; margin-top: 3px; }}\
.clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\ .clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\ min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\
.clock-colon {{ min-width: 10px; opacity: 0.7; }}\ .clock-colon {{ min-width: 10px; opacity: 0.7; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\ .clock-digit.flip {{ animation: digit-flip 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.clock-plain {{ padding: 0 4px; }}\ .date-label {{ font-size: 14px; opacity: 0.52; letter-spacing: 0.04em; }}\
.clock-plain-time {{ font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }}\
.date-label {{ font-size: 12px; opacity: 0.48; letter-spacing: 0.04em; }}\
.stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\ .stat-label {{ font-size: 14px; letter-spacing: 0.02em; opacity: 0.92; }}\
.stat-label.tick {{ animation: digit-flip 0.35s {spring} both; }}\ .stat-label.tick {{ animation: digit-flip 0.35s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
/* Odometer digit chips (volume/battery, ANIMATION WORK #2): one\
`.stat-label` per character instead of one label for the whole\
number, each with a fixed min-width so a `9` -> `10` or\
`8` -> `9` transition doesn't jitter the chip's overall width\
as narrower/wider glyphs swap in. `.flip` reuses the exact\
`digit-flip` keyframe + timing the clock's `.clock-digit.flip`\
already plays. */\
.stat-digit {{ min-width: 9px; padding: 0; margin: 0; }}\
.stat-digit.flip {{ animation: digit-flip 0.35s {spring} both; }}\
.stats-box {{ margin-right: 0; }}\ .stats-box {{ margin-right: 0; }}\
/* Radius was a hardcoded 10px here regardless of theme — right by\ .stat-pair {{ margin: 0; border-radius: 10px; padding: 5px 9px; min-height: 0;\
coincidence for liquid-motion's demo (`.chip {{ border-radius:\ transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
10px }}`, this theme's radius_sm is 9px, a 1px rounding-off),\
wrong for glass-workbench (demo's `.chip` is 6px, exactly this\
theme's radius_sm the hardcoded 10px never matched it), and\
wrong-in-spirit for spotlight even though no `.chip` class\
exists in that demo to compare against: a small, sharp-ish\
radius reads as a stray rectangle inside a 22px-radius capsule\
sitting right next to 999px-radius workspace dots (reported:\
spotlight's battery chip not matching its neighbours). See\
`chip_radius` above radius_sm for the other two themes,\
radius_pill for spotlight, so every theme's stat chips round\
the way that theme's *other* rounded chrome already does,\
instead of all three sharing one borrowed hardcoded number. */\
/* min-height (not the old `min-height: 0`): decision #1 — ONE\
chip highlight height per bar, vertically centred, shared by\
every chip so their fills align instead of each sizing to its\
own content box (reported: battery sat high, wifi/menu were\
taller than their neighbours). See `chip_height_px` above. */\
.stat-pair {{ margin: 0; border-radius: {chip_radius}; padding: 5px 9px;\
min-height: {chip_height_px};\
transition: background-color 0.22s {spring_settle},\
opacity 0.18s ease; }}\ opacity 0.18s ease; }}\
.stat-pair:hover {{ background: alpha({ink}, 0.12); }}\ .stat-pair:hover {{ background: alpha(@on-bg, 0.12); }}\
.stat-pair:active {{ background: alpha({ink}, 0.18); }}\ .stat-pair:active {{ background: alpha(@on-bg, 0.18); }}\
/* No border-radius override here (was a hardcoded 999px, making\ .stat-pair.icon-only {{ padding: 4px; border-radius: 999px;\
wifi/hamburger the only two `.icon-only` chips fully\ min-width: 32px; min-height: 32px; }}\
circular while their row neighbours vol/battery stayed a\
rounded rect at `.stat-pair`'s own radius: a visible rounding\
mismatch inside one row, reported against the liquid-motion\
hamburger specifically). Every demo's `.chip` class (liquid-\
motion, glass-workbench) draws vol/wifi/bat/menu identically,\
none of them circular dropping the override here just lets\
`.stat-pair`'s own `chip_radius` cascade through unchanged, so\
the icon-only chips match their siblings instead of standing\
out (spotlight has no icon-only chip today, but would get the\
same pill radius as its one `.stat-pair` sibling if it ever did). */\
.stat-pair.icon-only {{ padding: 4px;\
min-width: {chip_height_px}; min-height: {chip_height_px}; }}\
.stat-icon {{ margin-right: 6px; }}\ .stat-icon {{ margin-right: 6px; }}\
.stat-pair.icon-only .stat-icon {{ margin: 0; }}\ .stat-pair.icon-only .stat-icon {{ margin: 0; }}\
.bt-icon {{ margin-right: 8px; }} .bt-icon {{ margin-right: 8px; }}
separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\ separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\
background: alpha({ink}, 0.10); }}\ background: alpha(@on-bg, 0.10); }}\
window.breadbar-notification {{ background-color: transparent; color: {ink}; }}\ window.breadbar-notification {{ background-color: transparent; color: @on-bg; }}\
window.breadbar-history {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ window.breadbar-history {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s {spring} both; }}\ animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.notification-card {{ background: alpha({panel}, {card_alpha}); color: {ink}; border-radius: {radius};\ .notification-card {{ background: alpha(@bg, 0.70); color: @on-bg; border-radius: {radius};\
padding: {pad}; margin-bottom: 8px; border: 1px solid alpha({ink}, 0.10);\ padding: {pad}; margin-bottom: 8px; border: 1px solid alpha(@on-bg, 0.10);\
border-left: 3px solid transparent;\ border-left: 3px solid transparent;\
animation: notif-in 0.45s {spring_settle} both; }}\ 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-critical {{ border-left-color: @red; }}\
.notification-card.urgency-normal {{ border-left-color: @accent; }}\ .notification-card.urgency-normal {{ border-left-color: @accent; }}\
.notification-summary {{ font-weight: bold; }}\ .notification-summary {{ font-weight: bold; }}\
@ -465,69 +90,39 @@ fn load_css() -> String {
.notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\ .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\
.notification-reply {{ margin-top: 6px; }}\ .notification-reply {{ margin-top: 6px; }}\
.notification-reply-entry {{ min-width: 0; }}\ .notification-reply-entry {{ min-width: 0; }}\
/* NOTIFICATION INTERACTION #A: a direct dismiss control, floated\
in the card's top-right corner via an Overlay (see popup.rs's\
`make_card`) rather than a full extra header row, so it doesn't\
add vertical bulk the approved demo's own card never has. */\
.notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\
margin: 2px; border-radius: {radius_pill}; background: transparent;\
color: {ink}; opacity: 0.45; font-size: 12px; font-weight: bold;\
border: none; outline: none; box-shadow: none;\
transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\
.notification-dismiss:hover {{ opacity: 1; background: alpha({ink}, 0.16); }}\
.notification-dismiss:active {{ background: alpha({ink}, 0.24); }}\
.history-title {{ font-weight: bold; font-size: 13px; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\
.history-close {{ padding: 2px 8px; }}\ .history-close {{ padding: 2px 8px; }}\
.history-empty {{ opacity: 0.5; padding: 8px 0; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\
.history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-time {{ opacity: 0.5; font-size: 11px; }}\
.history-body {{ opacity: 0.75; }}\ .history-body {{ opacity: 0.75; }}\
.history-card {{ margin-bottom: 6px; }}\ .history-card {{ margin-bottom: 6px; }}\
window.breadbar-osd {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ window.breadbar-osd {{ background-color: alpha(@bg, 0.70); color: @on-bg;\
border-radius: {radius_pill}; border: 1px solid alpha({ink}, 0.10);\ border-radius: {radius_pill}; border: 1px solid alpha(@on-bg, 0.10);\
animation: osd-in 0.4s {spring_settle} both; }}\ 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 {{ opacity: 0.85; margin-right: 8px; }}\
.osd-icon-muted {{ opacity: 0.35; }}\ .osd-icon-muted {{ opacity: 0.35; }}\
progressbar.osd-bar {{ min-height: 6px; }}\ progressbar.osd-bar {{ min-height: 6px; }}\
progressbar.osd-bar trough {{ background-image: none; background-color: {trough_bg};\ progressbar.osd-bar trough {{ background-image: none; background-color: alpha(@accent, 0.25);\
border-radius: 3px; min-height: 6px; }}\ border-radius: 3px; min-height: 6px; }}\
progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\ progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\
border-radius: 3px; min-height: 6px; }}\ border-radius: 3px; min-height: 6px; }}\
window.breadbar-panel {{ background-color: alpha({panel}, {panel_surface_alpha}); color: {ink};\ .wifi-pair {{ padding: 6px; }}\
border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\ window.breadbar-panel {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
/* A real GtkButton's own label text does NOT inherit `color` from\ border-radius: 14px; border: 1px solid alpha(@on-bg, 0.12); }}\
an ancestor window: `bread_theme::stylesheet()`'s shared,\
ecosystem-wide `button {{ color: @on-surface }}` rule (lib.rs,\
applied to every bread app before this file's CSS layers on\
top) matches the button element directly, and a direct match\
always beats inheritance regardless of specificity. `@on-surface`\
is `ink_on(@surface)`, and `@surface` is `bread_theme::palette`'s\
FIXED_SURFACE constant pinned dark, same as `@bg` so every\
button's label (power row, hamburger, wifi/bluetooth popover\
rows, the add-network dialog's Cancel/Connect) rendered\
near-white regardless of the active shell theme. Invisible-but-\
correct on every dark theme through spotlight; confirmed\
empirically as near-invisible ghost text under daylight (isolated\
`bread-capture` control-panel screenshot, pre-fix). One rule,\
scoped by ancestor class so its specificity beats the shared\
unscoped `button` rule, instead of patching each `.power-btn`/\
`.control-panel-btn`/`.wifi-popover-row`/etc. class individually. */\
window.breadbar-panel button, window.wifi-popover button,\
window.wifi-add-dialog button, window.breadbar-notification button,\
window.breadbar-history button {{ color: {ink}; }}\
window.breadbar-dismiss, .breadbar-dismiss-hit {{\ window.breadbar-dismiss, .breadbar-dismiss-hit {{\
background-color: alpha(#000000, 0.02); }}\ background-color: alpha(#000000, 0.02); }}\
.popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\ .popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\
background-color: @accent;\ background-color: @accent;\
background-image: linear-gradient(90deg, @accent, @teal);\ background-image: linear-gradient(90deg, @accent, @teal);\
animation: caret-draw 0.45s {spring} both; }}\ animation: caret-draw 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
.wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\ .wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\
window.wifi-popover button {{ min-height: 0; min-width: 0; }}\ window.wifi-popover button {{ min-height: 0; min-width: 0; }}\
.popover-tab-row {{ background: alpha({ink}, 0.06); border-radius: 10px;\ .popover-tab-row {{ background: alpha(@on-bg, 0.06); border-radius: 10px;\
padding: 3px; margin-bottom: 10px; }}\ padding: 3px; margin-bottom: 10px; }}\
.popover-tab {{ background: transparent; color: {ink}; border: none; box-shadow: none;\ .popover-tab {{ background: transparent; color: @on-bg; border: none; box-shadow: none;\
outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\ outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\
font-size: 17px; font-weight: bold; opacity: 0.55;\ font-size: 17px; font-weight: bold; opacity: 0.55;\
transition: background-color 0.22s {spring_settle},\ transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
opacity 0.22s ease, color 0.22s ease; }}\ opacity 0.22s ease, color 0.22s ease; }}\
.popover-tab:hover {{ opacity: 0.8; }}\ .popover-tab:hover {{ opacity: 0.8; }}\
.popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\ .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\
@ -539,12 +134,12 @@ fn load_css() -> String {
letter-spacing: 0.12em; }}\ letter-spacing: 0.12em; }}\
.wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\
outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\ outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\
transition: background-color 0.18s {spring_settle}; }}\ transition: background-color 0.18s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.wifi-popover-row label {{ font-size: 18px; }}\ .wifi-popover-row label {{ font-size: 18px; }}\
.wifi-popover-row:hover {{ background: alpha({ink}, 0.08); }}\ .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 {{ background: alpha(@accent, 0.14); color: @accent; }}\
.wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\ .wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\
.row-in {{ animation: row-in 0.32s {spring} both; }}\ .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-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\
.stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\ .stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\
.stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\ .stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\
@ -557,29 +152,26 @@ fn load_css() -> String {
switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\ switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\
border: none; outline: none; box-shadow: none; background-image: none;\ border: none; outline: none; box-shadow: none; background-image: none;\
border-radius: 99px; }}\ border-radius: 99px; }}\
switch.bt-switch {{ background-color: alpha({ink}, 0.14);\ switch.bt-switch {{ background-color: alpha(@on-bg, 0.14);\
transition: background-color 0.25s {spring_settle}; }}\ transition: background-color 0.25s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
switch.bt-switch:checked {{ background-color: @accent; }}\ switch.bt-switch:checked {{ background-color: @accent; }}\
switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\ switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\
border-radius: 99px; border: none; outline: none; box-shadow: none;\ border-radius: 99px; border: none; outline: none; box-shadow: none;\
background-image: none; background-color: {ink}; }}\ background-image: none; background-color: @on-bg; }}\
window.wifi-add-dialog {{ background-color: alpha({panel}, {card_alpha}); color: {ink}; min-width: 240px;\ window.wifi-add-dialog {{ background-color: alpha(@bg, 0.70); color: @on-bg; min-width: 240px;\
border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\ border-radius: {radius}; border: 1px solid alpha(@on-bg, 0.10);\
animation: pop-in 0.45s {spring} both; }}\ animation: pop-in 0.45s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
window.wifi-add-dialog headerbar {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\ 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-top-left-radius: {radius}; border-top-right-radius: {radius};\
border-bottom: 1px solid alpha({ink}, 0.10); 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 {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
/* min-height: decision #1 — the media chip is a bar chip like\ .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: 0;\
any other, so it shares the same row height instead of sizing\ transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
to its own eq-bar/label content. */\ .media-widget:hover {{ background: alpha(@on-bg, 0.08); }}\
.media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\ .media-widget.media-in {{ animation: row-in 0.4s cubic-bezier(0.22, 1.35, 0.36, 1) both; }}\
transition: background-color 0.22s {spring_settle}; }}\
.media-widget:hover {{ background: alpha({ink}, 0.08); }}\
.media-widget.media-in {{ animation: row-in 0.4s {spring} both; }}\
.media-eq {{ min-height: 14px; margin-right: 4px; }}\ .media-eq {{ min-height: 14px; margin-right: 4px; }}\
.media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @{accent2};\ .media-eq-bar {{ min-width: 3px; min-height: 5px; background-color: @accent;\
border-radius: 2px; }}\ border-radius: 2px; }}\
.media-widget.playing .media-eq-bar {{\ .media-widget.playing .media-eq-bar {{\
animation: media-eq 0.85s ease-in-out infinite alternate; }}\ animation: media-eq 0.85s ease-in-out infinite alternate; }}\
@ -590,26 +182,14 @@ fn load_css() -> String {
.media-controls {{ padding: 4px; }}\ .media-controls {{ padding: 4px; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\ .media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\
transition: background-color 0.18s ease; }}\ transition: background-color 0.18s ease; }}\
.media-btn:hover {{ background: alpha({ink}, 0.10); }}\ .media-btn:hover {{ background: alpha(@on-bg, 0.10); }}\
/* No padding/border-radius/min-width/min-height here (was\ .control-panel-btn {{ padding: 5px 8px; margin: 0; border-radius: 10px;\
`padding: 5px 8px; border-radius: 10px; min-width: 0;\ opacity: 0.92; font-size: 18px; line-height: 1; min-width: 0; min-height: 0;\
min-height: 0`): the hamburger is the only button carrying both\
`.stat-pair.icon-only` AND `.control-panel-btn`, and because\
this rule sits later in the cascade its hardcoded 10px radius\
and 0 min-size were silently winning over `.stat-pair`'s own\
`chip_radius`/`chip_height_px` the exact hamburger-corner-\
mismatch bug decision #2 describes, and a second copy of\
decision #1's height bug, both reintroduced by\
this one class alone. Dropping the four properties lets\
`.stat-pair`/`.stat-pair.icon-only` cascade through unchanged,\
same fix shape as the icon-only border-radius removal above. */\
.control-panel-btn {{ margin: 0;\
opacity: 0.92; font-size: 18px; line-height: 1;\
background: transparent; border: none; outline: none; box-shadow: none;\ background: transparent; border: none; outline: none; box-shadow: none;\
transition: background-color 0.22s {spring_settle},\ transition: background-color 0.22s cubic-bezier(0.22, 1.2, 0.36, 1),\
opacity 0.18s ease; }}\ opacity 0.18s ease; }}\
.control-panel-btn:hover {{ opacity: 1; background: alpha({ink}, 0.10); }}\ .control-panel-btn:hover {{ opacity: 1; background: alpha(@on-bg, 0.10); }}\
.control-panel-btn:active {{ background: alpha({ink}, 0.16); }}\ .control-panel-btn:active {{ background: alpha(@on-bg, 0.16); }}\
.control-panel {{ }}\ .control-panel {{ }}\
.control-panel-inner {{ min-width: 248px; padding: {pad}; }}\ .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\
.sys-grid {{ margin: 2px 0 6px; }}\ .sys-grid {{ margin: 2px 0 6px; }}\
@ -621,7 +201,7 @@ fn load_css() -> String {
.control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\ .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\
.control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\ .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\
scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\ scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\
background-image: none; background-color: alpha({ink}, 0.12);\ background-image: none; background-color: alpha(@on-bg, 0.12);\
border: none; outline: none; box-shadow: none; }}\ border: none; outline: none; box-shadow: none; }}\
scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\ scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\
background-image: none; background-color: @accent; }}\ background-image: none; background-color: @accent; }}\
@ -632,14 +212,14 @@ fn load_css() -> String {
.sink-row label {{ font-size: 15px; }}\ .sink-row label {{ font-size: 15px; }}\
.power-row {{ margin-top: 8px; }}\ .power-row {{ margin-top: 8px; }}\
.power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\ .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\
background: alpha({ink}, 0.08); font-size: 13px; border: none;\ background: alpha(@on-bg, 0.08); font-size: 13px; border: none;\
outline: none; box-shadow: none;\ outline: none; box-shadow: none;\
transition: background-color 0.2s {spring_settle}; }}\ transition: background-color 0.2s cubic-bezier(0.22, 1.2, 0.36, 1); }}\
.power-btn:hover {{ background: alpha({ink}, 0.14); }}\ .power-btn:hover {{ background: alpha(@on-bg, 0.14); }}\
.power-btn:active {{ background: alpha(@accent, 0.22); }}\ .power-btn:active {{ background: alpha(@accent, 0.22); }}\
.notification-action {{ transition: background-color 0.18s ease; }}\ .notification-action {{ transition: background-color 0.18s ease; }}\
.tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\ .tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\
separator {{ margin: 4px 0; background: alpha({ink}, 0.10); }}\ separator {{ margin: 4px 0; background: alpha(@on-bg, 0.10); }}\
/* Lua-declared widgets (see Documentation.md's Widgets §style): the\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\
slot rule below is what the four inline `.bread-widget-slot`\ slot rule below is what the four inline `.bread-widget-slot`\
containers in main.rs rely on for the same 12px stat-pair rhythm\ containers in main.rs rely on for the same 12px stat-pair rhythm\
@ -652,7 +232,7 @@ fn load_css() -> String {
of Adwaita's default blue-on-gray, and let `style.color` retint\ of Adwaita's default blue-on-gray, and let `style.color` retint\
that fill the same way it retints label/icon text. */\ that fill the same way it retints label/icon text. */\
.bread-widget-slot {{ margin-right: 12px; }}\ .bread-widget-slot {{ margin-right: 12px; }}\
progressbar.bread-widget-node trough {{ background-image: none; background-color: {trough_bg}; border-radius: 3px; min-height: 6px; }}\ progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\
progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\
progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\
progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\
@ -689,96 +269,26 @@ fn load_css() -> String {
.bread-padding-none {{ padding: 0; }}\ .bread-padding-none {{ padding: 0; }}\
.bread-padding-xs {{ padding: 4px; }}\ .bread-padding-xs {{ padding: 4px; }}\
.bread-padding-sm {{ padding: 8px; }}\ .bread-padding-sm {{ padding: 8px; }}\
.bread-padding-md {{ padding: 12px; }}\ .bread-padding-md {{ padding: 12px; }}",
/* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\ radius = radius,
like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\ radius_bar = radius_bar,
are built regardless of the active theme (see main.rs's \"Assemble\"\ radius_sm = radius_sm,
section), just never placed in a slot outside spotlight, so these\ radius_pill = radius_pill,
rules render nothing on liquid-motion/glass-workbench. */\ pad = pad,
.launcher-entry {{ background: transparent; color: {ink}; border: none;\
outline: none; box-shadow: none; caret-color: @{accent_from};\
font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\
padding: 0; margin: 0; min-height: 0; }}\
.launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\
.bread-drawer {{ min-height: 0; }}\
.bread-drawer.open {{ border-top: 1px solid alpha({ink}, 0.08);\
margin-top: 6px; padding-top: 2px; }}\
.bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\
.bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\
color: {ink}; background-color: transparent; }}\
.bread-drawer row:hover {{ background-color: alpha({ink}, 0.08); }}\
.bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\
color: {ink}; }}\
.bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\
.bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\
/* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\
\"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\
build_header_row`). Unconditional, same reasoning as every other\
launcher rule above: only spotlight ever builds a row with this\
class at all. */\
.bread-drawer-section-header {{ padding: 6px 14px 2px; }}\
.section-header-label {{ font-size: 11px; font-weight: 600;\
letter-spacing: 0.08em; text-transform: uppercase;\
opacity: 0.45; }}",
// Implicit capture (2021 edition) for every `{name}` above: each
// matches an in-scope `let` binding of the same name (`radius`,
// `spring`, `ink`, `panel`, `bar_radius`, `window_chrome`,
// `segment_css`, `accent_from`, `accent2`, ...) rather than a hand-
// maintained `name = name,` list — axis 1's `panel`/`ink` swap and
// axis 3's `segment_css`/`window_chrome`/`bar_radius` locals both
// needed a growing, easy-to-desync explicit list to stay in step
// with the string body above; switching the whole call to implicit
// capture removes that failure mode instead of extending it further.
// (`radius_bar`, `flush`, `light`, `segmented`, `window_border`
// itself, and `accent_to` are each read by name ABOVE this literal,
// not inside it, so they're deliberately absent here — an unused
// implicit-capture name is a hard compile error, same discipline
// this crate already applies to unread `theme.toml` keys.)
) )
} }
/// Returns the ink colour for icon tinting in the stats bar — the same /// 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 /// luminance-picked colour the bar's text uses, so icons stay legible on the bar
/// bar whatever lightness pywal gives the background. /// whatever lightness pywal gives the background.
///
/// FIXED — axis 1, daylight: this used to be unconditionally
/// `ink_on(&load_palette().background)`. `load_palette().background` is
/// `bread_theme::palette::FIXED_BACKGROUND` (`"#0c0c0c"`), pinned dark
/// regardless of pywal OR the active shell theme (see that constant's own
/// doc comment) — so this always resolved to the SAME near-white value,
/// baked directly into a rasterised SVG texture at icon-build time
/// (`svg_texture_sized`, the only call site), completely outside CSS and
/// therefore untouched by `load_css`'s own `panel`/`ink` swap. Every icon
/// built through [`crate::svg_image`]/[`crate::svg_texture`] (volume, wifi,
/// battery, hamburger, media transport, the OSD glyph) was near-white on
/// every existing (dark) theme, which read as correct by construction —
/// until daylight's near-white paper pills made the SAME near-white glyph
/// nearly invisible against its own background. Confirmed empirically
/// (isolated `bread-capture` OSD-volume screenshot, pre-fix) before this
/// fix. Mirrors `load_css`'s own `ink` local exactly: the dark theme's
/// unchanged `ink_on(background)` (near-white), or — for a light theme —
/// `background` itself (the fixed dark hex IS the correct dark ink, the
/// same identity `load_css`'s `ink = "@bg"` case relies on).
pub fn fg_color() -> String { pub fn fg_color() -> String {
let p = load_palette(); ink_on(&load_palette().background).to_string()
if shell_theme().tokens().light() {
p.background.clone()
} else {
ink_on(&p.background).to_string()
}
} }
/// Ink colour for the given Hyprland output's wallpaper palette. See /// Ink colour for the given Hyprland output's wallpaper palette.
/// [`fg_color`]'s doc comment for the same light-theme fix; kept in step
/// even though this accessor has no call site today.
#[allow(dead_code)] #[allow(dead_code)]
pub fn fg_color_for(output: &str) -> String { pub fn fg_color_for(output: &str) -> String {
let p = load_palette_for(output); ink_on(&load_palette_for(output).background).to_string()
if shell_theme().tokens().light() {
p.background.clone()
} else {
ink_on(&p.background).to_string()
}
} }
/// Bind this window (and its popover children) to `output`'s palette. /// Bind this window (and its popover children) to `output`'s palette.
@ -814,31 +324,3 @@ pub fn apply() {
let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css")); let user_path = std::path::PathBuf::from(format!("{home}/.config/breadbar/style.css"));
USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell)); USER_PROVIDER.with(|cell| bgtk::apply_user_css(&user_path, cell));
} }
thread_local! {
// `bread_theme::shell::ThemeWatch`, not a bare `gio::FileMonitor`: the
// watch now re-arms itself onto a new theme's directory when the active
// theme id changes underneath it (see that type's doc comment), so the
// handle we keep alive is opaque, not a single fixed monitor.
static SHELL_THEME_MONITOR: RefCell<Option<bread_theme::shell::ThemeWatch>> =
const { RefCell::new(None) };
}
/// Wires `bread_theme::shell::watch()` (plan §10) so editing the active
/// theme's `theme.toml`/`extra.css` on disk re-resolves CSS tokens without a
/// restart, the same way a pywal palette change already does via
/// `apply_app_css`. Window-spec values (anchors, margins, exclusive zone,
/// keyboard mode) are read once at window-construction time and are *not*
/// re-applied here — per plan §10 those need a restart, since live-swapping
/// a mapped layer-shell surface's anchors/exclusive-zone is a lot of
/// teardown risk for a rare operation.
///
/// Call once at startup (primary instance only — every satellite window
/// calling this would just re-arm the same watch redundantly).
pub fn watch_hot_reload() {
let monitor = bread_theme::shell::watch(|new_theme| {
set_shell_theme(new_theme);
apply();
});
SHELL_THEME_MONITOR.with(|cell| *cell.borrow_mut() = Some(monitor));
}