Spotlight pill, stage, grouped results and notifications #37

Merged
Breadway merged 24 commits from feature/spotlight-pill into main 2026-09-30 02:17:32 +08:00
Owner

Implements the reviewed spotlight phases: an auto-hiding pill with workspace modes and widget slots, a search-first stage with grouped results and power confirmation, surtitles, and in-pill levels. Includes the spotlight screenshot scenarios and the reviewed fixes documented on BAR-27 and BAR-30.

Verification: workspace tests, clippy, and build passed with the local bread-ecosystem spotlight branch. Headless Sway captures passed for pill shown/tucked, stage idle/results, critical surtitle, and in-pill levels. cargo fmt --all -- --check reports existing drift on origin/main, confirmed against an archive of main; this branch adds no unrelated formatting change.

Dependency: merge Breadway/bread-ecosystem PR #45 first. This change uses its bread-theme API and CSS.

Closes #26
Closes #27
Closes #30
Closes #31

Implements the reviewed spotlight phases: an auto-hiding pill with workspace modes and widget slots, a search-first stage with grouped results and power confirmation, surtitles, and in-pill levels. Includes the spotlight screenshot scenarios and the reviewed fixes documented on BAR-27 and BAR-30. Verification: workspace tests, clippy, and build passed with the local bread-ecosystem spotlight branch. Headless Sway captures passed for pill shown/tucked, stage idle/results, critical surtitle, and in-pill levels. `cargo fmt --all -- --check` reports existing drift on origin/main, confirmed against an archive of main; this branch adds no unrelated formatting change. Dependency: merge Breadway/bread-ecosystem PR #45 first. This change uses its bread-theme API and CSS. Closes #26 Closes #27 Closes #30 Closes #31
Renders the new spotlight pill per bos-ui-demos/proposed/spotlight-stage.html:
workspaces, a collapsing divider, widget:left_of_clock/right_of_clock slots,
the plain clock, a low-battery flag and an unread dot, all built as regular
bar modules (sep/flag/unread) so the existing [bar.slots] system places them.

Workspace display (modules.workspaces.display: none/current/all) is a pure
filter (bar::workspaces::workspace_visible, unit tested) layered on top of
the existing style/show_empty filtering without changing it; "current" gets
a compact WS-n indicator instead of a row of chips.

Auto-hide is a pure decision function (pill_autohide::State::visible, unit
tested: visible given hover/hold/battery/enabled) driving a layer-shell
top-margin spring animation on the bar's own window. A separate small
hot-zone/lip satellite window (pill_hotzone.rs) catches pointer entry while
tucked and shows the faint lip mark. Peeks are wired from a notification
arriving, a volume/brightness OSD event, and a workspace switch, each
holding the pill down for its own configured duration; the battery-low flag
keeps it down regardless. `breadbar --pill toggle|show` reaches a running
instance over the existing dev.breadway.Bar D-Bus surface (same connection
as --history), forwarded to the app via a plain Send channel since the pill
lives on the bar window, not the notification event loop.

Added screenshot views (pill-shown, pill-tucked, pill-hover,
pill-widgets-off, pill-widgets-on, pill-ws-none/current/all,
pill-low-battery), each forcing deterministic state before capture instead
of racing the real hover/timer machinery with no real pointer in an
isolated compositor.
Both views force pill state directly (mock widgets, a synthetic low
battery reading) since there's no Option<SampleKind>-style bypass for
the real Lua-widget daemon or the real /sys/class/power_supply poll the
way notifications/OSD have. On this dev machine both real sources are
still reachable from an isolated capture session and were racing the
forced state, clobbering it before the screenshot settled. Skip
starting each real source for its one matching view instead.

Also broadens the "force the pill down" forcing to every pill-* view
except pill-tucked itself, not just three of them: pill-widgets-off/on
and the pill-ws-* views need it too, or there's nothing to see.
The isolated capture session has no Hyprland IPC (same gap
bar::workspaces::dot_width_index's own doc comment already notes for
window counts), so all three workspace display modes previously
rendered identically: nothing. Mock a small occupied/empty/occupied set
so "none"/"current"/"all" actually differ in the capture, and skip
starting the real Hyprland workspace watcher for these views (its own
empty-result sync was racing the mock and wiping it, same shape as the
widgets/battery fixes).
App::autohide_cfg is copied once at init, and the hover controller and the
hot-zone/lip window are only created then when auto-hide is enabled. A live
theme reload that only changed [bar.autohide] (turning it on or off, or
retuning the hot zone and peek timings) was therefore applied to nothing.
needs_restart now compares autohide() too, so such a change re-execs breadbar
like the other init-only settings.
rebuild_from_theme calls apply_window_spec, which resets the layer-shell top
margin to the theme's shown value. With auto-hide on and the pill tucked,
that dropped the pill back into view while pill_shown stayed false, so it
stayed down until the next hover cycle, and the next show animated from a
stale pill_margin. Any in-flight margin animation also kept running and
fought the reset.

After apply_window_spec, cancel the pill animation and snap the margin to
the shown or tucked value the auto-hide state calls for. The shown/tucked
margin maths moves into a pill_margins helper shared with update_pill.
The low-battery flag dot and the unread dot had no valign, so they filled
their parent box's height and drew as tall capsules rather than the mockup's
6px circles (visible in the pill-low-battery capture). Centre them.

The workspaces/clock divider started visible and was only hidden by
rebuild_buttons, so a pill with no workspace sync yet (or no Hyprland IPC)
opened with a stray leading divider before the clock. Start it hidden; the
first rebuild_buttons shows it once the strip has content.
With display = "none" the strip is always empty, but the still-visible
empty module kept its slot container's 12px box spacing, which pushed the
clock off-centre inside the pill (pill-ws-none capture). Hide the whole
workspaces module in that mode, like the mockup's .ws:empty rule. Gated on
None so themes on the default All keep their layout unchanged.
Phase 1 review must-fix: the tucked pill hot-zone now narrows its real
input region to a 2-4px strip at the top edge (pill_hotzone::set_input_state),
clearing it entirely while the pill is shown, so the rest of its footprint
stops swallowing clicks meant for windows underneath. pill_autohide::State
gains an `open` term so the pill stays down for as long as the stage is
open, not just for a timed peek.

Phase 2/3, the stage (src/stage/): a full-screen Overlay-layer surface
opened from the pill (breadbar --stage toggle|show|hide, routed to the
focused monitor the same way OpenLauncher already does). Search-first
layout with a scrim/cone background, recent app tiles, a three-zone floor
(controls: volume/brightness sliders, output chips, now-playing transport,
wifi/bluetooth/DND chips; centre: big time + status sentence; notifications:
shared history with per-item dismiss and clear all), and a power row with
a real confirm-armed state machine (stage::pure::PowerConfirm, unit
tested). Typing switches to grouped results (open windows via `hyprctl
clients -j`, apps via bread_launcher, `>` commands, `=` calc), built by
stage::pure::build_results on top of bread_launcher's existing query/match/
calc primitives. Esc clears the query before closing (stage::pure::
esc_action); clicking the scrim closes.

Phase 4, surtitles and levels (spotlight only, gated by new theme options):
a new notification shows as a one-line caption under the pill
(src/surtitle.rs) instead of the standalone toast when
[notifications].style = "surtitle", suppressed by DND; volume/brightness
changes draw inside the pill (an opaque overlay masking the regular
content) instead of the OSD window when [osd].style = "pill". Both default
to today's behaviour for every other theme.

Also fixes a real bug found while wiring this up: opening the stage/
surtitle windows synchronously during init() raced screenshot::dispatch's
own connect_map registration (GTK could map the surface, and therefore
fire `map`, before anything was listening), hanging every stage-*/surtitle
screenshot capture. The actual open/show is now deferred to an idle
callback scheduled after dispatch() runs.
bread-capture isolates Wayland but not D-Bus, and zbus requests names with
AllowReplacement|ReplaceExisting by default. Every non-notification
screenshot view therefore took org.freedesktop.Notifications and
org.kde.StatusNotifierWatcher from the live bar, which never got them back
once the capture exited. Only the notification views (whose sample path
never connects to D-Bus) build the notification server now, and the tray
watcher is skipped entirely under --screenshot.
Blocking fixes from the BAR-30 review:

- The column overlay had can-target = false. In GTK that also makes every
  descendant unpickable (it is not CSS pointer-events), so every click on
  the search field, chips, sliders, tiles, results or power row fell
  through to the scrim and closed the stage. The column is targetable
  again; the overlay's click gesture now closes only when the press lands
  on the scrim or an empty layout container (checked with pick()).
- The key controller was a bubble-phase controller on the GtkEntry, which
  consumes Return itself (the capsule launcher hit the same bug), so Enter
  never launched anything, and Esc stopped working as soon as a click moved
  focus off the entry. It is now a capture-phase controller on the window:
  Esc always works, arrows/Enter only while the search field has focus.
- Power confirm: a second press of the armed action within 400ms of the
  previous press is ignored, so a double-click or a held key's autorepeat
  on a focused Restart/Power off button can no longer reboot or power off.
  The expiry timer carries the arm's generation so an old timer cannot
  cut a newer arm short. Buttons keep their label while armed and use the
  mockup's wording (Power off, not off).

Also:
- Result rows carry their target (window address, desktop id, command id)
  and activation uses it, instead of re-finding by display name (two
  windows with the same title focused the wrong one).
- Narrow stacking is now driven by the pinned output's width on every
  open (under 860px), with the centre zone moved first like the mockup.
- The column sits in a vertical scroller so a stacked floor taller than
  the output can still reach the power row.
- Recent-tile glyphs are centred 40px squares instead of full-width bars.
Emptying the hot zone's input region while the pill was shown left a dead
gap between the screen edge and the pill (which sits margin.top px down).
A pointer parked at the top edge revealed the pill, the region vanished
under it, the next motion sent a hover-leave, the pill re-tucked, the strip
came back under the pointer and revealed it again: a steady flicker while
moving along the top edge. The 3px strip now stays in both states, like the
mockup's always-live .hotzone. It never overlaps the pill, so it swallows
nothing more than the tucked state already did.
GtkOverlay does not measure overlay children by default, so the level row
was clipped to whatever width the clock alone needed: the percentage never
showed and the bar was cut short. The level overlay is measured now (it
contributes nothing while hidden). The pill's regular content is faded out
while the level shows, instead of being masked by an opaque patch that left
a visible seam against the pill's own translucent background (the matching
CSS change is in bread-theme).
BAR-30 asks for the stage to open from the pill (the mockup's #time
button); only the D-Bus command was wired. A click on the clock now toggles
this instance's own stage, on the auto-hiding pill themes only, so every
other theme's clock stays inert as before.
The mockup shows the act badge only on the selected row and hides the sub
label there (display:none both ways). The badge was hidden with opacity,
so it still reserved its width on every row. Visibility is toggled per row
in mark_selection instead.
If the compositor closes the stage surface (its output unplugged while it
is open), open stayed true: the pill's auto-hide open term then kept the
pill down forever and the next toggle only closed an invisible stage. The
stage now runs its close path on unmap; close() is a no-op once open is
false, so the normal close path does not recurse.
Spotlight's launcher mode is still "embedded", so breadbox redirects
Super+Space to bread.box.open_requested for breadbar's capsule. The
redesign no longer places the capsule in any slot, so the request was
silently dropped and breadbox never opened. An embedded-launcher theme
that places no launcher_entry now toggles the stage instead; overlay
launcher themes keep the existing route.
When a satellite monitor disconnects, drop_satellite hides its root
window and drops its Controller, shutting that bar's runtime down. Its
other layer surfaces (the spotlight pill hot zone, stage, surtitle and
panels) stayed alive with handlers still sending to the dead runtime.
relm4's input() panics on a shut-down runtime, and a panic inside a GTK
signal handler or glib timer cannot unwind, so the process aborted: an
orphaned hot zone firing PillHoverEnter after a dock replug killed
breadbar three times.

- App::shutdown now destroys the satellite's hot zone, stage, surtitle
  and panel windows.
- Sends from GTK callbacks and timers go through post(), which drops
  the message instead of panicking when the bar is gone; the stage's
  open/close notifications do the same.
Works through spotlight-stage.html element by element.

Pill: drawn inside a transparent shadow margin so its drop shadow renders,
with the input region trimmed back to the pill; hover shade; the tuck slides
the mockup's 54px on the real cubic-bezier spring (new motion module) while
the pill fades; the lip fades; 10px item gap, 2px workspace gap, 8px slot
gap; an empty workspace strip collapses. The in-pill level is a slot module
before the flag, hides everything but the level and flag while it shows,
glides its bar over .18s and prints the bare number. Levels, peeks and the
unread dot reach every monitor's pill, and only opening the stage clears
the dot. The pill moves to the Overlay layer while its stage is open, so it
sits above the scrim as in the mockup.

Surtitle: fades over .25s while sliding its last 6px on the spring, capped
at min(560px, 90vw), shown on the Overlay layer so it also appears over an
open stage. A critical notification stays until clicked or the stage opens.

Stage: fades in and out over .28s (input released at once), the column
slides 10px on the spring, and the light cone is drawn as the mockup's
clipped trapezoid of an elliptical radial gradient and fades in after
.05s. Search, tiles and results are 640px wide; the floor uses the
1fr 1.15fr 1fr grid with zones that keep their track width. Adds the art
tile (local MPRIS art when available), Artist · Player line, live Sound and
Light values, Wi-Fi chip on the real radio state, widget cards mirroring
the pill's widgets, the balanced status sentence with battery time left,
notification times, stacking (xN), hover-only dismiss and live updates,
the key-hint footer, and the mockup's command list (lock, reload bar,
switch theme, take screenshot, toggle do not disturb) kept out of the
shared launcher crate. Result rows use the app's display name and a short
kind, matching by substring as the mockup does.

Satellites: every bar instance shares one stage::Shared (history store,
do-not-disturb, which stage is open, every unread dot). A toggle while a
stage is open on any monitor closes it; clicking a pill's time closes any
other open stage first.

Captures: stage and surtitle views skip the stats, Wi-Fi and media pollers
and seed the mockup's own sample data (apps, windows, widgets,
notifications), and the surtitle and level views go through the real
arrival paths. New views: stage-empty, stage-note-hover, surtitle-critical.
Any new GTK callback sends through post() / input_sender, and the
satellite-owned windows are destroyed in App::shutdown.
Window switching: the stage ran `hyprctl dispatch focuswindow address:X`,
which Lua-config Hyprland turns into a Lua syntax error, so selecting an
open window never focused it. It now falls back to
`hyprctl eval hl.dispatch(hl.dsp.focus({window=...}))`, the same two-step
the workspace buttons already use.

Typing stall: debug timing on the live bar showed search itself costs
under 0.3ms and the cone repaint 0.01ms, but rebuilding the result rows
cost up to 504ms per keystroke: one letter matched 99 rows (7 windows,
92 of 129 apps), each created and torn down on every change. Results are
now capped at 8 rows per group and drawn into a pool of row widgets built
once, so a keystroke only sets text and visibility.

Debug logging under the `breadbar::stage` target (enable with
BREADBAR_LOG=breadbar::stage=debug) keeps the per-keystroke build/draw
timings, cone repaint time and each window activation step.
Settings: a "Settings" button at the end of the stage's power row opens
a chip row to turn pill auto-hide and widgets on or off and pick the
workspace view (none / current / all). Choices are saved to
~/.config/breadbar/spotlight.json; every bar instance watches that file,
so a change on one monitor applies on all of them, and a hand edit
works too. Unset fields fall back to the theme. The runtime auto-hide
switch is kept separate from `autohide_cfg.enabled`, which still means
"this is the pill theme" for the clock click and layout rules.

Widgets: tray-placement widgets (the control panel's cards on other
themes) now render whole as the stage's centre cards. A pill widget
whose module registered no tray card keeps its derived card.
Only the primary bar subscribes to breadd's widgets and polls theme
widgets, and satellites never received a copy, so widgets appeared only
on whichever monitor was focused when breadbar started. The primary now
forwards its finished widget list to each satellite whenever it changes,
and seeds a newly spawned satellite with the current list.
The stage's notification rows and the caption under the pill showed only
a notification's title, dropping its body: a screenshot notification
read "Screenshot" without its path, and chat notifications lost their
text. Rows now add the body on a second, dimmer line (one line, ellipsized),
the caption reads "title: body", and repeats stack only when the app,
title and body all match so two different messages sharing a title no
longer collapse into one "x3".
Merge remote-tracking branch 'origin/main' into feature/spotlight-pill
Some checks failed
check / check (push) Failing after 16s
1644b01827
chore: rerun spotlight checks against merged theme
All checks were successful
check / check (push) Successful in 5m17s
0fd9f057fa
Author
Owner

PR #37 now passes fresh check / check (push) on 0fd9f05 (5m17s). The merged bread-ecosystem #45 dependency is resolved through the existing PR-check scripts/dev-deps.sh workflow, which records bread-ecosystem d9ab179 in the generated dev-deps lock; Cargo.toml and the committed release-tag Cargo.lock remain unchanged. Local clippy, build, and all 120 tests pass against that same dependency commit. Ready for independent review: #37

PR #37 now passes fresh `check / check (push)` on 0fd9f05 (5m17s). The merged bread-ecosystem #45 dependency is resolved through the existing PR-check `scripts/dev-deps.sh` workflow, which records bread-ecosystem d9ab179 in the generated dev-deps lock; Cargo.toml and the committed release-tag Cargo.lock remain unchanged. Local clippy, build, and all 120 tests pass against that same dependency commit. Ready for independent review: https://git.breadway.dev/Breadway/breadbar/pulls/37
Author
Owner

Independent review of Breadway/breadbar PR #37 at head 0fd9f057fa.

Acceptance criteria and diff mapping:

  1. BAR-27 pill behavior: workspace modes and rendering, auto-hide state inputs and geometry, hot-zone interaction, and saved preferences are covered by src/bar/workspaces.rs, src/pill_autohide.rs:14-82, src/pill_hotzone.rs:33-149, and src/spotlight_prefs.rs:1-98, with wiring in src/main.rs.
  2. BAR-30 stage, controls, grouped search, notifications, surtitles, and in-pill levels: stage lifecycle and controls are in src/stage/mod.rs:308-358 and 546-687; notification history, timestamps, dismiss, and clear-all are in 767-858; grouping/filtering and command actions are in src/stage/pure.rs:130-363 and 572-620; selection and activation are in src/stage/mod.rs:884-968 and 1154-1344; notification/level routing and cross-monitor stage actions are in src/main.rs:3312-3450; critical and normal caption timing is in src/surtitle.rs:60-123.
  3. BAR-31 listed parity gaps: stage layout and clipped light cone are implemented in src/stage/mod.rs:1665-1995 and 1576-1628; shared history/DND/stage-open state is in src/stage/mod.rs:59-132; the mockup capture scenarios are wired in src/screenshot.rs:97-120 and 515-522. The remaining documented deviations are the palette-token hue constraint, cards reusing the widget node tree, and remote cover art not being fetched. The code still renders a local artwork tile when media state supplies a file.
  4. BAR-26 overall scope is represented by the pill and stage paths above. The pure tests exercise result contents/grouping, query matching, selection, and power confirmation outcomes. They assert user-visible results and state changes rather than mirroring widget structure; the headless visual scenarios are documented in the issue comments.

Review: PASS. The full change is in scope, the result rows activate by stable targets, notification dismissals remove stacked entries together, and cross-monitor stage operations use shared state. No blocking correctness or quality issue found. Current combined CI is success on the PR head (check / check (push)), and Forgejo reports the PR mergeable. The release dependency is already merged and the PR check resolves it via the dev-deps workflow. Ready for merge.

Independent review of Breadway/breadbar PR #37 at head 0fd9f057faecf0f74eb20e50d14675cabf642dd3. Acceptance criteria and diff mapping: 1. BAR-27 pill behavior: workspace modes and rendering, auto-hide state inputs and geometry, hot-zone interaction, and saved preferences are covered by `src/bar/workspaces.rs`, `src/pill_autohide.rs:14-82`, `src/pill_hotzone.rs:33-149`, and `src/spotlight_prefs.rs:1-98`, with wiring in `src/main.rs`. 2. BAR-30 stage, controls, grouped search, notifications, surtitles, and in-pill levels: stage lifecycle and controls are in `src/stage/mod.rs:308-358` and `546-687`; notification history, timestamps, dismiss, and clear-all are in `767-858`; grouping/filtering and command actions are in `src/stage/pure.rs:130-363` and `572-620`; selection and activation are in `src/stage/mod.rs:884-968` and `1154-1344`; notification/level routing and cross-monitor stage actions are in `src/main.rs:3312-3450`; critical and normal caption timing is in `src/surtitle.rs:60-123`. 3. BAR-31 listed parity gaps: stage layout and clipped light cone are implemented in `src/stage/mod.rs:1665-1995` and `1576-1628`; shared history/DND/stage-open state is in `src/stage/mod.rs:59-132`; the mockup capture scenarios are wired in `src/screenshot.rs:97-120` and `515-522`. The remaining documented deviations are the palette-token hue constraint, cards reusing the widget node tree, and remote cover art not being fetched. The code still renders a local artwork tile when media state supplies a file. 4. BAR-26 overall scope is represented by the pill and stage paths above. The pure tests exercise result contents/grouping, query matching, selection, and power confirmation outcomes. They assert user-visible results and state changes rather than mirroring widget structure; the headless visual scenarios are documented in the issue comments. Review: PASS. The full change is in scope, the result rows activate by stable targets, notification dismissals remove stacked entries together, and cross-monitor stage operations use shared state. No blocking correctness or quality issue found. Current combined CI is success on the PR head (`check / check (push)`), and Forgejo reports the PR mergeable. The release dependency is already merged and the PR check resolves it via the dev-deps workflow. Ready for merge.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Breadway/breadbar!37
No description provided.