Commit graph

129 commits

Author SHA1 Message Date
Breadway
0aac9afcaa osd: reap pactl subscribe watchers on exit so pipewire-pulse clients aren't starved
Each breadbar restart orphaned a blocking `pactl subscribe` that kept its
PulseAudio connection open; once pipewire-pulse's client cap filled,
new clients (settings apps among them) were refused ("no devices in
settings"). Register an atexit hook (osd.rs) plus SIGINT/SIGTERM handlers
(main.rs) that kill and reap the watcher children before breadbar exits.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

That margin matters: breadbar-dismiss's layer (overlay) always renders
above the bar's own (top), so if its clickable region ever reached up
into where the drawer is actually drawn, it would swallow clicks meant
for a result row instead of the click-away. The capsule's own call site
(main.rs, not part of this commit) always passes a margin sized to the
drawer's maximum possible height, never its live one, so this can never
happen regardless of how tall the drawer currently is.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(cherry picked from commit f0de82aafb547134e0edc9dac8d72c01586db57c)
2026-08-23 15:07:51 +08:00
Breadway
9c4205b1b2 clippy: fix char-comparison and map_or lints
All checks were successful
dev release / build (push) Successful in 1m52s
(cherry picked from commit 2e393082a744fe85f1ca1f8c6af48ce288e64b6c)
2026-08-23 14:57:51 +08:00
Breadway
cd7465a18b Bump version to v0.3.3
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Failing after 1m50s
release / build (push) Successful in 3m11s
2026-08-16 14:09:04 +08:00
Breadway
e4c12e9b62 Add audio output switching and fix the workspace trail
All checks were successful
dev release / build (push) Successful in 1m33s
The control panel lists PipeWire sinks and sets the default, moving
playing streams so the change is immediate. The workspace pill starts
on switch without a layout wait, measures against the Fixed host so it
stays centered on the digit, and ignores row-wide bounds that used to
stretch it across several chips.
2026-08-16 14:04:05 +08:00
Breadway
241dfd17a7 Include GPU.svg so the bar release build can compile
All checks were successful
dev release / build (push) Successful in 2m8s
2026-08-16 13:49:52 +08:00
Breadway
96d666b3cb Fix CI: ship panel.rs and the workspace trail helper
Some checks failed
dev release / build (push) Failing after 9s
mod panel and stretch_geom_on were referenced on main but not
committed, so --locked release builds failed.
2026-08-16 13:45:59 +08:00
Breadway
1806c6f912 Bind each bar to its output palette and finish the island chrome
Some checks failed
dev release / build (push) Failing after 3m37s
One layer-shell window per Hyprland output now loads that output's
bread-theme palette. Notifications, history, and OSD follow the
monitor they appear on. Pin bread-theme to v0.7.4.
2026-08-16 13:23:31 +08:00
Breadway
ae1fee3591 CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 20s
dev release / build (push) Successful in 3m7s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 1m40s
2026-08-16 00:50:22 +08:00
Breadway
62c6dd5ea3 Adopt bread_utils::screenshot_cli for --screenshot flags
All checks were successful
dev release / build (push) Successful in 1m22s
Replace the local settle delay, canvas defaults, and pair-validation
error path with bread-utils v0.7.2. Clap parsing stays in-tree.
2026-08-16 00:27:46 +08:00