macro_rules! asset { ($n:literal) => { include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/", $n)) }; } mod bar; mod launcher_command; mod notifications; mod osd; mod panel; mod screenshot; mod surface; mod theme; mod widgets; 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; 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"; /// 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, pub primary: bool, } pub struct App { monitor: String, primary: bool, satellites: Vec<(String, Controller)>, // ── Workspaces ──────────────────────────────────────────────────────── workspaces: Vec, active_ws: WorkspaceId, workspace_box: gtk4::Box, workspace_trail: bar::workspaces::WorkspaceTrail, button_map: std::collections::HashMap, // ── Clock ───────────────────────────────────────────────────────────── time_str: String, clock_digits: Vec, date_lbl: gtk4::Label, // `modules.clock.style = "plain"` (glass-workbench, Phase 5): a plain // "HH:MM" label with no per-digit flip. Built alongside `clock_digits` // regardless of the active theme's style so switching styles needs no // 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 // / hamburger. CPU/RAM/power live in the control panel, not on the bar. system_stats_box: gtk4::Box, system_sep: gtk4::Separator, cpu_pair: gtk4::Box, mem_pair: gtk4::Box, pwr_pair: gtk4::Box, gpu_pair: gtk4::Box, cpu_lbl: gtk4::Label, mem_lbl: gtk4::Label, pwr_lbl: gtk4::Label, // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, Phase // 5): separate instances from `cpu_pair`/`mem_pair` above, which stay // parented in the control panel's sys-grid — a GTK widget can only have // one parent, so reusing those here would mean reparenting them out of // the panel, changing panel behaviour no theme asked to change. Fed by // the same `AppInput::StatsUpdate` data. bar_cpu_lbl: gtk4::Label, bar_ram_lbl: gtk4::Label, gpu_lbl: gtk4::Label, vol_lbl: gtk4::Label, bat_lbl: gtk4::Label, bat_img: gtk4::Image, bat_textures: std::collections::HashMap, ac_img: gtk4::Image, bt_img: gtk4::Image, bt_textures: std::collections::HashMap, wifi_lbl: gtk4::Label, wifi_img: gtk4::Image, // ── WiFi popover ────────────────────────────────────────────────────── wifi_pane: gtk4::Box, crumbs_status: Option, wifi_popover_data: Option, wifi_profile: Option, current_ssid: String, // ── Bluetooth popover ──────────────────────────────────────────────── bt_pane: gtk4::Box, bt_popover_data: Option, // ── Media ───────────────────────────────────────────────────────────── media_widget: gtk4::Box, media_track_lbl: gtk4::Label, media_play_icon: gtk4::Image, media_last: Option, media_paused_at: Option, // ── Control panel ───────────────────────────────────────────────────── panel_vol_slider: gtk4::Scale, panel_bright_slider: gtk4::Scale, panel_loading: Rc>, sink_box: gtk4::Box, sink_section: gtk4::Box, // ── Tray ────────────────────────────────────────────────────────────── tray_section: gtk4::Box, tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, // ── Lua-declared widgets ───────────────────────────────────────────── // One container per `widget:` slot entry (Phase 3b — see // bar::slots::ModuleRegistry and reconcile_widgets' routing below), // fully rebuilt on every AppInput::WidgetsUpdate — see widgets::client's // module doc for why that's simpler than incremental patching here. // Keyed by the slot entry's key: either a WidgetPlacement alias // (`right_of_workspaces`, `left_of_clock`, `right_of_clock`, // `left_of_stats`, `tray`) or a Lua module name. `bread_shared::widget`'s // `WidgetPlacement` itself never appears here — it's a wire type from // the bread daemon API and stays untouched. widget_containers: std::collections::HashMap, /// Widget ids already reported as undeliverable by `reconcile_widgets`, so /// the warning fires once per widget instead of once per reconcile. dropped_widget_warned: std::collections::HashSet, widget_tray_section: gtk4::Box, widget_tray_sep: gtk4::Separator, panels: panel::PanelSet, } #[derive(Debug)] pub enum AppInput { WorkspaceSync { workspaces: Vec, actives: std::collections::HashMap, }, MonitorAdded(String), MonitorRemoved(String), ClockTick, StatsUpdate(bar::stats::Stats), TrayUpdate(bar::tray::TrayUpdate), CrumbsStatus(bar::wifi::CrumbsStatus), WifiPopoverData(bar::wifi::WifiPopoverData), SetProfile(String), BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), 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)] impl SimpleComponent for App { type Init = BarInit; type Input = AppInput; type Output = (); view! { gtk::ApplicationWindow { add_css_class: "breadbar", set_title: Some("breadbar"), set_default_height: bar_height, // 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, }, } } } fn init( init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { let screenshot_req = init.screenshot; let monitor_name = init .monitor .clone() .or_else(primary_hypr_monitor) .unwrap_or_else(|| "eDP-1".into()); // `bar.window` (plan §2/§6) — window shape is data, not a closed // layout enum. Read once and reused below for the layer-shell setup // and (via `bar_height`, captured for the view! macro above) the // root window's initial GTK height. let window_spec = theme::shell_theme().window().clone(); let bar_height = window_spec.height; root.init_layer_shell(); root.set_namespace(Some("breadbar")); root.set_layer(if window_spec.layer == "overlay" { Layer::Overlay } else { Layer::Top }); for anchor in &window_spec.anchors { match anchor.as_str() { "top" => root.set_anchor(Edge::Top, true), "bottom" => root.set_anchor(Edge::Bottom, true), "left" => root.set_anchor(Edge::Left, true), "right" => root.set_anchor(Edge::Right, true), other => eprintln!( "breadbar: bar.window.anchors entry \"{other}\" is not top|bottom|left|right, ignoring" ), } } 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); // set_default_width alone is only a preference — a wide child (the // results list, whose natural width is its longest app name plus // icon and wm-class) overrides it, so the capsule rendered far // wider than the theme's 480px and stopped reading as a pill. // Pinning the request keeps the surface at the theme's width // regardless of what the app catalog contains. root.set_size_request(px, -1); } // "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 { Exclusive::Auto => window_spec.height + window_spec.margin.top, Exclusive::None => -1, Exclusive::Px(px) => px, }; root.set_exclusive_zone(exclusive_zone); // breadbar never called `set_keyboard_mode` before Phase 2 — it // relied on gtk4-layer-shell's own default (`KeyboardMode::None`), // which is exactly what the builtin manifest's `keyboard = "none"` // resolves to. Same behaviour, no longer implicit. root.set_keyboard_mode(match window_spec.keyboard { Keyboard::None => KeyboardMode::None, Keyboard::OnDemand => KeyboardMode::OnDemand, Keyboard::Exclusive => KeyboardMode::Exclusive, }); eprintln!( "breadbar: init monitor={monitor_name} primary={}", init.primary ); if screenshot_req.is_none() && !bind_layer_monitor(&root, &monitor_name) { // Unbound satellites must not map on the compositor default // (that stacks a second exclusive-zone bar on the laptop). root.set_exclusive_zone(-1); if !init.primary { root.set_visible(false); } } // ── Workspace row (left) ──────────────────────────────────────── // Built imperatively (not via the view! macro) so a widget // container can sit as a plain sibling of workspace_box — see the // `widget:*` slot-entry handling in "Assemble" below. The Overlay // trail lives behind the buttons; rebuild_buttons only touches the // button box, never the trail host. let workspace_trail = bar::workspaces::WorkspaceTrail::new(); let workspace_box = workspace_trail.buttons.clone(); let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); workspace_row.set_margin_start(8); workspace_row.set_valign(gtk4::Align::Center); workspace_row.set_vexpand(false); // `workspace_trail.overlay` is appended in the "Assemble" section // below, in the order `[bar.slots].left` names it — not here. // ── Lua-declared widget containers ────────────────────────────── // Phase 3b: a container per `widget:` slot entry is created // on demand while walking `[bar.slots]` in the "Assemble" section // below (see `bar::slots::widget_slot_container`), so ANY slot can // host a Lua widget — not just the four fixed positions Phase 3a // shipped with. Populated by widgets::client's events.subscribe- // driven refresh loop, started at the end of init. // `tokens.icon_px` (plan §4) — bar-chrome icon pixel size; reused // below for every `prepare_icon` call in this function. let icon_px = theme::shell_theme().tokens().icon_px() as i32; // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, ICON_VOLUME, }; let bat_textures: std::collections::HashMap = [BAT_HIGH, BAT_MID, BAT_LOW] .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); let bt_textures: std::collections::HashMap = [BT_OFF, BT_ON, BT_CONNECTED] .into_iter() .map(|p| (p.as_ptr() as usize, svg_texture(p))) .collect(); // ── Stat labels ────────────────────────────────────────────────── let cpu_lbl = stat_label(); let mem_lbl = stat_label(); let pwr_lbl = stat_label(); let gpu_lbl = stat_label(); let vol_lbl = stat_label(); let bat_lbl = stat_label(); let vol_img = svg_image(ICON_VOLUME); vol_img.add_css_class("stat-icon"); let bat_img = gtk4::Image::from_paintable(Some( bat_textures.get(&(BAT_MID.as_ptr() as usize)).unwrap(), )); prepare_icon(&bat_img, icon_px); let ac_img = svg_image(AC_POWER); ac_img.set_visible(false); let bt_img = gtk4::Image::from_paintable(Some( bt_textures.get(&(BT_OFF.as_ptr() as usize)).unwrap(), )); prepare_icon(&bt_img, icon_px); bt_img.set_visible(false); // ── WiFi pair + popover ────────────────────────────────────────── let wifi_lbl = gtk4::Label::new(None); wifi_lbl.add_css_class("stat-label"); wifi_lbl.add_css_class("wifi-label"); wifi_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); wifi_lbl.set_max_width_chars(28); wifi_lbl.set_xalign(0.0); // SSID lives in the popover + tooltip — a 20-char network name // crowding the tray is the opposite of a glass workbench bar. wifi_lbl.set_visible(false); let wifi_img = gtk4::Image::from_icon_name(bar::stats::WIFI_ICON_EXCELLENT); prepare_icon(&wifi_img, icon_px); wifi_img.add_css_class("stat-icon"); // Content pane only — this becomes a tab inside the merged // connectivity popover built alongside the Bluetooth pane below, // once bt_img exists too. See "Connectivity popover" further down. let wifi_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let loading_lbl = gtk4::Label::new(Some("Scanning…")); loading_lbl.add_css_class("wifi-popover-loading"); wifi_pane.append(&loading_lbl); // ── Media widget (center) ──────────────────────────────────────── let media_widget = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_widget.add_css_class("media-widget"); bar_chip(&media_widget); media_widget.set_visible(false); let media_eq = gtk4::Box::new(gtk4::Orientation::Horizontal, 3); media_eq.add_css_class("media-eq"); media_eq.set_valign(gtk4::Align::Center); for _ in 0..4 { let bar = gtk4::Box::new(gtk4::Orientation::Vertical, 0); bar.add_css_class("media-eq-bar"); bar.set_valign(gtk4::Align::End); media_eq.append(&bar); } let media_track_lbl = gtk4::Label::new(None); media_track_lbl.add_css_class("media-track-lbl"); media_track_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); media_track_lbl.set_max_width_chars(42); media_track_lbl.set_xalign(0.0); media_widget.append(&media_eq); media_widget.append(&media_track_lbl); // Media controls popover let media_controls_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); media_controls_box.add_css_class("media-controls"); media_controls_box.set_margin_top(4); media_controls_box.set_margin_bottom(4); media_controls_box.set_margin_start(4); media_controls_box.set_margin_end(4); let prev_btn = gtk4::Button::new(); prev_btn.set_child(Some(&svg_image(asset!("Previous.svg")))); prev_btn.add_css_class("flat"); prev_btn.add_css_class("media-btn"); prev_btn.connect_clicked(|_| bar::media::spawn_cmd("previous")); let media_play_icon = svg_image(asset!("Pause.svg")); let media_play_btn = gtk4::Button::new(); media_play_btn.set_child(Some(&media_play_icon)); media_play_btn.add_css_class("flat"); media_play_btn.add_css_class("media-btn"); media_play_btn.add_css_class("media-play-btn"); media_play_btn.connect_clicked(|_| bar::media::spawn_cmd("play-pause")); let next_btn = gtk4::Button::new(); next_btn.set_child(Some(&svg_image(asset!("Next.svg")))); next_btn.add_css_class("flat"); next_btn.add_css_class("media-btn"); next_btn.connect_clicked(|_| bar::media::spawn_cmd("next")); media_controls_box.append(&prev_btn); media_controls_box.append(&media_play_btn); media_controls_box.append(&next_btn); // Clock: time is the hero, date sits beside it quieter. // Per-glyph labels so a minute rollover can flip only the digits // that changed — same motion as the Liquid Motion demo. let clock_time = bar::clock::time(); let clock_digits = make_clock_digits(&clock_time); let clock_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); clock_box.add_css_class("clock-box"); clock_box.add_css_class("clock-label"); clock_box.set_valign(gtk4::Align::Center); clock_box.set_vexpand(false); // Varela Round's em box sits optically high in the 44px island. clock_box.set_margin_top(3); for digit in &clock_digits { clock_box.append(digit); } let date_lbl = gtk4::Label::new(Some(&bar::clock::date())); date_lbl.add_css_class("date-label"); date_lbl.set_visible(false); // `modules.clock.style = "plain"` (glass-workbench): date_lbl above // plus one plain "HH:MM" label, no per-digit flip markup at all. // Built unconditionally alongside the flip clock so a theme's style // choice is just which of the two gets registered into the "clock" // slot below — see "Assemble". let clock_plain_lbl = gtk4::Label::new(Some(&bar::clock::time())); clock_plain_lbl.add_css_class("clock-plain-time"); clock_plain_lbl.set_valign(gtk4::Align::Center); clock_plain_lbl.set_vexpand(false); let clock_plain_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); clock_plain_box.add_css_class("clock-plain"); clock_plain_box.set_valign(gtk4::Align::Center); clock_plain_box.set_vexpand(false); 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_cfg.sections, ); 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); // Starts non-focusable ("the spotlight theme starts with the // search open"). GTK4 auto-assigns keyboard focus to the first // can-focus widget in a window as it's first mapped/shown — with // nothing else focusable in the bar, that was always // `launcher_entry`, and `focus_ctrl`'s `connect_enter` below // (added unconditionally) turns "the entry gained focus" straight // into `open_fn()`, so the capsule opened itself at startup. A // widget that can't focus is skipped by BOTH that automatic // selection and an explicit `grab_focus()` call — see GTK's own // docs for `Widget::grab_focus`: "if widget is not focusable... // this function does nothing." Every place that later wants real // focus (the click gesture below, and `AppInput::OpenLauncher`'s // handler) flips this back to `true` immediately before grabbing. launcher_entry.set_can_focus(false); 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"); center_area.set_valign(gtk4::Align::Center); center_area.set_vexpand(false); // `media_widget`/`clock_box` and any `widget:*` entries interleaved // around them are all appended in "Assemble" below, in the exact // order `[bar.slots].centre` names them. // ── Stats box (right side) ─────────────────────────────────────── // Demo order: [vol 64] [wifi] [bat 83] [☰] let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); stats_box.add_css_class("stats-box"); stats_box.set_margin_end(2); stats_box.set_valign(gtk4::Align::Center); stats_box.set_vexpand(false); // Also appended in "Assemble" — before the right slot's modules, // preserving its fixed position (left of the stats modules) // whatever `[bar.slots].right` contains. // CPU/RAM/power draw stay built (control panel + screenshots still // read the labels) but never mount on the island — the demo bar // does not show them. let cpu_pair = stat_pair(asset!("CPU.svg"), &cpu_lbl); let mem_pair = stat_pair(asset!("RAM Usage.svg"), &mem_lbl); let pwr_pair = stat_pair(asset!("Power Draw.svg"), &pwr_lbl); let gpu_pair = stat_pair(asset!("GPU.svg"), &gpu_lbl); for pair in [&cpu_pair, &mem_pair, &pwr_pair, &gpu_pair] { pair.add_css_class("sys-stat"); pair.set_hexpand(true); } gpu_pair.set_visible(false); // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench, // Phase 5): separate chip instances from `cpu_pair`/`mem_pair` // above, which stay parented in the control panel's sys-grid below // — reusing them here would mean reparenting them out of the panel. // Same icons, same `.stat-pair` chip styling every other bar chip // (volume/battery) already uses; fed by the same `StatsUpdate` data. let bar_cpu_lbl = stat_label(); let bar_ram_lbl = stat_label(); let bar_cpu_pair = stat_pair(asset!("CPU.svg"), &bar_cpu_lbl); let bar_ram_pair = stat_pair(asset!("RAM Usage.svg"), &bar_ram_lbl); let system_stats_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); system_stats_box.add_css_class("sys-grid"); let sys_row1 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); sys_row1.append(&cpu_pair); sys_row1.append(&mem_pair); let sys_row2 = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); sys_row2.append(&gpu_pair); sys_row2.append(&pwr_pair); system_stats_box.append(&sys_row1); system_stats_box.append(&sys_row2); let system_sep = gtk4::Separator::new(gtk4::Orientation::Vertical); system_sep.add_css_class("bar-sep"); system_sep.set_visible(false); let vol_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); vol_box.add_css_class("stat-pair"); bar_chip(&vol_box); vol_lbl.add_css_class("stat-label"); vol_box.append(&vol_img); vol_box.append(&vol_lbl); // Appended in "Assemble" below, per `[bar.slots].right`. let bat_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); bat_box.add_css_class("stat-pair"); bar_chip(&bat_box); bat_img.add_css_class("stat-icon"); bat_lbl.add_css_class("stat-label"); ac_img.add_css_class("stat-icon"); bat_box.append(&bat_img); bat_box.append(&bat_lbl); bt_img.add_css_class("bt-icon"); // Content pane only — same deal as wifi_pane above. let bt_pane = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let bt_loading_lbl = gtk4::Label::new(Some("Loading…")); bt_loading_lbl.add_css_class("wifi-popover-loading"); bt_pane.append(&bt_loading_lbl); // ── Connectivity popover ───────────────────────────────────────── // WiFi and Bluetooth share one popover with one anatomy (same CSS // classes throughout the two panes) instead of two near-identical // popups behind two separate icons. A small tab row switches between // them; both fetches kick off on open so switching tabs afterward is // instant. The two panes live in a Stack rather than plain sibling // Boxes with manual visibility toggling, which left stale width // behind on reopen. // // Scrollport is a fixed 300px so tab-switch / first scan cannot // resize the xdg_popup (that vanish-on-grow bug). Nearby networks // live in the scroll, not a clipped 240px well. let content_stack = gtk4::Stack::new(); content_stack.set_hhomogeneous(true); content_stack.set_vhomogeneous(false); content_stack.set_transition_type(gtk4::StackTransitionType::Crossfade); content_stack.set_transition_duration(220); content_stack.add_named(&wifi_pane, Some("wifi")); content_stack.add_named(&bt_pane, Some("bluetooth")); content_stack.set_visible_child_name("wifi"); // Fixed scrollport so nearby networks stay reachable without // resizing the xdg_popup (that vanish-on-grow bug). let content_scroll = gtk4::ScrolledWindow::new(); content_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); content_scroll.set_propagate_natural_width(true); content_scroll.set_min_content_height(300); content_scroll.set_max_content_height(300); content_scroll.set_child(Some(&content_stack)); let wifi_tab_btn = popover_tab("Wi-Fi"); wifi_tab_btn.set_active(true); let bt_tab_btn = popover_tab("Bluetooth"); bt_tab_btn.set_group(Some(&wifi_tab_btn)); let tab_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); tab_row.add_css_class("popover-tab-row"); tab_row.set_homogeneous(true); tab_row.append(&wifi_tab_btn); tab_row.append(&bt_tab_btn); let wifi_caret = popover_caret(); let stack_for_wifi = content_stack.clone(); wifi_tab_btn.connect_toggled(move |btn| { if btn.is_active() { stack_for_wifi.set_visible_child_name("wifi"); } }); let stack_for_bt = content_stack.clone(); bt_tab_btn.connect_toggled(move |btn| { if btn.is_active() { stack_for_bt.set_visible_child_name("bluetooth"); } }); let connectivity_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); connectivity_inner.add_css_class("wifi-popover-inner"); connectivity_inner.append(&tab_row); connectivity_inner.append(&wifi_caret); connectivity_inner.append(&content_scroll); let connectivity_pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); connectivity_pair.add_css_class("stat-pair"); connectivity_pair.add_css_class("wifi-pair"); connectivity_pair.add_css_class("icon-only"); bar_chip(&connectivity_pair); wifi_img.set_halign(gtk4::Align::Center); wifi_img.set_hexpand(false); connectivity_pair.append(&wifi_img); // `connectivity_pair` and `bat_box` are appended in "Assemble" // below, per `[bar.slots].right`. // ── Control panel popover ──────────────────────────────────────── // Liquid Motion chrome: CONTROL / vol / bl / lock·sleep·off. // SNI tray + Lua tray widgets still mount here, but only as a // headerless icon row when something actually registers. let panel_inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); panel_inner.add_css_class("control-panel-inner"); let panel_header = gtk4::Label::new(Some("CONTROL")); panel_header.add_css_class("control-panel-header"); panel_header.set_xalign(0.0); panel_inner.append(&panel_header); panel_inner.append(&popover_caret()); let vol_row = build_slider_row("vol", 0.0, 1.5, 0.02); let panel_vol_slider = vol_row.1.clone(); panel_inner.append(&vol_row.0); let sink_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); sink_section.add_css_class("control-panel-section"); let sink_header = gtk4::Label::new(Some("OUTPUT")); sink_header.add_css_class("control-panel-header"); sink_header.set_xalign(0.0); sink_header.set_margin_top(6); let sink_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); sink_section.append(&sink_header); sink_section.append(&sink_box); sink_section.set_visible(false); panel_inner.append(&sink_section); let bright_row = build_slider_row("bl", 0.0, 1.0, 0.02); let panel_bright_slider = bright_row.1.clone(); panel_inner.append(&bright_row.0); let sys_header = gtk4::Label::new(Some("SYSTEM")); sys_header.add_css_class("control-panel-header"); sys_header.set_xalign(0.0); sys_header.set_margin_top(10); panel_inner.append(&sys_header); panel_inner.append(&system_stats_box); let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); power_row.add_css_class("power-row"); power_row.set_halign(gtk4::Align::Center); for (label, cmd) in [ // breadlock is the ecosystem's own screen locker — hyprlock is // the thing it was built to replace; the bar shouldn't still // be pointing at it. ("lock", vec!["breadlock"]), ("sleep", vec!["systemctl", "suspend"]), ("off", vec!["systemctl", "poweroff"]), ] { let btn = gtk4::Button::with_label(label); btn.add_css_class("flat"); btn.add_css_class("power-btn"); btn.connect_clicked(move |_| { let args = cmd.to_vec(); relm4::spawn(async move { let _ = tokio::process::Command::new(args[0]) .args(&args[1..]) .spawn(); }); }); power_row.append(&btn); } panel_inner.append(&power_row); // SNI / Lua tray sit under the demo chrome so a single icon cannot // split the sliders from the power chips. let tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); tray_section.add_css_class("control-panel-section"); let tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); tray_box.add_css_class("tray-box"); tray_box.set_halign(gtk4::Align::Center); tray_section.append(&tray_box); tray_section.set_visible(false); panel_inner.append(&tray_section); let tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); tray_sep.set_visible(false); let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 0); widget_tray_section.add_css_class("control-panel-section"); let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); widget_tray_box.add_css_class("tray-box"); widget_tray_box.set_halign(gtk4::Align::Center); widget_tray_section.append(&widget_tray_box); widget_tray_section.set_visible(false); panel_inner.append(&widget_tray_section); let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); widget_tray_sep.set_visible(false); // Hamburger button — same chip chrome as volume / wifi / battery. let hamburger_btn = gtk4::Button::with_label("☰"); hamburger_btn.add_css_class("flat"); hamburger_btn.add_css_class("control-panel-btn"); hamburger_btn.add_css_class("stat-pair"); hamburger_btn.add_css_class("icon-only"); bar_chip(&hamburger_btn); // Slider signals — use Rc> to suppress feedback during data load let panel_loading = Rc::new(Cell::new(false)); let loading_v = panel_loading.clone(); let vol_lbl_live = vol_lbl.clone(); panel_vol_slider.connect_value_changed(move |s| { vol_lbl_live.set_label(&format!("{:.0}", s.value() * 100.0)); if loading_v.get() { return; } bar::control::spawn_set_volume(s.value()); }); let loading_b = panel_loading.clone(); panel_bright_slider.connect_value_changed(move |s| { if loading_b.get() { return; } bar::control::spawn_set_brightness(s.value()); }); // `hamburger_btn` is appended in "Assemble" below, per // `[bar.slots].right`. // Standalone layer windows — below the island, slid in by Hyprland. let panels = panel::PanelSet::new( &monitor_name, &connectivity_inner, &panel_inner, &media_controls_box, ); let sender_conn = sender.clone(); panels.connectivity.connect_map(move |_| { bar::wifi::spawn_popover_load(sender_conn.clone()); bar::bluetooth::spawn_popover_load(sender_conn.clone()); }); let sender_cp = sender.clone(); panels.control.connect_map(move |_| { bar::control::spawn_load(sender_cp.clone()); }); { let panels = panels.clone(); let win = panels.connectivity.clone(); let gesture = gtk4::GestureClick::new(); gesture.connect_released(move |_, _, _, _| { panels.toggle(&win); }); connectivity_pair.add_controller(gesture); } { let panels = panels.clone(); let win = panels.control.clone(); hamburger_btn.connect_clicked(move |_| { panels.toggle(&win); }); } { let panels = panels.clone(); let win = panels.control.clone(); let vol_gesture = gtk4::GestureClick::new(); vol_gesture.connect_released(move |_, _, _, _| { panels.toggle(&win); }); vol_box.add_controller(vol_gesture); } { let panels = panels.clone(); let win = panels.media.clone(); let mgesture = gtk4::GestureClick::new(); mgesture.connect_released(move |_, _, _, _| { panels.toggle(&win); }); media_widget.add_controller(mgesture); } // ── Assemble: slot-driven module + widget order (plan §11 Phase 3b) ── // Every module widget above is already fully built; only the ORDER // it lands in its container, and which of left/centre/right it // lands in, comes from the theme manifest's `[bar.slots]` now. A // `widget:` slot entry gets (or creates) a Lua widget // container at that exact position — `` is either a // `WidgetPlacement` alias or a Lua module name; see // `bar::slots::widget_slot_container` and `reconcile_widgets`' // routing below. This is how a Lua widget can land in ANY slot, // not just the four fixed positions Phase 3a shipped with. let bar_shell_theme = theme::shell_theme(); let bar_slots = bar_shell_theme.slots(); let mut bar_modules = bar::slots::ModuleRegistry::new(); bar_modules.register("workspaces", &workspace_trail.overlay); bar_modules.register("media", &media_widget); // `modules.clock.style`: "flip" (default, liquid-motion) registers // the existing per-digit clock_box unchanged; "plain" (glass- // workbench) registers clock_plain_box instead and reveals date_lbl // per `show_date` — clock_box/clock_digits are still fully built in // that case, just never placed in any slot. "none" (Phase 6+, // unused today) registers neither. match bar_shell_theme.modules().clock.style { ClockStyle::Plain => { date_lbl.set_visible(bar_shell_theme.modules().clock.show_date); bar_modules.register("clock", &clock_plain_box); } ClockStyle::Flip => bar_modules.register("clock", &clock_box), ClockStyle::None => {} } bar_modules.register("volume", &vol_box); bar_modules.register("wifi", &connectivity_pair); bar_modules.register("battery", &bat_box); bar_modules.register("control", &hamburger_btn); // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass-workbench): // registered unconditionally, same as every other module — a theme // that never names "cpu"/"ram" in a slot (liquid-motion) just never // walks these entries in `for_each_in_slot` below, so they stay // built but unparented, exactly like `media_widget` does for // 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 // it's keyed here so `reconcile_widgets`' routing finds it the same // way as any slot-driven widget container. let mut widget_containers: std::collections::HashMap = std::collections::HashMap::new(); widget_containers.insert("tray".to_string(), widget_tray_box); bar_modules.for_each_in_slot( &bar_slots.left, |_, widget| workspace_row.append(widget), |key| workspace_row.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), ); bar_modules.for_each_in_slot( &bar_slots.centre, |_, widget| center_area.append(widget), |key| center_area.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), ); bar_modules.for_each_in_slot( &bar_slots.right, |_, widget| stats_box.append(widget), |key| stats_box.append(&bar::slots::widget_slot_container(&mut widget_containers, key)), ); // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); widgets.center_box.set_start_widget(Some(&workspace_row)); 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); // set_size_request is a MINIMUM, not a maximum: GTK still allocates a // visible box its natural height, and this layer-shell surface has no // fixed height, so the window grew to fit the whole results list and // the capsule sat open-at-idle showing a stray row. A hidden widget // requests no size at all, which is what "collapsed" actually needs. // open_fn/close_fn toggle this back on/off around the height animation. widgets.drawer_box.set_visible(false); // ── 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 // 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)); // 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; let monitor_name_for_dismiss = monitor_name.clone(); move || { if !launcher_open.get() { launcher_open.set(true); // Reveal before measuring/animating: while hidden the box // reports no natural height, so the open animation would // target 0 and nothing would appear. drawer_box.set_visible(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. `hole` scopes that // dead zone to the capsule's own column instead of the // full screen width ("it only sometimes is dismissed // when you click somewhere else") — see // `capsule_dismiss_hole`'s doc comment. Falls back to // the old full-width dead zone if the live geometry // query fails for any reason. let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then( |center_x| { let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?; Some(capsule_dismiss_hole(center_x, origin_x, search_width)) }, ); panels.show_capsule_dismiss(capsule_dismiss_margin, hole); } 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 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; } 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())); } 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 // 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>); } }); 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| { 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(); }); } { 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); } // "you can't close it using escape unless you are focused on the // UI" / "it doesn't grab keyboard for typing": both are the same // root cause as the startup-open bug above, from the other side. // `launcher_entry.set_can_focus(false)` means nothing (startup or // otherwise) can silently steal GTK's own notion of focus any // more — so it now needs a real, explicit grab. This click gesture // is that grab for the mouse path: a `GestureClick` press is a // genuine user-originated pointer event delivered through the // compositor, which is also exactly the kind of interaction // `KeyboardMode::OnDemand` (gtk4-layer-shell/wlr-layer-shell) is // documented to react to — the protocol spec (wlr-layer-shell- // unstable-v1.xml) leaves *when* an on-demand surface gets the // compositor's keyboard focus as "implementation-defined", but a // literal click on the surface is the universal, minimum-common- // denominator trigger every compositor implementation reacts to // (it's the same interaction wofi/fuzzel/rofi-wayland rely on). // Flipping `can_focus` back on right before `grab_focus()` mirrors // `AppInput::OpenLauncher`'s handler below, which needs the exact // same two-liner for the hotkey/command path. { let entry_for_click = launcher_entry.clone(); let click = gtk4::GestureClick::new(); click.connect_pressed(move |_, _, _, _| { entry_for_click.set_can_focus(true); entry_for_click.grab_focus(); }); launcher_entry.add_controller(click); } { 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 // stops propagation before a bubble-phase controller ever runs, so // Enter silently did nothing and the selected app never launched. // Capturing puts this controller ahead of the entry's own handling // for every key it cares about (Return/Up/Down/Escape), and keys it // doesn't claim still Proceed to the entry for normal text input. 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(); 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); } // 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(); 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. let control_panel_for_screenshot = panels.control.clone(); let connectivity_panel_for_screenshot = panels.connectivity.clone(); let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); 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 // creates a second primary on the laptop. Idle reconcile after. let satellites = Vec::new(); if init.primary && screenshot_req.is_none() { let later = sender.clone(); gtk4::glib::idle_add_local_once(move || { later.input(AppInput::ReconcileMonitors); }); } let model = App { monitor: monitor_name, primary: init.primary, satellites, workspaces: vec![], active_ws: 1, workspace_box, workspace_trail, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), clock_digits, date_lbl, clock_plain_lbl, launcher_entry, launcher_open, system_stats_box, system_sep, cpu_pair, mem_pair, pwr_pair, gpu_pair, cpu_lbl, mem_lbl, pwr_lbl, bar_cpu_lbl, bar_ram_lbl, gpu_lbl, vol_lbl, bat_lbl, bat_img, bat_textures, ac_img, bt_img, bt_textures, wifi_lbl, wifi_img, wifi_pane, crumbs_status: None, wifi_popover_data: None, wifi_profile: None, current_ssid: "—".to_string(), bt_pane, bt_popover_data: None, media_widget, media_track_lbl, media_play_icon, media_last: None, media_paused_at: None, panel_vol_slider, panel_bright_slider, panel_loading, sink_box, sink_section, tray_section, tray_sep, tray_box, tray_items: std::collections::HashMap::new(), widget_containers, dropped_widget_warned: std::collections::HashSet::new(), widget_tray_section, widget_tray_sep, panels, }; theme::apply(); theme::bind_output(&root, &model.monitor); bar::workspaces::spawn_watcher(sender.clone()); bar::clock::spawn_ticker(sender.clone()); bar::stats::spawn_poller(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); 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 // only the primary instance arms it. theme::watch_hot_reload(); } // Screenshot mode primes these with sample content instead of the // real D-Bus/pactl/backlight sources — see notifications::SampleKind // and osd::SampleKind's doc comments. let notif_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "notification" => Some(notifications::SampleKind::Normal), "notification-critical" => Some(notifications::SampleKind::Critical), _ => None, }); let notification_window = if init.primary { Some(notifications::spawn(notif_sample)) } else { None }; let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { "osd-volume" => Some(osd::SampleKind::Volume), "osd-brightness" => Some(osd::SampleKind::Brightness), _ => None, }); let osd_window = if init.primary { Some(osd::spawn(osd_sample)) } else { None }; if let Some(req) = screenshot_req { let notification_window = notification_window.filter(|_| { matches!(req.view.as_str(), "notification" | "notification-critical") }); let osd_window = osd_window.filter(|_| matches!(req.view.as_str(), "osd-volume" | "osd-brightness")); screenshot::dispatch( &root, req, screenshot::Handles { control_panel: control_panel_for_screenshot, connectivity_panel: connectivity_panel_for_screenshot, wifi_tab_btn: wifi_tab_btn_for_screenshot, bt_tab_btn: bt_tab_btn_for_screenshot, media_panel: media_panel_for_screenshot, media_widget: media_widget_for_screenshot, media_track_lbl: media_track_lbl_for_screenshot, notification_window, osd_window, launcher_entry: launcher_entry_for_screenshot, drawer_box: drawer_box_for_screenshot, }, ); } ComponentParts { model, widgets } } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { AppInput::WorkspaceSync { workspaces, actives, } => { let mut sorted = workspaces; sorted.sort_by_key(|w| w.id); let new_active = actives .get(&self.monitor) .copied() .unwrap_or(self.active_ws); // Workspace also carries last-window title/address, which // change constantly. Only the visible row (id/name/monitor/ // occupied) should rebuild the pills — otherwise a title // flicker on switch cancels the trail mid-stretch. let rows_changed = visible_ws_rows(&sorted, &self.monitor, new_active) != visible_ws_rows(&self.workspaces, &self.monitor, self.active_ws); let active_changed = new_active != self.active_ws; self.workspaces = sorted; if self.primary { self.reconcile_satellites(); } if rows_changed { self.active_ws = new_active; self.rebuild_buttons(active_changed); } else if active_changed { let from = self.button_map.get(&self.active_ws).cloned(); if let Some(old) = &from { old.remove_css_class("active"); } self.active_ws = new_active; if let Some(btn) = self.button_map.get(&self.active_ws).cloned() { btn.add_css_class("active"); // Trail style only: pill/dots never call place()/ // stretch() at all — the "active" CSS class above is // the whole of their active-workspace treatment // (solid accent fill, no trail overlay). if theme::shell_theme().modules().workspaces.style == WorkspaceStyle::Trail { self.workspace_trail.stretch(from.as_ref(), &btn); } } } } AppInput::MonitorAdded(name) => { if !self.primary || name == self.monitor { return; } if self.satellites.iter().any(|(n, _)| n == &name) { return; } if let Some(ctrl) = spawn_satellite(&name) { self.satellites.push((name, ctrl)); } } AppInput::MonitorRemoved(name) => { drop_satellite(&mut self.satellites, &name); } AppInput::ClockTick => { self.time_str = bar::clock::current(); self.date_lbl.set_label(&bar::clock::date()); let clock_module = theme::shell_theme().modules().clock.clone(); match clock_module.style { // Plain (glass-workbench): one label, no flip animation // — `flip_clock_digits` would just be wasted work (and // a pointless 450ms `play_once` timer) on digits that // are never on screen. ClockStyle::Plain => { self.clock_plain_lbl .set_label(&bar::clock::formatted(&clock_module.format)); } // Flip (default, liquid-motion) and None both keep // exactly today's per-digit-flip update — None has no // module in a slot to display it, but there's no reason // to special-case skipping the (cheap, idempotent) work. ClockStyle::Flip | ClockStyle::None => { 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 { Some(t) => format!("{} · {:.0}°", stats.cpu, t), None => stats.cpu, }; self.cpu_lbl.set_label(&cpu); self.mem_lbl.set_label(&stats.mem); self.pwr_lbl.set_label(&stats.power); // `[bar.slots].right = [..., "cpu", "ram", ...]` (glass- // workbench): same formatted text, separate chip instances // (see the App struct field docs for why). self.bar_cpu_lbl.set_label(&cpu); self.bar_ram_lbl.set_label(&stats.mem); match stats.gpu_usage { Some(g) => { let gpu = match stats.gpu_temp { Some(t) => format!("{g}% · {t:.0}°"), None => format!("{g}%"), }; self.gpu_lbl.set_label(&gpu); self.gpu_pair.set_visible(true); } None => self.gpu_pair.set_visible(false), } self.system_sep.set_visible(false); tick_label(&self.vol_lbl, &stats.volume_pct.to_string()); self.vol_lbl.set_tooltip_text(Some(&format!("volume {}%", stats.volume_pct))); tick_label(&self.bat_lbl, &stats.bat); if let Some(tex) = self.bat_textures.get(&(stats.bat_icon.as_ptr() as usize)) { self.bat_img.set_paintable(Some(tex)); } let bat_tip = if stats.ac_connected { format!("{}% · charging", stats.bat) } else { format!("{}%", stats.bat) }; self.bat_img.set_tooltip_text(Some(&bat_tip)); self.ac_img.set_visible(false); if let Some(tex) = self.bt_textures.get(&(stats.bt_icon.as_ptr() as usize)) { self.bt_img.set_paintable(Some(tex)); } self.current_ssid = stats.wifi_ssid.clone(); if stats.wifi_profile.is_some() { self.wifi_profile = stats.wifi_profile; } self.apply_wifi_label(); let internet_ok = self .crumbs_status .as_ref() .map(|s| s.internet && !s.captive_portal) .unwrap_or(true); let icon = if !internet_ok && stats.wifi_ssid != "—" { bar::stats::WIFI_ICON_OFF } else { stats.wifi_icon }; self.wifi_img.set_icon_name(Some(icon)); } AppInput::TrayUpdate(bar::tray::TrayUpdate::Add { id, icon, title }) => { if self.tray_items.contains_key(&id) { return; } let btn = gtk4::Button::new(); btn.add_css_class("tray-btn"); btn.set_child(Some(&bar::tray::make_tray_image(icon.as_ref()))); if !title.is_empty() { btn.set_tooltip_text(Some(&title)); } let id_click = id.clone(); btn.connect_clicked(move |_| bar::tray::spawn_activate(id_click.clone())); self.tray_box.append(&btn); self.tray_items.insert(id, btn); self.tray_section.set_visible(true); self.tray_sep.set_visible(true); } AppInput::TrayUpdate(bar::tray::TrayUpdate::Remove { id }) => { if let Some(btn) = self.tray_items.remove(&id) { self.tray_box.remove(&btn); } let has_items = !self.tray_items.is_empty(); self.tray_section.set_visible(has_items); self.tray_sep.set_visible(has_items); } AppInput::CrumbsStatus(status) => { self.crumbs_status = Some(status); // CrumbsStatus and WifiPopoverData arrive independently (different // pollers); whichever lands second must still repaint the header — // otherwise it only shows up if CrumbsStatus happens to win the race. if self.wifi_popover_data.is_some() { self.rebuild_wifi_popover(&sender); } } AppInput::WifiPopoverData(data) => { self.wifi_popover_data = Some(data); self.rebuild_wifi_popover(&sender); } AppInput::BtPopoverData(data) => { self.bt_popover_data = Some(data); self.rebuild_bt_popover(); } AppInput::SetProfile(name) => { self.wifi_profile = Some(name); self.apply_wifi_label(); } AppInput::MediaUpdate(state) => { if state.has_player { let label = if state.artist.is_empty() { state.title.clone() } else { format!("{} · {}", state.artist, state.title) }; self.media_track_lbl.set_label(&label); let icon_svg = if state.playing { asset!("Pause.svg") } else { asset!("Play.svg") }; self.media_play_icon .set_paintable(Some(&svg_texture(icon_svg))); prepare_icon( &self.media_play_icon, theme::shell_theme().tokens().icon_px() as i32, ); if state.playing { self.media_widget.add_css_class("playing"); } else { self.media_widget.remove_css_class("playing"); } if state.playing { self.media_paused_at = None; } else if self.media_paused_at.is_none() { self.media_paused_at = Some(std::time::Instant::now()); } let within_linger = self .media_paused_at .is_none_or(|t| t.elapsed().as_secs() < 30 * 60); self.media_last = Some(state); reveal_media(&self.media_widget, within_linger); } else { // Player gone — honour linger from last pause self.media_widget.remove_css_class("playing"); if let Some(paused_at) = self.media_paused_at { if paused_at.elapsed().as_secs() < 30 * 60 { reveal_media(&self.media_widget, true); } else { reveal_media(&self.media_widget, false); self.media_last = None; self.media_paused_at = None; } } else { reveal_media(&self.media_widget, false); self.media_last = None; } } } AppInput::ControlPanelData(data) => { // Suppress slider value-changed signals during programmatic update self.panel_loading.set(true); self.panel_vol_slider.set_value(data.volume); self.panel_bright_slider.set_value(data.brightness); self.panel_loading.set(false); self.rebuild_sinks(&data.sinks, &sender); } AppInput::WidgetsUpdate(specs) => { self.reconcile_widgets(specs); } AppInput::ReconcileMonitors => { if self.primary { self.reconcile_satellites(); } } AppInput::DismissPanels => { self.panels.hide_all(); } AppInput::OpenLauncher => { // Only the primary instance subscribes to the open command // (`launcher_command::spawn`), but `self.monitor` here is // whichever output was focused ONCE, at this instance's own // `init()` — baked in at process start, not re-resolved on // every keybind press. If the user has since moved focus to // a different monitor, blindly grabbing focus on `self` // would open the capsule on the wrong screen. Re-resolve // the focused monitor now and route to whichever instance // actually owns it. let satellite_names: Vec<&str> = self.satellites.iter().map(|(n, _)| n.as_str()).collect(); let focused = primary_hypr_monitor(); match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names) { LauncherRoute::Satellite(name) => { // resolve_launcher_route only returns a name present in // satellite_names, so this lookup cannot miss. if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) { ctrl.sender().emit(AppInput::OpenLauncher); } } LauncherRoute::Local => { // See `launcher_entry.set_can_focus(false)`'s own // comment above: a hotkey/command-triggered open // has no pointer click to flip this back on, so // this path has to do it itself or `grab_focus()` // below is a silent no-op. self.launcher_entry.set_can_focus(true); self.launcher_entry.grab_focus(); } } } } } } /// The `widget:` alias `for_each_in_slot` recognizes for each /// `WidgetPlacement` variant — the fallback a `WidgetSpec` routes through /// when no `widget:` container claims its module name specifically. /// Kept in one place since both the builtin manifest's slot lists and /// `reconcile_widgets`' routing below must agree on these names. fn placement_alias(placement: bread_shared::widget::WidgetPlacement) -> &'static str { use bread_shared::widget::WidgetPlacement::*; match placement { Tray => "tray", LeftOfClock => "left_of_clock", RightOfClock => "right_of_clock", RightOfWorkspaces => "right_of_workspaces", LeftOfStats => "left_of_stats", } } impl App { fn reconcile_widgets(&mut self, specs: Vec) { for container in self.widget_containers.values() { while let Some(child) = container.first_child() { container.remove(&child); } } // Route each spec to a widget_containers entry: a `widget:` // slot entry (keyed by WidgetSpec::module) takes priority over the // spec's placement alias, so a theme can retarget one Lua module's // widgets without moving every widget that shares its placement. // A spec whose module AND placement alias both lack a container // (e.g. a theme's slots omit that placement's widget: entry // entirely) is logged and dropped rather than silently vanishing — // WidgetPlacement itself never changes; only which container (if // any) each spec lands in does. let mut by_container: std::collections::HashMap< String, Vec<&bread_shared::widget::WidgetSpec>, > = std::collections::HashMap::new(); for spec in &specs { let key = if self.widget_containers.contains_key(&spec.module) { spec.module.clone() } else { placement_alias(spec.placement).to_string() }; if self.widget_containers.contains_key(&key) { by_container.entry(key).or_default().push(spec); } else if self.dropped_widget_warned.insert(spec.id.clone()) { // Warn ONCE per widget id, not once per reconcile: breadd // re-pushes every spec on each update (a widget on a timer, // like a git-branch poller, reconciles continuously), which // turned a legitimate one-off diagnostic into unbounded log // spam. The set is only added to, so a spec that starts // resolving again after a theme switch stays quiet — the // message is about a theme lacking the slot, and repeating it // every tick tells the reader nothing new. eprintln!( "breadbar: widget '{}' (module '{}', placement {:?}) has no matching \ [bar.slots] widget: container — dropping", spec.id, spec.module, spec.placement ); } } let mut has_tray_widgets = false; for (key, mut group) in by_container { let container = &self.widget_containers[&key]; group.sort_by_key(|s| s.order); for spec in group { if !spec.visible { continue; } if key == "tray" { has_tray_widgets = true; } let node = widgets::build_node(&spec.root, &spec.id); if let Some(tooltip) = &spec.tooltip { node.set_tooltip_text(Some(tooltip)); } container.append(&node); } } // The "tray" container has its own section/separator (handled // below, same as the existing SNI tray items) — an empty inline // slot has no such wrapper, so it must hide itself to stop // contributing to its parent box's `spacing` gap. for (key, container) in &self.widget_containers { if key == "tray" { continue; } container.set_visible(container.first_child().is_some()); } self.widget_tray_section.set_visible(has_tray_widgets); self.widget_tray_sep.set_visible(has_tray_widgets); } fn reconcile_satellites(&mut self) { let live: Vec = hypr_monitor_names() .into_iter() .filter(|n| n != &self.monitor) .collect(); let stale: Vec = self .satellites .iter() .filter_map(|(n, _)| { if live.contains(n) { None } else { Some(n.clone()) } }) .collect(); for name in stale { drop_satellite(&mut self.satellites, &name); } for name in live { if self.satellites.iter().any(|(n, _)| n == &name) { continue; } if let Some(ctrl) = spawn_satellite(&name) { self.satellites.push((name, ctrl)); } } } fn rebuild_sinks( &mut self, sinks: &[bar::control::AudioSink], sender: &ComponentSender, ) { while let Some(child) = self.sink_box.first_child() { self.sink_box.remove(&child); } self.sink_section.set_visible(!sinks.is_empty()); for (i, sink) in sinks.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); row.add_css_class("sink-row"); if sink.is_default { row.add_css_class("wifi-popover-row-active"); } stagger_row(&row, i); let lbl = gtk4::Label::new(Some(&sink.description)); lbl.set_xalign(0.0); lbl.set_hexpand(true); lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End); lbl.set_max_width_chars(22); lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let name = sink.name.clone(); let sender = sender.clone(); row.connect_clicked(move |_| { bar::control::spawn_set_sink(name.clone(), sender.clone()); }); self.sink_box.append(&row); } } fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), None => self.current_ssid.clone(), }; self.wifi_lbl.set_label(&label); self.wifi_img.set_tooltip_text(Some(&label)); } fn rebuild_buttons(&mut self, animate: bool) { self.workspace_trail.cancel(); let prev: std::collections::HashSet = self.button_map.keys().copied().collect(); while let Some(child) = self.workspace_box.first_child() { self.workspace_box.remove(&child); } self.button_map.clear(); let modules = theme::shell_theme().modules().clone(); let ws_style = modules.workspaces.style; let show_empty = modules.workspaces.show_empty; for ws in &self.workspaces { if ws.monitor != self.monitor { continue; } let empty = ws.windows == 0 && ws.id != self.active_ws; if empty { match ws_style { // Trail (default, liquid-motion): unconditionally off // the bar, exactly as before this change — regardless // of `show_empty`, which liquid-motion's own manifest // declares `true` but this style has never consumed. // Changing that now would be a real, undesired // liquid-motion regression, not a Phase 5 fix. WorkspaceStyle::Trail => continue, // Pill/Dots: honour `show_empty` for real — demo 02's // pills render an unoccupied, non-active workspace at // reduced opacity via the `.workspace-btn:not(.occupied) // :not(.active)` CSS rule rather than hiding it. _ => { if !show_empty { continue; } } } } 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); } self.workspace_box.append(&btn); self.button_map.insert(ws.id, btn); } // Trail style only: pill/dots never call place()/stretch()/clear() // at all — the "active" CSS class `make_button` already applies is // the whole of their active-workspace treatment. if ws_style == WorkspaceStyle::Trail { match self.button_map.get(&self.active_ws).cloned() { Some(btn) if animate => self.workspace_trail.stretch(None, &btn), Some(btn) => self.workspace_trail.place(&btn), None => self.workspace_trail.clear(), } } } fn rebuild_wifi_popover(&mut self, sender: &ComponentSender) { let panels = self.panels.clone(); while let Some(child) = self.wifi_pane.first_child() { self.wifi_pane.remove(&child); } if let Some(st) = &self.crumbs_status { let header = gtk4::Box::new(gtk4::Orientation::Vertical, 2); header.add_css_class("wifi-popover-header"); header.set_margin_bottom(6); let ssid_str = st.ssid.as_deref().filter(|s| !s.is_empty()).unwrap_or("—"); let ssid_lbl = gtk4::Label::new(Some(ssid_str)); ssid_lbl.add_css_class("wifi-popover-ssid"); ssid_lbl.set_xalign(0.0); header.append(&ssid_lbl); if let Some(ip) = &st.ip { let ip_lbl = gtk4::Label::new(Some(ip.as_str())); ip_lbl.add_css_class("wifi-popover-ip"); ip_lbl.set_xalign(0.0); header.append(&ip_lbl); } let mut parts = Vec::new(); if st.captive_portal { parts.push("captive portal"); } else if st.internet { parts.push("internet ✓"); } else { parts.push("internet ✗"); } if st.tailscale_required { parts.push(if st.tailscale_ok { "tailscale ✓" } else { "tailscale ✗" }); } let status_lbl = gtk4::Label::new(Some(&parts.join(" "))); status_lbl.add_css_class("wifi-popover-status"); status_lbl.set_xalign(0.0); header.append(&status_lbl); self.wifi_pane.append(&header); self.wifi_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); } let nh = gtk4::Label::new(Some("NETWORKS")); nh.add_css_class("wifi-popover-section"); nh.set_xalign(0.0); nh.set_margin_top(2); nh.set_margin_bottom(4); self.wifi_pane.append(&nh); let data = self.wifi_popover_data.as_ref(); let scan_ready = data.map(|d| d.scan_ready).unwrap_or(false); let scan = data.map(|d| d.scan.as_slice()).unwrap_or(&[]); let profiles = data .map(|d| unique_profiles(&d.profiles)) .unwrap_or_default(); if !scan_ready { let lbl = gtk4::Label::new(Some("Scanning…")); lbl.add_css_class("wifi-popover-loading"); lbl.set_xalign(0.0); self.wifi_pane.append(&lbl); } else if scan.is_empty() { let lbl = gtk4::Label::new(Some("No networks found")); lbl.add_css_class("wifi-popover-loading"); lbl.set_xalign(0.0); self.wifi_pane.append(&lbl); } else { for (i, entry) in scan.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); stagger_row(&row, i); if !entry.saved { row.add_css_class("wifi-popover-row-unsaved"); } let is_current = entry.ssid == self.current_ssid; if is_current { row.add_css_class("wifi-popover-row-active"); } let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let img = gtk4::Image::from_icon_name(wifi_icon_for_signal(entry.signal)); prepare_icon(&img, 18); img.add_css_class("stat-icon"); row_box.append(&img); let lbl = gtk4::Label::new(Some(&entry.ssid)); lbl.set_xalign(0.0); lbl.set_hexpand(true); lbl.set_valign(gtk4::Align::Center); row_box.append(&lbl); row.set_child(Some(&row_box)); let ssid_clone = entry.ssid.clone(); let saved = entry.saved; let panels = panels.clone(); row.connect_clicked(move |btn| { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } panels.hide_all(); }); self.wifi_pane.append(&row); } } if !profiles.is_empty() { let ph = gtk4::Label::new(Some("PROFILES")); ph.add_css_class("wifi-popover-section"); ph.set_xalign(0.0); ph.set_margin_top(10); ph.set_margin_bottom(4); self.wifi_pane.append(&ph); for (i, (name, active)) in profiles.into_iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); stagger_row(&row, i); if active { row.add_css_class("wifi-popover-row-active"); } let lbl = gtk4::Label::new(Some(&name)); lbl.set_xalign(0.0); lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let name_clone = name.clone(); let sender_clone = sender.clone(); let panels = panels.clone(); row.connect_clicked(move |_| { sender_clone.input(AppInput::SetProfile(name_clone.clone())); bar::wifi::spawn_profile_set(name_clone.clone()); panels.hide_all(); }); self.wifi_pane.append(&row); } } } fn rebuild_bt_popover(&mut self) { while let Some(child) = self.bt_pane.first_child() { self.bt_pane.remove(&child); } let Some(data) = &self.bt_popover_data else { let lbl = gtk4::Label::new(Some("Loading…")); lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); return; }; // Power toggle row let toggle_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); toggle_row.set_margin_bottom(4); let toggle_lbl = gtk4::Label::new(Some("Bluetooth")); toggle_lbl.add_css_class("wifi-popover-ssid"); toggle_lbl.set_hexpand(true); toggle_lbl.set_xalign(0.0); let toggle_switch = gtk4::Switch::new(); toggle_switch.add_css_class("bt-switch"); toggle_switch.set_active(data.powered); toggle_switch.set_valign(gtk4::Align::Center); toggle_switch.connect_state_set(|_, on| { bar::bluetooth::spawn_set_powered(on); gtk4::glib::Propagation::Proceed }); toggle_row.append(&toggle_lbl); toggle_row.append(&toggle_switch); self.bt_pane.append(&toggle_row); self.bt_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); if !data.powered { let lbl = gtk4::Label::new(Some("Bluetooth is off")); lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else if data.devices.is_empty() { let lbl = gtk4::Label::new(Some("No paired devices")); lbl.add_css_class("wifi-popover-loading"); self.bt_pane.append(&lbl); } else { let dh = gtk4::Label::new(Some("PAIRED")); dh.add_css_class("wifi-popover-section"); dh.set_xalign(0.0); dh.set_margin_top(2); dh.set_margin_bottom(4); self.bt_pane.append(&dh); for (i, dev) in data.devices.iter().enumerate() { let row = gtk4::Button::new(); row.add_css_class("flat"); row.add_css_class("wifi-popover-row"); stagger_row(&row, i); if dev.connected { row.add_css_class("wifi-popover-row-active"); } let lbl = gtk4::Label::new(Some(&dev.name)); lbl.set_xalign(0.0); lbl.set_valign(gtk4::Align::Center); row.set_child(Some(&lbl)); let address = dev.address.clone(); let connected = dev.connected; row.connect_clicked(move |_| { if connected { bar::bluetooth::spawn_disconnect(address.clone()); } else { bar::bluetooth::spawn_connect(address.clone()); } }); self.bt_pane.append(&row); } } self.bt_pane .append(>k4::Separator::new(gtk4::Orientation::Horizontal)); let settings_row = gtk4::Button::new(); settings_row.add_css_class("flat"); settings_row.add_css_class("wifi-popover-row"); let settings_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); let settings_icon = svg_image(bar::stats::ICON_BT_SETTINGS); settings_icon.add_css_class("stat-icon"); settings_box.append(&settings_icon); settings_box.append(>k4::Label::new(Some("Bluetooth settings"))); settings_row.set_child(Some(&settings_box)); settings_row.connect_clicked(|_| { relm4::spawn(async { let _ = tokio::process::Command::new("blueman-manager").spawn(); }); }); self.bt_pane.append(&settings_row); } } // ── Helpers ─────────────────────────────────────────────────────────────────── fn build_slider_row(label: &str, min: f64, max: f64, step: f64) -> (gtk4::Box, gtk4::Scale) { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); row.add_css_class("control-panel-row"); let lbl = gtk4::Label::new(Some(label)); lbl.add_css_class("control-panel-row-label"); lbl.set_xalign(0.0); lbl.set_width_chars(3); let slider = gtk4::Scale::with_range(gtk4::Orientation::Horizontal, min, max, step); slider.set_draw_value(false); slider.set_hexpand(true); slider.set_width_request(160); slider.add_css_class("control-panel-slider"); row.append(&lbl); row.append(&slider); (row, slider) } fn wifi_icon_for_signal(pct: u8) -> &'static str { use bar::stats::{ WIFI_ICON_EXCELLENT, WIFI_ICON_GOOD, WIFI_ICON_OFF, WIFI_ICON_OK, WIFI_ICON_WEAK, }; match pct { 75..=100 => WIFI_ICON_EXCELLENT, 50..=74 => WIFI_ICON_GOOD, 25..=49 => WIFI_ICON_OK, 1..=24 => WIFI_ICON_WEAK, _ => WIFI_ICON_OFF, } } /// Small modal prompting for a password, then saves + joins the network via /// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly /// built dialog *before* it's presented — screenshot mode's only hook point, /// since `connect_map` registered any later would miss a map that already /// happened. The real call site passes a no-op. fn show_add_network_dialog( anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window), ) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); dialog.add_css_class("wifi-add-dialog"); if let Some(output) = bread_theme::gtk::output_for_widget(anchor) { theme::bind_output(&dialog, &output); } else { theme::bind_auto(&dialog); } // A bare gtk4::Window with no titlebar set falls back to GTK's own // minimal CSD: a flat bar with plain system-font title text and no // rounding — which is what actually made this look like a stray window // from a different decade next to the rest of the (rounded, borderless, // shadowed) ecosystem. A real HeaderBar picks up the window's own title // automatically and gets the same `.wifi-add-dialog` theming below. let header = gtk4::HeaderBar::new(); header.set_show_title_buttons(true); dialog.set_titlebar(Some(&header)); if let Some(root) = anchor.root() { if let Ok(win) = root.downcast::() { dialog.set_transient_for(Some(&win)); dialog.set_modal(true); } } let body = gtk4::Box::new(gtk4::Orientation::Vertical, 8); body.set_margin_top(12); body.set_margin_bottom(12); body.set_margin_start(12); body.set_margin_end(12); let lbl = gtk4::Label::new(Some(&format!("Password for {ssid}"))); lbl.set_xalign(0.0); body.append(&lbl); let entry = gtk4::PasswordEntry::new(); entry.set_show_peek_icon(true); body.append(&entry); let btn_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); btn_row.set_halign(gtk4::Align::End); let cancel_btn = gtk4::Button::with_label("Cancel"); let connect_btn = gtk4::Button::with_label("Connect"); // Not "suggested-action" — GTK4's own bundled theme special-cases that // class for a newer native OS-accent-colour feature that isn't a normal // CSS rule at all, and simply doesn't lose to any `background-color` // override this stylesheet adds, however specific the selector (already // confirmed empirically: adding a much more specific override rule had // zero effect). "confirm-button" is the same ecosystem-wide accent // button convention breadman/breadpad already use successfully. connect_btn.add_css_class("confirm-button"); btn_row.append(&cancel_btn); btn_row.append(&connect_btn); body.append(&btn_row); dialog.set_child(Some(&body)); let d = dialog.clone(); cancel_btn.connect_clicked(move |_| d.close()); let dialog_for_connect = dialog.clone(); let entry_for_connect = entry.clone(); let ssid_for_connect = ssid.clone(); connect_btn.connect_clicked(move |_| { let password = entry_for_connect.text().to_string(); if password.is_empty() { return; } bar::wifi::spawn_add_and_join(ssid_for_connect.clone(), password); dialog_for_connect.close(); }); let dialog_for_activate = dialog.clone(); let entry_for_activate = entry.clone(); let ssid_for_activate = ssid.clone(); entry.connect_activate(move |_| { let password = entry_for_activate.text().to_string(); if password.is_empty() { return; } bar::wifi::spawn_add_and_join(ssid_for_activate.clone(), password); dialog_for_activate.close(); }); on_build(&dialog); dialog.present(); entry.grab_focus(); } fn stat_pair(icon_svg: &str, label: >k4::Label) -> gtk4::Box { let pair = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); pair.add_css_class("stat-pair"); bar_chip(&pair); let img = svg_image(icon_svg); img.add_css_class("stat-icon"); pair.append(&img); pair.append(label); pair } /// Identity of the pills this bar actually draws. Ignores last-window /// title/address so a tab title change cannot rebuild the row. fn visible_ws_rows( workspaces: &[Workspace], monitor: &str, active: WorkspaceId, ) -> Vec<(WorkspaceId, String, bool)> { workspaces .iter() .filter(|w| w.monitor == monitor && (w.windows > 0 || w.id == active)) .map(|w| (w.id, w.name.clone(), w.windows > 0)) .collect() } fn bar_chip(widget: &impl IsA) { widget.set_valign(gtk4::Align::Center); widget.set_vexpand(false); } fn popover_caret() -> gtk4::Box { let caret = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); caret.add_css_class("popover-caret"); caret.set_hexpand(true); caret } fn stagger_row(widget: &impl IsA, i: usize) { widget.add_css_class("row-in"); widget.add_css_class(&format!("stagger-{}", i.min(11))); } fn play_once(widget: &impl IsA, class: &str, ms: u64) { widget.remove_css_class(class); widget.add_css_class(class); let w = widget.as_ref().clone(); let class = class.to_string(); gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(ms), move || { w.remove_css_class(&class); }); } fn make_clock_digits(time: &str) -> Vec { time.chars() .map(|ch| { let lbl = gtk4::Label::new(Some(&ch.to_string())); lbl.add_css_class("clock-digit"); if ch == ':' { lbl.add_css_class("clock-colon"); } lbl.set_valign(gtk4::Align::Center); lbl.set_vexpand(false); lbl.set_yalign(0.5); lbl }) .collect() } fn flip_clock_digits(digits: &[gtk4::Label], time: &str) { let chars: Vec = time.chars().collect(); for (i, lbl) in digits.iter().enumerate() { let next = chars.get(i).copied().unwrap_or(' '); let next_s = next.to_string(); if lbl.label().as_str() == next_s { continue; } lbl.set_label(&next_s); if next != ':' { play_once(lbl, "flip", 450); } } } fn tick_label(lbl: >k4::Label, text: &str) { if lbl.label().as_str() == text { return; } lbl.set_label(text); play_once(lbl, "tick", 360); } fn reveal_media(widget: >k4::Box, show: bool) { if show && !widget.is_visible() { play_once(widget, "media-in", 420); } 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| { // `spring_ease` deliberately overshoots past t=1.0 — that bounce is the // point on expand, but on a collapse (from=content height, to=0) the // same overshoot carries the interpolated value BELOW zero, and // `set_size_request` hard-asserts `height >= -1` (GTK-CRITICAL, once // per frame at 60fps). -1 is GTK's "use natural height" sentinel, not a // valid animation frame, so clamp to 0 rather than -1: a drawer mid- // collapse wants zero height, never its natural height. target.set_size_request(-1, h.max(0)); // Collapse finished: hide the box so it stops claiming natural height. // set_size_request is only a minimum, so a visible-but-zero-request // drawer still gets allocated its children's full height and holds the // capsule open. Hiding here rather than in close_fn keeps the collapse // animated instead of snapping shut on the first frame. if to == 0 && h <= 0 { target.set_visible(false); } }); *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 /// (`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 { // 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()) } } /// Pure half of the `.`-mode URL action: adds a scheme when the user typed /// a bare host (`example.com` -> `https://example.com`), leaves anything /// that already looks like `scheme://...` untouched. fn url_open_target(url: &str) -> String { if url.contains("://") { url.to_string() } else { format!("https://{url}") } } fn run_mode_action(action: &ModeAction) { match action { ModeAction::RunShell(cmd) => { if let Err(e) = std::process::Command::new("bash") .args(["-c", cmd]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { eprintln!("breadbar: failed to run mode command {cmd:?}: {e}"); } } 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 = url_open_target(url); if let Err(e) = std::process::Command::new("xdg-open") .arg(&target) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { eprintln!("breadbar: failed to open url {target:?}: {e}"); } } } } /// 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 { let btn = gtk4::ToggleButton::with_label(label); btn.add_css_class("popover-tab"); btn.set_hexpand(true); btn.set_valign(gtk4::Align::Center); btn.set_vexpand(false); btn.set_size_request(-1, theme::shell_theme().tokens().chip_height() as i32); if let Some(child) = btn.child() { child.set_halign(gtk4::Align::Center); child.set_valign(gtk4::Align::Center); child.set_hexpand(true); child.set_vexpand(false); if let Ok(lbl) = child.downcast::() { lbl.set_xalign(0.5); lbl.set_yalign(0.5); } } btn } /// breadcrumbs can list the same profile twice under different case /// (`Home` / `home`). Keep the active spelling when there is one. fn unique_profiles(profiles: &[(String, bool)]) -> Vec<(String, bool)> { let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for (name, active) in profiles { if *active { seen.insert(name.to_ascii_lowercase()); out.push((name.clone(), true)); } } for (name, active) in profiles { if seen.insert(name.to_ascii_lowercase()) { out.push((name.clone(), *active)); } } out } pub(crate) fn prepare_icon(img: >k4::Image, px: i32) { img.set_pixel_size(px); img.set_valign(gtk4::Align::Center); img.set_vexpand(false); } pub(crate) fn svg_image(svg_src: &str) -> gtk4::Image { svg_image_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) } pub(crate) fn svg_image_sized(svg_src: &str, px: u32) -> gtk4::Image { let img = gtk4::Image::from_paintable(Some(&svg_texture_sized(svg_src, px))); prepare_icon(&img, px as i32); img } pub(crate) fn svg_texture(svg_src: &str) -> gtk4::gdk::Texture { svg_texture_sized(svg_src, theme::shell_theme().tokens().icon_px() as u32) } /// Rasterise at 2× the display size so Lucide strokes stay sharp when GTK /// displays the texture at `px` via `Image::set_pixel_size`. pub(crate) fn svg_texture_sized(svg_src: &str, px: u32) -> gtk4::gdk::Texture { use resvg::{tiny_skia, usvg}; let raster = px.saturating_mul(2).max(1); let fg = theme::fg_color(); let dim = format!(r#"width="{raster}" height="{raster}""#); let svg = svg_src .replace("currentColor", &fg) .replace(r#"stroke-width="2""#, r#"stroke-width="2.35""#) .replace(r#"width="24" height="24""#, &dim); let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("parse svg"); let size = tree.size().to_int_size(); let (w, h) = (size.width(), size.height()); let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap"); resvg::render( &tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut(), ); let bytes = gtk4::glib::Bytes::from_owned(pixmap.take()); gtk4::gdk::MemoryTexture::new( w as i32, h as i32, gtk4::gdk::MemoryFormat::R8g8b8a8Premultiplied, &bytes, (w * 4) as usize, ) .upcast() } fn stat_label() -> gtk4::Label { let lbl = gtk4::Label::new(None); lbl.add_css_class("stat-label"); lbl.set_xalign(0.0); lbl } fn main() { use clap::Parser; let cli = screenshot::Cli::parse(); if cli.history { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime"); if let Err(e) = rt.block_on(notifications::toggle_history_remote()) { eprintln!("breadbar: could not toggle history (is breadbar running?): {e}"); std::process::exit(1); } return; } let screenshot_req = cli.screenshot_request(); relm4::spawn(async { use tokio::signal::unix::{signal, SignalKind}; let mut stream = signal(SignalKind::hangup()).expect("SIGHUP handler"); loop { stream.recv().await; gtk4::glib::MainContext::default().invoke(theme::apply); } }); // `with_args(vec![])` stops relm4 from handing our own --screenshot/ // --output flags to GLib's option parser (`app.run()`'s default), which // would otherwise reject them as unrecognized before Cli::parse() above // ever sees argv. allow_multiple_instances is needed for screenshot runs // specifically: GApplication is single-instance by default, and a normal // breadbar is typically already running, so without this a screenshot // invocation would just activate that existing instance instead of // starting a fresh one whose `init()` receives the request at all. let app = RelmApp::new("sh.breadway.breadbar").with_args(vec![]); if screenshot_req.is_some() { app.allow_multiple_instances(true); } app.run::(BarInit { screenshot: screenshot_req, monitor: None, primary: true, }); } /// Live outputs only. `hyprctl monitors all` (and the hyprland crate's /// `Monitors::get`) keep ghost connectors after a rename — `DVI-I-1` stayed /// at 0×0 with `disabled=false` after the panel became `DVI-I-2`, and a /// geometry fallback then stacked a second bar on the laptop. #[derive(Debug, Clone, serde::Deserialize)] struct HyprMon { name: String, x: i32, y: i32, #[serde(default)] focused: bool, #[serde(default)] disabled: bool, } fn hypr_monitors_live() -> Vec { let output = match std::process::Command::new("hyprctl") .args(["monitors", "-j"]) .output() { Ok(o) if o.status.success() => o.stdout, _ => return Vec::new(), }; serde_json::from_slice::>(&output) .unwrap_or_default() .into_iter() .filter(|m| !m.disabled) .collect() } fn primary_hypr_monitor() -> Option { let mons = hypr_monitors_live(); mons.iter() .find(|m| m.focused) .or_else(|| mons.first()) .map(|m| m.name.clone()) } /// Where `AppInput::OpenLauncher` should be actually handled: locally (this /// instance grabs its own capsule's focus), or forwarded to a specific /// satellite instance. Pure decision logic, split out of the `update` match /// arm so it's unit-testable without a live `App`/GTK/Hyprland stack. #[derive(Debug, Clone, PartialEq, Eq)] enum LauncherRoute { Local, Satellite(String), } /// `focused`: the currently-focused Hyprland monitor, re-queried at /// keybind-fire time (`None` if Hyprland's monitor query failed, e.g. /// screenshot mode). `own`: this instance's own monitor, fixed at `init()`. /// `satellites`: names of monitors this (necessarily primary) instance /// tracks a `Controller` for. /// /// Falls back to `Local` whenever forwarding isn't possible or isn't /// needed, so a caller can always make forward progress: no focused /// monitor, the focused monitor is this instance's own, or the focused /// monitor has no tracked satellite yet. fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute { match focused { Some(name) if name != own && satellites.contains(&name) => { LauncherRoute::Satellite(name.to_string()) } _ => LauncherRoute::Local, } } fn hypr_monitor_names() -> Vec { hypr_monitors_live() .into_iter() .map(|m| m.name) .collect() } fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> { hypr_monitors_live() .into_iter() .find(|m| m.name == name) .map(|m| (m.x, m.y)) } /// The bar/capsule's own layer-surface geometry for `monitor`, straight /// from the compositor (`hyprctl layers -j`, ground truth — not derived /// from anything GTK/gtk4-layer-shell reports client-side, since the /// wlr-layer-shell protocol never hands a client its own assigned x/y back; /// only width/height come through `configure`). Matched by `namespace` /// ("breadbar", set via `root.set_namespace` above), which is unique per /// output since each monitor gets its own bound `App` instance/window. /// Returns the surface's horizontal center in Hyprland's global coordinate /// space. `None` on any parse/lookup failure — callers must fall back to /// the pre-existing, safe-but-broader dead-zone behaviour rather than /// guess. fn hypr_capsule_center_x(monitor: &str) -> Option { let output = std::process::Command::new("hyprctl") .args(["layers", "-j"]) .output() .ok()?; if !output.status.success() { return None; } let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; let levels = root.get(monitor)?.get("levels")?.as_object()?; for arr in levels.values() { let Some(items) = arr.as_array() else { continue; }; for item in items { if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") { continue; } let x = item.get("x")?.as_i64()? as i32; let w = item.get("w")?.as_i64()? as i32; return Some(x + w / 2); } } None } /// The click-away scrim's capsule-column hole, in coordinates local to the /// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) — /// pure arithmetic, split out for unit testing. `capsule_center_global` and /// `monitor_origin_x` are both in Hyprland's global compositor space /// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the /// capsule's own *configured* search-state width /// (`[launcher].search_width`), not a live-queried one — this fires right /// as `open_fn` starts the width-animation from idle to search width, so a /// live query at that exact instant would catch it mid-transition. Using /// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does /// for the vertical bound) means the hole is never narrower than the /// capsule ever actually gets while the scrim is showing. fn capsule_dismiss_hole( capsule_center_global: i32, monitor_origin_x: i32, column_width: i32, ) -> (i32, i32) { let local_center = capsule_center_global - monitor_origin_x; (local_center - column_width / 2, column_width) } /// Hyprland connector names and GDK connector names can disagree after a /// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the /// output's origin — transform swaps width/height so size is not reliable. /// Never steal a GDK output whose connector is already a live Hyprland name. fn gdk_monitor_for_hypr(name: &str) -> Option { use gtk4::gdk::prelude::MonitorExt; use gtk4::gio::prelude::ListModelExt; let display = gtk4::gdk::Display::default()?; let list = display.monitors(); for i in 0..list.n_items() { let Some(mon) = list.item(i).and_downcast::() else { continue; }; if mon.connector().as_deref() == Some(name) { return Some(mon); } } let (hx, hy) = hypr_monitor_origin(name)?; let live = hypr_monitor_names(); for i in 0..list.n_items() { let Some(mon) = list.item(i).and_downcast::() else { continue; }; let g = mon.geometry(); if g.x() != hx || g.y() != hy { continue; } if let Some(conn) = mon.connector() { if live.iter().any(|n| n != name && n == conn.as_str()) { return None; } } return Some(mon); } None } pub(crate) fn bind_layer_monitor(window: &impl LayerShell, name: &str) -> bool { match gdk_monitor_for_hypr(name) { Some(mon) => { window.set_monitor(Some(&mon)); true } None => { eprintln!("breadbar: no GDK monitor for {name}"); false } } } fn spawn_satellite(name: &str) -> Option> { if gdk_monitor_for_hypr(name).is_none() { eprintln!("breadbar: skip bar on {name}: no matching GDK output"); return None; } let ctrl = App::builder() .launch(BarInit { screenshot: None, monitor: Some(name.to_string()), primary: false, }) .detach(); ctrl.widget().present(); Some(ctrl) } fn drop_satellite(satellites: &mut Vec<(String, Controller)>, name: &str) { satellites.retain(|(n, ctrl)| { if n == name { ctrl.widget().set_visible(false); false } else { true } }); } #[cfg(test)] mod launcher_route_tests { use super::{resolve_launcher_route, LauncherRoute}; #[test] fn focused_monitor_is_own_stays_local() { assert_eq!( resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]), LauncherRoute::Local ); } #[test] fn focused_monitor_is_tracked_satellite_forwards() { assert_eq!( resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]), LauncherRoute::Satellite("DVI-I-1".to_string()) ); } #[test] fn focused_monitor_with_no_tracked_satellite_falls_back_local() { // e.g. reconcile hasn't caught up with a very recent hotplug yet. assert_eq!( resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]), LauncherRoute::Local ); } #[test] fn no_focused_monitor_falls_back_local() { // Hyprland's monitor query failed (screenshot mode, hyprctl missing). assert_eq!( resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]), LauncherRoute::Local ); } } #[cfg(test)] mod capsule_dismiss_hole_tests { use super::capsule_dismiss_hole; #[test] fn centered_capsule_on_primary_monitor_at_origin() { // A 520px-wide capsule centered on a 1920px-wide monitor at global // origin (0,0): global center x = 960, monitor origin x = 0. let (x, w) = capsule_dismiss_hole(960, 0, 520); assert_eq!((x, w), (960 - 260, 520)); } #[test] fn negative_origin_secondary_monitor_converts_to_local() { // This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this // module's doc comments): monitor origin x = -1080. A capsule // centered on that output's own 1080px-wide span sits at global // center x = -1080 + 540 = -540. let (x, w) = capsule_dismiss_hole(-540, -1080, 520); // Local center is 540 (origin subtracted back out); hole starts // 260px to either side of it, independent of the monitor's sign. assert_eq!((x, w), (540 - 260, 520)); } #[test] fn hole_width_always_matches_requested_column_width() { let (_, w) = capsule_dismiss_hole(100, 0, 480); assert_eq!(w, 480); } } #[cfg(test)] mod url_open_target_tests { use super::url_open_target; #[test] fn bare_host_gets_https_scheme() { assert_eq!(url_open_target("example.com"), "https://example.com"); } #[test] fn existing_scheme_is_left_untouched() { assert_eq!(url_open_target("http://example.com"), "http://example.com"); assert_eq!( url_open_target("ftp://example.com/file"), "ftp://example.com/file" ); } }