Daylight bar chrome + embedded launcher, v0.7.5 pins #5

Merged
Breadway merged 41 commits from feature/theme-spotlight into main 2026-08-31 18:14:19 +08:00
6 changed files with 512 additions and 10 deletions
Showing only changes of commit 1fb10a10cd - Show all commits

24
Cargo.lock generated
View file

@ -150,13 +150,22 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bread-launcher"
version = "0.7.4"
dependencies = [
"bread-utils 0.7.4",
"gtk4",
"serde_json",
]
[[package]]
name = "bread-screenshots"
version = "0.7.4"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#a9754d90ed32efcc26765abd01c9f441bfb01b1e"
dependencies = [
"anyhow",
"bread-utils",
"bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)",
"tracing",
]
@ -196,6 +205,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "bread-utils"
version = "0.7.4"
dependencies = [
"bread-shared 0.7.0",
"dirs 5.0.1",
"serde",
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.7.4"
@ -212,10 +231,11 @@ name = "breadbar"
version = "0.3.3"
dependencies = [
"anyhow",
"bread-launcher",
"bread-screenshots",
"bread-shared 0.8.0",
"bread-theme",
"bread-utils",
"bread-utils 0.7.4 (git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4)",
"clap",
"futures-lite",
"gtk4",

View file

@ -21,6 +21,11 @@ bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag =
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
# Headless app-launcher core + GTK4 results-list widget (plan §3/§7): theme
# 04/spotlight embeds the SAME `ResultsList` breadbox's overlay wraps, via
# `bar::launcher_results` — one implementation, two hosts. `gtk` feature for
# the results widget itself.
bread-launcher = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
relm4 = { version = "0.11", features = ["macros"] }
@ -46,3 +51,4 @@ strip = "symbols"
# (feature/shell-theme-manifest) before it is tagged as a release.
[patch."https://git.breadway.dev/Breadway/bread-ecosystem"]
bread-theme = { path = "../bread-ecosystem/bread-theme" }
bread-launcher = { path = "../bread-ecosystem/bread-launcher" }

View file

@ -155,6 +155,45 @@ pub fn make_button(
btn
}
/// `style = "dots"` (theme 04/spotlight): a label-less pill whose WIDTH
/// encodes `windows` (0/1/2/3-or-more open) via `dot_widths` — see
/// `bread_theme::shell::WorkspacesModule::dot_widths`. 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.
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);
let idx = (windows.max(0) as usize).min(3);
btn.set_size_request(dot_widths[idx], 6);
btn.connect_clicked(move |_| {
relm4::spawn(async move {
switch_workspace(id).await;
});
});
btn
}
#[derive(Clone, Copy)]
struct Geom {
x: f64,

View file

@ -13,7 +13,8 @@ mod surface;
mod theme;
mod widgets;
use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, WorkspaceStyle};
use bread_launcher::gtk::ResultsList;
use bread_theme::shell::{ClockStyle, Exclusive, Keyboard, Width, WorkspaceStyle};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use hyprland::data::Workspace;
@ -21,8 +22,21 @@ use hyprland::shared::WorkspaceId;
use relm4::prelude::*;
use relm4::{Component, ComponentController, Controller};
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
/// The launched-app event's publisher id and event name — deliberately
/// matching breadbox's OWN constants (`breadbox/src/main.rs`: `APP_ID =
/// "box"`, `LAUNCHED_EVENT = "bread.box.launched"`), NOT breadbar's own
/// `widgets::client::APP_ID` ("bar"). Theme 04/spotlight's capsule IS the
/// launcher wearing a different shell (plan §7), sharing breadbox's cache
/// and history via `bread_launcher::LAUNCHER_APP` — it publishes under that
/// same launcher identity too, so anything downstream listening for "an app
/// was launched via the launcher" sees one event stream regardless of which
/// surface launched it.
const LAUNCHER_APP_ID: &str = "box";
const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched";
pub struct BarInit {
pub screenshot: Option<screenshot::ScreenshotRequest>,
pub monitor: Option<String>,
@ -51,6 +65,15 @@ pub struct App {
// recompile; only one of the two ever lands in a `[bar.slots]` module
// registration (see the "Assemble" section).
clock_plain_lbl: gtk4::Label,
// `modules.clock.placeholder_clock` (spotlight, theme 04): when set,
// `AppInput::ClockTick` writes the time into this entry's placeholder
// text instead of (or alongside) any clock label — see that handler.
launcher_entry: gtk4::Entry,
// Whether the capsule's drawer is currently expanded — read by
// `ClockTick` so a live search in progress never has its placeholder
// text stomped (it wouldn't be visible anyway once there's real text,
// but matches the demo's own `if (!open) q.placeholder = t;` guard).
launcher_open: Rc<Cell<bool>>,
// ── Stats bar ─────────────────────────────────────────────────────────
// Island chrome matches the Liquid Motion demo: volume / wifi / battery
@ -165,8 +188,27 @@ impl SimpleComponent for App {
set_title: Some("breadbar"),
set_default_height: bar_height,
#[name = "center_box"]
gtk::CenterBox {
// Root is a vbox (bar row + drawer), not a bare CenterBox, per
// plan §2/§11: `drawer` is the only structural thing Capsule/
// theme-04 adds over Island/Edge, and it's a slot below the bar
// row, not a separate layout code path. `drawer_box` starts
// empty and zero-height for every theme that never names a
// module in `[bar.slots].drawer` (liquid-motion, glass-
// workbench) — see main.rs's "Assemble" section and
// `theme.rs`'s `window.breadbar > box > centerbox` selector
// update for why this is a no-op for both.
#[name = "root_vbox"]
gtk::Box {
set_orientation: gtk4::Orientation::Vertical,
#[name = "center_box"]
gtk::CenterBox {
},
#[name = "drawer_box"]
gtk::Box {
set_orientation: gtk4::Orientation::Vertical,
},
}
}
}
@ -211,6 +253,17 @@ impl SimpleComponent for App {
root.set_margin(Edge::Top, window_spec.margin.top);
root.set_margin(Edge::Left, window_spec.margin.left);
root.set_margin(Edge::Right, window_spec.margin.right);
// `Width::Fill` (Island/Edge): unset, exactly as before this
// change — the surface stretches to the anchored left/right edges
// on its own, with no explicit width request needed. `Width::Px`
// (the capsule, anchored top-only): gtk4-layer-shell has nothing to
// stretch it TO, so without this it would size to its natural
// content width instead of the theme's requested 480px — this was
// a schema key declared but never consumed before theme 04 needed
// a real value out of it.
if let Width::Px(px) = window_spec.width {
root.set_default_width(px);
}
// "auto" reserves height + top margin so tiled clients sit below the
// gap — see WindowSpec::exclusive's doc comment (bread-theme).
let exclusive_zone = match window_spec.exclusive {
@ -418,6 +471,66 @@ impl SimpleComponent for App {
clock_plain_box.append(&date_lbl);
clock_plain_box.append(&clock_plain_lbl);
// ── Launcher entry + results (theme 04/spotlight, plan §7) ───────
// Built unconditionally, exactly like `clock_plain_box` above —
// placed in a slot only by a theme that names "launcher_entry"/
// "launcher_results" (spotlight today; see "Assemble" below).
// `bread_launcher::LAUNCHER_APP` ("breadbox") is the launcher's
// shared identity: this reads/writes the SAME icon cache and
// launch history breadbox's own overlay window does, so the
// capsule and breadbox rank a user's apps identically instead of
// forking into two histories just because a different theme
// happens to be active (see that constant's own doc comment).
let launcher_cfg = theme::shell_theme().launcher().clone();
let launcher_manifest: std::collections::HashMap<String, std::path::PathBuf> =
std::fs::read_to_string(bread_launcher::IconCache::manifest_path(
bread_launcher::LAUNCHER_APP,
))
.ok()
.and_then(|s| serde_json::from_str::<std::collections::HashMap<String, String>>(&s).ok())
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, std::path::PathBuf::from(v)))
.collect();
let launcher_history = Rc::new(RefCell::new(bread_launcher::LaunchHistory::load(
bread_launcher::LAUNCHER_APP,
)));
// No per-workspace priority context here — that's breadbox's own
// `Config`/`Context` format (breadbox-shared), not launcher
// substance, and breadbar has no equivalent concept. The capsule
// sorts by launch history then alphabetically, same fallback
// ordering breadbox itself uses once a workspace has no configured
// priority list at all.
let launcher_entries = bread_launcher::load_sorted_entries(
&launcher_manifest,
&[],
&launcher_history.borrow(),
);
let launcher_results = ResultsList::new(
&launcher_entries,
launcher_cfg.icon_px,
Rc::clone(&launcher_history),
);
launcher_results.scroller.add_css_class("bread-drawer-scroller");
let launcher_entry = gtk4::Entry::new();
launcher_entry.add_css_class("launcher-entry");
launcher_entry.set_has_frame(false);
launcher_entry.set_hexpand(true);
gtk4::prelude::EntryExt::set_alignment(&launcher_entry, 0.5);
// `modules.clock.placeholder_clock` (spotlight): the entry's idle
// placeholder IS the clock — no separate clock module renders at
// all under `style = "none"`. Any other theme gets a plain "Search"
// placeholder (never shown today: no other builtin slots
// "launcher_entry" anywhere), so this still degrades sanely if a
// future/user theme places it without also setting the flag.
let placeholder_clock = theme::shell_theme().modules().clock.placeholder_clock;
launcher_entry.set_placeholder_text(Some(if placeholder_clock {
bar::clock::time()
} else {
"Search".to_string()
}.as_str()));
// Center area: [media_widget · widgets · clock · widgets]
let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
center_area.add_css_class("center-area");
@ -789,6 +902,11 @@ impl SimpleComponent for App {
// glass-workbench (which omits "media" entirely).
bar_modules.register("cpu", &bar_cpu_pair);
bar_modules.register("ram", &bar_ram_pair);
// Theme 04/spotlight (plan §7): unconditional, same reasoning —
// liquid-motion/glass-workbench never name either in a slot, so
// both stay built but unparented for them.
bar_modules.register("launcher_entry", &launcher_entry);
bar_modules.register("launcher_results", &launcher_results.scroller);
// `tray` never appears in a bar slot — it stays inside the
// control-panel popover (built above, next to the SNI tray) — but
@ -820,6 +938,143 @@ impl SimpleComponent for App {
widgets.center_box.set_center_widget(Some(&center_area));
widgets.center_box.set_end_widget(Some(&stats_box));
// `drawer` slot (plan §2/§7/§11 Phase 6): the only slot list that
// isn't left/centre/right of the CenterBox — appended into the vbox
// row below it instead. Empty for every theme but spotlight, so
// `drawer_box` stays a childless, zero-height box for them (see the
// `window.breadbar > box > centerbox` selector note in theme.rs for
// why the vbox wrapper itself is safe for those two themes too).
widgets.drawer_box.add_css_class("bread-drawer");
bar_modules.for_each_in_slot(
&bar_slots.drawer,
|_, widget| widgets.drawer_box.append(widget),
|key| {
widgets
.drawer_box
.append(&bar::slots::widget_slot_container(&mut widget_containers, key))
},
);
// Collapsed by default; `Overflow::Hidden` clips the results list
// while its allocated height is below its natural content height,
// same as the demo's `.results { max-height: 0; overflow: hidden }`.
widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden);
widgets.drawer_box.set_size_request(-1, 0);
// ── Capsule expand/collapse + search wiring (theme 04/spotlight) ──
// Effectively a no-op under every other theme: `launcher_entry`
// never receives focus if it's never in a slot, so `open_fn` is
// simply never invoked. `results.set_query`/select_next`/`select_prev`
// and launching all come straight from `bread-launcher`; only the
// capsule shell (drawer height, entry placeholder/alignment,
// keyboard focus) is this file's own.
let anim: Rc<RefCell<Option<gtk4::TickCallbackId>>> = Rc::new(RefCell::new(None));
let launcher_open: Rc<Cell<bool>> = Rc::new(Cell::new(false));
let open_fn: Rc<dyn Fn()> = Rc::new({
let drawer_box = widgets.drawer_box.clone();
let anim = Rc::clone(&anim);
let launcher_open = Rc::clone(&launcher_open);
let entry = launcher_entry.clone();
move || {
if !launcher_open.get() {
launcher_open.set(true);
entry.add_css_class("searching");
gtk4::prelude::EntryExt::set_alignment(&entry, 0.0);
}
let target = drawer_target_height(&drawer_box);
let current = drawer_box.size_request().1;
animate_drawer_height(&drawer_box, &anim, current, target);
}
});
let close_fn: Rc<dyn Fn()> = Rc::new({
let drawer_box = widgets.drawer_box.clone();
let anim = Rc::clone(&anim);
let launcher_open = Rc::clone(&launcher_open);
let entry = launcher_entry.clone();
let root_for_focus = root.clone();
move || {
if !launcher_open.get() {
return;
}
launcher_open.set(false);
entry.remove_css_class("searching");
gtk4::prelude::EntryExt::set_alignment(&entry, 0.5);
entry.set_text("");
if placeholder_clock {
entry.set_placeholder_text(Some(&bar::clock::time()));
}
let current = drawer_box.size_request().1;
animate_drawer_height(&drawer_box, &anim, current, 0);
// `keyboard = "on_demand"` (plan §7) ties the layer-shell
// surface's keyboard grab to GTK's own focus-widget state —
// releasing focus here is what hands the keyboard back.
gtk4::prelude::GtkWindowExt::set_focus(&root_for_focus, None::<&gtk4::Widget>);
}
});
{
let results = launcher_results.clone();
let open_fn = Rc::clone(&open_fn);
launcher_entry.connect_changed(move |entry| {
results.set_query(entry.text().as_str());
open_fn();
});
}
{
let open_fn = Rc::clone(&open_fn);
let focus_ctrl = gtk4::EventControllerFocus::new();
focus_ctrl.connect_enter(move |_| open_fn());
launcher_entry.add_controller(focus_ctrl);
}
{
let results = launcher_results.clone();
let close_fn = Rc::clone(&close_fn);
let key_ctrl = gtk4::EventControllerKey::new();
key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key;
match key {
Key::Escape => {
close_fn();
gtk4::glib::Propagation::Stop
}
Key::Down => {
results.select_next();
gtk4::glib::Propagation::Stop
}
Key::Up => {
results.select_prev();
gtk4::glib::Propagation::Stop
}
Key::Return | Key::KP_Enter => {
if let Some(entry) = results.selected_entry() {
results.record_launch(&entry);
bread_launcher::do_launch(
&entry,
LAUNCHER_APP_ID,
LAUNCHER_LAUNCHED_EVENT,
);
}
close_fn();
gtk4::glib::Propagation::Stop
}
_ => gtk4::glib::Propagation::Proceed,
}
});
launcher_entry.add_controller(key_ctrl);
}
// Row click launches too, same as breadbox's own overlay.
{
let results = launcher_results.clone();
let close_fn = Rc::clone(&close_fn);
launcher_results.list.connect_row_activated(move |_, row| {
if let Some(entry) = bread_launcher::gtk::row_entry(row) {
results.record_launch(&entry);
bread_launcher::do_launch(&entry, LAUNCHER_APP_ID, LAUNCHER_LAUNCHED_EVENT);
}
close_fn();
});
}
// Captured before these move into `model` (or are otherwise dropped
// as bare locals, never stored on `App` at all) — needed by the
// screenshot dispatch just before this function returns.
@ -830,6 +1085,12 @@ impl SimpleComponent for App {
let media_panel_for_screenshot = panels.media.clone();
let media_widget_for_screenshot = media_widget.clone();
let media_track_lbl_for_screenshot = media_track_lbl.clone();
// Theme 04/spotlight's capsule (plan §6b): `launcher_entry` moves
// into `model` below, `drawer_box` lives only in `widgets` — both
// need a clone out here for the same reason every other
// `_for_screenshot` handle above does.
let launcher_entry_for_screenshot = launcher_entry.clone();
let drawer_box_for_screenshot = widgets.drawer_box.clone();
// Never launch sibling App windows from inside this init — RelmApp
// is still in GApplication activate, and a same-type launch here
@ -855,6 +1116,8 @@ impl SimpleComponent for App {
clock_digits,
date_lbl,
clock_plain_lbl,
launcher_entry,
launcher_open,
system_stats_box,
system_sep,
cpu_pair,
@ -963,6 +1226,8 @@ impl SimpleComponent for App {
media_track_lbl: media_track_lbl_for_screenshot,
notification_window,
osd_window,
launcher_entry: launcher_entry_for_screenshot,
drawer_box: drawer_box_for_screenshot,
},
);
}
@ -1050,6 +1315,15 @@ impl SimpleComponent for App {
flip_clock_digits(&self.clock_digits, &bar::clock::time());
}
}
// `modules.clock.placeholder_clock` (spotlight): the
// capsule's entry IS the clock until focused — matches the
// demo's own `if (!open) q.placeholder = t;` guard so a
// live search in progress never has its placeholder text
// (invisibly, since real text covers it) stomped mid-type.
if clock_module.placeholder_clock && !self.launcher_open.get() {
self.launcher_entry
.set_placeholder_text(Some(&bar::clock::time()));
}
}
AppInput::StatsUpdate(stats) => {
let cpu = match stats.cpu_temp {
@ -1429,7 +1703,17 @@ impl App {
}
}
}
let btn = bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0);
let btn = match ws_style {
WorkspaceStyle::Dots => bar::workspaces::make_dot_button(
ws.id,
self.active_ws,
ws.windows as i32,
modules.workspaces.dot_widths,
),
WorkspaceStyle::Trail | WorkspaceStyle::Pill => {
bar::workspaces::make_button(ws.id, &ws.name, self.active_ws, ws.windows > 0)
}
};
if !prev.contains(&ws.id) {
play_once(&btn, "ws-in", 360);
}
@ -1925,6 +2209,40 @@ fn reveal_media(widget: &gtk4::Box, show: bool) {
widget.set_visible(show);
}
/// Drives `drawer_box`'s height from `from` to `to` over 360ms via
/// `bread_theme::anim::spring_to` (plan §7: GTK4 has no CSS height
/// transition on a widget, so the capsule's `.results { max-height: 0 →
/// 420px }` becomes a `set_size_request` interpolation on the frame clock
/// instead). Cancels any run already in flight first — reopening mid-close
/// (or vice versa) must restart from the CURRENT height, not fight a
/// leftover callback still walking toward the old target.
fn animate_drawer_height(
drawer_box: &gtk4::Box,
anim: &Rc<std::cell::RefCell<Option<gtk4::TickCallbackId>>>,
from: i32,
to: i32,
) {
if let Some(id) = anim.borrow_mut().take() {
id.remove();
}
let target = drawer_box.clone();
let id = bread_theme::anim::spring_to(drawer_box, from, to, 360.0, move |h| {
target.set_size_request(-1, h);
});
*anim.borrow_mut() = Some(id);
}
/// The drawer's natural content height right now, capped at the demo's own
/// 420px (`04-spotlight.html`: `.searching .results { max-height: 420px }`)
/// — `ResultsList`'s scroller already self-caps at 480px
/// (`max_content_height`), shared with breadbox, so this is a tighter,
/// spotlight-specific ceiling on top of that shared one, not a replacement
/// for it.
fn drawer_target_height(drawer_box: &gtk4::Box) -> i32 {
let (_, natural, _, _) = drawer_box.measure(gtk4::Orientation::Vertical, -1);
natural.min(420)
}
fn popover_tab(label: &str) -> gtk4::ToggleButton {
let btn = gtk4::ToggleButton::with_label(label);
btn.add_css_class("popover-tab");

View file

@ -45,6 +45,14 @@ const KNOWN_VIEWS: &[&str] = &[
"osd-volume",
"osd-brightness",
"wifi-add-dialog",
// Theme 04/spotlight's capsule (plan §6b) — see `dispatch`'s two new
// match arms. "capsule-collapsed" captures the same region "bar"
// always has (bar_capture_height already reflects the active theme's
// own window height/margin); it exists as its own name purely so a
// verification script doesn't have to already know "bar" means "the
// capsule, collapsed" under spotlight specifically.
"capsule-collapsed",
"capsule-expanded",
];
#[derive(Parser)]
@ -118,6 +126,15 @@ pub struct Handles {
pub notification_window: Option<gtk4::Window>,
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
pub osd_window: Option<gtk4::Window>,
/// Theme 04/spotlight's capsule centre module — see the
/// "capsule-expanded" view, which focuses it and types a query to
/// drive the drawer open before capturing.
pub launcher_entry: gtk4::Entry,
/// The `drawer` slot's own container — read back after the open
/// animation settles so "capsule-expanded"'s capture height matches
/// however tall the results actually grew, rather than a guessed
/// constant.
pub drawer_box: gtk4::Box,
}
/// Capture height for the `bar` view: layer-shell top margin + widget
@ -177,6 +194,48 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
};
capture_standalone_window(window, output, width, height);
}
"capsule-collapsed" => {
// Identical to "bar" — see KNOWN_VIEWS's doc comment on why
// this has its own name anyway.
root.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, bar_height, &output));
});
});
}
"capsule-expanded" => {
// Focus + a real query, the same way a person opens the
// capsule (`connect_changed`/`EventControllerFocus::enter` in
// main.rs's capsule wiring do the rest: `open_fn` runs,
// `results.set_query` filters, and `animate_drawer_height`
// grows `drawer_box`). Capture height is bar height + however
// tall the drawer actually settled, not a hardcoded guess —
// this stays correct even if `dot_widths`/font/entry-count
// numbers change later.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("f");
let output = output.clone();
let drawer_box = drawer_box.clone();
// Past the 360ms spring_to run plus a normal capture
// settle, so the drawer's size_request (and the actual
// on-screen layer-shell surface it grows) has reached
// its final height before grabbing pixels.
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"wifi-add-dialog" => {
let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| {

View file

@ -68,6 +68,13 @@ fn load_css() -> String {
// sites below (see the Phase 0 constant inventory).
let spring = tokens.spring();
let spring_settle = tokens.spring_settle();
let bg_alpha = tokens.bg_alpha();
// Palette token NAME (never hex — see every builtin theme.toml's own
// comment on this), used below by the dots/launcher-entry/drawer rules.
// liquid-motion/glass-workbench never render those (see the match arms
// and unconditional-but-unused block below), so this being "accent" vs
// "green" vs "pink" per theme has no visible effect on them.
let accent_from = tokens.accent_from();
// `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid-
// motion's floating island) draws a border on all four edges; "bottom"
@ -110,7 +117,7 @@ fn load_css() -> String {
.workspace-btn.active:hover {{ background: transparent; }}\
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
),
_ => {
bread_theme::shell::WorkspaceStyle::Pill => {
let accent = theme.tokens().accent_from();
format!(
".workspace-btn {{ background: transparent; opacity: 1; color: alpha(@on-bg, 0.4);\
@ -127,6 +134,30 @@ fn load_css() -> String {
.workspace-btn.ws-in {{ animation: row-in 0.32s {spring_settle} both; }}",
)
}
// "dots" (theme 04/spotlight): a label-less pill whose WIDTH comes
// from `modules.workspaces.dot_widths` and is set directly via
// `Widget::set_size_request` in `bar::workspaces::make_dot_button`
// — GTK CSS has no per-instance variable width, so unlike the demo's
// `.dots button[data-n="N"]` rules this class only supplies colour/
// opacity/radius, never a width. `04-spotlight.html`'s own base
// rule (`background: #5a4a54`) is a *dim, desaturated* grey, not the
// bar's ink colour — approximated here as a low-alpha `@on-bg` fill
// so it still tracks pywal instead of hardcoding a hex that would
// clash with a light palette.
bread_theme::shell::WorkspaceStyle::Dots => {
let accent = theme.tokens().accent_from();
format!(
".workspace-dot {{ background-color: alpha(@on-bg, 0.35); color: transparent;\
border-radius: {radius_pill}; border: none; outline: none; box-shadow: none;\
min-height: 6px; margin: 0; padding: 0;\
transition: background-color 0.25s {spring_settle},\
opacity 0.25s {spring_settle}; }}\
.workspace-dot:hover {{ background-color: alpha(@on-bg, 0.55); }}\
.workspace-dot:not(.occupied):not(.active) {{ opacity: 0.35; }}\
.workspace-dot.active {{ background-color: @{accent}; opacity: 1; }}\
.workspace-dot.active:hover {{ background-color: @{accent}; }}",
)
}
};
format!(
@ -138,9 +169,15 @@ fn load_css() -> String {
@keyframes row-in {{ from {{ opacity: 0; margin-top: 8px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes digit-flip {{ from {{ opacity: 0; margin-top: 7px; }} to {{ opacity: 1; margin-top: 0; }} }}\
@keyframes caret-draw {{ from {{ margin-right: 200px; opacity: 0.2; }} to {{ margin-right: 4px; opacity: 1; }} }}\
window.breadbar {{ background-color: alpha(@bg, 0.72); color: @on-bg;\
window.breadbar {{ background-color: alpha(@bg, {bg_alpha}); color: @on-bg;\
border-radius: {radius_bar}; {window_border} }}\
window.breadbar > centerbox {{ padding: {centerbox_padding}; }}\
/* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\
row + drawer, plan §2) as of the `drawer` slot wiring every\
theme's centerbox is now one level deeper than before, this\
selector just follows it there. Since `padding` doesn't depend\
on nesting depth, liquid-motion/glass-workbench render byte-\
identical CSS either way. */\
window.breadbar > box > centerbox {{ padding: {centerbox_padding}; }}\
window.breadbar button {{ min-height: 0; min-width: 0; }}\
{workspace_css}\
.clock-box {{ padding: 0 4px; }}\
@ -363,7 +400,28 @@ fn load_css() -> String {
.bread-padding-none {{ padding: 0; }}\
.bread-padding-xs {{ padding: 4px; }}\
.bread-padding-sm {{ padding: 8px; }}\
.bread-padding-md {{ padding: 12px; }}",
.bread-padding-md {{ padding: 12px; }}\
/* Theme 04/spotlight's embedded launcher (plan §7). Unconditional,\
like `.clock-plain-time` above: `launcher_entry`/`launcher_results`\
are built regardless of the active theme (see main.rs's \"Assemble\"\
section), just never placed in a slot outside spotlight, so these\
rules render nothing on liquid-motion/glass-workbench. */\
.launcher-entry {{ background: transparent; color: @on-bg; border: none;\
outline: none; box-shadow: none; caret-color: @{accent_from};\
font-size: 13px; font-weight: 500; letter-spacing: 0.06em;\
padding: 0; margin: 0; min-height: 0; }}\
.launcher-entry.searching {{ font-size: 15px; letter-spacing: 0; }}\
.bread-drawer {{ min-height: 0; }}\
.bread-drawer.open {{ border-top: 1px solid alpha(@on-bg, 0.08);\
margin-top: 6px; padding-top: 2px; }}\
.bread-drawer listbox {{ background: transparent; padding: 2px 0; }}\
.bread-drawer row {{ padding: 8px 14px; border-radius: {radius_sm};\
color: @on-bg; background-color: transparent; }}\
.bread-drawer row:hover {{ background-color: alpha(@on-bg, 0.08); }}\
.bread-drawer row:selected {{ background-color: alpha(@{accent_from}, 0.18);\
color: @on-bg; }}\
.bread-drawer .app-name {{ font-size: 14px; font-weight: 500; }}\
.bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}",
radius = radius,
radius_bar = radius_bar,
radius_sm = radius_sm,
@ -374,6 +432,8 @@ fn load_css() -> String {
window_border = window_border,
centerbox_padding = centerbox_padding,
workspace_css = workspace_css,
bg_alpha = bg_alpha,
accent_from = accent_from,
)
}