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.
524 lines
18 KiB
Rust
524 lines
18 KiB
Rust
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::time::Instant;
|
|
|
|
use futures_lite::StreamExt;
|
|
use gtk4::glib::ControlFlow;
|
|
use gtk4::prelude::*;
|
|
use hyprland::{
|
|
data::{Monitors, Workspaces},
|
|
event_listener::{Event, EventStream},
|
|
prelude::*,
|
|
shared::WorkspaceId,
|
|
};
|
|
use relm4::ComponentSender;
|
|
|
|
use crate::AppInput;
|
|
|
|
/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config
|
|
/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is
|
|
/// 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>) {
|
|
let workspaces = Workspaces::get_async()
|
|
.await
|
|
.map(|w| w.to_vec())
|
|
.unwrap_or_default();
|
|
let mut actives = std::collections::HashMap::new();
|
|
if let Ok(mons) = Monitors::get_async().await {
|
|
for m in mons {
|
|
if !m.disabled {
|
|
actives.insert(m.name, m.active_workspace.id);
|
|
}
|
|
}
|
|
}
|
|
sender.input(AppInput::WorkspaceSync {
|
|
workspaces,
|
|
actives,
|
|
});
|
|
}
|
|
|
|
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|
relm4::spawn(async move {
|
|
sync_state(&sender).await;
|
|
|
|
// Hyprland's IPC event socket can drop out from under us — a
|
|
// Hyprland restart/reload, or just a transient hiccup — at which
|
|
// point `stream.next()` yields `None` (or an `Err`, also excluded
|
|
// by this `while let Some(Ok(..))` pattern). That used to just fall
|
|
// through and end this whole task permanently, freezing every
|
|
// workspace button for the rest of the bar's life. Reconnect with a
|
|
// capped exponential backoff instead of giving up.
|
|
let mut backoff = std::time::Duration::from_millis(500);
|
|
const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
|
|
|
|
loop {
|
|
let mut stream = EventStream::new();
|
|
while let Some(Ok(event)) = stream.next().await {
|
|
backoff = std::time::Duration::from_millis(500);
|
|
match event {
|
|
Event::WorkspaceChanged(_)
|
|
| Event::WorkspaceAdded(_)
|
|
| Event::WorkspaceDeleted(_) => {
|
|
sync_state(&sender).await;
|
|
}
|
|
Event::MonitorAdded(data) => {
|
|
sender.input(AppInput::MonitorAdded(data.name));
|
|
sync_state(&sender).await;
|
|
}
|
|
Event::MonitorRemoved(name) => {
|
|
sender.input(AppInput::MonitorRemoved(name));
|
|
sync_state(&sender).await;
|
|
}
|
|
Event::ActiveWindowChanged(_) => {
|
|
sender.input(AppInput::DismissPanels);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
eprintln!(
|
|
"breadbar: Hyprland event stream ended (restart/reload/IPC hiccup); \
|
|
reconnecting in {:?}",
|
|
backoff
|
|
);
|
|
tokio::time::sleep(backoff).await;
|
|
backoff = (backoff * 2).min(MAX_BACKOFF);
|
|
sync_state(&sender).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn make_button(
|
|
id: WorkspaceId,
|
|
name: &str,
|
|
active: WorkspaceId,
|
|
occupied: bool,
|
|
) -> gtk4::Button {
|
|
let btn = gtk4::Button::with_label(name);
|
|
btn.add_css_class("workspace-btn");
|
|
if occupied {
|
|
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);
|
|
// `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 |_| {
|
|
relm4::spawn(async move {
|
|
switch_workspace(id).await;
|
|
});
|
|
});
|
|
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,
|
|
}
|
|
|
|
/// 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,
|
|
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: >k4::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<>k4::Button>, to: >k4::Button) {
|
|
self.cancel();
|
|
let Some(from_g) = self.from_geom(from) 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)
|
|
};
|
|
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);
|
|
}
|
|
|
|
fn from_geom(&self, from: Option<>k4::Button>) -> Option<Geom> {
|
|
let st = self.inner.borrow();
|
|
// A leftover mid-stretch can be as wide as the whole row — never
|
|
// treat that as the start of the next animation.
|
|
if self.pill.is_visible() && st.geom.w > 0.5 && st.geom.w <= MAX_CHIP_W {
|
|
return Some(st.geom);
|
|
}
|
|
drop(st);
|
|
from.and_then(|b| button_geom(b, &self.host).map(inset_pill))
|
|
}
|
|
}
|
|
|
|
/// 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: >k4::Button, host: >k4::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: >k4::Button, host: >k4::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: >k4::Fixed, pill: >k4::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: >k4::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)
|
|
}
|