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
4 changed files with 490 additions and 5 deletions
Showing only changes of commit e52c507f32 - Show all commits

39
src/launcher_command.rs Normal file
View file

@ -0,0 +1,39 @@
//! Subscribes to `bread.command.box.open` and focuses/opens the capsule —
//! only under `[launcher] mode = "embedded"` (spotlight, THEME_SYSTEM_PLAN.md
//! §7 phase 6c). See `breadbox/EVENTS.md` for the command's existing
//! contract: it's honored today only while `breadbox listen` is running, and
//! is a silent no-op at the bus with no subscriber. breadbar becomes a
//! SECOND subscriber of the exact same verb here — under an embedded theme,
//! breadbox's own `main` (see its doc comment on `dispatch_embedded_open`)
//! redirects a direct launch to this same event instead of mapping its own
//! overlay window, specifically so this module can pick it up. `breadbox
//! listen`'s own handling of the same event is separately made a no-op
//! under an embedded theme (see its `handle_open`) — there is exactly one
//! real handler for this event at a time, whichever theme is active.
//!
//! Same connection pattern as `widgets::client` (this crate's other
//! `BreadClient::subscribe` user): a fire-and-forget connect, a background
//! subscription thread with its own reconnect/backoff, and the handle is
//! leaked rather than threaded through `App` — there's no natural point to
//! stop it before the process exits.
use crate::{App, AppInput};
use bread_theme::shell::LauncherMode;
use bread_utils::bread_client::BreadClient;
use relm4::ComponentSender;
/// Starts the subscription iff the active shell theme's launcher is
/// `Embedded`. A no-op call under every other theme — never connects to
/// breadd at all, matching the "effectively a no-op under every other
/// theme" pattern the rest of the capsule wiring already follows (main.rs's
/// `open_fn`/`close_fn` doc comment).
pub fn spawn(sender: ComponentSender<App>) {
if crate::theme::shell_theme().launcher().mode != LauncherMode::Embedded {
return;
}
let client = BreadClient::connect(crate::widgets::client::APP_ID);
let subscription = client.subscribe("bread.command.box.open", move |_event| {
sender.input(AppInput::OpenLauncher);
});
std::mem::forget(subscription);
}

View file

@ -5,6 +5,7 @@ macro_rules! asset {
}
mod bar;
mod launcher_command;
mod notifications;
mod osd;
mod panel;
@ -37,6 +38,14 @@ use std::rc::Rc;
const LAUNCHER_APP_ID: &str = "box";
const LAUNCHER_LAUNCHED_EVENT: &str = "bread.box.launched";
/// The drawer's own content-height ceiling (`04-spotlight.html`: `.searching
/// .results { max-height: 420px }`) — see [`drawer_target_height`]. Also
/// used to size the click-away scrim's dead zone (see `open_fn` in `init`):
/// the scrim's clickable region never reaches higher than the capsule row
/// plus this much, so it can never overlap a real result row regardless of
/// how tall the drawer currently is.
const DRAWER_MAX_HEIGHT_PX: i32 = 420;
pub struct BarInit {
pub screenshot: Option<screenshot::ScreenshotRequest>,
pub monitor: Option<String>,
@ -177,6 +186,13 @@ pub enum AppInput {
WidgetsUpdate(Vec<bread_shared::widget::WidgetSpec>),
ReconcileMonitors,
DismissPanels,
// `bread.command.box.open` (plan §7 phase 6c, `launcher_command`
// module): only ever dispatched when the active theme's launcher is
// `Embedded` — `launcher_command::spawn` never subscribes otherwise.
// Focuses `launcher_entry`, which the existing `EventControllerFocus`
// (`connect_enter`) already turns into an `open_fn()` call, the same
// path a mouse click into the entry takes.
OpenLauncher,
}
#[relm4::component(pub)]
@ -513,6 +529,7 @@ impl SimpleComponent for App {
&launcher_entries,
launcher_cfg.icon_px,
Rc::clone(&launcher_history),
launcher_cfg.sections,
);
launcher_results.scroller.add_css_class("bread-drawer-scroller");
@ -963,6 +980,22 @@ impl SimpleComponent for App {
widgets.drawer_box.set_overflow(gtk4::Overflow::Hidden);
widgets.drawer_box.set_size_request(-1, 0);
// ── Query-mode results (plan §7 phase 6c: `=` calc, `>` cmd, `.`
// url) ───────────────────────────────────────────────────────────
// A second, small list living alongside `launcher_results.scroller`
// in the same drawer — only one of the two is ever visible at a
// time (see `connect_changed` below). Built unconditionally, same
// as `launcher_results` itself, but only ever APPENDED into
// `drawer_box` for an embedded launcher: every other theme must
// keep `drawer_box` exactly as childless as it already is (see the
// comment above `bar_modules.for_each_in_slot(&bar_slots.drawer, ..)`).
let mode_list = gtk4::ListBox::new();
mode_list.set_selection_mode(gtk4::SelectionMode::Browse);
mode_list.set_visible(false);
if launcher_cfg.mode == bread_theme::shell::LauncherMode::Embedded {
widgets.drawer_box.append(&mode_list);
}
// ── 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
@ -971,18 +1004,53 @@ impl SimpleComponent for App {
// 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));
// Search-state width (plan §7 phase 6c: `[launcher].search_width`,
// `04-spotlight.html`'s `.searching .capsule { width: 520px }`) —
// a separate animation from the drawer's own height, both driven
// by `bread_theme::anim::spring_to` independently.
let anim_width: Rc<RefCell<Option<gtk4::TickCallbackId>>> = Rc::new(RefCell::new(None));
let launcher_open: Rc<Cell<bool>> = Rc::new(Cell::new(false));
// The click-away scrim's dead zone (item B, see `DRAWER_MAX_HEIGHT_PX`'s
// own doc comment) — the capsule row's own theme-default dismiss
// offset (52px: `bar.window.height` + `bar.window.margin.top`) plus
// the drawer's own max content height, so the scrim's clickable
// region can never reach up into a rendered result row.
let capsule_dismiss_margin: i32 = theme::shell_theme()
.surfaces()
.get("breadbar-dismiss")
.map(|s| s.offset.first().copied().unwrap_or(0.0) as i32)
.unwrap_or(52)
+ DRAWER_MAX_HEIGHT_PX;
let open_fn: Rc<dyn Fn()> = Rc::new({
let drawer_box = widgets.drawer_box.clone();
let anim = Rc::clone(&anim);
let anim_width = Rc::clone(&anim_width);
let launcher_open = Rc::clone(&launcher_open);
let entry = launcher_entry.clone();
let root = root.clone();
let panels = panels.clone();
let idle_width = launcher_cfg.width;
let search_width = launcher_cfg.search_width;
move || {
if !launcher_open.get() {
launcher_open.set(true);
entry.add_css_class("searching");
gtk4::prelude::EntryExt::set_alignment(&entry, 0.0);
// `.searching` on the root itself (plan §7 phase 6c):
// toggles `[launcher].search_radius` via CSS (see
// theme.rs's `window.breadbar.searching` rule) and
// marks the width animation's starting point below.
root.add_css_class("searching");
let current_width = root.width();
let from = if current_width > 0 { current_width } else { idle_width };
animate_capsule_width(&root, &anim_width, from, search_width);
// Item B: click-away scrim. Shown at a fixed dead-zone
// offset (`capsule_dismiss_margin`), never the live
// drawer height — see that constant's own doc comment
// for why a live-tracking offset would risk swallowing
// clicks meant for a result row.
panels.show_capsule_dismiss(capsule_dismiss_margin);
}
let target = drawer_target_height(&drawer_box);
let current = drawer_box.size_request().1;
@ -992,9 +1060,13 @@ impl SimpleComponent for App {
let close_fn: Rc<dyn Fn()> = Rc::new({
let drawer_box = widgets.drawer_box.clone();
let anim = Rc::clone(&anim);
let anim_width = Rc::clone(&anim_width);
let launcher_open = Rc::clone(&launcher_open);
let entry = launcher_entry.clone();
let root_for_focus = root.clone();
let root_for_width = root.clone();
let panels = panels.clone();
let idle_width = launcher_cfg.width;
move || {
if !launcher_open.get() {
return;
@ -1006,6 +1078,10 @@ impl SimpleComponent for App {
if placeholder_clock {
entry.set_placeholder_text(Some(&bar::clock::time()));
}
root_for_width.remove_css_class("searching");
let current_width = root_for_width.width();
animate_capsule_width(&root_for_width, &anim_width, current_width, idle_width);
panels.hide_dismiss();
let current = drawer_box.size_request().1;
animate_drawer_height(&drawer_box, &anim, current, 0);
// `keyboard = "on_demand"` (plan §7) ties the layer-shell
@ -1015,11 +1091,55 @@ impl SimpleComponent for App {
}
});
panels.set_on_dismiss({
let close_fn = Rc::clone(&close_fn);
move || close_fn()
});
// Which of the four query modes is currently driving `mode_list`
// (plan §7 phase 6c) — read by `key_ctrl` below to route Up/Down/
// Return at the right list, and whether that mode's row is even
// selectable (an info-only row, e.g. an empty calc expression,
// never is). `launcher_cfg.modes` gates which prefixes actually
// switch mode: a prefix this theme doesn't list in `modes` falls
// through to a literal Apps query, prefix character and all.
let active_mode: Rc<Cell<bread_launcher::QueryKind>> =
Rc::new(Cell::new(bread_launcher::QueryKind::Apps));
let mode_selectable: Rc<Cell<bool>> = Rc::new(Cell::new(false));
let modes = launcher_cfg.modes.clone();
{
let results = launcher_results.clone();
let mode_list = mode_list.clone();
let open_fn = Rc::clone(&open_fn);
let active_mode = Rc::clone(&active_mode);
let mode_selectable = Rc::clone(&mode_selectable);
launcher_entry.connect_changed(move |entry| {
results.set_query(entry.text().as_str());
let text = entry.text();
let parsed = bread_launcher::parse_query(&text);
let mode_name = match parsed.kind {
bread_launcher::QueryKind::Calc => "calc",
bread_launcher::QueryKind::Cmd => "cmd",
bread_launcher::QueryKind::Url => "url",
bread_launcher::QueryKind::Apps => "apps",
};
let kind = if modes.iter().any(|m| m == mode_name) {
parsed.kind
} else {
bread_launcher::QueryKind::Apps
};
active_mode.set(kind);
if kind == bread_launcher::QueryKind::Apps {
mode_list.set_visible(false);
results.scroller.set_visible(true);
mode_selectable.set(false);
results.set_query(text.as_str());
} else {
results.scroller.set_visible(false);
let selectable = populate_mode_list(&mode_list, &parsed);
mode_selectable.set(selectable);
mode_list.set_visible(true);
}
open_fn();
});
}
@ -1031,7 +1151,10 @@ impl SimpleComponent for App {
}
{
let results = launcher_results.clone();
let mode_list = mode_list.clone();
let close_fn = Rc::clone(&close_fn);
let active_mode = Rc::clone(&active_mode);
let mode_selectable = Rc::clone(&mode_selectable);
let key_ctrl = gtk4::EventControllerKey::new();
// CAPTURE, not the default BUBBLE. A GtkEntry handles Return in the
// target phase itself — it emits `activate` and returns TRUE, which
@ -1043,6 +1166,37 @@ impl SimpleComponent for App {
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key;
if active_mode.get() != bread_launcher::QueryKind::Apps {
// Calc/Cmd/Url (plan §7 phase 6c): Up/Down move the
// (possibly single-row) `mode_list` selection; Return
// runs whatever `mode_row_action` finds on the
// selected row — nothing, for a calc result or an
// empty prompt, since those rows carry none.
return match key {
Key::Escape => {
close_fn();
gtk4::glib::Propagation::Stop
}
Key::Down if mode_selectable.get() => {
listbox_select_next(&mode_list);
gtk4::glib::Propagation::Stop
}
Key::Up if mode_selectable.get() => {
listbox_select_prev(&mode_list);
gtk4::glib::Propagation::Stop
}
Key::Return | Key::KP_Enter => {
if let Some(row) = mode_list.selected_row() {
if let Some(action) = mode_row_action(&row) {
run_mode_action(&action);
close_fn();
}
}
gtk4::glib::Propagation::Stop
}
_ => gtk4::glib::Propagation::Proceed,
};
}
match key {
Key::Escape => {
close_fn();
@ -1073,6 +1227,17 @@ impl SimpleComponent for App {
});
launcher_entry.add_controller(key_ctrl);
}
// Click on a mode_list row (a `>`-mode command or the `.`-mode
// "open this URL" prompt) acts too, same as a result row's click.
{
let close_fn = Rc::clone(&close_fn);
mode_list.connect_row_activated(move |_, row| {
if let Some(action) = mode_row_action(row) {
run_mode_action(&action);
close_fn();
}
});
}
// Row click launches too, same as breadbox's own overlay.
{
let results = launcher_results.clone();
@ -1188,6 +1353,12 @@ impl SimpleComponent for App {
if init.primary {
bar::tray::spawn_watcher(sender.clone());
widgets::client::spawn(sender.clone());
// `bread.command.box.open` (plan §7 phase 6c): one subscriber,
// same reasoning as `widgets::client::spawn` above — a keybind
// should focus ONE capsule, not every satellite monitor's.
// A no-op call under every theme but spotlight (see the
// module's own doc comment).
launcher_command::spawn(sender.clone());
// Optional (plan §10, Phase 2 item 6): live theme.toml/extra.css
// token reload, the same way a pywal palette change already
// hot-reloads via `apply_app_css`. One watch per process, so
@ -1515,6 +1686,9 @@ impl SimpleComponent for App {
AppInput::DismissPanels => {
self.panels.hide_all();
}
AppInput::OpenLauncher => {
self.launcher_entry.grab_focus();
}
}
}
}
@ -2259,6 +2433,30 @@ fn animate_drawer_height(
*anim.borrow_mut() = Some(id);
}
/// Drives the capsule's own window width from `from` to `to` over 360ms
/// (plan §7 phase 6c: `[launcher].search_width`, `04-spotlight.html`'s
/// `.searching .capsule { width: 520px }`) — the same `spring_to` +
/// `set_size_request` technique `animate_drawer_height` uses for the
/// drawer's height, applied to the root window itself instead of a child
/// box. Unlike a drawer collapse, width never animates toward a negative
/// target (idle/search widths are both positive theme values), so there is
/// no analogous "clamp to 0" concern here.
fn animate_capsule_width(
root: &gtk4::ApplicationWindow,
anim: &Rc<std::cell::RefCell<Option<gtk4::TickCallbackId>>>,
from: i32,
to: i32,
) {
if let Some(id) = anim.borrow_mut().take() {
id.remove();
}
let target = root.clone();
let id = bread_theme::anim::spring_to(root, from, to, 360.0, move |w| {
target.set_size_request(w.max(0), -1);
});
*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
@ -2266,8 +2464,180 @@ fn animate_drawer_height(
/// 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)
// Deliberately never measures `drawer_box` itself. `animate_drawer_height`'s
// tick callback calls `drawer_box.set_size_request(-1, h)` on every
// frame, and GTK clamps a widget's own `measure()` result up to at
// least its own explicit size request — so once an animation has run
// even one frame, `drawer_box.measure()` reports that frame's forced
// height (or the spring's overshoot past it), not whatever its
// children actually need next. This bit spotlight's new query-mode
// rows directly: switching from the (tall) app list to a one-row calc
// result measured "437" instead of "~33", because the PREVIOUS
// animation frame had already forced `drawer_box` to 437px.
//
// Summing each currently-visible child's own natural height instead
// sidesteps this entirely — `launcher_results.scroller` and
// `mode_list` never get an explicit size request of their own, so
// their `measure()` always reflects their actual current content.
let mut total = 0;
let mut child = drawer_box.first_child();
while let Some(c) = child {
if c.is_visible() {
let (_, natural, _, _) = c.measure(gtk4::Orientation::Vertical, -1);
total += natural;
}
child = c.next_sibling();
}
total.min(DRAWER_MAX_HEIGHT_PX)
}
// ── Query-mode rows (plan §7 phase 6c) ──────────────────────────────────
//
// `mode_list`'s rows are NOT `bread_launcher::DesktopEntry`-backed
// (`bread_launcher::gtk::row_entry` returns `None` for every one of
// these), so they're built/read here rather than through that crate.
/// A single-line, non-interactive row — the calc result, or a "nothing
/// typed yet" placeholder. Reuses `.app-name` so it inherits the same
/// `.bread-drawer row` typography `bread-launcher`'s own rows get.
fn mode_info_row(text: &str) -> gtk4::ListBoxRow {
let row = gtk4::ListBoxRow::new();
row.set_selectable(false);
row.set_activatable(false);
let lbl = gtk4::Label::new(Some(text));
lbl.add_css_class("app-name");
lbl.set_xalign(0.0);
row.set_child(Some(&lbl));
row
}
/// A single-line, actionable row (a `>`-mode command, or the `.`-mode
/// "open this URL" prompt) — Enter/click spawns `action` once resolved by
/// [`run_mode_action`].
fn mode_action_row(text: &str, action: ModeAction) -> gtk4::ListBoxRow {
let row = gtk4::ListBoxRow::new();
let lbl = gtk4::Label::new(Some(text));
lbl.add_css_class("app-name");
lbl.set_xalign(0.0);
row.set_child(Some(&lbl));
unsafe { row.set_data("mode_action", action) };
row
}
/// What Enter/click on a [`mode_action_row`] does. Two variants, not one
/// shell-command string, so a `.`-mode URL (arbitrary user-typed text)
/// never passes through `bash -c` at all — only a `>`-mode command's own
/// fixed, trusted `exec` string does.
#[derive(Clone)]
enum ModeAction {
RunShell(&'static str),
OpenUrl(String),
}
fn mode_row_action(row: &gtk4::ListBoxRow) -> Option<ModeAction> {
unsafe { row.data::<ModeAction>("mode_action").map(|p| p.as_ref().clone()) }
}
fn run_mode_action(action: &ModeAction) {
match action {
ModeAction::RunShell(cmd) => {
let _ = std::process::Command::new("bash")
.args(["-c", cmd])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
ModeAction::OpenUrl(url) => {
// No scheme-adding shell involved — `xdg-open` gets the raw
// argument, so nothing in a `.`-mode query is ever parsed as
// shell syntax.
let target = if url.contains("://") {
url.clone()
} else {
format!("https://{url}")
};
let _ = std::process::Command::new("xdg-open")
.arg(target)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
}
/// Moves `list`'s selection to the next/previous row — unlike
/// `bread_launcher::gtk::ResultsList::select_next`/`select_prev`, `mode_list`
/// never has hidden rows to skip (it's cleared and rebuilt from scratch on
/// every query change), so this is the plain, un-filtered version.
fn listbox_select_next(list: &gtk4::ListBox) {
let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1);
if let Some(row) = list.row_at_index(cur + 1) {
list.select_row(Some(&row));
}
}
fn listbox_select_prev(list: &gtk4::ListBox) {
let cur = list.selected_row().map(|r| r.index()).unwrap_or(0);
if cur > 0 {
if let Some(row) = list.row_at_index(cur - 1) {
list.select_row(Some(&row));
}
}
}
/// Clears `mode_list` and rebuilds it for `parsed` — the calc result,
/// filtered `>`-mode commands, or the `.`-mode "open this URL" prompt.
/// Returns whether anything is now selectable (a real command/URL row, not
/// just an info row) so the caller knows whether Return has anything to do.
fn populate_mode_list(mode_list: &gtk4::ListBox, parsed: &bread_launcher::ParsedQuery) -> bool {
while let Some(row) = mode_list.row_at_index(0) {
mode_list.remove(&row);
}
match parsed.kind {
bread_launcher::QueryKind::Calc => {
match bread_launcher::eval_calc(&parsed.value) {
Some(result) => mode_list.append(&mode_info_row(&format!("= {result}"))),
None => mode_list.append(&mode_info_row("=")),
}
false
}
bread_launcher::QueryKind::Cmd => {
let matches = bread_launcher::filter_commands(
&parsed.value,
bread_launcher::builtin_commands(),
);
if matches.is_empty() {
mode_list.append(&mode_info_row("No matching commands"));
false
} else {
for cmd in &matches {
mode_list.append(&mode_action_row(cmd.name, ModeAction::RunShell(cmd.exec)));
}
if let Some(first) = mode_list.row_at_index(0) {
mode_list.select_row(Some(&first));
}
true
}
}
bread_launcher::QueryKind::Url => {
if parsed.value.is_empty() {
mode_list.append(&mode_info_row("."));
false
} else {
mode_list.append(&mode_action_row(
&format!("Open {}", parsed.value),
ModeAction::OpenUrl(parsed.value.clone()),
));
if let Some(first) = mode_list.row_at_index(0) {
mode_list.select_row(Some(&first));
}
true
}
}
bread_launcher::QueryKind::Apps => false,
}
}
fn popover_tab(label: &str) -> gtk4::ToggleButton {

View file

@ -53,6 +53,13 @@ const KNOWN_VIEWS: &[&str] = &[
// capsule, collapsed" under spotlight specifically.
"capsule-collapsed",
"capsule-expanded",
// Phase 6c: query sections and the `=` calc mode — see `dispatch`'s
// two new match arms below. "capsule-expanded" already exercises the
// search-state width/radius change (item E: it types a query, which
// now also drives `open_fn`'s `.searching` root class + capsule-width
// spring animation), so that gap doesn't need its own view.
"capsule-sections",
"capsule-calc",
];
#[derive(Parser)]
@ -236,6 +243,53 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles:
});
});
}
"capsule-sections" => {
// Focus with NO query typed — the idle browse view
// (`ResultsList::new`'s "Recent"/"Apps" header rows are visible
// from construction; `set_query` is what would hide them, and
// it's never called here). Same settle timing as
// "capsule-expanded", just without the `entry.set_text` step.
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"capsule-calc" => {
// `=` mode (item C): the drawer's `mode_list` shows a single
// evaluated result row instead of `launcher_results.scroller`
// (see `populate_mode_list`'s `QueryKind::Calc` arm).
let entry = handles.launcher_entry;
let drawer_box = handles.drawer_box;
root.connect_map(move |_| {
let output = output.clone();
let entry = entry.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
entry.grab_focus();
entry.set_text("=6*7");
let output = output.clone();
let drawer_box = drawer_box.clone();
gtk4::glib::timeout_add_local_once(Duration::from_millis(500), move || {
let drawer_h = drawer_box.size_request().1.max(0);
let capture_h = bar_height + drawer_h;
finish(bread_screenshots::capture_region(0, 0, width, capture_h, &output));
});
});
});
}
"wifi-add-dialog" => {
let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| {

View file

@ -75,6 +75,13 @@ fn load_css() -> String {
// 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();
// `[launcher].search_radius` (plan §7 phase 6c) — `LauncherMode::
// Embedded` only (spotlight); `.launcher().radius` itself already
// equals `radius_bar` for that theme (see its own theme.toml comment),
// so a theme that omits `search_radius` gets `radius_search ==
// radius_bar` here too, i.e. no visible shrink, matching bread-theme's
// own "default to the idle value" fallback.
let radius_search = format!("{}px", theme.launcher().search_radius);
// `tokens.bar_border()` (plan §11 Phase 5): "full" (default, liquid-
// motion's floating island) draws a border on all four edges; "bottom"
@ -170,7 +177,12 @@ fn load_css() -> String {
@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, {bg_alpha}); color: @on-bg;\
border-radius: {radius_bar}; {window_border} }}\
border-radius: {radius_bar}; {window_border}\
transition: border-radius 0.3s {spring_settle}; }}\
/* `[launcher].search_radius` (plan §7 phase 6c, spotlight only —\
`launcher_entry` never gets focus under any other theme, so\
`.searching` never lands on `window.breadbar` there). */\
window.breadbar.searching {{ border-radius: {radius_search}; }}\
/* `> box > centerbox`, not `> centerbox`: the root is a vbox (bar\
row + drawer, plan §2) as of the `drawer` slot wiring every\
theme's centerbox is now one level deeper than before, this\
@ -421,8 +433,18 @@ fn load_css() -> String {
.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; }}",
.bread-drawer .app-muted {{ opacity: 0.45; font-size: 11px; }}\
/* `[launcher].sections` (plan §7 phase 6c) — the idle drawer's\
\"Recent\"/\"Apps\" group labels (`bread_launcher::gtk::\
build_header_row`). Unconditional, same reasoning as every other\
launcher rule above: only spotlight ever builds a row with this\
class at all. */\
.bread-drawer-section-header {{ padding: 6px 14px 2px; }}\
.section-header-label {{ font-size: 11px; font-weight: 600;\
letter-spacing: 0.08em; text-transform: uppercase;\
opacity: 0.45; }}",
radius = radius,
radius_search = radius_search,
radius_bar = radius_bar,
radius_sm = radius_sm,
radius_pill = radius_pill,