diff --git a/src/launcher_command.rs b/src/launcher_command.rs new file mode 100644 index 0000000..4d47560 --- /dev/null +++ b/src/launcher_command.rs @@ -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) { + 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); +} diff --git a/src/main.rs b/src/main.rs index aa0d318..89e2104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, pub monitor: Option, @@ -177,6 +186,13 @@ pub enum AppInput { WidgetsUpdate(Vec), 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>> = 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>> = Rc::new(RefCell::new(None)); let launcher_open: Rc> = 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 = 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 = 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> = + Rc::new(Cell::new(bread_launcher::QueryKind::Apps)); + let mode_selectable: Rc> = 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: >k4::ApplicationWindow, + anim: &Rc>>, + 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: >k4::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: >k4::ListBoxRow) -> Option { + unsafe { row.data::("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: >k4::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: >k4::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: >k4::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 { diff --git a/src/screenshot.rs b/src/screenshot.rs index 8299ecd..1b372fd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -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: >k4::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 |_| { diff --git a/src/theme.rs b/src/theme.rs index ebd9884..8e3f37c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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,