Compare commits

...

84 commits
v0.3.0 ... main

Author SHA1 Message Date
db4be9bb43 Merge pull request 'Daylight bar chrome + embedded launcher, v0.7.5 pins' (#5) from feature/theme-spotlight into main
Some checks failed
dev release / build (push) Failing after 14s
2026-08-31 18:14:18 +08:00
Breadway
e7c1f775ec Bump bread-ecosystem pins to v0.7.5, bread-shared to v0.8.0
Some checks failed
check / check (push) Failing after 12s
v0.7.5 carries the shell-theme manifest, layer-rule generation, and
bread-launcher API this branch depends on. bread-shared moves to v0.8.0
to match the tag bread-utils v0.7.5 pins, so only one copy is linked.
2026-08-31 15:48:41 +08:00
Breadway
a806ac08c1 Clear clippy 1.97 lints (dead App fields, from_geom self-convention)
Toolchain drift — pre-existing on main under clippy 0.1.97, both
mechanical: `#[allow(dead_code)]` on the four held-for-lifetime sys-grid
widget fields, and `#[allow(clippy::wrong_self_convention)]` on
`from_geom` (`from` is the source button, not a conversion).
2026-08-31 15:48:41 +08:00
Breadway
0aac9afcaa osd: reap pactl subscribe watchers on exit so pipewire-pulse clients aren't starved
Each breadbar restart orphaned a blocking `pactl subscribe` that kept its
PulseAudio connection open; once pipewire-pulse's client cap filled,
new clients (settings apps among them) were refused ("no devices in
settings"). Register an atexit hook (osd.rs) plus SIGINT/SIGTERM handlers
(main.rs) that kill and reap the watcher children before breadbar exits.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 15:45:33 +08:00
Breadway
31138da46e shell: fix icon/button/card legibility and screenshot capture for a light theme
Found via isolated bread-capture screenshots of daylight (BREAD_SHELL_THEME=
daylight), not guesswork:

- theme::fg_color() computed icon tint from load_palette().background, which
  is bread-theme's FIXED (never-pywal, never-theme-aware) dark constant —
  every SVG-rasterised bar/OSD icon was baked near-white regardless of the
  active theme: invisible-but-correct on every prior dark theme, nearly
  invisible on daylight's own near-white pills. Now theme-aware via
  tokens.light().
- Real GtkButtons (power row, hamburger, wifi/bluetooth popover rows, the
  add-network dialog) don't inherit the window's ink colour: the shared
  ecosystem stylesheet's unscoped button{color:@on-surface} rule matches
  them directly first, and @on-surface is ink computed against the same
  kind of fixed dark constant. One ancestor-scoped override per
  breadbar-owned window fixes all of them without touching the shared rule.
- The notification/history/OSD/wifi-add-dialog cards and the popover window
  hardcoded their own 0.70/0.72 alpha literals independent of bg_alpha —
  fine as translucent glass on a dark fill, but reads as pale glass instead
  of opaque paper on daylight. Now uses bg_alpha for light themes only,
  unchanged literal for the other three.
- Same story for the OSD/widget-node progress trough's accent-tinted empty
  track, barely visible against a light pill.
- screenshot.rs: the "bar"/"capsule-collapsed" capture views hardcoded a
  top-left capture rectangle — correct for every top-anchored theme, but a
  bottom-anchored bar's real footprint is the last bar_capture_height()
  pixels of the canvas. Confirmed empirically: a daylight bar capture
  returned a plain background gradient with no dock in frame at all before
  this fix.
2026-08-31 15:45:25 +08:00
Breadway
dbeb693643 shell: wire up bottom-anchored, segmented, light bar chrome for Daylight
- main.rs: apply bar.window.margin.bottom to the layer-shell surface (was
  parsed but never set — WindowSpec::margin.bottom's own doc comment
  flagged this gap); fix the exclusive-zone calc to reserve the ANCHORED
  edge's margin instead of always margin.top (exclusive_zone_for, unit
  tested); tag the three bar slot-group boxes with an unconditional
  .bar-segment CSS class (a no-op unless the active theme sets
  bar_border = "segmented").
- surface.rs: add the bottom_right satellite anchor (mirrors top_right on
  the bottom edge) and extend the fill anchor's offset to a [top, bottom]
  pair so the dismiss scrim can leave its gap at the bottom of the screen
  instead of only the top.
- theme.rs: tokens.light() swaps which of the fixed @bg/@on-bg pair plays
  paper-surface vs ink across every translucent card/panel/hover-wash in
  the stylesheet; bar_border = "segmented" makes window.breadbar fully
  transparent and gives .bar-segment its own pill surface/border/shadow;
  the Trail workspace style now actually substitutes accent_from/accent_to
  instead of a hardcoded @accent/@teal gradient; accent2 gives the media
  equaliser an accent independent of the workspace-trail one.
2026-08-31 15:45:25 +08:00
Breadway
9761b85112 bar: entrance animation on first map
ANIMATION WORK #3. Liquid Motion only: the island's layer-shell top
margin springs (anim::spring_to) from just above its resting position
up to the theme's configured margin.top over 420ms on first map, plus
an opacity-only fade (the new bar-entrance CSS class / bar-in
keyframe). Deliberately animates the surface's own layer-shell margin
in Rust rather than a CSS margin on an inner widget: that would touch
box-model geometry, and WorkspaceTrail::place()'s single-shot initial
sample is a documented previous crash site for exactly that kind of
still-moving-on-first-paint bug (see the ws-in/row-in guard's own
comment further down). A layer-shell margin change repositions the
whole surface without touching any widget's own measured size, so it's
invisible to compute_bounds(host) by construction.

glass-workbench (flush edge-to-edge bar, no floating margin to slide
from) gets nothing. spotlight gets the opacity fade only -- its
capsule already has its own width/drawer motion in flight during a
real search, so a margin spring felt like piling onto that rather than
complementing it.
2026-08-31 15:45:25 +08:00
Breadway
c0a013c8ed notifications: add a dismiss button to the toast card
NOTIFICATION INTERACTION #A: a direct way to dismiss a toast, rather
than only auto-expiry or the D-Bus CloseNotification path. A small "x"
button floats in the card's top-right corner via a gtk4::Overlay
wrapping the existing content box, styled via the new
.notification-dismiss CSS class, so it doesn't add an extra header row
the approved demo's card layout never has.

dismiss_card emits NotificationClosed(id, DISMISSED_BY_USER) -- the
same freedesktop reason code (2) invoke_action/submit_reply already use
for their own user-initiated dismissals -- via the shared dismiss()
helper, which (as of the previous commit) already recomputes the
toast's clickable input region on every removal.
2026-08-31 15:45:25 +08:00
Breadway
3eada87c7d notifications: click-through-except-buttons input region + push-down entrance
surface::click_through set a completely empty input region on the toast
so every pointer event passed through -- including to the toast's own
action buttons and inline-reply entry. Replace it with
surface::set_hit_region(window, widgets), which sets the input region to
the union of the given widgets' rectangles instead of empty; everywhere
else on the surface stays click-through exactly as before.

popup.rs recomputes this via refresh_hit_region every time the card set
could have changed (shown, dismissed, expired -- routed through the one
dismiss() function) and keeps recomputing every frame for HIT_TRACK_MS
afterward, since a card's own entrance animation or the stack's
push-down reflow can still be moving a button on the frame the change
happens. collect_interactive walks the real widget tree for
GtkButton/GtkEntry rather than tracking a flat list, so it can't drift
out of sync with make_card's structure. build_window's connect_map
handles the first-map race the same way surface::click_through used to.

Also: spring_in_card grows a newly shown card's height from 0 to its
natural size via bread_theme::anim::spring_to (same technique as
main.rs's animate_drawer_height), so the existing stack gets pushed
down smoothly instead of jumping.

KeyboardMode::None is unchanged on the toast; history.rs's OnDemand
mode is untouched.

Still to do: the actual dismiss button in make_card (this commit wires
the mechanism that will hit-test it, but no card has one yet).
2026-08-31 15:45:25 +08:00
Breadway
ba4e3480d4 osd: overshoot the volume/brightness fill before settling
pbar.set_fraction() jumped straight to the target with no motion.
anim::spring_to clamps every frame to [min(from,to), max(from,to)] (a
safety clamp for its other callers, e.g. never letting a collapsing
drawer's height go negative), which also means a single spring_to call
can never visibly overshoot its target. animate_osd_fill chains two
legs instead — one toward a point a few percent past the real value,
then one back onto it — to get the same overshoot-and-settle read the
rest of the theme's motion has. A generation token guards leg 2 against
firing after a newer OSD event has already superseded it.
2026-08-31 15:45:25 +08:00
Breadway
4cdd6f0213 workspaces: squash and stretch the trail pill during switches
Compress the trail pill's height toward SQUASH_MIN while it's mid-flight
(fastest travel, during STRETCH_MS) and spring it back past resting
height before settling (during SNAP_MS, via the existing overshoot
curve), so the pill reads as having weight instead of sliding like a
rigid box. Implemented as a pure per-frame post-process (squash_geom)
applied to the frame actually painted, on top of the existing x/w/y
trajectory — never fed back into TrailInner::natural, and skipped
entirely on the final `done` frame so the resting geometry is still
exactly to_g.
2026-08-31 15:45:25 +08:00
Breadway
bb9d08b8d3 bar: odometer-roll the volume and battery chip digits
Reuse the clock's per-digit flip machinery (make_clock_digits /
flip_clock_digits) for the volume and battery stat chips: vol_lbl/bat_lbl
become digit-chip boxes (one .stat-digit label per character) instead of
a single gtk4::Label, and only the characters that actually changed play
the digit-flip keyframe on a StatsUpdate. The volume slider's live drag
feedback uses a new no-animation set_digit_chip instead, since it fires
on every pointer-move tick and replaying the flip that fast would just
flicker.

Drops the now-unused tick_label (both call sites moved to
flip_digit_chip).
2026-08-31 15:45:25 +08:00
Breadway
1fe62f6bd9 bar: centre the row vertically instead of pinning it to the top
Wrapping the bar row in a vbox to gain the drawer slot left the CenterBox at
its natural height, so it sat at the TOP of the window and the remainder
showed as dead space along the bottom edge. Every valign:Center child then
centred within that short box rather than within the bar, and the whole row
rode high.

The clock was the one element that looked right, because it carried a
margin_top(3) nudging it back down. With the row centring properly that offset
would push it below everything else, so it goes too — the compensation and the
bug are the same change.
2026-08-31 15:45:25 +08:00
Breadway
74f280eaed workspaces: keep the trail animating and level across row rebuilds
Two regressions from the previous commit, both from trusting the live
geometry too far.

Snapping instead of moving: switching to an empty workspace rebuilds the
button row, and for a frame afterwards no button is allocated, so button_geom
returned None for both the source and destination. from_geom then returned
None and the caller fell back to an instant place(). The trail now remembers
the last size measured from an allocated button and animates from that, so a
rebuild costs continuity of size, not the animation itself.

Sitting low: y was taken from the live geometry, which after a rebuild or
mid-animation is not where the row actually is. Only x comes from the live
geometry now — that is what makes an interrupted switch continue from where
the pill is rather than jumping back — while y, w and h always come from a
real button.
2026-08-31 15:45:25 +08:00
Breadway
1b523eab63 workspaces: derive trail width only from a live button; wire the launcher hotkey
Two fixes.

The trail pill still grew when spamming between workspace 1 and an empty 6.
Switching to an empty workspace makes Hyprland create and destroy it, which
rebuilds the button row mid-animation and leaves the source button detached —
button_geom then returned None and the fallback handed the wide mid-stretch
span straight back in, reintroducing exactly the accumulation the previous
commit removed. Width now falls back to the destination button, which is
always live, and no path returns the live width any more.

Separately, AppInput::OpenLauncher's local arm only called grab_focus(). Focus
stopped opening the capsule when connect_enter's open_fn() call was removed
(that call was what opened the capsule during window construction), so the
keybind focused the entry and left the drawer shut. It now invokes
launcher_open_fn, which was added for this and never read — the dead-code
warning on that field is what surfaced it.
2026-08-31 15:45:25 +08:00
Breadway
42d5acb736 workspaces: stop the trail pill growing when switches are spammed
Interrupting an in-flight stretch fed the pill's CURRENT geometry back in as
the next animation's starting point. Mid-stretch that geometry deliberately
spans both the old and new buttons, and ease_overshoot (c = 1.4) pushes it
wider still past the target — so each interrupted switch began wider than the
last and the pill grew until it hit MAX_CHIP_W. That cap was masking the
compounding rather than preventing it, which is why the bug survived an
earlier fix that only tightened the cap.

from_geom now takes POSITION from the live geometry, so rapid switches still
read as one continuous movement, and WIDTH from the source button's natural
size, so width can no longer compound across interruptions.
2026-08-31 15:45:25 +08:00
Breadway
d724ec6dd4 capsule: open only on real user input, never on focus or programmatic text
Three prior fixes failed because each neutered one path that called open_fn
while others remained. This inverts the approach: opening is now gated on the
input actually coming from a person.

- connect_enter no longer opens. Focus alone is not intent, and GTK grants
  focus during window construction, which is what put the capsule in its open
  state at startup.
- connect_changed skips when a programmatic_text_change flag is set, so
  close_fn's set_text("") and initial buffer setup cannot reopen it.
- A click gesture and the OpenLauncher hotkey are the only paths that open.
- BREADBAR_CAPSULE_DEBUG=1 traces every open/close and why, so a future
  accidental open is diagnosable instead of mysterious.

Compiles and tests pass, but NOT yet verified against a live compositor: the
agent that wrote this was interrupted before running it. The startup-collapsed
check still needs a real session.
2026-08-31 15:45:25 +08:00
Breadway
70fe814593 theme: uniform chip height, fix hamburger radius cascade, spotlight dot spec, first-paint trail bug
Decision #1 (one chip highlight height per bar, vertically centred): the
.stat-pair/.stat-pair.icon-only/.media-widget/workspace-btn CSS all sized
to their own content box before this (min-height: 0, or a stale 32/20px
figure), which is why battery sat high and wifi/menu ran taller than their
neighbours. Added a hardcoded approved_chip_height() (26/22/22 by
WorkspaceStyle, matching the demo spec) since bread-ecosystem's own
chip_height token (32/20/36) predates this pass and disagrees with it, and
that repo is a sibling agent's this cycle.

Also found and fixed a second copy of the hamburger-corner-mismatch bug:
.control-panel-btn (hamburger only) hardcoded its own border-radius/
min-width/min-height *later* in the cascade than .stat-pair, silently
winning over chip_radius/chip_height regardless of the icon-only fix
already in place. Dropped the four conflicting properties so .stat-pair
cascades through unchanged.

Spotlight workspace dots: bumped to the approved Option B spec (10px
tall, widths 8/13/17/22 by open-window count) — the prior pass landed
9px/[6,10,14,18], one generation behind. dot_widths is likewise
theme.toml-derived and stale, so overridden locally with a comment
rather than edited upstream.

Workspace row "sits low on first paint" bug: root cause was the ws-in
entrance animation (row-in keyframe, margin-top 8px -> 0) playing on
every button during the very first rebuild_buttons call, racing
WorkspaceTrail::place()'s single-shot geometry sample and freezing the
trail pill a few px low until the next real switch re-measured settled
layout. The demo never animates the initial row in at all — only
subsequently-added workspaces should. Suppressed ws-in specifically on
the first build (button_map starts empty exactly once).

Also fixed: make_button's set_size_request was still pinning the stale
tokens().chip_height() as a hard GTK minimum, which would have out-ranked
the CSS min-height fix above for Trail/Pill workspace pills.
2026-08-31 15:45:25 +08:00
Breadway
dd5618a5ea spotlight workspace dots: bump height for legibility
04-spotlight.html's .dots button is 6px tall — reported as "too small
and hard to click." The click-target side of that is a separate,
non-visual fix elsewhere; this is the visual side only. 6px is also
genuinely hard to see on a real display, not just hard to hit, so
DOT_HEIGHT bumps it to 9px (bar::workspaces::make_dot_button) — enough
to read clearly without growing into a little chip that would fight
the capsule's minimal look. Widths are untouched: dot_widths is the
manifest's own per-occupancy encoding ([6, 10, 14, 18] in the builtin
spotlight theme.toml) and stays exactly as specified; only the height,
which has no manifest token of its own, is breadbar's call to make.
The CSS .workspace-dot min-height is updated to match (9px) so it
doesn't silently drift from the value that actually governs the
rendered size (a direct set_size_request, not normal CSS layout).

Not visually verified via capture — the isolated headless-Sway harness
has no Hyprland IPC, so workspace dots render empty there regardless
of theme (pre-existing harness limit, unrelated to this change).
Verified by reading the CSS/Rust against 04-spotlight.html's <style>
block instead, and by cargo build/clippy/test passing clean.
2026-08-31 15:45:25 +08:00
Breadway
583d73c53c liquid-motion workspace pills: match demo radius and height
.workspace-trail/.workspace-btn hardcoded border-radius: 12px and
min-height: 28px — neither matches this theme's own radius_sm token
(9px) nor the 01-liquid-motion.html demo's .ws-btn/.trail rules (26px
tall, 9px radius). radius_sm happens to be an exact match for the
demo's 9px here. Reported: "the pills on liquid motion just look
off".

Not visually verified via capture — the isolated headless-Sway
harness has no Hyprland IPC, so workspace buttons render empty there
regardless of theme (pre-existing harness limit, unrelated to this
change). Verified by reading the CSS against 01-liquid-motion.html's
<style> block instead, and by `cargo build`/clippy/test passing clean.
2026-08-31 15:45:25 +08:00
Breadway
1c028b59be stat-pair chips: token-driven radius, drop icon-only circular override
.stat-pair's border-radius was a hardcoded 10px shared identically by
all three themes: coincidentally close for liquid-motion (radius_sm
9px, matching its demo's .chip radius almost exactly), flatly wrong
for glass-workbench (demo's .chip is 6px, exactly this theme's own
radius_sm), and disconnected from spotlight's much rounder capsule
language. Chips now round by chip_radius: radius_sm for liquid-motion/
glass-workbench, radius_pill for spotlight (its bar/dots are already
that round, so its lone .stat-pair occupant, the battery chip, now
reads as part of that same family instead of a stray rounded rect
inside a much-rounder capsule).

Also drops .stat-pair.icon-only's own 999px radius override. That
made wifi and the liquid-motion hamburger — the only two icon-only
chips — fully circular while their row neighbours (vol, battery)
stayed a rounded rect at .stat-pair's radius: a visible mismatch
inside one row, called out against the hamburger specifically.
Every demo's .chip class draws vol/wifi/battery/menu identically and
none of them circular, so dropping the override just lets the shared
radius cascade through unchanged.

Verified via bread-capture's isolated headless-Sway harness with a
temporary (not committed) debug outline on .stat-pair: liquid-motion's
four right-side chips now share one visible corner radius instead of
two circular + two rounded-rect, and spotlight's battery chip reads as
a rounded pill matching its capsule/dots instead of a sharper rect.
2026-08-31 15:45:25 +08:00
Breadway
0a0bd5f431 wifi chip: fix off-center icon and drop dead CSS rule
wifi_img.set_hexpand(false) meant the icon packed at the start of the
32px icon-only chip with no leftover space for halign:Center to work
with, sitting visibly left of center on glass-workbench/liquid-motion.
hexpand(true) fixes the centering, but that alone silently bubbles the
expand flag up through connectivity_pair into the shared right-hand
stats box and the centerbox's end slot, blowing the wifi/vol cluster's
layout apart (confirmed via a --screenshot bar capture: the whole
cluster jumped left against the clock with a huge gap before
battery/hamburger). connectivity_pair.set_hexpand(false) pins the
box's own expand explicitly so the fix stays contained to this chip.

Also drops the dead .wifi-pair { padding: 6px } rule: it can never
apply since .stat-pair.icon-only's two-class selector always beats
its one-class specificity regardless of source order, so the intended
padding never actually rendered.
2026-08-31 15:45:25 +08:00
Breadway
55d0e399bd panel: scope the capsule's click-away dead zone to its own column
"it only sometimes is dismissed when you click somewhere else": the
dismiss scrim's clickable region started at a fixed offset below the
screen top (the capsule row plus the drawer's max possible height,
kept generous on purpose so an animating drawer never has its result
rows swallowed) but via a plain layer-shell margin, which pushes the
scrim's *entire width* down by that much — not just the strip under
the capsule. That left a full-screen-wide dead band above it (near
470px tall on a 1200px-tall display) where a click neither dismissed
the capsule nor hit anything else, since breadbar-dismiss sits on the
overlay layer above the bar's own top layer and nothing else was
there to catch it either.

show_capsule_dismiss now also takes an optional (x, width) hole,
computed from the capsule's own real on-screen column — queried live
from `hyprctl layers -j` (ground truth; the layer-shell protocol never
hands a client its own assigned position back) rather than assumed —
and punches exactly that column out of the scrim's input region via a
custom cairo Region, leaving the same vertical safety margin in place
but no longer swallowing clicks beside the capsule. Falls back to the
old full-width margin if the geometry query fails for any reason.

Added capsule_dismiss_hole (pure coordinate math, unit tested) and
hypr_capsule_center_x (the live query) to main.rs.
2026-08-31 15:45:25 +08:00
Breadway
fecac7671a launcher: stop the capsule opening itself at startup
GTK4 auto-assigns keyboard focus to the first can-focus widget in a
window as it's first mapped, and launcher_entry was that widget with
nothing to stop it — EventControllerFocus's connect_enter turned that
spurious focus into an unconditional open_fn() call, so the capsule
looked open (and "focused") the moment breadbar started, with no real
click ever having reached it. Since keyboard = "on_demand" only grants
the layer-shell surface real compositor keyboard focus in response to
genuine interaction, this also explains why typing and Escape did
nothing in that state: GTK believed the entry had focus, but the
compositor never actually handed the surface a keyboard grab.

launcher_entry now starts with can_focus(false), so nothing can land
focus on it implicitly. The two places that legitimately want it
focused now ask for it explicitly: a new GestureClick on the entry
(a real, compositor-visible click — the same kind of interaction
on-demand keyboard mode is meant to react to) and the OpenLauncher
command handler, both flip can_focus back on immediately before
calling grab_focus().
2026-08-31 15:45:25 +08:00
Breadway
030cf28096 launcher: log when a mode-row action fails to launch
run_mode_action discarded spawn()'s Result with 'let _ =' for both
RunShell and OpenUrl, with no log line either way. If xdg-open is
missing, or the shell/spawn fails for any reason, pressing Enter on a
'>'-command or '.'-URL row did nothing at all with zero diagnostic —
worse than logging nothing being silent, there was no way to even
suspect what happened.

Log to stderr on Err for both variants. Also pull the URL
scheme-adding logic out into a pure url_open_target() helper, covered
by two new tests, so it's exercised without spawning a real process.
2026-08-31 15:45:25 +08:00
Breadway
2cbdb58f37 launcher: open the capsule on the currently-focused monitor
launcher_command::spawn() only runs for the primary App instance, and
AppInput::OpenLauncher always grabbed focus on that instance's own
capsule — but self.monitor is whichever output was focused ONCE, at
that instance's own init(), not re-resolved on every keybind press.
Start breadbar while eDP-1 is focused, move to DVI-I-1, press the
launcher keybind, and the capsule opened on eDP-1, off-screen from
where the user was looking.

Re-resolve the focused Hyprland monitor at fire-time via
primary_hypr_monitor() (already used for the same purpose at startup)
and route to the right instance: the primary's own capsule if the
focused monitor is its own, or forward to that monitor's tracked
Controller<App> in self.satellites otherwise. Falls back to the local
capsule when there's no focused monitor to resolve (screenshot mode,
hyprctl unavailable) or no tracked satellite for it yet (a very recent
hotplug reconcile hasn't caught up with).

Split the routing decision into a pure resolve_launcher_route()
helper so it's unit-testable without a live App/GTK/Hyprland stack;
covered by four new tests.
2026-08-31 15:45:25 +08:00
Breadway
110ad2c6f9 notifications: re-apply toast click-through after the surface::apply migration
feature/theme-spotlight branched before main's 297207a ('notifications:
stop toast popups from stealing focus or blocking clicks') and the
notification popup was rewritten in this branch to use
surface::apply() for its layer-shell setup, which sets only
anchor/margin/width/layer — no input region — and left the window on
KeyboardMode::OnDemand. Merging this branch would have silently
reintroduced the original bug with no merge conflict to flag it.

Re-apply the fix on top of surface::apply(): add surface::click_through(),
an opt-in helper that sets an empty layer-shell input region on map so
every pointer event passes through to whatever's underneath, and call
it from the toast's create_window(). history.rs's window (the genuinely
interactive notification-history view) is untouched and correctly
keeps OnDemand and normal hit-testing.

Also switch the toast itself from KeyboardMode::OnDemand to
KeyboardMode::None. The toast's card layout does build action buttons
and, when a notification carries an inline-reply hint, a GtkEntry —
but with an empty input region nothing on the toast is ever clickable
or focusable regardless of keyboard mode, so OnDemand only offered a
focus capability with no way to trigger it. Those controls remain
reachable from the history window, which is opened deliberately and
keeps real hit-testing.
2026-08-31 15:45:25 +08:00
Breadway
1d30818510 surface: pin satellite window width instead of just requesting it
surface::apply()'s SurfaceWidth::Px handling only called
set_default_width(), which is advisory — a wide child (an unwrapped
app-name label, or a long summary/body with nothing narrower than its
natural width to wrap against) overrides it, so breadbar-notif (320px)
and breadbar-osd (180px) could render wider than their theme's
configured width instead of wrapping. This is the exact 'wide child
overrides set_default_width' trap main.rs's capsule Width::Px handling
already learned and documented; apply() predates that fix and never
got it.

Add the same set_size_request(px, -1) pin used there. history.rs's
360px override of the shared breadbar-notif namespace has to override
both calls now, not just set_default_width, since the pin from
apply() would otherwise win over a bare default-width override.
2026-08-31 15:45:25 +08:00
Breadway
02ed92ae9d theme: adapt to bread-theme's ThemeWatch return type
bread_theme:🐚:watch() now returns an opaque ThemeWatch (it re-arms
itself onto a new theme's directory when the active theme id changes,
rather than staying pinned to whichever directory was active at call
time) instead of a bare gio::FileMonitor. Update the stored handle's
type to match.
2026-08-31 15:45:25 +08:00
Breadway
6b15f24fb2 build: move the dev-only source override out of Cargo.toml
Same change as breadbox's fa85fc1. The [patch] block pointing bread-theme and
bread-launcher at ../bread-ecosystem shipped in the committed manifest, where
CI cannot resolve it — the workflow clones this repo alone and runs cargo
--locked with no sibling checkout. Moved to a gitignored .cargo/config.toml.

Hygiene, not a CI fix: bread-launcher does not exist at tag v0.7.4, so the pin
cannot resolve without the override until bread-ecosystem is tagged.
2026-08-31 15:45:25 +08:00
Breadway
6c4df8813c capsule: accept the open event breadbox can actually emit
An app may only publish inside its own bread.<app_id>.* namespace, so
breadbox (app id 'box') cannot emit bread.command.box.open — bread-client
refuses it and the redirect did nothing but log a warning. The capsule now
also subscribes to bread.box.open_requested, which is what breadbox emits.

bread.command.box.open is kept as well: that is the addressed-to-an-app
command form an external trigger (the bread CLI, a keybind, another app)
would legitimately send, so the capsule opens whether it was asked directly
or told by breadbox.
2026-08-31 15:45:25 +08:00
Breadway
a7db4d093d capsule: keep the drawer hidden when collapsed and pin the capsule width
Two rendering bugs, both from treating set_size_request as a maximum when it
is only a minimum.

The drawer was collapsed with set_size_request(-1, 0) but left visible. GTK
still allocates a visible box its natural height, and this layer-shell surface
has no fixed height, so the window grew to fit the entire results list: the
capsule rendered open at idle with a stray result row beneath it. It is now
hidden while collapsed, revealed in open_fn before the height animation, and
hidden again on the frame the collapse reaches zero so the close stays
animated rather than snapping shut.

The capsule's width came from set_default_width, which is only a preference a
wide child overrides. Combined with the results list propagating its natural
width, the pill stretched well past the theme's 480px. The width request is
now pinned, and the list no longer propagates width.
2026-08-31 15:45:25 +08:00
Breadway
e52c507f32 capsule: keyboard open, query modes, sections, and search-state geometry (phase 6c)
Completes the spotlight capsule (theme 04), THEME_SYSTEM_PLAN.md §7:

- Keyboard open (item A): a new launcher_command module subscribes to
  bread.command.box.open — only when the active theme's launcher is
  Embedded — and focuses launcher_entry on receipt, which the existing
  EventControllerFocus::connect_enter already turns into an open_fn()
  call, same path a mouse click into the entry takes. breadbox's own
  keybind-triggered launch now redirects to this same event under an
  embedded theme instead of stacking its overlay window on top (see the
  breadbox commit).

- Click-away scrim (item B): open_fn/close_fn now show/hide panel.rs's
  dismiss surface via the new show_capsule_dismiss/hide_dismiss, and
  panels.set_on_dismiss(close_fn) wires a click-away into the capsule's
  own close path. The scrim's clickable region starts at a fixed offset
  (capsule row height + the drawer's own maximum content height,
  DRAWER_MAX_HEIGHT_PX) rather than the drawer's live height, so it can
  never geometrically overlap a rendered result row regardless of
  breadbar-dismiss's overlay layer always rendering above the bar's own.

- Query modes (item C): connect_changed now runs bread_launcher::parse_query
  against the entry text and, when the parsed prefix is listed in
  [launcher].modes, swaps launcher_results.scroller for a new mode_list
  populated by populate_mode_list (calc result / filtered commands / an
  "open this URL" prompt). key_ctrl routes Up/Down/Return at whichever
  list is active; a `>`/`.` row's action runs through the new ModeAction
  enum (RunShell for a command's own fixed exec string, OpenUrl straight
  to xdg-open) so arbitrary typed text is never passed through a shell.

- Sections (item D): ResultsList::new's new `sections` bool is threaded
  from [launcher].sections.

- Search-state geometry (item E): root's own width now spring-animates
  between [launcher].width and .search_width (animate_capsule_width, same
  set_size_request-driven technique animate_drawer_height already uses),
  and a `.searching` class on root switches border-radius to
  .search_radius via a new CSS rule/transition in theme.rs.

Also fixes a real bug this surfaced: drawer_target_height was measuring
drawer_box itself, whose size_request is continuously overwritten by
animate_drawer_height's own tick callback — GTK clamps a widget's
measure() up to at least its own explicit size request, so switching from
the (tall) app list to a one-row calc result measured the PREVIOUS
frame's forced height instead of the new content's actual size. It now
sums each visible child's own natural height directly, which also fixes
the same latent issue for narrowing app-search results.

screenshot.rs gains two new views (capsule-sections, capsule-calc) to
exercise D and C's calc path.
2026-08-31 15:45:25 +08:00
Breadway
c68ed0d7e9 panel: extend the dismiss-scrim pattern for the capsule's click-away (item B)
PanelSet's shared breadbar-dismiss surface already handles click-away for
the wifi/control/media popovers; this adds the pieces the capsule needs to
reuse it rather than a second mechanism: an extra on_dismiss callback
invoked alongside hide_all() on every dismiss click, and
show_capsule_dismiss/hide_dismiss, which show the scrim with its
clickable region starting at a caller-given top margin instead of the
popover default.

That margin matters: breadbar-dismiss'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 the click-away. The capsule's own call site
(main.rs, not part of this commit) always passes a margin sized to the
drawer's maximum possible height, never its live one, so this can never
happen regardless of how tall the drawer currently is.

Not yet wired into the capsule's open/close — PanelSet's three new
methods are unused until that follow-up commit, hence the transient
dead_code warnings.
2026-08-31 15:45:25 +08:00
Breadway
33181a7461 capsule: capture key events before the entry consumes Return
EventControllerKey defaults to PropagationPhase::Bubble. GtkEntry handles
Return in the target phase itself — emitting activate and returning TRUE —
which stops propagation before a bubble-phase controller runs. The capsule's
Enter-to-launch handler was therefore never reached: the selected app never
launched, with no error, because the keypress was consumed upstream.

Capture phase puts the controller ahead of the entry for the keys it claims
(Return/Up/Down/Escape); everything else still Proceeds to the entry so
normal text input is unaffected.
2026-08-31 15:45:25 +08:00
Breadway
2694dc0e13 capsule: clamp drawer collapse height and warn once per dropped widget
The drawer's spring easing overshoots past t=1.0 by design — the bounce is
the point on expand. On collapse (from=content height, to=0) that same
overshoot carried the interpolated value below zero, and set_size_request
hard-asserts height >= -1, so a GTK-CRITICAL fired once per frame for the
whole 360ms close. Clamp to 0 rather than -1: -1 is GTK's use-natural-height
sentinel, which is not what a closing drawer wants.

reconcile_widgets re-ran its undeliverable-widget warning on every reconcile,
and breadd re-pushes specs continuously for any timer-driven widget, so a
one-off diagnostic became unbounded log spam under a theme whose slots don't
name that widget's placement. Warn once per widget id instead.
2026-08-31 15:45:25 +08:00
Breadway
88c1bb0b07 bar/workspaces: unit-test the dots width-index mapping
The isolated screenshot harness has no Hyprland IPC (bread-capture's
isolation.rs), so it can never exercise a nonzero window count and prove
dot widths visually. Pulled the count->dot_widths-index mapping out of
make_dot_button as dot_width_index(), a pure function with no GTK
dependency, and unit-tested it directly (0/1/2 pass through, 3+ collapses
onto index 3, negative counts clamp instead of underflowing the array
index) as the stand-in for that visual proof.
2026-08-31 15:45:25 +08:00
Breadway
1fb10a10cd bar: implement spotlight's embedded launcher capsule (Phase 6b)
- New bread-launcher dep (gtk feature), pinned tag v0.7.4 with a dev-only
  [patch] entry alongside bread-theme's.
- Wire the drawer slot (plan §2/§7): root becomes a vbox whose first row is
  the existing CenterBox and whose second is drawer_box, appended via the
  same ModuleRegistry::for_each_in_slot pattern left/centre/right already
  use. Empty for liquid-motion/glass-workbench (zero children, zero size);
  theme.rs's window.breadbar > centerbox selector becomes > box > centerbox
  to follow the new nesting, byte-identical CSS for both existing themes.
- modules.workspaces.style = dots: bar::workspaces::make_dot_button, width
  from modules.workspaces.dot_widths, wired as a third rebuild_buttons arm.
- New launcher_entry (plain GtkEntry) and launcher_results
  (bread_launcher::gtk::ResultsList, sharing breadbox's cache/history via
  bread_launcher::LAUNCHER_APP) modules, built unconditionally and placed
  only when a theme's [bar.slots] names them (spotlight today).
- Capsule expand/collapse: typing/focus opens the drawer and animates its
  height via bread_theme::anim::spring_to; Up/Down move the selection,
  Enter launches (do_launch + record_launch) and collapses, Escape collapses
  and releases keyboard focus (on_demand ties to GTK's own focus-widget
  state, so releasing GTK focus hands the compositor keyboard back).
- window.width now wires Width::Px into set_default_width (previously
  unconsumed — Fill-anchored themes never needed it, the capsule does).
- Two new --screenshot views: capsule-collapsed, capsule-expanded (the
  latter focuses launcher_entry, types a query, and captures bar height +
  the drawer's actual settled height rather than a guessed constant).

cargo check/clippy: no new warnings (same two pre-existing: dead_code on
system_stats_box/cpu_pair/mem_pair/pwr_pair, from_* on workspaces.rs).
cargo test: 18/18 unchanged.
2026-08-31 15:45:25 +08:00
Breadway
df6b568cc1 bar: implement glass-workbench's three module variants (Phase 5)
Wires modules.workspaces.style/modules.clock.style into the bar for real
(Phase 3 shipped the schema but only ever consumed trail/flip), and adds
cpu/ram as bar modules, so glass-workbench (bread-theme) renders correctly
while liquid-motion's default path is untouched:

- workspaces: "pill" never calls WorkspaceTrail::place/stretch (the trail
  overlay stays invisible) and honours modules.workspaces.show_empty for
  real, rendering unoccupied non-active workspaces dimmed via CSS instead
  of filtering them out of the row. "trail" keeps the exact pre-existing
  filter/place/stretch behaviour regardless of show_empty.
- clock: "plain" registers a plain date+time label (date_lbl, built but
  never parented until now, plus a new clock_plain_lbl) instead of the
  per-digit flip box. "flip" is untouched.
- cpu/ram: two new bar chips (bar_cpu_pair/bar_ram_pair, bar_cpu_lbl/
  bar_ram_lbl) fed by the same AppInput::StatsUpdate data as the control
  panel's cpu_pair/mem_pair. Separate instances rather than reparenting
  the panel's own pair — a GTK widget can only have one parent, and
  reparenting would pull them out of the control panel's sys-grid, which
  no theme asked to change.

theme.rs::load_css() now branches on tokens.bar_border() (full-border
island vs. flush bar's bottom-only hairline) and modules.workspaces.style
(dimmed/translucent trail-fill buttons vs. solid-accent-fill pills), and
unconditionally gains .clock-plain/.clock-plain-time rules. The trail/flip
branches reproduce today's CSS byte-for-byte.

Verification (bread-capture, isolated headless-Sway harness):
- Noise floor: same baseline binary against itself varies from AE=0 (same
  capture minute) up to ~1900px (an in-flight digit-flip/clock-tick or a
  live Wi-Fi scan straddling two captures) and ~39000px on control-panel
  (live hardware sensors) — all pre-existing, not introduced by this change.
- Regression: this binary vs. the pre-Phase-5 baseline, captured in the
  same clock-minute to remove the dominant noise source, is AE=0 (bit-for-
  bit identical) on the bar view under the default liquid-motion theme.
- glass-workbench (BREAD_SHELL_THEME=glass-workbench): bar view is
  1920x36 (flush, no margin) vs. liquid-motion's 1920x56 (12px margin +
  44px island), square corners vs. liquid-motion's rounded island, plain
  "Mon 24/08 23:24" clock, and cpu/ram/wifi/battery/control chips with no
  media widget — matches demo 02. The isolated Sway capture harness has no
  live Hyprland IPC, so it reports zero workspaces under both themes
  (pre-existing, unrelated to this change) — the pill fill/dim CSS itself
  is exercised by code review and the trail-untouched regression result,
  not by a captured pixel with visible buttons.

cargo test: 18/18 passing (breadbar), no new clippy/cargo-check warnings
beyond the two pre-existing ones this task named up front.
2026-08-31 15:45:25 +08:00
Breadway
8d80a05d9b bar: route Lua widgets to any [bar.slots] slot, not just four fixed spots
Phase 3b of the shell theme system. A slot list entry can now 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. ModuleRegistry::for_each_in_slot creates each
widget container on demand at its slot position; reconcile_widgets routes
each WidgetSpec by module name first, falling back to its placement
alias, and logs+drops (never panics) a spec with no matching container.

bread_shared::widget::WidgetPlacement stays untouched — it's still the
wire type breadd sends, unmodified and unshadowed; only the container
map that placement now resolves through is theme-driven.
2026-08-31 15:45:25 +08:00
Breadway
c5f7dd1ee3 bar: assemble modules from [bar.slots] instead of a fixed source order
Adds ModuleRegistry (src/bar/slots.rs), mapping each [bar.slots] module
name (workspaces/media/clock/volume/wifi/battery/control) to its
already-built widget. main.rs now registers the seven modules once
they're constructed, then walks ShellTheme::slots() to append them
into the left/centre/right containers in theme order instead of a
hardcoded sequence. An unknown module name in a theme manifest is
logged and skipped rather than panicking.

The Lua-declared widget_* containers keep their fixed interleave
(right-of-workspaces, left/right-of-clock, left-of-stats) — that's
Phase 3b, not this change.
2026-08-31 15:45:25 +08:00
Breadway
bbb1a6c8d2 Drive bar/window geometry and CSS tokens from the shell theme manifest
breadbar no longer hardcodes BAR_HEIGHT/BAR_MARGIN_TOP/BAR_MARGIN_SIDES/
CHIP_HEIGHT/ICON_PX or the root window's layer-shell setup: it loads
bread_theme:🐚:ShellTheme once (theme::shell_theme(), cached in a
thread-local) and reads bar.window (anchors, margin, exclusive zone,
keyboard mode - now set explicitly instead of relying on the library
default) plus tokens.{icon_px,chip_height} from it everywhere those used
to be literals.

The four satellite surfaces (breadbar-osd, breadbar-notif, breadbar-panel,
breadbar-dismiss) now get their anchor/margin/width/layer from the
manifest's [surfaces.*] table via a small new surface::apply() helper,
narrowly scoped to the three anchor shapes those surfaces actually use.
The pre-existing 8px gap between the panel's top margin and the dismiss
scrim's is preserved exactly (and now commented) rather than "fixed".

theme.rs's load_css() keeps its ~250 lines of hand-written breadbar CSS
(notifications, wifi popover, control panel, media widget) but now reads
its five radius/pad locals and the two easing curves (spring vs
spring_settle - hover/settle transitions were previously miscategorized
as the overshoot curve in the constant audit; the actual code already
used the settle curve there, confirmed against src) from theme tokens
instead of hardcoding them.

Also wires bread_theme:🐚:watch() so editing the active theme's
theme.toml/extra.css hot-reloads CSS tokens without a restart, same as
a pywal palette change already does. Window-spec values still need a
restart per the plan (read once at window-construction time).

Verified pixel-identical: captured all 10 breadbar --screenshot views
(bar, control-panel, connectivity-wifi/bluetooth, media-popover,
notification/-critical, osd-volume/-brightness, wifi-add-dialog) via
bread-capture's isolated headless-Sway harness against both the
pre-change and post-change binaries; every view diffs byte-identical
at the decoded-pixel level.
2026-08-31 15:45:25 +08:00
Breadway
297207a7aa notifications: stop toast popups from stealing focus or blocking clicks
All checks were successful
dev release / build (push) Successful in 1m42s
Toasts never grab keyboard focus and pass every pointer event through
to whatever's underneath, via an empty layer-shell input region.
2026-08-24 13:11:20 +08:00
Breadway
3049a20be3 gitignore: exclude graphify-out local tool cache
All checks were successful
dev release / build (push) Successful in 1m7s
2026-08-23 15:07:51 +08:00
Breadway
700fc3ed16 Unify bread-ecosystem crate pins at v0.7.4
bread-theme was already pinned to v0.7.4 while bread-utils and
bread-screenshots trailed at v0.7.2, even though bread-ecosystem locks
all workspace packages together as of a9754d9. Bump the two lagging
pins so all three crates from that repo resolve to the same tag/commit.

(cherry picked from commit f0de82aafb547134e0edc9dac8d72c01586db57c)
2026-08-23 15:07:51 +08:00
Breadway
9c4205b1b2 clippy: fix char-comparison and map_or lints
All checks were successful
dev release / build (push) Successful in 1m52s
(cherry picked from commit 2e393082a744fe85f1ca1f8c6af48ce288e64b6c)
2026-08-23 14:57:51 +08:00
Breadway
cd7465a18b Bump version to v0.3.3
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Failing after 1m50s
release / build (push) Successful in 3m11s
2026-08-16 14:09:04 +08:00
Breadway
e4c12e9b62 Add audio output switching and fix the workspace trail
All checks were successful
dev release / build (push) Successful in 1m33s
The control panel lists PipeWire sinks and sets the default, moving
playing streams so the change is immediate. The workspace pill starts
on switch without a layout wait, measures against the Fixed host so it
stays centered on the digit, and ignores row-wide bounds that used to
stretch it across several chips.
2026-08-16 14:04:05 +08:00
Breadway
241dfd17a7 Include GPU.svg so the bar release build can compile
All checks were successful
dev release / build (push) Successful in 2m8s
2026-08-16 13:49:52 +08:00
Breadway
96d666b3cb Fix CI: ship panel.rs and the workspace trail helper
Some checks failed
dev release / build (push) Failing after 9s
mod panel and stretch_geom_on were referenced on main but not
committed, so --locked release builds failed.
2026-08-16 13:45:59 +08:00
Breadway
1806c6f912 Bind each bar to its output palette and finish the island chrome
Some checks failed
dev release / build (push) Failing after 3m37s
One layer-shell window per Hyprland output now loads that output's
bread-theme palette. Notifications, history, and OSD follow the
monitor they appear on. Pin bread-theme to v0.7.4.
2026-08-16 13:23:31 +08:00
Breadway
ae1fee3591 CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 20s
dev release / build (push) Successful in 3m7s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 1m40s
2026-08-16 00:50:22 +08:00
Breadway
62c6dd5ea3 Adopt bread_utils::screenshot_cli for --screenshot flags
All checks were successful
dev release / build (push) Successful in 1m22s
Replace the local settle delay, canvas defaults, and pair-validation
error path with bread-utils v0.7.2. Clap parsing stays in-tree.
2026-08-16 00:27:46 +08:00
Breadway
110f0cd3df Add notification actions and inline reply
Some checks failed
check / check (push) Failing after 10s
dev release / build (push) Successful in 1m0s
Parse FDO action pairs onto popup buttons, emit ActionInvoked on
click (default action on the body), and show an inline-reply field
when senders request it. Closing still emits NotificationClosed.
History persist is unchanged.
2026-08-16 00:26:06 +08:00
Breadway
4fddad510f Persist notification history to XDG state
All checks were successful
dev release / build (push) Successful in 1m32s
2026-08-15 23:11:28 +08:00
Breadway
9691485bd6 Bump version to v0.3.1
Some checks are pending
dev release / build (push) Waiting to run
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 2m23s
2026-08-15 23:05:47 +08:00
Breadway
465088dc55 Add notification history; pin ecosystem crates to v0.7.2
All checks were successful
dev release / build (push) Successful in 1m25s
Keep a bounded in-memory history (last 50) and a layer-shell window
listing app, summary, truncated body, and time. Toggle with
`breadbar --history` or D-Bus `dev.breadway.Bar.ToggleHistory`.

Pin bread-theme, bread-utils, and bread-screenshots to tag v0.7.2.
bread-shared stays on bread v0.8.0-rc.1.
2026-08-15 22:54:04 +08:00
Breadway
614dca71af ci: port breadbar onto shared bread-ecosystem build system
All checks were successful
dev release / build (push) Successful in 2m36s
Delegates dev/rc/release build steps to bread-ecosystem's pinned Arch
container via ci/build.sh, pinned to bread-ecosystem commit
147cfbbf96ae4b171027defa1130d2caddb934b1. Adds a check.yml workflow
for fast clippy/test signal on feature/fix branches, matching the
pattern proven in breadpad. No ci/deps.txt needed — breadbar's
dependencies (zbus, resvg, gtk4-layer-shell, hyprland) are all pure
Rust or already covered by the shared image's package set.
2026-08-15 22:34:12 +08:00
Breadway
89f7a93e8a Remove CLAUDE.md (renamed to AGENTS.md)
All checks were successful
dev release / build (push) Successful in 2m30s
2026-08-15 22:04:00 +08:00
Breadway
92f2e52c1f Rename CLAUDE.md to AGENTS.md 2026-08-15 22:03:30 +08:00
Breadway
be0b54e1ea Pin ecosystem crates off floating main; bakery pulls bread
bread-theme moves to git.breadway.dev v0.7.1. bread-utils and
bread-screenshots pin ecosystem origin/main (v0.7.1 has no
BreadClient::request and predates the screenshots crate). bread-shared
pins bread v0.8.0-rc.1 — latest existing tag with widget wire types
(v0.7.0 has none; v0.8.0 was never cut).

bakery.toml sets bread_deps = ["bread"] so the daemon is installed
alongside the bar. CLAUDE.md records the single-trunk workflow and
the bar / notifications / OSD / widgets split.
2026-08-15 21:40:24 +08:00
Breadway
d8c766abe4 Repoint bread-ecosystem/bread git deps from dev to main branch
All checks were successful
dev release / build (push) Successful in 6m31s
2026-07-31 14:30:01 +08:00
Breadway
bba4aa2d2b CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 6m5s
2026-07-31 11:08:41 +08:00
Breadway
9dc82817e9 CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
1752db84c8 Merge feature/wifi-add-dialog-csd: proper CSD for the wifi add-network dialog
All checks were successful
dev release / build (push) Successful in 4m24s
2026-07-30 19:05:22 +08:00
Breadway
c53d504208 breadbar: give the wifi add-network dialog proper CSD + rounded corners
Was a bare gtk4::Window with no titlebar — GTK4's own minimal CSD
fallback for that case is a flat bar with plain system-font title text
and square corners, which is what actually made this look like a stray
window from a different decade next to the rest of the (rounded,
borderless) ecosystem. Now sets a real HeaderBar (which picks up the
window's existing title automatically) and gives the dialog + headerbar
matching rounded corners and dark theming via the existing
.wifi-add-dialog CSS class.

Also switched the Connect button off "suggested-action": GTK4's bundled
theme special-cases that exact class name for a native OS-accent-colour
feature that isn't a normal CSS rule and doesn't yield to any
background-color override this stylesheet adds, confirmed empirically
(a much more specific selector had zero effect). Moved to
"confirm-button", the same accent-button convention breadman/breadpad
already use.

Known residual issue, not resolved here: the Connect button still
doesn't render the accent colour even via "confirm-button" — narrowed
down to *some* button-specific styling quirk in this one dialog (window-
level rules like the rounded corners and dark background apply
correctly; a maximally-obvious magenta test rule on the same selector
also failed to render, ruling out a color-value mistake specifically),
but not root-caused further. Left as a known follow-up rather than
blocking the CSD fix, which was the actual ask.

Also removed two `cursor: pointer` CSS properties (on .clickable and
.media-widget) that GTK4 doesn't support as a CSS property at all — was
logging "Theme parser error: No property named cursor" on every
startup, doing nothing functionally either way.
2026-07-30 19:02:39 +08:00
Breadway
872e7e1589 Merge feature/screenshot-mode: capture every breadbar view
All checks were successful
dev release / build (push) Successful in 4m29s
2026-07-29 21:57:55 +08:00
Breadway
566aeeed8b breadbar: capture every view, not just bar/control-panel
breadbar is "a bar + the notification daemon + the OSD" — the screenshot
mode only covered the bar and its control-panel popover, missing seven
more distinct surfaces: the WiFi/Bluetooth connectivity popover (both
tabs), the media-controls popover, the standalone notification window
(both normal and critical urgency), the standalone OSD window (volume
and brightness), and the wifi add-network dialog. All ten views are now
--screenshot targets.

Two real refactors needed to make the standalone notification/OSD
windows screenshot-able at all, not just bigger match arms:

- Both windows are built deep inside an async task (`run_osd`/
  `popup::run`), only reachable after the real event loop starts — no
  window handle ever existed for a caller to hook `connect_map` on before
  that. Window construction is now synchronous in `osd::spawn`/
  `notifications::spawn`, handed to the async loop as a parameter instead
  of created inside it.

- Screenshot mode seeds each with one fixed sample event (SampleKind) via
  the same channel the real pactl/backlight/D-Bus sources feed, instead
  of waiting for real hardware/dbus activity. For notifications
  specifically this also means skipping the real
  org.freedesktop.Notifications D-Bus registration entirely in screenshot
  mode — claiming that well-known name would just race the real breadbar
  (if running) for it, for no benefit, since nothing external needs to
  reach a screenshot-only instance.

show_add_network_dialog gained an `on_build` hook (called before
`.present()`, the only point `connect_map` can still catch the map) so
screenshot mode can capture it without changing its one real call site's
behavior — and its `anchor` parameter widened from `&Button` to
`&impl IsA<Widget>` since the screenshot path anchors off a
`ToggleButton`, not a `Button`.
2026-07-29 17:17:17 +08:00
Breadway
059e11cdeb Bump bread-utils/bread-screenshots lockfile refs to bread-ecosystem's headless-Sway dev commit 2026-07-29 11:23:20 +08:00
Breadway
ac6ccfe88a Add --screenshot CLI mode for automated capture
Renders a single view ("bar" or "control-panel"), captures it via
bread-screenshots, then exits — driven by bread-ecosystem's bread-capture
orchestrator, or runnable standalone for one-off captures.

Waits on GTK's map signal (plus a short settle delay) before capturing
rather than guessing a fixed sleep upfront: the control-panel view in
particular needs the popover's autohide disabled (a programmatic popup()
has no input-event serial to grab the Wayland seat with) and a longer
settle window so the CPU/RAM/PWR/GPU/network stats — populated by a
2-second background poller, not the popover's own open — have had at
least one full cycle to fill in before the capture fires.

--width/--height match whatever canvas bread-capture's isolation sizes
the compositor to, so the "bar" view's geometry doesn't depend on
querying anything at capture time.

Needs RelmApp::with_args(vec![]) (GLib's own arg parser otherwise rejects
--screenshot/--output before clap ever sees them) and
allow_multiple_instances(true) for screenshot runs specifically, since
GApplication is single-instance by default and this machine typically
already has a real breadbar running.
2026-07-29 11:15:57 +08:00
Breadway
a00934a53d update deps
All checks were successful
dev release / build (push) Successful in 1m27s
2026-07-23 11:48:36 +08:00
Breadway
56e8b02599 Will change this commit message to mean something later
All checks were successful
dev release / build (push) Successful in 1m30s
2026-07-23 11:25:06 +08:00
Breadway
f5daf902ce Drop pacman packaging, bakery-only distribution
Some checks failed
dev release / build (push) Failing after 1s
bakery already fully covers what the PKGBUILD provided (binary, dependency
declarations) except a LICENSE copy, which bakery.toml's new license_file
field now closes. Removes packaging/arch/ and .forgejo/workflows/package.yml;
adds the LICENSE artifact to each release/dev-release/beta-release
workflow's prepare step. Not pacman-installed inside BOS today (BOS already
consumes these apps exclusively via build-local.sh's skel-staging), so this
only removes the option to `pacman -S` outside of BOS/bakery.
2026-07-23 10:27:52 +08:00
9b5b475036 Merge pull request 'fix: honor x-canonical-private-synchronous hint for notification replacement' (#3) from fix/2 into dev
Some checks failed
dev release / build (push) Failing after 1s
Reviewed-on: #3
2026-07-23 10:15:59 +08:00
Breadway
552d771e15 fix: honor x-canonical-private-synchronous hint for notification replacement
notify-send-based senders (e.g. breadcrumbs) fire a new process per
notification, so replaces_id is always 0 — they instead tag related
notifications with the x-canonical-private-synchronous hint and expect
the daemon to replace whatever's currently showing from that app. We
weren't honoring it, so a persistent (Expire::Never) critical
notification like breadcrumbs' "no Wi-Fi adapter" during a suspend
transition could never be superseded by a later notification, leaving
it stuck until breadbar was restarted. Fixes #2.
2026-07-23 10:12:52 +08:00
Breadway
175af5d483 Will change this commit message to mean something later
Some checks failed
dev release / build (push) Failing after 2s
2026-07-22 19:53:51 +08:00
Breadway
905d91580d docs: add CONTRIBUTING.md
All checks were successful
dev release / build (push) Successful in 1m47s
Documents the dev/beta/main branch and release-track workflow shared
across the bread ecosystem. See bread-ecosystem's docs/release-channels.md
for the full policy this implements.
2026-07-22 19:40:34 +08:00
Breadway
5af8b6097d ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
dev release / build (push) Successful in 2m13s
beta release / build (push) Successful in 1m59s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:15 +08:00
Breadway
e8cd2c88bc ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
dev release / build (push) Successful in 1m47s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:51:44 +08:00
Breadway
15f2b111f1 random commit message, read it yourself
All checks were successful
dev release / build (push) Successful in 1m34s
2026-07-22 11:44:24 +08:00
Breadway
3996bce3a9 ci: use a unique temp dir for the bread-ecosystem clone in dev/beta CI
All checks were successful
dev release / build (push) Successful in 2m21s
The fixed /tmp/bread-ecosystem-ci path races when multiple repos' dev/beta
workflows run close together on the same self-hosted runner — one job's
rm -rf/clone can stomp another's in-progress checkout, causing the
regenerate-index step to fail intermittently. Switch to mktemp -d.
2026-07-22 10:24:13 +08:00
Breadway
4d33c0e9ae ci: retrigger dev-track build now that BAKERY_MINISIGN_SEC_KEY_PATH is set
All checks were successful
dev release / build (push) Successful in 3m19s
2026-07-22 10:10:00 +08:00
Breadway
174440bcbf ci: add dev/beta build track workflows
Some checks failed
dev release / build (push) Failing after 2m11s
Adds dev-release.yml (publishes on every push to dev) and beta-release.yml
(publishes on a beta-v* tag), mirroring the pattern landing in
bread-ecosystem/bread. See bread-ecosystem/docs/release-channels.md for
the three-track policy.
2026-07-22 09:58:03 +08:00
Breadway
d6baab24fc ci: remove GitHub push-mirror workflow 2026-07-21 19:17:42 +08:00
37 changed files with 8228 additions and 1024 deletions

View file

@ -0,0 +1,24 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**']
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --locked

View file

@ -0,0 +1,76 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk
# branch — there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadbar/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64"
strip "${PKG_DIR}/breadbar-x86_64"
sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadbar-x86_64.sha256"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbar/latest"
# No GitHub Release upload — dev, like the other non-stable track,
# is only distributed via dl.breadway.dev/dev/.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -1,19 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbar.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell libpulse iw
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="breadbar-${VERSION}/" HEAD \
> packaging/arch/breadbar-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/breadbar-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -0,0 +1,57 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# pushed to `main` — there is no separate `beta` branch; "freezing" is
# just pausing pushes to main while an RC gets tested. See
# bread-ecosystem's docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadbar/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/breadbar" "${PKG_DIR}/breadbar-x86_64"
strip "${PKG_DIR}/breadbar-x86_64"
sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadbar-x86_64.sha256"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbar/latest"
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -6,6 +6,7 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -16,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -28,12 +38,19 @@ jobs:
strip "${PKG_DIR}/breadbar-x86_64" strip "${PKG_DIR}/breadbar-x86_64"
sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \ sha256sum "${PKG_DIR}/breadbar-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/breadbar-x86_64.sha256" > "${PKG_DIR}/breadbar-x86_64.sha256"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml" cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadbar/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

6
.gitignore vendored
View file

@ -35,3 +35,9 @@ 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/

51
AGENTS.md Normal file
View file

@ -0,0 +1,51 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI — plus a
short map of the binary. It is not user-facing project documentation.
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
— read and follow it for any git, branch, or release work here (the
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
etc). Don't improvise a different workflow. The short version: there is one
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
"Freezing" for stabilization means pausing pushes to `main`, not moving a
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
after `main` was found to have silently rotted out of sync with `dev`/`beta`
across most repos in this ecosystem.
When starting work on a new feature, create branch `feature/<feature-name>`.
When working on a bug or issue, create branch `fix/<issue you are fixing>`.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push `origin` only; GitHub auto-mirrors.
## CI
- `dev-release.yml` triggers on `push: branches: ['main']`.
- `rc-release.yml` triggers on `vX.Y.Z-rc.N` tag pushes (beta track).
- `release.yml` triggers on any other `v*` tag push (stable).
None of these run on plain commits or PRs beyond what's listed.
## Architecture
One GTK4/`relm4` binary, four surfaces:
| Area | Path | Role |
|---|---|---|
| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray |
| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups + in-memory history (`breadbar --history`) |
| OSD | `src/osd.rs` | Volume/brightness overlay |
| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` |
`--screenshot` (`src/screenshot.rs`) captures those views through
`bread-screenshots`; do not rewrite it just to retarget the crate pin.
`application_id` drift vs Hyprland layer-rules/tour docs is known — leave it
unless every mention is updated in the same change.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't rewrite the widget system or `screenshot.rs` as part of pin/docs work.

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# Contributing
`breadbar` — Minimal status bar and notification daemon for Hyprland.
Part of the bread ecosystem; this repo follows the same branch/release
workflow as every other ecosystem product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release
cargo test --release
```
## CI
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

720
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadbar" name = "breadbar"
version = "0.3.0" version = "0.3.3"
edition = "2021" edition = "2021"
description = "Minimal status bar and notification daemon for Hyprland on Wayland" description = "Minimal status bar and notification daemon for Hyprland on Wayland"
license = "MIT" license = "MIT"
@ -10,7 +10,22 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"]
categories = ["gui"] categories = ["gui"]
[dependencies] [dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["gtk"] }
# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe)
# for talking to breadd's IPC socket, and bread-shared purely for the
# WidgetSpec/WidgetNode wire types so we deserialize into real structs
# 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_shared::widget wire types; match the bread-shared tag bread-utils
# pins (v0.8.0) so only one copy of bread-shared is linked.
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5" }
# 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"] }
@ -20,9 +35,11 @@ zbus = { version = "5", default-features = false, features = ["tokio"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "signal", "sync"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
# Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons # Pure-Rust SVG rasteriser (default features off → no text/font deps; the icons
# are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader.
resvg = { version = "0.44", default-features = false } resvg = { version = "0.47", default-features = false }
[profile.release] [profile.release]
lto = "thin" lto = "thin"

View file

@ -33,6 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da
- Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.)
- Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s)
- Supports `CloseNotification` and `replaces_id` - Supports `CloseNotification` and `replaces_id`
- History of the last 50 notifications (app, summary, truncated body, time). Loaded from and saved to `$XDG_STATE_HOME/breadbar/history.json` (typically `~/.local/state/breadbar/history.json`). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`.
**Volume/brightness OSD**: **Volume/brightness OSD**:
@ -138,9 +139,11 @@ Example — change the font size:
| `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | | `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration |
| `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) |
| `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering |
| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | | `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC |
| `src/notifications/popup.rs` | Layer-shell popup window and card stack | | `src/notifications/popup.rs` | Layer-shell popup window and card stack |
| `src/notifications/history.rs` | Bounded history (last 50, persisted under XDG state) and layer-shell history window |
| `src/osd.rs` | Volume/brightness on-screen display | | `src/osd.rs` | Volume/brightness on-screen display |
| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) |
| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection |
Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`. Stats are polled every 2 seconds. Bluetooth and WiFi are sampled every 16 seconds and cached in between to avoid hammering D-Bus and `iw`.

1
assets/GPU.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><rect x="8" y="9" width="6" height="6" rx="1"/><path d="M7 18v2"/><path d="M12 18v2"/><path d="M17 18v2"/><path d="M17 6V4"/></svg>

After

Width:  |  Height:  |  Size: 362 B

View file

@ -3,7 +3,8 @@ description = "Minimal status bar and notification daemon for Hyprland"
binaries = ["breadbar"] binaries = ["breadbar"]
system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"] system_deps = ["gtk4", "gtk4-layer-shell", "wireplumber", "pipewire-pulse", "brightnessctl", "iw"]
optional_system_deps = ["hyprland"] optional_system_deps = ["hyprland"]
bread_deps = [] bread_deps = ["bread"]
license_file = "LICENSE"
[config] [config]
dir = "~/.config/breadbar" dir = "~/.config/breadbar"

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
147cfbbf96ae4b171027defa1130d2caddb934b1

21
ci/build.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift (see the bread-theme test
# that broke here for exactly that reason, before it was pinned by rev).
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
if [ ! -d "$CACHE_DIR" ]; then
rm -rf /tmp/bread-ecosystem-ci-*
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
bash "${CACHE_DIR}/ci/build.sh" breadbar "$ROOT" "$@"

View file

@ -1,36 +0,0 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=breadbar
pkgver=0.2.0
pkgrel=1
pkgdesc="Minimal status bar and notification daemon for Hyprland"
arch=('x86_64')
url="https://git.breadway.dev/Breadway/breadbar"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'gtk4-layer-shell' 'wireplumber' 'pipewire-pulse' 'brightnessctl' 'iw')
optdepends=(
'hyprland: workspace and window data integration'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadbar "${pkgdir}/usr/bin/breadbar"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

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

View file

@ -121,11 +121,29 @@ pub fn spawn_set_brightness(v: f64) {
}); });
} }
pub fn spawn_set_sink(name: String) { pub fn spawn_set_sink(name: String, sender: ComponentSender<App>) {
relm4::spawn(async move { relm4::spawn(async move {
let _ = tokio::process::Command::new("pactl") let _ = tokio::process::Command::new("pactl")
.args(["set-default-sink", &name]) .args(["set-default-sink", &name])
.output() .output()
.await; .await;
// Default sink alone leaves already-playing streams on the old
// device — move them too so the switch is audible immediately.
if let Ok(o) = tokio::process::Command::new("pactl")
.args(["list", "short", "sink-inputs"])
.output()
.await
{
for line in String::from_utf8_lossy(&o.stdout).lines() {
let Some(id) = line.split_whitespace().next() else {
continue;
};
let _ = tokio::process::Command::new("pactl")
.args(["move-sink-input", id, &name])
.output()
.await;
}
}
spawn_load(sender);
}); });
} }

View file

@ -2,6 +2,7 @@ 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;

86
src/bar/slots.rs Normal file
View file

@ -0,0 +1,86 @@
//! 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

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

View file

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

View file

@ -1,7 +1,12 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use futures_lite::StreamExt; use futures_lite::StreamExt;
use gtk4::glib::ControlFlow;
use gtk4::prelude::*; use gtk4::prelude::*;
use hyprland::{ use hyprland::{
data::{Workspace, Workspaces}, data::{Monitors, Workspaces},
event_listener::{Event, EventStream}, event_listener::{Event, EventStream},
prelude::*, prelude::*,
shared::WorkspaceId, shared::WorkspaceId,
@ -10,16 +15,62 @@ use relm4::ComponentSender;
use crate::AppInput; use crate::AppInput;
/// Fetches the current workspace list + active workspace and pushes both to /// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config
/// the app — used both for the initial state and to re-sync after the event /// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is
/// stream reconnects (state may have changed while we were disconnected). /// a syntax error — the working form is `hl.dsp.focus({workspace=N})`.
async fn switch_workspace(id: hyprland::shared::WorkspaceId) {
let arg = id.to_string();
let stock = tokio::process::Command::new("hyprctl")
.args(["dispatch", "workspace", &arg])
.output()
.await;
if let Ok(o) = &stock {
let err = String::from_utf8_lossy(&o.stderr);
let out = String::from_utf8_lossy(&o.stdout);
if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") {
return;
}
}
let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))");
let lua = tokio::process::Command::new("hyprctl")
.args(["eval", &expr])
.output()
.await;
match lua {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"breadbar: workspace {arg}: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => eprintln!("breadbar: workspace {arg}: {e}"),
}
}
/// Stretch to the old→new span, then snap onto the destination — CSS
/// transitions cannot widen a pill across two buttons, so the trail's
/// Fixed allocation is interpolated on the frame clock instead.
const STRETCH_MS: f64 = 220.0;
const SNAP_MS: f64 = 380.0;
/// Full workspace + per-monitor active snapshot. Each bar filters this to
/// its own output so a second display does not inherit the laptop's set.
async fn sync_state(sender: &ComponentSender<crate::App>) { async fn sync_state(sender: &ComponentSender<crate::App>) {
if let Ok(ws) = Workspaces::get_async().await { let workspaces = Workspaces::get_async()
sender.input(AppInput::WorkspaceList(ws.to_vec())); .await
.map(|w| w.to_vec())
.unwrap_or_default();
let mut actives = std::collections::HashMap::new();
if let Ok(mons) = Monitors::get_async().await {
for m in mons {
if !m.disabled {
actives.insert(m.name, m.active_workspace.id);
} }
if let Ok(active) = Workspace::get_active_async().await {
sender.input(AppInput::ActiveWorkspace(active.id));
} }
}
sender.input(AppInput::WorkspaceSync {
workspaces,
actives,
});
} }
pub fn spawn_watcher(sender: ComponentSender<crate::App>) { pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
while let Some(Ok(event)) = stream.next().await { while let Some(Ok(event)) = stream.next().await {
backoff = std::time::Duration::from_millis(500); backoff = std::time::Duration::from_millis(500);
match event { match event {
Event::WorkspaceChanged(data) => { Event::WorkspaceChanged(_)
sender.input(AppInput::ActiveWorkspace(data.id)); | Event::WorkspaceAdded(_)
| Event::WorkspaceDeleted(_) => {
sync_state(&sender).await;
} }
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { Event::MonitorAdded(data) => {
if let Ok(ws) = Workspaces::get_async().await { sender.input(AppInput::MonitorAdded(data.name));
sender.input(AppInput::WorkspaceList(ws.to_vec())); sync_state(&sender).await;
} }
Event::MonitorRemoved(name) => {
sender.input(AppInput::MonitorRemoved(name));
sync_state(&sender).await;
}
Event::ActiveWindowChanged(_) => {
sender.input(AppInput::DismissPanels);
} }
_ => {} _ => {}
} }
@ -65,17 +124,556 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
}); });
} }
pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button { pub fn make_button(
id: WorkspaceId,
name: &str,
active: WorkspaceId,
occupied: bool,
) -> gtk4::Button {
let btn = gtk4::Button::with_label(name); let btn = gtk4::Button::with_label(name);
btn.add_css_class("workspace-btn"); btn.add_css_class("workspace-btn");
if occupied {
btn.add_css_class("occupied");
}
if id == active { if id == active {
btn.add_css_class("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);
// `crate::theme::approved_chip_height`, not `tokens().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() {
child.set_halign(gtk4::Align::Center);
child.set_valign(gtk4::Align::Center);
}
btn.connect_clicked(move |_| { btn.connect_clicked(move |_| {
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; relm4::spawn(async move {
let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id( switch_workspace(id).await;
id, });
)));
}); });
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)]
struct Geom {
x: f64,
y: f64,
w: f64,
h: f64,
}
struct TrailInner {
tick: Option<gtk4::TickCallbackId>,
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's measured size comes from the button row; the pill is the
/// main child so it paints underneath and never steals clicks.
pub struct WorkspaceTrail {
pub overlay: gtk4::Overlay,
pub buttons: gtk4::Box,
host: gtk4::Fixed,
pill: gtk4::Box,
inner: Rc<RefCell<TrailInner>>,
}
impl WorkspaceTrail {
pub fn new() -> Self {
let overlay = gtk4::Overlay::new();
overlay.add_css_class("workspace-overlay");
overlay.set_valign(gtk4::Align::Center);
overlay.set_vexpand(false);
let host = gtk4::Fixed::new();
host.set_can_target(false);
let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
pill.add_css_class("workspace-trail");
pill.set_can_target(false);
pill.set_visible(false);
host.put(&pill, 0.0, 0.0);
let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 1);
buttons.set_halign(gtk4::Align::Fill);
buttons.set_valign(gtk4::Align::Center);
buttons.set_vexpand(false);
overlay.set_child(Some(&host));
overlay.add_overlay(&buttons);
overlay.set_measure_overlay(&buttons, true);
let inner = Rc::new(RefCell::new(TrailInner {
tick: None,
natural: None,
geom: Geom {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
},
}));
Self {
overlay,
buttons,
host,
pill,
inner,
}
}
pub fn cancel(&self) {
if let Some(id) = self.inner.borrow_mut().tick.take() {
id.remove();
}
}
pub fn clear(&self) {
self.cancel();
self.pill.set_visible(false);
self.inner.borrow_mut().geom.w = 0.0;
}
pub fn place(&self, btn: &gtk4::Button) {
self.cancel();
if let Some(g) = button_geom(btn, &self.host) {
apply_geom(&self.host, &self.pill, &self.inner, &inset_pill(g));
return;
}
let pill = self.pill.clone();
let host = self.host.clone();
let inner = self.inner.clone();
let btn = btn.clone();
let id = self.overlay.add_tick_callback(move |_, _| {
let Some(g) = button_geom(&btn, &host) else {
return ControlFlow::Continue;
};
apply_geom(&host, &pill, &inner, &inset_pill(g));
inner.borrow_mut().tick = None;
ControlFlow::Break
});
self.inner.borrow_mut().tick = Some(id);
}
pub fn stretch(&self, from: Option<&gtk4::Button>, to: &gtk4::Button) {
self.cancel();
let Some(from_g) = self.from_geom(from, to) else {
self.place(to);
return;
};
let dest = to.clone();
let pill = self.pill.clone();
let host = self.host.clone();
let inner = self.inner.clone();
let started = Instant::now();
let id = self.overlay.add_tick_callback(move |_, _| {
let to_g = resolved_dest(&dest, &host, &from_g);
let mid = {
let span_x = from_g.x.min(to_g.x);
let span_w = (from_g.x + from_g.w).max(to_g.x + to_g.w) - span_x;
Geom {
x: span_x,
y: to_g.y,
w: span_w,
h: to_g.h,
}
};
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
let (g, done) = if elapsed < STRETCH_MS {
let t = ease(elapsed / STRETCH_MS);
(lerp_geom(&from_g, &mid, t), false)
} else if elapsed < STRETCH_MS + SNAP_MS {
let t = ease_overshoot((elapsed - STRETCH_MS) / SNAP_MS);
(lerp_geom(&mid, &to_g, t), false)
} else {
(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);
if done {
inner.borrow_mut().tick = None;
ControlFlow::Break
} else {
ControlFlow::Continue
}
});
self.inner.borrow_mut().tick = Some(id);
}
// `from` is a noun here (the source button we animate away from), not a
// 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 live = if self.pill.is_visible() && st.geom.w > 0.5 {
Some(st.geom)
} else {
None
};
let cached = st.natural;
drop(st);
// 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,
}
}
}
/// Keep the trail slimmer than the hit target so the fill doesn't look
/// like a second, fatter button.
const PILL_INSET_X: f64 = 5.0;
const PILL_INSET_Y: f64 = 3.0;
/// One workspace chip is a digit + padding. Wider than this is the overlay
/// or the whole button row leaking through `compute_bounds`.
const MAX_CHIP_W: f64 = 72.0;
fn inset_pill(g: Geom) -> Geom {
let w = (g.w - PILL_INSET_X * 2.0).max(10.0);
let h = (g.h - PILL_INSET_Y * 2.0).max(18.0);
Geom {
x: g.x + (g.w - w) * 0.5,
y: g.y + (g.h - h) * 0.5,
w,
h,
}
}
fn resolved_dest(btn: &gtk4::Button, host: &gtk4::Fixed, from: &Geom) -> Geom {
match button_geom(btn, host) {
Some(g) if !still_placeholder(btn, &g) => inset_pill(g),
Some(g) => {
let centered = inset_pill(g);
Geom {
x: centered.x + (centered.w - from.w) * 0.5,
y: centered.y + (centered.h - from.h) * 0.5,
w: from.w,
h: from.h,
}
}
None => *from,
}
}
/// Position in the Fixed host's space — that's what `host.move_` uses.
/// Measuring against the Overlay instead left the pill a few px left of
/// the digit whenever the host and overlay origins disagreed.
fn button_geom(btn: &gtk4::Button, host: &gtk4::Fixed) -> Option<Geom> {
let r = btn.compute_bounds(host)?;
let w = f64::from(r.width());
let h = f64::from(r.height());
if w < 8.0 || h < 8.0 || w > MAX_CHIP_W {
return None;
}
Some(Geom {
x: f64::from(r.x()),
y: f64::from(r.y()),
w,
h,
})
}
fn apply_geom(host: &gtk4::Fixed, pill: &gtk4::Box, inner: &Rc<RefCell<TrailInner>>, g: &Geom) {
inner.borrow_mut().geom = Geom {
x: g.x,
y: g.y,
w: g.w,
h: g.h,
};
let w = g.w.max(1.0).round() as i32;
let h = g.h.max(1.0).round() as i32;
// Clearing first lets GTK shrink; size-request is a minimum.
pill.set_size_request(-1, -1);
pill.set_size_request(w, h);
host.move_(pill, g.x, g.y);
pill.set_visible(true);
}
fn still_placeholder(btn: &gtk4::Button, g: &Geom) -> bool {
let (min_w, nat_w, _, _) = btn.measure(gtk4::Orientation::Horizontal, -1);
g.w <= f64::from(min_w) + 1.0 || g.w + 0.5 < f64::from(nat_w)
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom {
Geom {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t),
}
}
fn ease(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap.
fn ease_overshoot(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let c = 1.4;
let t1 = t - 1.0;
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
}
/// 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);
}
}

53
src/launcher_command.rs Normal file
View file

@ -0,0 +1,53 @@
//! 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

@ -0,0 +1,458 @@
use std::collections::VecDeque;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use gtk4::prelude::*;
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use serde::{Deserialize, Serialize};
use super::Urgency;
pub const LIMIT: usize = 50;
const BODY_MAX_CHARS: usize = 96;
pub type Store = Arc<Mutex<VecDeque<Entry>>>;
#[derive(Debug, Clone)]
pub struct Entry {
pub id: u32,
pub app_name: String,
pub summary: String,
pub body: String,
pub urgency: Urgency,
pub received: SystemTime,
}
pub struct Ui {
pub window: gtk4::Window,
pub list: gtk4::Box,
pub store: Store,
}
pub fn new_store() -> Store {
Arc::new(Mutex::new(VecDeque::new()))
}
/// Load the last [`LIMIT`] entries from `$XDG_STATE_HOME/breadbar/history.json`
/// (or `~/.local/state/breadbar/history.json`). Missing or corrupt files
/// yield an empty store — never fail startup.
pub fn load_store() -> Store {
let store = new_store();
if let Some(path) = history_path() {
load_into(&store, &path);
}
store
}
/// Next D-Bus notification id so persisted rows are not replaced on restart.
pub fn next_id(store: &Store) -> u32 {
store
.lock()
.unwrap()
.iter()
.map(|e| e.id)
.max()
.unwrap_or(0)
.saturating_add(1)
.max(1)
}
/// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`].
pub fn record(store: &Store, entry: Entry) {
let mut hist = store.lock().unwrap();
if let Some(pos) = hist.iter().position(|e| e.id == entry.id) {
hist.remove(pos);
}
hist.push_front(entry);
while hist.len() > LIMIT {
hist.pop_back();
}
}
/// Best-effort write of the in-memory store (already bounded) to the
/// XDG state file. Failures are silent — history stays in memory.
pub fn persist(store: &Store) {
if let Some(path) = history_path() {
let _ = persist_to(store, &path);
}
}
fn history_path() -> Option<PathBuf> {
Some(state_dir()?.join("history.json"))
}
fn state_dir() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("breadbar"));
}
}
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".local/state/breadbar"))
}
#[derive(Serialize, Deserialize)]
struct PersistedEntry {
id: u32,
app_name: String,
summary: String,
body: String,
urgency: String,
received_unix: u64,
}
fn urgency_name(u: Urgency) -> &'static str {
match u {
Urgency::Low => "low",
Urgency::Normal => "normal",
Urgency::Critical => "critical",
}
}
fn urgency_from_name(s: &str) -> Urgency {
match s {
"low" => Urgency::Low,
"critical" => Urgency::Critical,
_ => Urgency::Normal,
}
}
fn to_persisted(entry: &Entry) -> PersistedEntry {
let received_unix = entry
.received
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
PersistedEntry {
id: entry.id,
app_name: entry.app_name.clone(),
summary: entry.summary.clone(),
body: entry.body.clone(),
urgency: urgency_name(entry.urgency).into(),
received_unix,
}
}
fn from_persisted(entry: PersistedEntry) -> Entry {
Entry {
id: entry.id,
app_name: entry.app_name,
summary: entry.summary,
body: entry.body,
urgency: urgency_from_name(&entry.urgency),
received: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.received_unix),
}
}
fn persist_to(store: &Store, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let payload: Vec<PersistedEntry> = store.lock().unwrap().iter().map(to_persisted).collect();
let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, bytes)?;
fs::rename(&tmp, path)
}
fn load_into(store: &Store, path: &Path) {
let Ok(bytes) = fs::read(path) else {
return;
};
let Ok(parsed) = serde_json::from_slice::<Vec<PersistedEntry>>(&bytes) else {
return;
};
let mut hist = store.lock().unwrap();
hist.clear();
for entry in parsed.into_iter().take(LIMIT) {
hist.push_back(from_persisted(entry));
}
}
pub fn build_window(store: Store) -> Ui {
let window = gtk4::Window::new();
window.add_css_class("breadbar-history");
window.init_layer_shell();
window.set_namespace(Some("breadbar-notif"));
crate::surface::apply(&window, "breadbar-notif");
// Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast
// popup's width — see `surface::apply`'s doc comment): the history
// window genuinely wants a different width on the same namespace, and
// that isn't something the manifest schema models today. Both calls
// 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_size_request(360, -1);
window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);
let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
outer.set_margin_top(10);
outer.set_margin_bottom(10);
outer.set_margin_start(10);
outer.set_margin_end(10);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let title = gtk4::Label::new(Some("Notifications"));
title.add_css_class("history-title");
title.set_xalign(0.0);
title.set_hexpand(true);
header.append(&title);
let close_btn = gtk4::Button::with_label("Close");
close_btn.add_css_class("flat");
close_btn.add_css_class("history-close");
let win_close = window.clone();
close_btn.connect_clicked(move |_| {
win_close.set_visible(false);
});
header.append(&close_btn);
outer.append(&header);
let list = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let scroll = gtk4::ScrolledWindow::new();
scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
scroll.set_propagate_natural_height(true);
scroll.set_max_content_height(480);
scroll.set_min_content_width(320);
scroll.set_child(Some(&list));
outer.append(&scroll);
window.set_child(Some(&outer));
let win_esc = window.clone();
let keys = gtk4::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Escape {
win_esc.set_visible(false);
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
window.add_controller(keys);
window.connect_close_request(|w| {
w.set_visible(false);
gtk4::glib::Propagation::Stop
});
Ui {
window,
list,
store,
}
}
pub fn toggle(ui: &Ui) {
if ui.window.is_visible() {
ui.window.set_visible(false);
} else {
rebuild(&ui.list, &ui.store);
ui.window.set_visible(true);
}
}
pub fn refresh_if_visible(ui: &Ui) {
if ui.window.is_visible() {
rebuild(&ui.list, &ui.store);
}
}
pub fn rebuild(list: &gtk4::Box, store: &Store) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let entries: Vec<Entry> = store.lock().unwrap().iter().cloned().collect();
if entries.is_empty() {
let empty = gtk4::Label::new(Some("No notifications yet"));
empty.add_css_class("history-empty");
empty.set_xalign(0.0);
list.append(&empty);
return;
}
for entry in entries {
list.append(&make_row(&entry));
}
}
fn make_row(entry: &Entry) -> gtk4::Box {
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
card.add_css_class("notification-card");
card.add_css_class("history-card");
if let Some(class) = entry.urgency.css_class() {
card.add_css_class(class);
}
let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let show_app =
!entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary);
if show_app {
let app = gtk4::Label::new(Some(&entry.app_name));
app.add_css_class("notification-app");
app.set_xalign(0.0);
app.set_hexpand(true);
app.set_ellipsize(gtk4::pango::EllipsizeMode::End);
top.append(&app);
} else {
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
top.append(&spacer);
}
let time = gtk4::Label::new(Some(&format_time(entry.received)));
time.add_css_class("history-time");
time.set_xalign(1.0);
top.append(&time);
card.append(&top);
if !entry.summary.is_empty() {
let summary = gtk4::Label::new(Some(&entry.summary));
summary.add_css_class("notification-summary");
summary.set_xalign(0.0);
summary.set_wrap(true);
summary.set_wrap_mode(gtk4::pango::WrapMode::WordChar);
card.append(&summary);
}
let body = collapse_ws(&entry.body);
if !body.is_empty() {
let body_lbl = gtk4::Label::new(Some(&truncate(&body, BODY_MAX_CHARS)));
body_lbl.add_css_class("notification-body");
body_lbl.add_css_class("history-body");
body_lbl.set_xalign(0.0);
body_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End);
body_lbl.set_max_width_chars(48);
card.append(&body_lbl);
}
card
}
fn format_time(received: SystemTime) -> String {
let Ok(dur) = received.duration_since(SystemTime::UNIX_EPOCH) else {
return "--:--".into();
};
let Ok(dt) = gtk4::glib::DateTime::from_unix_local(dur.as_secs() as i64) else {
return "--:--".into();
};
dt.format("%H:%M")
.map(|s| s.to_string())
.unwrap_or_else(|_| "--:--".into())
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate(s: &str, max_chars: usize) -> String {
let mut chars = s.chars();
let taken: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{taken}")
} else {
taken
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: u32, summary: &str) -> Entry {
Entry {
id,
app_name: "app".into(),
summary: summary.into(),
body: String::new(),
urgency: Urgency::Normal,
received: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn record_is_newest_first_and_bounded() {
let store = new_store();
for i in 0..(LIMIT as u32 + 5) {
record(&store, entry(i, &format!("n{i}")));
}
let hist = store.lock().unwrap();
assert_eq!(hist.len(), LIMIT);
assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 4);
assert_eq!(hist.back().unwrap().id, 5);
}
#[test]
fn record_replaces_same_id_and_moves_to_front() {
let store = new_store();
record(&store, entry(1, "old"));
record(&store, entry(2, "other"));
record(&store, entry(1, "new"));
let hist = store.lock().unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].id, 1);
assert_eq!(hist[0].summary, "new");
assert_eq!(hist[1].id, 2);
}
#[test]
fn truncate_adds_ellipsis_past_limit() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hello…");
}
#[test]
fn persist_roundtrip_keeps_newest_first_and_bound() {
let dir = std::env::temp_dir().join(format!(
"breadbar-history-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("history.json");
let store = new_store();
for i in 0..(LIMIT as u32 + 3) {
record(&store, entry(i, &format!("n{i}")));
}
persist_to(&store, &path).unwrap();
let loaded = new_store();
load_into(&loaded, &path);
assert_eq!(next_id(&loaded), LIMIT as u32 + 3);
let hist = loaded.lock().unwrap();
assert_eq!(hist.len(), LIMIT);
assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 2);
assert_eq!(
hist.front().unwrap().summary,
format!("n{}", LIMIT as u32 + 2)
);
drop(hist);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_into_ignores_corrupt_file() {
let dir = std::env::temp_dir().join(format!(
"breadbar-history-bad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("history.json");
fs::write(&path, "not-json").unwrap();
let store = new_store();
load_into(&store, &path);
assert!(store.lock().unwrap().is_empty());
let _ = fs::remove_dir_all(&dir);
}
}

View file

@ -1,10 +1,44 @@
pub mod history;
pub mod popup; pub mod popup;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration; use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use zbus::zvariant::OwnedValue; use zbus::zvariant::OwnedValue;
/// Hint key used by `notify-send` and honored by notify-osd/dunst: senders
/// that fire off a new process per notification (so `replaces_id` is always
/// 0) tag related notifications with the same `(app_name, tag)` pair to mean
/// "replace whatever from this app is already showing." Without honoring
/// this, a fire-and-forget sender can never supersede an earlier
/// `Expire::Never` notification from itself (e.g. a critical hardware
/// warning) — it just piles up a new card next to it forever.
const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous";
/// Spec + GNOME/KDE reserved action id for an inline reply field. Hidden
/// from the button row; submitting the field emits `NotificationReplied`
/// (and `ActionInvoked` with this key). See `popup::emit_replied`.
pub const INLINE_REPLY_KEY: &str = "inline-reply";
/// KDE placeholder hint. Presence (or an `inline-reply` action) is enough
/// to show the reply field — Discord/Telegram use the action, Plasma often
/// only the hint.
const KDE_REPLY_PLACEHOLDER: &str = "x-kde-reply-placeholder";
/// Advertised `GetCapabilities` strings. `body` is the original set;
/// `actions` / `inline-reply` are this change; `body-markup` is the usual
/// companion so senders can ship `<b>`/`<i>` instead of stripping tags.
const CAPABILITIES: &[&str] = &["body", "body-markup", "actions", "inline-reply"];
/// One `(id, localized label)` pair from the Notify `actions` array.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Action {
pub key: String,
pub label: String,
}
/// How long a shown notification should stay up before auto-dismissing. /// How long a shown notification should stay up before auto-dismissing.
/// Distinct from `Option<Duration>` mainly for readability at call sites — /// Distinct from `Option<Duration>` mainly for readability at call sites —
/// `Never` covers both the spec's `expire_timeout == 0` ("never expire") /// `Never` covers both the spec's `expire_timeout == 0` ("never expire")
@ -24,8 +58,12 @@ pub enum NotifEvent {
body: String, body: String,
urgency: Urgency, urgency: Urgency,
expire: Expire, expire: Expire,
actions: Vec<Action>,
/// Placeholder for the inline-reply field, if one should be shown.
inline_reply: Option<String>,
}, },
Close(u32), Close(u32),
ToggleHistory,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -55,6 +93,36 @@ impl Urgency {
} }
} }
/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An
/// unpaired trailing id is ignored. Empty keys are dropped.
fn parse_actions(raw: &[String]) -> Vec<Action> {
raw.chunks_exact(2)
.filter(|c| !c[0].is_empty())
.map(|c| Action {
key: c[0].clone(),
label: c[1].clone(),
})
.collect()
}
/// Show an inline reply field when the sender asked for `inline-reply` or
/// sent the KDE placeholder hint. Placeholder text prefers the hint.
fn inline_reply_placeholder(
actions: &[Action],
hints: &HashMap<String, OwnedValue>,
) -> Option<String> {
let from_hint = hints
.get(KDE_REPLY_PLACEHOLDER)
.and_then(|v| String::try_from(v.clone()).ok())
.filter(|s| !s.is_empty());
let has_action = actions.iter().any(|a| a.key == INLINE_REPLY_KEY);
if has_action || from_hint.is_some() {
Some(from_hint.unwrap_or_else(|| "Reply".into()))
} else {
None
}
}
/// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint
/// was critical) to our internal `Expire`, per the freedesktop notification /// was critical) to our internal `Expire`, per the freedesktop notification
/// spec: `0` always means never expire; a negative value means "server /// spec: `0` always means never expire; a negative value means "server
@ -79,6 +147,45 @@ fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire {
struct NotifServer { struct NotifServer {
tx: mpsc::Sender<NotifEvent>, tx: mpsc::Sender<NotifEvent>,
next_id: AtomicU32, next_id: AtomicU32,
/// (app_name, synchronous-hint tag) -> id, for senders relying on
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
sync_tags: Mutex<HashMap<(String, String), u32>>,
history: history::Store,
/// Unit tests leave this off so `Notify` does not write `$XDG_STATE_HOME`.
persist_history: bool,
}
/// Private breadbar control surface on the same connection as
/// `org.freedesktop.Notifications`. `breadbar --history` is a one-shot
/// client of `ToggleHistory` — there is no other IPC.
struct BarService {
tx: mpsc::Sender<NotifEvent>,
}
#[zbus::interface(name = "dev.breadway.Bar")]
impl BarService {
async fn toggle_history(&self) {
let _ = self.tx.send(NotifEvent::ToggleHistory).await;
}
}
const BAR_DEST: &str = "org.freedesktop.Notifications";
const BAR_PATH: &str = "/dev/breadway/Bar";
const BAR_IFACE: &str = "dev.breadway.Bar";
/// Ask a running breadbar to toggle the history window. Used by
/// `breadbar --history`; does not start a second bar.
pub async fn toggle_history_remote() -> zbus::Result<()> {
let conn = zbus::Connection::session().await?;
conn.call_method(
Some(BAR_DEST),
BAR_PATH,
Some(BAR_IFACE),
"ToggleHistory",
&(),
)
.await?;
Ok(())
} }
#[zbus::interface(name = "org.freedesktop.Notifications")] #[zbus::interface(name = "org.freedesktop.Notifications")]
@ -92,12 +199,28 @@ impl NotifServer {
_app_icon: &str, _app_icon: &str,
summary: &str, summary: &str,
body: &str, body: &str,
_actions: Vec<String>, actions: Vec<String>,
hints: std::collections::HashMap<String, OwnedValue>, hints: std::collections::HashMap<String, OwnedValue>,
expire_timeout: i32, expire_timeout: i32,
) -> u32 { ) -> u32 {
let sync_tag = hints
.get(SYNCHRONOUS_HINT)
.and_then(|v| String::try_from(v.clone()).ok());
let id = if replaces_id != 0 { let id = if replaces_id != 0 {
if let Some(tag) = &sync_tag {
self.sync_tags
.lock()
.unwrap()
.insert((app_name.to_string(), tag.clone()), replaces_id);
}
replaces_id replaces_id
} else if let Some(tag) = &sync_tag {
let key = (app_name.to_string(), tag.clone());
let mut sync_tags = self.sync_tags.lock().unwrap();
*sync_tags
.entry(key)
.or_insert_with(|| self.next_id.fetch_add(1, Ordering::Relaxed))
} else { } else {
self.next_id.fetch_add(1, Ordering::Relaxed) self.next_id.fetch_add(1, Ordering::Relaxed)
}; };
@ -109,6 +232,23 @@ impl NotifServer {
// when the sender left expire_timeout at the server-default (-1). // when the sender left expire_timeout at the server-default (-1).
let urgency = Urgency::from_hint(hints.get("urgency")); let urgency = Urgency::from_hint(hints.get("urgency"));
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
let actions = parse_actions(&actions);
let inline_reply = inline_reply_placeholder(&actions, &hints);
history::record(
&self.history,
history::Entry {
id,
app_name: app_name.to_string(),
summary: summary.to_string(),
body: body.to_string(),
urgency,
received: SystemTime::now(),
},
);
if self.persist_history {
history::persist(&self.history);
}
let _ = self let _ = self
.tx .tx
@ -119,6 +259,8 @@ impl NotifServer {
body: body.to_string(), body: body.to_string(),
urgency, urgency,
expire, expire,
actions,
inline_reply,
}) })
.await; .await;
id id
@ -129,7 +271,7 @@ impl NotifServer {
} }
fn get_capabilities(&self) -> Vec<String> { fn get_capabilities(&self) -> Vec<String> {
vec!["body".to_string()] CAPABILITIES.iter().map(|s| (*s).to_string()).collect()
} }
fn get_server_information(&self) -> (String, String, String, String) { fn get_server_information(&self) -> (String, String, String, String) {
@ -142,15 +284,71 @@ impl NotifServer {
} }
} }
pub fn spawn() { /// A fixed sample notification for `--screenshot notification`/
/// `notification-critical` — substitutes for a real `Notify` D-Bus call so a
/// capture doesn't depend on some external sender firing one at just the
/// right moment.
pub enum SampleKind {
Normal,
Critical,
}
impl SampleKind {
fn sample_event(&self) -> NotifEvent {
let urgency = match self {
SampleKind::Normal => Urgency::Normal,
SampleKind::Critical => Urgency::Critical,
};
NotifEvent::Show {
id: 1,
app_name: "Sample App".into(),
summary: "Sample notification".into(),
body: "This is what a notification card looks like.".into(),
urgency,
expire: Expire::Never,
actions: vec![],
inline_reply: None,
}
}
}
/// Builds the notification window synchronously (see
/// `popup::build_window`'s doc comment) and spawns the event loop that
/// shows/updates/hides it.
///
/// `sample`: `Some` skips real D-Bus registration entirely and seeds the
/// loop with one fixed sample event instead — screenshot mode only. Doing
/// the real `org.freedesktop.Notifications` registration in every
/// screenshot run would race the real breadbar (if running) for the same
/// well-known name for no benefit, since nothing needs to reach this
/// instance externally.
pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
let (window, cards_box) = popup::build_window();
let (tx, rx) = mpsc::channel(32); let (tx, rx) = mpsc::channel(32);
match sample {
Some(kind) => {
let _ = tx.try_send(kind.sample_event());
let window_for_loop = window.clone();
relm4::spawn_local(async move {
popup::run(window_for_loop, cards_box, rx, None, None).await;
});
}
None => {
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
let store = history::load_store();
let next_id = history::next_id(&store);
let history_ui = history::build_window(store.clone());
relm4::spawn(async move { relm4::spawn(async move {
let server = NotifServer { let server = NotifServer {
tx, tx: tx.clone(),
next_id: AtomicU32::new(1), next_id: AtomicU32::new(next_id),
sync_tags: Mutex::new(HashMap::new()),
history: store,
persist_history: true,
}; };
let bar = BarService { tx };
// Builder failures here would only occur with invalid static strings — safe to unwrap. // Builder failures here would only occur with invalid static strings — safe to unwrap.
let conn = zbus::connection::Builder::session() let conn = zbus::connection::Builder::session()
.unwrap() .unwrap()
@ -158,6 +356,8 @@ pub fn spawn() {
.unwrap() .unwrap()
.serve_at("/org/freedesktop/Notifications", server) .serve_at("/org/freedesktop/Notifications", server)
.unwrap() .unwrap()
.serve_at(BAR_PATH, bar)
.unwrap()
.build() .build()
.await .await
.expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus");
@ -168,11 +368,16 @@ pub fn spawn() {
std::future::pending::<()>().await std::future::pending::<()>().await
}); });
let window_for_loop = window.clone();
relm4::spawn_local(async move { relm4::spawn_local(async move {
if let Ok(conn) = conn_rx.await { if let Ok(conn) = conn_rx.await {
popup::run(rx, conn).await; popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await;
} }
}); });
}
}
window
} }
#[cfg(test)] #[cfg(test)]
@ -212,4 +417,251 @@ mod tests {
Expire::Never => panic!("expected 1500ms, got Never"), Expire::Never => panic!("expected 1500ms, got Never"),
} }
} }
fn test_server() -> (NotifServer, mpsc::Receiver<NotifEvent>) {
let (tx, rx) = mpsc::channel(32);
(
NotifServer {
tx,
next_id: AtomicU32::new(1),
sync_tags: Mutex::new(HashMap::new()),
history: history::new_store(),
persist_history: false,
},
rx,
)
}
fn sync_hints(tag: &str) -> HashMap<String, OwnedValue> {
let mut hints = HashMap::new();
hints.insert(
SYNCHRONOUS_HINT.to_string(),
OwnedValue::try_from(zbus::zvariant::Value::from(tag)).unwrap(),
);
hints
}
#[tokio::test]
async fn synchronous_hint_reuses_id_for_same_app_and_tag() {
let (server, _rx) = test_server();
let first = server
.notify(
"breadcrumbs",
0,
"",
"no Wi-Fi adapter",
"",
vec![],
sync_hints("breadcrumbs"),
-1,
)
.await;
let second = server
.notify(
"breadcrumbs",
0,
"",
"back online",
"",
vec![],
sync_hints("breadcrumbs"),
-1,
)
.await;
assert_eq!(
first, second,
"same app+tag should replace, not stack, a prior notification"
);
}
#[tokio::test]
async fn synchronous_hint_is_scoped_per_app_name() {
let (server, _rx) = test_server();
let first = server
.notify(
"breadcrumbs",
0,
"",
"no Wi-Fi adapter",
"",
vec![],
sync_hints("breadcrumbs"),
-1,
)
.await;
let second = server
.notify(
"other-app",
0,
"",
"unrelated",
"",
vec![],
sync_hints("breadcrumbs"),
-1,
)
.await;
assert_ne!(
first, second,
"same tag from a different app must not collide"
);
}
#[tokio::test]
async fn no_synchronous_hint_always_allocates_a_new_id() {
let (server, _rx) = test_server();
let first = server
.notify("breadcrumbs", 0, "", "one", "", vec![], HashMap::new(), -1)
.await;
let second = server
.notify("breadcrumbs", 0, "", "two", "", vec![], HashMap::new(), -1)
.await;
assert_ne!(first, second);
}
#[tokio::test]
async fn notify_records_history_newest_first() {
let (server, _rx) = test_server();
server
.notify(
"app-a",
0,
"",
"first",
"body-a",
vec![],
HashMap::new(),
-1,
)
.await;
server
.notify(
"app-b",
0,
"",
"second",
"body-b",
vec![],
HashMap::new(),
-1,
)
.await;
let hist = server.history.lock().unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].summary, "second");
assert_eq!(hist[0].app_name, "app-b");
assert_eq!(hist[0].body, "body-b");
assert_eq!(hist[1].summary, "first");
}
#[test]
fn parse_actions_pairs_and_drops_trailing_id() {
let parsed = parse_actions(&[
"default".into(),
"Open".into(),
"snooze".into(),
"Snooze".into(),
"orphan".into(),
]);
assert_eq!(
parsed,
vec![
Action {
key: "default".into(),
label: "Open".into(),
},
Action {
key: "snooze".into(),
label: "Snooze".into(),
},
]
);
}
#[test]
fn parse_actions_skips_empty_keys() {
assert!(parse_actions(&["", "Nope"].map(String::from)).is_empty());
}
#[test]
fn inline_reply_from_action_or_kde_hint() {
let reply_action = vec![Action {
key: INLINE_REPLY_KEY.into(),
label: "Reply".into(),
}];
assert_eq!(
inline_reply_placeholder(&reply_action, &HashMap::new()).as_deref(),
Some("Reply")
);
assert!(inline_reply_placeholder(&[], &HashMap::new()).is_none());
let mut hints = HashMap::new();
hints.insert(
KDE_REPLY_PLACEHOLDER.to_string(),
OwnedValue::try_from(zbus::zvariant::Value::from("Write a reply…")).unwrap(),
);
assert_eq!(
inline_reply_placeholder(&[], &hints).as_deref(),
Some("Write a reply…")
);
// Hint wins over the generic default when both are present.
assert_eq!(
inline_reply_placeholder(&reply_action, &hints).as_deref(),
Some("Write a reply…")
);
}
#[test]
fn get_capabilities_includes_actions_and_inline_reply() {
let (server, _rx) = test_server();
let caps = server.get_capabilities();
for wanted in ["body", "body-markup", "actions", "inline-reply"] {
assert!(
caps.iter().any(|c| c == wanted),
"missing capability {wanted}"
);
}
}
#[tokio::test]
async fn notify_forwards_actions_and_inline_reply() {
let (server, mut rx) = test_server();
server
.notify(
"chat",
0,
"",
"Alice",
"hello",
vec![
"default".into(),
"Open".into(),
INLINE_REPLY_KEY.into(),
"Reply".into(),
],
HashMap::new(),
-1,
)
.await;
match rx.recv().await.expect("Show event") {
NotifEvent::Show {
actions,
inline_reply,
summary,
..
} => {
assert_eq!(summary, "Alice");
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].key, "default");
assert_eq!(actions[1].key, INLINE_REPLY_KEY);
assert_eq!(inline_reply.as_deref(), Some("Reply"));
}
_ => panic!("expected Show, got a different event"),
}
// History persist path is unchanged: actions are UI-only, not stored.
let hist = server.history.lock().unwrap();
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].summary, "Alice");
assert_eq!(hist[0].body, "hello");
}
} }

View file

@ -1,10 +1,11 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc}; use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
use gtk4::glib::ControlFlow;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell}; use gtk4_layer_shell::{KeyboardMode, LayerShell};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use super::{Expire, NotifEvent, Urgency}; use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY};
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>; type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer // Bumped every time an id gets a (re)placed card — an auto-dismiss timer
@ -18,12 +19,15 @@ type Generations = Rc<RefCell<HashMap<u32, u64>>>;
/// NotificationClosed reason codes per the freedesktop spec. /// NotificationClosed reason codes per the freedesktop spec.
mod close_reason { mod close_reason {
pub const EXPIRED: u32 = 1; pub const EXPIRED: u32 = 1;
#[allow(dead_code)] // no in-app dismiss button exists yet (see make_card)
pub const DISMISSED_BY_USER: u32 = 2; pub const DISMISSED_BY_USER: u32 = 2;
pub const CLOSE_NOTIFICATION_CALL: u32 = 3; pub const CLOSE_NOTIFICATION_CALL: u32 = 3;
} }
pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) { /// Builds the notification window synchronously — so a caller (screenshot
/// mode in particular) has a real window to hook `connect_map` on before
/// `run`'s event loop, which needs an async `zbus::Connection` handshake in
/// the real path, ever starts.
pub fn build_window() -> (gtk4::Window, gtk4::Box) {
let window = create_window(); let window = create_window();
let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
cards_box.set_margin_top(8); cards_box.set_margin_top(8);
@ -32,6 +36,38 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
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)
}
/// `conn`: `None` in screenshot mode, which skips real D-Bus registration
/// entirely (see `super::spawn`) — there's no external client that needs to
/// reach a screenshot-only instance, and registering the well-known name
/// would just race the real breadbar for it. `NotificationClosed` is a
/// spec-mandated signal for real clients only, so it's simply not emitted
/// when there's no real connection to emit it on.
pub async fn run(
window: gtk4::Window,
cards_box: gtk4::Box,
mut rx: Receiver<NotifEvent>,
conn: Option<zbus::Connection>,
history_ui: Option<history::Ui>,
) {
let cards: Cards = Rc::new(RefCell::new(HashMap::new())); let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
let generations: Generations = Rc::new(RefCell::new(HashMap::new())); let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
@ -44,15 +80,41 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
body, body,
urgency, urgency,
expire, expire,
actions,
inline_reply,
} => { } => {
// Replace existing card with same id (replaces_id case) // Replace existing card with same id (replaces_id case)
if let Some(old) = cards.borrow_mut().remove(&id) { if let Some(old) = cards.borrow_mut().remove(&id) {
cards_box.remove(&old); cards_box.remove(&old);
} }
let card = make_card(&app_name, &summary, &body, urgency); let card = make_card(CardSpec {
id,
app_name: &app_name,
summary: &summary,
body: &body,
urgency,
actions: &actions,
inline_reply: inline_reply.as_deref(),
conn: conn.clone(),
cards: cards.clone(),
cards_box: cards_box.clone(),
window: window.clone(),
});
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 {
history::refresh_if_visible(ui);
}
let my_generation = { let my_generation = {
let mut gens = generations.borrow_mut(); let mut gens = generations.borrow_mut();
@ -74,8 +136,7 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
gtk4::glib::timeout_future(duration).await; gtk4::glib::timeout_future(duration).await;
let still_current = let still_current =
generations_clone.borrow().get(&id) == Some(&my_generation); generations_clone.borrow().get(&id) == Some(&my_generation);
if still_current if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
&& dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
{ {
emit_closed(&conn_clone, id, close_reason::EXPIRED).await; emit_closed(&conn_clone, id, close_reason::EXPIRED).await;
} }
@ -87,13 +148,24 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
} }
} }
NotifEvent::ToggleHistory => {
if let Some(ui) = &history_ui {
history::toggle(ui);
}
}
} }
} }
} }
/// 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). /// (not a no-op on an id that's already gone or was never shown). Every
/// 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 {
@ -103,14 +175,17 @@ 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
} }
/// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent /// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent
/// directly over the connection rather than through the zbus interface /// directly over the connection rather than through the zbus interface
/// macro's generated helper, since the dismiss decision happens here in the /// macro's generated helper, since the dismiss decision happens here in the
/// popup task, not inside `NotifServer`'s own method bodies. /// popup task, not inside `NotifServer`'s own method bodies. No-op when
async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) { /// `conn` is `None` (screenshot mode — see `run`'s doc comment).
async fn emit_closed(conn: &Option<zbus::Connection>, id: u32, reason: u32) {
let Some(conn) = conn else { return };
let result = conn let result = conn
.emit_signal( .emit_signal(
None::<&str>, None::<&str>,
@ -125,49 +200,396 @@ async fn emit_closed(conn: &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_layer(Layer::Overlay); window.set_namespace(Some("breadbar-notif"));
window.set_anchor(Edge::Top, true); crate::surface::apply(&window, "breadbar-notif");
window.set_anchor(Edge::Right, true); // Toasts are purely informational — they never grab keyboard focus,
window.set_margin(Edge::Top, 20); // full stop, regardless of what's clickable on them (KeyboardMode::None
window.set_margin(Edge::Right, 20); // stays; do NOT change this — see the NOTIFICATION INTERACTION #B task
window.set_default_width(320); // note). Historically ("stop toast popups from stealing focus or
// blocking clicks") that also meant a fully empty input region: every
// pointer event passed straight through to whatever's underneath, but
// that made `make_card`'s own action buttons, its inline-reply
// `GtkEntry`, and the dismiss button below permanently unreachable too.
// `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);
window window
} }
fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { struct CardSpec<'a> {
id: u32,
app_name: &'a str,
summary: &'a str,
body: &'a str,
urgency: Urgency,
actions: &'a [Action],
inline_reply: Option<&'a str>,
conn: Option<zbus::Connection>,
cards: Cards,
cards_box: gtk4::Box,
window: gtk4::Window,
}
fn make_card(spec: CardSpec<'_>) -> gtk4::Box {
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
card.add_css_class("notification-card"); card.add_css_class("notification-card");
if let Some(class) = urgency.css_class() { if let Some(class) = spec.urgency.css_class() {
card.add_css_class(class); card.add_css_class(class);
} }
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
// Senders often set the title/summary to their own app name (e.g. a bare // Senders often set the title/summary to their own app name (e.g. a bare
// "Spotify" notification) — showing app_name above an identical summary // "Spotify" notification) — showing app_name above an identical summary
// is pure repetition, so skip the app label in that case. // is pure repetition, so skip the app label in that case.
if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) {
let lbl = gtk4::Label::new(Some(app_name)); let lbl = gtk4::Label::new(Some(spec.app_name));
lbl.add_css_class("notification-app"); lbl.add_css_class("notification-app");
lbl.set_xalign(0.0); lbl.set_xalign(0.0);
card.append(&lbl); content.append(&lbl);
} }
let summary_lbl = gtk4::Label::new(Some(summary)); let summary_lbl = gtk4::Label::new(Some(spec.summary));
summary_lbl.add_css_class("notification-summary"); summary_lbl.add_css_class("notification-summary");
summary_lbl.set_xalign(0.0); summary_lbl.set_xalign(0.0);
summary_lbl.set_wrap(true); summary_lbl.set_wrap(true);
card.append(&summary_lbl); content.append(&summary_lbl);
if !body.is_empty() { if !spec.body.is_empty() {
let body_lbl = gtk4::Label::new(Some(body)); let body_lbl = gtk4::Label::new(None);
body_lbl.add_css_class("notification-body"); body_lbl.add_css_class("notification-body");
body_lbl.set_xalign(0.0); body_lbl.set_xalign(0.0);
body_lbl.set_wrap(true); body_lbl.set_wrap(true);
card.append(&body_lbl); apply_body_text(&body_lbl, spec.body);
content.append(&body_lbl);
}
if spec.actions.iter().any(|a| a.key == "default") {
content.add_css_class("notification-default");
let gesture = gtk4::GestureClick::new();
let invoke = Invoke {
conn: spec.conn.clone(),
cards: spec.cards.clone(),
cards_box: spec.cards_box.clone(),
window: spec.window.clone(),
id: spec.id,
};
gesture.connect_released(move |_, _, _, _| {
invoke_action(invoke.clone(), "default");
});
content.add_controller(gesture);
}
// NOTIFICATION INTERACTION #A: a direct dismiss control. Floated in
// 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
.actions
.iter()
.filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY)
.collect();
if !visible.is_empty() {
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
row.add_css_class("notification-actions");
row.set_halign(gtk4::Align::End);
for action in visible {
let btn = gtk4::Button::with_label(&action.label);
btn.add_css_class("notification-action");
let invoke = Invoke {
conn: spec.conn.clone(),
cards: spec.cards.clone(),
cards_box: spec.cards_box.clone(),
window: spec.window.clone(),
id: spec.id,
};
let key = action.key.clone();
btn.connect_clicked(move |_| {
invoke_action(invoke.clone(), &key);
});
row.append(&btn);
}
card.append(&row);
}
if let Some(placeholder) = spec.inline_reply {
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
row.add_css_class("notification-reply");
let entry = gtk4::Entry::new();
entry.add_css_class("notification-reply-entry");
entry.set_placeholder_text(Some(placeholder));
entry.set_hexpand(true);
let send_label = spec
.actions
.iter()
.find(|a| a.key == INLINE_REPLY_KEY)
.map(|a| a.label.as_str())
.filter(|l| !l.is_empty())
.unwrap_or("Send");
let send = gtk4::Button::with_label(send_label);
send.add_css_class("notification-action");
let invoke = Invoke {
conn: spec.conn.clone(),
cards: spec.cards.clone(),
cards_box: spec.cards_box.clone(),
window: spec.window.clone(),
id: spec.id,
};
let entry_for_btn = entry.clone();
let invoke_btn = invoke.clone();
send.connect_clicked(move |_| {
submit_reply(&entry_for_btn, invoke_btn.clone());
});
entry.connect_activate(move |e| {
submit_reply(e, invoke.clone());
});
row.append(&entry);
row.append(&send);
card.append(&row);
} }
card card
} }
/// FDO `body-markup` is a small Pango-ish subset (`<b>`, `<i>`, `<u>`,
/// `<a href>`). Invalid markup falls back to plain text so a bad sender
/// doesn't blank the card.
fn apply_body_text(label: &gtk4::Label, body: &str) {
if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() {
label.set_markup(body);
return;
}
label.set_text(body);
}
#[derive(Clone)]
struct Invoke {
conn: Option<zbus::Connection>,
cards: Cards,
cards_box: gtk4::Box,
window: gtk4::Window,
id: u32,
}
fn invoke_action(invoke: Invoke, key: &str) {
let key = key.to_string();
relm4::spawn_local(async move {
emit_action(&invoke.conn, invoke.id, &key).await;
if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) {
emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await;
}
});
}
/// 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) {
let text = entry.text().to_string();
if text.trim().is_empty() {
return;
}
relm4::spawn_local(async move {
emit_replied(&invoke.conn, invoke.id, &text).await;
emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await;
if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) {
emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await;
}
});
}
async fn emit_action(conn: &Option<zbus::Connection>, id: u32, action_key: &str) {
let Some(conn) = conn else { return };
let result = conn
.emit_signal(
None::<&str>,
"/org/freedesktop/Notifications",
"org.freedesktop.Notifications",
"ActionInvoked",
&(id, action_key),
)
.await;
if let Err(e) = result {
eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}");
}
}
/// GNOME/KDE (and clients such as Discord/Telegram) listen for this
/// non-spec signal on `org.freedesktop.Notifications` when the user
/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`.
/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only
/// watch the spec signal still see the send.
async fn emit_replied(conn: &Option<zbus::Connection>, id: u32, text: &str) {
let Some(conn) = conn else { return };
let result = conn
.emit_signal(
None::<&str>,
"/org/freedesktop/Notifications",
"org.freedesktop.Notifications",
"NotificationReplied",
&(id, text),
)
.await;
if let Err(e) = result {
eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}");
}
}

View file

@ -1,7 +1,13 @@
use std::{cell::Cell, rc::Rc, time::Duration}; use std::{
cell::{Cell, RefCell},
process::Child,
rc::Rc,
sync::{Mutex, Once},
time::Duration,
};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell}; use gtk4_layer_shell::LayerShell;
use tokio::sync::mpsc; use tokio::sync::mpsc;
enum OsdEvent { enum OsdEvent {
@ -9,14 +15,90 @@ enum OsdEvent {
Brightness { pct: u8 }, Brightness { pct: u8 },
} }
pub fn spawn() { /// A fixed sample event for `--screenshot osd-volume`/`osd-brightness` —
/// substitutes for the real `pactl subscribe`/backlight-sysfs watchers so a
/// capture doesn't depend on this machine's actual volume/brightness at
/// capture time.
pub enum SampleKind {
Volume,
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 {
fn sample_event(&self) -> OsdEvent {
match self {
SampleKind::Volume => OsdEvent::Volume { pct: 65, muted: false },
SampleKind::Brightness => OsdEvent::Brightness { pct: 80 },
}
}
}
/// Builds the OSD window synchronously (so a caller — screenshot mode, via
/// `sample`, in particular — has a real window to hook `connect_map` on
/// before the async event loop below ever runs) and spawns the event loop
/// that shows/updates/hides it.
///
/// `sample`: `Some` skips the real volume/brightness watchers entirely and
/// seeds the loop with one fixed sample event instead — screenshot mode
/// only, so a capture never depends on (or is disrupted by) this machine's
/// actual audio/backlight state.
pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
let (tx, rx) = mpsc::channel::<OsdEvent>(8); let (tx, rx) = mpsc::channel::<OsdEvent>(8);
match sample {
Some(kind) => {
let _ = tx.try_send(kind.sample_event());
}
None => {
let tx1 = tx.clone(); let tx1 = tx.clone();
std::thread::spawn(move || volume_watcher(tx1)); std::thread::spawn(move || volume_watcher(tx1));
std::thread::spawn(move || brightness_watcher(tx)); std::thread::spawn(move || brightness_watcher(tx));
}
}
relm4::spawn_local(run_osd(rx)); let window = create_window();
relm4::spawn_local(run_osd(window.clone(), rx));
window
} }
fn volume_watcher(tx: mpsc::Sender<OsdEvent>) { fn volume_watcher(tx: mpsc::Sender<OsdEvent>) {
@ -33,6 +115,14 @@ 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) {
@ -119,9 +209,7 @@ fn brightness_watcher(tx: mpsc::Sender<OsdEvent>) {
} }
} }
async fn run_osd(mut rx: mpsc::Receiver<OsdEvent>) { async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
let window = create_window();
let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
container.set_margin_top(10); container.set_margin_top(10);
container.set_margin_bottom(10); container.set_margin_bottom(10);
@ -129,9 +217,7 @@ async fn run_osd(mut rx: mpsc::Receiver<OsdEvent>) {
container.set_margin_end(14); container.set_margin_end(14);
window.set_child(Some(&container)); window.set_child(Some(&container));
let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture( let icon = crate::svg_image(crate::bar::stats::ICON_VOLUME);
crate::bar::stats::ICON_VOLUME,
)));
icon.add_css_class("osd-icon"); icon.add_css_class("osd-icon");
container.append(&icon); container.append(&icon);
@ -142,6 +228,15 @@ async fn run_osd(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 {
@ -150,12 +245,13 @@ async fn run_osd(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);
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");
} }
pbar.set_fraction(pct as f64 / 100.0); animate_osd_fill(&pbar, &fill_pct, &fill_anim, &fill_token, pct);
window.set_visible(true); window.set_visible(true);
let token = dismiss_token.get().wrapping_add(1); let token = dismiss_token.get().wrapping_add(1);
@ -171,13 +267,85 @@ async fn run_osd(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_layer(Layer::Overlay); window.set_namespace(Some("breadbar-osd"));
window.set_anchor(Edge::Bottom, true); crate::surface::apply(&window, "breadbar-osd");
window.set_margin(Edge::Bottom, 80); crate::theme::bind_auto(&window);
window.set_default_width(180);
window window
} }

317
src/panel.rs Normal file
View file

@ -0,0 +1,317 @@
//! Standalone layer-shell panels for wifi / control / media.
//!
//! GTK `Popover` is an xdg_popup child of the island, so it paints over the
//! bar and Hyprland can only fade it. These are their own surfaces, parked
//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from
//! the right.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gtk4::gdk::Key;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell};
use crate::{bind_layer_monitor, theme};
/// Outside this rectangle's local x/y span, the dismiss window's own real
/// 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)]
pub struct PanelSet {
pub connectivity: gtk4::Window,
pub control: gtk4::Window,
pub media: 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 {
pub fn new(
monitor: &str,
connectivity_child: &impl IsA<gtk4::Widget>,
control_child: &impl IsA<gtk4::Widget>,
media_child: &impl IsA<gtk4::Widget>,
) -> Self {
let connectivity = make_panel("wifi-popover", connectivity_child, monitor);
let control = make_panel("control-panel", control_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, &dismiss_hole);
let set = Self {
connectivity,
control,
media,
dismiss,
on_dismiss: Rc::new(RefCell::new(None)),
dismiss_hole,
};
set.wire_dismiss();
set.wire_escape();
set
}
pub fn toggle(&self, which: &gtk4::Window) {
if which.is_visible() {
self.hide_all();
} else {
self.show(which);
}
}
pub fn show(&self, which: &gtk4::Window) {
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).
self.dismiss.set_visible(true);
self.dismiss.present();
which.set_visible(true);
which.present();
}
pub fn hide_all(&self) {
self.hide_panels();
self.dismiss.set_visible(false);
}
fn hide_panels(&self) {
self.connectivity.set_visible(false);
self.control.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) {
let set = self.clone();
let click = gtk4::GestureClick::new();
click.set_button(0);
click.connect_pressed(move |_, _, _, _| {
set.hide_all();
if let Some(cb) = set.on_dismiss.borrow().as_ref() {
cb();
}
});
if let Some(child) = self.dismiss.child() {
child.add_controller(click);
} else {
self.dismiss.add_controller(click);
}
}
fn wire_escape(&self) {
for win in [&self.connectivity, &self.control, &self.media] {
let set = self.clone();
let keys = gtk4::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, _| {
if key == Key::Escape {
set.hide_all();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
win.add_controller(keys);
}
}
}
fn make_panel(class: &str, child: &impl IsA<gtk4::Widget>, monitor: &str) -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-panel");
window.add_css_class(class);
window.set_decorated(false);
window.set_resizable(false);
window.init_layer_shell();
window.set_namespace(Some("breadbar-panel"));
crate::surface::apply(&window, "breadbar-panel");
window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::OnDemand);
window.set_child(Some(child));
bind_layer_monitor(&window, monitor);
theme::bind_output(&window, monitor);
window.set_visible(false);
window
}
fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-dismiss");
window.init_layer_shell();
window.set_namespace(Some("breadbar-dismiss"));
// Overlay with the panels, but mapped first so they sit above it.
// Top margin keeps the island's chips clickable.
//
// NOTE — deliberate, not a bug: this surface's top margin is 8px less
// than `breadbar-panel`'s (see `make_panel` above / `[surfaces.*]` in
// the active theme). The panels start 8px lower than the dismiss
// scrim's clickable region. This predates Phase 2 and is preserved
// exactly for pixel-identical rendering — do not "fix" this gap.
crate::surface::apply(&window, "breadbar-dismiss");
window.set_exclusive_zone(-1);
window.set_keyboard_mode(KeyboardMode::None);
// An empty window never maps a hit region. A filling child + a hair of
// alpha is what actually receives the click-away.
let hit = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
hit.add_css_class("breadbar-dismiss-hit");
hit.set_hexpand(true);
hit.set_vexpand(true);
window.set_child(Some(&hit));
bind_layer_monitor(&window, monitor);
theme::bind_output(&window, monitor);
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
}
/// 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),
}
}

386
src/screenshot.rs Normal file
View file

@ -0,0 +1,386 @@
//! `--screenshot` CLI mode: render a specific view, capture it via
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
//! `bread-capture` orchestrator, or run standalone for one-off captures.
//!
//! Capture waits on GTK's `map` signal rather than a blind sleep before
//! grabbing pixels — the surface (or, for popover views, the popover itself)
//! genuinely isn't on screen yet before that fires, so a fixed delay would
//! either race a slow first paint or pad every fast one for nothing.
//!
//! breadbar is "a bar + the notification daemon + the OSD" (see its own
//! module docs), so its screenshot views span three separate top-level
//! surfaces, not just the bar: the bar itself and its popovers (this
//! module, anchored off `root`), plus the standalone notification and OSD
//! windows (`notifications::spawn`/`osd::spawn`, built and primed with
//! sample data by `main.rs` before `dispatch` runs — see [`Handles`]).
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
use clap::Parser;
use gtk4::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// Settle time for views whose content depends on a live-data popover load
/// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing
/// any sooner leaves placeholder dashes/"Scanning…" instead of real content.
const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// Delay between the bar's own `map` and calling `popover.popup()`. Calling
/// `popup()` synchronously from inside the root window's `map` handler
/// produces a popover that reports itself `map`ped but never actually paints
/// (confirmed by an independent `grim` capture taken mid-sequence, showing no
/// popover at all) — presumably the parent widget's own allocation isn't
/// settled yet at that exact point. Giving the initial layout pass a beat to
/// finish first is what makes it actually render.
const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
const KNOWN_VIEWS: &[&str] = &[
"bar",
"control-panel",
"connectivity-wifi",
"connectivity-bluetooth",
"media-popover",
"notification",
"notification-critical",
"osd-volume",
"osd-brightness",
"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)]
#[command(name = "breadbar")]
pub struct Cli {
/// Render the named view, capture it, then exit instead of running
/// normally. See `screenshot::KNOWN_VIEWS` for the full list.
#[arg(long)]
pub screenshot: Option<String>,
/// PNG path to write the capture to. Required together with --screenshot.
#[arg(long)]
pub output: Option<PathBuf>,
/// Capture canvas width — matches the isolated compositor's output width
/// (`bread-capture --isolate-width`) so the geometry passed to `grim`
/// doesn't depend on querying anything at capture time.
#[arg(long, default_value_t = DEFAULT_WIDTH)]
pub width: u32,
/// Capture canvas height — see `width`.
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
pub height: u32,
/// Toggle the in-memory notification history on a running breadbar, then
/// exit. Keybind-friendly; does not start a second instance.
#[arg(long)]
pub history: bool,
}
pub struct ScreenshotRequest {
pub view: String,
pub output: PathBuf,
pub width: u32,
pub height: u32,
}
impl Cli {
/// `None` for a normal run. Exits the process with an error if the
/// `--screenshot` / `--output` pair is incomplete, before any GTK/relm4
/// setup happens.
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
eprintln!("breadbar: {e}");
std::process::exit(1);
}
Some(ScreenshotRequest {
view: self.screenshot.clone()?,
output: self.output.clone()?,
width: self.width,
height: self.height,
})
}
}
/// Every widget/window `dispatch` might need, gathered by `main.rs`'s
/// `init()` — most of these are plain locals there that never otherwise
/// outlive `init()` (never stored on `App`), so they have to be cloned out
/// before dispatch time same as `control_popover` always was.
pub struct Handles {
pub control_panel: gtk4::Window,
pub connectivity_panel: gtk4::Window,
pub wifi_tab_btn: gtk4::ToggleButton,
pub bt_tab_btn: gtk4::ToggleButton,
pub media_panel: gtk4::Window,
pub media_widget: gtk4::Box,
pub media_track_lbl: gtk4::Label,
/// Already built and primed with sample content by `main.rs` (via
/// `notifications::spawn(Some(kind))`) when `req.view` calls for it —
/// `None` otherwise.
pub notification_window: Option<gtk4::Window>,
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
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
/// height (the exclusive zone). Unlike the other views' full canvas,
/// this never varies with `--width`/`--height`.
fn bar_capture_height() -> i32 {
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) {
let output = req.output;
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() {
"bar" => {
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));
});
});
}
"control-panel" => {
open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-wifi" => {
handles.wifi_tab_btn.set_active(true);
open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-bluetooth" => {
handles.bt_tab_btn.set_active(true);
open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"media-popover" => {
// Real media state only shows the widget/text when something's
// actually playing (see AppInput::MediaUpdate) — an automated
// run has nothing playing, so fake enough of it directly on the
// widgets to get a representative capture.
handles.media_widget.set_visible(true);
handles.media_widget.add_css_class("playing");
handles.media_track_lbl.set_text("Sample Track — Sample Artist");
open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height);
}
"notification" | "notification-critical" => {
let Some(window) = handles.notification_window else {
eprintln!("breadbar: internal error — no notification window built for '{}'", req.view);
std::process::exit(1);
};
capture_standalone_window(window, output, width, height);
}
"osd-volume" | "osd-brightness" => {
let Some(window) = handles.osd_window else {
eprintln!("breadbar: internal error — no OSD window built for '{}'", req.view);
std::process::exit(1);
};
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" => {
let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| {
let output = output.clone();
let anchor = anchor.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
crate::show_add_network_dialog(&anchor, "Sample Network".to_string(), move |dialog| {
capture_standalone_window(dialog.clone(), output.clone(), width, height);
});
});
});
}
other => {
eprintln!(
"breadbar: unknown screenshot view '{other}' (known: {})",
KNOWN_VIEWS.join(", ")
);
std::process::exit(1);
}
}
}
/// Shared shape for panel views: present the standalone layer window after
/// the bar maps, then capture the canvas once the panel itself maps.
fn open_panel_on_root_map(
root: &gtk4::ApplicationWindow,
panel: gtk4::Window,
settle: Duration,
output: PathBuf,
width: i32,
height: i32,
) {
let panel_to_open = panel.clone();
root.connect_map(move |_| {
let panel_to_open = panel_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
panel_to_open.set_visible(true);
panel_to_open.present();
});
});
panel.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(settle, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
/// Shared shape for the standalone notification/OSD windows and the wifi
/// add-network dialog: wait for `map`, settle, capture, exit. These are
/// already-visible-or-about-to-be windows by the time this is called (their
/// sample event is queued before `dispatch` even runs), so this is just the
/// capture half.
fn capture_standalone_window(window: gtk4::Window, output: PathBuf, width: i32, height: i32) {
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("breadbar: screenshot capture failed: {e}");
std::process::exit(1);
}
}
}

166
src/surface.rs Normal file
View file

@ -0,0 +1,166 @@
//! 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,13 +1,56 @@
use bread_theme::{gtk as bgtk, hex_to_rgba, ink_on, load_palette}; use bread_theme::shell::ShellTheme;
use bread_theme::{gtk as bgtk, ink_on, load_palette, load_palette_for, Palette};
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 {
let p = load_palette();
// breadbar-specific rules only — fonts, base colours, and generic widgets // breadbar-specific rules only — fonts, base colours, and generic widgets
// come from the shared ecosystem stylesheet (applied first in `apply()`). // come from the shared ecosystem stylesheet (applied first in `apply()`).
// Colour is set on each surface (bar, active workspace pill, notification // Colour is set on each surface (bar, active workspace pill, notification
@ -15,97 +58,746 @@ fn load_css() -> String {
// pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for // pywal hands a given slot. `on_*` are luminance-picked ink (black/white) for
// that background — the pywal hues themselves are untouched. // that background — the pywal hues themselves are untouched.
// //
// Shared tokens: one radius and one padding rhythm reused across every // Glass workbench: 16px island on the bar, 12px cards/popovers, pill OSD.
// popover/card/OSD surface so they read as one design system rather than // Hyprland `layerrule = blur, breadbar` frosts the translucent fills —
// four different ones. `radius_pill` is only for the tiny transient OSD. // the CSS just leaves alpha. Colours are bread-theme tokens so pywal
let radius = "10px"; // accents (`@accent`) flow through on SIGHUP / `bread-theme reload`.
let radius_sm = "6px"; //
let radius_pill = "20px"; // These ~250 lines are breadbar-specific chrome (notifications, wifi
let pad = "10px"; // popover, control panel, media widget) that `ShellTheme::css()` does
// not template — only the window/workspace/clock chrome the manifest's
// own concepts model does (plan §6 scope note). This function stays
// 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!(
"window.breadbar {{ background-color: {bg_rgba}; color: {on_bg}; border-radius: 0; }}\ "@keyframes notif-in {{ from {{ opacity: 0; margin-right: -16px; }} }}\
.workspace-btn {{ background: transparent; opacity: 0.45; color: {on_bg};\ @keyframes osd-in {{ from {{ opacity: 0; margin-bottom: -8px; }} }}\
border-radius: {radius_sm}; border: none; outline: none; box-shadow: none;\ @keyframes media-eq {{ to {{ min-height: 14px; }} }}\
min-width: 20px; margin: 5px 2px; padding: 2px 9px; }}\ @keyframes pop-in {{ from {{ opacity: 0; margin-top: -10px; }} to {{ opacity: 1; margin-top: 0; }} }}\
.workspace-btn:hover {{ opacity: 0.8; }}\ @keyframes pop-out {{ from {{ opacity: 1; margin-top: 0; }} to {{ opacity: 0; margin-top: -6px; }} }}\
.workspace-btn.active {{ background: alpha({accent}, 0.18); color: {accent}; opacity: 1; }}\ @keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\
.stats-box {{ margin-right: 8px; }}\ @keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
.stat-pair {{ margin-right: 14px; }}\ @keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
.stat-icon {{ margin-right: 2px; }}\ /* ANIMATION WORK #3, bar entrance on first map: opacity ONLY —\
.bt-icon {{ margin-right: 14px; }}\ no margin/geometry term so this can never perturb any\
separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ descendant's own box-model size (see main.rs's own long\
window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ comment on this, next to where `bar-entrance` gets added, for\
.notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ why that matters to the workspace trail specifically).\
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ Liquid Motion additionally springs the surface's own\
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\ layer-shell top margin via `anim::spring_to` in Rust, which\
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\ this keyframe knows nothing about; glass-workbench never adds\
this class at all. */\
@keyframes bar-in {{ from {{ opacity: 0; }} }}\
.bar-entrance {{ animation: bar-in 0.4s {spring_settle} both; }}\
window.breadbar {{ color: {ink}; border-radius: {bar_radius}; {window_chrome}\
transition: border-radius 0.3s {spring_settle}; }}\
/* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\
`launcher_entry` never gets focus under any other theme, so\
`.searching` never lands on `window.breadbar` there). */\
window.breadbar.searching {{ border-radius: {radius_search}; }}\
/* `> 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-label {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-height: 0; padding: 0; margin-top: 3px; }}\
.clock-digit {{ font-size: 24px; font-weight: bold; letter-spacing: 0.04em;\
min-width: 15px; min-height: 0; padding: 0; margin: 0; }}\
.clock-colon {{ min-width: 10px; opacity: 0.7; }}\
.clock-digit.flip {{ animation: digit-flip 0.45s {spring} both; }}\
.clock-plain {{ padding: 0 4px; }}\
.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.tick {{ animation: digit-flip 0.35s {spring} 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; }}\
/* Radius was a hardcoded 10px here regardless of theme — right by\
coincidence for liquid-motion's demo (`.chip {{ border-radius:\
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; }}\
.stat-pair:hover {{ background: alpha({ink}, 0.12); }}\
.stat-pair:active {{ background: alpha({ink}, 0.18); }}\
/* No border-radius override here (was a hardcoded 999px, making\
wifi/hamburger the only two `.icon-only` chips fully\
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-pair.icon-only .stat-icon {{ margin: 0; }}\
.bt-icon {{ margin-right: 8px; }}
separator.bar-sep {{ min-height: 12px; min-width: 1px; margin: 0 10px 0 2px;\
background: alpha({ink}, 0.10); }}\
window.breadbar-notification {{ background-color: transparent; color: {ink}; }}\
window.breadbar-history {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\
border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\
animation: pop-in 0.45s {spring} both; }}\
.notification-card {{ background: alpha({panel}, {card_alpha}); color: {ink}; border-radius: {radius};\
padding: {pad}; margin-bottom: 8px; border: 1px solid alpha({ink}, 0.10);\
border-left: 3px solid transparent;\
animation: notif-in 0.45s {spring_settle} both; }}\
.notification-card.urgency-critical {{ border-left-color: @red; }}\
.notification-card.urgency-normal {{ border-left-color: @accent; }}\
.notification-summary {{ font-weight: bold; }}\ .notification-summary {{ font-weight: bold; }}\
.notification-app {{ opacity: 0.6; }}\ .notification-app {{ opacity: 0.55; font-size: 11px; letter-spacing: 0.04em; }}\
window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ .notification-actions {{ margin-top: 6px; }}\
.notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\
.notification-reply {{ margin-top: 6px; }}\
.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-close {{ padding: 2px 8px; }}\
.history-empty {{ opacity: 0.5; padding: 8px 0; }}\
.history-time {{ opacity: 0.5; font-size: 11px; }}\
.history-body {{ opacity: 0.75; }}\
.history-card {{ margin-bottom: 6px; }}\
window.breadbar-osd {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\
border-radius: {radius_pill}; border: 1px solid alpha({ink}, 0.10);\
animation: osd-in 0.4s {spring_settle} 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}; border-radius: 3px; min-height: 6px; }}\ progressbar.osd-bar trough {{ background-image: none; background-color: {trough_bg};\
progressbar.osd-bar trough progress {{ background-image: none; background-color: {accent}; border-radius: 3px; min-height: 6px; }}\ border-radius: 3px; min-height: 6px; }}\
.clickable {{ cursor: pointer; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\
.wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ border-radius: 3px; min-height: 6px; }}\
.wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ window.breadbar-panel {{ background-color: alpha({panel}, {panel_surface_alpha}); color: {ink};\
.wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\
.popover-tab-row {{ margin-bottom: {pad}; }}\ /* A real GtkButton's own label text does NOT inherit `color` from\
.popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ an ancestor window: `bread_theme::stylesheet()`'s shared,\
outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ ecosystem-wide `button {{ color: @on-surface }}` rule (lib.rs,\
font-weight: bold; opacity: 0.55; }}\ 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 {{\
background-color: alpha(#000000, 0.02); }}\
.popover-caret {{ min-height: 2px; margin: 2px 4px 10px; border-radius: 2px;\
background-color: @accent;\
background-image: linear-gradient(90deg, @accent, @teal);\
animation: caret-draw 0.45s {spring} both; }}\
.wifi-popover-inner {{ min-width: 228px; padding: {pad}; }}\
window.wifi-popover button {{ min-height: 0; min-width: 0; }}\
.popover-tab-row {{ background: alpha({ink}, 0.06); border-radius: 10px;\
padding: 3px; margin-bottom: 10px; }}\
.popover-tab {{ background: transparent; color: {ink}; border: none; box-shadow: none;\
outline: none; border-radius: 999px; padding: 0 14px; min-height: 32px;\
font-size: 17px; font-weight: bold; opacity: 0.55;\
transition: background-color 0.22s {spring_settle},\
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.18); color: {accent}; opacity: 1; }}\ .popover-tab:checked {{ background: alpha(@accent, 0.22); color: @accent; opacity: 1; }}\
.wifi-popover-ssid {{ font-weight: bold; font-size: 13px; }}\ .popover-tab label {{ padding: 0; margin: 0; }}\
.wifi-popover-ip {{ opacity: 0.6; font-size: 11px; }}\ .wifi-popover-ssid {{ font-weight: bold; font-size: 18px; }}\
.wifi-popover-status {{ font-size: 11px; margin-top: 2px; }}\ .wifi-popover-ip {{ opacity: 0.6; font-size: 16px; }}\
.wifi-popover-section {{ font-size: 10px; font-weight: bold; opacity: 0.5; letter-spacing: 0.08em; }}\ .wifi-popover-status {{ font-size: 16px; margin-top: 2px; }}\
.wifi-popover-section {{ font-size: 13px; font-weight: bold; opacity: 0.45;\
letter-spacing: 0.12em; }}\
.wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\ .wifi-popover-row {{ background: transparent; border: none; box-shadow: none;\
border-radius: {radius_sm}; padding: 4px 6px; }}\ outline: none; border-radius: 10px; padding: 0 12px; min-height: 42px;\
.wifi-popover-row:hover {{ background: alpha({on_bg}, 0.08); }}\ transition: background-color 0.18s {spring_settle}; }}\
.wifi-popover-row-active {{ color: {accent}; }}\ .wifi-popover-row label {{ font-size: 18px; }}\
.wifi-popover-row:hover {{ background: alpha({ink}, 0.08); }}\
.wifi-popover-row-active {{ background: alpha(@accent, 0.14); color: @accent; }}\
.wifi-popover-row-active:hover {{ background: alpha(@accent, 0.20); }}\
.row-in {{ animation: row-in 0.32s {spring} both; }}\
.stagger-0 {{ animation-delay: 0ms; }} .stagger-1 {{ animation-delay: 28ms; }}\
.stagger-2 {{ animation-delay: 56ms; }} .stagger-3 {{ animation-delay: 84ms; }}\
.stagger-4 {{ animation-delay: 112ms; }} .stagger-5 {{ animation-delay: 140ms; }}\
.stagger-6 {{ animation-delay: 168ms; }} .stagger-7 {{ animation-delay: 196ms; }}\
.stagger-8 {{ animation-delay: 224ms; }} .stagger-9 {{ animation-delay: 252ms; }}\
.stagger-10 {{ animation-delay: 280ms; }} .stagger-11 {{ animation-delay: 308ms; }}\
.wifi-popover-row-unsaved {{ opacity: 0.4; }}\ .wifi-popover-row-unsaved {{ opacity: 0.4; }}\
.wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\ .wifi-popover-loading {{ opacity: 0.5; padding: 8px; }}\
window.wifi-add-dialog {{ background-color: {bg_rgba}; color: {on_bg}; min-width: 240px; }}\ switch.bt-switch, switch.bt-switch:hover, switch.bt-switch:checked,\
.media-widget {{ border-radius: {radius_sm}; padding: 0 6px; cursor: pointer; }}\ switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\
.media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ border: none; outline: none; box-shadow: none; background-image: none;\
.media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ border-radius: 99px; }}\
.media-track-lbl {{ font-size: 12px; }}\ switch.bt-switch {{ background-color: alpha({ink}, 0.14);\
.media-controls {{ padding: 2px; }}\ transition: background-color 0.25s {spring_settle}; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; }}\ switch.bt-switch:checked {{ background-color: @accent; }}\
.control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\
border-radius: 99px; border: none; outline: none; box-shadow: none;\
background-image: none; background-color: {ink}; }}\
window.wifi-add-dialog {{ background-color: alpha({panel}, {card_alpha}); color: {ink}; min-width: 240px;\
border-radius: {radius}; border: 1px solid alpha({ink}, 0.10);\
animation: pop-in 0.45s {spring} both; }}\
window.wifi-add-dialog headerbar {{ background-color: alpha({panel}, {card_alpha}); color: {ink};\
border-top-left-radius: {radius}; border-top-right-radius: {radius};\
border-bottom: 1px solid alpha({ink}, 0.10); box-shadow: none; }}\
.confirm-button {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
/* min-height: decision #1 — the media chip is a bar chip like\
any other, so it shares the same row height instead of sizing\
to its own eq-bar/label content. */\
.media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\
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-bar {{ min-width: 3px; min-height: 5px; background-color: @{accent2};\
border-radius: 2px; }}\
.media-widget.playing .media-eq-bar {{\
animation: media-eq 0.85s ease-in-out infinite alternate; }}\
.media-widget.playing .media-eq-bar:nth-child(2) {{ animation-delay: 0.1s; min-height: 11px; }}\
.media-widget.playing .media-eq-bar:nth-child(3) {{ animation-delay: 0.22s; min-height: 7px; }}\
.media-widget.playing .media-eq-bar:nth-child(4) {{ animation-delay: 0.06s; min-height: 13px; }}\
.media-track-lbl {{ font-size: 17px; }}\
.media-controls {{ padding: 4px; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; border-radius: {radius_sm};\
transition: background-color 0.18s ease; }}\
.media-btn:hover {{ background: alpha({ink}, 0.10); }}\
/* No padding/border-radius/min-width/min-height here (was\
`padding: 5px 8px; border-radius: 10px; min-width: 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;\
transition: background-color 0.22s {spring_settle},\
opacity 0.18s ease; }}\
.control-panel-btn:hover {{ opacity: 1; background: alpha({ink}, 0.10); }}\
.control-panel-btn:active {{ background: alpha({ink}, 0.16); }}\
.control-panel {{ }}\ .control-panel {{ }}\
.control-panel-inner {{ min-width: 220px; padding: {pad}; }}\ .control-panel-inner {{ min-width: 248px; padding: {pad}; }}\
.control-panel-row {{ margin: 4px 0; }}\ .sys-grid {{ margin: 2px 0 6px; }}\
.control-panel-row-icon {{ opacity: 1; margin-right: 4px; }}\ .sys-stat {{ padding: 4px 2px; background: transparent; }}\
.control-panel-slider {{ margin: 0; }}\ .sys-stat:hover {{ background: transparent; }}\
.control-panel-stats {{ margin: {pad} 0; }}\ .control-panel-header {{ font-size: 12px; font-weight: bold; letter-spacing: 0.12em;\
.control-panel-stat {{ font-size: 12px; opacity: 0.85; margin: 1px 0; }}\ opacity: 0.45; margin-bottom: 8px; }}\
.control-panel-section {{ margin: {pad} 0; }}\ .control-panel-row {{ margin: 8px 0; }}\
.control-panel-section-header {{ font-size: 10px; font-weight: bold; opacity: 0.5;\ .control-panel-row-label {{ font-size: 16px; opacity: 0.78; }}\
letter-spacing: 0.08em; margin-bottom: 4px; }}\ .control-panel-slider {{ margin: 0; padding: 0; min-height: 18px; }}\
.control-panel-sink-dropdown {{ }}\ scale.control-panel-slider trough {{ min-height: 6px; border-radius: 99px;\
.power-row {{ margin-top: 2px; }}\ background-image: none; background-color: alpha({ink}, 0.12);\
.power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ border: none; outline: none; box-shadow: none; }}\
separator {{ margin: 4px 0; }}", scale.control-panel-slider highlight {{ min-height: 6px; border-radius: 99px;\
bg_plain = p.background, background-image: none; background-color: @accent; }}\
bg_rgba = hex_to_rgba(&p.background, 0.92), scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\
surface = p.color0, padding: 0; opacity: 0; background: transparent; border: none;\
accent = p.color4, outline: none; box-shadow: none; }}\
critical = p.color1, .control-panel-section {{ margin: 8px 0 0; }}\
on_bg = ink_on(&p.background), .sink-row label {{ font-size: 15px; }}\
on_surface = ink_on(&p.color0), .power-row {{ margin-top: 8px; }}\
trough = hex_to_rgba(&p.color4, 0.25), .power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\
background: alpha({ink}, 0.08); font-size: 13px; border: none;\
outline: none; box-shadow: none;\
transition: background-color 0.2s {spring_settle}; }}\
.power-btn:hover {{ background: alpha({ink}, 0.14); }}\
.power-btn:active {{ background: alpha(@accent, 0.22); }}\
.notification-action {{ transition: background-color 0.18s ease; }}\
.tray-btn {{ transition: opacity 0.2s ease, background-color 0.2s ease; }}\
separator {{ margin: 4px 0; background: alpha({ink}, 0.10); }}\
/* Lua-declared widgets (see Documentation.md's Widgets §style): the\
slot rule below is what the four inline `.bread-widget-slot`\
containers in main.rs rely on for the same 12px stat-pair rhythm\
everything else in the bar uses (they carried the class with no\
rule defining it until now). Everything after that is the fixed,\
closed `style` vocabulary a `WidgetNode` can opt into one class\
per enum variant, so a module can only ever pick from this set,\
never inject arbitrary CSS. The progress-bar rules give an\
unstyled Progress node an intentional accent-colored fill instead\
of Adwaita's default blue-on-gray, and let `style.color` retint\
that fill the same way it retints label/icon text. */\
.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 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-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\
progressbar.bread-widget-node.bread-color-accent trough progress {{ background-color: @accent; }}\
progressbar.bread-widget-node.bread-color-red trough progress {{ background-color: @red; }}\
progressbar.bread-widget-node.bread-color-green trough progress {{ background-color: @green; }}\
progressbar.bread-widget-node.bread-color-yellow trough progress {{ background-color: @yellow; }}\
progressbar.bread-widget-node.bread-color-blue trough progress {{ background-color: @blue; }}\
progressbar.bread-widget-node.bread-color-pink trough progress {{ background-color: @pink; }}\
progressbar.bread-widget-node.bread-color-teal trough progress {{ background-color: @teal; }}\
.bread-color-fg {{ color: @fg; }}\
.bread-color-dim {{ color: @fg; opacity: 0.6; }}\
.bread-color-accent {{ color: @accent; }}\
.bread-color-red {{ color: @red; }}\
.bread-color-green {{ color: @green; }}\
.bread-color-yellow {{ color: @yellow; }}\
.bread-color-blue {{ color: @blue; }}\
.bread-color-pink {{ color: @pink; }}\
.bread-color-teal {{ color: @teal; }}\
.bread-weight-normal {{ font-weight: normal; }}\
.bread-weight-bold {{ font-weight: bold; }}\
.bread-size-xs {{ font-size: 10px; }}\
.bread-size-sm {{ font-size: 12px; }}\
.bread-size-md {{ font-size: 14px; }}\
.bread-size-lg {{ font-size: 16px; }}\
.bread-size-xl {{ font-size: 20px; }}\
.bread-bg-none {{ background-color: transparent; }}\
.bread-bg-surface {{ background-color: @surface; color: @on-surface; }}\
.bread-bg-card {{ background-color: @surface; color: @on-surface; border-radius: 8px; padding: 12px; }}\
.bread-radius-none {{ border-radius: 0; }}\
.bread-radius-sm {{ border-radius: 4px; }}\
.bread-radius-md {{ border-radius: 8px; }}\
.bread-radius-full {{ border-radius: 999px; }}\
.bread-padding-none {{ padding: 0; }}\
.bread-padding-xs {{ padding: 4px; }}\
.bread-padding-sm {{ padding: 8px; }}\
.bread-padding-md {{ padding: 12px; }}\
/* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\
like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\
are built regardless of the active theme (see main.rs's \"Assemble\"\
section), just never placed in a slot outside spotlight, so these\
rules render nothing on liquid-motion/glass-workbench. */\
.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 bar /// luminance-picked colour the bar's text uses, so icons stay legible on the
/// whatever lightness pywal gives the background. /// bar 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 {
ink_on(&load_palette().background).to_string() let p = load_palette();
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
/// [`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)]
pub fn fg_color_for(output: &str) -> String {
let p = load_palette_for(output);
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.
///
/// App CSS still uses `@accent` / `@on-bg` tokens; `bind_window_with_app_css`
/// resolves them against that output. Display-level [`apply`] stays as the
/// SIGHUP / single-output fallback.
pub fn bind_output(widget: &impl IsA<gtk4::Widget>, output: &str) {
bgtk::bind_window_with_app_css(widget, output, load_css_for);
}
/// Bind a satellite window (notification, history, OSD, wifi dialog) to
/// whichever output it is actually rendered on.
pub fn bind_auto(window: &impl IsA<gtk4::Native>) {
bgtk::bind_window_auto_with_app_css(window, load_css_for);
}
fn load_css_for(_palette: &Palette) -> String {
load_css()
} }
/// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`. /// Apply (or reload) the theme CSS. Safe to call from `glib::MainContext::invoke`.
@ -122,3 +814,31 @@ 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));
}

106
src/widgets/client.rs Normal file
View file

@ -0,0 +1,106 @@
//! Connects to breadd's IPC socket and keeps the bar's widget set in sync.
//!
//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*`
//! events are used purely as a "something changed, go re-fetch" signal, not
//! applied as incremental patches. Every dirty signal (and the initial
//! connect) re-requests the complete widget list and hands it to `update()`
//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers
//! from scratch. This sidesteps event-ordering/drop concerns entirely, and
//! widget registries are small enough that re-fetching the full list on
//! every change is not a real cost.
use crate::{App, AppInput};
use bread_shared::widget::WidgetSpec;
use bread_utils::bread_client::BreadClient;
use relm4::ComponentSender;
use std::time::Duration;
/// breadbar's own registered app id — already reserved in
/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces
/// section). Used both to fetch widgets and to publish click events.
pub const APP_ID: &str = "bar";
/// Safety-net poll interval. `bread.widget.cleared` (emitted once per
/// daemon reload, including a full restart — see breadd's `reload_internal`)
/// is meant to catch the case where a module stops registering widgets
/// without anything else re-triggering a fetch, but a *restart* (as opposed
/// to a live `bread reload`) drops the subscription entirely; if that one
/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff
/// finishes re-establishing the stream, it's missed and there's no second
/// chance from the event side. This poll is the backstop for that race —
/// infrequent enough that it's not a real cost, frequent enough that a missed
/// event self-heals well within a session rather than needing a manual
/// breadbar restart to clear stale widgets.
const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// Start the widget subsystem: an initial fetch, a live subscription that
/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as
/// a backstop against the reconnect race described above. Call once from
/// `init`.
pub fn spawn(sender: ComponentSender<App>) {
// BreadClient::request is blocking std I/O; run it off the tokio
// runtime breadbar's other pollers rely on, same as the reasoning in
// `BreadClient::subscribe`'s own background-thread design.
let initial = sender.clone();
std::thread::spawn(move || fetch_and_send(&initial));
// `subscribe` already reconnects with backoff on its own background
// thread for the lifetime of the process — there is no natural point to
// stop it before the app exits, so the handle is intentionally leaked
// rather than threaded through App just to be dropped at shutdown.
let live = sender.clone();
let client = BreadClient::connect(APP_ID);
let subscription = client.subscribe("bread.widget.**", move |_event| {
fetch_and_send(&live);
});
std::mem::forget(subscription);
let polled = sender.clone();
relm4::spawn(async move {
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let polled = polled.clone();
std::thread::spawn(move || fetch_and_send(&polled));
}
});
}
fn fetch_and_send(sender: &ComponentSender<App>) {
let client = BreadClient::connect(APP_ID);
let Some(result) = client.request("widgets.list", serde_json::Value::Null) else {
return;
};
// Decode element-wise rather than `Vec<WidgetSpec>` in one shot — one
// malformed entry from any module (a bad `class`, an unknown enum value,
// ...) must not blank out every other module's widgets.
let raw: Vec<serde_json::Value> = serde_json::from_value(result).unwrap_or_default();
let specs: Vec<WidgetSpec> = raw
.into_iter()
.filter_map(|v| {
// `id`/`module` are read before the value is consumed by the
// failed parse below, so a malformed spec still names itself in
// the warning instead of just printing a bare serde error.
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string();
let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string();
match serde_json::from_value::<WidgetSpec>(v) {
Ok(spec) => Some(spec),
Err(e) => {
eprintln!(
"breadbar: dropping malformed widget spec (id={id}, module={module}): {e}"
);
None
}
}
})
.collect();
sender.input(AppInput::WidgetsUpdate(specs));
}
/// Publish a widget click back to breadd. `action` is whatever opaque value
/// the Lua module put in the clicked node's `on_click`.
pub fn emit_click(widget_id: &str, action: &serde_json::Value) {
BreadClient::connect(APP_ID).emit(
"bread.bar.widget_clicked",
serde_json::json!({ "widget_id": widget_id, "action": action }),
);
}

7
src/widgets/mod.rs Normal file
View file

@ -0,0 +1,7 @@
//! Lua-declared, live-updating widgets (see `Documentation.md`'s "Widgets"
//! section in the `bread` repo) rendered into breadbar's fixed layout slots.
pub mod client;
mod render;
pub use render::build_node;

219
src/widgets/render.rs Normal file
View file

@ -0,0 +1,219 @@
//! Turns a `WidgetNode` tree into a live GTK4 widget tree.
//!
//! There is no diffing at the node level — see `client.rs`'s module doc for
//! why the whole thing is simply rebuilt whenever a widget's spec changes.
//! This keeps the renderer a pure, stateless `WidgetNode -> gtk4::Widget`
//! function.
use super::client;
use bread_shared::widget::{
Align as StyleAlign, Background, FontWeight, Orientation as NodeOrientation, Padding, Radius,
SemanticColor, TextSize, WidgetNode, WidgetStyle,
};
use gtk4::prelude::*;
/// Default max width for a Label node, in characters, absent an explicit
/// `size`/other override — the `style` vocabulary (see Documentation.md's
/// Widgets §style) has no dedicated width field yet, so this stays fixed for
/// every label rather than becoming a half-exposed knob.
const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32;
/// Curated bundled icons a widget can reference by name, so module authors
/// don't need to ship an SVG just to show a battery or bluetooth glyph.
/// Anything else goes through `icon.path` instead (see `bundled_or_path_icon`).
fn bundled_icon(name: &str) -> Option<&'static str> {
use crate::bar::stats::{
AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK,
ICON_RESTART, ICON_SHUTDOWN, ICON_SLEEP, ICON_VOLUME, WIFI_MEDIUM, WIFI_OFF, WIFI_STRONG,
WIFI_WEAK,
};
Some(match name {
"ac-power" => AC_POWER,
"battery-high" => BAT_HIGH,
"battery-mid" => BAT_MID,
"battery-low" => BAT_LOW,
"bluetooth-on" => BT_ON,
"bluetooth-off" => BT_OFF,
"wifi-strong" => WIFI_STRONG,
"wifi-medium" => WIFI_MEDIUM,
"wifi-weak" => WIFI_WEAK,
"wifi-off" => WIFI_OFF,
"lock" => ICON_LOCK,
"sleep" => ICON_SLEEP,
"restart" => ICON_RESTART,
"shutdown" => ICON_SHUTDOWN,
"volume" => ICON_VOLUME,
"brightness" => ICON_BRIGHTNESS,
_ => return None,
})
}
fn icon_texture(
widget_id: &str,
name: Option<&str>,
path: Option<&str>,
px: u32,
) -> Option<gtk4::gdk::Texture> {
if let Some(n) = name {
return match bundled_icon(n) {
Some(svg) => Some(crate::svg_texture_sized(svg, px)),
None => {
eprintln!("breadbar: widget {widget_id}: unknown bundled icon name '{n}'");
None
}
};
}
let Some(path) = path else {
eprintln!("breadbar: widget {widget_id}: icon node has neither 'name' nor 'path'");
return None;
};
let expanded = bread_shared::expand_path(path);
match std::fs::read_to_string(&expanded) {
Ok(svg) => Some(crate::svg_texture_sized(&svg, px)),
Err(e) => {
eprintln!("breadbar: widget {widget_id}: failed to read icon path '{path}': {e}");
None
}
}
}
/// Map a node's typed `style` onto predefined CSS classes (see `theme.rs` for
/// the class definitions) — this is the only path from Lua's `style` field to
/// the widget, kept as narrow `Some(field) -> one class` mappings so there is
/// no way for it to become raw style injection.
fn apply_style(widget: &gtk4::Widget, style: &WidgetStyle) {
if let Some(color) = style.color {
widget.add_css_class(match color {
SemanticColor::Fg => "bread-color-fg",
SemanticColor::Dim => "bread-color-dim",
SemanticColor::Accent => "bread-color-accent",
SemanticColor::Red => "bread-color-red",
SemanticColor::Green => "bread-color-green",
SemanticColor::Yellow => "bread-color-yellow",
SemanticColor::Blue => "bread-color-blue",
SemanticColor::Pink => "bread-color-pink",
SemanticColor::Teal => "bread-color-teal",
});
}
if let Some(weight) = style.weight {
widget.add_css_class(match weight {
FontWeight::Normal => "bread-weight-normal",
FontWeight::Bold => "bread-weight-bold",
});
}
if let Some(size) = style.size {
widget.add_css_class(match size {
TextSize::Xs => "bread-size-xs",
TextSize::Sm => "bread-size-sm",
TextSize::Md => "bread-size-md",
TextSize::Lg => "bread-size-lg",
TextSize::Xl => "bread-size-xl",
});
}
if let Some(background) = style.background {
widget.add_css_class(match background {
Background::None => "bread-bg-none",
Background::Surface => "bread-bg-surface",
Background::Card => "bread-bg-card",
});
}
if let Some(radius) = style.radius {
widget.add_css_class(match radius {
Radius::None => "bread-radius-none",
Radius::Sm => "bread-radius-sm",
Radius::Md => "bread-radius-md",
Radius::Full => "bread-radius-full",
});
}
if let Some(padding) = style.padding {
widget.add_css_class(match padding {
Padding::None => "bread-padding-none",
Padding::Xs => "bread-padding-xs",
Padding::Sm => "bread-padding-sm",
Padding::Md => "bread-padding-md",
});
}
// GTK CSS has no text-align/justify-content equivalent — alignment is a
// widget property, not a stylesheet rule, so it's set directly instead
// of routing through an inert CSS class like the fields above.
if let Some(align) = style.align {
widget.set_halign(match align {
StyleAlign::Start => gtk4::Align::Start,
StyleAlign::Center => gtk4::Align::Center,
StyleAlign::End => gtk4::Align::End,
});
}
}
/// Build (or rebuild) the GTK widget tree for `node`, belonging to widget
/// `widget_id` (fully-qualified `<module>.<id>`, used to tag any click).
pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget {
let widget: gtk4::Widget = match node {
WidgetNode::Box {
orientation,
spacing,
children,
..
} => {
let gtk_orientation = match orientation {
NodeOrientation::Horizontal => gtk4::Orientation::Horizontal,
NodeOrientation::Vertical => gtk4::Orientation::Vertical,
};
let container = gtk4::Box::new(gtk_orientation, spacing.unwrap_or(4));
for child in children {
container.append(&build_node(child, widget_id));
}
container.upcast()
}
WidgetNode::Label { text, .. } => {
let label = gtk4::Label::new(Some(text));
// Unbounded, this is a bar-width-blowout waiting to happen from
// any buggy or malicious module — see Documentation.md issue #6.
label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
label.set_max_width_chars(DEFAULT_LABEL_MAX_WIDTH_CHARS);
label.upcast()
}
WidgetNode::Icon { name, path, size, .. } => {
let px = size.unwrap_or(16).max(1) as u32;
let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px);
let image = gtk4::Image::from_paintable(texture.as_ref());
crate::prepare_icon(&image, px as i32);
image.upcast()
}
WidgetNode::Progress { value, .. } => {
let bar = gtk4::ProgressBar::new();
bar.set_fraction(value.clamp(0.0, 1.0));
// GtkProgressBar's natural expand behavior is to fill all
// available width, which — unlike Label/Box/Image, which hug
// their content by default — propagates up through every
// ancestor Box that doesn't set hexpand explicitly, all the way
// to the bar's end_widget. Pin it to a small fixed footprint so
// it reads as an inline meter instead of swallowing the bar.
bar.set_hexpand(false);
bar.set_valign(gtk4::Align::Center);
bar.set_size_request(40, 6);
bar.upcast()
}
};
widget.add_css_class("bread-widget-node");
if let Some(class) = node.class() {
widget.add_css_class(class);
}
if let Some(style) = node.style() {
apply_style(&widget, style);
}
if let Some(action) = node.on_click() {
widget.add_css_class("clickable");
let widget_id = widget_id.to_string();
let action = action.clone();
let gesture = gtk4::GestureClick::new();
gesture.connect_released(move |_, _, _, _| {
client::emit_click(&widget_id, &action);
});
widget.add_controller(gesture);
}
widget
}