From 1fb10a10cdcc0580204bffbdfb0ca5ecb6cc4721 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 25 Aug 2026 00:29:38 +0800 Subject: [PATCH] bar: implement spotlight's embedded launcher capsule (Phase 6b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New bread-launcher dep (gtk feature), pinned tag v0.7.4 with a dev-only [patch] entry alongside bread-theme's. - Wire the drawer slot (plan §2/§7): root becomes a vbox whose first row is the existing CenterBox and whose second is drawer_box, appended via the same ModuleRegistry::for_each_in_slot pattern left/centre/right already use. Empty for liquid-motion/glass-workbench (zero children, zero size); theme.rs's window.breadbar > centerbox selector becomes > box > centerbox to follow the new nesting, byte-identical CSS for both existing themes. - modules.workspaces.style = dots: bar::workspaces::make_dot_button, width from modules.workspaces.dot_widths, wired as a third rebuild_buttons arm. - New launcher_entry (plain GtkEntry) and launcher_results (bread_launcher::gtk::ResultsList, sharing breadbox's cache/history via bread_launcher::LAUNCHER_APP) modules, built unconditionally and placed only when a theme's [bar.slots] names them (spotlight today). - Capsule expand/collapse: typing/focus opens the drawer and animates its height via bread_theme::anim::spring_to; Up/Down move the selection, Enter launches (do_launch + record_launch) and collapses, Escape collapses and releases keyboard focus (on_demand ties to GTK's own focus-widget state, so releasing GTK focus hands the compositor keyboard back). - window.width now wires Width::Px into set_default_width (previously unconsumed — Fill-anchored themes never needed it, the capsule does). - Two new --screenshot views: capsule-collapsed, capsule-expanded (the latter focuses launcher_entry, types a query, and captures bar height + the drawer's actual settled height rather than a guessed constant). cargo check/clippy: no new warnings (same two pre-existing: dead_code on system_stats_box/cpu_pair/mem_pair/pwr_pair, from_* on workspaces.rs). cargo test: 18/18 unchanged. --- Cargo.lock | 24 +++- Cargo.toml | 6 + src/bar/workspaces.rs | 39 +++++ src/main.rs | 326 +++++++++++++++++++++++++++++++++++++++++- src/screenshot.rs | 59 ++++++++ src/theme.rs | 68 ++++++++- 6 files changed, 512 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5be557..c1c3eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index de10b69..7fba78a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index 611e15e..8b30505 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index db91068..0b794ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, pub monitor: Option, @@ -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>, // ── 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 = + std::fs::read_to_string(bread_launcher::IconCache::manifest_path( + bread_launcher::LAUNCHER_APP, + )) + .ok() + .and_then(|s| serde_json::from_str::>(&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(¢er_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>> = Rc::new(RefCell::new(None)); + let launcher_open: Rc> = Rc::new(Cell::new(false)); + + let open_fn: Rc = 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 = 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::<>k4::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: >k4::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: >k4::Box, + anim: &Rc>>, + 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: >k4::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"); diff --git a/src/screenshot.rs b/src/screenshot.rs index 38f857e..8299ecd 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -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, /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. pub osd_window: Option, + /// 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: >k4::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 |_| { diff --git a/src/theme.rs b/src/theme.rs index 57d307f..ebd9884 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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, ) }