Compare commits

..

51 commits
v0.3.2 ... 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
21 changed files with 5555 additions and 872 deletions

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/

240
Cargo.lock generated
View file

@ -123,9 +123,9 @@ dependencies = [
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.91" version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -150,31 +150,29 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bread-launcher"
version = "0.7.5"
dependencies = [
"bread-utils 0.7.5",
"gtk4",
"serde_json",
]
[[package]] [[package]]
name = "bread-screenshots" name = "bread-screenshots"
version = "0.7.2" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)",
"tracing", "tracing",
] ]
[[package]]
name = "bread-shared"
version = "0.7.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
dependencies = [
"dirs 5.0.1",
"serde",
"serde_json",
"toml 0.8.23",
]
[[package]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.8.0" version = "0.8.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0-rc.1#2485e1af1f941c724461c0d59416c829ded638fe" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"serde", "serde",
@ -185,21 +183,33 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.7.2" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"anyhow",
"dirs 5.0.1", "dirs 5.0.1",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23",
"tracing",
]
[[package]]
name = "bread-utils"
version = "0.7.5"
dependencies = [
"bread-shared",
"dirs 5.0.1",
"serde",
"serde_json",
] ]
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.7.2" version = "0.7.5"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5#46b886b7bc168e73945269e143b18ef8a0d987f8"
dependencies = [ dependencies = [
"bread-shared 0.7.0", "bread-shared",
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
"serde_json", "serde_json",
@ -207,13 +217,14 @@ dependencies = [
[[package]] [[package]]
name = "breadbar" name = "breadbar"
version = "0.3.1" version = "0.3.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-launcher",
"bread-screenshots", "bread-screenshots",
"bread-shared 0.8.0", "bread-shared",
"bread-theme", "bread-theme",
"bread-utils", "bread-utils 0.7.5 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.5)",
"clap", "clap",
"futures-lite", "futures-lite",
"gtk4", "gtk4",
@ -265,7 +276,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -286,9 +297,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.4" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -296,9 +307,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.2" version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -564,9 +575,9 @@ dependencies = [
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -579,9 +590,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -589,15 +600,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -606,9 +617,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]] [[package]]
name = "futures-lite" name = "futures-lite"
@ -625,32 +636,32 @@ dependencies = [
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -685,7 +696,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -718,7 +729,7 @@ dependencies = [
"libc", "libc",
"pango-sys", "pango-sys",
"pkg-config", "pkg-config",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -771,7 +782,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -835,7 +846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [ dependencies = [
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -846,7 +857,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -867,7 +878,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -898,7 +909,7 @@ dependencies = [
"graphene-sys", "graphene-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -924,9 +935,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell" name = "gtk4-layer-shell"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"gdk4", "gdk4",
@ -939,15 +950,15 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell-sys" name = "gtk4-layer-shell-sys"
version = "0.6.0" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b"
dependencies = [ dependencies = [
"gdk4-sys", "gdk4-sys",
"glib-sys", "glib-sys",
"gtk4-sys", "gtk4-sys",
"libc", "libc",
"system-deps", "system-deps 8.0.0",
] ]
[[package]] [[package]]
@ -978,7 +989,7 @@ dependencies = [
"gsk4-sys", "gsk4-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -1058,9 +1069,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.103" version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@ -1093,9 +1104,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.18" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -1214,7 +1225,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -1243,9 +1254,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "png" name = "png"
@ -1321,7 +1332,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.19", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@ -1624,6 +1635,19 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "system-deps"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.1.4+spec-1.1.0",
"version-compare",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@ -1654,11 +1678,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl 2.0.19", "thiserror-impl 2.0.20",
] ]
[[package]] [[package]]
@ -1674,9 +1698,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1728,13 +1752,13 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.7.1" version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
] ]
[[package]] [[package]]
@ -1919,9 +1943,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.0" version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
@ -1943,9 +1967,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.126" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -1956,9 +1980,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.126" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -1966,9 +1990,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.126" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -1979,9 +2003,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.126" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -2087,9 +2111,9 @@ dependencies = [
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.28" version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xmlwriter" name = "xmlwriter"
@ -2099,9 +2123,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.18.0" version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-recursion", "async-recursion",
@ -2129,14 +2153,14 @@ dependencies = [
[[package]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.18.0" version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
"zbus_names", "zbus_names",
"zvariant", "zvariant",
"zvariant_utils", "zvariant_utils",
@ -2153,6 +2177,15 @@ dependencies = [
"zvariant", "zvariant",
] ]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
@ -2161,40 +2194,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.13.1" version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
dependencies = [ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"winnow 1.0.4", "winnow 1.0.4",
"zcheapstr",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",
] ]
[[package]] [[package]]
name = "zvariant_derive" name = "zvariant_derive"
version = "5.13.1" version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.3",
"zvariant_utils", "zvariant_utils",
] ]
[[package]] [[package]]
name = "zvariant_utils" name = "zvariant_utils"
version = "3.5.0" version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde", "serde",
"syn 2.0.119", "syn 3.0.3",
"winnow 1.0.4", "winnow 1.0.4",
] ]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadbar" name = "breadbar"
version = "0.3.1" 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,17 +10,22 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"]
categories = ["gui"] categories = ["gui"]
[dependencies] [dependencies]
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", 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) # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe)
# for talking to breadd's IPC socket, and bread-shared purely for the # for talking to breadd's IPC socket, and bread-shared purely for the
# WidgetSpec/WidgetNode wire types so we deserialize into real structs # WidgetSpec/WidgetNode wire types so we deserialize into real structs
# instead of hand-parsing serde_json::Value. See src/widgets/. # instead of hand-parsing serde_json::Value. See src/widgets/.
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.5", features = ["bread-client"] }
# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if # bread_shared::widget wire types; match the bread-shared tag bread-utils
# ecosystem crates move independently. # 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-rc.1" } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } 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"] }

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

@ -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())
if let Ok(active) = Workspace::get_active_async().await { .unwrap_or_default();
sender.input(AppInput::ActiveWorkspace(active.id)); let mut actives = std::collections::HashMap::new();
if let Ok(mons) = Monitors::get_async().await {
for m in mons {
if !m.disabled {
actives.insert(m.name, m.active_workspace.id);
}
}
} }
sender.input(AppInput::WorkspaceSync {
workspaces,
actives,
});
} }
pub fn spawn_watcher(sender: ComponentSender<crate::App>) { 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

@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use gtk4_layer_shell::{KeyboardMode, LayerShell};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::Urgency; use super::Urgency;
@ -175,13 +175,19 @@ pub fn build_window(store: Store) -> Ui {
let window = gtk4::Window::new(); let window = gtk4::Window::new();
window.add_css_class("breadbar-history"); window.add_css_class("breadbar-history");
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); // Overrides `[surfaces."breadbar-notif"].width` (320px, the live-toast
window.set_margin(Edge::Top, 48); // popup's width — see `surface::apply`'s doc comment): the history
window.set_margin(Edge::Right, 20); // 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_default_width(360);
window.set_size_request(360, -1);
window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_keyboard_mode(KeyboardMode::OnDemand);
crate::theme::bind_auto(&window);
let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8); let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
outer.set_margin_top(10); outer.set_margin_top(10);
@ -284,8 +290,8 @@ fn make_row(entry: &Entry) -> gtk4::Box {
} }
let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let show_app = !entry.app_name.is_empty() let show_app =
&& !entry.app_name.eq_ignore_ascii_case(&entry.summary); !entry.app_name.is_empty() && !entry.app_name.eq_ignore_ascii_case(&entry.summary);
if show_app { if show_app {
let app = gtk4::Label::new(Some(&entry.app_name)); let app = gtk4::Label::new(Some(&entry.app_name));
app.add_css_class("notification-app"); app.add_css_class("notification-app");

View file

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

View file

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

View file

@ -20,11 +20,9 @@ use gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s /// Settle time for views whose content depends on a live-data popover load
/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels, /// (connectivity's wifi/bluetooth scan, control-panel sliders) — capturing
/// gated on popover visibility) or a similar live-data popover load /// any sooner leaves placeholder dashes/"Scanning…" instead of real content.
/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves
/// placeholder dashes/"Scanning…" instead of real content.
const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200); const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// Delay between the bar's own `map` and calling `popover.popup()`. Calling /// Delay between the bar's own `map` and calling `popover.popup()`. Calling
@ -47,6 +45,21 @@ const KNOWN_VIEWS: &[&str] = &[
"osd-volume", "osd-volume",
"osd-brightness", "osd-brightness",
"wifi-add-dialog", "wifi-add-dialog",
// Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new
// match arms. "capsule-collapsed" captures the same region "bar"
// always has (bar_capture_height already reflects the active theme's
// own window height/margin); it exists as its own name purely so a
// verification script doesn't have to already know "bar" means "the
// capsule, collapsed" under spotlight specifically.
"capsule-collapsed",
"capsule-expanded",
// Phase 6c: query sections and the `=` calc mode — see `dispatch`'s
// two new match arms below. "capsule-expanded" already exercises the
// search-state width/radius change (item E: it types a query, which
// now also drives `open_fn`'s `.searching` root class + capsule-width
// spring animation), so that gap doesn't need its own view.
"capsule-sections",
"capsule-calc",
]; ];
#[derive(Parser)] #[derive(Parser)]
@ -107,11 +120,11 @@ impl Cli {
/// outlive `init()` (never stored on `App`), so they have to be cloned out /// outlive `init()` (never stored on `App`), so they have to be cloned out
/// before dispatch time same as `control_popover` always was. /// before dispatch time same as `control_popover` always was.
pub struct Handles { pub struct Handles {
pub control_popover: gtk4::Popover, pub control_panel: gtk4::Window,
pub connectivity_popover: gtk4::Popover, pub connectivity_panel: gtk4::Window,
pub wifi_tab_btn: gtk4::ToggleButton, pub wifi_tab_btn: gtk4::ToggleButton,
pub bt_tab_btn: gtk4::ToggleButton, pub bt_tab_btn: gtk4::ToggleButton,
pub media_popover: gtk4::Popover, pub media_panel: gtk4::Window,
pub media_widget: gtk4::Box, pub media_widget: gtk4::Box,
pub media_track_lbl: gtk4::Label, pub media_track_lbl: gtk4::Label,
/// Already built and primed with sample content by `main.rs` (via /// Already built and primed with sample content by `main.rs` (via
@ -120,36 +133,72 @@ pub struct Handles {
pub notification_window: Option<gtk4::Window>, pub notification_window: Option<gtk4::Window>,
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
pub osd_window: Option<gtk4::Window>, pub osd_window: Option<gtk4::Window>,
/// Theme 04/spotlight's capsule centre module — see the
/// "capsule-expanded" view, which focuses it and types a query to
/// drive the drawer open before capturing.
pub launcher_entry: gtk4::Entry,
/// The `drawer` slot's own container — read back after the open
/// animation settles so "capsule-expanded"'s capture height matches
/// however tall the results actually grew, rather than a guessed
/// constant.
pub drawer_box: gtk4::Box,
} }
/// The bar's fixed height — matches `root.set_exclusive_zone(32)` / /// Capture height for the `bar` view: layer-shell top margin + widget
/// `set_default_height: 32` in `main.rs`. Unlike the other views' full /// height (the exclusive zone). Unlike the other views' full canvas,
/// canvas, this never varies with `--width`/`--height`. /// this never varies with `--width`/`--height`.
const BAR_HEIGHT: i32 = 32; 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) { pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) {
let output = req.output; let output = req.output;
let (width, height) = (req.width as i32, req.height as i32); let (width, height) = (req.width as i32, req.height as i32);
let bar_height = bar_capture_height();
let bar_y = bar_capture_y(height, bar_height);
match req.view.as_str() { match req.view.as_str() {
"bar" => { "bar" => {
root.connect_map(move |_| { root.connect_map(move |_| {
let output = output.clone(); let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, BAR_HEIGHT, &output)); finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output));
}); });
}); });
} }
"control-panel" => { "control-panel" => {
open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); open_panel_on_root_map(root, handles.control_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
} }
"connectivity-wifi" => { "connectivity-wifi" => {
handles.wifi_tab_btn.set_active(true); handles.wifi_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
} }
"connectivity-bluetooth" => { "connectivity-bluetooth" => {
handles.bt_tab_btn.set_active(true); handles.bt_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height); open_panel_on_root_map(root, handles.connectivity_panel, LIVE_DATA_SETTLE_DELAY, output, width, height);
} }
"media-popover" => { "media-popover" => {
// Real media state only shows the widget/text when something's // Real media state only shows the widget/text when something's
@ -157,8 +206,9 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
// run has nothing playing, so fake enough of it directly on the // run has nothing playing, so fake enough of it directly on the
// widgets to get a representative capture. // widgets to get a representative capture.
handles.media_widget.set_visible(true); handles.media_widget.set_visible(true);
handles.media_widget.add_css_class("playing");
handles.media_track_lbl.set_text("Sample Track — Sample Artist"); handles.media_track_lbl.set_text("Sample Track — Sample Artist");
open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height); open_panel_on_root_map(root, handles.media_panel, SETTLE_DELAY, output, width, height);
} }
"notification" | "notification-critical" => { "notification" | "notification-critical" => {
let Some(window) = handles.notification_window else { let Some(window) = handles.notification_window else {
@ -174,6 +224,95 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
}; };
capture_standalone_window(window, output, width, height); capture_standalone_window(window, output, width, height);
} }
"capsule-collapsed" => {
// Identical to "bar" — see KNOWN_VIEWS's doc comment on why
// this has its own name anyway.
root.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, bar_y, width, bar_height, &output));
});
});
}
"capsule-expanded" => {
// Focus + a real query, the same way a person opens the
// capsule (`connect_changed`/`EventControllerFocus::enter` in
// main.rs's capsule wiring do the rest: `open_fn` runs,
// `results.set_query` filters, and `animate_drawer_height`
// grows `drawer_box`). Capture height is bar height + however
// tall the drawer actually settled, not a hardcoded guess —
// this stays correct even if `dot_widths`/font/entry-count
// numbers change later.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("f");
let output = output.clone();
let drawer_box = drawer_box.clone();
// Past the 360ms spring_to run plus a normal capture
// settle, so the drawer's size_request (and the actual
// on-screen layer-shell surface it grows) has reached
// its final height before grabbing pixels.
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"capsule-sections" => {
// Focus with NO query typed — the idle browse view
// (`ResultsList::new`'s "Recent"/"Apps" header rows are visible
// from construction; `set_query` is what would hide them, and
// it's never called here). Same settle timing as
// "capsule-expanded", just without the `entry.set_text` step.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"capsule-calc" => {
// `=` mode (item C): the drawer's `mode_list` shows a single
// evaluated result row instead of `launcher_results.scroller`
// (see `populate_mode_list`'s `QueryKind::Calc` arm).
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("=6*7");
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"wifi-add-dialog" => { "wifi-add-dialog" => {
let anchor = handles.wifi_tab_btn; let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| { root.connect_map(move |_| {
@ -196,27 +335,25 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
} }
} }
/// Shared shape for every popover view: force it open shortly after the bar /// Shared shape for panel views: present the standalone layer window after
/// maps (autohide disabled — a programmatic `popup()` has no real input /// the bar maps, then capture the canvas once the panel itself maps.
/// event serial to grab the Wayland seat with), then capture the whole fn open_panel_on_root_map(
/// canvas after `settle` once the popover itself maps.
fn open_popover_on_root_map(
root: &gtk4::ApplicationWindow, root: &gtk4::ApplicationWindow,
popover: gtk4::Popover, panel: gtk4::Window,
settle: Duration, settle: Duration,
output: PathBuf, output: PathBuf,
width: i32, width: i32,
height: i32, height: i32,
) { ) {
let popover_to_open = popover.clone(); let panel_to_open = panel.clone();
root.connect_map(move |_| { root.connect_map(move |_| {
popover_to_open.set_autohide(false); let panel_to_open = panel_to_open.clone();
let popover_to_open = popover_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
popover_to_open.popup(); panel_to_open.set_visible(true);
panel_to_open.present();
}); });
}); });
popover.connect_map(move |_| { panel.connect_map(move |_| {
let output = output.clone(); let output = output.clone();
gtk4::glib::timeout_add_local_once(settle, move || { gtk4::glib::timeout_add_local_once(settle, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output)); finish(bread_screenshots::capture_region(0, 0, width, height, &output));

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,98 +58,588 @@ 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 = "0px"; //
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\
window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ why that matters to the workspace trail specifically).\
border-radius: {radius}; }}\ Liquid Motion additionally springs the surface's own\
.notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ layer-shell top margin via `anim::spring_to` in Rust, which\
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ this keyframe knows nothing about; glass-workbench never adds\
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\ this class at all. */\
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\ @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; }}\
.notification-actions {{ margin-top: 6px; }}\ .notification-actions {{ margin-top: 6px; }}\
.notification-action {{ padding: 2px 8px; font-size: 11px; }}\ .notification-action {{ padding: 2px 8px; font-size: 11px; border-radius: {radius_sm}; }}\
.notification-reply {{ margin-top: 6px; }}\ .notification-reply {{ margin-top: 6px; }}\
.notification-reply-entry {{ min-width: 0; }}\ .notification-reply-entry {{ min-width: 0; }}\
/* NOTIFICATION INTERACTION #A: a direct dismiss control, floated\
in the card's top-right corner via an Overlay (see popup.rs's\
`make_card`) rather than a full extra header row, so it doesn't\
add vertical bulk the approved demo's own card never has. */\
.notification-dismiss {{ min-width: 18px; min-height: 18px; padding: 0;\
margin: 2px; border-radius: {radius_pill}; background: transparent;\
color: {ink}; opacity: 0.45; font-size: 12px; font-weight: bold;\
border: none; outline: none; box-shadow: none;\
transition: background-color 0.18s {spring_settle}, opacity 0.18s ease; }}\
.notification-dismiss:hover {{ opacity: 1; background: alpha({ink}, 0.16); }}\
.notification-dismiss:active {{ background: alpha({ink}, 0.24); }}\
.history-title {{ font-weight: bold; font-size: 13px; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\
.history-close {{ padding: 2px 8px; }}\ .history-close {{ padding: 2px 8px; }}\
.history-empty {{ opacity: 0.5; padding: 8px 0; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\
.history-time {{ opacity: 0.5; font-size: 11px; }}\ .history-time {{ opacity: 0.5; font-size: 11px; }}\
.history-body {{ opacity: 0.75; }}\ .history-body {{ opacity: 0.75; }}\
.history-card {{ margin-bottom: 6px; }}\ .history-card {{ margin-bottom: 6px; }}\
window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ 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; }}\
.wifi-pair {{ border-radius: {radius_sm}; padding: 0 2px; }}\ progressbar.osd-bar trough progress {{ background-image: none; background-color: @accent;\
.wifi-pair:hover {{ background: alpha({on_bg}, 0.12); }}\ border-radius: 3px; min-height: 6px; }}\
.wifi-popover-inner {{ min-width: 200px; padding: {pad}; }}\ window.breadbar-panel {{ background-color: alpha({panel}, {panel_surface_alpha}); color: {ink};\
.popover-tab-row {{ margin-bottom: {pad}; }}\ border-radius: 14px; border: 1px solid alpha({ink}, 0.12); }}\
.popover-tab {{ background: transparent; color: {on_bg}; border: none; box-shadow: none;\ /* A real GtkButton's own label text does NOT inherit `color` from\
outline: none; border-radius: {radius_sm}; padding: 4px 10px; font-size: 11px;\ an ancestor window: `bread_theme::stylesheet()`'s shared,\
font-weight: bold; opacity: 0.55; }}\ ecosystem-wide `button {{ color: @on-surface }}` rule (lib.rs,\
applied to every bread app before this file's CSS layers on\
top) matches the button element directly, and a direct match\
always beats inheritance regardless of specificity. `@on-surface`\
is `ink_on(@surface)`, and `@surface` is `bread_theme::palette`'s\
FIXED_SURFACE constant pinned dark, same as `@bg` so every\
button's label (power row, hamburger, wifi/bluetooth popover\
rows, the add-network dialog's Cancel/Connect) rendered\
near-white regardless of the active shell theme. Invisible-but-\
correct on every dark theme through spotlight; confirmed\
empirically as near-invisible ghost text under daylight (isolated\
`bread-capture` control-panel screenshot, pre-fix). One rule,\
scoped by ancestor class so its specificity beats the shared\
unscoped `button` rule, instead of patching each `.power-btn`/\
`.control-panel-btn`/`.wifi-popover-row`/etc. class individually. */\
window.breadbar-panel button, window.wifi-popover button,\
window.wifi-add-dialog button, window.breadbar-notification button,\
window.breadbar-history button {{ color: {ink}; }}\
window.breadbar-dismiss, .breadbar-dismiss-hit {{\
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,\
border-radius: {radius}; }}\ switch.bt-switch:checked:hover {{ min-width: 42px; min-height: 24px; padding: 2px;\
window.wifi-add-dialog headerbar {{ background-color: {bg_rgba}; color: {on_bg};\ border: none; outline: none; box-shadow: none; background-image: none;\
border-radius: 99px; }}\
switch.bt-switch {{ background-color: alpha({ink}, 0.14);\
transition: background-color 0.25s {spring_settle}; }}\
switch.bt-switch:checked {{ background-color: @accent; }}\
switch.bt-switch slider {{ min-width: 20px; min-height: 20px; margin: 0;\
border-radius: 99px; border: none; outline: none; box-shadow: none;\
background-image: none; background-color: {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-top-left-radius: {radius}; border-top-right-radius: {radius};\
border-bottom: 1px solid alpha({on_bg}, 0.08); box-shadow: none; }}\ border-bottom: 1px solid alpha({ink}, 0.10); box-shadow: none; }}\
.confirm-button {{ background-color: @accent; color: @on-accent; }}\ .confirm-button {{ background-color: @accent; color: @on-accent; }}\
.confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\ .confirm-button:hover {{ background-color: alpha(@accent, 0.85); }}\
.media-widget {{ border-radius: {radius_sm}; padding: 0 6px; }}\ /* min-height: decision #1 — the media chip is a bar chip like\
.media-widget:hover {{ background: alpha({on_bg}, 0.10); }}\ any other, so it shares the same row height instead of sizing\
.media-indicator {{ font-size: 11px; opacity: 0.7; margin-right: 2px; }}\ to its own eq-bar/label content. */\
.media-track-lbl {{ font-size: 12px; }}\ .media-widget {{ border-radius: 10px; padding: 4px 8px; min-height: {chip_height_px};\
.media-controls {{ padding: 2px; }}\ transition: background-color 0.22s {spring_settle}; }}\
.media-btn {{ min-width: 32px; padding: 4px 8px; }}\ .media-widget:hover {{ background: alpha({ink}, 0.08); }}\
.control-panel-btn {{ padding: 0 6px; margin-left: 6px; border-radius: {radius_sm}; }}\ .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;\
background-image: none; background-color: @accent; }}\
scale.control-panel-slider slider {{ min-width: 0; min-height: 0; margin: 0;\
padding: 0; opacity: 0; background: transparent; border: none;\
outline: none; box-shadow: none; }}\
.control-panel-section {{ margin: 8px 0 0; }}\
.sink-row label {{ font-size: 15px; }}\
.power-row {{ margin-top: 8px; }}\
.power-btn {{ min-width: 0; min-height: 0; padding: 8px 10px; border-radius: 8px;\
background: alpha({ink}, 0.08); font-size: 13px; border: none;\
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\ /* Lua-declared widgets (see Documentation.md's Widgets §style): the\
slot rule below is what the four inline `.bread-widget-slot`\ slot rule below is what the four inline `.bread-widget-slot`\
containers in main.rs rely on for the same 12px stat-pair rhythm\ containers in main.rs rely on for the same 12px stat-pair rhythm\
@ -119,7 +652,7 @@ fn load_css() -> String {
of Adwaita's default blue-on-gray, and let `style.color` retint\ of Adwaita's default blue-on-gray, and let `style.color` retint\
that fill the same way it retints label/icon text. */\ that fill the same way it retints label/icon text. */\
.bread-widget-slot {{ margin-right: 12px; }}\ .bread-widget-slot {{ margin-right: 12px; }}\
progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ 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 trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\
progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\
progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\
@ -156,23 +689,115 @@ fn load_css() -> String {
.bread-padding-none {{ padding: 0; }}\ .bread-padding-none {{ padding: 0; }}\
.bread-padding-xs {{ padding: 4px; }}\ .bread-padding-xs {{ padding: 4px; }}\
.bread-padding-sm {{ padding: 8px; }}\ .bread-padding-sm {{ padding: 8px; }}\
.bread-padding-md {{ padding: 12px; }}", .bread-padding-md {{ padding: 12px; }}\
bg_plain = p.background, /* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\
bg_rgba = hex_to_rgba(&p.background, 0.92), like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\
surface = p.color0, are built regardless of the active theme (see main.rs's \"Assemble\"\
accent = p.color4, section), just never placed in a slot outside spotlight, so these\
critical = p.color1, rules render nothing on liquid-motion/glass-workbench. */\
on_bg = ink_on(&p.background), .launcher-entry {{ background: transparent; color: {ink}; border: none;\
on_surface = ink_on(&p.color0), outline: none; box-shadow: none; caret-color: @{accent_from};\
trough = hex_to_rgba(&p.color4, 0.25), 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`.
@ -189,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));
}

View file

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